diff --git a/.env.test b/.env.test new file mode 100644 index 00000000..2c87534c --- /dev/null +++ b/.env.test @@ -0,0 +1 @@ +NODE_ENV=test diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 00000000..8dfeb6a4 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,162 @@ +name: Bug Report / Bug 反馈 +description: Report a bug to help us improve / 报告 bug 帮助我们改进 +title: "[Bug]: " +labels: ["bug"] +body: + - type: markdown + attributes: + value: | + Thanks for taking the time to fill out this bug report. Even a brief description helps us understand what went wrong. If you can share the details below, it usually helps us reproduce the issue without asking you to repeat yourself. + + 感谢您愿意告诉我们这个问题。哪怕只是简要描述,也能帮助我们理解发生了什么。如果方便的话,补充下面这些信息,通常能帮助我们更快复现问题,也尽量减少反复向您确认。 + + - type: checkboxes + id: pre-submission + attributes: + label: Pre-submission Checklist / 提交前检查清单 + description: Please confirm the following before submitting / 提交前请确认以下内容 + options: + - label: I searched existing issues to avoid creating a duplicate / 我已检索现有 issue,确认这不是重复问题 + required: true + - label: I tested on the latest release or current main branch / 我已在最新 release 或 main 分支版本中复现 + required: true + + - type: dropdown + id: deployment + attributes: + label: Deployment Type / 部署方式 + description: Where did you reproduce the problem? / 您是在什么运行方式下复现这个问题的? + options: + - Docker Compose / Docker Compose 部署 + - Docker run / Docker 命令部署 + - Desktop app / 桌面端 + - Local development / 本地源码开发 + - Zeabur deployment / Zeabur 部署 + - Render deployment / Render 部署 + - Other / 其他 + validations: + required: true + + - type: dropdown + id: problem-area + attributes: + label: Problem Area / 问题模块 + description: Which part of Metapi seems related? Optional, but helpful. / 您觉得问题和 Metapi 的哪个模块有关?选填,但有助于排查。 + options: + - Site / Account Management / 站点与账号管理 + - Token Management / 令牌管理 + - Routing / 路由 + - Proxy API (/v1/*) / 代理接口 + - Model Marketplace / 模型广场 + - Check-in / 签到 + - Notifications / 通知 + - Desktop App / 桌面端 + - Deployment / Database / 部署或数据库 + - Other / 其他 + validations: + required: false + + - type: textarea + id: app-version + attributes: + label: Metapi Version / Metapi 版本 + description: Tell us the version, image tag, or commit if you know it. / 如果您知道,请填写版本号、镜像标签或 commit。 + placeholder: | + e.g. v1.2.2 / latest / commit SHA + 例如 v1.2.2 / latest / commit SHA + validations: + required: true + + - type: dropdown + id: database + attributes: + label: Database Type / 数据库类型 + description: Which runtime database are you using? / 您当前运行时使用的是哪种数据库? + options: + - SQLite + - MySQL + - PostgreSQL + - Not sure / 不确定 + validations: + required: false + + - type: textarea + id: bug-description + attributes: + label: Bug Description / Bug 描述 + description: A clear and concise description of what the bug is / 清晰简洁地描述这个 bug 是什么 + placeholder: | + Describe the problem you observed. + 请描述您实际遇到的问题。 + validations: + required: true + + - type: textarea + id: steps-to-reproduce + attributes: + label: Steps To Reproduce / 复现步骤 + description: Steps to reproduce the behavior / 请尽量提供稳定复现的步骤 + placeholder: | + 1. Go to ... / 打开 ... + 2. Configure ... / 配置 ... + 3. Click or send request ... / 点击或发送请求 ... + 4. Observe the result ... / 观察结果 ... + validations: + required: true + + - type: textarea + id: expected-behavior + attributes: + label: Expected Behavior / 预期行为 + description: What did you expect to happen? / 您原本期望发生什么? + placeholder: | + Describe the expected behavior. + 请描述您期望的正确行为。 + validations: + required: true + + - type: textarea + id: actual-behavior + attributes: + label: Actual Behavior / 实际行为 + description: What actually happened? / 实际发生了什么? + placeholder: | + Describe the actual result. + 请描述实际发生的结果。 + validations: + required: true + + - type: textarea + id: logs + attributes: + label: Logs Or Error Output / 日志或错误输出 + description: Paste relevant logs, stack traces, request/response snippets, or screenshots of error output. / 请粘贴相关日志、报错堆栈、请求/响应片段,或错误输出截图。 + render: shell + + - type: textarea + id: screenshots + attributes: + label: Screenshots / 截图 + description: If applicable, add screenshots to help explain the problem. / 如果方便,请附上截图帮助说明问题。 + + - type: textarea + id: environment + attributes: + label: Environment Details / 环境信息 + description: Include OS, host platform, reverse proxy, client tool, browser version if relevant, and anything else that might matter. / 请补充系统、宿主环境、反向代理、客户端工具,以及在相关时补充浏览器版本等信息。 + placeholder: | + OS: + Deployment: + Host platform: + Reverse proxy: + Client tool: + Metapi version: + Database: + + 操作系统: + 部署方式: + 宿主环境: + 反向代理: + 客户端工具: + Metapi 版本: + 数据库: diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 00000000..0de19be2 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: false +contact_links: + - name: Metapi README + url: https://github.com/cita-777/metapi#readme + about: Start with the README for setup, usage, and deployment details. diff --git a/.github/ISSUE_TEMPLATE/docs.yml b/.github/ISSUE_TEMPLATE/docs.yml new file mode 100644 index 00000000..b4b8852a --- /dev/null +++ b/.github/ISSUE_TEMPLATE/docs.yml @@ -0,0 +1,65 @@ +name: Documentation Update / 文档反馈 +description: Report missing, incorrect, or unclear documentation / 反馈文档缺失、错误或不清晰的问题 +title: "[Docs]: " +labels: ["docs"] +body: + - type: markdown + attributes: + value: | + Use this template when documentation is missing, incorrect, outdated, or hard to follow. + + 如果文档存在缺失、错误、过时或难以理解的问题,请使用这个模板。 + + - type: checkboxes + id: checks + attributes: + label: Before submitting / 提交前确认 + options: + - label: I searched existing issues to avoid creating a duplicate / 我已搜索现有 issue,确认这不是重复反馈 + required: true + + - type: input + id: location + attributes: + label: Documentation Location / 文档位置 + description: Link the page or file that needs attention. / 请填写需要修改的页面、章节或文件路径。 + placeholder: e.g. README.md or docs/getting-started.md / 例如 README.md 或 docs/getting-started.md + validations: + required: true + + - type: dropdown + id: doc-area + attributes: + label: Documentation Area / 文档类型 + description: Which documentation area is affected? / 受影响的是哪一类文档? + options: + - README / 项目首页说明 + - Getting Started / 快速开始 + - Deployment / 部署 + - Configuration / 配置 + - Client Integration / 客户端接入 + - FAQ / 常见问题 + - Operations / 运维 + - Other / 其他 + validations: + required: false + + - type: textarea + id: issue + attributes: + label: What Is Wrong Or Missing? / 哪里有问题或缺失? + description: Describe the missing, inaccurate, outdated, or confusing part. / 请描述缺失、不准确、过时或不清晰的内容。 + placeholder: | + Describe the missing, inaccurate, or confusing part. + 请说明哪一部分有问题,以及为什么会造成困惑。 + validations: + required: true + + - type: textarea + id: fix + attributes: + label: Suggested Improvement / 建议修改方式 + description: Describe how the documentation could be improved. / 请描述您建议的修改方式或理想写法。 + placeholder: | + Describe how the documentation could be improved. + 请尽量给出建议内容、结构或示例。 diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 00000000..8e978c65 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,78 @@ +name: Feature Request / 功能请求 +description: Suggest a new feature or enhancement / 建议新功能或改进 +title: "[Feature]: " +labels: ["enhancement"] +body: + - type: markdown + attributes: + value: | + Thanks for taking the time to suggest a new feature. A focused request with enough context helps us evaluate the idea much faster. + + 感谢您提出功能建议。一个聚焦、信息完整的功能请求,通常能让我们更快判断需求价值和实现方向。 + + - type: checkboxes + id: pre-submission + attributes: + label: Pre-submission Checklist / 提交前检查清单 + description: Please confirm the following before submitting / 提交前请确认以下内容 + options: + - label: I searched existing issues to avoid creating a duplicate / 我已检索现有 issue,确认这不是重复需求 + required: true + - label: I confirmed this feature is not already available in the latest release or main branch / 我已确认该功能尚未在最新 release 或 main 分支中提供 + required: true + + - type: textarea + id: problem + attributes: + label: Problem Or Use Case / 使用场景或问题背景 + description: What problem are you trying to solve? What is inconvenient today? / 您想解决什么问题?当前使用上哪里不方便? + placeholder: | + Explain the workflow or pain point behind this request. + 请描述当前流程、痛点或为什么需要这个功能。 + validations: + required: true + + - type: dropdown + id: area + attributes: + label: Related Area / 相关模块 + description: Which part of Metapi is this feature mainly about? / 这个功能主要涉及 Metapi 的哪个模块? + options: + - Site / Account Management / 站点与账号管理 + - Token Management / 令牌管理 + - Routing / 路由 + - Proxy API (/v1/*) / 代理接口 + - Model Marketplace / 模型广场 + - Check-in / 签到 + - Notifications / 通知 + - Desktop App / 桌面端 + - Deployment / Database / 部署或数据库 + - Other / 其他 + validations: + required: false + + - type: textarea + id: proposal + attributes: + label: Proposed Solution / 建议的解决方案 + description: Describe the change you want to see. / 请描述您希望增加的功能或行为变化。 + placeholder: | + Explain the feature or behavior you want added. + 请尽量具体描述您希望如何实现。 + validations: + required: true + + - type: textarea + id: alternatives + attributes: + label: Alternatives Considered / 考虑过的替代方案 + description: Describe any workaround or alternative approach you considered. / 请描述您考虑过的替代方案、当前 workaround,或为什么现有方式不够好。 + + - type: textarea + id: context + attributes: + label: Additional Context / 其他信息 + description: Add screenshots, examples, related issues, or links if they help explain the request. / 如果截图、示例、关联 issue 或参考链接有帮助,请补充在这里。 + placeholder: | + You can include route examples, API paths, screenshots, upstream platform names, or migration concerns. + 可以补充路由示例、API 路径、截图、上游平台名称,或兼容性/迁移方面的考虑。 diff --git a/.github/ISSUE_TEMPLATE/question.yml b/.github/ISSUE_TEMPLATE/question.yml new file mode 100644 index 00000000..f1b84d4c --- /dev/null +++ b/.github/ISSUE_TEMPLATE/question.yml @@ -0,0 +1,72 @@ +name: Question / 使用问题 +description: Ask for clarification or usage guidance / 询问使用方法、配置方式或行为解释 +title: "[Question]: " +labels: ["question"] +body: + - type: markdown + attributes: + value: | + Use this template for support or clarification questions that are not bug reports or feature requests. The more context you share, the easier it is for us to give a direct answer. + + 如果这是使用方式、配置方法或行为解释相关的问题,请使用这个模板。您提供的上下文越完整,我们越容易直接给出准确答案。 + + - type: checkboxes + id: checks + attributes: + label: Before submitting / 提交前确认 + options: + - label: I searched existing issues and the README for an answer / 我已搜索现有 issue 和 README,但还没有找到答案 + required: true + + - type: textarea + id: question + attributes: + label: Question / 问题描述 + description: What do you need help understanding? / 您具体想确认什么问题? + placeholder: | + Ask one clear question and include the relevant context. + 请尽量用一句话先说清楚您想问什么。 + validations: + required: true + + - type: dropdown + id: scenario + attributes: + label: Question Type / 问题类型 + description: What kind of question is this? / 这是哪一类使用问题? + options: + - Deployment / 部署 + - Configuration / 配置 + - Client Integration / 客户端接入 + - Routing / 路由 + - Site / Account / Token / 站点、账号或令牌 + - Desktop App / 桌面端 + - Proxy API (/v1/*) / 代理接口 + - Other / 其他 + validations: + required: false + + - type: textarea + id: tried + attributes: + label: What Have You Tried? / 您已经尝试过什么? + description: Include commands, config, screenshots, or investigation you already attempted. / 请补充您已经尝试过的命令、配置、截图或排查过程。 + + - type: textarea + id: environment + attributes: + label: Relevant Environment Or Config / 相关环境或配置 + description: Paste config snippets, request examples, deployment details, or environment information if needed. / 如有需要,请补充配置片段、请求示例、部署方式或环境信息。 + placeholder: | + Deployment: + Base URL: + Client: + Database: + Related config: + + 部署方式: + Base URL: + 客户端: + 数据库: + 相关配置: + render: shell diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..95814580 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,130 @@ +version: 2 +updates: + - package-ecosystem: npm + directory: / + schedule: + interval: weekly + day: monday + time: "03:00" + timezone: Asia/Shanghai + open-pull-requests-limit: 6 + ignore: + - dependency-name: "vite" + update-types: + - "version-update:semver-major" + - dependency-name: "@vitejs/plugin-react" + update-types: + - "version-update:semver-major" + - dependency-name: "vitest" + update-types: + - "version-update:semver-major" + - dependency-name: "react-router-dom" + update-types: + - "version-update:semver-major" + - dependency-name: "undici" + update-types: + - "version-update:semver-major" + - dependency-name: "cross-env" + update-types: + - "version-update:semver-major" + - dependency-name: "drizzle-orm" + update-types: + - "version-update:semver-major" + - dependency-name: "drizzle-kit" + update-types: + - "version-update:semver-major" + groups: + drizzle-risk: + patterns: + - "drizzle-orm" + - "drizzle-kit" + update-types: + - minor + - patch + build-tooling: + patterns: + - "vite" + - "@vitejs/plugin-react" + - "vitest" + - "tsx" + - "typescript" + update-types: + - minor + - patch + routing: + patterns: + - "react-router-dom" + update-types: + - minor + - patch + runtime-http: + patterns: + - "undici" + update-types: + - minor + - patch + node-runtime-sensitive: + patterns: + - "cross-env" + update-types: + - minor + - patch + minor-and-patch: + patterns: + - "*" + exclude-patterns: + - "drizzle-orm" + - "drizzle-kit" + - "vite" + - "@vitejs/plugin-react" + - "vitest" + - "tsx" + - "typescript" + - "react-router-dom" + - "undici" + - "cross-env" + update-types: + - minor + - patch + dev-dependencies: + dependency-type: development + patterns: + - "*" + update-types: + - minor + - patch + exclude-patterns: + - "drizzle-kit" + - "vite" + - "@vitejs/plugin-react" + - "vitest" + - "tsx" + - "typescript" + - "react-router-dom" + - "cross-env" + + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + day: monday + time: "03:00" + timezone: Asia/Shanghai + open-pull-requests-limit: 5 + groups: + github-actions: + patterns: + - "*" + + - package-ecosystem: docker + directory: /docker + schedule: + interval: weekly + day: monday + time: "03:00" + timezone: Asia/Shanghai + open-pull-requests-limit: 5 + groups: + docker: + patterns: + - "*" diff --git a/.github/labeler.yml b/.github/labeler.yml new file mode 100644 index 00000000..cb17b5c3 --- /dev/null +++ b/.github/labeler.yml @@ -0,0 +1,45 @@ +"area: db": + - changed-files: + - any-glob-to-any-file: + - "src/server/db/**" + - "drizzle/**" + +"area: server": + - all: + - changed-files: + - any-glob-to-any-file: + - "src/server/**" + - all-globs-to-all-files: + - "!src/server/db/**" + +"area: web": + - changed-files: + - any-glob-to-any-file: + - "src/web/**" + +"area: desktop": + - changed-files: + - any-glob-to-any-file: + - "src/desktop/**" + - "electron-builder.yml" + - "tsconfig.desktop.json" + +"area: docs": + - changed-files: + - any-glob-to-any-file: + - "docs/**" + - "README.md" + - "README_EN.md" + +"area: scripts": + - changed-files: + - any-glob-to-any-file: + - "scripts/**" + +"area: ci": + - changed-files: + - any-glob-to-any-file: + - ".github/**" + - "docker/**" + - "render.yaml" + - "zeabur-template.yaml" diff --git a/.github/workflows/backfill-pr-labels.yml b/.github/workflows/backfill-pr-labels.yml new file mode 100644 index 00000000..f8e1a800 --- /dev/null +++ b/.github/workflows/backfill-pr-labels.yml @@ -0,0 +1,176 @@ +name: Backfill PR Labels + +on: + workflow_dispatch: + inputs: + state: + description: Pull request state to scan + required: true + default: all + type: choice + options: + - open + - closed + - all + limit: + description: Maximum number of pull requests to inspect + required: true + default: "100" + type: string + +permissions: + contents: read + pull-requests: write + +jobs: + backfill: + name: Backfill missing PR labels + runs-on: ubuntu-latest + steps: + - name: Backfill PR labels + uses: actions/github-script@v8 + with: + script: | + const owner = context.repo.owner; + const repo = context.repo.repo; + const inputs = context.payload.inputs || {}; + const requestedState = inputs.state || 'all'; + const limit = Number.parseInt(inputs.limit || '100', 10); + const sizeLabels = ['size: XS', 'size: S', 'size: M', 'size: L', 'size: XL', 'size: XXL']; + const areaRules = [ + { label: 'area: db', include: ['src/server/db/', 'drizzle/'] }, + { label: 'area: server', include: ['src/server/'], exclude: ['src/server/db/'] }, + { label: 'area: web', include: ['src/web/'] }, + { label: 'area: desktop', include: ['src/desktop/', 'electron-builder.yml', 'tsconfig.desktop.json'] }, + { label: 'area: docs', include: ['docs/', 'README.md', 'README_EN.md'] }, + { label: 'area: scripts', include: ['scripts/'] }, + { label: 'area: ci', include: ['.github/', 'docker/', 'render.yaml', 'zeabur-template.yaml'] }, + ]; + + if (!Number.isFinite(limit) || limit <= 0) { + core.setFailed('The limit input must be a positive integer.'); + return; + } + + function matchesRule(filename, rule) { + const included = rule.include.some((prefix) => filename === prefix || filename.startsWith(prefix)); + if (!included) return false; + if (!rule.exclude) return true; + return !rule.exclude.some((prefix) => filename === prefix || filename.startsWith(prefix)); + } + + function computeAreaLabels(files) { + return areaRules + .filter((rule) => files.some((file) => matchesRule(file.filename, rule))) + .map((rule) => rule.label); + } + + function computeSizeLabel(totalChanges) { + if (totalChanges < 50) return 'size: XS'; + if (totalChanges < 200) return 'size: S'; + if (totalChanges < 500) return 'size: M'; + if (totalChanges < 1000) return 'size: L'; + if (totalChanges < 2000) return 'size: XL'; + return 'size: XXL'; + } + + const state = requestedState === 'all' ? 'all' : requestedState; + const pulls = await github.paginate(github.rest.pulls.list, { + owner, + repo, + state, + per_page: 100, + }); + + let inspected = 0; + let updated = 0; + let skipped = 0; + let warned = 0; + + for (const pull of pulls) { + if (inspected >= limit) { + break; + } + + inspected += 1; + + const { data: fullPull } = await github.rest.pulls.get({ + owner, + repo, + pull_number: pull.number, + }); + + const files = await github.paginate(github.rest.pulls.listFiles, { + owner, + repo, + pull_number: pull.number, + per_page: 100, + }); + + const desiredAreaLabels = computeAreaLabels(files); + const totalChanges = fullPull.additions + fullPull.deletions; + const hasValidSizeData = Number.isFinite(totalChanges) && totalChanges >= 0; + const currentLabels = pull.labels.map((label) => label.name); + const missingAreaLabels = desiredAreaLabels.filter((label) => !currentLabels.includes(label)); + const currentSizeLabels = currentLabels.filter((label) => sizeLabels.includes(label)); + const desiredSizeLabel = hasValidSizeData ? computeSizeLabel(totalChanges) : null; + const sizeMatches = + hasValidSizeData && + currentSizeLabels.length === 1 && + currentSizeLabels[0] === desiredSizeLabel; + + if (!hasValidSizeData) { + warned += 1; + core.warning(`Skipping size label for PR #${pull.number}: invalid additions/deletions values.`); + } + + if (missingAreaLabels.length === 0 && (hasValidSizeData ? sizeMatches : currentSizeLabels.length === 0)) { + skipped += 1; + continue; + } + + if (hasValidSizeData) { + for (const label of currentSizeLabels) { + if (label === desiredSizeLabel) { + continue; + } + + try { + await github.rest.issues.removeLabel({ + owner, + repo, + issue_number: pull.number, + name: label, + }); + } catch (error) { + if (error.status !== 404) { + throw error; + } + } + } + } + + const labelsToAdd = [...missingAreaLabels]; + if (hasValidSizeData && !sizeMatches) { + labelsToAdd.push(desiredSizeLabel); + } + + if (labelsToAdd.length > 0) { + await github.rest.issues.addLabels({ + owner, + repo, + issue_number: pull.number, + labels: labelsToAdd, + }); + } + + updated += 1; + } + + core.summary + .addHeading('Backfill summary') + .addRaw(`Inspected PRs: ${inspected}\n`) + .addRaw(`Updated PRs: ${updated}\n`) + .addRaw(`Skipped PRs: ${skipped}\n`) + .addRaw(`Warnings: ${warned}\n`) + .write(); diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5593d601..2139321d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,41 +3,340 @@ name: CI on: push: branches: [main, master] - tags: ['v*'] + paths-ignore: + - 'docs/**' + - 'README.md' + - 'README_EN.md' + - 'CONTRIBUTING.md' + - 'SECURITY.md' + - 'CODE_OF_CONDUCT.md' pull_request: +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +env: + NODE_VERSION: 22 + jobs: - test-and-build: + changes: + name: Detect Docs Changes + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + outputs: + docs: ${{ steps.filter.outputs.docs }} + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Detect docs-related changes + id: filter + uses: dorny/paths-filter@v4 + with: + filters: | + docs: + - 'docs/**' + - 'README.md' + - 'README_EN.md' + - 'CONTRIBUTING.md' + - 'SECURITY.md' + - 'CODE_OF_CONDUCT.md' + - '.github/workflows/docs-pages.yml' + + test-core: + name: Test Core runs-on: ubuntu-latest timeout-minutes: 20 steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: - node-version: 22 + node-version: ${{ env.NODE_VERSION }} cache: npm - name: Install dependencies - run: npm ci + run: npm ci --prefer-offline --no-audit --no-fund - name: Run tests + env: + DB_PARITY_SKIP_LIVE_SCHEMA: 'true' run: npm test - - name: Build - run: npm run build + build-web: + name: Build Web + runs-on: ubuntu-latest + timeout-minutes: 15 + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: ${{ env.NODE_VERSION }} + cache: npm + + - name: Install dependencies + run: npm ci --prefer-offline --no-audit --no-fund + + - name: Build web + run: npm run build:web + + build-server: + name: Build Server + runs-on: ubuntu-latest + timeout-minutes: 15 + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: ${{ env.NODE_VERSION }} + cache: npm + + - name: Install dependencies + run: npm ci --prefer-offline --no-audit --no-fund + + - name: Build server + run: npm run build:server + + build-desktop: + name: Build Desktop + runs-on: ubuntu-latest + timeout-minutes: 15 + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: ${{ env.NODE_VERSION }} + cache: npm + + - name: Install dependencies + run: npm ci --prefer-offline --no-audit --no-fund + + - name: Build desktop + run: npm run build:desktop + + typecheck: + name: Typecheck + runs-on: ubuntu-latest + timeout-minutes: 15 + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: ${{ env.NODE_VERSION }} + cache: npm + + - name: Install dependencies + run: npm ci --prefer-offline --no-audit --no-fund + + - name: Run typecheck + run: npm run typecheck + + repo-drift: + name: Repo Drift Check + runs-on: ubuntu-latest + timeout-minutes: 10 + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: ${{ env.NODE_VERSION }} + cache: npm + + - name: Install dependencies + run: npm ci --prefer-offline --no-audit --no-fund + + - name: Run repo drift check + run: npm run repo:drift-check + + schema-sqlite: + name: Schema Check (SQLite) + runs-on: ubuntu-latest + timeout-minutes: 15 + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: ${{ env.NODE_VERSION }} + cache: npm + + - name: Install dependencies + run: npm ci --prefer-offline --no-audit --no-fund + + - name: Run sqlite schema parity + run: npm run test:schema:parity + + - name: Run sqlite schema upgrade + run: npm run test:schema:upgrade + + schema-mysql: + name: Schema Check (MySQL) + runs-on: ubuntu-latest + timeout-minutes: 20 + services: + mysql: + image: mysql:8.4 + env: + MYSQL_DATABASE: metapi + MYSQL_ROOT_PASSWORD: root + ports: + - 3306:3306 + options: >- + --health-cmd="mysqladmin ping -h 127.0.0.1 -proot" + --health-interval=10s + --health-timeout=5s + --health-retries=10 + env: + DB_PARITY_MYSQL_URL: mysql://root:root@127.0.0.1:3306/metapi + DB_PARITY_SQLITE: 'false' + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: ${{ env.NODE_VERSION }} + cache: npm + + - name: Install dependencies + run: npm ci --prefer-offline --no-audit --no-fund + + - name: Run mysql schema parity + run: npm run test:schema:parity + + - name: Run mysql schema upgrade + run: npm run test:schema:upgrade + + - name: Run mysql runtime schema bootstrap + run: npm run test:schema:runtime + + schema-postgres: + name: Schema Check (Postgres) + runs-on: ubuntu-latest + timeout-minutes: 20 + services: + postgres: + image: postgres:16 + env: + POSTGRES_DB: metapi + POSTGRES_PASSWORD: postgres + POSTGRES_USER: postgres + ports: + - 5432:5432 + options: >- + --health-cmd="pg_isready -U postgres -d metapi" + --health-interval=10s + --health-timeout=5s + --health-retries=10 + env: + DB_PARITY_POSTGRES_URL: postgres://postgres:postgres@127.0.0.1:5432/metapi + DB_PARITY_SQLITE: 'false' + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: ${{ env.NODE_VERSION }} + cache: npm + + - name: Install dependencies + run: npm ci --prefer-offline --no-audit --no-fund + + - name: Run postgres schema parity + run: npm run test:schema:parity + + - name: Run postgres schema upgrade + run: npm run test:schema:upgrade + + - name: Run postgres runtime schema bootstrap + run: npm run test:schema:runtime + + docs-build: + name: Build Docs + if: github.event_name == 'pull_request' && needs.changes.outputs.docs == 'true' + needs: changes + runs-on: ubuntu-latest + timeout-minutes: 15 + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: ${{ env.NODE_VERSION }} + cache: npm + + - name: Install dependencies + run: npm ci --prefer-offline --no-audit --no-fund + + - name: Build docs + run: npm run docs:build + + audit: + name: Audit Production Dependencies + runs-on: ubuntu-latest + timeout-minutes: 10 + # Keep PR audit informational so build/test/schema remain the merge gate. + continue-on-error: true + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: ${{ env.NODE_VERSION }} + cache: npm + + - name: Install dependencies + run: npm ci --prefer-offline --no-audit --no-fund - name: Audit production dependencies run: npm audit --omit=dev --audit-level=high publish-docker-arch: name: Publish Docker Image (${{ matrix.arch }}) + if: github.event_name == 'push' runs-on: ${{ matrix.runner }} - needs: test-and-build - if: github.event_name == 'push' && !startsWith(github.ref, 'refs/tags/') timeout-minutes: 45 strategy: fail-fast: false @@ -49,10 +348,13 @@ jobs: - arch: arm64 platform: linux/arm64 runner: ubuntu-24.04-arm + - arch: armv7 + platform: linux/arm/v7 + runner: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Ensure Docker Hub secrets are configured run: | @@ -62,23 +364,26 @@ jobs: fi - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@v4 + + - name: Set up QEMU + if: matrix.arch == 'armv7' + uses: docker/setup-qemu-action@v4 - name: Log in to Docker Hub - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Extract Docker metadata id: meta - uses: docker/metadata-action@v5 + uses: docker/metadata-action@v6 with: images: 1467078763/metapi tags: | type=raw,value=latest,enable={{is_default_branch}} type=ref,event=branch - type=ref,event=tag type=sha,format=short - name: Add architecture suffix to tags @@ -98,7 +403,7 @@ jobs: } >> "$GITHUB_OUTPUT" - name: Build and push ${{ matrix.platform }} image - uses: docker/build-push-action@v6 + uses: docker/build-push-action@v7 with: context: . file: docker/Dockerfile @@ -111,14 +416,14 @@ jobs: publish-docker: name: Publish Docker Manifest + if: github.event_name == 'push' runs-on: ubuntu-latest needs: publish-docker-arch - if: github.event_name == 'push' && !startsWith(github.ref, 'refs/tags/') timeout-minutes: 15 steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Ensure Docker Hub secrets are configured run: | @@ -128,23 +433,22 @@ jobs: fi - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@v4 - name: Log in to Docker Hub - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Extract Docker metadata id: meta - uses: docker/metadata-action@v5 + uses: docker/metadata-action@v6 with: images: 1467078763/metapi tags: | type=raw,value=latest,enable={{is_default_branch}} type=ref,event=branch - type=ref,event=tag type=sha,format=short - name: Create multi-arch manifests @@ -156,5 +460,6 @@ jobs: docker buildx imagetools create \ --tag "$tag" \ "${tag}-amd64" \ - "${tag}-arm64" + "${tag}-arm64" \ + "${tag}-armv7" done <<< "${{ steps.meta.outputs.tags }}" diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 00000000..fba8f387 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,56 @@ +name: CodeQL + +on: + push: + branches: [main, master] + paths-ignore: + - 'docs/**' + - 'README.md' + - 'README_EN.md' + - 'CONTRIBUTING.md' + - 'SECURITY.md' + - 'CODE_OF_CONDUCT.md' + pull_request: + paths-ignore: + - 'docs/**' + - 'README.md' + - 'README_EN.md' + - 'CONTRIBUTING.md' + - 'SECURITY.md' + - 'CODE_OF_CONDUCT.md' + schedule: + - cron: '19 18 * * 0' + +permissions: + actions: read + contents: read + security-events: write + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + analyze: + name: Analyze (JavaScript/TypeScript) + runs-on: ubuntu-latest + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + language: ['javascript-typescript'] + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Initialize CodeQL + uses: github/codeql-action/init@v4 + with: + languages: ${{ matrix.language }} + build-mode: none + + - name: Analyze + uses: github/codeql-action/analyze@v4 + with: + category: /language:${{ matrix.language }} diff --git a/.github/workflows/docs-pages.yml b/.github/workflows/docs-pages.yml index c3e881e4..712f2744 100644 --- a/.github/workflows/docs-pages.yml +++ b/.github/workflows/docs-pages.yml @@ -3,6 +3,11 @@ name: Docs Pages on: push: branches: [main] + paths: + - 'docs/**' + - '.github/workflows/docs-pages.yml' + - 'package.json' + - 'package-lock.json' workflow_dispatch: permissions: @@ -11,7 +16,7 @@ permissions: id-token: write concurrency: - group: docs-pages + group: docs-pages-${{ github.ref }} cancel-in-progress: true jobs: @@ -22,10 +27,10 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version: 22 cache: npm @@ -40,7 +45,7 @@ jobs: uses: actions/configure-pages@v5 - name: Upload pages artifact - uses: actions/upload-pages-artifact@v3 + uses: actions/upload-pages-artifact@v4 with: path: docs/.vitepress/dist diff --git a/.github/workflows/harness-drift-report.yml b/.github/workflows/harness-drift-report.yml new file mode 100644 index 00000000..846ec1af --- /dev/null +++ b/.github/workflows/harness-drift-report.yml @@ -0,0 +1,44 @@ +name: Harness Drift Report + +on: + schedule: + - cron: '37 17 * * 1' + workflow_dispatch: + +permissions: + contents: read + +env: + NODE_VERSION: 22 + +jobs: + drift-report: + name: Generate Drift Report + runs-on: ubuntu-latest + timeout-minutes: 15 + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: ${{ env.NODE_VERSION }} + cache: npm + + - name: Install dependencies + run: npm ci --prefer-offline --no-audit --no-fund + + - name: Generate repo drift report + run: npm run repo:drift-check -- --format markdown --output tmp/repo-drift-report.md --report-only + + - name: Publish report to step summary + run: cat tmp/repo-drift-report.md >> "$GITHUB_STEP_SUMMARY" + + - name: Upload report artifact + uses: actions/upload-artifact@v4 + with: + name: repo-drift-report + path: tmp/repo-drift-report.md + retention-days: 30 diff --git a/.github/workflows/labeler.yml b/.github/workflows/labeler.yml new file mode 100644 index 00000000..caeb4b0d --- /dev/null +++ b/.github/workflows/labeler.yml @@ -0,0 +1,71 @@ +name: Label Pull Requests + +on: + pull_request_target: + types: [opened, synchronize, reopened, ready_for_review] + +permissions: + contents: read + pull-requests: write + +jobs: + apply-labels: + name: Apply area and size labels + runs-on: ubuntu-latest + steps: + - name: Apply area labels + uses: actions/labeler@v6 + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} + sync-labels: false + + - name: Apply size label + uses: actions/github-script@v8 + with: + script: | + const owner = context.repo.owner; + const repo = context.repo.repo; + const pull_number = context.payload.pull_request.number; + const sizeLabels = ['size: XS', 'size: S', 'size: M', 'size: L', 'size: XL', 'size: XXL']; + + const { data: pull } = await github.rest.pulls.get({ + owner, + repo, + pull_number, + }); + + const totalChanges = pull.additions + pull.deletions; + let nextSize = 'size: XXL'; + if (totalChanges < 50) nextSize = 'size: XS'; + else if (totalChanges < 200) nextSize = 'size: S'; + else if (totalChanges < 500) nextSize = 'size: M'; + else if (totalChanges < 1000) nextSize = 'size: L'; + else if (totalChanges < 2000) nextSize = 'size: XL'; + + const currentLabels = pull.labels.map((label) => label.name); + const existingSizeLabels = currentLabels.filter((label) => sizeLabels.includes(label)); + const labelsToRemove = existingSizeLabels.filter((label) => label !== nextSize); + + for (const label of labelsToRemove) { + try { + await github.rest.issues.removeLabel({ + owner, + repo, + issue_number: pull_number, + name: label, + }); + } catch (error) { + if (error.status !== 404) { + throw error; + } + } + } + + if (!currentLabels.includes(nextSize)) { + await github.rest.issues.addLabels({ + owner, + repo, + issue_number: pull_number, + labels: [nextSize], + }); + } diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d5afa11a..08720704 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,264 +1,289 @@ -name: Release - -on: - push: - tags: ['v*'] - workflow_dispatch: - -permissions: - contents: write - packages: write - -concurrency: - group: release-${{ github.ref }} - cancel-in-progress: false - -jobs: - verify: - name: Verify - runs-on: ubuntu-latest - timeout-minutes: 20 - - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: 22 - cache: npm - - - name: Install dependencies - run: npm ci - - - name: Run tests - run: npm test - - - name: Build - run: npm run build - - - name: Upload built dist - uses: actions/upload-artifact@v4 - with: - name: metapi-dist-${{ github.sha }} - path: dist - if-no-files-found: error - - build-packages: - name: Build Desktop Package (${{ matrix.os }}) - needs: verify - runs-on: ${{ matrix.os }} - timeout-minutes: 60 - strategy: - fail-fast: false - matrix: - os: [ubuntu-latest, windows-latest, macos-latest] - - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: 22 - cache: npm - - - name: Restore Electron cache (Windows) - if: runner.os == 'Windows' - uses: actions/cache@v4 - with: - path: | - ${{ runner.temp }}/.cache/electron - ${{ runner.temp }}/.cache/electron-builder - ${{ env.LOCALAPPDATA }}\electron\Cache - ${{ env.LOCALAPPDATA }}\electron-builder\Cache - key: electron-cache-${{ runner.os }}-${{ hashFiles('package-lock.json', 'package.json', 'electron-builder.yml') }} - restore-keys: | - electron-cache-${{ runner.os }}- - - - name: Restore Electron cache (Linux) - if: runner.os == 'Linux' - uses: actions/cache@v4 - with: - path: | - ${{ runner.temp }}/.cache/electron - ${{ runner.temp }}/.cache/electron-builder - ${{ runner.temp }}/.cache/appimage-tools - ~/.cache/electron - ~/.cache/electron-builder - key: electron-cache-${{ runner.os }}-${{ hashFiles('package-lock.json', 'package.json', 'electron-builder.yml') }} - restore-keys: | - electron-cache-${{ runner.os }}- - - - name: Restore Electron cache (macOS) - if: runner.os == 'macOS' - uses: actions/cache@v4 - with: - path: | - ${{ runner.temp }}/.cache/electron - ${{ runner.temp }}/.cache/electron-builder - ~/Library/Caches/electron - ~/Library/Caches/electron-builder - key: electron-cache-${{ runner.os }}-${{ hashFiles('package-lock.json', 'package.json', 'electron-builder.yml') }} - restore-keys: | - electron-cache-${{ runner.os }}- - - - name: Install dependencies - run: npm ci - - - name: Download built dist - uses: actions/download-artifact@v4 - with: - name: metapi-dist-${{ github.sha }} - path: dist - - - name: Build desktop artifacts (Windows) - if: runner.os == 'Windows' - shell: pwsh - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - npm_config_cache: ${{ runner.temp }}/.npm - ELECTRON_CACHE: ${{ runner.temp }}/.cache/electron - ELECTRON_BUILDER_CACHE: ${{ runner.temp }}/.cache/electron-builder - ELECTRON_BUILDER_BINARIES_MIRROR: https://github.com/electron-userland/electron-builder-binaries/releases/download/ - npm_config_electron_builder_binaries_mirror: https://github.com/electron-userland/electron-builder-binaries/releases/download/ - WIN_CSC_LINK_INPUT: ${{ secrets.CSC_LINK }} - WIN_CSC_KEY_PASSWORD_INPUT: ${{ secrets.CSC_KEY_PASSWORD }} - run: | - if ($env:WIN_CSC_LINK_INPUT) { $env:WIN_CSC_LINK = $env:WIN_CSC_LINK_INPUT } - if ($env:WIN_CSC_KEY_PASSWORD_INPUT) { $env:WIN_CSC_KEY_PASSWORD = $env:WIN_CSC_KEY_PASSWORD_INPUT } - for ($attempt = 1; $attempt -le 2; $attempt++) { - Write-Host "Desktop build attempt $attempt/2" - npm run package:desktop - if ($LASTEXITCODE -eq 0) { - break - } - if ($attempt -eq 2) { - exit $LASTEXITCODE - } - Start-Sleep -Seconds 90 - } - - - name: Smoke test packaged native modules (Windows) - if: runner.os == 'Windows' - shell: pwsh - env: - ELECTRON_RUN_AS_NODE: 1 - run: | - $appRoot = Join-Path $PWD 'release\win-unpacked\resources\app' - $exePath = Join-Path $PWD 'release\win-unpacked\Metapi.exe' - Push-Location $appRoot - try { - & $exePath -e "require('./node_modules/better-sqlite3'); console.log('better-sqlite3 ok')" - if ($LASTEXITCODE -ne 0) { - exit $LASTEXITCODE - } - } finally { - Pop-Location - } - - - name: Build desktop artifacts (Linux) - if: runner.os == 'Linux' - shell: bash - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - npm_config_cache: ${{ runner.temp }}/.npm - ELECTRON_CACHE: ${{ runner.temp }}/.cache/electron - ELECTRON_BUILDER_CACHE: ${{ runner.temp }}/.cache/electron-builder - ELECTRON_BUILDER_BINARIES_MIRROR: https://github.com/electron-userland/electron-builder-binaries/releases/download/ - npm_config_electron_builder_binaries_mirror: https://github.com/electron-userland/electron-builder-binaries/releases/download/ - run: | - set -euo pipefail - for attempt in 1 2; do - echo "Desktop build attempt ${attempt}/2" - if npm run package:desktop; then - break - fi - if [ "$attempt" -eq 2 ]; then - exit 1 - fi - sleep 90 - done - - - name: Build desktop artifacts (macOS) - if: runner.os == 'macOS' - shell: bash - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - npm_config_cache: ${{ runner.temp }}/.npm - ELECTRON_CACHE: ${{ runner.temp }}/.cache/electron - ELECTRON_BUILDER_CACHE: ${{ runner.temp }}/.cache/electron-builder - ELECTRON_BUILDER_BINARIES_MIRROR: https://github.com/electron-userland/electron-builder-binaries/releases/download/ - npm_config_electron_builder_binaries_mirror: https://github.com/electron-userland/electron-builder-binaries/releases/download/ - APPLE_ID_INPUT: ${{ secrets.APPLE_ID }} - APPLE_APP_SPECIFIC_PASSWORD_INPUT: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} - APPLE_TEAM_ID_INPUT: ${{ secrets.APPLE_TEAM_ID }} - run: | - set -euo pipefail - if [ -n "${APPLE_ID_INPUT:-}" ]; then export APPLE_ID="$APPLE_ID_INPUT"; fi - if [ -n "${APPLE_APP_SPECIFIC_PASSWORD_INPUT:-}" ]; then export APPLE_APP_SPECIFIC_PASSWORD="$APPLE_APP_SPECIFIC_PASSWORD_INPUT"; fi - if [ -n "${APPLE_TEAM_ID_INPUT:-}" ]; then export APPLE_TEAM_ID="$APPLE_TEAM_ID_INPUT"; fi - for attempt in 1 2; do - echo "Desktop build attempt ${attempt}/2" - if npm run package:desktop; then - break - fi - if [ "$attempt" -eq 2 ]; then - exit 1 - fi - sleep 90 - done - - - name: Upload desktop package - uses: actions/upload-artifact@v4 - with: - name: metapi-desktop-${{ github.ref_name }}-${{ runner.os }}-${{ runner.arch }} - path: | - release/*.exe - release/*.dmg - release/*.zip - release/*.AppImage - release/*.deb - release/*.blockmap - release/latest*.yml - release/latest*.json - release/builder-debug.yml - if-no-files-found: error - - publish-release: - name: Publish GitHub Release - needs: build-packages - runs-on: ubuntu-latest - timeout-minutes: 15 - if: startsWith(github.ref, 'refs/tags/') - - steps: - - name: Download package artifacts - uses: actions/download-artifact@v4 - with: - path: release-assets - merge-multiple: true - - - name: Show release files - run: ls -la release-assets - - - name: Upload Release Assets - uses: softprops/action-gh-release@v2 - with: - tag_name: ${{ github.ref_name }} - generate_release_notes: true - files: release-assets/* - - publish-docker-arch: - name: Publish Docker Image (${{ matrix.arch }}) - needs: build-packages - runs-on: ${{ matrix.runner }} - timeout-minutes: 45 - if: startsWith(github.ref, 'refs/tags/') - strategy: - fail-fast: false +name: Release + +on: + push: + tags: ['v*'] + workflow_dispatch: + +permissions: + contents: write + packages: write + +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false + +jobs: + verify: + name: Verify + runs-on: ubuntu-latest + timeout-minutes: 20 + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: 22 + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Run tests + run: npm test + + - name: Build + run: npm run build + + - name: Upload built dist + uses: actions/upload-artifact@v7 + with: + name: metapi-dist-${{ github.sha }} + path: dist + if-no-files-found: error + + build-packages: + name: Build Desktop Package (${{ matrix.platform }}) + needs: verify + runs-on: ${{ matrix.runner }} + timeout-minutes: 60 + strategy: + fail-fast: false + matrix: + include: + - platform: linux + runner: ubuntu-latest + artifactLabel: linux-x64 + - platform: windows + runner: windows-latest + artifactLabel: windows-x64 + - platform: mac-arm64 + runner: macos-15 + artifactLabel: mac-arm64 + packageCommand: npm run package:desktop -- --mac --arm64 + expectedMacArch: arm64 + - platform: mac-x64 + runner: macos-15-intel + artifactLabel: mac-x64 + packageCommand: npm run package:desktop:mac:intel + expectedMacArch: x64 + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: 22 + cache: npm + + - name: Restore Electron cache (Windows) + if: runner.os == 'Windows' + uses: actions/cache@v5 + with: + path: | + ${{ runner.temp }}/.cache/electron + ${{ runner.temp }}/.cache/electron-builder + ${{ env.LOCALAPPDATA }}\electron\Cache + ${{ env.LOCALAPPDATA }}\electron-builder\Cache + key: electron-cache-${{ runner.os }}-${{ hashFiles('package-lock.json', 'package.json', 'electron-builder.yml') }} + restore-keys: | + electron-cache-${{ runner.os }}- + + - name: Restore Electron cache (Linux) + if: runner.os == 'Linux' + uses: actions/cache@v5 + with: + path: | + ${{ runner.temp }}/.cache/electron + ${{ runner.temp }}/.cache/electron-builder + ${{ runner.temp }}/.cache/appimage-tools + ~/.cache/electron + ~/.cache/electron-builder + key: electron-cache-${{ runner.os }}-${{ hashFiles('package-lock.json', 'package.json', 'electron-builder.yml') }} + restore-keys: | + electron-cache-${{ runner.os }}- + + - name: Restore Electron cache (macOS) + if: runner.os == 'macOS' + uses: actions/cache@v5 + with: + path: | + ${{ runner.temp }}/.cache/electron + ${{ runner.temp }}/.cache/electron-builder + ~/Library/Caches/electron + ~/Library/Caches/electron-builder + key: electron-cache-${{ runner.os }}-${{ hashFiles('package-lock.json', 'package.json', 'electron-builder.yml') }} + restore-keys: | + electron-cache-${{ runner.os }}- + + - name: Install dependencies + run: npm ci + + - name: Download built dist + uses: actions/download-artifact@v8 + with: + name: metapi-dist-${{ github.sha }} + path: dist + + - name: Build desktop artifacts (Windows) + if: runner.os == 'Windows' + shell: pwsh + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + npm_config_cache: ${{ runner.temp }}/.npm + ELECTRON_CACHE: ${{ runner.temp }}/.cache/electron + ELECTRON_BUILDER_CACHE: ${{ runner.temp }}/.cache/electron-builder + ELECTRON_BUILDER_BINARIES_MIRROR: https://github.com/electron-userland/electron-builder-binaries/releases/download/ + npm_config_electron_builder_binaries_mirror: https://github.com/electron-userland/electron-builder-binaries/releases/download/ + WIN_CSC_LINK_INPUT: ${{ secrets.CSC_LINK }} + WIN_CSC_KEY_PASSWORD_INPUT: ${{ secrets.CSC_KEY_PASSWORD }} + run: | + if ($env:WIN_CSC_LINK_INPUT) { $env:WIN_CSC_LINK = $env:WIN_CSC_LINK_INPUT } + if ($env:WIN_CSC_KEY_PASSWORD_INPUT) { $env:WIN_CSC_KEY_PASSWORD = $env:WIN_CSC_KEY_PASSWORD_INPUT } + for ($attempt = 1; $attempt -le 2; $attempt++) { + Write-Host "Desktop build attempt $attempt/2" + npm run package:desktop + if ($LASTEXITCODE -eq 0) { + break + } + if ($attempt -eq 2) { + exit $LASTEXITCODE + } + Start-Sleep -Seconds 90 + } + + - name: Smoke test packaged native modules (Windows) + if: runner.os == 'Windows' + shell: pwsh + env: + ELECTRON_RUN_AS_NODE: 1 + run: | + $appRoot = Join-Path $PWD 'release\win-unpacked\resources\app' + $exePath = Join-Path $PWD 'release\win-unpacked\Metapi.exe' + Push-Location $appRoot + try { + & $exePath -e "require('./node_modules/better-sqlite3'); console.log('better-sqlite3 ok')" + if ($LASTEXITCODE -ne 0) { + exit $LASTEXITCODE + } + } finally { + Pop-Location + } + + - name: Build desktop artifacts (Linux) + if: runner.os == 'Linux' + shell: bash + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + npm_config_cache: ${{ runner.temp }}/.npm + ELECTRON_CACHE: ${{ runner.temp }}/.cache/electron + ELECTRON_BUILDER_CACHE: ${{ runner.temp }}/.cache/electron-builder + ELECTRON_BUILDER_BINARIES_MIRROR: https://github.com/electron-userland/electron-builder-binaries/releases/download/ + npm_config_electron_builder_binaries_mirror: https://github.com/electron-userland/electron-builder-binaries/releases/download/ + run: | + set -euo pipefail + for attempt in 1 2; do + echo "Desktop build attempt ${attempt}/2" + if npm run package:desktop; then + break + fi + if [ "$attempt" -eq 2 ]; then + exit 1 + fi + sleep 90 + done + + - name: Build desktop artifacts (macOS) + if: startsWith(matrix.platform, 'mac-') + shell: bash + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + npm_config_cache: ${{ runner.temp }}/.npm + ELECTRON_CACHE: ${{ runner.temp }}/.cache/electron + ELECTRON_BUILDER_CACHE: ${{ runner.temp }}/.cache/electron-builder + ELECTRON_BUILDER_BINARIES_MIRROR: https://github.com/electron-userland/electron-builder-binaries/releases/download/ + npm_config_electron_builder_binaries_mirror: https://github.com/electron-userland/electron-builder-binaries/releases/download/ + APPLE_ID_INPUT: ${{ secrets.APPLE_ID }} + APPLE_APP_SPECIFIC_PASSWORD_INPUT: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} + APPLE_TEAM_ID_INPUT: ${{ secrets.APPLE_TEAM_ID }} + PACKAGE_COMMAND: ${{ matrix.packageCommand }} + run: | + set -euo pipefail + if [ -n "${APPLE_ID_INPUT:-}" ]; then export APPLE_ID="$APPLE_ID_INPUT"; fi + if [ -n "${APPLE_APP_SPECIFIC_PASSWORD_INPUT:-}" ]; then export APPLE_APP_SPECIFIC_PASSWORD="$APPLE_APP_SPECIFIC_PASSWORD_INPUT"; fi + if [ -n "${APPLE_TEAM_ID_INPUT:-}" ]; then export APPLE_TEAM_ID="$APPLE_TEAM_ID_INPUT"; fi + for attempt in 1 2; do + echo "Desktop build (${PACKAGE_COMMAND}) attempt ${attempt}/2" + if eval "${PACKAGE_COMMAND}"; then + break + fi + if [ "$attempt" -eq 2 ]; then + exit 1 + fi + sleep 90 + done + + - name: Verify packaged macOS architecture + if: startsWith(matrix.platform, 'mac-') + shell: bash + env: + expectedMacArch: ${{ matrix.expectedMacArch }} + run: | + node scripts/desktop/verifyMacArchitecture.mjs --release-dir release --expected-arch "$expectedMacArch" + + - name: Upload desktop package + uses: actions/upload-artifact@v7 + with: + name: metapi-desktop-${{ github.run_id }}-${{ matrix.artifactLabel }} + path: | + release/*.exe + release/*.dmg + release/*.zip + release/*.AppImage + release/*.deb + release/*.blockmap + release/latest*.yml + release/latest*.json + release/builder-debug.yml + if-no-files-found: error + + publish-release: + name: Publish GitHub Release + needs: build-packages + runs-on: ubuntu-latest + timeout-minutes: 15 + if: startsWith(github.ref, 'refs/tags/') + + steps: + - name: Download package artifacts + uses: actions/download-artifact@v8 + with: + path: release-assets + merge-multiple: true + + - name: Show release files + run: ls -la release-assets + + - name: Upload Release Assets + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ github.ref_name }} + generate_release_notes: true + files: release-assets/* + + publish-docker-arch: + name: Publish Docker Image (${{ matrix.arch }}) + needs: build-packages + runs-on: ${{ matrix.runner }} + timeout-minutes: 45 + if: startsWith(github.ref, 'refs/tags/') + strategy: + fail-fast: false matrix: include: - arch: amd64 @@ -267,126 +292,134 @@ jobs: - arch: arm64 platform: linux/arm64 runner: ubuntu-24.04-arm - - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Ensure Docker Hub secrets are configured - run: | - if [ -z "${{ secrets.DOCKERHUB_USERNAME }}" ] || [ -z "${{ secrets.DOCKERHUB_TOKEN }}" ]; then - echo "Missing Docker Hub secrets: DOCKERHUB_USERNAME / DOCKERHUB_TOKEN" - exit 1 - fi - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - - name: Login to GitHub Container Registry - uses: docker/login-action@v3 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Login to Docker Hub - uses: docker/login-action@v3 - with: - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} - - - name: Docker metadata - id: meta - uses: docker/metadata-action@v5 - with: - images: | - 1467078763/metapi - ghcr.io/${{ github.repository }} - tags: | - type=ref,event=tag - type=raw,value=latest - - - name: Add architecture suffix to tags - id: arch-tags - shell: bash - run: | - set -euo pipefail - rm -f tags.txt - while IFS= read -r tag; do - [ -n "$tag" ] || continue - echo "${tag}-${{ matrix.arch }}" >> tags.txt - done <<< "${{ steps.meta.outputs.tags }}" - { - echo 'tags<> "$GITHUB_OUTPUT" - - - name: Build and push ${{ matrix.platform }} image - uses: docker/build-push-action@v6 - with: - context: . - file: docker/Dockerfile - platforms: ${{ matrix.platform }} - push: true - tags: ${{ steps.arch-tags.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} - cache-from: type=gha,scope=release-${{ matrix.arch }} - cache-to: type=gha,mode=max,scope=release-${{ matrix.arch }} - - publish-docker: - name: Publish Docker Manifests - needs: publish-docker-arch - runs-on: ubuntu-latest - timeout-minutes: 15 - if: startsWith(github.ref, 'refs/tags/') - - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Ensure Docker Hub secrets are configured - run: | - if [ -z "${{ secrets.DOCKERHUB_USERNAME }}" ] || [ -z "${{ secrets.DOCKERHUB_TOKEN }}" ]; then - echo "Missing Docker Hub secrets: DOCKERHUB_USERNAME / DOCKERHUB_TOKEN" - exit 1 - fi - + - arch: armv7 + platform: linux/arm/v7 + runner: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Ensure Docker Hub secrets are configured + run: | + if [ -z "${{ secrets.DOCKERHUB_USERNAME }}" ] || [ -z "${{ secrets.DOCKERHUB_TOKEN }}" ]; then + echo "Missing Docker Hub secrets: DOCKERHUB_USERNAME / DOCKERHUB_TOKEN" + exit 1 + fi + - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - - name: Login to GitHub Container Registry - uses: docker/login-action@v3 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Login to Docker Hub - uses: docker/login-action@v3 - with: - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} - - - name: Docker metadata - id: meta - uses: docker/metadata-action@v5 - with: - images: | - 1467078763/metapi - ghcr.io/${{ github.repository }} - tags: | - type=ref,event=tag - type=raw,value=latest - - - name: Create multi-arch manifests - shell: bash - run: | - set -euo pipefail - while IFS= read -r tag; do - [ -n "$tag" ] || continue + uses: docker/setup-buildx-action@v4 + + - name: Set up QEMU + if: matrix.arch == 'armv7' + uses: docker/setup-qemu-action@v4 + + - name: Login to GitHub Container Registry + uses: docker/login-action@v4 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Login to Docker Hub + uses: docker/login-action@v4 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Docker metadata + id: meta + uses: docker/metadata-action@v6 + with: + images: | + 1467078763/metapi + ghcr.io/${{ github.repository }} + tags: | + type=ref,event=tag + type=raw,value=latest + + - name: Add architecture suffix to tags + id: arch-tags + shell: bash + run: | + set -euo pipefail + rm -f tags.txt + while IFS= read -r tag; do + [ -n "$tag" ] || continue + echo "${tag}-${{ matrix.arch }}" >> tags.txt + done <<< "${{ steps.meta.outputs.tags }}" + { + echo 'tags<> "$GITHUB_OUTPUT" + + - name: Build and push ${{ matrix.platform }} image + uses: docker/build-push-action@v7 + with: + context: . + file: docker/Dockerfile + platforms: ${{ matrix.platform }} + push: true + tags: ${{ steps.arch-tags.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha,scope=release-${{ matrix.arch }} + cache-to: type=gha,mode=max,scope=release-${{ matrix.arch }} + + publish-docker: + name: Publish Docker Manifests + needs: publish-docker-arch + runs-on: ubuntu-latest + timeout-minutes: 15 + if: startsWith(github.ref, 'refs/tags/') + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Ensure Docker Hub secrets are configured + run: | + if [ -z "${{ secrets.DOCKERHUB_USERNAME }}" ] || [ -z "${{ secrets.DOCKERHUB_TOKEN }}" ]; then + echo "Missing Docker Hub secrets: DOCKERHUB_USERNAME / DOCKERHUB_TOKEN" + exit 1 + fi + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v4 + + - name: Login to GitHub Container Registry + uses: docker/login-action@v4 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Login to Docker Hub + uses: docker/login-action@v4 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Docker metadata + id: meta + uses: docker/metadata-action@v6 + with: + images: | + 1467078763/metapi + ghcr.io/${{ github.repository }} + tags: | + type=ref,event=tag + type=raw,value=latest + + - name: Create multi-arch manifests + shell: bash + run: | + set -euo pipefail + while IFS= read -r tag; do + [ -n "$tag" ] || continue docker buildx imagetools create \ --tag "$tag" \ "${tag}-amd64" \ - "${tag}-arm64" + "${tag}-arm64" \ + "${tag}-armv7" done <<< "${{ steps.meta.outputs.tags }}" diff --git a/.gitignore b/.gitignore index 32353900..d789c71e 100644 --- a/.gitignore +++ b/.gitignore @@ -9,11 +9,13 @@ data/* logs/ tmp/* !tmp/.gitkeep +.worktrees/ # Local desktop/debug artifacts /.tmp-* /main.js /NUL +/.playwright-cli/ .DS_Store Thumbs.db diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 00000000..5bd68117 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +20.19.0 diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..1a2ee166 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,74 @@ +# Metapi Engineering Rules + +These rules apply to the whole repository unless a deeper `AGENTS.md` overrides +them. They are intentionally opinionated and mechanical so humans and agents can +make small, consistent changes without re-learning the codebase each time. + +## Golden Principles + +- Prefer one source of truth. If a helper, contract, or workflow already owns + an invariant, extend it instead of creating a parallel implementation. +- Fix the family, not just the symptom. When a bug comes from a repeated + pattern, sweep adjacent paths in the same subsystem before calling the work + done. +- Keep changes narrow and reviewable. Land one coherent slice at a time and + avoid bundling unrelated cleanup into the same patch. + +## Server Layers + +- `src/server/routes/**` are adapters, not owners. Route files may register + Fastify endpoints, parse request context, and delegate. They must not own + protocol conversion, retry policy, stream lifecycle, billing, or + persistence. +- If a helper is imported by anything outside one route file, it does not + belong under `src/server/routes/proxy/`. +- `src/server/proxy-core/**` owns proxy orchestration. Endpoint fallback should + flow through `executeEndpointFlow()`. Channel/session bookkeeping should flow + through `sharedSurface.ts`. +- `src/server/transformers/**` are protocol-pure. Do not import from + `src/server/routes/**`, Fastify, OAuth services, token router, or runtime + dispatch modules. If a transformer needs a shared contract, move it to a + neutral module first. +- Whole-body upstream reads in proxy orchestration should use + `readRuntimeResponseText()` instead of direct `.text()` reads. + +## Platform And Routing Rules + +- Platform behavior must be explicit. Detection, endpoint preference, discovery + transport, and management capability should come from one declared capability + story, not scattered `if platform === ...` branches. +- Thin adapters must stay honest. Do not let a platform look feature-complete + through inherited defaults if the underlying upstream does not support the + feature. +- Retry classification and routing health classification should share the same + failure vocabulary whenever possible. + +## Database Rules + +- One schema change requires three synchronized outputs: update the Drizzle + schema, update SQLite migration history, and regenerate checked-in schema + artifacts together. +- Cross-dialect bootstrap and upgrade SQL must be generated from the schema + contract. Do not hand-write new MySQL/Postgres schema patches in feature + code. +- Legacy schema compatibility is temporary and spec-owned. Additive startup + shims should stay narrow and trace back to a feature compatibility spec. + +## Web Rules + +- Pages are orchestration surfaces, not shared utility libraries. Do not import + one top-level page from another top-level page. +- Mobile behavior should reuse existing shared primitives first: + `ResponsiveFilterPanel`, `ResponsiveBatchActionBar`, `MobileCard`, + `useIsMobile`, and `mobileLayout.ts`. +- When a page grows a second complex modal, drawer, or panel family, extract it + into a domain subfolder before adding more inline state and rendering logic. + +## Guardrails + +- Run `npm run repo:drift-check` before finishing changes that touch shared + architecture boundaries. +- If you add a new boundary-heavy module, add or extend an architecture test in + the same area so the rule becomes executable. +- Keep local planning files under `docs/plans/`. They are intentionally ignored + by git and should not be treated as published documentation. diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index a88b8157..166d5ab5 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -1,44 +1,106 @@ -# Code of Conduct +# Code of Conduct / 行为准则 -## Our Commitment +## Our Pledge / 我们的承诺 -We are committed to providing a welcoming, harassment-free community for everyone. +We as members, contributors, and maintainers pledge to make participation in the Metapi community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation. -## Expected Behavior +作为 Metapi 社区的成员、贡献者和维护者,我们承诺让每个人都能在无骚扰的环境中参与,无论年龄、体型、残疾、种族、性别认同和表达、经验水平、国籍、个人外貌、种族、宗教或性取向如何。 -- Be respectful and constructive. -- Focus on technical discussion, not personal attacks. -- Assume good intent and ask clarifying questions before escalating. +## Our Standards / 我们的标准 -## Unacceptable Behavior +### Expected Behavior / 期望的行为 -- Harassment, discrimination, or abusive language -- Personal attacks or intimidation -- Publishing others' private information without consent -- Deliberate disruption, trolling, or bad-faith escalation -- Repeatedly ignoring maintainer moderation requests +Examples of behavior that contributes to a positive environment / 有助于营造积极环境的行为示例: -## Reporting +- Using welcoming and inclusive language / 使用友好和包容的语言 +- Being respectful of differing viewpoints and experiences / 尊重不同的观点和经验 +- Gracefully accepting constructive criticism / 优雅地接受建设性批评 +- Focusing on what is best for the community / 关注对社区最有利的事情 +- Showing empathy towards other community members / 对其他社区成员表现出同理心 +- Providing helpful and constructive feedback / 提供有益和建设性的反馈 +- Assuming good intent and asking clarifying questions / 假定善意并提出澄清性问题 -- Email `cita-777@users.noreply.github.com` with subject prefix `[metapi conduct]`. -- Include links, screenshots, timestamps, and any context that helps reconstruct the incident. -- If the incident is happening directly on GitHub and needs urgent platform action, also use GitHub's built-in report tools. -- We will keep reporter details as private as practical and do not allow retaliation for good-faith reports. +### Unacceptable Behavior / 不可接受的行为 -## Enforcement +Examples of unacceptable behavior / 不可接受行为的示例: -Project maintainers are responsible for clarifying and enforcing this code of conduct. +- Harassment, discrimination, or abusive language / 骚扰、歧视或辱骂性语言 +- Trolling, insulting/derogatory comments, and personal or political attacks / 挑衅、侮辱性/贬损性评论以及人身或政治攻击 +- Public or private harassment or intimidation / 公开或私下的骚扰或恐吓 +- Publishing others' private information (e.g., physical or email address) without explicit permission / 未经明确许可发布他人的私人信息(例如,实际地址或电子邮件地址) +- Deliberate disruption of discussions or project activities / 故意破坏讨论或项目活动 +- Repeatedly ignoring maintainer moderation requests / 反复忽视维护者的管理要求 +- Other conduct which could reasonably be considered inappropriate in a professional setting / 在专业环境中可能被合理认为不当的其他行为 -Possible actions include: +## Reporting / 举报 -- editing or removing comments, issues, discussions, or PR content -- warning the contributor -- locking threads or limiting participation in project spaces -- rejecting contributions or blocking a contributor from repository spaces -- escalating serious cases to GitHub or another hosting provider +If you experience or witness unacceptable behavior, or have any other concerns, please report it by: -We aim to acknowledge conduct reports within 7 days when possible and will review the available evidence before taking action. +如果您遇到或目睹不可接受的行为,或有任何其他疑虑,请通过以下方式举报: -## Scope +1. **Email** / **邮件**: `cita-777@users.noreply.github.com` with subject prefix `[Metapi Conduct]` / 主题前缀为 `[Metapi Conduct]` +2. **GitHub**: Use [GitHub's built-in reporting tools](https://docs.github.com/en/communities/maintaining-your-safety-on-github/reporting-abuse-or-spam) for urgent platform-level issues / 对于紧急的平台级问题,使用 [GitHub 的内置举报工具](https://docs.github.com/en/communities/maintaining-your-safety-on-github/reporting-abuse-or-spam) -This policy applies to project spaces and public/private interactions where an individual is representing the project. +### What to Include / 应包含的内容 + +When reporting, please include / 举报时请包含: + +- Your contact information / 您的联系信息 +- Names (usernames, real names) of individuals involved / 涉及人员的姓名(用户名、真实姓名) +- Description of the incident / 事件描述 +- Links to relevant issues, PRs, or discussions / 相关 issue、PR 或讨论的链接 +- Screenshots or logs (with sensitive information redacted) / 截图或日志(删除敏感信息) +- Timestamps and context / 时间戳和上下文 +- Any other information that would be helpful / 任何其他有用的信息 + +### Confidentiality / 保密性 + +All reports will be handled with discretion. We will keep reporter details as private as practical and do not allow retaliation for good-faith reports. + +所有举报都将谨慎处理。我们将尽可能保持举报者信息的私密性,并且不允许对善意举报进行报复。 + +## Enforcement / 执行 + +Project maintainers are responsible for clarifying and enforcing this code of conduct. They have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned with this Code of Conduct. + +项目维护者负责阐明和执行本行为准则。他们有权利和责任删除、编辑或拒绝不符合本行为准则的评论、提交、代码、wiki 编辑、issue 和其他贡献。 + +### Enforcement Actions / 执行措施 + +Depending on the severity and context, maintainers may take actions including / 根据严重程度和上下文,维护者可能采取的措施包括: + +1. **Warning** / **警告**: A private or public warning about the behavior / 对行为进行私下或公开警告 +2. **Temporary Ban** / **临时禁止**: Temporary restriction from interacting in project spaces / 暂时限制在项目空间中的互动 +3. **Permanent Ban** / **永久禁止**: Permanent removal from all project spaces / 永久从所有项目空间中移除 +4. **Content Removal** / **内容删除**: Editing or removing comments, issues, discussions, or PR content / 编辑或删除评论、issue、讨论或 PR 内容 +5. **Thread Locking** / **话题锁定**: Locking threads to prevent further discussion / 锁定话题以防止进一步讨论 +6. **Platform Escalation** / **平台升级**: Escalating serious cases to GitHub or other hosting providers / 将严重案例上报给 GitHub 或其他托管平台 + +### Response Timeline / 响应时间 + +- We aim to acknowledge conduct reports within **7 days** when possible. / 我们力求在 **7 天**内确认行为举报(如有可能)。 +- We will review the available evidence before taking action. / 我们将在采取行动之前审查可用证据。 +- Complex cases may require additional time for investigation. / 复杂案例可能需要额外时间进行调查。 + +## Scope / 适用范围 + +This Code of Conduct applies to all project spaces, including / 本行为准则适用于所有项目空间,包括: + +- GitHub repository (issues, PRs, discussions, code reviews) / GitHub 仓库(issue、PR、讨论、代码审查) +- Documentation and wiki / 文档和 wiki +- Community forums and chat channels / 社区论坛和聊天频道 +- Official social media accounts / 官方社交媒体账号 +- Project events (online or offline) / 项目活动(线上或线下) +- Any other spaces where an individual is representing the Metapi project / 个人代表 Metapi 项目的任何其他空间 + +## Attribution / 归属 + +This Code of Conduct is adapted from the [Contributor Covenant](https://www.contributor-covenant.org/), version 2.1. + +本行为准则改编自 [Contributor Covenant](https://www.contributor-covenant.org/) 2.1 版本。 + +## Questions / 问题 + +If you have questions about this Code of Conduct, please open an issue or contact the maintainers at `cita-777@users.noreply.github.com`. + +如果您对本行为准则有疑问,请开启 issue 或通过 `cita-777@users.noreply.github.com` 联系维护者。 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 465e9d0b..87f7ed87 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,99 +1,224 @@ -# Contributing +# Contributing / 贡献指南 -Thanks for contributing to Metapi. +Thank you for your interest in contributing to Metapi! / 感谢您对 Metapi 项目的贡献! -## Local Setup +Metapi is a meta-aggregation layer for AI API platforms (New API, One API, OneHub, etc.), providing unified proxy, intelligent routing, and centralized management. -1. Install dependencies: +Metapi 是 AI API 聚合平台(New API、One API、OneHub 等)的元聚合层,提供统一代理、智能路由和集中管理。 + +## Before You Start / 开始之前 + +- Check existing [Issues](https://github.com/cita-777/metapi/issues) and [Pull Requests](https://github.com/cita-777/metapi/pulls) to avoid duplicates. / 检查现有的 [Issues](https://github.com/cita-777/metapi/issues) 和 [Pull Requests](https://github.com/cita-777/metapi/pulls) 以避免重复。 +- For major changes, open an issue first to discuss your proposal. / 对于重大更改,请先开启 issue 讨论您的提案。 +- Read our [Code of Conduct](CODE_OF_CONDUCT.md). / 阅读我们的[行为准则](CODE_OF_CONDUCT.md)。 + +## Local Development Setup / 本地开发环境设置 + +### Prerequisites / 前置要求 + +- Node.js 20+ / Node.js 20 或更高版本 +- npm or compatible package manager / npm 或兼容的包管理器 + +### Setup Steps / 设置步骤 + +1. **Fork and clone the repository** / **Fork 并克隆仓库** ```bash -npm install +git clone https://github.com/YOUR_USERNAME/metapi.git +cd metapi ``` -2. Create a local environment file: +2. **Install dependencies** / **安装依赖** -```powershell -Copy-Item .env.example .env +```bash +npm install ``` +3. **Create environment file** / **创建环境文件** + ```bash +# Windows PowerShell +Copy-Item .env.example .env + +# Linux/macOS/Git Bash cp .env.example .env ``` -3. Initialize the default SQLite database: +Edit `.env` and set your tokens / 编辑 `.env` 并设置您的令牌: + +```env +AUTH_TOKEN=your-dev-admin-token +PROXY_TOKEN=your-dev-proxy-token +``` + +4. **Initialize database** / **初始化数据库** ```bash npm run db:migrate ``` -## Common Commands - -### App development +5. **Start development server** / **启动开发服务器** ```bash npm run dev -npm run dev:server -restart.bat ``` -- `npm run dev` starts the Fastify server and Vite together. -- `npm run dev:server` runs only the backend watcher. -- `restart.bat` is the Windows-friendly restart entrypoint; it forwards to `scripts\dev\restart.bat`, clears stale listeners, and starts `npm run dev`. +The app will be available at `http://localhost:4000` (backend) and `http://localhost:5173` (frontend). -### Docs +应用将在 `http://localhost:4000`(后端)和 `http://localhost:5173`(前端)可用。 + +## Development Commands / 开发命令 + +### Web Application / Web 应用 ```bash -npm run docs:dev -npm run docs:build -npm run docs:preview +npm run dev # Start backend + frontend with hot reload / 启动后端 + 前端热更新 +npm run dev:server # Start backend only / 仅启动后端 +npm run build # Build all (web + server + desktop) / 构建全部 +npm run build:web # Build frontend only / 仅构建前端 +npm run build:server # Build backend only / 仅构建后端 ``` -### Desktop +### Desktop Application / 桌面应用 ```bash -npm run dev:desktop -npm run build:desktop -npm run dist:desktop -npm run package:desktop +npm run dev:desktop # Start desktop app in dev mode / 开发模式启动桌面应用 +npm run build:desktop # Build desktop app / 构建桌面应用 +npm run dist:desktop # Package desktop app / 打包桌面应用 +npm run dist:desktop:mac:intel # Package for macOS Intel / 打包 macOS Intel 版本 ``` -`npm run dev:desktop` expects the backend on `http://127.0.0.1:4000` and the Vite frontend on `http://127.0.0.1:5173`. +### Documentation / 文档 + +```bash +npm run docs:dev # Start VitePress dev server / 启动 VitePress 开发服务器 +npm run docs:build # Build documentation / 构建文档 +npm run docs:preview # Preview built docs / 预览构建的文档 +``` + +### Testing / 测试 + +```bash +npm test # Run all tests / 运行所有测试 +npm run test:watch # Run tests in watch mode / 监听模式运行测试 +npm run smoke:db # Database smoke test (SQLite) / 数据库冒烟测试(SQLite) +npm run smoke:db:mysql # MySQL smoke test / MySQL 冒烟测试 +npm run smoke:db:postgres # PostgreSQL smoke test / PostgreSQL 冒烟测试 +``` -### Test, build, and smoke checks +### Database / 数据库 ```bash -npm test -npm run test:watch -npm run build -npm run build:web -npm run build:server -npm run smoke:db -npm run smoke:db:sqlite -npm run smoke:db:mysql -- --db-url mysql://user:pass@host:3306/db -npm run smoke:db:postgres -- --db-url postgres://user:pass@host:5432/db +npm run db:generate # Generate Drizzle migration files / 生成 Drizzle 迁移文件 +npm run db:migrate # Run database migrations / 运行数据库迁移 +npm run schema:generate # Generate schema artifacts / 生成 schema 构件 ``` -## Windows Notes +## Project Structure / 项目结构 + +``` +metapi/ +├── src/ +│ ├── server/ # Backend (Fastify) / 后端(Fastify) +│ │ ├── routes/ # API routes / API 路由 +│ │ ├── services/ # Business logic / 业务逻辑 +│ │ ├── db/ # Database & ORM / 数据库与 ORM +│ │ └── middleware/ # Middleware / 中间件 +│ ├── web/ # Frontend (React + Vite) / 前端(React + Vite) +│ └── desktop/ # Electron desktop app / Electron 桌面应用 +├── docs/ # VitePress documentation / VitePress 文档 +├── drizzle/ # Database migrations / 数据库迁移 +└── scripts/ # Build & dev scripts / 构建与开发脚本 +``` + +## Pull Request Guidelines / Pull Request 指南 + +### Before Submitting / 提交之前 + +1. **Keep PRs focused and small** / **保持 PR 专注且小巧** + - One feature or fix per PR / 每个 PR 一个功能或修复 + - Split large changes into multiple PRs / 将大型更改拆分为多个 PR + +2. **Write tests** / **编写测试** + - Add tests for new features / 为新功能添加测试 + - Update tests for behavior changes / 为行为变更更新测试 + - Ensure all tests pass: `npm test` / 确保所有测试通过:`npm test` + +3. **Update documentation** / **更新文档** + - Update README if adding user-facing features / 如果添加面向用户的功能,请更新 README + - Update docs/ for configuration or API changes / 配置或 API 更改请更新 docs/ + - Add JSDoc comments for new functions / 为新函数添加 JSDoc 注释 + +4. **Run checks** / **运行检查** + - Documentation changes: `npm run docs:build` / 文档更改:`npm run docs:build` + - Code changes: `npm test && npm run build` / 代码更改:`npm test && npm run build` + - Database changes: `npm run smoke:db` / 数据库更改:`npm run smoke:db` + - Architecture / repo drift changes: `npm run repo:drift-check` / 架构与仓库漂移检查:`npm run repo:drift-check` + +5. **Follow code style** / **遵循代码风格** + - Use TypeScript for type safety / 使用 TypeScript 确保类型安全 + - Follow existing code patterns / 遵循现有代码模式 + - Follow repo-level engineering rules in `AGENTS.md` / 遵循仓库根目录 `AGENTS.md` 中的工程规则 + - Keep functions small and focused / 保持函数小而专注 + +### Commit Messages / 提交信息 + +Use conventional commit format / 使用约定式提交格式: + +``` +: + +[optional body] +``` + +Types / 类型: +- `feat`: New feature / 新功能 +- `fix`: Bug fix / 错误修复 +- `docs`: Documentation / 文档 +- `refactor`: Code refactoring / 代码重构 +- `test`: Tests / 测试 +- `chore`: Build/tooling / 构建/工具 + +Examples / 示例: +``` +feat: add AnyRouter platform adapter +fix: handle empty model list in dashboard +docs: update Docker deployment guide +refactor: extract route selection logic +test: add tests for checkin reward parser +chore: upgrade Vite to 6.0 +``` + +### What Not to Commit / 不要提交的内容 + +- Runtime data: `data/`, `tmp/` / 运行时数据:`data/`、`tmp/` +- Environment files: `.env` (only `.env.example` is tracked) / 环境文件:`.env`(仅跟踪 `.env.example`) +- Build artifacts: `dist/`, `node_modules/` / 构建产物:`dist/`、`node_modules/` +- IDE-specific files (unless beneficial to all contributors) / IDE 特定文件(除非对所有贡献者有益) + +## Platform Adapters / 平台适配器 + +If you're adding support for a new AI API platform / 如果您要添加对新 AI API 平台的支持: + +1. Create adapter in `src/server/services/platforms/` / 在 `src/server/services/platforms/` 中创建适配器 +2. Implement required interfaces: login, balance, models, proxy / 实现必需接口:登录、余额、模型、代理 +3. Add platform tests / 添加平台测试 +4. Update documentation with platform details / 更新文档说明平台详情 + +## Windows Development Notes / Windows 开发注意事项 -- Prefer `Copy-Item` or Explorer copy/paste over `cp` if you are working in PowerShell or `cmd.exe`. -- If a previous dev process keeps ports busy, use `restart.bat` instead of manually hunting PIDs. -- If dependencies or `.cmd` shims look broken after a Node.js upgrade, rerun `npm install` before assuming the scripts are wrong. +- Use `restart.bat` to restart dev server (clears port locks) / 使用 `restart.bat` 重启开发服务器(清除端口锁定) +- Use PowerShell `Copy-Item` instead of `cp` / 使用 PowerShell 的 `Copy-Item` 而不是 `cp` +- If Node.js upgrade breaks scripts, run `npm install` again / 如果 Node.js 升级导致脚本损坏,请重新运行 `npm install` -## Pull Request Guidelines +## Getting Help / 获取帮助 -- Keep PRs focused and small. -- Add or update tests for behavior changes. -- Update docs when user-facing behavior, commands, ports, or configuration change. -- Run the checks that match your change set before opening a PR: - - docs only: `npm run docs:build` - - app code: `npm test` and the relevant `npm run build:*` - - runtime DB work: one of the `npm run smoke:db*` commands -- Avoid committing runtime data (`data/`) or temporary files (`tmp/`). +- 📖 [Documentation](https://metapi.cita777.me) / [文档](https://metapi.cita777.me) +- 💬 [GitHub Discussions](https://github.com/cita-777/metapi/discussions) / [GitHub 讨论区](https://github.com/cita-777/metapi/discussions) +- 🐛 [Issue Tracker](https://github.com/cita-777/metapi/issues) / [Issue 跟踪](https://github.com/cita-777/metapi/issues) -## Commit Messages +## License / 许可证 -Use concise messages with clear scope, for example: +By contributing, you agree that your contributions will be licensed under the [MIT License](LICENSE). -- `feat: add token route health guard` -- `fix: handle empty model list in dashboard` -- `docs: clarify docker env setup` +通过贡献,您同意您的贡献将根据 [MIT 许可证](LICENSE) 授权。 diff --git a/PR_PREPARATION.md b/PR_PREPARATION.md new file mode 100644 index 00000000..0b7098ab --- /dev/null +++ b/PR_PREPARATION.md @@ -0,0 +1,253 @@ +# 模型白名单功能 - PR 准备文档 + +## 功能概述 + +实现全局模型白名单功能,允许管理员配置只启用特定模型的路由。当白名单配置后,不在白名单中的模型路由会被自动移除,实现精细化的模型访问控制。 + +## 业务场景 + +- **成本控制**: 只允许使用价格合理的模型 +- **合规要求**: 限制只使用审批通过的模型 +- **简化管理**: 隐藏不需要的模型,减少用户困惑 +- **测试验证**: 在特定场景下只开放测试模型 + +## 技术实现 + +### 1. 配置层 (src/server/config.ts) + +```typescript +globalAllowedModels: [] as string[] +``` + +- 默认为空数组(向后兼容) +- 存储在内存中,启动时从数据库加载 + +### 2. 数据持久化 (src/server/routes/api/settings.ts) + +**数据库存储:** +- Key: `global_allowed_models` +- Value: JSON 字符串数组 + +**API 接口:** + +#### GET /api/settings/runtime +返回当前白名单配置 + +#### PUT /api/settings/runtime +```json +{ + "globalAllowedModels": ["gpt-4", "gpt-4o", "claude-3-sonnet"] +} +``` + +**处理流程:** +1. 验证输入(必须是字符串数组) +2. 去重、trim、过滤空值 +3. 比较新旧配置 +4. 更新数据库和内存配置 +5. 如果配置变更,自动触发路由重建 + +### 3. 路由重建 (src/server/services/modelService.ts) + +**核心函数:** `rebuildTokenRoutesFromAvailability()` + +```typescript +// 加载白名单 +const globalAllowedModels = new Set( + config.globalAllowedModels.map(m => m.toLowerCase().trim()).filter(Boolean) +); + +// 判断模型是否允许 +function isModelAllowedByWhitelist(modelName: string): boolean { + if (globalAllowedModels.size === 0) return true; // 向后兼容 + return globalAllowedModels.has(modelName.toLowerCase().trim()); +} + +// 添加模型候选时过滤 +const addModelCandidate = (modelNameRaw, accountId, tokenId, siteId) => { + const modelName = (modelNameRaw || '').trim(); + if (!modelName) return; + if (!isModelAllowedByWhitelist(modelName)) return; // 白名单过滤 + // ... 其他过滤逻辑 +}; +``` + +**处理逻辑:** +1. 从 `token_model_availability` 加载所有可用模型 +2. 通过 `addModelCandidate()` 过滤,只保留白名单中的模型 +3. 创建/更新路由和渠道 +4. 删除不在白名单中的旧路由 + +### 4. 模型候选API (src/server/routes/api/stats.ts) + +**接口:** GET /api/models/token-candidates + +**返回字段:** +- `models`: 有token的模型 +- `modelsWithoutToken`: 无token的模型 +- `modelsMissingTokenGroups`: 缺少token分组的模型 + +**过滤逻辑:** +```typescript +if (globalAllowedModels.size > 0) { + // 白名单模式:只返回白名单中的模型 + for (const [modelName, candidates] of Object.entries(result)) { + if (globalAllowedModels.has(modelName.toLowerCase().trim())) { + filteredResult[modelName] = candidates; + } + } +} else { + // 向后兼容:返回所有模型 + Object.assign(filteredResult, result); +} +``` + +### 5. 前端UI (src/web/pages/Settings.tsx) + +**新增卡片:** "全局模型白名单" + +**功能特性:** +- 输入框手动添加模型名称(支持回车) +- 可用模型列表展示(点击快速添加) +- 已选模型徽章显示(绿色徽章) +- 点击 × 删除模型 +- "保存模型白名单"按钮 +- 保存成功后显示提示并自动刷新页面 + +**交互流程:** +1. 加载当前白名单配置 +2. 加载可用模型列表 +3. 用户添加/删除模型 +4. 点击保存触发API调用 +5. 后端自动重建路由 +6. 前端显示成功提示 + +## 数据流程图 + +``` +用户输入 → Settings API + ↓ + 数据库存储 (global_allowed_models) + ↓ + 内存配置更新 (config.globalAllowedModels) + ↓ + 触发路由重建 (异步后台任务) + ↓ + 加载 token_model_availability + ↓ + 白名单过滤 (isModelAllowedByWhitelist) + ↓ + 创建/更新/删除路由 + ↓ + 候选API过滤 (token-candidates) + ↓ + 前端显示过滤后的模型列表 +``` + +## 向后兼容性 + +| 场景 | 行为 | +|------|------| +| 白名单为空 | 允许所有模型(原有行为) | +| 白名单有值 | 只允许白名单中的模型 | +| 数据库无配置 | 默认为空数组 | +| API未传该字段 | 不修改现有配置 | + +## 测试验证 + +### 功能测试清单 + +- [x] 白名单为空时,所有模型可见 +- [x] 设置白名单后,只显示白名单模型 +- [x] 大小写不敏感匹配(GPT-4 = gpt-4) +- [x] 自动trim空格 +- [x] 保存后自动触发路由重建 +- [x] 路由重建成功后,路由正确过滤 +- [x] 候选API正确过滤三个返回字段 +- [ ] 前端UI交互正常 +- [ ] 输入框支持回车添加 +- [ ] 可用模型列表正确显示 +- [ ] 点击模型快速添加 +- [ ] 删除模型功能正常 +- [ ] 保存成功提示显示 + +### API 测试 + +```bash +# 查看白名单 +curl -H "Authorization: Bearer test-admin-token" \ + http://localhost:4000/api/settings/runtime + +# 设置白名单 +curl -X PUT \ + -H "Authorization: Bearer test-admin-token" \ + -H "Content-Type: application/json" \ + -d '{"globalAllowedModels": ["gpt-4", "gpt-4o"]}' \ + http://localhost:4000/api/settings/runtime + +# 查看模型候选 +curl -H "Authorization: Bearer test-admin-token" \ + http://localhost:4000/api/models/token-candidates +``` + +## 文件修改列表 + +| 文件 | 修改内容 | 行数 | +|------|---------|-----| +| src/server/config.ts | 新增 globalAllowedModels 配置 | 1 | +| src/server/routes/api/settings.ts | 类型定义、加载、保存、触发重建 | ~80 | +| src/server/services/modelService.ts | 白名单过滤逻辑 | ~20 | +| src/server/routes/api/stats.ts | 候选API过滤 | ~30 | +| src/web/pages/Settings.tsx | 前端UI组件 | ~150 | + +**总计:** 约 280 行代码新增/修改 + +## 性能影响 + +- **内存:** 新增一个 Set 数据结构,大小为白名单模型数量 +- **路由重建:** 无额外开销(仅增加一个 O(1) 的 Set 查找) +- **候选API:** 增加过滤循环,复杂度 O(n),n为模型数量 + +**结论:** 性能影响可忽略不计 + +## 安全考虑 + +- ✅ 仅管理员可配置(需要 admin token) +- ✅ 输入验证(类型检查、trim、去重) +- ✅ 无SQL注入风险(使用 ORM) +- ✅ 配置变更自动记录日志 + +## 后续优化建议 + +1. **批量导入**: 支持从文件批量导入白名单 +2. **预设模板**: 提供常用模型组合模板 +3. **分组管理**: 支持多个白名单分组 +4. **权限控制**: 不同用户看到不同白名单 +5. **审计日志**: 记录白名单变更历史 + +## 已知限制 + +1. 白名单只支持精确匹配,不支持通配符或正则 +2. 模型名称大小写不敏感,但保留原始大小写显示 +3. 白名单全局生效,不支持按站点或账号单独配置 + +## 相关 Issue + +- Issue #XXX: 模型访问控制需求 +- Issue #XXX: 简化模型列表显示 + +## 测试环境 + +- Node.js: v20.x +- pnpm: 8.x +- SQLite: 3.x +- 测试服务器: http://localhost:4000 +- 真实模型数据已验证 + +## 下一步计划 + +1. ✅ 完成功能开发和测试 +2. ✅ 编写PR文档 +3. ⏳ 等待用户前端测试验证 +4. ⏳ 合并到主仓库 +5. ⏳ 开始第二个功能:新站点跳转选择 \ No newline at end of file diff --git a/README.md b/README.md index 1cbd83cb..8a4308d9 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,12 @@ 汇聚成 一个 API Key、一个入口,自动发现模型、智能路由、成本最优。

+

+ + LINUX DO + +

+

GitHub Release @@ -17,6 +23,9 @@ --> GitHub Stars + Ask DeepWiki + Docker Pulls License Node.jsNode.jsTypeScript Deploy on Zeabur + + Deploy to Render

@@ -51,6 +63,21 @@ --- +## 🌐 在线体验 + +> 无需部署,直接体验 Metapi 的完整功能: + +| | | +| ---------------------- | ---------------------------------------------------------- | +| 🔗**体验地址** | [metapi-t9od.onrender.com](https://metapi-t9od.onrender.com/) | +| 🔑**管理员令牌** | `123456` | + +> **⚠️ 安全提示**:体验站为公共环境,**请勿填入你的 API Key、账号密码或站点信息**。数据随时可能被清空。 + +> **ℹ️ 说明**:体验站使用 Render 免费方案 + OpenRouter 免费模型(仅 `:free` 后缀的模型可用)。 + +--- + ## 📖 介绍 现在 AI 生态里有越来越多基于 New API / One API 系列的聚合中转站,要管理多个站点的余额、模型列表和 API 密钥,往往既分散又费时。 @@ -65,14 +92,14 @@ - [AnyRouter](https://anyrouter.top) — 通用路由平台 - [Sub2API](https://github.com/Wei-Shaw/sub2api) — 订阅制中转 -| 痛点 | Metapi 怎么解决 | -| ------------------------------------- | --------------------------------------------------------------- | +| 痛点 | Metapi 怎么解决 | +| ------------------------------------- | ---------------------------------------------------------------------- | | 🔑 每个站点一个 Key,下游工具配置一堆 | **统一代理入口 + 可选多下游 Key 策略**,模型自动聚合到 `/v1/*` | -| 💸 不知道哪个站点用某个模型最便宜 | **智能路由** 自动按成本、余额、使用率选最优通道 | -| 🔄 某个站点挂了,手动切换好麻烦 | **自动故障转移**,一个通道失败自动冷却并切到下一个 | -| 📊 余额分散在各处,不知道还剩多少 | **集中看板** 一目了然,余额不足自动告警 | -| ✅ 每天得去各站签到领额度 | **自动签到** 定时执行,奖励自动追踪 | -| 🤷 不知道哪个站有什么模型 | **自动模型发现**,上游新增模型零配置出现在你的模型列表里 | +| 💸 不知道哪个站点用某个模型最便宜 | **智能路由** 自动按成本、余额、使用率选最优通道 | +| 🔄 某个站点挂了,手动切换好麻烦 | **自动故障转移**,一个通道失败自动冷却并切到下一个 | +| 📊 余额分散在各处,不知道还剩多少 | **集中看板** 一目了然,余额不足自动告警 | +| ✅ 每天得去各站签到领额度 | **自动签到** 定时执行,奖励自动追踪 | +| 🤷 不知道哪个站有什么模型 | **自动模型发现**,上游新增模型零配置出现在你的模型列表里 | --- @@ -141,7 +168,6 @@ - --- ## 🏛️ 架构概览 @@ -208,22 +234,61 @@

模型广场 — 一站式浏览所有可用模型的覆盖率、定价和性能指标

-### ✅ 自动签到 · 💰 余额管理 · 🔔 告警通知 · 📊 数据看板 +### ✅ 自动签到 + +- Cron 定时执行(默认每日 08:00) +- 智能解析奖励金额,签到失败自动通知 +- 按账号启用/禁用控制 +- 完整签到日志与历史查询 +- 并发锁防止重复签到 + +### 💰 余额管理 + +- 定时余额刷新(默认每小时),批量更新所有活跃账号 +- 收入追踪:每日/累计收入与消费趋势分析 +- 余额兜底估算:API 不可用时通过代理日志推算余额变动 +- 凭证过期自动重新登录 + +### 🔔 告警通知 -- **自动签到**:Cron 定时执行,智能解析奖励金额,签到失败自动通知 -- **余额管理**:定时刷新,收入追踪,余额兜底估算,凭证过期自动重登录 -- **告警通知**:支持 Webhook / Bark / Server酱 / Telegram Bot / SMTP 邮件,覆盖余额不足、站点异常、签到失败等场景 -- **数据看板**:余额分布饼图、消费趋势图表、全局搜索、代理请求日志 +支持五种通知渠道: + +| 渠道 | 说明 | +| ---------------------- | ----------------- | +| **Webhook** | 自定义 HTTP 推送 | +| **Bark** | iOS 推送通知 | +| **Server酱** | 微信通知 | +| **Telegram Bot** | Telegram 消息通知 | +| **SMTP 邮件** | 标准邮件通知 | + +告警场景:余额不足预警、站点/账号异常、签到失败、代理请求失败、Token 过期提醒、每日摘要报告。告警冷却机制(默认 300 秒)防止重复通知。 + +### 📊 数据看板 + +- 站点余额饼图、每日消费趋势图 +- 全局搜索(站点、账号、模型) +- 系统事件日志、代理请求日志(模型、状态、延迟、Token 用量、成本估算)
dashboard-detail

数据看板 — 余额分布、消费趋势、系统健康状态一目了然

+### 🎮 模型操练场 + +- 交互式聊天测试,即时验证模型可用性与响应质量 +- 选择任意路由模型,对比不同通道输出 +- 流式 / 非流式双模式测试 + +
+ playground-detail +

模型操练场 — 在线交互测试,验证模型可用性与响应质量

+
+ ### 📦 轻量部署 -- **单 Docker 容器**,默认本地数据目录部署,开箱即用 -- Alpine 基础镜像,体积精简 +- **单 Docker 容器**,默认本地数据目录部署,支持外接 MySQL / PostgreSQL 运行时数据库 +- Docker 镜像支持 `amd64`、`arm64` 和 `armv7l`(`linux/arm/v7`)服务端部署 - 数据完整导入导出,迁移无忧 --- @@ -233,41 +298,93 @@ Deploy on Zeabur + + Deploy to Render + + +### Docker Compose(推荐) + +```bash +mkdir metapi && cd metapi + +cat > docker-compose.yml << 'EOF' +services: + metapi: + image: 1467078763/metapi:latest + ports: + - "4000:4000" + volumes: + - ./data:/app/data + environment: + AUTH_TOKEN: ${AUTH_TOKEN:?AUTH_TOKEN is required} + PROXY_TOKEN: ${PROXY_TOKEN:?PROXY_TOKEN is required} + CHECKIN_CRON: "0 8 * * *" + BALANCE_REFRESH_CRON: "0 * * * *" + PORT: ${PORT:-4000} + DATA_DIR: /app/data + TZ: ${TZ:-Asia/Shanghai} + restart: unless-stopped +EOF + +# 设置 Token 并启动 +# AUTH_TOKEN = 管理后台登录令牌(登录时输入此值) +export AUTH_TOKEN=your-admin-token +# PROXY_TOKEN = 下游客户端调用 /v1/* 的 Token +export PROXY_TOKEN=your-proxy-sk-token +docker compose up -d +``` + +
+一行 Docker 命令 ```bash -docker run -d --name metapi -p 4000:4000 \ +docker run -d --name metapi \ + -p 4000:4000 \ -e AUTH_TOKEN=your-admin-token \ -e PROXY_TOKEN=your-proxy-sk-token \ - -v ./data:/app/data --restart unless-stopped \ + -e TZ=Asia/Shanghai \ + -v ./data:/app/data \ + --restart unless-stopped \ 1467078763/metapi:latest ``` +
+ 启动后访问 `http://localhost:4000`,用 `AUTH_TOKEN` 登录即可。 +> [!NOTE] +> Docker 镜像支持 `amd64`、`arm64` 和 `armv7l`(`linux/arm/v7`)服务端部署。 +> 当前 `armv7l` 支持范围仅限服务端 / Docker 运行,不包含桌面安装包。 + + > [!IMPORTANT] -> 请务必修改 `AUTH_TOKEN` 和 `PROXY_TOKEN`,不要使用默认值。 +> 请务必修改 `AUTH_TOKEN` 和 `PROXY_TOKEN`,不要使用默认值。数据存储在 `./data` 目录,升级不会丢失。 -📖 **[完整部署文档](https://metapi.cita777.me/deployment)** — Zeabur 一键部署 / Docker Compose / 桌面版安装包 / 反向代理 / 升级回滚 +> [!TIP] +> 初始管理员令牌即启动时配置的 `AUTH_TOKEN`。 +> 若在 Compose 外运行且未显式设置 `AUTH_TOKEN`,默认为 `change-me-admin-token`(仅用于本地调试)。 +> 桌面安装包首次启动也属于这类场景:如果你没有额外注入 `AUTH_TOKEN`,默认管理员令牌同样是 `change-me-admin-token`。 +> 如果在「设置」面板中修改了管理员令牌,后续登录请使用新令牌。 -📖 **[环境变量与配置](https://metapi.cita777.me/configuration)** — 全部环境变量、路由参数、通知渠道 +Docker Compose、桌面安装包、反向代理、升级与数据库选项等详见 [部署指南](https://metapi.cita777.me/deployment)。 -📖 **[客户端接入指南](https://metapi.cita777.me/client-integration)** — Cursor / Claude Code / Codex / Open WebUI 等配置方法 +📖 **[环境变量与配置](https://metapi.cita777.me/configuration)** · **[客户端接入指南](https://metapi.cita777.me/client-integration)** · **[常见问题](https://metapi.cita777.me/faq)** --- ## 🏗️ 技术栈 -| 层 | 技术 | -| -------------------- | --------------------------------------------------------------------------------------------------------- | -| **后端框架** | [Fastify](https://fastify.dev) — 高性能 Node.js 后端框架 | -| **前端框架** | [React 18](https://react.dev) + [Vite](https://vitejs.dev) | -| **语言** | [TypeScript](https://www.typescriptlang.org) — 端到端类型安全 | -| **样式** | [Tailwind CSS v4](https://tailwindcss.com) — 原子化样式框架 | -| **数据库** | SQLite / MySQL / PostgreSQL + [Drizzle ORM](https://orm.drizzle.team) | -| **数据可视化** | [VChart](https://visactor.io/vchart) (@visactor/react-vchart) | -| **定时任务** | [node-cron](https://github.com/node-cron/node-cron) | -| **容器化** | Docker (Alpine) + Docker Compose | -| **测试** | [Vitest](https://vitest.dev) | +| 层 | 技术 | +| -------------------- | ----------------------------------------------------------------- | +| **后端框架** | [Fastify](https://fastify.dev) — 高性能 Node.js 后端框架 | +| **前端框架** | [React 18](https://react.dev) + [Vite](https://vitejs.dev) | +| **语言** | [TypeScript](https://www.typescriptlang.org) — 端到端类型安全 | +| **样式** | [Tailwind CSS v4](https://tailwindcss.com) — 原子化样式框架 | +| **数据库** | SQLite / MySQL / PostgreSQL +[Drizzle ORM](https://orm.drizzle.team) | +| **数据可视化** | [VChart](https://visactor.io/vchart) (@visactor/react-vchart) | +| **定时任务** | [node-cron](https://github.com/node-cron/node-cron) | +| **容器化** | Docker (Debian slim) + Docker Compose | +| **测试** | [Vitest](https://vitest.dev) | --- @@ -288,6 +405,7 @@ npm run dev npm run build # 构建前端 + 后端 npm run build:web # 仅构建前端(Vite) npm run build:server # 仅构建后端(TypeScript) +npm run dist:desktop:mac:intel # 构建 mac Intel (x64) 桌面安装包 npm test # 运行全部测试 npm run test:watch # 监听模式 npm run db:generate # 生成 Drizzle 迁移文件 @@ -349,7 +467,7 @@ Metapi 完全自托管,所有数据(账号、令牌、路由、日志)均 ## ⭐ Star History -[![Star History Chart](https://api.star-history.com/image?repos=cita-777/metapi&type=timeline&legend=top-left&v=123)](https://www.star-history.com/?repos=cita-777%2Fmetapi&type=timeline&legend=top-left) +[![Star History Chart](https://api.star-history.com/svg?repos=cita-777/metapi&type=date&legend=top-left&v=2)](https://www.star-history.com/#cita-777/metapi&type=date&legend=top-left) --- @@ -357,6 +475,6 @@ Metapi 完全自托管,所有数据(账号、令牌、路由、日志)均 **⭐ 如果 Metapi 对你有帮助,给个 Star 就是最大的支持!** -Built with ❤️ by the AI community +``Built with ❤️ by the AI community`` diff --git a/README_EN.md b/README_EN.md index 702e136d..34c78341 100644 --- a/README_EN.md +++ b/README_EN.md @@ -10,6 +10,12 @@ Bring together all your New API / One API / OneHub / DoneHub / Veloera / AnyRout into one API Key, one endpoint, with automatic model discovery, smart routing, and cost optimization.

+

+ + LINUX DO + +

+

GitHub Release @@ -17,6 +23,9 @@ into one API Key, one endpoint, with automatic model discovery, --> GitHub Stars + Ask DeepWiki + Docker Pulls License Node.jsTypeScript +-->Node.jsTypeScript + Deploy on Zeabur + + Deploy to Render +

@@ -36,11 +51,12 @@ into one API Key, one endpoint, with automatic model discovery,

- Docs · - Quick Start · - Deployment · - Configuration · - FAQ · + Docs · + Quick Start · + Deployment · + Configuration · + Client Integration · + FAQ · Contributing

@@ -48,6 +64,21 @@ into one API Key, one endpoint, with automatic model discovery, --- +## 🌐 Live Demo + +> Try Metapi without deploying — full-featured demo instance: + +| | | +|---|---| +| 🔗 **Demo URL** | [metapi-t9od.onrender.com](https://metapi-t9od.onrender.com/) | +| 🔑 **Admin Token** | `123456` | + +> **⚠️ Security Notice**: This is a public demo. **Do NOT enter any real API keys, credentials, or site information.** Data may be reset at any time. + +> **ℹ️ Note**: Demo runs on Render free tier + OpenRouter free models (only `:free` suffixed models available). First visit may take 30-60s to wake up. + +--- + ## About The AI ecosystem is seeing a growing number of aggregation relay stations based on New API / One API and similar projects. Managing balances, model lists, and API keys across multiple sites is scattered and time-consuming. @@ -265,7 +296,7 @@ Alert scenarios: low balance warning, site/account anomalies, check-in failures, ### Lightweight Deployment - **Single Docker container** with a default local data directory, plus optional external MySQL / PostgreSQL runtime DB -- Alpine base image, minimal footprint +- Docker images support `amd64`, `arm64`, and `armv7l` (`linux/arm/v7`) server deployments - Full data import/export for worry-free migration --- @@ -322,12 +353,18 @@ docker run -d --name metapi \ After starting, visit `http://localhost:4000` and log in with your `AUTH_TOKEN`! +> [!NOTE] +> Docker images support `amd64`, `arm64`, and `armv7l` (`linux/arm/v7`) server deployments. +> Current `armv7l` support is limited to server / Docker usage and does not include Electron desktop packaging support. + + > [!IMPORTANT] > Make sure to change `AUTH_TOKEN` and `PROXY_TOKEN` — do not use default values. Data is stored in the `./data` directory and persists across upgrades. > [!TIP] > The initial admin token is the `AUTH_TOKEN` configured at startup. > If running outside Compose without explicitly setting `AUTH_TOKEN`, the default is `change-me-admin-token` (for local debugging only). +> The desktop installer falls into this category on first launch too: if you do not inject `AUTH_TOKEN`, the default admin token is also `change-me-admin-token`. > If you change the admin token in the Settings panel, use the new token for subsequent logins. For Docker Compose, desktop installers, reverse proxy, upgrades, and database options, see [Deployment Guide](docs/deployment.md). @@ -387,46 +424,9 @@ For Docker Compose, desktop installers, reverse proxy, upgrades, and database op | `BALANCE_REFRESH_CRON` | Balance refresh cron expression | `0 * * * *` |
-Smart Routing Parameters - -| Variable | Description | Default | -| --- | --- | --- | -| `ROUTING_FALLBACK_UNIT_COST` | Default unit price when no cost signal | `1` | -| `BASE_WEIGHT_FACTOR` | Base weight factor | `0.5` | -| `VALUE_SCORE_FACTOR` | Value score factor | `0.5` | -| `COST_WEIGHT` | Cost weight in routing selection | `0.4` | -| `BALANCE_WEIGHT` | Balance weight in routing selection | `0.3` | -| `USAGE_WEIGHT` | Usage weight in routing selection | `0.3` | - -
- -
-Notification Channel Configuration +Smart Routing, Notification & Security Configuration -| Variable | Description | Default | -| --- | --- | --- | -| `WEBHOOK_ENABLED` | Enable Webhook notifications | `true` | -| `WEBHOOK_URL` | Webhook push URL | empty | -| `BARK_ENABLED` | Enable Bark push | `true` | -| `BARK_URL` | Bark push URL | empty | -| `SERVERCHAN_ENABLED` | Enable ServerChan notifications | `true` | -| `SERVERCHAN_KEY` | ServerChan SendKey | empty | -| `SMTP_ENABLED` | Enable email notifications | `false` | -| `SMTP_HOST` | SMTP server address | empty | -| `SMTP_PORT` | SMTP port | `587` | -| `SMTP_SECURE` | Use SSL/TLS | `false` | -| `SMTP_USER` / `SMTP_PASS` | SMTP authentication | empty | -| `SMTP_FROM` / `SMTP_TO` | Sender / recipient | empty | -| `NOTIFY_COOLDOWN_SEC` | Same-alert cooldown (seconds) | `300` | - -
- -
-Security Configuration - -| Variable | Description | Default | -| --- | --- | --- | -| `ADMIN_IP_ALLOWLIST` | Admin API IP allowlist (comma-separated) | empty (no restriction) | +See [docs/configuration.md](docs/configuration.md) for full details on smart routing parameters, notification channels (Webhook / Bark / ServerChan / Telegram / SMTP), and security settings (IP allowlist).
@@ -456,7 +456,7 @@ Include `Authorization: Bearer ` in request headers. The global `PROXY_TOKEN` works by default. From `System Settings -> Downstream API Key Strategy` you can create multiple project-level downstream keys with individual configuration: -- Expiration time (ExpireAt) +- Expiration time (expiresAt) - Cost and request limits (MaxCost / MaxRequests) - Model allowlist (SupportedModels, supports exact/glob/re:regex) - Route allowlist (AllowedRouteIds) @@ -491,7 +491,7 @@ For detailed per-client setup, examples, and troubleshooting, see [docs/client-i | **Database** | SQLite / MySQL / PostgreSQL + [Drizzle ORM](https://orm.drizzle.team) | | **Charts** | [VChart](https://visactor.io/vchart) (@visactor/react-vchart) | | **Scheduling** | [node-cron](https://github.com/node-cron/node-cron) | -| **Containerization** | Docker (Alpine) + Docker Compose | +| **Containerization** | Docker (Debian slim) + Docker Compose | | **Testing** | [Vitest](https://vitest.dev) | --- @@ -513,6 +513,7 @@ npm run dev npm run build # Build frontend + backend npm run build:web # Build frontend only (Vite) npm run build:server # Build backend only (TypeScript) +npm run dist:desktop:mac:intel # Build mac Intel (x64) desktop installer npm test # Run all tests npm run test:watch # Watch mode npm run db:generate # Generate Drizzle migration files @@ -575,6 +576,7 @@ If you discover a security issue, please refer to [SECURITY.md](SECURITY.md) and ## Star History [![Star History Chart](https://api.star-history.com/svg?repos=cita-777/metapi&type=date&legend=top-left&v=2)](https://www.star-history.com/#cita-777/metapi&type=date&legend=top-left) + ---
diff --git a/SECURITY.md b/SECURITY.md index b623c683..5aa2edb3 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,37 +1,178 @@ -# Security Policy +# Security Policy / 安全政策 -## Supported Versions +## Overview / 概述 -- The latest release on GitHub. -- The current `main` branch. +The security of Metapi is important to us. This document outlines our security policy and how to report vulnerabilities. -Older tags and ad-hoc forks may receive guidance, but they are not guaranteed security backports. +Metapi 的安全对我们很重要。本文档概述了我们的安全政策以及如何报告漏洞。 -## Reporting a Vulnerability +Since Metapi is a self-hosted meta-aggregation layer that manages sensitive credentials (API keys, account passwords) and proxies AI API requests, security is a critical concern. -Please do not report security issues in public issues, discussions, or pull requests. +由于 Metapi 是一个自托管的元聚合层,管理敏感凭证(API 密钥、账号密码)并代理 AI API 请求,因此安全性至关重要。 -Use one of these channels: +## Supported Versions / 支持的版本 -1. GitHub Security Advisory private report: `https://github.com/cita-777/metapi/security/advisories/new` -2. Email: `cita-777@users.noreply.github.com` with subject prefix `[metapi security]` +We provide security updates for the following versions / 我们为以下版本提供安全更新: -When reporting, include: +| Version / 版本 | Supported / 支持状态 | +| -------------- | -------------------- | +| Latest release / 最新版本 | ✅ Fully supported / 完全支持 | +| `main` branch / `main` 分支 | ✅ Supported / 支持 | +| Older releases / 旧版本 | ⚠️ Best effort / 尽力而为 | +| Forks / 分支 | ❌ Not supported / 不支持 | -- Affected version, commit, or deployment mode -- Affected endpoint, module, or configuration surface -- Reproduction steps or proof-of-concept -- Expected impact and attack preconditions -- Any logs, screenshots, or packets with secrets redacted -- Suggested mitigation or patch ideas, if you already have them +**Recommendation** / **建议**: Always use the latest stable release from [GitHub Releases](https://github.com/cita-777/metapi/releases) or the `main` branch for the most up-to-date security patches. -## Response Process +始终使用 [GitHub Releases](https://github.com/cita-777/metapi/releases) 的最新稳定版本或 `main` 分支以获得最新的安全补丁。 -- We aim to acknowledge private reports within 3 business days. -- We aim to provide an initial triage or follow-up questions within 7 business days. -- We may ask for extra reproduction details, redacted logs, or environment information before confirming severity. -- We coordinate disclosure with the reporter when possible. Please keep the report private until a fix or mitigation is ready. +## Security Considerations / 安全注意事项 -## Handling Public Reports +When deploying Metapi, please consider / 部署 Metapi 时,请考虑: -If a vulnerability is posted publicly by mistake, maintainers may redact sensitive details, convert the report to a private channel, or close the public thread after redirecting the reporter to the private process above. +### Credential Storage / 凭证存储 + +- All sensitive credentials (API keys, passwords) are encrypted at rest in the database / 所有敏感凭证(API 密钥、密码)在数据库中静态加密存储 +- Use strong `AUTH_TOKEN` and `PROXY_TOKEN` values / 使用强 `AUTH_TOKEN` 和 `PROXY_TOKEN` 值 +- Never commit `.env` files or expose tokens in logs / 切勿提交 `.env` 文件或在日志中暴露令牌 + +### Network Security / 网络安全 + +- Deploy behind HTTPS/TLS in production / 在生产环境中部署在 HTTPS/TLS 后面 +- Use `ADMIN_IP_ALLOWLIST` to restrict admin access / 使用 `ADMIN_IP_ALLOWLIST` 限制管理员访问 +- Consider firewall rules to limit access to port 4000 / 考虑使用防火墙规则限制对端口 4000 的访问 + +### Database Security / 数据库安全 + +- Secure your database with strong credentials / 使用强凭证保护您的数据库 +- Regularly backup the `data/` directory / 定期备份 `data/` 目录 +- For production, consider using MySQL/PostgreSQL instead of SQLite / 对于生产环境,考虑使用 MySQL/PostgreSQL 而不是 SQLite + +### Docker Security / Docker 安全 + +- Keep Docker images up to date / 保持 Docker 镜像最新 +- Use volume mounts carefully to avoid exposing sensitive data / 谨慎使用卷挂载以避免暴露敏感数据 +- Run containers with minimal privileges / 以最小权限运行容器 + +## Reporting a Vulnerability / 报告漏洞 + +**⚠️ Please do NOT report security vulnerabilities through public GitHub issues, discussions, or pull requests.** + +**⚠️ 请勿通过公开的 GitHub issue、讨论或 pull request 报告安全漏洞。** + +### Reporting Channels / 报告渠道 + +Use one of these private channels / 使用以下私密渠道之一: + +1. **GitHub Security Advisory** (Preferred) / **GitHub 安全公告**(首选) + - Go to: https://github.com/cita-777/metapi/security/advisories/new + - This allows for coordinated disclosure and CVE assignment / 这允许协调披露和 CVE 分配 + +2. **Email** / **邮件** + - Send to: `cita-777@users.noreply.github.com` + - Subject: `[Metapi Security] ` / 主题:`[Metapi Security] <简要描述>` + +### What to Include / 应包含的内容 + +To help us understand and address the issue quickly, please include / 为了帮助我们快速理解和解决问题,请包含: + +- **Description** / **描述**: Clear description of the vulnerability / 漏洞的清晰描述 +- **Impact** / **影响**: What an attacker could achieve / 攻击者可能实现的目标 +- **Affected versions** / **受影响的版本**: Version, commit hash, or deployment mode / 版本、提交哈希或部署模式 +- **Affected components** / **受影响的组件**: Specific endpoints, modules, or configuration / 特定端点、模块或配置 +- **Reproduction steps** / **复现步骤**: Step-by-step instructions to reproduce / 逐步复现说明 +- **Proof of concept** / **概念验证**: Code, screenshots, or logs (with secrets redacted) / 代码、截图或日志(删除敏感信息) +- **Attack preconditions** / **攻击前提条件**: Required access level, network position, etc. / 所需访问级别、网络位置等 +- **Suggested fix** / **建议修复**: If you have ideas for mitigation or patches / 如果您有缓解措施或补丁的想法 + +### Example Report / 报告示例 + +``` +Subject: [Metapi Security] SQL Injection in account search + +Description: +The account search endpoint is vulnerable to SQL injection through the +'name' parameter, allowing unauthorized database access. + +Impact: +An authenticated attacker could extract all account credentials from +the database. + +Affected Version: +v1.2.2 and earlier + +Reproduction: +1. Login to Metapi admin panel +2. Navigate to /api/accounts/search?name=' OR '1'='1 +3. Observe all accounts returned + +Proof of Concept: +[Screenshot attached with sensitive data redacted] + +Suggested Fix: +Use parameterized queries instead of string concatenation in +src/server/routes/api/accounts.ts:45 +``` + +## Response Process / 响应流程 + +Our security response process / 我们的安全响应流程: + +1. **Acknowledgment** / **确认**: We aim to acknowledge your report within **3 business days** / 我们力求在 **3 个工作日**内确认您的报告 + +2. **Initial Triage** / **初步分类**: We will provide initial assessment or follow-up questions within **7 business days** / 我们将在 **7 个工作日**内提供初步评估或后续问题 + +3. **Investigation** / **调查**: We may request additional details, logs, or reproduction steps / 我们可能会要求额外的细节、日志或复现步骤 + +4. **Fix Development** / **修复开发**: We will develop and test a fix / 我们将开发并测试修复方案 + +5. **Coordinated Disclosure** / **协调披露**: We will coordinate disclosure timing with you / 我们将与您协调披露时间 + - Please keep the vulnerability confidential until we release a fix / 请在我们发布修复之前对漏洞保密 + - We will credit you in the security advisory (unless you prefer to remain anonymous) / 我们将在安全公告中致谢您(除非您希望保持匿名) + +6. **Release** / **发布**: We will release a patched version and publish a security advisory / 我们将发布修补版本并发布安全公告 + +### Severity Assessment / 严重性评估 + +We use the following severity levels / 我们使用以下严重性级别: + +- **Critical** / **严重**: Remote code execution, credential theft, data breach / 远程代码执行、凭证盗窃、数据泄露 +- **High** / **高**: Authentication bypass, privilege escalation / 身份验证绕过、权限提升 +- **Medium** / **中**: Information disclosure, denial of service / 信息泄露、拒绝服务 +- **Low** / **低**: Minor information leaks, configuration issues / 轻微信息泄露、配置问题 + +## Public Disclosure / 公开披露 + +If a vulnerability is accidentally posted publicly / 如果漏洞被意外公开发布: + +1. We will immediately assess the risk / 我们将立即评估风险 +2. We may redact sensitive details from the public post / 我们可能会从公开帖子中删除敏感细节 +3. We will redirect the reporter to this private process / 我们将把报告者重定向到此私密流程 +4. We will expedite the fix and release process / 我们将加快修复和发布流程 + +## Security Updates / 安全更新 + +Security updates will be announced through / 安全更新将通过以下方式公布: + +- [GitHub Security Advisories](https://github.com/cita-777/metapi/security/advisories) +- [GitHub Releases](https://github.com/cita-777/metapi/releases) with `[SECURITY]` tag / 带有 `[SECURITY]` 标签 +- Project README and documentation / 项目 README 和文档 + +Subscribe to repository notifications to stay informed / 订阅仓库通知以保持了解。 + +## Bug Bounty / 漏洞赏金 + +We currently do not offer a paid bug bounty program. However, we deeply appreciate security researchers who responsibly disclose vulnerabilities and will publicly acknowledge your contribution (with your permission). + +我们目前不提供付费漏洞赏金计划。但是,我们非常感谢负责任地披露漏洞的安全研究人员,并将公开致谢您的贡献(经您许可)。 + +## Questions / 问题 + +If you have questions about this security policy, please contact `cita-777@users.noreply.github.com`. + +如果您对本安全政策有疑问,请联系 `cita-777@users.noreply.github.com`。 + +--- + +**Thank you for helping keep Metapi and its users safe!** + +**感谢您帮助保护 Metapi 及其用户的安全!** diff --git a/TEST_ENVIRONMENT_SETUP.md b/TEST_ENVIRONMENT_SETUP.md new file mode 100644 index 00000000..e544f5f5 --- /dev/null +++ b/TEST_ENVIRONMENT_SETUP.md @@ -0,0 +1,208 @@ +# 本地测试环境搭建指南 + +## 目录结构 + +``` +D:\Code\Projects\Metapi\ +├── metapi # 主仓库(upstream) +├── metapi-routing-ux-optimization # 开发 worktree (模型白名单功能) +├── metapi-upstream-latest # 其他 worktree +└── ... +``` + +## 快速启动测试服务器 + +### 1. 进入开发 worktree + +```bash +cd D:/Code/Projects/Metapi/metapi-routing-ux-optimization +``` + +### 2. 安装依赖(首次运行) + +```bash +pnpm install +``` + +### 3. 构建项目 + +```bash +pnpm build +``` + +### 4. 启动测试服务器 + +```bash +DATA_DIR="./tmp/test-db" node dist/server/index.js +``` + +服务器将在 http://localhost:4000 启动 + +**测试Token:** `test-admin-token` + +### 5. 访问前端 + +打开浏览器访问: http://localhost:4000 + +登录使用 Token: `test-admin-token` + +## 测试数据准备 + +### 方式1: 导入真实站点 + +在前端"站点管理"中添加真实的 new-api 站点: +- 站点URL: 真实的 new-api 地址 +- 平台: new-api +- 添加账号和token + +然后触发模型发现: +```bash +curl -X POST -H "Authorization: Bearer test-admin-token" \ + -H "Content-Type: application/json" \ + -d '{"refreshModels": true, "wait": true}' \ + http://localhost:4000/api/routes/rebuild +``` + +### 方式2: 使用测试脚本(可选) + +```bash +DATA_DIR="./tmp/test-db" node scripts/seed-test-data.js +``` + +## 测试模型白名单功能 + +### 1. 进入设置页面 + +点击左侧导航"设置",滚动到"全局模型白名单"卡片 + +### 2. 测试功能 + +#### 添加模型到白名单 +- **方式1**: 在输入框输入模型名称,按回车 +- **方式2**: 点击"可用模型列表"中的模型 + +#### 删除模型 +- 点击已选模型徽章上的 × 按钮 + +#### 保存配置 +- 点击"保存模型白名单"按钮 +- 系统自动触发路由重建 + +### 3. 验证效果 + +#### 查看路由 +进入"令牌路由"页面,确认只有白名单中的模型有路由 + +#### 查看模型候选 +```bash +curl -H "Authorization: Bearer test-admin-token" \ + http://localhost:4000/api/models/token-candidates | python -m json.tool +``` + +应该只返回白名单中的模型 + +### 4. API 测试命令 + +#### 查看当前白名单 +```bash +curl -H "Authorization: Bearer test-admin-token" \ + http://localhost:4000/api/settings/runtime | python -m json.tool | grep -A 5 "globalAllowedModels" +``` + +#### 设置白名单 +```bash +curl -X PUT \ + -H "Authorization: Bearer test-admin-token" \ + -H "Content-Type: application/json" \ + -d '{"globalAllowedModels": ["gpt-4", "gpt-4o"]}' \ + http://localhost:4000/api/settings/runtime | python -m json.tool +``` + +#### 清空白名单(允许所有模型) +```bash +curl -X PUT \ + -H "Authorization: Bearer test-admin-token" \ + -H "Content-Type: application/json" \ + -d '{"globalAllowedModels": []}' \ + http://localhost:4000/api/settings/runtime | python -m json.tool +``` + +## 停止测试服务器 + +### 查找进程 +```bash +# Windows +netstat -ano | grep ":4000" | grep "LISTENING" + +# Linux/Mac +lsof -i :4000 +``` + +### 停止进程 +```bash +# Windows (替换 PID) +taskkill //F //PID + +# Linux/Mac +kill +``` + +## 开发工作流 + +### 修改代码后重新构建 + +```bash +pnpm build +``` + +### 运行测试 + +```bash +pnpm test +``` + +### 提交代码 + +```bash +git add . +git commit -m "feat: 实现模型白名单功能" +git push origin routing-ux-optimization +``` + +## 常见问题 + +### Q: 路由列表为空? +A: 检查 token_model_availability 表是否有数据,触发模型发现重新加载 + +### Q: 白名单保存后路由消失? +A: 正常现象,白名单会过滤掉不在列表中的模型路由 + +### Q: 如何恢复所有模型? +A: 清空白名单(设置为空数组 [])即可 + +## 数据库位置 + +测试数据库存储在: `./tmp/test-db/hub.db` + +查看数据库: +```bash +sqlite3 ./tmp/test-db/hub.db +``` + +常用查询: +```sql +-- 查看所有站点 +SELECT id, name, url, platform FROM sites WHERE status = 'active'; + +-- 查看所有账号 +SELECT id, site_id, username, status FROM accounts; + +-- 查看所有token +SELECT id, account_id, name, value_status FROM account_tokens; + +-- 查看模型可用性 +SELECT COUNT(*) FROM token_model_availability; + +-- 查看路由 +SELECT COUNT(*) FROM token_routes; +``` \ No newline at end of file diff --git a/build/desktop-icon.png b/build/desktop-icon.png new file mode 100644 index 00000000..00a365e7 Binary files /dev/null and b/build/desktop-icon.png differ diff --git a/docker/Dockerfile b/docker/Dockerfile index 692340a4..9350db66 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -1,5 +1,5 @@ # syntax=docker/dockerfile:1.7 -FROM node:20-bookworm-slim AS builder +FROM node:22-bookworm-slim AS builder WORKDIR /app @@ -10,13 +10,14 @@ RUN apt-get update \ ENV PYTHON=/usr/bin/python3 COPY package.json package-lock.json ./ -RUN --mount=type=cache,target=/root/.npm npm ci --no-audit --no-fund +RUN --mount=type=cache,target=/root/.npm npm ci --ignore-scripts --no-audit --no-fund +RUN npm rebuild esbuild sharp better-sqlite3 --no-audit --no-fund COPY . . -RUN npm run build +RUN npm run build:web && npm run build:server RUN --mount=type=cache,target=/root/.npm npm prune --omit=dev --no-audit --no-fund -FROM node:20-bookworm-slim +FROM node:22-bookworm-slim WORKDIR /app diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 360a8f46..99d4a6aa 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -2,7 +2,7 @@ services: metapi: image: 1467078763/metapi:latest ports: - - "4000:4000" + - "127.0.0.1:4000:4000" volumes: - ./data:/app/data environment: diff --git a/docs/.vitepress/config.test.ts b/docs/.vitepress/config.test.ts new file mode 100644 index 00000000..0e60588f --- /dev/null +++ b/docs/.vitepress/config.test.ts @@ -0,0 +1,96 @@ +import { existsSync, globSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; +import config from './config'; + +const getAliasEntry = (aliasConfig: unknown, specifier: string) => { + if (!aliasConfig) return undefined; + + if (Array.isArray(aliasConfig)) { + return aliasConfig.find( + (entry) => + entry && + typeof entry === 'object' && + 'find' in entry && + ((typeof entry.find === 'string' && entry.find === specifier) || + (entry.find instanceof RegExp && entry.find.test(specifier))), + ); + } + + if (typeof aliasConfig === 'object' && aliasConfig !== null && specifier in aliasConfig) { + return { + find: specifier, + replacement: String((aliasConfig as Record)[specifier]), + }; + } + + return undefined; +}; + +const getAlias = (aliasConfig: unknown, specifier: string): string | undefined => { + const aliasEntry = getAliasEntry(aliasConfig, specifier); + return aliasEntry && typeof aliasEntry === 'object' && 'replacement' in aliasEntry ? String(aliasEntry.replacement) : undefined; +}; + +const repoRoot = fileURLToPath(new URL('../..', import.meta.url)); + +const getExpectedEntry = (hoistedRelativePath: string, pnpmPattern: string) => { + const hoistedEntry = resolve(repoRoot, hoistedRelativePath); + if (existsSync(hoistedEntry)) return hoistedEntry; + + const [pnpmEntry] = globSync(resolve(repoRoot, pnpmPattern)); + return pnpmEntry; +}; + +describe('docs vitepress config', () => { + it('ships copied main-app favicon assets for docs', () => { + expect(existsSync(resolve(repoRoot, 'docs/public/favicon.png'))).toBe(true); + expect(existsSync(resolve(repoRoot, 'docs/public/favicon-64.png'))).toBe(true); + expect(existsSync(resolve(repoRoot, 'docs/public/favicon.ico'))).toBe(true); + }); + + it('ships a fallback favicon.ico for browsers that probe the default path', () => { + expect(existsSync(resolve(repoRoot, 'docs/public/favicon.ico'))).toBe(true); + }); + + it('declares the main-app favicon assets in docs head tags', () => { + const iconLinks = + config.head?.filter( + (entry) => + entry[0] === 'link' && + typeof entry[1] === 'object' && + entry[1] !== null && + 'rel' in entry[1] && + (entry[1].rel === 'icon' || entry[1].rel === 'shortcut icon'), + ) ?? []; + + expect(iconLinks.some((entry) => typeof entry[1] === 'object' && entry[1] !== null && 'href' in entry[1] && entry[1].href === '/favicon.png')).toBe(true); + expect(iconLinks.some((entry) => typeof entry[1] === 'object' && entry[1] !== null && 'href' in entry[1] && entry[1].href === '/favicon-64.png')).toBe(true); + expect(iconLinks.some((entry) => typeof entry[1] === 'object' && entry[1] !== null && 'href' in entry[1] && entry[1].href === '/favicon.ico')).toBe(true); + }); + + it('aliases dayjs to the ESM entry for mermaid browser compatibility', () => { + const aliasConfig = config.vite?.resolve?.alias; + const alias = getAlias(aliasConfig, 'dayjs'); + const aliasEntry = getAliasEntry(aliasConfig, 'dayjs'); + + expect(alias).toBe(getExpectedEntry('node_modules/dayjs/esm/index.js', 'node_modules/.pnpm/dayjs@*/node_modules/dayjs/esm/index.js')); + expect(alias && existsSync(alias)).toBe(true); + expect(Array.isArray(aliasConfig)).toBe(true); + expect(aliasEntry && typeof aliasEntry === 'object' && 'find' in aliasEntry && aliasEntry.find instanceof RegExp).toBe(true); + expect(aliasEntry && typeof aliasEntry === 'object' && 'find' in aliasEntry && aliasEntry.find instanceof RegExp && aliasEntry.find.test('dayjs/plugin/duration.js')).toBe(false); + }); + + it('aliases sanitize-url to a browser-loadable source entry', () => { + const alias = getAlias(config.vite?.resolve?.alias, '@braintree/sanitize-url'); + + expect(alias).toBe( + getExpectedEntry( + 'node_modules/@braintree/sanitize-url/src/index.ts', + 'node_modules/.pnpm/@braintree+sanitize-url@*/node_modules/@braintree/sanitize-url/src/index.ts', + ), + ); + expect(alias && existsSync(alias)).toBe(true); + }); +}); diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index 502bb1dc..e79909ce 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -1,20 +1,63 @@ +import { existsSync, globSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; import { defineConfig } from 'vitepress'; import { withMermaid } from 'vitepress-plugin-mermaid'; +const repoRoot = fileURLToPath(new URL('../..', import.meta.url)); +const resolveDependencyEntry = (hoistedRelativePath: string, pnpmPattern: string) => { + const hoistedEntry = resolve(repoRoot, hoistedRelativePath); + if (existsSync(hoistedEntry)) return hoistedEntry; + + const [pnpmEntry] = globSync(resolve(repoRoot, pnpmPattern)); + return pnpmEntry; +}; + +const dayjsEsmEntry = resolveDependencyEntry( + 'node_modules/dayjs/esm/index.js', + 'node_modules/.pnpm/dayjs@*/node_modules/dayjs/esm/index.js', +); +const sanitizeUrlSourceEntry = resolveDependencyEntry( + 'node_modules/@braintree/sanitize-url/src/index.ts', + 'node_modules/.pnpm/@braintree+sanitize-url@*/node_modules/@braintree/sanitize-url/src/index.ts', +); + +if (!dayjsEsmEntry) { + throw new Error('Unable to resolve the dayjs ESM entry required by vitepress-plugin-mermaid.'); +} + +if (!sanitizeUrlSourceEntry) { + throw new Error('Unable to resolve the sanitize-url source entry required by vitepress-plugin-mermaid.'); +} + export default withMermaid( defineConfig({ lang: 'zh-CN', title: 'Metapi 文档', description: 'Metapi 使用文档、FAQ 与维护协作指南', + head: [ + ['link', { rel: 'icon', type: 'image/png', sizes: '32x32', href: '/favicon.png' }], + ['link', { rel: 'icon', type: 'image/png', sizes: '64x64', href: '/favicon-64.png' }], + ['link', { rel: 'shortcut icon', href: '/favicon.ico' }], + ], cleanUrls: true, lastUpdated: true, ignoreDeadLinks: true, + vite: { + resolve: { + alias: [ + { find: /^dayjs$/, replacement: dayjsEsmEntry }, + { find: /^@braintree\/sanitize-url$/, replacement: sanitizeUrlSourceEntry }, + ], + }, + }, themeConfig: { siteTitle: 'Metapi Docs', logo: '/logos/logo-icon-512.png', nav: [ { text: '首页', link: '/' }, { text: '快速上手', link: '/getting-started' }, + { text: '上游接入', link: '/upstream-integration' }, { text: 'FAQ', link: '/faq' }, { text: '文档维护', link: '/README' }, { text: '项目主页', link: 'https://github.com/cita-777/metapi' }, @@ -31,6 +74,7 @@ export default withMermaid( { text: '使用与运维', items: [ + { text: '上游接入', link: '/upstream-integration' }, { text: '配置说明', link: '/configuration' }, { text: '客户端接入', link: '/client-integration' }, { text: '运维手册', link: '/operations' }, diff --git a/docs/README.md b/docs/README.md index baaad0d1..8af72617 100644 --- a/docs/README.md +++ b/docs/README.md @@ -28,12 +28,14 @@ npm run docs:build |------|--------|------------| | 对外第一印象、产品定位、核心入口 | [文档首页](/) | 需要调整公开落地页信息架构、首页 CTA 或首屏导航时 | | 新用户部署与首条请求 | [快速上手](./getting-started.md) | 新安装流程、默认端口、首次调用步骤变化时 | +| 上游平台选择与接法 | [上游接入](./upstream-integration.md) | 平台支持范围、默认连接分段、自动识别规则变化时 | | 生产部署与回滚 | [部署指南](./deployment.md) | Docker Compose、反向代理、升级回滚策略变更时 | | 环境变量、参数和配置项 | [配置说明](./configuration.md) | 新增配置、默认值变化、兼容行为变化时 | | 客户端与工具接入 | [客户端接入](./client-integration.md) | Open WebUI、Cherry Studio、Cursor 等接入方式变化时 | | 运维排障与日常维护 | [运维手册](./operations.md) / [常见问题](./faq.md) | 新排障案例、备份恢复、健康检查、典型报错变化时 | | FAQ / 教程协作沉淀 | [FAQ/教程贡献规范](./community/faq-tutorial-guidelines.md) | 需要新增教程、FAQ 模板、内容提交流程时 | | 仓库目录与组织约定 | [目录规范](./project-structure.md) | 目录结构、归档策略或命名约定变化时 | +| 工程守则与漂移治理 | [Harness Engineering](./engineering/harness-engineering.md) | 需要更新仓库级黄金原则、自动巡检范围或垃圾回收流程时 | ## 维护约定 diff --git a/docs/client-integration.md b/docs/client-integration.md index 4e28ba96..28fcc385 100644 --- a/docs/client-integration.md +++ b/docs/client-integration.md @@ -13,10 +13,13 @@ Metapi 暴露标准 OpenAI / Claude 兼容接口,下游客户端通常只需 | 配置项 | 值 | |--------|-----| | **Base URL** | 按客户端字段行为填写:会自动补 `/v1` 的字段填 `https://your-domain.com`;要求完整 API URL 的字段填 `https://your-domain.com/v1` | -| **API Key** | 你设置的 `PROXY_TOKEN` 值,或创建的下游 API Key | +| **API Key** | 你设置的 `PROXY_TOKEN` 值,或创建的[下游 API Key](./configuration.md#下游-api-key-策略) | 模型列表自动从 `GET /v1/models` 获取,无需手动配置。 +> [!TIP] +> 如果不确定客户端是否自动补 `/v1`,先试根域名,报 404 再改成带 `/v1` 的完整路径。 + ## 支持的接口 | 接口 | 方法 | 说明 | @@ -82,9 +85,19 @@ Metapi 暴露标准 OpenAI / Claude 兼容接口,下游客户端通常只需 } ``` -> 说明:`ANTHROPIC_BASE_URL` 这里填根域名,不要手动拼接 `/v1`;不同版本客户端可能读取 `ANTHROPIC_API_KEY` 或 `ANTHROPIC_AUTH_TOKEN`,建议同时设置。 -> 上述变量由 Claude Code 客户端读取,属于客户端行为开关; -> `metapi` 服务端无法替代客户端自行设置这些变量,只能处理已经发来的请求。 +**环境变量说明:** + +| 变量 | 作用 | +|------|------| +| `ANTHROPIC_BASE_URL` | 指向 Metapi 的根域名,Claude Code 会自动拼接 `/v1/messages` | +| `ANTHROPIC_API_KEY` | 认证令牌,填 `PROXY_TOKEN` 值 | +| `ANTHROPIC_AUTH_TOKEN` | 部分版本读取此变量而非 `ANTHROPIC_API_KEY`,建议两个都设 | +| `CLAUDE_CODE_ATTRIBUTION_HEADER` | 设为 `0` 禁用 Attribution 头,避免部分上游不支持该头导致报错 | +| `CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC` | 设为 `1` 减少非必要请求(遥测等),降低 Token 消耗 | + +> [!IMPORTANT] +> `ANTHROPIC_BASE_URL` 填根域名即可,**不要**手动拼接 `/v1`。 +> 上述变量由 Claude Code 客户端读取,属于客户端行为开关;Metapi 服务端只能处理已经发来的请求。 ### Codex CLI @@ -115,22 +128,23 @@ base_url = "https://your-domain.com/v1" ### 其他客户端 -所有支持 OpenAI API 格式的客户端均可接入,只需找到 Base URL 和 API Key 的配置位置即可:如果客户端会自动补 `/v1`,填根域名;如果字段要求完整 OpenAI API URL,就填 `https://your-domain.com/v1`。 +所有支持 OpenAI API 格式的客户端均可接入,只需找到 Base URL 和 API Key 的配置位置即可: -## 下游 API Key 策略 +| 你的客户端... | 填法 | +|--------------|------| +| 有 "Base URL" 字段,会自动补 `/v1` | 填 `https://your-domain.com` | +| 有 "OpenAI API URL" 字段,要求完整路径 | 填 `https://your-domain.com/v1` | +| 有 "Anthropic" 选项 | 填 `https://your-domain.com`,Metapi 自动处理 `/v1/messages` | +| 只能填 API Key | 填 `PROXY_TOKEN` 值即可 | -除了全局 `PROXY_TOKEN`,你还可以在 **系统设置 → 下游 API Key 策略** 中创建多个项目级下游 Key,每个 Key 可独立配置: +> [!TIP] +> 不确定选哪种?先用 `/v1/models` 测试连通性,再配置模型。 -| 配置项 | 说明 | -|--------|------| -| **过期时间**(ExpiresAt) | Key 到期后自动失效 | -| **费用上限**(MaxCost) | 累计消费超限后拒绝请求 | -| **请求上限**(MaxRequests) | 累计请求数超限后拒绝请求 | -| **模型白名单**(SupportedModels) | 限制可用模型,支持 exact / glob / `re:regex` | -| **路由白名单**(AllowedRouteIds) | 限制可走的路由规则 | -| **站点倍率**(SiteWeightMultipliers) | 按站点调整路由权重,控制上游偏好 | +## 下游 API Key 策略 + +除了全局 `PROXY_TOKEN`,你还可以创建多个项目级下游 Key,每个 Key 可独立配置过期时间、费用/请求上限、模型白名单、路由白名单和站点倍率,适用于多团队/多项目共用一个 Metapi 实例的场景。 -适用于多团队/多项目共用一个 Metapi 实例但需要独立计量和权限控制的场景。 +详细字段说明见 [配置说明 → 下游 API Key 策略](./configuration.md#下游-api-key-策略)。 --- @@ -164,29 +178,11 @@ curl -sS https://your-domain.com/v1/chat/completions \ ## 常见问题 -### 流式响应异常 - -如果非流式正常但流式异常,原因几乎都是反向代理配置问题: - -1. Nginx 未设置 `proxy_buffering off` -2. CDN 或中间层缓存了 SSE 响应 -3. 中间层改写了 `text/event-stream` Content-Type - -参考 [部署指南 → Nginx 配置](./deployment.md#nginx) 解决。 - -### 模型列表为空 - -- 检查是否已添加站点和账号 -- 检查账号是否处于 `healthy` 状态 -- 检查是否已同步 Token -- 在管理后台手动触发「刷新模型」 - -### 客户端提示 401 / 403 - -- 确认使用的是 `PROXY_TOKEN` 而非 `AUTH_TOKEN` -- 确认反向代理透传了 `Authorization` 请求头 +遇到流式响应异常、模型列表为空、客户端提示 401/403 等问题,请参阅 [常见问题 FAQ](./faq.md)。 ## 下一步 -- [配置说明](./configuration.md) — 环境变量详解 +- [配置说明](./configuration.md) — 环境变量详解与下游 API Key 策略 +- [上游接入](./upstream-integration.md) — 添加和管理上游平台 +- [运维手册](./operations.md) — 日志排查与健康检查 - [常见问题](./faq.md) — 更多故障排查 diff --git a/docs/configuration.md b/docs/configuration.md index 15d2fee6..2019fa8f 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -87,12 +87,19 @@ Metapi 的路由引擎按多因子加权选择最优通道。 ### 路由预设建议 -| 场景 | COST_WEIGHT | BALANCE_WEIGHT | USAGE_WEIGHT | -|------|:-----------:|:--------------:|:------------:| -| **成本优先** | 0.7 | 0.2 | 0.1 | -| **均衡(默认)** | 0.4 | 0.3 | 0.3 | -| **稳定优先** | 0.2 | 0.5 | 0.3 | -| **轮转均匀** | 0.1 | 0.1 | 0.8 | +| 场景 | COST_WEIGHT | BALANCE_WEIGHT | USAGE_WEIGHT | 说明 | +|------|:-----------:|:--------------:|:------------:|------| +| **成本优先** | 0.7 | 0.2 | 0.1 | 适合预算敏感场景,总是选最便宜的通道 | +| **均衡(默认)** | 0.4 | 0.3 | 0.3 | 兼顾成本、余额和负载均衡,推荐大多数用户 | +| **稳定优先** | 0.2 | 0.5 | 0.3 | 适合生产环境,优先选余额充足的通道避免中断 | +| **轮转均匀** | 0.1 | 0.1 | 0.8 | 适合测试场景,尽量让每个通道都被使用到 | + +**调参建议:** + +- 如果某个站点总是被选中导致余额很快耗尽 → 调高 `BALANCE_WEIGHT` +- 如果你有几个很便宜的站点想优先用 → 调高 `COST_WEIGHT` +- 如果你想让请求均匀分散到所有站点 → 调高 `USAGE_WEIGHT` +- 如果你需要对特定项目偏好某个站点 → 使用[下游 API Key 的 `siteWeightMultipliers`](#下游-api-key-策略) 而非调全局权重 ## 代理日志保留 @@ -169,8 +176,9 @@ Metapi 的路由引擎按多因子加权选择最优通道。 - **个人聊天**:向你的 Bot 发送任意消息,然后访问 `https://api.telegram.org/bot<你的Token>/getUpdates`,在返回的 JSON 中找到 `chat.id`,或者在 Telegram 搜索 @userinfobot 或是 @getmyid_bot。点击 Start,它会立刻回复一串数字(通常是 9 到 10 位)。 - **群组**:将 Bot 邀请进群组,在群内发送消息后同样通过 `getUpdates` 接口获取群组 Chat ID(通常为负数,如 `-1001234567890`) - **频道**:使用频道用户名,如 `@your_channel`(需先将 Bot 添加为频道管理员) -3. **填入配置**:将获取的 Token 和 Chat ID 填入环境变量或在管理后台「通知设置」页面中配置 -4. **测试**:保存后点击页面上的「测试通知」按钮验证是否收到消息 +3. **填入配置**:将获取的 Token 和 Chat ID 填入管理后台「通知设置」页面 +4. **大陆服务器反代**:如果服务器无法直连 Telegram,可在「通知设置」里填写 `Telegram API Base URL`,例如 `https://your-proxy.example.com`,Metapi 将请求 `https://your-proxy.example.com/bot/sendMessage` +5. **测试**:保存后点击页面上的「测试通知」按钮验证是否收到消息 ### SMTP 邮件 @@ -205,7 +213,18 @@ Metapi 的路由引擎按多因子加权选择最优通道。 > `TOKEN_ROUTER_CACHE_TTL_MS`、`PROXY_LOG_RETENTION_DAYS`、`PROXY_LOG_RETENTION_PRUNE_INTERVAL_MINUTES` 当前仍属于部署级环境变量,不在后台运行时设置里单独维护。 +## 站点公告 + +管理后台新增了「站点公告」页面,用于保存和浏览 Metapi 已同步到本地的上游公告记录。 + +- 首次发现的上游公告会写入站内通知,并按现有通知渠道外发一次 +- 后续重复同步只更新本地公告记录,不会重复外发同一条公告 +- 当前支持的上游公告来源包括 `new-api`、`done-hub` 与 `sub2api` +- 「清空公告」只删除 Metapi 本地保存的公告记录,不会修改上游站点数据 + ## 下一步 - [部署指南](./deployment.md) — Docker Compose 与反向代理 - [客户端接入](./client-integration.md) — 对接下游应用 +- [上游接入](./upstream-integration.md) — 添加和管理上游平台 +- [运维手册](./operations.md) — 备份、日志与健康检查 diff --git a/docs/deployment.md b/docs/deployment.md index d3ba7542..9e11788c 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -9,7 +9,8 @@ | 场景 | 推荐方式 | 对外访问方式 | 数据位置 | |------|----------|--------------|----------| | 云服务器 / NAS / 家用主机长期运行 | Docker / Docker Compose / Zeabur | 固定服务地址,例如 `http://your-host:4000` 或反向代理域名 | 你挂载的 `DATA_DIR` / 持久化卷 | -| 个人电脑本地使用 | 桌面版安装包 | 桌面窗口;如需本机客户端直连,使用日志中打印的 `http://127.0.0.1:` | Electron `app.getPath('userData')/data` | +| 免费云部署(24h 在线) | Render + TiDB + UptimeRobot | Render 分配的 `.onrender.com` 域名或自定义域名 | TiDB Serverless(外部 MySQL 数据库) | +| 个人电脑本地使用 | 桌面版安装包 | 桌面窗口;默认本机客户端直连使用 `http://127.0.0.1:4000`,局域网客户端可使用当前机器 IP + `4000`;如有覆盖则按 `METAPI_DESKTOP_SERVER_PORT` 的实际端口访问 | Electron `app.getPath('userData')/data` | | 二次开发 / 调试 | 本地开发 | 前端 `http://localhost:5173`,后端默认 `http://localhost:4000` | 仓库内 `./data` 或自定义 `DATA_DIR` | > [!NOTE] @@ -44,6 +45,89 @@ --- +## Render 一键部署(免费 24h 运行) + + + Deploy to Render + + +通过 **Render + TiDB + UptimeRobot** 组合,可以实现 **完全免费的 24 小时持续运行**: + +| 组件 | 作用 | 免费额度 | +|------|------|----------| +| [Render](https://render.com) | 运行 Metapi 容器 | Free Web Service(750 小时/月,闲置 15 分钟自动休眠) | +| [TiDB Serverless](https://tidbcloud.com) | MySQL 兼容数据库,替代 SQLite 实现数据持久化 | 5 GiB 存储 + 5000 万 Request Units/月 | +| [UptimeRobot](https://uptimerobot.com) | 每 5 分钟 ping 一次,防止 Render 免费实例休眠 | 50 个免费监控 | + +> [!IMPORTANT] +> Render 免费版 **不支持持久化磁盘**,容器重启后本地文件会丢失。因此 **必须使用外部数据库**(推荐 TiDB Serverless),不能使用默认的 SQLite。 + +### 步骤 1:注册 TiDB Serverless 并获取连接串 + +1. 前往 [TiDB Cloud](https://tidbcloud.com) 注册账号 +2. 创建一个 **Serverless** 集群(免费) +3. 在集群概览页点击 **Connect**,在弹出的面板中: + - **Connection Type**: Public + - **Database**: ⚠️ **必须改为 `test`**(默认是 `sys`,这是系统库,不允许建表!) + - 点击 **Generate Password** 生成密码并妥善保存 +4. 使用面板中显示的参数拼接 `DB_URL`: + + ``` + mysql://:@:4000/test?ssl={"rejectUnauthorized":true} + ``` + + > ⚠️ 注意:``、``、`` 从 Connect 面板中获取,**数据库名必须用 `test` 而非默认的 `sys`**。 + +> [!TIP] +> 这里只是以TiDB作为示例,你也可以使用其他提供免费额度的云数据库方案(如 Neon、Supabase 等),只需将 `DB_TYPE` 设为对应的 `mysql` 或 `postgres`,并填入正确的连接串即可。什么?你不会其他的?把步骤复制给Gemini问他怎么改。 + +### 步骤 2:部署到 Render + +**方式一:一键部署(推荐)** + +1. 点击上方 **Deploy to Render** 按钮 +2. 如果你 Fork 了仓库,也可以使用你自己的仓库地址 +3. 在 Render 界面中填写环境变量(见下表) + +**方式二:手动创建** + +1. 在 [Render Dashboard](https://dashboard.render.com) 点击 **New → Web Service** +2. 连接你的 GitHub 仓库(或使用公开仓库地址 `https://github.com/cita-777/metapi`) +3. 配置: + - **Environment**: Docker + - **Dockerfile Path**: `./docker/Dockerfile` + - **Docker Build Context**: `.`(仓库根目录) + - **Instance Type**: Free +4. 添加环境变量(见下表) + +### 环境变量配置 + +| 变量 | 说明 | 示例值 | +|------|------|--------| +| `AUTH_TOKEN` | 管理后台登录令牌(**必填**) | 你的强密码 | +| `PROXY_TOKEN` | 代理接口 Bearer Token(**必填**) | 你的代理密钥 | +| `DB_TYPE` | 数据库类型(**必填**) | `mysql` | +| `DB_URL` | TiDB 连接串(**必填**) | `mysql://user:pass@host:4000/db?ssl=...` | +| `DB_SSL` | 启用 SSL 连接 | `true` | +| `TZ` | 时区 | `Asia/Shanghai` | +| `PORT` | 服务端口(默认即可) | `4000` | + +### 步骤 3:配置 UptimeRobot 防休眠 + +Render 免费实例在 15 分钟无流量后会自动休眠。使用 UptimeRobot 定时 ping 可以保持实例 24h 在线: + +1. 前往 [UptimeRobot](https://uptimerobot.com) 注册免费账号 +2. 添加新监控: + - **Monitor Type**: HTTP(s) + - **URL**: `https://your-app.onrender.com`(替换为 Render 分配的域名) + - **Monitoring Interval**: 5 minutes +3. 保存即可,UptimeRobot 会每 5 分钟访问一次你的服务,防止休眠 + +> [!TIP] +> 部署完成后,通过 Render 分配的 `.onrender.com` 域名访问后台,使用 `AUTH_TOKEN` 登录即可。也可以在 Render 设置中绑定自定义域名。 + +--- + ## Docker Compose 部署(推荐) ### 标准步骤 @@ -97,50 +181,23 @@ docker run -d --name metapi \ ## 桌面版部署(Windows / macOS / Linux) -个人电脑本地部署请直接使用 [Releases](https://github.com/cita-777/metapi/releases) 中的 Electron 安装包: - -1. 下载与你系统匹配的桌面安装包 -2. 安装并启动 Metapi Desktop -3. 桌面壳会自动启动本地服务并将数据保存到应用数据目录 +桌面版面向个人电脑本地使用,基本安装与配置流程见 [快速上手 → 桌面版启动](./getting-started.md#方式二-桌面版启动-windows-macos-linux)。 -| 项目 | 说明 | -|------|------| -| 适用场景 | 单机本地使用、个人电脑常驻、需要免 Docker 的桌面体验 | -| 后端监听 | 绑定 `127.0.0.1`,默认会在 `4310..4399` 中选择空闲端口;只有设置 `METAPI_DESKTOP_SERVER_PORT` 时才固定 | -| 管理界面 | 直接在桌面窗口打开,不建议把桌面版当成固定 `localhost:4000` 的服务来写文档或脚本 | -| 数据目录 | `app.getPath('userData')/data` | -| 日志目录 | `app.getPath('userData')/logs`,托盘菜单提供 `Open Logs Folder` | - -> [!TIP] -> - Windows 下常见路径是 `%APPDATA%\Metapi\data` 和 `%APPDATA%\Metapi\logs`。 -> - 如果你想让本机其他客户端调用桌面版内置 `/v1/*`,先从日志确认当前端口。 +以下是部署相关的补充说明。 -桌面版特性: +### 桌面版特性 - 内置本地 Metapi 服务,无需手动准备 Node.js 运行环境 - 托盘菜单支持重新打开窗口、重启后端、开机自启 - 支持基于 GitHub Releases 的应用内更新检查 +> [!IMPORTANT] +> 桌面版首次启动时,如果没有显式注入 `AUTH_TOKEN`,管理员登录令牌默认是 `change-me-admin-token`。 +> 这只适合本机初始调试使用,首次登录后应立即修改。 + > [!NOTE] > 服务器部署不再提供裸 Node.js Release 压缩包,统一推荐 Docker / Docker Compose。 -### 桌面版如何找到当前本地地址 - -桌面版启动后,后端会把当前地址打印到日志中。定位方式: - -1. 打开托盘菜单,点击 `Open Logs Folder` -2. 打开最新日志文件 -3. 搜索 `Dashboard:` 或 `Proxy API:` 行 - -常见日志内容如下: - -```text -Dashboard: http://127.0.0.1:4312 -Proxy API: http://127.0.0.1:4312/v1/chat/completions -``` - -如果你只是通过桌面窗口使用管理后台,可以完全忽略这个端口;只有在本机其他客户端需要直连桌面版内置后端时,才需要读取这里的地址。 - ### 桌面版升级 1. 通过应用内更新提示安装新版本,或从 Releases 下载最新安装包覆盖安装 @@ -149,22 +206,7 @@ Proxy API: http://127.0.0.1:4312/v1/chat/completions ## 本地开发运行(源码调试) -如果你的目标是开发、调试或提交 PR,请直接跑源码: - -```bash -git clone https://github.com/cita-777/metapi.git -cd metapi -npm install -npm run db:migrate -npm run dev -``` - -默认访问地址: - -| 服务 | 地址 | -|------|------| -| 前端(Vite) | `http://localhost:5173` | -| 后端 API | `http://localhost:4000` | +开发、调试或提交 PR 的完整流程见 [快速上手 → 本地开发启动](./getting-started.md#方式三-本地开发启动) 和 [CONTRIBUTING.md](../CONTRIBUTING.md)。 > [!NOTE] > 这条路径是开发流程,不是下载 `Release` 包后再手动跑 Node.js 的替代说法。 @@ -173,7 +215,7 @@ npm run dev ## 反向代理 -以下反向代理配置面向 Docker / 服务器模式。桌面版内置后端默认只绑定本机 `127.0.0.1`,通常不作为公网服务直接暴露。 +以下反向代理配置面向 Docker / 服务器模式。桌面版内置后端默认监听 `0.0.0.0:4000`,但通常仍作为单机桌面应用使用;如果要给局域网或公网客户端访问,请自行配置防火墙、反向代理和认证边界。若你显式设置了 `METAPI_DESKTOP_SERVER_PORT`,请把示例里的 `4000` 改成对应端口。 ### Nginx @@ -235,27 +277,9 @@ docker image prune -f ## 回滚 -如果升级后出现问题: - -1. **升级前备份**(建议每次升级前执行): +如果升级后出现问题,请参考 [运维手册 → 数据备份与恢复](./operations.md#数据备份) 进行回滚。 -```bash -cp -r data/ data-backup-$(date +%Y%m%d)/ -``` - -2. **回滚到指定版本**: - -```bash -# 修改 docker-compose.yml 中的 image tag -# 例如:image: 1467078763/metapi:v1.0.0 - -# 恢复数据 -rm -rf data/ -cp -r data-backup-20260228/ data/ - -# 重启 -docker compose up -d -``` +核心思路:升级前备份数据目录(或数据库),出问题时停止服务、还原数据、指定旧版镜像重启。 ## 数据持久化 @@ -264,6 +288,7 @@ docker compose up -d | 运行方式 | 数据目录 | 说明 | |----------|----------|------| | Docker / Docker Compose / Zeabur | 容器内 `DATA_DIR`(常见为 `/app/data`) | 需要映射到宿主机目录或平台持久化卷 | +| Render + TiDB | TiDB Serverless(外部 MySQL) | 无本地持久化,数据全部存储在 TiDB 云端数据库 | | 本地开发 | `DATA_DIR`,默认 `./data` | 位于当前仓库工作目录 | | 桌面版 | `app.getPath('userData')/data` | 不在仓库目录里,升级桌面应用时会保留 | @@ -271,39 +296,7 @@ docker compose up -d 只要备份了对应的数据目录,升级、重启通常都不会丢失现有配置和 SQLite 数据。 -### 备份策略建议 - -- 每日自动备份 `data/` 目录 -- 保留最近 7~30 天的备份 -- 重要操作前手动快照 - -## 文档站部署 - -Metapi 使用 [VitePress](https://vitepress.dev) 构建文档站,支持本地预览和 GitHub Pages 自动部署。 - -### 本地预览 - -```bash -npm run docs:dev -``` - -访问 `http://localhost:4173` 查看文档站。 - -### 构建静态站点 - -```bash -npm run docs:build -``` - -构建产物位于 `docs/.vitepress/dist/`,可部署到任意静态站点托管服务。 - -### GitHub Pages 自动部署 - -推送到 `main` 分支后,`.github/workflows/docs-pages.yml` 会自动构建并部署到 GitHub Pages。 - -首次使用需在仓库设置中开启: - -`Settings → Pages → Build and deployment → Source: GitHub Actions` +完整备份策略见 [运维手册 → 数据备份](./operations.md#数据备份)。 --- diff --git a/docs/engineering/harness-engineering.md b/docs/engineering/harness-engineering.md new file mode 100644 index 00000000..2f48fd4a --- /dev/null +++ b/docs/engineering/harness-engineering.md @@ -0,0 +1,140 @@ +# Metapi Harness Engineering + +This document captures the repo-level engineering taste that should remain true +even when future work is performed by autonomous agents. The goal is not to +replace feature tests. The goal is to keep the repository readable, +review-friendly, and hard to drift. + +## Why This Exists + +`metapi` already has strong local discipline in several areas: + +- architecture tests that pin important boundaries +- schema parity and runtime bootstrap verification across databases +- shared protocol helpers that deliberately replace parallel implementations + +What was still missing was one place that states the mechanical rules behind +those choices and a lightweight loop that continuously checks for drift. This +document is that shared contract. + +## Golden Principles + +### 1. One Invariant, One Source Of Truth + +If a helper, contract, or workflow already owns an invariant, new work should +reuse that home instead of creating a second implementation. + +Examples: + +- protocol shaping and normalization belong in `src/server/transformers/**` +- endpoint fallback belongs in `src/server/routes/proxy/endpointFlow.ts` +- proxy success/failure bookkeeping belongs in + `src/server/proxy-core/surfaces/sharedSurface.ts` +- schema heuristics belong in `src/server/db/schemaMetadata.ts` + +### 2. Routes Are Adapters, Not Owners + +`src/server/routes/**` should remain thin. Route files may register endpoints, +read Fastify request state, and delegate to shared orchestration. They should +not become the place where retry loops, protocol conversion, stream lifecycle, +or billing logic lives. + +If a helper is imported outside a single route file, that helper should move to +`proxy-core`, `services`, `transformers`, or another neutral home. + +### 3. Transformers Must Stay Protocol-Pure + +Transformers are the protocol boundary. They may depend on canonical/shared +contracts, but they should not reach back into route files, Fastify handlers, +OAuth services, token routing, or runtime dispatch details. + +In practice this means: + +- `canonical` is the request truth source +- `shared/normalized` is the response truth source +- compatibility layers may orchestrate retries or fallback bodies, but they do + not redefine protocol semantics + +### 4. Proxy-Core Follows The Golden Path + +Proxy orchestration should prefer shared, tested paths instead of bespoke +surface-local logic. + +Current preferred path: + +- endpoint ranking and request shaping via `upstreamEndpoint.ts` +- endpoint-attempt loops via `executeEndpointFlow()` +- whole-body response decoding via `readRuntimeResponseText()` +- OAuth refresh, sticky/lease behavior, billing, and proxy logging via + `sharedSurface.ts` +- Codex header/session semantics via provider profiles and header utils +- Codex websocket transport via the dedicated websocket runtime, not generic + fetch executors + +### 5. Platform Capability Must Be Explicit + +Platform behavior is easy to let drift because it spans adapters, discovery, +endpoint preference, and routing. Any platform-specific behavior that matters +at runtime should be stated once and reused, not re-inferred in multiple +subsystems. + +Thin adapters should stay honest. Feature-complete adapters should be tested as +feature-complete. Do not silently “upgrade” support through generic defaults. + +### 6. Database Changes Must Stay Contract-Driven + +Schema work already has one of the strongest harnesses in the repo. Keep it +that way. + +- update Drizzle schema and SQLite migration history together +- regenerate checked-in contract/artifacts together +- keep cross-dialect bootstrap/upgrade generation contract-driven +- keep legacy startup compatibility narrow and spec-owned + +### 7. Web Pages Are Orchestration Surfaces + +Top-level pages should not be reused as shared component libraries. + +- no page-to-page imports for reusable UI +- mobile behavior should reuse shared primitives first +- extract repeated modals, drawers, and panels into domain subfolders early + +## First-Wave Drift Checks + +The first repo-level drift loop is intentionally small and high-signal. It +checks for: + +- transformer imports from `routes/proxy` +- new proxy-core surface body reads that bypass `readRuntimeResponseText()` +- new imports from `routes/proxy` inside `proxy-core` beyond the current debt + baseline +- new top-level page-to-page imports in the admin UI beyond the current debt + baseline + +These checks live in `scripts/dev/repo-drift-check.ts` and are wired into CI. + +## Tracked Debt Vs New Violations + +The repository already contains some known architectural debt, especially where +`proxy-core` still imports helpers from `routes/proxy` and where one admin page +reuses another page's export. + +The harness uses a ratchet: + +- tracked debt is reported so it stays visible +- new violations fail CI + +This keeps the repo moving forward without forcing a risky “rewrite +everything first” migration. + +## Garbage Collection Loop + +The harness loop is intentionally conservative: + +1. rules are encoded in repo docs and executable checks +2. CI blocks new drift +3. a scheduled workflow generates a drift report artifact +4. targeted cleanup PRs can remove tracked debt in small slices + +The goal is steady principal payments on technical debt, not occasional heroic +cleanup sprints. diff --git a/docs/faq.md b/docs/faq.md index 97e6bb06..a738e1ce 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -47,11 +47,13 @@ **A:** Sub2API 常见 JWT 短期会话机制,和传统 NewAPI 站点差异较大。当前建议: -1. 在「凭证模式」里选择 Session 模式,粘贴 JWT / Bearer Token 进行验证 +1. 在「凭证模式」里选择 Session 模式,分别粘贴 F12 界面中的 `auth_token`、`refresh_token`、`token_expires_at` 字段进行验证,无需配置用户 ID 2. 不要使用账号密码登录,Metapi 不支持代替 Sub2API 做登录 -3. Sub2API 不支持签到;如果你只关心代理调用,也可以直接改用 API Key 模式 +3. Sub2API 通常为订阅制使用,不支持签到;如果你只关心代理调用,也可以直接改用 API Key 模式 4. 若 `GET /v1/models` 为空,先确认该账号下已有可用用户 API Key,Metapi 会再尝试用它发现模型 +详细操作截图见 [上游接入 → Sub2API](./upstream-integration.md#sub2api)。 + ## 部署相关 ### Q: 启动后无法访问管理后台 diff --git a/docs/getting-started.md b/docs/getting-started.md index a71ff4bf..8a341448 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -13,6 +13,7 @@ | 场景 | 推荐方式 | 需要准备 | |------|----------|----------| | 云服务器 / NAS / 家用主机长期运行 | Docker / Docker Compose | Docker 与 Docker Compose | +| 免费云部署(24h 在线) | Render + TiDB + UptimeRobot | 注册 Render / TiDB Cloud / UptimeRobot 免费账号,详见 [Render 部署指南](./deployment.md#render-一键部署免费-24h-运行) | | 个人电脑本地使用 | 桌面版安装包 | 从 [Releases](https://github.com/cita-777/metapi/releases) 下载对应系统的桌面安装包 | | 二次开发 / 调试 | 本地开发 | Node.js 20+ 与 npm | @@ -79,13 +80,22 @@ docker compose up -d | 项目 | 说明 | |------|------| | 管理界面 | 应用启动后会直接打开桌面窗口,不需要假设固定的 `http://localhost:4000` | -| 本地后端地址 | 桌面版把内置服务绑定到 `127.0.0.1`,默认会在 `4310..4399` 中挑选空闲端口;只有显式设置 `METAPI_DESKTOP_SERVER_PORT` 时才会固定 | +| 本地后端地址 | 桌面版内置服务默认监听 `0.0.0.0:4000`;桌面窗口和本机 curl 可继续使用 `http://127.0.0.1:4000`,局域网其他设备请使用当前机器的实际 IP + `4000`;如需改端口,可显式设置 `METAPI_DESKTOP_SERVER_PORT` | | 数据目录 | 保存在 `app.getPath('userData')/data`,不是仓库里的 `./data` | | 日志目录 | 保存在 `app.getPath('userData')/logs`;托盘菜单提供 `Open Logs Folder` | +> [!IMPORTANT] +> 桌面版首次启动时,如果你没有额外注入 `AUTH_TOKEN`,默认管理员令牌就是 `change-me-admin-token`。 +> 首次登录后建议立即到「设置」里改成你自己的强密码令牌。 + > [!TIP] > - Windows 下常见路径是 `%APPDATA%\Metapi\data` 和 `%APPDATA%\Metapi\logs`。 -> - 如果你要把本机其他客户端接到桌面版内置后端,先到日志里查当前端口,不要写死 `4000`。 +> - 如果没有额外覆盖端口,本机其他客户端可以直接连接 `http://127.0.0.1:4000`。 + +> [!WARNING] +> **端口冲突排障:** 桌面版默认使用 `4000` 端口;如果该端口被其他应用占用: +> - 设置环境变量 `METAPI_DESKTOP_SERVER_PORT=<指定端口>` 改到一个空闲端口 +> - 或关闭占用 `4000` 的应用后重启 Metapi Desktop > [!NOTE] > 服务器部署统一推荐 Docker / Docker Compose,不再提供裸 Node.js 的 Release 压缩包。 @@ -111,6 +121,8 @@ npm run dev > [!TIP] 从 ALL-API-Hub 迁移(可选) > 如果你使用过 ALL-API-Hub,Metapi 兼容其导出的备份设置,可直接导入,无需手动逐项配置。 > +> 导入后刷新账号状态可能出现个别账号令牌过期,点击重新绑定按钮按照下面步骤2的方法获取Access Token或者Cookie等即可。 +> > ![ALL-API-Hub备份导入](./screenshots/allapi-hub-backup.png) ### 步骤 1:添加站点 @@ -118,59 +130,77 @@ npm run dev 进入 **站点管理**,添加你使用的上游中转站: - 填写站点名称(自己想怎么取就怎么取)和 URL -- 选择平台类型(New API / One API / OneHub / DoneHub / Veloera / AnyRouter / Sub2API),通常可自动检测 -- 填写站点的管理员 API Key(可选,部分功能需要) +- 选择平台类型(`new-api` / `one-api` / `one-hub` / `done-hub` / `veloera` / `anyrouter` / `sub2api` / `openai` / `claude` / `gemini` / `cliproxyapi`),通常可自动检测,检测有误或者因为防护页导致检测失败可以手动选择。 +- 可选是否开启系统代理,方便国内机器访问国外中转站。 +- 可选站点权重,站点权重越大,路由将更加频繁使用这个站点的模型。 + +如果你不确定该选哪个平台,先看 [上游接入](./upstream-integration.md)。 ![站点管理](./screenshots/site-management.png) -### 步骤 2:添加账号 +### 步骤 2:添加账号(可签到、查询余额等) -首先前往你想添加的公益站,进入下图界面: +进入 **连接管理中的账号管理**,为每个站点添加已注册的账号: ![账号管理](./screenshots/account-management.png) -进入 **账号管理**,为每个站点添加已注册的账号: - - - -![账号余额](./screenshots/account-balance.png) - - 填入用户名和访问凭证 ![账号凭证](./screenshots/account-credentials.png) - 系统会自动登录并获取余额信息 -![账号余额](./screenshots/account-balance.png) + ![账号余额](./screenshots/account-balance.png) - 启用自动签到(如站点支持) -### 步骤 3:同步 Token +### 步骤 3:添加 API Key(Base URL+Key模式,只可获取模型和使用模型) + +首先你需要在步骤1中,确保添加了(`new-api` / `one-api` / `one-hub` / `done-hub` / `veloera` / `anyrouter` / `sub2api` / `openai` / `claude` / `gemini` / `cliproxyapi`)的类型的Base URL。 -进入 **Token 管理**: +- 进入 **连接管理中的API Key管理**,为每个站点添加你的API Key: -- 点击「同步」从上游账号拉取 API Key +![API Key 管理](./screenshots/api-key-management.png) -- 或手动添加已有的 API Key,如下图所示。 +### 步骤 4:同步账号令牌 + +进入 **连接管理中的账号令牌管理**: + +- 点击「同步」从上游账号拉取 账号令牌 + +- 或手动添加已有的账号令牌,添加后上游站点的令牌管理页面会同步出现令牌,如下图所示。 ![Token管理](./screenshots/token-management.png) -### 步骤 4:检查路由 +### 步骤 5:路由管理 进入 **路由管理**: - 系统会自动发现模型并生成路由规则 +- 点击右上角的刷新选中概率可以显示并将概率载入缓存中 - 可以手动调整通道的优先级和权重 +- 关于路由权重参数调优,参考 [配置说明 → 智能路由](./configuration.md#智能路由) +- 左侧可以进行品牌、站点、接口等的筛选,如下图所示: + +![路由筛选](./screenshots/routes-filter.png) + +- **可以通过创建群组,从而对上游模型进行匹配和重定向,如果建立下图群组,下游访问Metapi时获取的claude-opus-4-6模型将在命中样本中智能选取,日志中可以看见映射。** ![路由群组示例](./screenshots/route-group.png) + +- **可以在使用日志中看见下游的请求模型和实际分配给下游使用的模型** + + ![日志中的模型映射](./screenshots/proxy-logs-mapping.png) ### 步骤 5:验证代理 +**Metapi还有更多功能,可以在设置中寻找,请尽情探索,有建议可以提出Issue改进。** + 按运行方式选择验证入口: | 运行方式 | 管理界面 | 代理接口基地址 | |----------|----------|----------------| | Docker / Docker Compose | `http://localhost:4000` | `http://localhost:4000` | | 本地开发 | `http://localhost:5173` | `http://localhost:4000` | -| 桌面版 | 直接使用桌面窗口 | 先从日志里的 `Proxy API:` 行确认当前 `http://127.0.0.1:` | +| 桌面版 | 直接使用桌面窗口 | 默认 `http://127.0.0.1:4000`;如果设置了 `METAPI_DESKTOP_SERVER_PORT`,则按日志里的实际端口访问,局域网其他设备改用当前机器 IP + 同一端口 | ### Docker / 本地开发:直接用 curl 验证 @@ -186,26 +216,29 @@ curl -sS http://localhost:4000/v1/chat/completions \ -d '{"model":"gpt-4o-mini","messages":[{"role":"user","content":"hi"}]}' ``` -### 桌面版:先确认当前端口再验证 +### 桌面版:默认直接用 4000 验证 打开托盘菜单的 `Open Logs Folder`,在最新日志里查找类似下面的启动信息: ```text -Dashboard: http://127.0.0.1:4312 -Proxy API: http://127.0.0.1:4312/v1/chat/completions +Dashboard: http://127.0.0.1:4000 +Proxy API: http://127.0.0.1:4000/v1/chat/completions ``` -然后把日志里的实际端口替换进 curl: +如果你没有覆盖端口,可直接执行: ```bash -curl -sS http://127.0.0.1:4312/v1/models \ +curl -sS http://127.0.0.1:4000/v1/models \ -H "Authorization: Bearer your-proxy-sk-token" ``` -返回正常响应,说明代理链路已经可用。 +如果你显式设置了 `METAPI_DESKTOP_SERVER_PORT`,再把上面的 `4000` 替换成日志里的实际端口。返回正常响应,说明代理链路已经可用。 + +如果你要从同一局域网的其他设备访问桌面版,把上面的 `127.0.0.1` 替换成这台电脑的实际局域网 IP,并确认系统防火墙已放行对应端口。 ## 下一步 +- [上游接入](./upstream-integration.md) — 当前代码支持哪些上游、默认该走哪个连接分段 - [部署指南](./deployment.md) — 反向代理、HTTPS、升级策略 - [配置说明](./configuration.md) — 详细环境变量与路由参数 - [客户端接入](./client-integration.md) — 对接 Open WebUI、Cherry Studio 等 diff --git a/docs/index.md b/docs/index.md index f115e010..da881172 100644 --- a/docs/index.md +++ b/docs/index.md @@ -12,23 +12,23 @@ hero: - theme: brand text: 10 分钟快速上手 link: /getting-started + - theme: alt + text: 上游接入 + link: /upstream-integration - theme: alt text: 常见问题 FAQ link: /faq - - theme: alt - text: 文档维护与贡献 - link: /README features: - title: 快速上手 details: 从部署到第一条请求,按步骤完成最小可用环境搭建。 link: /getting-started + - title: 上游接入 + details: 按当前代码支持的平台类型,快速判断该选什么平台、先走哪个连接分段。 + link: /upstream-integration - title: 问题排查 details: 汇总高频报错、根因定位和标准修复路径,降低重复沟通成本。 link: /faq - - title: 运维与配置 - details: 覆盖部署、配置、监控、备份、升级回滚等生产场景。 - link: /operations --- ## 项目架构 @@ -289,6 +289,7 @@ onBeforeUnmount(() => { ## 从这里开始 - 初次部署或首次接入:从 [快速上手](/getting-started) 开始,先跑通最小可用链路。 +- 不确定上游平台该怎么选:先看 [上游接入](/upstream-integration),再决定走 `账号管理` 还是 `API Key管理`。 - 准备上线或升级回滚:查看 [部署指南](/deployment) 与 [运维手册](/operations)。 - 需要补齐环境变量或路由参数:直接查 [配置说明](/configuration)。 - 正在处理客户端或第三方工具接入:优先看 [客户端接入](/client-integration)。 diff --git a/docs/operations.md b/docs/operations.md index 4b0ce06d..a3cc0f1b 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -30,12 +30,35 @@ cp -r data/ data-backup-$(date +%Y%m%d)/ ### 方式二:数据库原生备份(MySQL / PostgreSQL) -如果当前运行库已经切到 MySQL / Postgres,请使用数据库自己的备份工具或云快照,例如 `mysqldump`、`pg_dump`、RDS 快照等。 +如果当前运行库已经切到 MySQL / Postgres,请使用数据库自己的备份工具或云快照。 + +**MySQL 备份示例:** + +```bash +# 全量导出(替换为你的实际连接信息) +mysqldump -h -u -p metapi > metapi-backup-$(date +%Y%m%d).sql + +# 自动备份(crontab,每天凌晨 3 点) +0 3 * * * mysqldump -h -u -p metapi | gzip > /path/to/backups/metapi-$(date +\%Y\%m\%d).sql.gz +``` + +**PostgreSQL 备份示例:** + +```bash +# 全量导出 +pg_dump -h -U -d metapi -F c -f metapi-backup-$(date +%Y%m%d).dump + +# 自动备份(crontab,每天凌晨 3 点,使用 .pgpass 免交互密码) +0 3 * * * pg_dump -h -U -d metapi -F c -f /path/to/backups/metapi-$(date +\%Y\%m\%d).dump +``` + +**云托管数据库:** RDS、PlanetScale、Neon 等可直接使用平台的自动快照功能。 建议: - 升级、迁移、执行「重新初始化系统」前先做一次库级备份 - 备份对象是当前 metapi 正在使用的运行库,而不只是本地 `data/` - 恢复后重启 Metapi 一次,确认它重新连接到了正确的运行库 +- 建议保留最近 7~30 天的备份,定期清理过期文件 ### 方式三:应用内导出 @@ -104,11 +127,16 @@ npm run dev | 关键词 | 含义 | 处理方式 | |--------|------|----------| -| `auth failed` | 上游站点鉴权失败 | 检查账号凭证是否过期 | -| `no available channel` | 路由无可用通道 | 检查 Token 是否同步、通道是否被冷却 | -| `notify failed` | 通知发送失败 | 检查通知渠道配置 | +| `auth failed` | 上游站点鉴权失败 | 检查账号凭证是否过期,系统会自动尝试重登录 | +| `no available channel` | 路由无可用通道 | 检查 Token 是否同步、通道是否被冷却;可在路由页查看冷却状态 | +| `channel cooling` | 通道进入冷却期 | 通道在请求失败后自动冷却 10 分钟,期间不会被路由选中;无需干预,会自动恢复 | +| `upstream 429` | 上游限流 | 该上游站点触发了速率限制;路由引擎会自动切换其他通道,冷却期后重试 | +| `upstream 5xx` | 上游服务器错误 | 上游站点临时不可用,路由引擎会自动故障转移到其他通道 | +| `notify failed` | 通知发送失败 | 检查 [通知渠道配置](./configuration.md#通知渠道) | | `checkin failed` | 签到失败 | 检查账号状态和站点连通性 | -| `balance refresh failed` | 余额刷新失败 | 检查账号凭证 | +| `balance refresh failed` | 余额刷新失败 | 检查账号凭证,可能需要重新登录 | +| `proxy timeout` | 代理请求超时 | 上游响应过慢;检查网络延迟或考虑切换其他通道 | +| `token expired` | Token 过期 | 系统会自动尝试续签;若反复出现,手动刷新 Token | ## 健康检查 @@ -126,7 +154,7 @@ curl -sS http://localhost:4000/v1/chat/completions \ -d '{"model":"gpt-4o-mini","messages":[{"role":"user","content":"ping"}]}' ``` -以上示例默认服务端部署监听 `localhost:4000`。Desktop 内置后端运行在 `127.0.0.1` 的动态端口,优先通过应用界面、托盘日志或健康检查日志排查,不要假定固定端口。 +以上示例默认服务端部署监听 `localhost:4000`。Desktop 内置后端默认监听 `0.0.0.0:4000`;本机排查通常可直接使用 `127.0.0.1:4000`,如果显式设置了 `METAPI_DESKTOP_SERVER_PORT`,则按日志里的实际端口访问;局域网排查改用当前机器的实际 IP。 ### 自动化监控建议 @@ -168,9 +196,14 @@ curl -sS http://localhost:4000/v1/chat/completions \ ### 清理缓存并重建路由 -- 「设置 → 清除缓存并重建路由」会清空模型发现缓存、自动路由和自动通道,再后台触发一次模型刷新与路由重建 -- 「TokenRoutes → 重建路由」适合在调整账号、Token、路由规则后手动重新生成自动路由 -- 如果 `GET /v1/models`、路由列表或选择概率明显滞后,优先执行这一操作 +当模型列表(`GET /v1/models`)、路由列表或选择概率明显滞后时,使用以下操作: + +| 操作 | 位置 | 适用场景 | +|------|------|----------| +| **清除缓存并重建路由** | 设置 → 清除缓存并重建路由 | 全局刷新:清空模型发现缓存、自动路由和自动通道,后台触发模型刷新与路由重建 | +| **重建路由** | TokenRoutes → 重建路由 | 局部刷新:调整账号、Token、路由规则后手动重新生成自动路由 | + +优先使用「重建路由」做局部刷新,问题持续时再用全局清除。 ### 批量操作 @@ -193,16 +226,9 @@ curl -sS http://localhost:4000/v1/chat/completions \ 执行前建议先做一次导出或数据库备份。 -## 发布前检查清单 - -如果你在本地开发并准备发布: - -- [ ] `npm test` 通过 -- [ ] `npm run build` 通过 -- [ ] `.env`、`data/`、`tmp/` 未提交到 Git -- [ ] 敏感凭证已从代码中移除 - ## 下一步 - [常见问题](./faq.md) — 常见报错与修复 - [配置说明](./configuration.md) — 环境变量详解 +- [上游接入](./upstream-integration.md) — 平台特定的连接与排障 +- [客户端接入](./client-integration.md) — 下游客户端对接 diff --git a/docs/plans/2026-03-10-model-refresh-health-design.md b/docs/plans/2026-03-10-model-refresh-health-design.md new file mode 100644 index 00000000..d4df8e77 --- /dev/null +++ b/docs/plans/2026-03-10-model-refresh-health-design.md @@ -0,0 +1,49 @@ +--- +title: Model Refresh Health + Toast Feedback +date: 2026-03-10 +owner: codex +status: approved +--- + +# Goal +When users click the "模型" refresh action, failed model discovery should mark the account runtime health as `unhealthy` and surface a clear failure toast. Success should show the discovered model names and count. + +# Scope +- Applies to all account types (Session + API Key). +- Server determines success/failure and error category; UI uses returned payload for toast text. + +# Non-goals +- Changing upstream adapters' auth logic. +- Altering auto-refresh schedules or background tasks. + +# Architecture +- Update `refreshModelsForAccount(...)` to return structured outcome: + - `status`: `success` | `failed` + - `modelCount` + - `modelsPreview` (first N names) + - `errorCode` (e.g. `timeout`, `unauthorized`, `empty_models`, `unknown`) + - `errorMessage` (human-readable) +- On failure: write `runtimeHealth = unhealthy` with a reason derived from `errorCode`. +- `/api/models/check/:accountId` should return the above outcome. +- UI toast reads outcome and displays: + - Success: `已获取到模型:A、B、C(共 N 个)` + - Failure: `模型获取失败(请求超时)` / `模型获取失败,API Key 已无效` / `模型获取失败:` + +# Data Flow +1. User clicks "模型". +2. UI calls `/api/models/check/:accountId`. +3. Server refreshes models and returns structured outcome. +4. UI shows toast based on outcome and reloads list. + +# Error Classification +- `timeout`: error message contains `timeout` / `timed out` / `请求超时`. +- `unauthorized`: HTTP 401/403 or message contains `invalid`, `unauthorized`, `无权`, `未提供令牌`. +- `empty_models`: request succeeded but model list empty. +- `unknown`: fallback for other errors. + +# Testing +- Unit tests for `refreshModelsForAccount`: + - Failure updates runtime health to `unhealthy`. + - Failure classification maps to the right `errorCode`. + - Success returns `modelsPreview` and `modelCount`. +- UI behavior: verify toast text for success vs. each failure category. diff --git a/docs/plans/2026-03-16-downstream-key-ui-and-site-links.md b/docs/plans/2026-03-16-downstream-key-ui-and-site-links.md new file mode 100644 index 00000000..902228e5 --- /dev/null +++ b/docs/plans/2026-03-16-downstream-key-ui-and-site-links.md @@ -0,0 +1,160 @@ +# Downstream Key UI And Site Links Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Restyle the downstream key page to match the restrained management-console pages and standardize site buttons across pages using the accounts page interaction. + +**Architecture:** Introduce one shared site link component based on the accounts page behavior, then update each affected page to consume it. Refactor downstream key layout styling toward existing `card`, `badge`, and `data-table` patterns without changing backend behavior. + +**Tech Stack:** React, TypeScript, React Router, Vitest, React Test Renderer + +--- + +### Task 1: Extract the shared site button + +**Files:** +- Create: `src/web/components/SiteBadgeLink.tsx` +- Test: `src/web/components/site-badge-link.test.tsx` +- Modify: `src/web/pages/Accounts.tsx` +- Modify: `src/web/index.css` + +**Step 1: Write the failing test** + +Add a component test that renders a site badge link and asserts: +- it renders the site name, +- it has the shared interactive class, +- clicking navigates to `/sites?focusSiteId=`. + +**Step 2: Run test to verify it fails** + +Run: `npm test -- src/web/components/site-badge-link.test.tsx` + +Expected: FAIL because the component does not exist yet. + +**Step 3: Write minimal implementation** + +- Create a small component that wraps `Link` from `react-router-dom`. +- Accept `siteId`, `siteName`, and optional badge class props. +- Reproduce the accounts page badge-link styling and fallback behavior. +- Move the accounts page site badge rendering to this component. +- Add any missing shared CSS class if accounts currently relies on page-local styling. + +**Step 4: Run test to verify it passes** + +Run: `npm test -- src/web/components/site-badge-link.test.tsx src/web/pages/accounts.segmented-connections.test.tsx` + +Expected: PASS + +**Step 5: Commit** + +```bash +git add src/web/components/SiteBadgeLink.tsx src/web/components/site-badge-link.test.tsx src/web/pages/Accounts.tsx src/web/index.css +git commit -m "refactor: extract shared site badge link" +``` + +### Task 2: Apply the shared site button across affected pages + +**Files:** +- Modify: `src/web/pages/Models.tsx` +- Modify: `src/web/pages/ProxyLogs.tsx` +- Modify: `src/web/pages/TokenRoutes.tsx` +- Modify: `src/web/pages/DownstreamKeys.tsx` +- Test: `src/web/pages/Models.marketplace-text.test.tsx` +- Test: `src/web/pages/tokenRoutes.group-collapse.test.tsx` +- Test: `src/web/pages/DownstreamKeys.test.tsx` + +**Step 1: Write failing assertions** + +Add or update tests to assert the affected pages render the shared site button class and, where practical, point to the focused site management route. + +**Step 2: Run test to verify it fails** + +Run: `npm test -- src/web/pages/Models.marketplace-text.test.tsx src/web/pages/tokenRoutes.group-collapse.test.tsx src/web/pages/DownstreamKeys.test.tsx` + +Expected: FAIL because the pages still use inconsistent site rendering. + +**Step 3: Write minimal implementation** + +- Replace page-local site label rendering with `SiteBadgeLink`. +- Only convert labels that represent navigable management-site references. +- Preserve non-site filters, external URLs, and unrelated badges. + +**Step 4: Run test to verify it passes** + +Run: `npm test -- src/web/pages/Models.marketplace-text.test.tsx src/web/pages/tokenRoutes.group-collapse.test.tsx src/web/pages/DownstreamKeys.test.tsx src/web/pages/ProxyLogs.server-driven.test.tsx` + +Expected: PASS + +**Step 5: Commit** + +```bash +git add src/web/pages/Models.tsx src/web/pages/ProxyLogs.tsx src/web/pages/TokenRoutes.tsx src/web/pages/DownstreamKeys.tsx src/web/pages/Models.marketplace-text.test.tsx src/web/pages/tokenRoutes.group-collapse.test.tsx src/web/pages/DownstreamKeys.test.tsx +git commit -m "feat: unify site badge navigation across pages" +``` + +### Task 3: Restyle downstream keys page + +**Files:** +- Modify: `src/web/pages/DownstreamKeys.tsx` +- Test: `src/web/pages/DownstreamKeys.test.tsx` +- Modify: `src/web/index.css` + +**Step 1: Write failing assertions** + +Add focused assertions for the downstream keys page structure where needed, such as shared management-page card classes, reduced bespoke styling hooks, or the new site badge placement. + +**Step 2: Run test to verify it fails** + +Run: `npm test -- src/web/pages/DownstreamKeys.test.tsx` + +Expected: FAIL after adding assertions that reflect the updated restrained layout. + +**Step 3: Write minimal implementation** + +- Reduce the page’s dashboard-like presentation. +- Align summary, filters, and batch action sections with the same management-console card language used in sites/accounts. +- Reuse shared badge/link patterns instead of one-off visual treatments where practical. +- Keep operations, data fetching, and drawer behavior unchanged. + +**Step 4: Run test to verify it passes** + +Run: `npm test -- src/web/pages/DownstreamKeys.test.tsx` + +Expected: PASS + +**Step 5: Commit** + +```bash +git add src/web/pages/DownstreamKeys.tsx src/web/pages/DownstreamKeys.test.tsx src/web/index.css +git commit -m "style: align downstream key page with management UI" +``` + +### Task 4: Final verification + +**Files:** +- Modify: none + +**Step 1: Run targeted verification** + +Run: +- `npm test -- src/web/components/site-badge-link.test.tsx` +- `npm test -- src/web/pages/DownstreamKeys.test.tsx` +- `npm test -- src/web/pages/Models.marketplace-text.test.tsx` +- `npm test -- src/web/pages/tokenRoutes.group-collapse.test.tsx` +- `npm test -- src/web/pages/ProxyLogs.server-driven.test.tsx` +- `npm test -- src/web/pages/accounts.segmented-connections.test.tsx` + +Expected: PASS + +**Step 2: Run broader sanity verification if time permits** + +Run: `npm test -- src/web/App.sidebar.test.ts src/web/App.sidebar-mobile.test.tsx` + +Expected: PASS + +**Step 3: Commit final polish** + +```bash +git add -A +git commit -m "test: verify downstream key UI and site link consistency" +``` diff --git a/docs/plans/2026-03-22-single-source-consolidation-pr1.md b/docs/plans/2026-03-22-single-source-consolidation-pr1.md new file mode 100644 index 00000000..14a101be --- /dev/null +++ b/docs/plans/2026-03-22-single-source-consolidation-pr1.md @@ -0,0 +1,254 @@ +# Single Source Consolidation PR1 Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Land PR1 for the highest-value duplication and double-source issues around responses normalization, shared proxy surface orchestration, provider runtime headers, token-route contract sharing, account mutation workflow, and standard API platform helpers. + +**Architecture:** Keep PR1 focused on extracting shared single-source modules and rewiring existing callers to consume them. Do not expand this PR into schema metamodel rewrites, `chatFormatsCore` breakup, or mobile page scaffold work; those remain follow-up PR scope once the highest-risk protocol and workflow duplications are removed. + +**Tech Stack:** TypeScript, Fastify, Vitest, shared server/web modules under `src/shared`, server proxy-core surfaces/providers/services, React admin pages. + +--- + +## Task 1: Savepoint Commit + +**Files:** +- Commit: `src/server/config.ts` +- Commit: `src/server/config.test.ts` +- Commit: `src/server/db/proxyFileSchemaCompatibility.ts` +- Commit: `src/server/db/proxyFileSchemaCompatibility.test.ts` + +**Step 1: Verify the savepoint changes** + +Run: `npm test -- src/server/config.test.ts src/server/db/proxyFileSchemaCompatibility.test.ts` +Expected: PASS + +**Step 2: Commit the savepoint** + +Run: + +```bash +git add src/server/config.ts src/server/config.test.ts src/server/db/proxyFileSchemaCompatibility.ts src/server/db/proxyFileSchemaCompatibility.test.ts +git commit -m "fix: trust proxy headers and align proxy file mysql indexes" +``` + +**Step 3: Cherry-pick into PR1 branch** + +Run: + +```bash +git cherry-pick +``` + +Expected: first commit preserved inside PR1 branch before the larger consolidation commit. + +## Task 2: Responses Single Source + +**Files:** +- Create: `src/server/transformers/openai/responses/normalization.ts` +- Modify: `src/server/transformers/openai/responses/conversion.ts` +- Modify: `src/server/transformers/openai/responses/compatibility.ts` +- Test: `src/server/transformers/openai/responses/conversion.test.ts` + +**Step 1: Move shared normalization helpers into one source** + +Keep `normalizeResponsesInputForCompatibility`, `normalizeResponsesMessageContent`, and related block normalization in `normalization.ts`. + +**Step 2: Rewire both callers** + +Make `conversion.ts` and `compatibility.ts` import the shared helpers instead of maintaining their own independent copies. + +**Step 3: Verify parity** + +Run: `npm test -- src/server/transformers/openai/responses/conversion.test.ts` +Expected: PASS, including assertions that compatibility exports point at the shared implementation. + +## Task 3: Shared Surface Orchestration + +**Files:** +- Create: `src/server/proxy-core/surfaces/sharedSurface.ts` +- Modify: `src/server/proxy-core/surfaces/chatSurface.ts` +- Modify: `src/server/proxy-core/surfaces/openAiResponsesSurface.ts` +- Modify: `src/server/proxy-core/surfaces/geminiSurface.ts` +- Test: `src/server/proxy-core/surfaces/sharedSurface.test.ts` + +**Step 1: Extract common orchestration primitives** + +Centralize channel selection, dispatch, proxy logging, retry/failure handling, and common failure response shaping in `sharedSurface.ts`. + +**Step 2: Rewire surface callers** + +Make chat and responses surfaces consume the shared orchestration helpers instead of each keeping a parallel flow. + +**Step 3: Verify behavior** + +Run: `npm test -- src/server/proxy-core/surfaces/sharedSurface.test.ts` +Expected: PASS + +## Task 4: Provider Header Builders + +**Files:** +- Create: `src/server/proxy-core/providers/headerUtils.ts` +- Modify: `src/server/proxy-core/providers/codexProviderProfile.ts` +- Modify: `src/server/proxy-core/providers/claudeProviderProfile.ts` +- Modify: `src/server/proxy-core/providers/geminiCliProviderProfile.ts` +- Modify: `src/server/routes/proxy/upstreamEndpoint.ts` +- Test: `src/server/proxy-core/providers/headerUtils.test.ts` + +**Step 1: Extract header parsing and runtime header construction** + +Keep Codex, Claude, and Gemini CLI runtime header shaping in `headerUtils.ts`. + +**Step 2: Remove duplicate builders** + +Make both provider profiles and `upstreamEndpoint.ts` call the shared header builders. + +**Step 3: Verify behavior** + +Run: `npm test -- src/server/proxy-core/providers/headerUtils.test.ts` +Expected: PASS + +## Task 5: Cross-Layer Token Route Contract + +**Files:** +- Create: `src/shared/tokenRouteContract.js` +- Create: `src/shared/tokenRouteContract.d.ts` +- Create: `src/shared/tokenRoutePatterns.js` +- Create: `src/shared/tokenRoutePatterns.d.ts` +- Modify: `src/server/services/tokenRouter.ts` +- Modify: `src/server/routes/api/tokens.ts` +- Modify: `src/web/pages/token-routes/utils.ts` +- Modify: `src/web/pages/token-routes/types.ts` +- Modify: `src/web/pages/helpers/routeListVisibility.ts` +- Modify: `src/web/pages/Settings.tsx` +- Test: `src/shared/tokenRouteContract.test.ts` +- Test: `src/shared/tokenRoutePatterns.test.ts` + +**Step 1: Extract shared route mode and pattern matching** + +Make route mode normalization and model pattern matching authoritative in `src/shared`. + +**Step 2: Rewire server and web** + +Make token router, route APIs, token-routes helpers, and settings consume the shared contract instead of maintaining separate copies. + +**Step 3: Verify behavior** + +Run: `npm test -- src/shared/tokenRouteContract.test.ts src/shared/tokenRoutePatterns.test.ts` +Expected: PASS + +## Task 6: Account Mutation Workflow + +**Files:** +- Create: `src/server/services/accountMutationWorkflow.ts` +- Modify: `src/server/routes/api/accounts.ts` +- Modify: `src/server/routes/api/accountTokens.ts` +- Test: `src/server/services/accountMutationWorkflow.test.ts` + +**Step 1: Extract convergence workflow** + +Move token sync, default token ensure, balance refresh, model refresh, and route rebuild sequencing into `accountMutationWorkflow.ts`. + +**Step 2: Rewire controllers** + +Make account and account-token APIs call the workflow service instead of each owning private orchestration logic. + +**Step 3: Verify behavior** + +Run: `npm test -- src/server/services/accountMutationWorkflow.test.ts` +Expected: PASS + +## Task 7: Standard API Provider Base + +**Files:** +- Create: `src/server/services/platforms/standardApiProvider.ts` +- Modify: `src/server/services/platforms/openai.ts` +- Modify: `src/server/services/platforms/claude.ts` +- Modify: `src/server/services/platforms/gemini.ts` +- Modify: `src/server/services/platforms/cliproxyapi.ts` +- Test: `src/server/services/platforms/standardApiProvider.test.ts` +- Test: `src/server/services/platforms/llmUpstream.test.ts` + +**Step 1: Extract shared standard API adapter logic** + +Centralize base URL normalization, `/v1/models` resolution, unsupported login/checkin/balance defaults, and common model fetch behavior. + +**Step 2: Rewire adapters** + +Make OpenAI, Claude, Gemini, and CLIProxyAPI adapters extend the shared base instead of repeating the same template logic. + +**Step 3: Verify behavior** + +Run: `npm test -- src/server/services/platforms/standardApiProvider.test.ts src/server/services/platforms/llmUpstream.test.ts` +Expected: PASS + +## Task 8: Shared Input File Resolution And Web Helpers + +**Files:** +- Modify: `src/server/services/proxyInputFileResolver.ts` +- Modify: `src/server/services/proxyInputFileResolver.test.ts` +- Modify: `src/server/proxy-core/surfaces/inputFilesSurface.ts` +- Create: `src/web/pages/helpers/accountConnection.ts` +- Create: `src/web/pages/helpers/accountConnection.test.ts` +- Modify: `src/web/pages/Accounts.tsx` +- Modify: `src/web/pages/Tokens.tsx` +- Modify: `src/web/api.ts` +- Modify: `src/web/api.test.ts` + +**Step 1: Make route-level file inlining reuse the service implementation** + +Replace the duplicate `inputFilesSurface.ts` implementation with a re-export of the shared resolver service. + +**Step 2: Extract shared web account helpers** + +Move `resolveAccountCredentialMode`, `parsePositiveInt`, and `isTruthyFlag` into a single helper consumed by both Accounts and Tokens. + +**Step 3: De-duplicate proxy test aliases** + +Make `testProxy` / `proxyTest` and stream variants reuse the same implementation function. + +**Step 4: Verify behavior** + +Run: `npm test -- src/server/services/proxyInputFileResolver.test.ts src/web/pages/helpers/accountConnection.test.ts src/web/api.test.ts` +Expected: PASS + +## Task 9: PR1 Verification And Commit + +**Files:** +- Verify: `scripts/dev/copy-runtime-db-generated.ts` +- Verify: `scripts/dev/copy-runtime-db-generated.test.ts` +- Verify: all files touched in Tasks 2-8 + +**Step 1: Run focused regression suite** + +Run: + +```bash +npm test -- scripts/dev/copy-runtime-db-generated.test.ts src/server/proxy-core/cliProfiles/codexProfile.test.ts src/server/proxy-core/providers/headerUtils.test.ts src/server/proxy-core/surfaces/sharedSurface.test.ts src/server/routes/api/oauth.test.ts src/server/services/accountMutationWorkflow.test.ts src/server/services/modelService.discovery.test.ts src/server/services/oauth/oauthAccount.test.ts src/server/services/platforms/standardApiProvider.test.ts src/server/services/platforms/llmUpstream.test.ts src/server/services/proxyInputFileResolver.test.ts src/server/transformers/openai/responses/conversion.test.ts src/shared/tokenRouteContract.test.ts src/shared/tokenRoutePatterns.test.ts src/web/api.test.ts src/web/pages/helpers/accountConnection.test.ts +``` + +Expected: PASS + +**Step 2: Run server build** + +Run: `npm run build:server` +Expected: PASS + +**Step 3: Commit PR1 consolidation work** + +Run: + +```bash +git add docs/plans/2026-03-22-single-source-consolidation-pr1.md scripts/dev/copy-runtime-db-generated.test.ts scripts/dev/copy-runtime-db-generated.ts src/server/proxy-core/cliProfiles/codexProfile.ts src/server/proxy-core/cliProfiles/codexProfile.test.ts src/server/proxy-core/providers/headerUtils.ts src/server/proxy-core/providers/headerUtils.test.ts src/server/proxy-core/providers/claudeProviderProfile.ts src/server/proxy-core/providers/codexProviderProfile.ts src/server/proxy-core/providers/geminiCliProviderProfile.ts src/server/proxy-core/surfaces/chatSurface.ts src/server/proxy-core/surfaces/geminiSurface.ts src/server/proxy-core/surfaces/inputFilesSurface.ts src/server/proxy-core/surfaces/openAiResponsesSurface.ts src/server/proxy-core/surfaces/sharedSurface.ts src/server/proxy-core/surfaces/sharedSurface.test.ts src/server/routes/api/accountTokens.ts src/server/routes/api/accounts.ts src/server/routes/api/oauth.test.ts src/server/routes/api/tokens.ts src/server/routes/proxy/downstreamClientContext.ts src/server/routes/proxy/responsesWebsocket.ts src/server/routes/proxy/upstreamEndpoint.ts src/server/services/accountMutationWorkflow.ts src/server/services/accountMutationWorkflow.test.ts src/server/services/backupService.ts src/server/services/modelService.discovery.test.ts src/server/services/modelService.ts src/server/services/oauth/oauthAccount.ts src/server/services/oauth/oauthAccount.test.ts src/server/services/oauth/quota.ts src/server/services/oauth/service.ts src/server/services/platforms/claude.ts src/server/services/platforms/cliproxyapi.ts src/server/services/platforms/gemini.ts src/server/services/platforms/llmUpstream.test.ts src/server/services/platforms/openai.ts src/server/services/platforms/standardApiProvider.ts src/server/services/platforms/standardApiProvider.test.ts src/server/services/proxyInputFileResolver.ts src/server/services/proxyInputFileResolver.test.ts src/server/services/siteProxy.ts src/server/services/tokenRouter.ts src/server/transformers/anthropic/messages/inbound.ts src/server/transformers/openai/responses/compatibility.ts src/server/transformers/openai/responses/conversion.test.ts src/server/transformers/openai/responses/conversion.ts src/server/transformers/openai/responses/normalization.ts src/shared/tokenRouteContract.d.ts src/shared/tokenRouteContract.js src/shared/tokenRouteContract.test.ts src/shared/tokenRoutePatterns.d.ts src/shared/tokenRoutePatterns.js src/shared/tokenRoutePatterns.test.ts src/web/api.ts src/web/api.test.ts src/web/pages/Accounts.tsx src/web/pages/Settings.tsx src/web/pages/Tokens.tsx src/web/pages/helpers/accountConnection.ts src/web/pages/helpers/accountConnection.test.ts src/web/pages/helpers/routeListVisibility.ts src/web/pages/token-routes/types.ts src/web/pages/token-routes/utils.ts +git commit -m "refactor: consolidate single-source routing and protocol helpers" +``` + +### Deferred After PR1 + +**Keep out of this PR:** +- `shared/chatFormatsCore.ts` breakup +- Gemini `generate-content` module boundary cleanup +- schema contract/introspection meta-rule unification +- `legacySchemaCompat.ts` feature compatibility dedupe +- mobile page scaffold extraction +- large-file decomposition for `Settings.tsx`, `ModelTester.tsx`, `tokenRouter.ts`, `modelService.ts` diff --git a/docs/plans/2026-03-23-single-source-consolidation-mega-plan.md b/docs/plans/2026-03-23-single-source-consolidation-mega-plan.md new file mode 100644 index 00000000..b14bce5e --- /dev/null +++ b/docs/plans/2026-03-23-single-source-consolidation-mega-plan.md @@ -0,0 +1,96 @@ +# Single Source Consolidation Full-Scope Follow-Through + +## Context + +This document supersedes the scoped execution boundary of +[`2026-03-22-single-source-consolidation-pr1.md`](./2026-03-22-single-source-consolidation-pr1.md) +for PR #244. The PR1 document remains a historical snapshot of the initial +landing slice. This document captures the expanded branch-wide finish that +continued until the single-source, workflow, schema, and web admin duplication +themes were fully closed for this PR. + +## Goals + +- Eliminate active double-source implementations for proxy protocols and runtime headers. +- Centralize account, OAuth, route refresh, and platform discovery workflows. +- Collapse schema metadata normalization and feature compatibility drift points. +- Remove repeated web admin page scaffolding and large inline modal/panel implementations. +- Finish the branch in a merge-ready state with focused regressions and formal builds. + +## Scope Delivered In This Branch + +### Server and Shared Runtime + +- OpenAI Responses normalization and conversion now flow through one shared implementation boundary. +- Shared proxy surface orchestration owns retry recovery, success bookkeeping, and surface-level failure handling. +- Provider header/body runtime shaping is centralized behind provider profiles and shared header utilities. +- OAuth identity now treats structured columns as the canonical source, with compatibility/backfill isolated to a removable layer. +- Route refresh and rebuild sequencing flows through `routeRefreshWorkflow`, not controller-local orchestration. +- Platform discovery moved out of `modelService` into dedicated registry-backed discovery modules. +- Schema metadata normalization is shared by contract and introspection code paths. +- Legacy schema compatibility derives feature-owned allowances from feature specs instead of maintaining a second whitelist. + +### Web Admin + +- Token-route, brand, and downstream client display rules now rely on shared helpers and registries instead of page-local duplicates. +- `ResponsiveBatchActionBar` centralizes repeated batch action shells across admin list pages. +- `ResponsiveFilterPanel` centralizes the responsive "desktop controls vs mobile sheet" scaffold across admin pages. +- `Settings`, `Accounts`, `ModelTester`, and `DownstreamKeys` no longer keep their heaviest modal or drawer UIs inline in the page file. +- Brand matching metadata is owned by `brandRegistry`, with `BrandIcon` reduced to rendering concerns. + +## Commit Groups + +### Protocol and Workflow Consolidation + +- review-driven cleanup commits +- OAuth identity and route refresh workflow commits +- downstream client/runtime header/profile consolidation commits +- platform discovery extraction commits + +### Schema and Shared Contract Consolidation + +- shared schema metadata normalization +- legacy schema compatibility derivation from feature specs +- shared token-route contract and pattern ownership + +### Web Admin Decomposition + +- responsive batch action scaffold +- responsive filter scaffold +- settings modal extraction +- accounts model management modal extraction +- model tester panel extraction +- downstream key drawer extraction + +## Final Verification Matrix + +Run these groups before calling the branch complete. + +### Protocol and Surface + +- `npm test -- src/server/proxy-core/surfaces/sharedSurface.test.ts src/server/routes/proxy src/server/transformers/openai/responses src/server/transformers/anthropic/messages src/server/services/proxyInputFileResolver.test.ts` + +### OAuth, Workflow, and Platforms + +- `npm test -- src/server/routes/api src/server/services/accountMutationWorkflow.test.ts src/server/services/modelService.discovery.test.ts src/server/services/oauth/oauthAccount.test.ts src/server/services/platforms/standardApiProvider.test.ts src/server/services/platforms/llmUpstream.test.ts` + +### Schema + +- `npm run test:schema:unit` +- `npm run schema:contract` +- `npm run smoke:db:sqlite` + +### Web and Build + +- `npm test -- src/web/pages/responsiveFilterPanel.architecture.test.ts src/web/pages/checkin.mobile.test.tsx src/web/pages/logs.mobile.test.tsx src/web/pages/programLogs.mobile-layout.test.tsx src/web/pages/accounts.batch-actions.test.tsx src/web/pages/sites.mobile-actions.test.tsx src/web/pages/tokens.batch-actions.test.tsx src/web/pages/DownstreamKeys.test.tsx src/web/pages/DownstreamKeys.mobile.test.tsx src/web/pages/downstreamKeys.mobile-layout.test.tsx src/web/pages/tokenRoutes.mobile-layout.test.tsx src/web/pages/tokenRoutes.mobile.test.tsx src/web/pages/models.mobile-layout.test.tsx` +- `npm run build` + +## Done Definition + +The branch is complete when: + +- no page under `src/web/pages` imports `MobileFilterSheet` directly; +- no active protocol path keeps parallel single-source conversions alive; +- controller layers do not own route rebuild choreography; +- schema metadata and compatibility rules do not keep a second handwritten truth source; +- focused regressions and formal builds pass from the commands above. diff --git a/docs/project-structure.md b/docs/project-structure.md index 5e4470b0..1e0550a5 100644 --- a/docs/project-structure.md +++ b/docs/project-structure.md @@ -90,6 +90,7 @@ scripts/ docs/ ├── .vitepress/ # 文档站导航与主题配置 ├── community/ # 社区贡献规范 +├── engineering/ # 仓库级工程规则、harness 与漂移治理说明 ├── public/ # 文档站公开静态资源 ├── logos/ # 可编辑 Logo 源文件与草稿 ├── screenshots/ # 文档截图 diff --git a/docs/public/favicon-64.png b/docs/public/favicon-64.png new file mode 100644 index 00000000..85eb0598 Binary files /dev/null and b/docs/public/favicon-64.png differ diff --git a/docs/public/favicon.ico b/docs/public/favicon.ico new file mode 100644 index 00000000..85eb0598 Binary files /dev/null and b/docs/public/favicon.ico differ diff --git a/docs/public/favicon.png b/docs/public/favicon.png new file mode 100644 index 00000000..101e6f33 Binary files /dev/null and b/docs/public/favicon.png differ diff --git a/docs/screenshots/api-key-management.png b/docs/screenshots/api-key-management.png new file mode 100644 index 00000000..d214de78 Binary files /dev/null and b/docs/screenshots/api-key-management.png differ diff --git a/docs/screenshots/donehub-token.png b/docs/screenshots/donehub-token.png new file mode 100644 index 00000000..37c83a45 Binary files /dev/null and b/docs/screenshots/donehub-token.png differ diff --git a/docs/screenshots/proxy-logs-mapping.png b/docs/screenshots/proxy-logs-mapping.png new file mode 100644 index 00000000..74c88996 Binary files /dev/null and b/docs/screenshots/proxy-logs-mapping.png differ diff --git a/docs/screenshots/route-group.png b/docs/screenshots/route-group.png new file mode 100644 index 00000000..4d1feeb7 Binary files /dev/null and b/docs/screenshots/route-group.png differ diff --git a/docs/screenshots/routes-filter.png b/docs/screenshots/routes-filter.png new file mode 100644 index 00000000..50fefa24 Binary files /dev/null and b/docs/screenshots/routes-filter.png differ diff --git a/docs/screenshots/session-cookie-f12.png b/docs/screenshots/session-cookie-f12.png new file mode 100644 index 00000000..547265e9 Binary files /dev/null and b/docs/screenshots/session-cookie-f12.png differ diff --git a/docs/screenshots/sub2api-auth-f12.png b/docs/screenshots/sub2api-auth-f12.png new file mode 100644 index 00000000..60cd0650 Binary files /dev/null and b/docs/screenshots/sub2api-auth-f12.png differ diff --git a/docs/screenshots/sub2api-session-config.png b/docs/screenshots/sub2api-session-config.png new file mode 100644 index 00000000..688fd509 Binary files /dev/null and b/docs/screenshots/sub2api-session-config.png differ diff --git a/docs/upstream-integration.md b/docs/upstream-integration.md new file mode 100644 index 00000000..6afdc9ad --- /dev/null +++ b/docs/upstream-integration.md @@ -0,0 +1,500 @@ +# 🔌 上游接入指南 + +本文档详细说明如何将不同类型的 AI 中转站接入 Metapi。 + +[返回文档中心](./README.md) + +--- + +## 概述 + +Metapi 支持两大类上游站点: + +1. **中转聚合平台** — New API / One API / OneHub / DoneHub / Veloera / AnyRouter / Sub2API 等 +2. **官方 API 端点** — OpenAI / Claude (Anthropic) / Gemini (Google) 直连 + +每种站点类型的接入方式略有不同,本文档按站点类型分别说明。 + +--- + +## 🎯 快速接入流程 + +### 通用步骤 + +1. **登录管理后台** — 访问 `http://your-metapi-host:4000`,使用 `AUTH_TOKEN` 登录 +2. **进入站点管理** — 点击左侧菜单「站点管理」 +3. **添加站点** — 点击「添加站点」按钮 +4. **填写站点信息** — 根据站点类型填写对应字段(见下文详细说明) +5. **添加账号** — 站点创建后,在站点详情页添加账号凭证 +6. **验证连接** — 系统自动验证账号可用性并获取模型列表 + +--- + +## 📦 中转聚合平台接入 + +### New API + +**适用平台:** New API 及其衍生版本(AnyRouter、VO-API、Super-API、RIX-API、Neo-API 等) + +#### 站点配置 + +| 字段 | 说明 | 示例 | +|------|------|------| +| **站点名称** | 自定义名称,便于识别 | `我的 New API` | +| **站点 URL** | New API 部署地址(**不含** `/v1` 后缀) | `https://api.example.com` | +| **平台类型** | 选择 `new-api` | - | +| **代理 URL** | (可选)该站点专用代理地址 | `http://proxy.example.com:7890` | +| **使用系统代理** | 是否使用全局 `SYSTEM_PROXY_URL` | 默认关闭 | + +#### 账号凭证类型 + +New API 支持三种凭证类型: + +##### 1. 用户名密码登录(推荐AnyRouter使用) + +- **适用场景:** 有完整账号权限,需要自动签到、余额查询、账号令牌管理 +- **填写方式:** + - 用户名:`your-username` + - 密码:`your-password` +- **自动获取:** 系统自动登录并获取 Access Token 和账号令牌 + +##### 2. Access Token / Session Cookie + +- **适用场景:** 已有登录凭证,无需密码 +- **填写方式:** + - 在「Access Token」字段填入以下任一格式: + - Session Cookie:**(最不推荐)** + - 可通过浏览器F12获取 ![Session Cookie 获取](./screenshots/session-cookie-f12.png) + - 一般为如下格式:`session=MTczNjQxMjM0NXxEdi1CQUFFQ180SUFBUkFCRUFBQVB2LUNBQUVHYzNSeWFXNW5EQThBRFhObGMzTnBiMjVmZEdGaWJHVUdjM1J5YVc1bkRBSUFBQT09fGRlYWRiZWVmMTIzNDU2Nzg5MGFiY2RlZjEyMzQ1Njc4OTBhYmNkZWY=` + - 系统访问令牌和用户ID**(推荐非Anyrouter的其他New API站点使用)** + - ![](./screenshots/account-management.png) +- **自动解析:** 系统自动识别凭证类型并提取用户信息 + +##### 3. API Key(仅代理) + +- **适用场景:** 仅用于模型调用,不需要余额管理、自动签到等功能 +- **填写方式:** + - 在「API Token」字段填入:`sk-xxxxxxxxxxxxxx` +- **限制:** 无法使用签到、余额刷新、账号令牌管理等功能 + +#### 特殊说明 + +**User ID 自动探测:** New API 通常需要在请求头中携带 `New-API-User` / `Veloera-User` / `voapi-user` 等字段。Metapi 会自动: + +1. 从 JWT Token 中解码 User ID +2. 从 Session Cookie 中提取 User ID(支持 Gob 编码解析) +3. 通过探测常见 ID 范围验证可用性 + +如果以上方法都不能获取到ID,则需要用户手动获取。 + +**防护盾穿透:** 自动处理阿里云盾 / Cloudflare 等 JS 挑战(`acw_sc__v2` / `cdn_sec_tc`),无需手动配置。 + +--- + +### One API + +**适用平台:** One API 原版及兼容分支 + +#### 站点配置 + +| 字段 | 说明 | 示例 | +|------|------|------| +| **站点名称** | 自定义名称 | `One API 主站` | +| **站点 URL** | One API 部署地址 | `https://oneapi.example.com` | +| **平台类型** | 选择 `one-api` | - | + +#### 账号凭证 + +One API 支持与 New API 相同的三种凭证类型(用户名密码 / Access Token / API Key),配置方式相同。 + +--- + +### OneHub + +**适用平台:** OneHub(One API 增强分支) + +#### 站点配置 + +| 字段 | 说明 | 示例 | +|------|------|------| +| **站点名称** | 自定义名称 | `OneHub 站点` | +| **站点 URL** | OneHub 部署地址 | `https://onehub.example.com` | +| **平台类型** | 选择 `one-hub` | - | + +#### 账号凭证 + +OneHub 继承 One API 的凭证体系,支持用户名密码、Access Token、API Key 三种方式。 + +**额外功能:** OneHub 支持 Token 分组(`token_group`),Metapi 会自动识别并保留分组信息。 + +--- + +### DoneHub + +**适用平台:** DoneHub(OneHub 增强分支) + +#### 站点配置 + +| 字段 | 说明 | 示例 | +|------|------|------| +| **站点名称** | 自定义名称 | `DoneHub 站点` | +| **站点 URL** | DoneHub 部署地址 | `https://donehub.example.com` | +| **平台类型** | 选择 `done-hub` | - | + +#### 账号凭证 + +DoneHub 完全兼容 OneHub 的凭证体系,配置方式相同,DoneHub获取令牌位置如下图所示:![DoneHub 令牌位置](./screenshots/donehub-token.png) + +--- + +### Veloera + +**适用平台:** Veloera API 网关 + +#### 站点配置 + +| 字段 | 说明 | 示例 | +|------|------|------| +| **站点名称** | 自定义名称 | `Veloera 网关` | +| **站点 URL** | Veloera 部署地址 | `https://veloera.example.com` | +| **平台类型** | 选择 `veloera` | - | + +#### 账号凭证 + +Veloera 基于 New API 架构,支持相同的凭证类型。特别注意: +- Veloera 需要 `Veloera-User` 请求头,Metapi 会自动添加 + +--- + +### Sub2API + +**适用平台:** Sub2API 订阅制中转平台 + +#### 站点配置 + +| 字段 | 说明 | 示例 | +|------|------|------| +| **站点名称** | 自定义名称 | `Sub2API 订阅站` | +| **站点 URL** | Sub2API 部署地址 | `https://sub2api.example.com` | +| **平台类型** | 选择 `sub2api` | - | + +#### 账号凭证 + +Sub2API 常见 JWT 短期会话机制,和传统 NewAPI 站点差异较大。按下面步骤进行添加: + +首先去中转站点F12打开如下界面: + +![Sub2API 认证字段示例](./screenshots/sub2api-auth-f12.png) + +然后回到Metapi账号添加处: + +![Sub2API Session 配置](./screenshots/sub2api-session-config.png) + +1. 在「凭证模式」里选择 Session 模式,分别粘贴F12界面中的auth_token、refresh_token、token_expires_at字段进行验证,无需配置用户ID。 +2. 不要使用账号密码登录,Metapi 不支持代替 Sub2API 做登录 +3. Sub2API 通常为订阅制使用,不支持签到;如果你只关心代理调用,也可以直接改用 API Key 模式 +4. 若 `GET /v1/models` 为空,先确认该账号下已有可用用户 API Key,Metapi 会再尝试用它发现模型 + +--- + +## 🌐 官方 API 端点接入 + +### OpenAI + +**适用场景:** 直连 OpenAI 官方 API 或 OpenAI 兼容端点 + +#### 站点配置 + +| 字段 | 说明 | 示例 | +|------|------|------| +| **站点名称** | 自定义名称 | `OpenAI 官方` | +| **站点 URL** | OpenAI API 端点(**不含** `/v1` 后缀) | `https://api.openai.com` | +| **平台类型** | 选择 `openai` | - | +| **代理 URL** | (推荐)配置代理以访问 OpenAI | `http://proxy.example.com:7890` | + +#### 账号凭证 + +**仅支持 API Key:** +- 在「API Token」字段填入:`sk-proj-xxxxxxxxxxxxxx` +- 不支持用户名密码登录(OpenAI 无此接口) + +#### 功能限制 + +| 功能 | 支持情况 | +|------|----------| +| 模型列表获取 | ✅ 支持(`/v1/models`) | +| 代理调用 | ✅ 支持 | +| 余额查询 | ❌ 不支持(OpenAI 无公开接口) | +| 自动签到 | ❌ 不适用 | +| 账号令牌管理 | ❌ 不适用 | + +--- + +### Claude (Anthropic) + +**适用场景:** 直连 Anthropic Claude API + +#### 站点配置 + +| 字段 | 说明 | 示例 | +|------|------|------| +| **站点名称** | 自定义名称 | `Claude 官方` | +| **站点 URL** | Anthropic API 端点 | `https://api.anthropic.com` | +| **平台类型** | 选择 `claude` | - | + +#### 账号凭证 + +**仅支持 API Key:** +- 在「API Token」字段填入:`sk-ant-api03-xxxxxxxxxxxxxx` + +#### 功能限制 + +| 功能 | 支持情况 | +|------|----------| +| 模型列表获取 | ✅ 支持(内置模型目录) | +| 代理调用 | ✅ 支持(自动转换 OpenAI ⇄ Claude 格式) | +| 余额查询 | ❌ 不支持 | +| 自动签到 | ❌ 不适用 | +| 账号令牌管理 | ❌ 不适用 | + +**协议转换:** Metapi 自动处理 OpenAI 格式与 Claude Messages API 格式的双向转换,下游客户端可使用 OpenAI SDK 调用 Claude 模型。 + +--- + +### Gemini (Google) + +**适用场景:** 直连 Google Gemini API + +#### 站点配置 + +| 字段 | 说明 | 示例 | +|------|------|------| +| **站点名称** | 自定义名称 | `Gemini 官方` | +| **站点 URL** | Gemini API 端点 | `https://generativelanguage.googleapis.com` | +| **平台类型** | 选择 `gemini` | - | + +#### 账号凭证 + +**仅支持 API Key:** +- 在「API Token」字段填入:`AIzaSyxxxxxxxxxxxxxx` + +#### 功能限制 + +| 功能 | 支持情况 | +|------|----------| +| 模型列表获取 | ✅ 支持(`/v1beta/models`) | +| 代理调用 | ✅ 支持(自动转换 OpenAI ⇄ Gemini 格式) | +| 余额查询 | ❌ 不支持 | +| 自动签到 | ❌ 不适用 | +| 账号令牌管理 | ❌ 不适用 | + +**协议转换:** Metapi 自动处理 OpenAI 格式与 Gemini `generateContent` API 格式的双向转换。 + +--- + +## 🔧 高级配置 + +### 站点级代理 + +每个站点可单独配置代理,优先级高于全局 `SYSTEM_PROXY_URL`: + +``` +站点专用代理 > 全局系统代理 > 直连 +``` + +**配置方式:** +1. 在站点编辑页面填写「代理 URL」字段 +2. 格式:`http://proxy-host:port` 或 `socks5://proxy-host:port` +3. 支持 HTTP / HTTPS / SOCKS5 代理 + +### 站点权重 + +**全局权重(`global_weight`):** 影响该站点下所有通道的路由概率。 + +- 默认值:`1.0` +- 范围:`0.1` ~ `10.0` +- 示例: + - 设置为 `2.0` — 该站点通道被选中的概率翻倍 + - 设置为 `0.5` — 该站点通道被选中的概率减半 + +**配置位置:** 站点编辑页面 → 高级设置 → 全局权重 + +### 外部签到 URL + +**适用场景:** 某些站点的签到接口非标准路径,或需要通过外部服务触发签到。 + +**配置方式:** +1. 在站点编辑页面填写「外部签到 URL」 +2. Metapi 会向该 URL 发送 POST 请求执行签到 +3. 请求头自动携带账号凭证 + +--- + +## 🔍 站点自动检测 + +Metapi 支持自动识别站点类型,检测优先级如下: + +### 1. URL 特征检测 + +根据 URL 中的关键字自动识别: + +| URL 特征 | 识别为 | +|----------|--------| +| `api.openai.com` | OpenAI | +| `api.anthropic.com` | Claude | +| `generativelanguage.googleapis.com` | Gemini | +| `anyrouter` | AnyRouter | +| `donehub` / `done-hub` | DoneHub | +| `onehub` / `one-hub` | OneHub | +| `veloera` | Veloera | +| `sub2api` | Sub2API | + +### 2. 页面标题检测 + +访问站点首页,解析 `` 标签识别平台类型。 + +### 3. API 探测 + +依次尝试各平台的特征接口: +- New API:`/api/status` 返回 `system_name` +- One API:`/api/status` 返回特定结构 +- OpenAI:`/v1/models` 返回模型列表 + +**手动指定:** 如果自动检测失败,可在添加站点时手动选择平台类型。 + +--- + +## 📊 账号健康状态 + +Metapi 自动追踪每个账号的健康状态: + +| 状态 | 说明 | 触发条件 | +|------|------|----------| +| `healthy` | 健康 | 最近请求成功,余额充足 | +| `degraded` | 降级 | 部分模型不可用,或余额不足 | +| `unhealthy` | 不健康 | 连续失败,或凭证过期 | +| `disabled` | 已禁用 | 手动禁用或站点禁用 | + +**自动恢复:** `unhealthy` 状态的账号会定期重试,成功后自动恢复为 `healthy`。 + +--- + +## 📰 站点公告 + +Metapi 会定期抓取已接入站点的公告,并在首次发现时写入站内通知与「站点公告」页面。 + +当前支持的公告来源: + +- `new-api`:读取 `/api/notice` 的全站公告 +- `done-hub`:读取 `/api/notice` 的全站公告 +- `sub2api`:读取 `/api/v1/announcements` 的公告列表 + +行为约定: + +1. 首次发现的公告会触发一次 Metapi 通知 +2. 已经保存过的公告再次同步时只更新本地记录 +3. `sub2api` 这类需要登录态的公告接口,会优先使用站点下已启用账号的会话令牌 +4. 「站点公告」页面的清空操作只影响 Metapi 本地数据库 + +--- + +## 🛠️ 故障排查 + +### 问题:添加站点后无法获取模型列表 + +**可能原因:** +1. 站点 URL 填写错误(检查是否包含 `/v1` 后缀,应去除) +2. 网络不通(检查代理配置或防火墙) +3. 凭证无效(重新验证账号密码或 Token) + +**解决方法:** +- 在站点详情页点击「测试连接」 +- 查看「事件日志」中的错误信息 +- 检查「代理日志」中的请求详情 + +### 问题:New API 账号提示「需要 New-API-User 头」 + +**原因:** 该站点是 New API 衍生版本,需要 User ID。 + +**解决方法:** +1. Metapi 会自动探测 User ID,通常无需手动配置 +2. 如果自动探测失败,可在账号编辑页面的「额外配置」中手动填写: + ```json + { + "platformUserId": 12345 + } + ``` + +### 问题:签到失败 + +**可能原因:** +1. 凭证过期(Access Token 有效期到期) +2. 站点签到接口变更 +3. 已经签到过(部分站点限制每日一次) + +**解决方法:** +- 查看「签到记录」中的失败原因 +- 尝试重新登录获取新凭证 +- 配置「外部签到 URL」使用备用签到方式 + +### 问题:余额显示不准确 + +**原因:** 不同平台的余额单位不同。 + +**说明:** +- New API 系列:余额单位为「美元」,内部存储为 `quota / 500000` +- OpenAI / Claude / Gemini:官方 API 无余额查询接口,显示为 `N/A` + +--- + +## 📝 最佳实践 + +### 1. 凭证选择建议 + +| 场景 | 推荐凭证类型 | 原因 | +|------|-------------|------| +| 个人站点,需要完整功能 | 用户名密码 | 支持自动签到、账号令牌管理 | +| 共享账号,只读权限 | Access Token | 避免密码泄露 | +| 仅用于模型调用 | API Token | 最小权限原则 | + +### 2. 站点命名规范 + +建议使用清晰的命名规则,便于管理: + +``` +[平台类型] - [站点特征] - [用途] +``` + +示例: +- `New API - 主站 - 生产环境` +- `OneHub - 备用 - 测试` +- `OpenAI - 官方 - 高优先级` + +### 3. 代理配置策略 + +- **国内访问 OpenAI / Claude / Gemini:** 必须配置代理 +- **国内中转站:** 通常不需要代理 +- **海外中转站:** 根据网络情况选择 + +### 4. 定期维护 + +- **每周检查:** 账号健康状态、余额预警 +- **每月清理:** 禁用长期不可用的站点和账号 +- **凭证轮换:** 定期更新 Access Token 和 API Key + +--- + +## 🔗 相关文档 + +- [配置说明](./configuration.md) — 环境变量与路由参数 +- [客户端接入](./client-integration.md) — 下游应用配置 +- [常见问题](./faq.md) — 故障排查与优化建议 + +--- + +## 💡 提示 + +- 添加站点后,系统会自动发现可用模型并生成路由表,无需手动配置 +- 支持同时接入多个相同类型的站点(如多个 New API 实例) +- 站点禁用后,关联的所有账号和路由通道会自动禁用 +- 删除站点会级联删除所有关联账号、Token 和路由配置,请谨慎操作 diff --git a/drizzle/0006_site_disabled_models.sql b/drizzle/0006_site_disabled_models.sql new file mode 100644 index 00000000..020fc917 --- /dev/null +++ b/drizzle/0006_site_disabled_models.sql @@ -0,0 +1,13 @@ +CREATE TABLE IF NOT EXISTS `site_disabled_models` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `site_id` integer NOT NULL, + `model_name` text NOT NULL, + `created_at` text DEFAULT (datetime('now')), + FOREIGN KEY (`site_id`) REFERENCES `sites`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS `site_disabled_models_site_model_unique` +ON `site_disabled_models` (`site_id`, `model_name`); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS `site_disabled_models_site_id_idx` +ON `site_disabled_models` (`site_id`); diff --git a/drizzle/0007_account_token_group.sql b/drizzle/0007_account_token_group.sql new file mode 100644 index 00000000..9adddae0 --- /dev/null +++ b/drizzle/0007_account_token_group.sql @@ -0,0 +1 @@ +ALTER TABLE `account_tokens` ADD `token_group` text; diff --git a/drizzle/0008_sqlite_schema_backfill.sql b/drizzle/0008_sqlite_schema_backfill.sql new file mode 100644 index 00000000..0251ba36 --- /dev/null +++ b/drizzle/0008_sqlite_schema_backfill.sql @@ -0,0 +1,214 @@ +ALTER TABLE `sites` ADD `proxy_url` text; +--> statement-breakpoint +ALTER TABLE `sites` ADD `use_system_proxy` integer DEFAULT false; +--> statement-breakpoint +UPDATE `sites` +SET `use_system_proxy` = false +WHERE `use_system_proxy` IS NULL; +--> statement-breakpoint +ALTER TABLE `sites` ADD `custom_headers` text; +--> statement-breakpoint +ALTER TABLE `sites` ADD `external_checkin_url` text; +--> statement-breakpoint +ALTER TABLE `sites` ADD `global_weight` real DEFAULT 1; +--> statement-breakpoint +UPDATE `sites` +SET `global_weight` = 1 +WHERE `global_weight` IS NULL + OR `global_weight` <= 0; +--> statement-breakpoint +ALTER TABLE `token_routes` ADD `display_name` text; +--> statement-breakpoint +ALTER TABLE `token_routes` ADD `display_icon` text; +--> statement-breakpoint +ALTER TABLE `token_routes` ADD `decision_snapshot` text; +--> statement-breakpoint +ALTER TABLE `token_routes` ADD `decision_refreshed_at` text; +--> statement-breakpoint +ALTER TABLE `token_routes` ADD `routing_strategy` text DEFAULT 'weighted'; +--> statement-breakpoint +ALTER TABLE `route_channels` ADD `source_model` text; +--> statement-breakpoint +ALTER TABLE `route_channels` ADD `last_selected_at` text; +--> statement-breakpoint +ALTER TABLE `route_channels` ADD `consecutive_fail_count` integer NOT NULL DEFAULT 0; +--> statement-breakpoint +ALTER TABLE `route_channels` ADD `cooldown_level` integer NOT NULL DEFAULT 0; +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS `downstream_api_keys` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `name` text NOT NULL, + `key` text NOT NULL, + `description` text, + `enabled` integer DEFAULT true, + `expires_at` text, + `max_cost` real, + `used_cost` real DEFAULT 0, + `max_requests` integer, + `used_requests` integer DEFAULT 0, + `supported_models` text, + `allowed_route_ids` text, + `site_weight_multipliers` text, + `last_used_at` text, + `created_at` text DEFAULT (datetime('now')), + `updated_at` text DEFAULT (datetime('now')) +); +--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS `downstream_api_keys_key_unique` +ON `downstream_api_keys` (`key`); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS `downstream_api_keys_name_idx` +ON `downstream_api_keys` (`name`); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS `downstream_api_keys_enabled_idx` +ON `downstream_api_keys` (`enabled`); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS `downstream_api_keys_expires_at_idx` +ON `downstream_api_keys` (`expires_at`); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS `proxy_files` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `public_id` text NOT NULL, + `owner_type` text NOT NULL, + `owner_id` text NOT NULL, + `filename` text NOT NULL, + `mime_type` text NOT NULL, + `purpose` text, + `byte_size` integer NOT NULL, + `sha256` text NOT NULL, + `content_base64` text NOT NULL, + `created_at` text DEFAULT (datetime('now')), + `updated_at` text DEFAULT (datetime('now')), + `deleted_at` text +); +--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS `proxy_files_public_id_unique` +ON `proxy_files` (`public_id`); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS `proxy_files_owner_lookup_idx` +ON `proxy_files` (`owner_type`, `owner_id`, `deleted_at`); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS `proxy_video_tasks` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `public_id` text NOT NULL, + `upstream_video_id` text NOT NULL, + `site_url` text NOT NULL, + `token_value` text NOT NULL, + `requested_model` text, + `actual_model` text, + `channel_id` integer, + `account_id` integer, + `status_snapshot` text, + `upstream_response_meta` text, + `last_upstream_status` integer, + `last_polled_at` text, + `created_at` text DEFAULT (datetime('now')), + `updated_at` text DEFAULT (datetime('now')) +); +--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS `proxy_video_tasks_public_id_unique` +ON `proxy_video_tasks` (`public_id`); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS `proxy_video_tasks_upstream_video_id_idx` +ON `proxy_video_tasks` (`upstream_video_id`); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS `proxy_video_tasks_created_at_idx` +ON `proxy_video_tasks` (`created_at`); +--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS `model_availability_account_model_unique` +ON `model_availability` (`account_id`, `model_name`); +--> statement-breakpoint +PRAGMA foreign_keys=OFF; +--> statement-breakpoint +CREATE TABLE `__new_route_channels` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `route_id` integer NOT NULL, + `account_id` integer NOT NULL, + `token_id` integer, + `source_model` text, + `priority` integer DEFAULT 0, + `weight` integer DEFAULT 10, + `enabled` integer DEFAULT true, + `manual_override` integer DEFAULT false, + `success_count` integer DEFAULT 0, + `fail_count` integer DEFAULT 0, + `total_latency_ms` integer DEFAULT 0, + `total_cost` real DEFAULT 0, + `last_used_at` text, + `last_selected_at` text, + `last_fail_at` text, + `consecutive_fail_count` integer NOT NULL DEFAULT 0, + `cooldown_level` integer NOT NULL DEFAULT 0, + `cooldown_until` text, + FOREIGN KEY (`route_id`) REFERENCES `token_routes`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`account_id`) REFERENCES `accounts`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`token_id`) REFERENCES `account_tokens`(`id`) ON UPDATE no action ON DELETE set null +); +--> statement-breakpoint +INSERT INTO `__new_route_channels` ( + `id`, + `route_id`, + `account_id`, + `token_id`, + `source_model`, + `priority`, + `weight`, + `enabled`, + `manual_override`, + `success_count`, + `fail_count`, + `total_latency_ms`, + `total_cost`, + `last_used_at`, + `last_selected_at`, + `last_fail_at`, + `consecutive_fail_count`, + `cooldown_level`, + `cooldown_until` +) +SELECT + `id`, + `route_id`, + `account_id`, + CASE + WHEN `token_id` IS NULL THEN NULL + WHEN EXISTS (SELECT 1 FROM `account_tokens` WHERE `account_tokens`.`id` = `route_channels`.`token_id`) THEN `token_id` + ELSE NULL + END, + `source_model`, + `priority`, + `weight`, + `enabled`, + `manual_override`, + `success_count`, + `fail_count`, + `total_latency_ms`, + `total_cost`, + `last_used_at`, + `last_selected_at`, + `last_fail_at`, + `consecutive_fail_count`, + `cooldown_level`, + `cooldown_until` +FROM `route_channels`; +--> statement-breakpoint +DROP TABLE `route_channels`; +--> statement-breakpoint +ALTER TABLE `__new_route_channels` RENAME TO `route_channels`; +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS `route_channels_route_id_idx` +ON `route_channels` (`route_id`); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS `route_channels_account_id_idx` +ON `route_channels` (`account_id`); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS `route_channels_token_id_idx` +ON `route_channels` (`token_id`); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS `route_channels_route_enabled_idx` +ON `route_channels` (`route_id`, `enabled`); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS `route_channels_route_token_idx` +ON `route_channels` (`route_id`, `token_id`); +--> statement-breakpoint +PRAGMA foreign_keys=ON; diff --git a/drizzle/0009_model_availability_is_manual.sql b/drizzle/0009_model_availability_is_manual.sql new file mode 100644 index 00000000..3d381703 --- /dev/null +++ b/drizzle/0009_model_availability_is_manual.sql @@ -0,0 +1 @@ +ALTER TABLE `model_availability` ADD `is_manual` integer DEFAULT false; \ No newline at end of file diff --git a/drizzle/0010_proxy_logs_downstream_api_key.sql b/drizzle/0010_proxy_logs_downstream_api_key.sql new file mode 100644 index 00000000..67ccbf8f --- /dev/null +++ b/drizzle/0010_proxy_logs_downstream_api_key.sql @@ -0,0 +1,3 @@ +ALTER TABLE `proxy_logs` ADD `downstream_api_key_id` integer; +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS `proxy_logs_downstream_api_key_created_at_idx` ON `proxy_logs` (`downstream_api_key_id`, `created_at`); diff --git a/drizzle/0011_downstream_api_key_metadata.sql b/drizzle/0011_downstream_api_key_metadata.sql new file mode 100644 index 00000000..5a99895f --- /dev/null +++ b/drizzle/0011_downstream_api_key_metadata.sql @@ -0,0 +1,3 @@ +ALTER TABLE `downstream_api_keys` ADD `group_name` text; +--> statement-breakpoint +ALTER TABLE `downstream_api_keys` ADD `tags` text; diff --git a/drizzle/0012_account_token_value_status.sql b/drizzle/0012_account_token_value_status.sql new file mode 100644 index 00000000..0e999eb3 --- /dev/null +++ b/drizzle/0012_account_token_value_status.sql @@ -0,0 +1 @@ +ALTER TABLE `account_tokens` ADD `value_status` text DEFAULT 'ready' NOT NULL; diff --git a/drizzle/0013_oauth_multi_provider.sql b/drizzle/0013_oauth_multi_provider.sql new file mode 100644 index 00000000..92d53454 --- /dev/null +++ b/drizzle/0013_oauth_multi_provider.sql @@ -0,0 +1,24 @@ +ALTER TABLE `accounts` ADD `oauth_provider` text; +--> statement-breakpoint +ALTER TABLE `accounts` ADD `oauth_account_key` text; +--> statement-breakpoint +ALTER TABLE `accounts` ADD `oauth_project_id` text; +--> statement-breakpoint +UPDATE `accounts` +SET + `oauth_provider` = NULLIF(TRIM(json_extract(`extra_config`, '$.oauth.provider')), ''), + `oauth_account_key` = NULLIF( + TRIM(COALESCE( + json_extract(`extra_config`, '$.oauth.accountKey'), + json_extract(`extra_config`, '$.oauth.accountId') + )), + '' + ), + `oauth_project_id` = NULLIF(TRIM(json_extract(`extra_config`, '$.oauth.projectId')), '') +WHERE `extra_config` IS NOT NULL; +--> statement-breakpoint +CREATE INDEX `accounts_oauth_provider_idx` ON `accounts` (`oauth_provider`); +--> statement-breakpoint +CREATE INDEX `accounts_oauth_identity_idx` ON `accounts` (`oauth_provider`,`oauth_account_key`,`oauth_project_id`); +--> statement-breakpoint +CREATE UNIQUE INDEX `sites_platform_url_unique` ON `sites` (`platform`,`url`); diff --git a/drizzle/0014_explicit_group_routes.sql b/drizzle/0014_explicit_group_routes.sql new file mode 100644 index 00000000..24d57228 --- /dev/null +++ b/drizzle/0014_explicit_group_routes.sql @@ -0,0 +1,13 @@ +ALTER TABLE `token_routes` ADD `route_mode` text DEFAULT 'pattern'; +--> statement-breakpoint +CREATE TABLE `route_group_sources` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `group_route_id` integer NOT NULL, + `source_route_id` integer NOT NULL, + FOREIGN KEY (`group_route_id`) REFERENCES `token_routes`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`source_route_id`) REFERENCES `token_routes`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE UNIQUE INDEX `route_group_sources_group_source_unique` ON `route_group_sources` (`group_route_id`,`source_route_id`); +--> statement-breakpoint +CREATE INDEX `route_group_sources_source_route_id_idx` ON `route_group_sources` (`source_route_id`); diff --git a/drizzle/0015_site_announcements.sql b/drizzle/0015_site_announcements.sql new file mode 100644 index 00000000..ea42d23b --- /dev/null +++ b/drizzle/0015_site_announcements.sql @@ -0,0 +1,26 @@ +CREATE TABLE `site_announcements` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `site_id` integer NOT NULL, + `platform` text NOT NULL, + `source_key` text NOT NULL, + `title` text NOT NULL, + `content` text NOT NULL, + `level` text DEFAULT 'info' NOT NULL, + `source_url` text, + `starts_at` text, + `ends_at` text, + `upstream_created_at` text, + `upstream_updated_at` text, + `first_seen_at` text DEFAULT (datetime('now')), + `last_seen_at` text DEFAULT (datetime('now')), + `read_at` text, + `dismissed_at` text, + `raw_payload` text, + FOREIGN KEY (`site_id`) REFERENCES `sites`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE UNIQUE INDEX `site_announcements_site_source_key_unique` ON `site_announcements` (`site_id`,`source_key`); +--> statement-breakpoint +CREATE INDEX `site_announcements_site_id_first_seen_at_idx` ON `site_announcements` (`site_id`,`first_seen_at`); +--> statement-breakpoint +CREATE INDEX `site_announcements_read_at_idx` ON `site_announcements` (`read_at`); diff --git a/drizzle/0016_proxy_logs_client_fields.sql b/drizzle/0016_proxy_logs_client_fields.sql new file mode 100644 index 00000000..3e06c023 --- /dev/null +++ b/drizzle/0016_proxy_logs_client_fields.sql @@ -0,0 +1,6 @@ +ALTER TABLE `proxy_logs` ADD `client_family` text;--> statement-breakpoint +ALTER TABLE `proxy_logs` ADD `client_app_id` text;--> statement-breakpoint +ALTER TABLE `proxy_logs` ADD `client_app_name` text;--> statement-breakpoint +ALTER TABLE `proxy_logs` ADD `client_confidence` text;--> statement-breakpoint +CREATE INDEX `proxy_logs_client_app_id_created_at_idx` ON `proxy_logs` (`client_app_id`,`created_at`);--> statement-breakpoint +CREATE INDEX `proxy_logs_client_family_created_at_idx` ON `proxy_logs` (`client_family`,`created_at`); diff --git a/drizzle/meta/0008_snapshot.json b/drizzle/meta/0008_snapshot.json new file mode 100644 index 00000000..cdc0af3f --- /dev/null +++ b/drizzle/meta/0008_snapshot.json @@ -0,0 +1,1855 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "4bdb796c-cedc-4b81-8bfb-78c5e0bde8c0", + "prevId": "886fa1ac-d97d-4a9d-bd5e-354568cba09d", + "tables": { + "account_tokens": { + "name": "account_tokens", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "account_id": { + "name": "account_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token_group": { + "name": "token_group", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'manual'" + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": true + }, + "is_default": { + "name": "is_default", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(datetime('now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(datetime('now'))" + } + }, + "indexes": { + "account_tokens_account_id_idx": { + "name": "account_tokens_account_id_idx", + "columns": [ + "account_id" + ], + "isUnique": false + }, + "account_tokens_account_enabled_idx": { + "name": "account_tokens_account_enabled_idx", + "columns": [ + "account_id", + "enabled" + ], + "isUnique": false + }, + "account_tokens_enabled_idx": { + "name": "account_tokens_enabled_idx", + "columns": [ + "enabled" + ], + "isUnique": false + } + }, + "foreignKeys": { + "account_tokens_account_id_accounts_id_fk": { + "name": "account_tokens_account_id_accounts_id_fk", + "tableFrom": "account_tokens", + "tableTo": "accounts", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "accounts": { + "name": "accounts", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "site_id": { + "name": "site_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "api_token": { + "name": "api_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "balance": { + "name": "balance", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "balance_used": { + "name": "balance_used", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "quota": { + "name": "quota", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "unit_cost": { + "name": "unit_cost", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "value_score": { + "name": "value_score", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'active'" + }, + "is_pinned": { + "name": "is_pinned", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "checkin_enabled": { + "name": "checkin_enabled", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": true + }, + "last_checkin_at": { + "name": "last_checkin_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_balance_refresh": { + "name": "last_balance_refresh", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "extra_config": { + "name": "extra_config", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(datetime('now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(datetime('now'))" + } + }, + "indexes": { + "accounts_site_id_idx": { + "name": "accounts_site_id_idx", + "columns": [ + "site_id" + ], + "isUnique": false + }, + "accounts_status_idx": { + "name": "accounts_status_idx", + "columns": [ + "status" + ], + "isUnique": false + }, + "accounts_site_status_idx": { + "name": "accounts_site_status_idx", + "columns": [ + "site_id", + "status" + ], + "isUnique": false + } + }, + "foreignKeys": { + "accounts_site_id_sites_id_fk": { + "name": "accounts_site_id_sites_id_fk", + "tableFrom": "accounts", + "tableTo": "sites", + "columnsFrom": [ + "site_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "checkin_logs": { + "name": "checkin_logs", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "account_id": { + "name": "account_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reward": { + "name": "reward", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(datetime('now'))" + } + }, + "indexes": { + "checkin_logs_account_created_at_idx": { + "name": "checkin_logs_account_created_at_idx", + "columns": [ + "account_id", + "created_at" + ], + "isUnique": false + }, + "checkin_logs_created_at_idx": { + "name": "checkin_logs_created_at_idx", + "columns": [ + "created_at" + ], + "isUnique": false + }, + "checkin_logs_status_idx": { + "name": "checkin_logs_status_idx", + "columns": [ + "status" + ], + "isUnique": false + } + }, + "foreignKeys": { + "checkin_logs_account_id_accounts_id_fk": { + "name": "checkin_logs_account_id_accounts_id_fk", + "tableFrom": "checkin_logs", + "tableTo": "accounts", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "downstream_api_keys": { + "name": "downstream_api_keys", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": true + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "max_cost": { + "name": "max_cost", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "used_cost": { + "name": "used_cost", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "max_requests": { + "name": "max_requests", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "used_requests": { + "name": "used_requests", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "supported_models": { + "name": "supported_models", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "allowed_route_ids": { + "name": "allowed_route_ids", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "site_weight_multipliers": { + "name": "site_weight_multipliers", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(datetime('now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(datetime('now'))" + } + }, + "indexes": { + "downstream_api_keys_key_unique": { + "name": "downstream_api_keys_key_unique", + "columns": [ + "key" + ], + "isUnique": true + }, + "downstream_api_keys_name_idx": { + "name": "downstream_api_keys_name_idx", + "columns": [ + "name" + ], + "isUnique": false + }, + "downstream_api_keys_enabled_idx": { + "name": "downstream_api_keys_enabled_idx", + "columns": [ + "enabled" + ], + "isUnique": false + }, + "downstream_api_keys_expires_at_idx": { + "name": "downstream_api_keys_expires_at_idx", + "columns": [ + "expires_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "events": { + "name": "events", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "level": { + "name": "level", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'info'" + }, + "read": { + "name": "read", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "related_id": { + "name": "related_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "related_type": { + "name": "related_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(datetime('now'))" + } + }, + "indexes": { + "events_read_created_at_idx": { + "name": "events_read_created_at_idx", + "columns": [ + "read", + "created_at" + ], + "isUnique": false + }, + "events_type_created_at_idx": { + "name": "events_type_created_at_idx", + "columns": [ + "type", + "created_at" + ], + "isUnique": false + }, + "events_created_at_idx": { + "name": "events_created_at_idx", + "columns": [ + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "model_availability": { + "name": "model_availability", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "account_id": { + "name": "account_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "model_name": { + "name": "model_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "available": { + "name": "available", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_manual": { + "name": "is_manual", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "latency_ms": { + "name": "latency_ms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "checked_at": { + "name": "checked_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(datetime('now'))" + } + }, + "indexes": { + "model_availability_account_model_unique": { + "name": "model_availability_account_model_unique", + "columns": [ + "account_id", + "model_name" + ], + "isUnique": true + }, + "model_availability_account_available_idx": { + "name": "model_availability_account_available_idx", + "columns": [ + "account_id", + "available" + ], + "isUnique": false + }, + "model_availability_model_name_idx": { + "name": "model_availability_model_name_idx", + "columns": [ + "model_name" + ], + "isUnique": false + } + }, + "foreignKeys": { + "model_availability_account_id_accounts_id_fk": { + "name": "model_availability_account_id_accounts_id_fk", + "tableFrom": "model_availability", + "tableTo": "accounts", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "proxy_files": { + "name": "proxy_files", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "public_id": { + "name": "public_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "owner_type": { + "name": "owner_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "filename": { + "name": "filename", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "purpose": { + "name": "purpose", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "byte_size": { + "name": "byte_size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sha256": { + "name": "sha256", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content_base64": { + "name": "content_base64", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(datetime('now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(datetime('now'))" + }, + "deleted_at": { + "name": "deleted_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "proxy_files_public_id_unique": { + "name": "proxy_files_public_id_unique", + "columns": [ + "public_id" + ], + "isUnique": true + }, + "proxy_files_owner_lookup_idx": { + "name": "proxy_files_owner_lookup_idx", + "columns": [ + "owner_type", + "owner_id", + "deleted_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "proxy_logs": { + "name": "proxy_logs", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "route_id": { + "name": "route_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "channel_id": { + "name": "channel_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "account_id": { + "name": "account_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "model_requested": { + "name": "model_requested", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "model_actual": { + "name": "model_actual", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "http_status": { + "name": "http_status", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "latency_ms": { + "name": "latency_ms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "prompt_tokens": { + "name": "prompt_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "completion_tokens": { + "name": "completion_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "total_tokens": { + "name": "total_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "estimated_cost": { + "name": "estimated_cost", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "billing_details": { + "name": "billing_details", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "retry_count": { + "name": "retry_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(datetime('now'))" + } + }, + "indexes": { + "proxy_logs_created_at_idx": { + "name": "proxy_logs_created_at_idx", + "columns": [ + "created_at" + ], + "isUnique": false + }, + "proxy_logs_account_created_at_idx": { + "name": "proxy_logs_account_created_at_idx", + "columns": [ + "account_id", + "created_at" + ], + "isUnique": false + }, + "proxy_logs_status_created_at_idx": { + "name": "proxy_logs_status_created_at_idx", + "columns": [ + "status", + "created_at" + ], + "isUnique": false + }, + "proxy_logs_model_actual_created_at_idx": { + "name": "proxy_logs_model_actual_created_at_idx", + "columns": [ + "model_actual", + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "proxy_video_tasks": { + "name": "proxy_video_tasks", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "public_id": { + "name": "public_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "upstream_video_id": { + "name": "upstream_video_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "site_url": { + "name": "site_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token_value": { + "name": "token_value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "requested_model": { + "name": "requested_model", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "actual_model": { + "name": "actual_model", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "channel_id": { + "name": "channel_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "account_id": { + "name": "account_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status_snapshot": { + "name": "status_snapshot", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "upstream_response_meta": { + "name": "upstream_response_meta", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_upstream_status": { + "name": "last_upstream_status", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_polled_at": { + "name": "last_polled_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(datetime('now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(datetime('now'))" + } + }, + "indexes": { + "proxy_video_tasks_public_id_unique": { + "name": "proxy_video_tasks_public_id_unique", + "columns": [ + "public_id" + ], + "isUnique": true + }, + "proxy_video_tasks_upstream_video_id_idx": { + "name": "proxy_video_tasks_upstream_video_id_idx", + "columns": [ + "upstream_video_id" + ], + "isUnique": false + }, + "proxy_video_tasks_created_at_idx": { + "name": "proxy_video_tasks_created_at_idx", + "columns": [ + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "route_channels": { + "name": "route_channels", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "route_id": { + "name": "route_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "account_id": { + "name": "account_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token_id": { + "name": "token_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_model": { + "name": "source_model", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "weight": { + "name": "weight", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 10 + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": true + }, + "manual_override": { + "name": "manual_override", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "success_count": { + "name": "success_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "fail_count": { + "name": "fail_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "total_latency_ms": { + "name": "total_latency_ms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "total_cost": { + "name": "total_cost", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "last_used_at": { + "name": "last_used_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_selected_at": { + "name": "last_selected_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_fail_at": { + "name": "last_fail_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "consecutive_fail_count": { + "name": "consecutive_fail_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "cooldown_level": { + "name": "cooldown_level", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "cooldown_until": { + "name": "cooldown_until", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "route_channels_route_id_idx": { + "name": "route_channels_route_id_idx", + "columns": [ + "route_id" + ], + "isUnique": false + }, + "route_channels_account_id_idx": { + "name": "route_channels_account_id_idx", + "columns": [ + "account_id" + ], + "isUnique": false + }, + "route_channels_token_id_idx": { + "name": "route_channels_token_id_idx", + "columns": [ + "token_id" + ], + "isUnique": false + }, + "route_channels_route_enabled_idx": { + "name": "route_channels_route_enabled_idx", + "columns": [ + "route_id", + "enabled" + ], + "isUnique": false + }, + "route_channels_route_token_idx": { + "name": "route_channels_route_token_idx", + "columns": [ + "route_id", + "token_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "route_channels_route_id_token_routes_id_fk": { + "name": "route_channels_route_id_token_routes_id_fk", + "tableFrom": "route_channels", + "tableTo": "token_routes", + "columnsFrom": [ + "route_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "route_channels_account_id_accounts_id_fk": { + "name": "route_channels_account_id_accounts_id_fk", + "tableFrom": "route_channels", + "tableTo": "accounts", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "route_channels_token_id_account_tokens_id_fk": { + "name": "route_channels_token_id_account_tokens_id_fk", + "tableFrom": "route_channels", + "tableTo": "account_tokens", + "columnsFrom": [ + "token_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "settings": { + "name": "settings", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "site_disabled_models": { + "name": "site_disabled_models", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "site_id": { + "name": "site_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "model_name": { + "name": "model_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(datetime('now'))" + } + }, + "indexes": { + "site_disabled_models_site_model_unique": { + "name": "site_disabled_models_site_model_unique", + "columns": [ + "site_id", + "model_name" + ], + "isUnique": true + }, + "site_disabled_models_site_id_idx": { + "name": "site_disabled_models_site_id_idx", + "columns": [ + "site_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "site_disabled_models_site_id_sites_id_fk": { + "name": "site_disabled_models_site_id_sites_id_fk", + "tableFrom": "site_disabled_models", + "tableTo": "sites", + "columnsFrom": [ + "site_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "sites": { + "name": "sites", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "external_checkin_url": { + "name": "external_checkin_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "platform": { + "name": "platform", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "proxy_url": { + "name": "proxy_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "use_system_proxy": { + "name": "use_system_proxy", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "custom_headers": { + "name": "custom_headers", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'active'" + }, + "is_pinned": { + "name": "is_pinned", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "global_weight": { + "name": "global_weight", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 1 + }, + "api_key": { + "name": "api_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(datetime('now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(datetime('now'))" + } + }, + "indexes": { + "sites_status_idx": { + "name": "sites_status_idx", + "columns": [ + "status" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "token_model_availability": { + "name": "token_model_availability", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "token_id": { + "name": "token_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "model_name": { + "name": "model_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "available": { + "name": "available", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "latency_ms": { + "name": "latency_ms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "checked_at": { + "name": "checked_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(datetime('now'))" + } + }, + "indexes": { + "token_model_availability_token_model_unique": { + "name": "token_model_availability_token_model_unique", + "columns": [ + "token_id", + "model_name" + ], + "isUnique": true + }, + "token_model_availability_token_available_idx": { + "name": "token_model_availability_token_available_idx", + "columns": [ + "token_id", + "available" + ], + "isUnique": false + }, + "token_model_availability_model_name_idx": { + "name": "token_model_availability_model_name_idx", + "columns": [ + "model_name" + ], + "isUnique": false + }, + "token_model_availability_available_idx": { + "name": "token_model_availability_available_idx", + "columns": [ + "available" + ], + "isUnique": false + } + }, + "foreignKeys": { + "token_model_availability_token_id_account_tokens_id_fk": { + "name": "token_model_availability_token_id_account_tokens_id_fk", + "tableFrom": "token_model_availability", + "tableTo": "account_tokens", + "columnsFrom": [ + "token_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "token_routes": { + "name": "token_routes", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "model_pattern": { + "name": "model_pattern", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "display_icon": { + "name": "display_icon", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "model_mapping": { + "name": "model_mapping", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "decision_snapshot": { + "name": "decision_snapshot", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "decision_refreshed_at": { + "name": "decision_refreshed_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "routing_strategy": { + "name": "routing_strategy", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'weighted'" + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(datetime('now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(datetime('now'))" + } + }, + "indexes": { + "token_routes_model_pattern_idx": { + "name": "token_routes_model_pattern_idx", + "columns": [ + "model_pattern" + ], + "isUnique": false + }, + "token_routes_enabled_idx": { + "name": "token_routes_enabled_idx", + "columns": [ + "enabled" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/0012_snapshot.json b/drizzle/meta/0012_snapshot.json new file mode 100644 index 00000000..bfcee45b --- /dev/null +++ b/drizzle/meta/0012_snapshot.json @@ -0,0 +1,1892 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "b1aee21b-b789-4e47-ab03-7ba93cc26951", + "prevId": "4bdb796c-cedc-4b81-8bfb-78c5e0bde8c0", + "tables": { + "account_tokens": { + "name": "account_tokens", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "account_id": { + "name": "account_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token_group": { + "name": "token_group", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "value_status": { + "name": "value_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'ready'" + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'manual'" + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": true + }, + "is_default": { + "name": "is_default", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(datetime('now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(datetime('now'))" + } + }, + "indexes": { + "account_tokens_account_id_idx": { + "name": "account_tokens_account_id_idx", + "columns": [ + "account_id" + ], + "isUnique": false + }, + "account_tokens_account_enabled_idx": { + "name": "account_tokens_account_enabled_idx", + "columns": [ + "account_id", + "enabled" + ], + "isUnique": false + }, + "account_tokens_enabled_idx": { + "name": "account_tokens_enabled_idx", + "columns": [ + "enabled" + ], + "isUnique": false + } + }, + "foreignKeys": { + "account_tokens_account_id_accounts_id_fk": { + "name": "account_tokens_account_id_accounts_id_fk", + "tableFrom": "account_tokens", + "tableTo": "accounts", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "accounts": { + "name": "accounts", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "site_id": { + "name": "site_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "api_token": { + "name": "api_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "balance": { + "name": "balance", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "balance_used": { + "name": "balance_used", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "quota": { + "name": "quota", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "unit_cost": { + "name": "unit_cost", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "value_score": { + "name": "value_score", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'active'" + }, + "is_pinned": { + "name": "is_pinned", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "checkin_enabled": { + "name": "checkin_enabled", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": true + }, + "last_checkin_at": { + "name": "last_checkin_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_balance_refresh": { + "name": "last_balance_refresh", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "extra_config": { + "name": "extra_config", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(datetime('now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(datetime('now'))" + } + }, + "indexes": { + "accounts_site_id_idx": { + "name": "accounts_site_id_idx", + "columns": [ + "site_id" + ], + "isUnique": false + }, + "accounts_status_idx": { + "name": "accounts_status_idx", + "columns": [ + "status" + ], + "isUnique": false + }, + "accounts_site_status_idx": { + "name": "accounts_site_status_idx", + "columns": [ + "site_id", + "status" + ], + "isUnique": false + } + }, + "foreignKeys": { + "accounts_site_id_sites_id_fk": { + "name": "accounts_site_id_sites_id_fk", + "tableFrom": "accounts", + "tableTo": "sites", + "columnsFrom": [ + "site_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "checkin_logs": { + "name": "checkin_logs", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "account_id": { + "name": "account_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reward": { + "name": "reward", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(datetime('now'))" + } + }, + "indexes": { + "checkin_logs_account_created_at_idx": { + "name": "checkin_logs_account_created_at_idx", + "columns": [ + "account_id", + "created_at" + ], + "isUnique": false + }, + "checkin_logs_created_at_idx": { + "name": "checkin_logs_created_at_idx", + "columns": [ + "created_at" + ], + "isUnique": false + }, + "checkin_logs_status_idx": { + "name": "checkin_logs_status_idx", + "columns": [ + "status" + ], + "isUnique": false + } + }, + "foreignKeys": { + "checkin_logs_account_id_accounts_id_fk": { + "name": "checkin_logs_account_id_accounts_id_fk", + "tableFrom": "checkin_logs", + "tableTo": "accounts", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "downstream_api_keys": { + "name": "downstream_api_keys", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "group_name": { + "name": "group_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "tags": { + "name": "tags", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": true + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "max_cost": { + "name": "max_cost", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "used_cost": { + "name": "used_cost", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "max_requests": { + "name": "max_requests", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "used_requests": { + "name": "used_requests", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "supported_models": { + "name": "supported_models", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "allowed_route_ids": { + "name": "allowed_route_ids", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "site_weight_multipliers": { + "name": "site_weight_multipliers", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(datetime('now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(datetime('now'))" + } + }, + "indexes": { + "downstream_api_keys_key_unique": { + "name": "downstream_api_keys_key_unique", + "columns": [ + "key" + ], + "isUnique": true + }, + "downstream_api_keys_name_idx": { + "name": "downstream_api_keys_name_idx", + "columns": [ + "name" + ], + "isUnique": false + }, + "downstream_api_keys_enabled_idx": { + "name": "downstream_api_keys_enabled_idx", + "columns": [ + "enabled" + ], + "isUnique": false + }, + "downstream_api_keys_expires_at_idx": { + "name": "downstream_api_keys_expires_at_idx", + "columns": [ + "expires_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "events": { + "name": "events", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "level": { + "name": "level", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'info'" + }, + "read": { + "name": "read", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "related_id": { + "name": "related_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "related_type": { + "name": "related_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(datetime('now'))" + } + }, + "indexes": { + "events_read_created_at_idx": { + "name": "events_read_created_at_idx", + "columns": [ + "read", + "created_at" + ], + "isUnique": false + }, + "events_type_created_at_idx": { + "name": "events_type_created_at_idx", + "columns": [ + "type", + "created_at" + ], + "isUnique": false + }, + "events_created_at_idx": { + "name": "events_created_at_idx", + "columns": [ + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "model_availability": { + "name": "model_availability", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "account_id": { + "name": "account_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "model_name": { + "name": "model_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "available": { + "name": "available", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_manual": { + "name": "is_manual", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "latency_ms": { + "name": "latency_ms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "checked_at": { + "name": "checked_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(datetime('now'))" + } + }, + "indexes": { + "model_availability_account_model_unique": { + "name": "model_availability_account_model_unique", + "columns": [ + "account_id", + "model_name" + ], + "isUnique": true + }, + "model_availability_account_available_idx": { + "name": "model_availability_account_available_idx", + "columns": [ + "account_id", + "available" + ], + "isUnique": false + }, + "model_availability_model_name_idx": { + "name": "model_availability_model_name_idx", + "columns": [ + "model_name" + ], + "isUnique": false + } + }, + "foreignKeys": { + "model_availability_account_id_accounts_id_fk": { + "name": "model_availability_account_id_accounts_id_fk", + "tableFrom": "model_availability", + "tableTo": "accounts", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "proxy_files": { + "name": "proxy_files", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "public_id": { + "name": "public_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "owner_type": { + "name": "owner_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "filename": { + "name": "filename", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "purpose": { + "name": "purpose", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "byte_size": { + "name": "byte_size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sha256": { + "name": "sha256", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content_base64": { + "name": "content_base64", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(datetime('now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(datetime('now'))" + }, + "deleted_at": { + "name": "deleted_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "proxy_files_public_id_unique": { + "name": "proxy_files_public_id_unique", + "columns": [ + "public_id" + ], + "isUnique": true + }, + "proxy_files_owner_lookup_idx": { + "name": "proxy_files_owner_lookup_idx", + "columns": [ + "owner_type", + "owner_id", + "deleted_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "proxy_logs": { + "name": "proxy_logs", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "route_id": { + "name": "route_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "channel_id": { + "name": "channel_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "account_id": { + "name": "account_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "downstream_api_key_id": { + "name": "downstream_api_key_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "model_requested": { + "name": "model_requested", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "model_actual": { + "name": "model_actual", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "http_status": { + "name": "http_status", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "latency_ms": { + "name": "latency_ms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "prompt_tokens": { + "name": "prompt_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "completion_tokens": { + "name": "completion_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "total_tokens": { + "name": "total_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "estimated_cost": { + "name": "estimated_cost", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "billing_details": { + "name": "billing_details", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "retry_count": { + "name": "retry_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(datetime('now'))" + } + }, + "indexes": { + "proxy_logs_created_at_idx": { + "name": "proxy_logs_created_at_idx", + "columns": [ + "created_at" + ], + "isUnique": false + }, + "proxy_logs_account_created_at_idx": { + "name": "proxy_logs_account_created_at_idx", + "columns": [ + "account_id", + "created_at" + ], + "isUnique": false + }, + "proxy_logs_status_created_at_idx": { + "name": "proxy_logs_status_created_at_idx", + "columns": [ + "status", + "created_at" + ], + "isUnique": false + }, + "proxy_logs_model_actual_created_at_idx": { + "name": "proxy_logs_model_actual_created_at_idx", + "columns": [ + "model_actual", + "created_at" + ], + "isUnique": false + }, + "proxy_logs_downstream_api_key_created_at_idx": { + "name": "proxy_logs_downstream_api_key_created_at_idx", + "columns": [ + "downstream_api_key_id", + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "proxy_video_tasks": { + "name": "proxy_video_tasks", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "public_id": { + "name": "public_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "upstream_video_id": { + "name": "upstream_video_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "site_url": { + "name": "site_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token_value": { + "name": "token_value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "requested_model": { + "name": "requested_model", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "actual_model": { + "name": "actual_model", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "channel_id": { + "name": "channel_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "account_id": { + "name": "account_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status_snapshot": { + "name": "status_snapshot", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "upstream_response_meta": { + "name": "upstream_response_meta", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_upstream_status": { + "name": "last_upstream_status", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_polled_at": { + "name": "last_polled_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(datetime('now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(datetime('now'))" + } + }, + "indexes": { + "proxy_video_tasks_public_id_unique": { + "name": "proxy_video_tasks_public_id_unique", + "columns": [ + "public_id" + ], + "isUnique": true + }, + "proxy_video_tasks_upstream_video_id_idx": { + "name": "proxy_video_tasks_upstream_video_id_idx", + "columns": [ + "upstream_video_id" + ], + "isUnique": false + }, + "proxy_video_tasks_created_at_idx": { + "name": "proxy_video_tasks_created_at_idx", + "columns": [ + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "route_channels": { + "name": "route_channels", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "route_id": { + "name": "route_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "account_id": { + "name": "account_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token_id": { + "name": "token_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_model": { + "name": "source_model", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "weight": { + "name": "weight", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 10 + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": true + }, + "manual_override": { + "name": "manual_override", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "success_count": { + "name": "success_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "fail_count": { + "name": "fail_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "total_latency_ms": { + "name": "total_latency_ms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "total_cost": { + "name": "total_cost", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "last_used_at": { + "name": "last_used_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_selected_at": { + "name": "last_selected_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_fail_at": { + "name": "last_fail_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "consecutive_fail_count": { + "name": "consecutive_fail_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "cooldown_level": { + "name": "cooldown_level", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "cooldown_until": { + "name": "cooldown_until", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "route_channels_route_id_idx": { + "name": "route_channels_route_id_idx", + "columns": [ + "route_id" + ], + "isUnique": false + }, + "route_channels_account_id_idx": { + "name": "route_channels_account_id_idx", + "columns": [ + "account_id" + ], + "isUnique": false + }, + "route_channels_token_id_idx": { + "name": "route_channels_token_id_idx", + "columns": [ + "token_id" + ], + "isUnique": false + }, + "route_channels_route_enabled_idx": { + "name": "route_channels_route_enabled_idx", + "columns": [ + "route_id", + "enabled" + ], + "isUnique": false + }, + "route_channels_route_token_idx": { + "name": "route_channels_route_token_idx", + "columns": [ + "route_id", + "token_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "route_channels_route_id_token_routes_id_fk": { + "name": "route_channels_route_id_token_routes_id_fk", + "tableFrom": "route_channels", + "tableTo": "token_routes", + "columnsFrom": [ + "route_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "route_channels_account_id_accounts_id_fk": { + "name": "route_channels_account_id_accounts_id_fk", + "tableFrom": "route_channels", + "tableTo": "accounts", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "route_channels_token_id_account_tokens_id_fk": { + "name": "route_channels_token_id_account_tokens_id_fk", + "tableFrom": "route_channels", + "tableTo": "account_tokens", + "columnsFrom": [ + "token_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "settings": { + "name": "settings", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "site_disabled_models": { + "name": "site_disabled_models", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "site_id": { + "name": "site_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "model_name": { + "name": "model_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(datetime('now'))" + } + }, + "indexes": { + "site_disabled_models_site_model_unique": { + "name": "site_disabled_models_site_model_unique", + "columns": [ + "site_id", + "model_name" + ], + "isUnique": true + }, + "site_disabled_models_site_id_idx": { + "name": "site_disabled_models_site_id_idx", + "columns": [ + "site_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "site_disabled_models_site_id_sites_id_fk": { + "name": "site_disabled_models_site_id_sites_id_fk", + "tableFrom": "site_disabled_models", + "tableTo": "sites", + "columnsFrom": [ + "site_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "sites": { + "name": "sites", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "external_checkin_url": { + "name": "external_checkin_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "platform": { + "name": "platform", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "proxy_url": { + "name": "proxy_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "use_system_proxy": { + "name": "use_system_proxy", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "custom_headers": { + "name": "custom_headers", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'active'" + }, + "is_pinned": { + "name": "is_pinned", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "global_weight": { + "name": "global_weight", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 1 + }, + "api_key": { + "name": "api_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(datetime('now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(datetime('now'))" + } + }, + "indexes": { + "sites_status_idx": { + "name": "sites_status_idx", + "columns": [ + "status" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "token_model_availability": { + "name": "token_model_availability", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "token_id": { + "name": "token_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "model_name": { + "name": "model_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "available": { + "name": "available", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "latency_ms": { + "name": "latency_ms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "checked_at": { + "name": "checked_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(datetime('now'))" + } + }, + "indexes": { + "token_model_availability_token_model_unique": { + "name": "token_model_availability_token_model_unique", + "columns": [ + "token_id", + "model_name" + ], + "isUnique": true + }, + "token_model_availability_token_available_idx": { + "name": "token_model_availability_token_available_idx", + "columns": [ + "token_id", + "available" + ], + "isUnique": false + }, + "token_model_availability_model_name_idx": { + "name": "token_model_availability_model_name_idx", + "columns": [ + "model_name" + ], + "isUnique": false + }, + "token_model_availability_available_idx": { + "name": "token_model_availability_available_idx", + "columns": [ + "available" + ], + "isUnique": false + } + }, + "foreignKeys": { + "token_model_availability_token_id_account_tokens_id_fk": { + "name": "token_model_availability_token_id_account_tokens_id_fk", + "tableFrom": "token_model_availability", + "tableTo": "account_tokens", + "columnsFrom": [ + "token_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "token_routes": { + "name": "token_routes", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "model_pattern": { + "name": "model_pattern", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "display_icon": { + "name": "display_icon", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "model_mapping": { + "name": "model_mapping", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "decision_snapshot": { + "name": "decision_snapshot", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "decision_refreshed_at": { + "name": "decision_refreshed_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "routing_strategy": { + "name": "routing_strategy", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'weighted'" + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(datetime('now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(datetime('now'))" + } + }, + "indexes": { + "token_routes_model_pattern_idx": { + "name": "token_routes_model_pattern_idx", + "columns": [ + "model_pattern" + ], + "isUnique": false + }, + "token_routes_enabled_idx": { + "name": "token_routes_enabled_idx", + "columns": [ + "enabled" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/0016_snapshot.json b/drizzle/meta/0016_snapshot.json new file mode 100644 index 00000000..e6cab774 --- /dev/null +++ b/drizzle/meta/0016_snapshot.json @@ -0,0 +1,2063 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "6c654ca3-d194-4c7b-a991-9b91891c3913", + "prevId": "b1aee21b-b789-4e47-ab03-7ba93cc26951", + "tables": { + "account_tokens": { + "name": "account_tokens", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "account_id": { + "name": "account_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token_group": { + "name": "token_group", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "value_status": { + "name": "value_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'ready'" + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'manual'" + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": true + }, + "is_default": { + "name": "is_default", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(datetime('now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(datetime('now'))" + } + }, + "indexes": { + "account_tokens_account_id_idx": { + "name": "account_tokens_account_id_idx", + "columns": [ + "account_id" + ], + "isUnique": false + }, + "account_tokens_account_enabled_idx": { + "name": "account_tokens_account_enabled_idx", + "columns": [ + "account_id", + "enabled" + ], + "isUnique": false + }, + "account_tokens_enabled_idx": { + "name": "account_tokens_enabled_idx", + "columns": [ + "enabled" + ], + "isUnique": false + } + }, + "foreignKeys": { + "account_tokens_account_id_accounts_id_fk": { + "name": "account_tokens_account_id_accounts_id_fk", + "tableFrom": "account_tokens", + "tableTo": "accounts", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "accounts": { + "name": "accounts", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "site_id": { + "name": "site_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "api_token": { + "name": "api_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "balance": { + "name": "balance", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "balance_used": { + "name": "balance_used", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "quota": { + "name": "quota", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "unit_cost": { + "name": "unit_cost", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "value_score": { + "name": "value_score", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'active'" + }, + "is_pinned": { + "name": "is_pinned", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "checkin_enabled": { + "name": "checkin_enabled", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": true + }, + "last_checkin_at": { + "name": "last_checkin_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_balance_refresh": { + "name": "last_balance_refresh", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "oauth_provider": { + "name": "oauth_provider", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "oauth_account_key": { + "name": "oauth_account_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "oauth_project_id": { + "name": "oauth_project_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "extra_config": { + "name": "extra_config", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(datetime('now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(datetime('now'))" + } + }, + "indexes": { + "accounts_site_id_idx": { + "name": "accounts_site_id_idx", + "columns": [ + "site_id" + ], + "isUnique": false + }, + "accounts_status_idx": { + "name": "accounts_status_idx", + "columns": [ + "status" + ], + "isUnique": false + }, + "accounts_site_status_idx": { + "name": "accounts_site_status_idx", + "columns": [ + "site_id", + "status" + ], + "isUnique": false + }, + "accounts_oauth_provider_idx": { + "name": "accounts_oauth_provider_idx", + "columns": [ + "oauth_provider" + ], + "isUnique": false + }, + "accounts_oauth_identity_idx": { + "name": "accounts_oauth_identity_idx", + "columns": [ + "oauth_provider", + "oauth_account_key", + "oauth_project_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "accounts_site_id_sites_id_fk": { + "name": "accounts_site_id_sites_id_fk", + "tableFrom": "accounts", + "tableTo": "sites", + "columnsFrom": [ + "site_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "checkin_logs": { + "name": "checkin_logs", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "account_id": { + "name": "account_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reward": { + "name": "reward", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(datetime('now'))" + } + }, + "indexes": { + "checkin_logs_account_created_at_idx": { + "name": "checkin_logs_account_created_at_idx", + "columns": [ + "account_id", + "created_at" + ], + "isUnique": false + }, + "checkin_logs_created_at_idx": { + "name": "checkin_logs_created_at_idx", + "columns": [ + "created_at" + ], + "isUnique": false + }, + "checkin_logs_status_idx": { + "name": "checkin_logs_status_idx", + "columns": [ + "status" + ], + "isUnique": false + } + }, + "foreignKeys": { + "checkin_logs_account_id_accounts_id_fk": { + "name": "checkin_logs_account_id_accounts_id_fk", + "tableFrom": "checkin_logs", + "tableTo": "accounts", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "downstream_api_keys": { + "name": "downstream_api_keys", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "group_name": { + "name": "group_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "tags": { + "name": "tags", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": true + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "max_cost": { + "name": "max_cost", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "used_cost": { + "name": "used_cost", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "max_requests": { + "name": "max_requests", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "used_requests": { + "name": "used_requests", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "supported_models": { + "name": "supported_models", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "allowed_route_ids": { + "name": "allowed_route_ids", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "site_weight_multipliers": { + "name": "site_weight_multipliers", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(datetime('now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(datetime('now'))" + } + }, + "indexes": { + "downstream_api_keys_key_unique": { + "name": "downstream_api_keys_key_unique", + "columns": [ + "key" + ], + "isUnique": true + }, + "downstream_api_keys_name_idx": { + "name": "downstream_api_keys_name_idx", + "columns": [ + "name" + ], + "isUnique": false + }, + "downstream_api_keys_enabled_idx": { + "name": "downstream_api_keys_enabled_idx", + "columns": [ + "enabled" + ], + "isUnique": false + }, + "downstream_api_keys_expires_at_idx": { + "name": "downstream_api_keys_expires_at_idx", + "columns": [ + "expires_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "events": { + "name": "events", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "level": { + "name": "level", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'info'" + }, + "read": { + "name": "read", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "related_id": { + "name": "related_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "related_type": { + "name": "related_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(datetime('now'))" + } + }, + "indexes": { + "events_read_created_at_idx": { + "name": "events_read_created_at_idx", + "columns": [ + "read", + "created_at" + ], + "isUnique": false + }, + "events_type_created_at_idx": { + "name": "events_type_created_at_idx", + "columns": [ + "type", + "created_at" + ], + "isUnique": false + }, + "events_created_at_idx": { + "name": "events_created_at_idx", + "columns": [ + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "model_availability": { + "name": "model_availability", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "account_id": { + "name": "account_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "model_name": { + "name": "model_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "available": { + "name": "available", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_manual": { + "name": "is_manual", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "latency_ms": { + "name": "latency_ms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "checked_at": { + "name": "checked_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(datetime('now'))" + } + }, + "indexes": { + "model_availability_account_model_unique": { + "name": "model_availability_account_model_unique", + "columns": [ + "account_id", + "model_name" + ], + "isUnique": true + }, + "model_availability_account_available_idx": { + "name": "model_availability_account_available_idx", + "columns": [ + "account_id", + "available" + ], + "isUnique": false + }, + "model_availability_model_name_idx": { + "name": "model_availability_model_name_idx", + "columns": [ + "model_name" + ], + "isUnique": false + } + }, + "foreignKeys": { + "model_availability_account_id_accounts_id_fk": { + "name": "model_availability_account_id_accounts_id_fk", + "tableFrom": "model_availability", + "tableTo": "accounts", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "proxy_files": { + "name": "proxy_files", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "public_id": { + "name": "public_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "owner_type": { + "name": "owner_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "filename": { + "name": "filename", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "purpose": { + "name": "purpose", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "byte_size": { + "name": "byte_size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sha256": { + "name": "sha256", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content_base64": { + "name": "content_base64", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(datetime('now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(datetime('now'))" + }, + "deleted_at": { + "name": "deleted_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "proxy_files_public_id_unique": { + "name": "proxy_files_public_id_unique", + "columns": [ + "public_id" + ], + "isUnique": true + }, + "proxy_files_owner_lookup_idx": { + "name": "proxy_files_owner_lookup_idx", + "columns": [ + "owner_type", + "owner_id", + "deleted_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "proxy_logs": { + "name": "proxy_logs", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "route_id": { + "name": "route_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "channel_id": { + "name": "channel_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "account_id": { + "name": "account_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "downstream_api_key_id": { + "name": "downstream_api_key_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "model_requested": { + "name": "model_requested", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "model_actual": { + "name": "model_actual", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "http_status": { + "name": "http_status", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "latency_ms": { + "name": "latency_ms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "prompt_tokens": { + "name": "prompt_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "completion_tokens": { + "name": "completion_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "total_tokens": { + "name": "total_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "estimated_cost": { + "name": "estimated_cost", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "billing_details": { + "name": "billing_details", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "client_family": { + "name": "client_family", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "client_app_id": { + "name": "client_app_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "client_app_name": { + "name": "client_app_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "client_confidence": { + "name": "client_confidence", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "retry_count": { + "name": "retry_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(datetime('now'))" + } + }, + "indexes": { + "proxy_logs_created_at_idx": { + "name": "proxy_logs_created_at_idx", + "columns": [ + "created_at" + ], + "isUnique": false + }, + "proxy_logs_account_created_at_idx": { + "name": "proxy_logs_account_created_at_idx", + "columns": [ + "account_id", + "created_at" + ], + "isUnique": false + }, + "proxy_logs_status_created_at_idx": { + "name": "proxy_logs_status_created_at_idx", + "columns": [ + "status", + "created_at" + ], + "isUnique": false + }, + "proxy_logs_model_actual_created_at_idx": { + "name": "proxy_logs_model_actual_created_at_idx", + "columns": [ + "model_actual", + "created_at" + ], + "isUnique": false + }, + "proxy_logs_downstream_api_key_created_at_idx": { + "name": "proxy_logs_downstream_api_key_created_at_idx", + "columns": [ + "downstream_api_key_id", + "created_at" + ], + "isUnique": false + }, + "proxy_logs_client_app_id_created_at_idx": { + "name": "proxy_logs_client_app_id_created_at_idx", + "columns": [ + "client_app_id", + "created_at" + ], + "isUnique": false + }, + "proxy_logs_client_family_created_at_idx": { + "name": "proxy_logs_client_family_created_at_idx", + "columns": [ + "client_family", + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "proxy_video_tasks": { + "name": "proxy_video_tasks", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "public_id": { + "name": "public_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "upstream_video_id": { + "name": "upstream_video_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "site_url": { + "name": "site_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token_value": { + "name": "token_value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "requested_model": { + "name": "requested_model", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "actual_model": { + "name": "actual_model", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "channel_id": { + "name": "channel_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "account_id": { + "name": "account_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status_snapshot": { + "name": "status_snapshot", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "upstream_response_meta": { + "name": "upstream_response_meta", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_upstream_status": { + "name": "last_upstream_status", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_polled_at": { + "name": "last_polled_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(datetime('now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(datetime('now'))" + } + }, + "indexes": { + "proxy_video_tasks_public_id_unique": { + "name": "proxy_video_tasks_public_id_unique", + "columns": [ + "public_id" + ], + "isUnique": true + }, + "proxy_video_tasks_upstream_video_id_idx": { + "name": "proxy_video_tasks_upstream_video_id_idx", + "columns": [ + "upstream_video_id" + ], + "isUnique": false + }, + "proxy_video_tasks_created_at_idx": { + "name": "proxy_video_tasks_created_at_idx", + "columns": [ + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "route_channels": { + "name": "route_channels", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "route_id": { + "name": "route_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "account_id": { + "name": "account_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token_id": { + "name": "token_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_model": { + "name": "source_model", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "weight": { + "name": "weight", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 10 + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": true + }, + "manual_override": { + "name": "manual_override", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "success_count": { + "name": "success_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "fail_count": { + "name": "fail_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "total_latency_ms": { + "name": "total_latency_ms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "total_cost": { + "name": "total_cost", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "last_used_at": { + "name": "last_used_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_selected_at": { + "name": "last_selected_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_fail_at": { + "name": "last_fail_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "consecutive_fail_count": { + "name": "consecutive_fail_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "cooldown_level": { + "name": "cooldown_level", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "cooldown_until": { + "name": "cooldown_until", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "route_channels_route_id_idx": { + "name": "route_channels_route_id_idx", + "columns": [ + "route_id" + ], + "isUnique": false + }, + "route_channels_account_id_idx": { + "name": "route_channels_account_id_idx", + "columns": [ + "account_id" + ], + "isUnique": false + }, + "route_channels_token_id_idx": { + "name": "route_channels_token_id_idx", + "columns": [ + "token_id" + ], + "isUnique": false + }, + "route_channels_route_enabled_idx": { + "name": "route_channels_route_enabled_idx", + "columns": [ + "route_id", + "enabled" + ], + "isUnique": false + }, + "route_channels_route_token_idx": { + "name": "route_channels_route_token_idx", + "columns": [ + "route_id", + "token_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "route_channels_route_id_token_routes_id_fk": { + "name": "route_channels_route_id_token_routes_id_fk", + "tableFrom": "route_channels", + "tableTo": "token_routes", + "columnsFrom": [ + "route_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "route_channels_account_id_accounts_id_fk": { + "name": "route_channels_account_id_accounts_id_fk", + "tableFrom": "route_channels", + "tableTo": "accounts", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "route_channels_token_id_account_tokens_id_fk": { + "name": "route_channels_token_id_account_tokens_id_fk", + "tableFrom": "route_channels", + "tableTo": "account_tokens", + "columnsFrom": [ + "token_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "route_group_sources": { + "name": "route_group_sources", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "group_route_id": { + "name": "group_route_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_route_id": { + "name": "source_route_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "route_group_sources_group_source_unique": { + "name": "route_group_sources_group_source_unique", + "columns": [ + "group_route_id", + "source_route_id" + ], + "isUnique": true + }, + "route_group_sources_source_route_id_idx": { + "name": "route_group_sources_source_route_id_idx", + "columns": [ + "source_route_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "route_group_sources_group_route_id_token_routes_id_fk": { + "name": "route_group_sources_group_route_id_token_routes_id_fk", + "tableFrom": "route_group_sources", + "tableTo": "token_routes", + "columnsFrom": [ + "group_route_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "route_group_sources_source_route_id_token_routes_id_fk": { + "name": "route_group_sources_source_route_id_token_routes_id_fk", + "tableFrom": "route_group_sources", + "tableTo": "token_routes", + "columnsFrom": [ + "source_route_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "settings": { + "name": "settings", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "site_disabled_models": { + "name": "site_disabled_models", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "site_id": { + "name": "site_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "model_name": { + "name": "model_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(datetime('now'))" + } + }, + "indexes": { + "site_disabled_models_site_model_unique": { + "name": "site_disabled_models_site_model_unique", + "columns": [ + "site_id", + "model_name" + ], + "isUnique": true + }, + "site_disabled_models_site_id_idx": { + "name": "site_disabled_models_site_id_idx", + "columns": [ + "site_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "site_disabled_models_site_id_sites_id_fk": { + "name": "site_disabled_models_site_id_sites_id_fk", + "tableFrom": "site_disabled_models", + "tableTo": "sites", + "columnsFrom": [ + "site_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "sites": { + "name": "sites", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "external_checkin_url": { + "name": "external_checkin_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "platform": { + "name": "platform", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "proxy_url": { + "name": "proxy_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "use_system_proxy": { + "name": "use_system_proxy", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "custom_headers": { + "name": "custom_headers", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'active'" + }, + "is_pinned": { + "name": "is_pinned", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "global_weight": { + "name": "global_weight", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 1 + }, + "api_key": { + "name": "api_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(datetime('now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(datetime('now'))" + } + }, + "indexes": { + "sites_status_idx": { + "name": "sites_status_idx", + "columns": [ + "status" + ], + "isUnique": false + }, + "sites_platform_url_unique": { + "name": "sites_platform_url_unique", + "columns": [ + "platform", + "url" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "token_model_availability": { + "name": "token_model_availability", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "token_id": { + "name": "token_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "model_name": { + "name": "model_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "available": { + "name": "available", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "latency_ms": { + "name": "latency_ms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "checked_at": { + "name": "checked_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(datetime('now'))" + } + }, + "indexes": { + "token_model_availability_token_model_unique": { + "name": "token_model_availability_token_model_unique", + "columns": [ + "token_id", + "model_name" + ], + "isUnique": true + }, + "token_model_availability_token_available_idx": { + "name": "token_model_availability_token_available_idx", + "columns": [ + "token_id", + "available" + ], + "isUnique": false + }, + "token_model_availability_model_name_idx": { + "name": "token_model_availability_model_name_idx", + "columns": [ + "model_name" + ], + "isUnique": false + }, + "token_model_availability_available_idx": { + "name": "token_model_availability_available_idx", + "columns": [ + "available" + ], + "isUnique": false + } + }, + "foreignKeys": { + "token_model_availability_token_id_account_tokens_id_fk": { + "name": "token_model_availability_token_id_account_tokens_id_fk", + "tableFrom": "token_model_availability", + "tableTo": "account_tokens", + "columnsFrom": [ + "token_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "token_routes": { + "name": "token_routes", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "model_pattern": { + "name": "model_pattern", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "display_icon": { + "name": "display_icon", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "route_mode": { + "name": "route_mode", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'pattern'" + }, + "model_mapping": { + "name": "model_mapping", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "decision_snapshot": { + "name": "decision_snapshot", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "decision_refreshed_at": { + "name": "decision_refreshed_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "routing_strategy": { + "name": "routing_strategy", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'weighted'" + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(datetime('now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(datetime('now'))" + } + }, + "indexes": { + "token_routes_model_pattern_idx": { + "name": "token_routes_model_pattern_idx", + "columns": [ + "model_pattern" + ], + "isUnique": false + }, + "token_routes_enabled_idx": { + "name": "token_routes_enabled_idx", + "columns": [ + "enabled" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 921bc283..e3c667ba 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -36,6 +36,83 @@ "when": 1772320800000, "tag": "0004_sorting_preferences", "breakpoints": true + }, + { + "idx": 5, + "version": "6", + "when": 1773500331000, + "tag": "0006_site_disabled_models", + "breakpoints": true + }, + { + "idx": 6, + "version": "6", + "when": 1773500400000, + "tag": "0007_account_token_group", + "breakpoints": true + }, + { + "idx": 7, + "version": "6", + "when": 1773500500000, + "tag": "0008_sqlite_schema_backfill", + "breakpoints": true + }, + { + "idx": 8, + "version": "6", + "when": 1773528832339, + "tag": "0009_model_availability_is_manual", + "breakpoints": true + }, + { + "idx": 9, + "version": "6", + "when": 1773553945763, + "tag": "0010_proxy_logs_downstream_api_key", + "breakpoints": true + }, + { + "idx": 10, + "version": "6", + "when": 1773605400000, + "tag": "0011_downstream_api_key_metadata", + "breakpoints": true + }, + { + "idx": 11, + "version": "6", + "when": 1773665311013, + "tag": "0012_account_token_value_status", + "breakpoints": true + }, + { + "idx": 12, + "version": "6", + "when": 1773682200000, + "tag": "0013_oauth_multi_provider", + "breakpoints": true + }, + { + "idx": 13, + "version": "6", + "when": 1773855000000, + "tag": "0014_explicit_group_routes", + "breakpoints": true + }, + { + "idx": 14, + "version": "6", + "when": 1773900000000, + "tag": "0015_site_announcements", + "breakpoints": true + }, + { + "idx": 15, + "version": "6", + "when": 1773979652606, + "tag": "0016_proxy_logs_client_fields", + "breakpoints": true } ] } diff --git a/electron-builder.yml b/electron-builder.yml index e99c6466..ae093b1e 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -4,7 +4,7 @@ artifactName: ${name}-${version}-${os}-${arch}.${ext} directories: output: release electronDist: node_modules/electron/dist -icon: build/icon.png +icon: build/desktop-icon.png asar: false files: - dist/**/* diff --git a/package-lock.json b/package-lock.json index 49e5c715..3c1c3d17 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "metapi", - "version": "1.2.1", + "version": "1.2.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "metapi", - "version": "1.2.1", + "version": "1.2.3", "license": "MIT", "dependencies": { "@dnd-kit/core": "^6.3.1", @@ -14,25 +14,28 @@ "@dnd-kit/utilities": "^3.2.2", "@fastify/cors": "^11.2.0", "@fastify/static": "^9.0.0", - "@visactor/react-vchart": "^2.0.16", - "better-sqlite3": "^11.3.0", - "dotenv": "^16.4.5", - "drizzle-orm": "^0.36.4", + "@visactor/react-vchart": "^2.0.19", + "better-sqlite3": "^12.8.0", + "dotenv": "^17.3.1", + "drizzle-orm": "^0.45.1", "electron-log": "^5.2.4", "electron-updater": "^6.6.2", - "fastify": "^5.7.4", + "fastify": "^5.8.2", "get-port": "^7.1.0", + "marked": "^17.0.4", "minimatch": "^10.2.4", "minimist": "^1.2.8", - "mysql2": "^3.15.3", - "node-cron": "^3.0.3", - "nodemailer": "^8.0.1", + "mysql2": "^3.20.0", + "node-cron": "^4.2.1", + "nodemailer": "^8.0.3", "pg": "^8.16.3", - "undici": "^6.20.1" + "socks": "^2.8.7", + "undici": "^6.24.1", + "ws": "^8.19.0" }, "devDependencies": { "@electron/notarize": "^3.1.0", - "@tailwindcss/vite": "^4.0.0", + "@tailwindcss/vite": "^4.2.2", "@types/better-sqlite3": "^7.6.11", "@types/node": "^22.10.1", "@types/node-cron": "^3.0.11", @@ -40,16 +43,18 @@ "@types/pg": "^8.15.6", "@types/react": "^18.3.12", "@types/react-dom": "^18.3.1", + "@types/react-test-renderer": "^19.1.0", "@vitejs/plugin-react": "^4.3.4", "concurrently": "^9.1.0", "cross-env": "^7.0.3", - "drizzle-kit": "^0.28.1", - "electron": "^35.0.1", + "drizzle-kit": "^0.31.10", + "electron": "^41.0.3", "electron-builder": "^26.0.12", - "mermaid": "^11.12.3", + "jsdom": "^29.0.1", + "mermaid": "^11.13.0", "react": "^18.3.1", "react-dom": "^18.3.1", - "react-router-dom": "^6.28.0", + "react-router-dom": "^7.13.1", "react-test-renderer": "^18.3.1", "sharp": "^0.34.5", "tailwindcss": "^4.0.0", @@ -59,7 +64,10 @@ "vitepress": "^1.6.4", "vitepress-plugin-mermaid": "^2.0.17", "vitest": "^2.1.8", - "wait-on": "^8.0.3" + "wait-on": "^9.0.4" + }, + "engines": { + "node": ">=22.15.0" } }, "node_modules/@algolia/abtesting": { @@ -223,6 +231,7 @@ "integrity": "sha512-Nt9hri7nbOo0RipAsGjIssHkpLMHHN/P7QqENywAq5TLsoYDzUyJGny8FEiD/9KJUxtGH8blGpMedilI6kK3rA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@algolia/client-common": "5.49.1", "@algolia/requester-browser-xhr": "5.49.1", @@ -344,6 +353,67 @@ "node": ">=18" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.0.1.tgz", + "integrity": "sha512-2SZFvqMyvboVV1d15lMf7XiI3m7SDqXUuKaTymJYLN6dSGadqp+fVojqJlVoMlbZnlTmu3S0TLwLTJpvBMO1Aw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^3.1.1", + "@csstools/css-color-parser": "^4.0.2", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0", + "lru-cache": "^11.2.6" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "11.2.7", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz", + "integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.0.3.tgz", + "integrity": "sha512-Q6mU0Z6bfj6YvnX2k9n0JxiIwrCFN59x/nWmYQnAqP000ruX/yV+5bp/GRcF5T8ncvfwJQ7fgfP74DlpKExILA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.2.7" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector/node_modules/lru-cache": { + "version": "11.2.7", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz", + "integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@babel/code-frame": { "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", @@ -375,6 +445,7 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -633,6 +704,19 @@ "dev": true, "license": "MIT" }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, "node_modules/@chevrotain/cst-dts-gen": { "version": "11.1.2", "resolved": "https://registry.npmjs.org/@chevrotain/cst-dts-gen/-/cst-dts-gen-11.1.2.tgz", @@ -677,6 +761,148 @@ "dev": true, "license": "Apache-2.0" }, + "node_modules/@csstools/color-helpers": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz", + "integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.1.1.tgz", + "integrity": "sha512-HJ26Z/vmsZQqs/o3a6bgKslXGFAungXGbinULZO3eMsOyNJHeBBZfup5FiZInOghgoM4Hwnmw+OgbJCNg1wwUQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.0.2.tgz", + "integrity": "sha512-0GEfbBLmTFf0dJlpsNU7zwxRIH0/BGEMuXLTCvFYxuL1tNhqzTbtnFICyJLTNK4a+RechKP75e7w42ClXSnJQw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.0.2", + "@csstools/css-calc": "^3.1.1" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "peer": true, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.1.tgz", + "integrity": "sha512-BvqN0AMWNAnLk9G8jnUT77D+mUbY/H2b3uDTvg2isJkHaOufUE2R3AOwxWo7VBQKT1lOdwdvorddo2B/lk64+w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "peer": true, + "engines": { + "node": ">=20.19.0" + } + }, "node_modules/@develar/schema-utils": { "version": "2.6.5", "resolved": "https://registry.npmjs.org/@develar/schema-utils/-/schema-utils-2.6.5.tgz", @@ -701,6 +927,7 @@ "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -746,6 +973,7 @@ "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", "license": "MIT", + "peer": true, "dependencies": { "@dnd-kit/accessibility": "^3.1.1", "@dnd-kit/utilities": "^3.2.2", @@ -1235,7 +1463,6 @@ "dev": true, "license": "BSD-2-Clause", "optional": true, - "peer": true, "dependencies": { "cross-dirname": "^0.1.0", "debug": "^4.3.4", @@ -1257,7 +1484,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", @@ -1274,7 +1500,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "dependencies": { "universalify": "^2.0.0" }, @@ -1289,7 +1514,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "engines": { "node": ">= 10.0.0" } @@ -1742,9 +1966,9 @@ } }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.19.12.tgz", - "integrity": "sha512-bmoCYyWdEL3wDQIVbcyzRyeKLgk2WtWLTWz1ZIAZF/EGbNOwSA6ew3PftJ1PqMiOOGu0OyFMzG53L0zqIpPeNA==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", "cpu": [ "ppc64" ], @@ -1755,13 +1979,13 @@ "aix" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/android-arm": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.19.12.tgz", - "integrity": "sha512-qg/Lj1mu3CdQlDEEiWrlC4eaPZ1KztwGJ9B6J+/6G+/4ewxJg7gqj8eVYWvao1bXrqGiW2rsBZFSX3q2lcW05w==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", "cpu": [ "arm" ], @@ -1772,13 +1996,13 @@ "android" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/android-arm64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.19.12.tgz", - "integrity": "sha512-P0UVNGIienjZv3f5zq0DP3Nt2IE/3plFzuaS96vihvD0Hd6H/q4WXUGpCxD/E8YrSXfNyRPbpTq+T8ZQioSuPA==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", "cpu": [ "arm64" ], @@ -1789,13 +2013,13 @@ "android" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/android-x64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.19.12.tgz", - "integrity": "sha512-3k7ZoUW6Q6YqhdhIaq/WZ7HwBpnFBlW905Fa4s4qWJyiNOgT1dOqDiVAQFwBH7gBRZr17gLrlFCRzF6jFh7Kew==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", "cpu": [ "x64" ], @@ -1806,13 +2030,13 @@ "android" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.19.12.tgz", - "integrity": "sha512-B6IeSgZgtEzGC42jsI+YYu9Z3HKRxp8ZT3cqhvliEHovq8HSX2YX8lNocDn79gCKJXOSaEot9MVYky7AKjCs8g==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", "cpu": [ "arm64" ], @@ -1823,13 +2047,13 @@ "darwin" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.19.12.tgz", - "integrity": "sha512-hKoVkKzFiToTgn+41qGhsUJXFlIjxI/jSYeZf3ugemDYZldIXIxhvwN6erJGlX4t5h417iFuheZ7l+YVn05N3A==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", "cpu": [ "x64" ], @@ -1840,13 +2064,13 @@ "darwin" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.19.12.tgz", - "integrity": "sha512-4aRvFIXmwAcDBw9AueDQ2YnGmz5L6obe5kmPT8Vd+/+x/JMVKCgdcRwH6APrbpNXsPz+K653Qg8HB/oXvXVukA==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", "cpu": [ "arm64" ], @@ -1857,13 +2081,13 @@ "freebsd" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.19.12.tgz", - "integrity": "sha512-EYoXZ4d8xtBoVN7CEwWY2IN4ho76xjYXqSXMNccFSx2lgqOG/1TBPW0yPx1bJZk94qu3tX0fycJeeQsKovA8gg==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", "cpu": [ "x64" ], @@ -1874,13 +2098,13 @@ "freebsd" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-arm": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.19.12.tgz", - "integrity": "sha512-J5jPms//KhSNv+LO1S1TX1UWp1ucM6N6XuL6ITdKWElCu8wXP72l9MM0zDTzzeikVyqFE6U8YAV9/tFyj0ti+w==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", "cpu": [ "arm" ], @@ -1891,13 +2115,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.19.12.tgz", - "integrity": "sha512-EoTjyYyLuVPfdPLsGVVVC8a0p1BFFvtpQDB/YLEhaXyf/5bczaGeN15QkR+O4S5LeJ92Tqotve7i1jn35qwvdA==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", "cpu": [ "arm64" ], @@ -1908,13 +2132,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.19.12.tgz", - "integrity": "sha512-Thsa42rrP1+UIGaWz47uydHSBOgTUnwBwNq59khgIwktK6x60Hivfbux9iNR0eHCHzOLjLMLfUMLCypBkZXMHA==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", "cpu": [ "ia32" ], @@ -1925,13 +2149,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.19.12.tgz", - "integrity": "sha512-LiXdXA0s3IqRRjm6rV6XaWATScKAXjI4R4LoDlvO7+yQqFdlr1Bax62sRwkVvRIrwXxvtYEHHI4dm50jAXkuAA==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", "cpu": [ "loong64" ], @@ -1942,13 +2166,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.19.12.tgz", - "integrity": "sha512-fEnAuj5VGTanfJ07ff0gOA6IPsvrVHLVb6Lyd1g2/ed67oU1eFzL0r9WL7ZzscD+/N6i3dWumGE1Un4f7Amf+w==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", "cpu": [ "mips64el" ], @@ -1959,13 +2183,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.19.12.tgz", - "integrity": "sha512-nYJA2/QPimDQOh1rKWedNOe3Gfc8PabU7HT3iXWtNUbRzXS9+vgB0Fjaqr//XNbd82mCxHzik2qotuI89cfixg==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", "cpu": [ "ppc64" ], @@ -1976,13 +2200,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.19.12.tgz", - "integrity": "sha512-2MueBrlPQCw5dVJJpQdUYgeqIzDQgw3QtiAHUC4RBz9FXPrskyyU3VI1hw7C0BSKB9OduwSJ79FTCqtGMWqJHg==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", "cpu": [ "riscv64" ], @@ -1993,13 +2217,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.19.12.tgz", - "integrity": "sha512-+Pil1Nv3Umes4m3AZKqA2anfhJiVmNCYkPchwFJNEJN5QxmTs1uzyy4TvmDrCRNT2ApwSari7ZIgrPeUx4UZDg==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", "cpu": [ "s390x" ], @@ -2010,13 +2234,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-x64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.19.12.tgz", - "integrity": "sha512-B71g1QpxfwBvNrfyJdVDexenDIt1CiDN1TIXLbhOw0KhJzE78KIFGX6OJ9MrtC0oOqMWf+0xop4qEU8JrJTwCg==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", "cpu": [ "x64" ], @@ -2027,7 +2251,7 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/netbsd-arm64": { @@ -2048,9 +2272,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.19.12.tgz", - "integrity": "sha512-3ltjQ7n1owJgFbuC61Oj++XhtzmymoCihNFgT84UAmJnxJfm4sYCiSLTXZtE00VWYpPMYc+ZQmB6xbSdVh0JWA==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", "cpu": [ "x64" ], @@ -2061,7 +2285,7 @@ "netbsd" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/openbsd-arm64": { @@ -2082,9 +2306,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.19.12.tgz", - "integrity": "sha512-RbrfTB9SWsr0kWmb9srfF+L933uMDdu9BIzdA7os2t0TXhCRjrQyCeOt6wVxr79CKD4c+p+YhCj31HBkYcXebw==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", "cpu": [ "x64" ], @@ -2095,7 +2319,7 @@ "openbsd" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/openharmony-arm64": { @@ -2116,9 +2340,9 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.19.12.tgz", - "integrity": "sha512-HKjJwRrW8uWtCQnQOz9qcU3mUZhTUQvi56Q8DPTLLB+DawoiQdjsYq+j+D3s9I8VFtDr+F9CjgXKKC4ss89IeA==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", "cpu": [ "x64" ], @@ -2129,13 +2353,13 @@ "sunos" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.19.12.tgz", - "integrity": "sha512-URgtR1dJnmGvX864pn1B2YUYNzjmXkuJOIqG2HdU62MVS4EHpU2946OZoTMnRUHklGtJdJZ33QfzdjGACXhn1A==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", "cpu": [ "arm64" ], @@ -2146,15 +2370,15 @@ "win32" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.19.12.tgz", - "integrity": "sha512-+ZOE6pUkMOJfmxmBZElNOx72NKpIa/HFOMGzu8fqzQJ5kgf6aTGrcJaFsNiVMH4JKpMipyK+7k0n2UXN7a8YKQ==", - "cpu": [ - "ia32" + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" ], "dev": true, "license": "MIT", @@ -2163,13 +2387,13 @@ "win32" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/win32-x64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.19.12.tgz", - "integrity": "sha512-T1QyPSDCyMXaO3pzBkF96E8xMkiRYbUEZADd29SyPGabqxMViNoii+NcK7eWJAEoU6RZyEm5lVSIjTmcdoB9HA==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", "cpu": [ "x64" ], @@ -2180,7 +2404,25 @@ "win32" ], "engines": { - "node": ">=12" + "node": ">=18" + } + }, + "node_modules/@exodus/bytes": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.0.tgz", + "integrity": "sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } } }, "node_modules/@fastify/accept-negotiator": { @@ -3198,9 +3440,9 @@ "optional": true }, "node_modules/@mermaid-js/parser": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@mermaid-js/parser/-/parser-1.0.0.tgz", - "integrity": "sha512-vvK0Hi/VWndxoh03Mmz6wa1KDriSPjS2XMZL/1l19HFwygiObEEoEwSDxOqyLzzAI6J2PU3261JjTMTO7x+BPw==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@mermaid-js/parser/-/parser-1.0.1.tgz", + "integrity": "sha512-opmV19kN1JsK0T6HhhokHpcVkqKpF+x2pPDKKM2ThHtZAB5F4PROopk0amuVYK5qMrIA4erzpNm8gmPNJgMDxQ==", "dev": true, "license": "MIT", "dependencies": { @@ -3274,16 +3516,6 @@ "node": ">=14" } }, - "node_modules/@remix-run/router": { - "version": "1.23.2", - "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.2.tgz", - "integrity": "sha512-Ic6m2U/rMjTkhERIa/0ZtXJP17QUi2CbWE7cqx4J58M8aA3QTfW+2UlQ4psvTX9IO1RfNVhK3pcpdjej7L+t2w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, "node_modules/@resvg/resvg-js": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/@resvg/resvg-js/-/resvg-js-2.4.1.tgz", @@ -3977,49 +4209,49 @@ } }, "node_modules/@tailwindcss/node": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.1.tgz", - "integrity": "sha512-jlx6sLk4EOwO6hHe1oCGm1Q4AN/s0rSrTTPBGPM0/RQ6Uylwq17FuU8IeJJKEjtc6K6O07zsvP+gDO6MMWo7pg==", + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.2.tgz", + "integrity": "sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA==", "dev": true, "license": "MIT", "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.19.0", "jiti": "^2.6.1", - "lightningcss": "1.31.1", + "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", - "tailwindcss": "4.2.1" + "tailwindcss": "4.2.2" } }, "node_modules/@tailwindcss/oxide": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.1.tgz", - "integrity": "sha512-yv9jeEFWnjKCI6/T3Oq50yQEOqmpmpfzG1hcZsAOaXFQPfzWprWrlHSdGPEF3WQTi8zu8ohC9Mh9J470nT5pUw==", + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.2.tgz", + "integrity": "sha512-qEUA07+E5kehxYp9BVMpq9E8vnJuBHfJEC0vPC5e7iL/hw7HR61aDKoVoKzrG+QKp56vhNZe4qwkRmMC0zDLvg==", "dev": true, "license": "MIT", "engines": { "node": ">= 20" }, "optionalDependencies": { - "@tailwindcss/oxide-android-arm64": "4.2.1", - "@tailwindcss/oxide-darwin-arm64": "4.2.1", - "@tailwindcss/oxide-darwin-x64": "4.2.1", - "@tailwindcss/oxide-freebsd-x64": "4.2.1", - "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.1", - "@tailwindcss/oxide-linux-arm64-gnu": "4.2.1", - "@tailwindcss/oxide-linux-arm64-musl": "4.2.1", - "@tailwindcss/oxide-linux-x64-gnu": "4.2.1", - "@tailwindcss/oxide-linux-x64-musl": "4.2.1", - "@tailwindcss/oxide-wasm32-wasi": "4.2.1", - "@tailwindcss/oxide-win32-arm64-msvc": "4.2.1", - "@tailwindcss/oxide-win32-x64-msvc": "4.2.1" + "@tailwindcss/oxide-android-arm64": "4.2.2", + "@tailwindcss/oxide-darwin-arm64": "4.2.2", + "@tailwindcss/oxide-darwin-x64": "4.2.2", + "@tailwindcss/oxide-freebsd-x64": "4.2.2", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.2", + "@tailwindcss/oxide-linux-arm64-gnu": "4.2.2", + "@tailwindcss/oxide-linux-arm64-musl": "4.2.2", + "@tailwindcss/oxide-linux-x64-gnu": "4.2.2", + "@tailwindcss/oxide-linux-x64-musl": "4.2.2", + "@tailwindcss/oxide-wasm32-wasi": "4.2.2", + "@tailwindcss/oxide-win32-arm64-msvc": "4.2.2", + "@tailwindcss/oxide-win32-x64-msvc": "4.2.2" } }, "node_modules/@tailwindcss/oxide-android-arm64": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.1.tgz", - "integrity": "sha512-eZ7G1Zm5EC8OOKaesIKuw77jw++QJ2lL9N+dDpdQiAB/c/B2wDh0QPFHbkBVrXnwNugvrbJFk1gK2SsVjwWReg==", + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.2.tgz", + "integrity": "sha512-dXGR1n+P3B6748jZO/SvHZq7qBOqqzQ+yFrXpoOWWALWndF9MoSKAT3Q0fYgAzYzGhxNYOoysRvYlpixRBBoDg==", "cpu": [ "arm64" ], @@ -4034,9 +4266,9 @@ } }, "node_modules/@tailwindcss/oxide-darwin-arm64": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.1.tgz", - "integrity": "sha512-q/LHkOstoJ7pI1J0q6djesLzRvQSIfEto148ppAd+BVQK0JYjQIFSK3JgYZJa+Yzi0DDa52ZsQx2rqytBnf8Hw==", + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.2.tgz", + "integrity": "sha512-iq9Qjr6knfMpZHj55/37ouZeykwbDqF21gPFtfnhCCKGDcPI/21FKC9XdMO/XyBM7qKORx6UIhGgg6jLl7BZlg==", "cpu": [ "arm64" ], @@ -4051,9 +4283,9 @@ } }, "node_modules/@tailwindcss/oxide-darwin-x64": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.1.tgz", - "integrity": "sha512-/f/ozlaXGY6QLbpvd/kFTro2l18f7dHKpB+ieXz+Cijl4Mt9AI2rTrpq7V+t04nK+j9XBQHnSMdeQRhbGyt6fw==", + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.2.tgz", + "integrity": "sha512-BlR+2c3nzc8f2G639LpL89YY4bdcIdUmiOOkv2GQv4/4M0vJlpXEa0JXNHhCHU7VWOKWT/CjqHdTP8aUuDJkuw==", "cpu": [ "x64" ], @@ -4068,9 +4300,9 @@ } }, "node_modules/@tailwindcss/oxide-freebsd-x64": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.1.tgz", - "integrity": "sha512-5e/AkgYJT/cpbkys/OU2Ei2jdETCLlifwm7ogMC7/hksI2fC3iiq6OcXwjibcIjPung0kRtR3TxEITkqgn0TcA==", + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.2.tgz", + "integrity": "sha512-YUqUgrGMSu2CDO82hzlQ5qSb5xmx3RUrke/QgnoEx7KvmRJHQuZHZmZTLSuuHwFf0DJPybFMXMYf+WJdxHy/nQ==", "cpu": [ "x64" ], @@ -4085,9 +4317,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.1.tgz", - "integrity": "sha512-Uny1EcVTTmerCKt/1ZuKTkb0x8ZaiuYucg2/kImO5A5Y/kBz41/+j0gxUZl+hTF3xkWpDmHX+TaWhOtba2Fyuw==", + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.2.tgz", + "integrity": "sha512-FPdhvsW6g06T9BWT0qTwiVZYE2WIFo2dY5aCSpjG/S/u1tby+wXoslXS0kl3/KXnULlLr1E3NPRRw0g7t2kgaQ==", "cpu": [ "arm" ], @@ -4102,9 +4334,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.1.tgz", - "integrity": "sha512-CTrwomI+c7n6aSSQlsPL0roRiNMDQ/YzMD9EjcR+H4f0I1SQ8QqIuPnsVp7QgMkC1Qi8rtkekLkOFjo7OlEFRQ==", + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.2.tgz", + "integrity": "sha512-4og1V+ftEPXGttOO7eCmW7VICmzzJWgMx+QXAJRAhjrSjumCwWqMfkDrNu1LXEQzNAwz28NCUpucgQPrR4S2yw==", "cpu": [ "arm64" ], @@ -4119,9 +4351,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm64-musl": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.1.tgz", - "integrity": "sha512-WZA0CHRL/SP1TRbA5mp9htsppSEkWuQ4KsSUumYQnyl8ZdT39ntwqmz4IUHGN6p4XdSlYfJwM4rRzZLShHsGAQ==", + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.2.tgz", + "integrity": "sha512-oCfG/mS+/+XRlwNjnsNLVwnMWYH7tn/kYPsNPh+JSOMlnt93mYNCKHYzylRhI51X+TbR+ufNhhKKzm6QkqX8ag==", "cpu": [ "arm64" ], @@ -4136,9 +4368,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-x64-gnu": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.1.tgz", - "integrity": "sha512-qMFzxI2YlBOLW5PhblzuSWlWfwLHaneBE0xHzLrBgNtqN6mWfs+qYbhryGSXQjFYB1Dzf5w+LN5qbUTPhW7Y5g==", + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.2.tgz", + "integrity": "sha512-rTAGAkDgqbXHNp/xW0iugLVmX62wOp2PoE39BTCGKjv3Iocf6AFbRP/wZT/kuCxC9QBh9Pu8XPkv/zCZB2mcMg==", "cpu": [ "x64" ], @@ -4153,9 +4385,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-x64-musl": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.1.tgz", - "integrity": "sha512-5r1X2FKnCMUPlXTWRYpHdPYUY6a1Ar/t7P24OuiEdEOmms5lyqjDRvVY1yy9Rmioh+AunQ0rWiOTPE8F9A3v5g==", + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.2.tgz", + "integrity": "sha512-XW3t3qwbIwiSyRCggeO2zxe3KWaEbM0/kW9e8+0XpBgyKU4ATYzcVSMKteZJ1iukJ3HgHBjbg9P5YPRCVUxlnQ==", "cpu": [ "x64" ], @@ -4170,9 +4402,9 @@ } }, "node_modules/@tailwindcss/oxide-wasm32-wasi": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.1.tgz", - "integrity": "sha512-MGFB5cVPvshR85MTJkEvqDUnuNoysrsRxd6vnk1Lf2tbiqNlXpHYZqkqOQalydienEWOHHFyyuTSYRsLfxFJ2Q==", + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.2.tgz", + "integrity": "sha512-eKSztKsmEsn1O5lJ4ZAfyn41NfG7vzCg496YiGtMDV86jz1q/irhms5O0VrY6ZwTUkFy/EKG3RfWgxSI3VbZ8Q==", "bundleDependencies": [ "@napi-rs/wasm-runtime", "@emnapi/core", @@ -4199,10 +4431,74 @@ "node": ">=14.0.0" } }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.8.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.1.0", + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.8.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": { + "version": "1.1.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1", + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": { + "version": "2.8.1", + "dev": true, + "inBundle": true, + "license": "0BSD", + "optional": true + }, "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.1.tgz", - "integrity": "sha512-YlUEHRHBGnCMh4Nj4GnqQyBtsshUPdiNroZj8VPkvTZSoHsilRCwXcVKnG9kyi0ZFAS/3u+qKHBdDc81SADTRA==", + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.2.tgz", + "integrity": "sha512-qPmaQM4iKu5mxpsrWZMOZRgZv1tOZpUm+zdhhQP0VhJfyGGO3aUKdbh3gDZc/dPLQwW4eSqWGrrcWNBZWUWaXQ==", "cpu": [ "arm64" ], @@ -4217,9 +4513,9 @@ } }, "node_modules/@tailwindcss/oxide-win32-x64-msvc": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.1.tgz", - "integrity": "sha512-rbO34G5sMWWyrN/idLeVxAZgAKWrn5LiR3/I90Q9MkA67s6T1oB0xtTe+0heoBvHSpbU9Mk7i6uwJnpo4u21XQ==", + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.2.tgz", + "integrity": "sha512-1T/37VvI7WyH66b+vqHj/cLwnCxt7Qt3WFu5Q8hk65aOvlwAhs7rAp1VkulBJw/N4tMirXjVnylTR72uI0HGcA==", "cpu": [ "x64" ], @@ -4234,18 +4530,18 @@ } }, "node_modules/@tailwindcss/vite": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.2.1.tgz", - "integrity": "sha512-TBf2sJjYeb28jD2U/OhwdW0bbOsxkWPwQ7SrqGf9sVcoYwZj7rkXljroBO9wKBut9XnmQLXanuDUeqQK0lGg/w==", + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.2.2.tgz", + "integrity": "sha512-mEiF5HO1QqCLXoNEfXVA1Tzo+cYsrqV7w9Juj2wdUFyW07JRenqMG225MvPwr3ZD9N1bFQj46X7r33iHxLUW0w==", "dev": true, "license": "MIT", "dependencies": { - "@tailwindcss/node": "4.2.1", - "@tailwindcss/oxide": "4.2.1", - "tailwindcss": "4.2.1" + "@tailwindcss/node": "4.2.2", + "@tailwindcss/oxide": "4.2.2", + "tailwindcss": "4.2.2" }, "peerDependencies": { - "vite": "^5.2.0 || ^6 || ^7" + "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, "node_modules/@turf/boolean-clockwise": { @@ -4386,6 +4682,7 @@ "integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "@types/node": "*" } @@ -4795,6 +5092,7 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.11.tgz", "integrity": "sha512-BH7YwL6rA93ReqeQS1c4bsPpcfOmJasG+Fkr6Y59q83f9M1WcBRHR2vM+P9eOisYRcN3ujQoiZY8uk5W+1WL8w==", "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -4822,6 +5120,7 @@ "integrity": "sha512-gT+oueVQkqnj6ajGJXblFR4iavIXWsGAFCk3dP4Kki5+a9R4NMt0JARdk6s8cUKcfUoqP5dAtDSLU8xYUTFV+Q==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "@types/node": "*", "pg-protocol": "*", @@ -4844,15 +5143,16 @@ "version": "15.7.15", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/@types/react": { "version": "18.3.28", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz", "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", - "devOptional": true, + "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" @@ -4868,6 +5168,16 @@ "@types/react": "^18.0.0" } }, + "node_modules/@types/react-test-renderer": { + "version": "19.1.0", + "resolved": "https://registry.npmjs.org/@types/react-test-renderer/-/react-test-renderer-19.1.0.tgz", + "integrity": "sha512-XD0WZrHqjNrxA/MaR9O22w/RNidWR9YZmBdRGI7wcnWGrv/3dA8wKCJ8m63Sn+tLJhcjmuhOi629N66W6kgWzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/react": "*" + } + }, "node_modules/@types/responselike": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/@types/responselike/-/responselike-1.0.3.tgz", @@ -4926,16 +5236,27 @@ "dev": true, "license": "ISC" }, + "node_modules/@upsetjs/venn.js": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@upsetjs/venn.js/-/venn.js-2.0.0.tgz", + "integrity": "sha512-WbBhLrooyePuQ1VZxrJjtLvTc4NVfpOyKx0sKqioq9bX1C1m7Jgykkn8gLrtwumBioXIqam8DLxp88Adbue6Hw==", + "dev": true, + "license": "MIT", + "optionalDependencies": { + "d3-selection": "^3.0.0", + "d3-transition": "^3.0.1" + } + }, "node_modules/@visactor/react-vchart": { - "version": "2.0.16", - "resolved": "https://registry.npmjs.org/@visactor/react-vchart/-/react-vchart-2.0.16.tgz", - "integrity": "sha512-HOsrWBYdP/17DR+GH7Y9gVqIDV4Tb+uLldQeR5HgUjyfabxtz5GtVE/3vv1PlZUEKgCOEXCa+QC9bS/PyS459g==", + "version": "2.0.19", + "resolved": "https://registry.npmjs.org/@visactor/react-vchart/-/react-vchart-2.0.19.tgz", + "integrity": "sha512-gLEJ8Q6DhI21xX6PDUEmhSUu4Ft8+Foho7TktiMMX6Nll5RYgXagjtjRAI1kSSKm6t/7ZXbBsmP4RdL+KaGtNA==", "license": "MIT", "dependencies": { - "@visactor/vchart": "2.0.16", - "@visactor/vchart-extension": "2.0.16", - "@visactor/vrender-core": "~1.0.40", - "@visactor/vrender-kits": "~1.0.40", + "@visactor/vchart": "2.0.19", + "@visactor/vchart-extension": "2.0.19", + "@visactor/vrender-core": "~1.0.41", + "@visactor/vrender-kits": "~1.0.41", "@visactor/vutils": "~1.0.22", "react-is": "^18.2.0" }, @@ -4945,48 +5266,48 @@ } }, "node_modules/@visactor/vchart": { - "version": "2.0.16", - "resolved": "https://registry.npmjs.org/@visactor/vchart/-/vchart-2.0.16.tgz", - "integrity": "sha512-jRcMyxuq7C1lJfOH3s4Wkaz0lcspyIvaOsFXkxkHQN5h3qsSKxLKdHZCIXgHjsv/MFytZCaRKtb+gNPMZQ93Iw==", + "version": "2.0.19", + "resolved": "https://registry.npmjs.org/@visactor/vchart/-/vchart-2.0.19.tgz", + "integrity": "sha512-pPNAHUuNlw1FNgbSSwT9comeJUXpaTAe8yed1MVI5rT+TspIg9Wpio29ZlUjrevUxosQqka5VAD63jQS6PMaOg==", "license": "MIT", "dependencies": { "@visactor/vdataset": "~1.0.22", "@visactor/vlayouts": "~1.0.22", - "@visactor/vrender-animate": "~1.0.40", - "@visactor/vrender-components": "~1.0.40", - "@visactor/vrender-core": "~1.0.40", - "@visactor/vrender-kits": "~1.0.40", + "@visactor/vrender-animate": "~1.0.41", + "@visactor/vrender-components": "~1.0.41", + "@visactor/vrender-core": "~1.0.41", + "@visactor/vrender-kits": "~1.0.41", "@visactor/vscale": "~1.0.22", "@visactor/vutils": "~1.0.22", - "@visactor/vutils-extension": "2.0.16" + "@visactor/vutils-extension": "2.0.19" } }, "node_modules/@visactor/vchart-extension": { - "version": "2.0.16", - "resolved": "https://registry.npmjs.org/@visactor/vchart-extension/-/vchart-extension-2.0.16.tgz", - "integrity": "sha512-n88Tu6V8FFY4E4zqVeECwlFJjeIlyiF3vrlnYlR3dlnRbt/OCBC0ThGK3eCqkV9LuwD632cRFKqjQeYinYFrTA==", + "version": "2.0.19", + "resolved": "https://registry.npmjs.org/@visactor/vchart-extension/-/vchart-extension-2.0.19.tgz", + "integrity": "sha512-SCFysGMRGdNK6wYFKDrvZzGdYbVjGhdbqlWA5t2fdbuVcCN7wNBTDKI+pL4yUL3n9M1ZtVxQp5uOw9I0AjFHZQ==", "license": "MIT", "dependencies": { - "@visactor/vchart": "2.0.16", + "@visactor/vchart": "2.0.19", "@visactor/vdataset": "~1.0.22", "@visactor/vlayouts": "~1.0.22", - "@visactor/vrender-animate": "~1.0.40", - "@visactor/vrender-components": "~1.0.40", - "@visactor/vrender-core": "~1.0.40", - "@visactor/vrender-kits": "~1.0.40", + "@visactor/vrender-animate": "~1.0.41", + "@visactor/vrender-components": "~1.0.41", + "@visactor/vrender-core": "~1.0.41", + "@visactor/vrender-kits": "~1.0.41", "@visactor/vutils": "~1.0.22" } }, "node_modules/@visactor/vdataset": { - "version": "1.0.22", - "resolved": "https://registry.npmjs.org/@visactor/vdataset/-/vdataset-1.0.22.tgz", - "integrity": "sha512-B+Xd7UuXNNLWRsH0ySd9LsvFFBz/S+lFN7ha7GU7SdTYr9P0ZExYyumQPntaa5iOMLxKysFxJw7t/Or70ZA6hQ==", + "version": "1.0.23", + "resolved": "https://registry.npmjs.org/@visactor/vdataset/-/vdataset-1.0.23.tgz", + "integrity": "sha512-zrLk9FBUWJoW6b30XnPKzXwAXl8USdLDfed6QZLsmdkylRU8V7yZeXE2aKwU8Lg1U4HmQngqmqOx7/QlbX44Tg==", "license": "MIT", "dependencies": { "@turf/flatten": "^6.5.0", "@turf/helpers": "^6.5.0", "@turf/rewind": "^6.5.0", - "@visactor/vutils": "1.0.22", + "@visactor/vutils": "1.0.23", "d3-dsv": "^2.0.0", "d3-geo": "^1.12.1", "d3-hexbin": "^0.2.2", @@ -5003,45 +5324,45 @@ } }, "node_modules/@visactor/vlayouts": { - "version": "1.0.22", - "resolved": "https://registry.npmjs.org/@visactor/vlayouts/-/vlayouts-1.0.22.tgz", - "integrity": "sha512-iTPJeROqmwBOBDoewDHYdEAe6MlDPgJ/1V1j+FcWr3zWGPDOSduoIDyjhB/FpkLaUQyhYCTCfCH3p2BDs3Wy4g==", + "version": "1.0.23", + "resolved": "https://registry.npmjs.org/@visactor/vlayouts/-/vlayouts-1.0.23.tgz", + "integrity": "sha512-fK1f5LmuumhYanLArk5yrT4BZxu4IAmdc8WMwfB/KAvV+2dTPFuBUMWbWnDl0siQoU9SX9l/bLozUnI9n7BwBQ==", "license": "MIT", "dependencies": { "@turf/helpers": "^6.5.0", "@turf/invariant": "^6.5.0", - "@visactor/vscale": "1.0.22", - "@visactor/vutils": "1.0.22", + "@visactor/vscale": "1.0.23", + "@visactor/vutils": "1.0.23", "eventemitter3": "^4.0.7" } }, "node_modules/@visactor/vrender-animate": { - "version": "1.0.40", - "resolved": "https://registry.npmjs.org/@visactor/vrender-animate/-/vrender-animate-1.0.40.tgz", - "integrity": "sha512-/OT+xKwTdRa+bhTF28mQxl+9HsaIncNFGv00G3m2EHQT7Ls8SCDAyD46J36+bU3eYKz/+V+CzL2ApwI/zXP8/A==", + "version": "1.0.41", + "resolved": "https://registry.npmjs.org/@visactor/vrender-animate/-/vrender-animate-1.0.41.tgz", + "integrity": "sha512-kdMoIh7OEo6z4rZfnJHX7d+izBhGVWq6MR22uppk0LPilfdBd/1hSNAEKO6C9JWAy5uROGFpEkh+kk+ar/zSZg==", "license": "MIT", "dependencies": { - "@visactor/vrender-core": "1.0.40", + "@visactor/vrender-core": "1.0.41", "@visactor/vutils": "~1.0.12" } }, "node_modules/@visactor/vrender-components": { - "version": "1.0.40", - "resolved": "https://registry.npmjs.org/@visactor/vrender-components/-/vrender-components-1.0.40.tgz", - "integrity": "sha512-+mooyFfpAaAjhBDN5XHKz1SH0vHe3IjabLJQWYwDZGvpPHieVIlZstkTIRMH63pJek0ViIZLHcxpf/i/qli+sw==", + "version": "1.0.41", + "resolved": "https://registry.npmjs.org/@visactor/vrender-components/-/vrender-components-1.0.41.tgz", + "integrity": "sha512-B7iXJE1TdkYapPZN6DNxoaErY4FzGf5AbcbG/z6Q0hnzO4Iw1hKdtlTGOIYA1+JXhehDWcyy+D0bnoNQNf+rgw==", "license": "MIT", "dependencies": { - "@visactor/vrender-animate": "1.0.40", - "@visactor/vrender-core": "1.0.40", - "@visactor/vrender-kits": "1.0.40", + "@visactor/vrender-animate": "1.0.41", + "@visactor/vrender-core": "1.0.41", + "@visactor/vrender-kits": "1.0.41", "@visactor/vscale": "~1.0.12", "@visactor/vutils": "~1.0.12" } }, "node_modules/@visactor/vrender-core": { - "version": "1.0.40", - "resolved": "https://registry.npmjs.org/@visactor/vrender-core/-/vrender-core-1.0.40.tgz", - "integrity": "sha512-VNfxYGvNS2k1v2/+H0y9jIxjsFjgy3ieFGTDYz+o1rES2RY4wQIr6xJtdEkzXg6Foavp1gJj4fSf/wl2idEuTA==", + "version": "1.0.41", + "resolved": "https://registry.npmjs.org/@visactor/vrender-core/-/vrender-core-1.0.41.tgz", + "integrity": "sha512-P7YVUJ45vwqPA460W6JJjg201ThvxBrjEgTBJ4tHKaHZPP/nVyF8rPyUOCL8QesEFDB+R0e/sUJUVs+ckHhd2w==", "license": "MIT", "dependencies": { "@visactor/vutils": "~1.0.12", @@ -5049,13 +5370,13 @@ } }, "node_modules/@visactor/vrender-kits": { - "version": "1.0.40", - "resolved": "https://registry.npmjs.org/@visactor/vrender-kits/-/vrender-kits-1.0.40.tgz", - "integrity": "sha512-tMD2C4vQd5kN2WyDIkel4tId1NYGclKM1exIsJY51rgOjfJsjpRzC5WPe6KYqoIcDO5xDarX8TY4dkTR9S0l6w==", + "version": "1.0.41", + "resolved": "https://registry.npmjs.org/@visactor/vrender-kits/-/vrender-kits-1.0.41.tgz", + "integrity": "sha512-ffJlKkNseOsnRjvBmxKxQ9dsQXu+K288NFCtB0ZZ2N/mtcTsZUlH5SDCcsAvDhPGEvJ22Ye4MFKcXSEDctpGzA==", "license": "MIT", "dependencies": { "@resvg/resvg-js": "2.4.1", - "@visactor/vrender-core": "1.0.40", + "@visactor/vrender-core": "1.0.41", "@visactor/vutils": "~1.0.12", "gifuct-js": "2.1.2", "lottie-web": "^5.12.2", @@ -5063,18 +5384,18 @@ } }, "node_modules/@visactor/vscale": { - "version": "1.0.22", - "resolved": "https://registry.npmjs.org/@visactor/vscale/-/vscale-1.0.22.tgz", - "integrity": "sha512-DCCqBA9gBZexCsfYmUjRZM1OBp2Qr8dRIGOqWcFerINIDtpQatSS4c8Ah2r8pqkgiizSNeKgDoXOtsiPe9zqBQ==", + "version": "1.0.23", + "resolved": "https://registry.npmjs.org/@visactor/vscale/-/vscale-1.0.23.tgz", + "integrity": "sha512-XePhYuRoNAp+8MeSMuEOOvhVAlOwvM1sDT2yFxE6zdwVB2GjZk8mH+5N2xQGQWk75YmGJjlJASFtgwjlb1yWxw==", "license": "MIT", "dependencies": { - "@visactor/vutils": "1.0.22" + "@visactor/vutils": "1.0.23" } }, "node_modules/@visactor/vutils": { - "version": "1.0.22", - "resolved": "https://registry.npmjs.org/@visactor/vutils/-/vutils-1.0.22.tgz", - "integrity": "sha512-n+lKEeSQl1wGqRG829oQ4SwU6UysUiP9rpp0K87TwmERVMupSy6C0KcM2SGXGBLvnp1Cyjjciw5ZtYQXv3ZfKQ==", + "version": "1.0.23", + "resolved": "https://registry.npmjs.org/@visactor/vutils/-/vutils-1.0.23.tgz", + "integrity": "sha512-M8SLqgdHhKN8QmQKTWD1gzEaHptpIV9pvMYvC6+VeOsqYvZZ6UdhSCAAczTYVo+m/uwcEC2JHSUspbrs8rzlRQ==", "license": "MIT", "dependencies": { "@turf/helpers": "^6.5.0", @@ -5083,9 +5404,9 @@ } }, "node_modules/@visactor/vutils-extension": { - "version": "2.0.16", - "resolved": "https://registry.npmjs.org/@visactor/vutils-extension/-/vutils-extension-2.0.16.tgz", - "integrity": "sha512-2ioPsCZ95a8QbCNNtjZQ0ZtsDqrrwLEx2WYR9lwrz8AqlXqHKahaOFRpse1MeUf/x99KnKuPTvFV9uaToDexJQ==", + "version": "2.0.19", + "resolved": "https://registry.npmjs.org/@visactor/vutils-extension/-/vutils-extension-2.0.19.tgz", + "integrity": "sha512-uRYCZFw7bYFdrSPQNIrYYsEGKuVn7lTMd4zDKBA2qnAfAZ/Y4WeXzZhQKdXgTFu5UGa292CqmVqMlVKFNWjC+A==", "license": "MIT", "dependencies": { "@visactor/vdataset": "~1.0.22", @@ -5579,6 +5900,7 @@ "integrity": "sha512-X3Pp2aRQhg4xUC6PQtkubn5NpRKuUPQ9FPDQlx36SmpFwwH2N0/tw4c+NXV3nw3PsgeUs+BuWGP0gjz3TvENLQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@algolia/abtesting": "1.15.1", "@algolia/client-abtesting": "5.49.1", @@ -5801,6 +6123,19 @@ "node": ">=8" } }, + "node_modules/app-builder-lib/node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, "node_modules/app-builder-lib/node_modules/fs-extra": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", @@ -6000,6 +6335,7 @@ "integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "follow-redirects": "^1.15.11", "form-data": "^4.0.5", @@ -6049,14 +6385,28 @@ } }, "node_modules/better-sqlite3": { - "version": "11.10.0", - "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-11.10.0.tgz", - "integrity": "sha512-EwhOpyXiOEL/lKzHz9AW1msWFNzGc/z+LzeB3/jnFJpxu+th2yqvzsSWas1v9jgs9+xiXJcD5A8CJxAG2TaghQ==", + "version": "12.8.0", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.8.0.tgz", + "integrity": "sha512-RxD2Vd96sQDjQr20kdP+F+dK/1OUNiVOl200vKBZY8u0vTwysfolF6Hq+3ZK2+h8My9YvZhHsF+RSGZW2VYrPQ==", "hasInstallScript": true, "license": "MIT", + "peer": true, "dependencies": { "bindings": "^1.5.0", "prebuild-install": "^7.1.1" + }, + "engines": { + "node": "20.x || 22.x || 23.x || 24.x || 25.x" + } + }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" } }, "node_modules/bindings": { @@ -6130,6 +6480,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -6533,6 +6884,7 @@ "integrity": "sha512-opLQzEVriiH1uUQ4Kctsd49bRoFDXGGSC4GUqj7pGyxM3RehRhvTlZJc1FL/Flew2p5uwxa1tUDWKzI4wNM8pg==", "dev": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "@chevrotain/cst-dts-gen": "11.1.2", "@chevrotain/gast": "11.1.2", @@ -6888,8 +7240,7 @@ "integrity": "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==", "dev": true, "license": "MIT", - "optional": true, - "peer": true + "optional": true }, "node_modules/cross-env": { "version": "7.0.3", @@ -6925,11 +7276,25 @@ "node": ">= 8" } }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/cytoscape": { @@ -6938,6 +7303,7 @@ "integrity": "sha512-iJc4TwyANnOGR1OmWhsS9ayRS3s+XQ185FmuHObThD+5AeJCakAAbWv8KimMTt08xCCLNgneQwFp+JRJOr9qGQ==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=0.10" } @@ -7371,6 +7737,7 @@ "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "dev": true, "license": "ISC", + "peer": true, "engines": { "node": ">=12" } @@ -7550,9 +7917,9 @@ } }, "node_modules/dagre-d3-es": { - "version": "7.0.13", - "resolved": "https://registry.npmjs.org/dagre-d3-es/-/dagre-d3-es-7.0.13.tgz", - "integrity": "sha512-efEhnxpSuwpYOKRm/L5KbqoZmNNukHa/Flty4Wp62JRvgH2ojwVgPgdYyr4twpieZnyRDdIH7PY2mopX26+j2Q==", + "version": "7.0.14", + "resolved": "https://registry.npmjs.org/dagre-d3-es/-/dagre-d3-es-7.0.14.tgz", + "integrity": "sha512-P4rFMVq9ESWqmOgK+dlXvOtLwYg0i7u0HBGJER0LZDJT2VHIPAMZ/riPxqJceWMStH5+E61QxFra9kIS3AqdMg==", "dev": true, "license": "MIT", "dependencies": { @@ -7560,6 +7927,20 @@ "lodash-es": "^4.17.21" } }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/dayjs": { "version": "1.11.19", "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.19.tgz", @@ -7584,6 +7965,13 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, "node_modules/decompress-response": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", @@ -7805,6 +8193,7 @@ "integrity": "sha512-glMJgnTreo8CFINujtAhCgN96QAqApDMZ8Vl1r8f0QT8QprvC1UCltV4CcWj20YoIyLZx6IUskaJZ0NV8fokcg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "app-builder-lib": "26.8.1", "builder-util": "26.8.1", @@ -7931,9 +8320,9 @@ } }, "node_modules/dotenv": { - "version": "16.6.1", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", - "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "version": "17.3.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.3.1.tgz", + "integrity": "sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA==", "license": "BSD-2-Clause", "engines": { "node": ">=12" @@ -7958,54 +8347,67 @@ "url": "https://dotenvx.com" } }, + "node_modules/dotenv-expand/node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, "node_modules/drizzle-kit": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/drizzle-kit/-/drizzle-kit-0.28.1.tgz", - "integrity": "sha512-JimOV+ystXTWMgZkLHYHf2w3oS28hxiH1FR0dkmJLc7GHzdGJoJAQtQS5DRppnabsRZwE2U1F6CuezVBgmsBBQ==", + "version": "0.31.10", + "resolved": "https://registry.npmjs.org/drizzle-kit/-/drizzle-kit-0.31.10.tgz", + "integrity": "sha512-7OZcmQUrdGI+DUNNsKBn1aW8qSoKuTH7d0mYgSP8bAzdFzKoovxEFnoGQp2dVs82EOJeYycqRtciopszwUf8bw==", "dev": true, "license": "MIT", "dependencies": { "@drizzle-team/brocli": "^0.10.2", "@esbuild-kit/esm-loader": "^2.5.5", - "esbuild": "^0.19.7", - "esbuild-register": "^3.5.0" + "esbuild": "^0.25.4", + "tsx": "^4.21.0" }, "bin": { "drizzle-kit": "bin.cjs" } }, "node_modules/drizzle-orm": { - "version": "0.36.4", - "resolved": "https://registry.npmjs.org/drizzle-orm/-/drizzle-orm-0.36.4.tgz", - "integrity": "sha512-1OZY3PXD7BR00Gl61UUOFihslDldfH4NFRH2MbP54Yxi0G/PKn4HfO65JYZ7c16DeP3SpM3Aw+VXVG9j6CRSXA==", + "version": "0.45.1", + "resolved": "https://registry.npmjs.org/drizzle-orm/-/drizzle-orm-0.45.1.tgz", + "integrity": "sha512-Te0FOdKIistGNPMq2jscdqngBRfBpC8uMFVwqjf6gtTVJHIQ/dosgV/CLBU2N4ZJBsXL5savCba9b0YJskKdcA==", "license": "Apache-2.0", "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", - "@cloudflare/workers-types": ">=3", + "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", - "@planetscale/database": ">=1", + "@planetscale/database": ">=1.13", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", - "@types/react": ">=18", "@types/sql.js": "*", + "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", + "gel": ">=2", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", - "react": ">=18", "sql.js": ">=1", "sqlite3": ">=5" }, @@ -8049,10 +8451,10 @@ "@types/pg": { "optional": true }, - "@types/react": { + "@types/sql.js": { "optional": true }, - "@types/sql.js": { + "@upstash/redis": { "optional": true }, "@vercel/postgres": { @@ -8070,6 +8472,9 @@ "expo-sqlite": { "optional": true }, + "gel": { + "optional": true + }, "knex": { "optional": true }, @@ -8088,9 +8493,6 @@ "prisma": { "optional": true }, - "react": { - "optional": true - }, "sql.js": { "optional": true }, @@ -8138,15 +8540,15 @@ } }, "node_modules/electron": { - "version": "35.7.5", - "resolved": "https://registry.npmjs.org/electron/-/electron-35.7.5.tgz", - "integrity": "sha512-dnL+JvLraKZl7iusXTVTGYs10TKfzUi30uEDTqsmTm0guN9V2tbOjTzyIZbh9n3ygUjgEYyo+igAwMRXIi3IPw==", + "version": "41.0.3", + "resolved": "https://registry.npmjs.org/electron/-/electron-41.0.3.tgz", + "integrity": "sha512-IDjx8liW1q+r7+MOip5W1Eo1eMwJzVObmYrd9yz2dPCkS7XlgLq3qPVMR80TpiROFp73iY30kTzMdpA6fEVs3A==", "dev": true, "hasInstallScript": true, "license": "MIT", "dependencies": { "@electron/get": "^2.0.0", - "@types/node": "^22.7.7", + "@types/node": "^24.9.0", "extract-zip": "^2.0.1" }, "bin": { @@ -8387,7 +8789,6 @@ "dev": true, "hasInstallScript": true, "license": "MIT", - "peer": true, "dependencies": { "@electron/asar": "^3.2.1", "debug": "^4.1.1", @@ -8408,7 +8809,6 @@ "integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "graceful-fs": "^4.1.2", "jsonfile": "^4.0.0", @@ -8418,6 +8818,23 @@ "node": ">=6 <7 || >=8" } }, + "node_modules/electron/node_modules/@types/node": { + "version": "24.12.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.0.tgz", + "integrity": "sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/electron/node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "dev": true, + "license": "MIT" + }, "node_modules/emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", @@ -8467,9 +8884,9 @@ } }, "node_modules/enhanced-resolve": { - "version": "5.19.0", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.19.0.tgz", - "integrity": "sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg==", + "version": "5.20.1", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.1.tgz", + "integrity": "sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==", "dev": true, "license": "MIT", "dependencies": { @@ -8575,9 +8992,9 @@ "optional": true }, "node_modules/esbuild": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.19.12.tgz", - "integrity": "sha512-aARqgq8roFBj054KvQr5f1sFu0D65G+miZRCuJyJ0G13Zwx7vRar5Zhn2tkQNzIXcBrNVsv/8stehpj+GAjgbg==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -8585,61 +9002,102 @@ "esbuild": "bin/esbuild" }, "engines": { - "node": ">=12" + "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.19.12", - "@esbuild/android-arm": "0.19.12", - "@esbuild/android-arm64": "0.19.12", - "@esbuild/android-x64": "0.19.12", - "@esbuild/darwin-arm64": "0.19.12", - "@esbuild/darwin-x64": "0.19.12", - "@esbuild/freebsd-arm64": "0.19.12", - "@esbuild/freebsd-x64": "0.19.12", - "@esbuild/linux-arm": "0.19.12", - "@esbuild/linux-arm64": "0.19.12", - "@esbuild/linux-ia32": "0.19.12", - "@esbuild/linux-loong64": "0.19.12", - "@esbuild/linux-mips64el": "0.19.12", - "@esbuild/linux-ppc64": "0.19.12", - "@esbuild/linux-riscv64": "0.19.12", - "@esbuild/linux-s390x": "0.19.12", - "@esbuild/linux-x64": "0.19.12", - "@esbuild/netbsd-x64": "0.19.12", - "@esbuild/openbsd-x64": "0.19.12", - "@esbuild/sunos-x64": "0.19.12", - "@esbuild/win32-arm64": "0.19.12", - "@esbuild/win32-ia32": "0.19.12", - "@esbuild/win32-x64": "0.19.12" - } - }, - "node_modules/esbuild-register": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/esbuild-register/-/esbuild-register-3.6.0.tgz", - "integrity": "sha512-H2/S7Pm8a9CL1uhp9OvjwrBh5Pvx0H8qVOxNu8Wed9Y7qv56MPtq+GGM8RJpq6glYJn9Wspr8uw7l55uyinNeg==", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "^4.3.4" - }, - "peerDependencies": { - "esbuild": ">=0.12 <1" + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" } }, - "node_modules/escalade": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", - "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "node_modules/esbuild/node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], "engines": { - "node": ">=6" + "node": ">=18" } }, - "node_modules/escape-html": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", - "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "node_modules/esbuild/node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/esbuild/node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", "license": "MIT" }, "node_modules/escape-string-regexp": { @@ -8799,9 +9257,9 @@ "license": "BSD-3-Clause" }, "node_modules/fastify": { - "version": "5.7.4", - "resolved": "https://registry.npmjs.org/fastify/-/fastify-5.7.4.tgz", - "integrity": "sha512-e6l5NsRdaEP8rdD8VR0ErJASeyaRbzXYpmkrpr2SuvuMq6Si3lvsaVy5C+7gLanEkvjpMDzBXWE5HPeb/hgTxA==", + "version": "5.8.2", + "resolved": "https://registry.npmjs.org/fastify/-/fastify-5.8.2.tgz", + "integrity": "sha512-lZmt3navvZG915IE+f7/TIVamxIwmBd+OMB+O9WBzcpIwOo6F0LTh0sluoMFk5VkrKTvvrwIaoJPkir4Z+jtAg==", "funding": [ { "type": "github", @@ -8823,7 +9281,7 @@ "fast-json-stringify": "^6.0.0", "find-my-way": "^9.0.0", "light-my-request": "^6.0.0", - "pino": "^10.1.0", + "pino": "^9.14.0 || ^10.1.0", "process-warning": "^5.0.0", "rfdc": "^1.3.1", "secure-json-parse": "^4.0.0", @@ -8971,6 +9429,7 @@ "integrity": "sha512-/yNdlIkpWbM0ptxno3ONTuf+2g318kh2ez3KSeZN5dZ8YC6AAmgeWz+GasYYiBJPFaYcSAPeu4GfhUaChzIJXA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "tabbable": "^6.4.0" } @@ -9559,6 +10018,19 @@ "dev": true, "license": "ISC" }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/html-void-elements": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-3.0.0.tgz", @@ -9737,7 +10209,6 @@ "version": "10.1.0", "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", - "dev": true, "license": "MIT", "engines": { "node": ">= 12" @@ -9772,6 +10243,13 @@ "node": ">=8" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/is-property": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz", @@ -9917,6 +10395,67 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsdom": { + "version": "29.0.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.0.1.tgz", + "integrity": "sha512-z6JOK5gRO7aMybVq/y/MlIpKh8JIi68FBKMUtKkK2KH/wMSRlCxQ682d08LB9fYXplyY/UXG8P4XXTScmdjApg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^5.0.1", + "@asamuzakjp/dom-selector": "^7.0.3", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.1", + "@exodus/bytes": "^1.15.0", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.2.7", + "parse5": "^8.0.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.1", + "undici": "^7.24.5", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.1", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/lru-cache": { + "version": "11.2.7", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz", + "integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/jsdom/node_modules/undici": { + "version": "7.24.5", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.24.5.tgz", + "integrity": "sha512-3IWdCpjgxp15CbJnsi/Y9TCDE7HWVN19j1hmzVhoAkY/+CJx449tVxT5wZc1Gwg8J+P0LWvzlBzxYRnHJ+1i7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -10105,9 +10644,9 @@ "license": "MIT" }, "node_modules/lightningcss": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.31.1.tgz", - "integrity": "sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", "dev": true, "license": "MPL-2.0", "dependencies": { @@ -10121,23 +10660,23 @@ "url": "https://opencollective.com/parcel" }, "optionalDependencies": { - "lightningcss-android-arm64": "1.31.1", - "lightningcss-darwin-arm64": "1.31.1", - "lightningcss-darwin-x64": "1.31.1", - "lightningcss-freebsd-x64": "1.31.1", - "lightningcss-linux-arm-gnueabihf": "1.31.1", - "lightningcss-linux-arm64-gnu": "1.31.1", - "lightningcss-linux-arm64-musl": "1.31.1", - "lightningcss-linux-x64-gnu": "1.31.1", - "lightningcss-linux-x64-musl": "1.31.1", - "lightningcss-win32-arm64-msvc": "1.31.1", - "lightningcss-win32-x64-msvc": "1.31.1" + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" } }, "node_modules/lightningcss-android-arm64": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.31.1.tgz", - "integrity": "sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", "cpu": [ "arm64" ], @@ -10156,9 +10695,9 @@ } }, "node_modules/lightningcss-darwin-arm64": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.31.1.tgz", - "integrity": "sha512-02uTEqf3vIfNMq3h/z2cJfcOXnQ0GRwQrkmPafhueLb2h7mqEidiCzkE4gBMEH65abHRiQvhdcQ+aP0D0g67sg==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", "cpu": [ "arm64" ], @@ -10177,9 +10716,9 @@ } }, "node_modules/lightningcss-darwin-x64": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.31.1.tgz", - "integrity": "sha512-1ObhyoCY+tGxtsz1lSx5NXCj3nirk0Y0kB/g8B8DT+sSx4G9djitg9ejFnjb3gJNWo7qXH4DIy2SUHvpoFwfTA==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", "cpu": [ "x64" ], @@ -10198,9 +10737,9 @@ } }, "node_modules/lightningcss-freebsd-x64": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.31.1.tgz", - "integrity": "sha512-1RINmQKAItO6ISxYgPwszQE1BrsVU5aB45ho6O42mu96UiZBxEXsuQ7cJW4zs4CEodPUioj/QrXW1r9pLUM74A==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", "cpu": [ "x64" ], @@ -10219,9 +10758,9 @@ } }, "node_modules/lightningcss-linux-arm-gnueabihf": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.31.1.tgz", - "integrity": "sha512-OOCm2//MZJ87CdDK62rZIu+aw9gBv4azMJuA8/KB74wmfS3lnC4yoPHm0uXZ/dvNNHmnZnB8XLAZzObeG0nS1g==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", "cpu": [ "arm" ], @@ -10240,9 +10779,9 @@ } }, "node_modules/lightningcss-linux-arm64-gnu": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.31.1.tgz", - "integrity": "sha512-WKyLWztD71rTnou4xAD5kQT+982wvca7E6QoLpoawZ1gP9JM0GJj4Tp5jMUh9B3AitHbRZ2/H3W5xQmdEOUlLg==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", "cpu": [ "arm64" ], @@ -10261,9 +10800,9 @@ } }, "node_modules/lightningcss-linux-arm64-musl": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.31.1.tgz", - "integrity": "sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", "cpu": [ "arm64" ], @@ -10282,9 +10821,9 @@ } }, "node_modules/lightningcss-linux-x64-gnu": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.31.1.tgz", - "integrity": "sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", "cpu": [ "x64" ], @@ -10303,9 +10842,9 @@ } }, "node_modules/lightningcss-linux-x64-musl": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.31.1.tgz", - "integrity": "sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", "cpu": [ "x64" ], @@ -10324,9 +10863,9 @@ } }, "node_modules/lightningcss-win32-arm64-msvc": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.31.1.tgz", - "integrity": "sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", "cpu": [ "arm64" ], @@ -10345,9 +10884,9 @@ } }, "node_modules/lightningcss-win32-x64-msvc": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.31.1.tgz", - "integrity": "sha512-I9aiFrbd7oYHwlnQDqr1Roz+fTz61oDDJX7n9tYF9FJymH1cIN1DtKw3iYt6b8WZgEjoNwVSncwF4wx/ZedMhw==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", "cpu": [ "x64" ], @@ -10516,10 +11055,9 @@ "license": "MIT" }, "node_modules/marked": { - "version": "16.4.2", - "resolved": "https://registry.npmjs.org/marked/-/marked-16.4.2.tgz", - "integrity": "sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA==", - "dev": true, + "version": "17.0.4", + "resolved": "https://registry.npmjs.org/marked/-/marked-17.0.4.tgz", + "integrity": "sha512-NOmVMM+KAokHMvjWmC5N/ZOvgmSWuqJB8FoYI019j4ogb/PeRMKoKIjReZ2w3376kkA8dSJIP8uD993Kxc0iRQ==", "license": "MIT", "bin": { "marked": "bin/marked.js" @@ -10574,35 +11112,56 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, "node_modules/mermaid": { - "version": "11.12.3", - "resolved": "https://registry.npmjs.org/mermaid/-/mermaid-11.12.3.tgz", - "integrity": "sha512-wN5ZSgJQIC+CHJut9xaKWsknLxaFBwCPwPkGTSUYrTiHORWvpT8RxGk849HPnpUAQ+/9BPRqYb80jTpearrHzQ==", + "version": "11.13.0", + "resolved": "https://registry.npmjs.org/mermaid/-/mermaid-11.13.0.tgz", + "integrity": "sha512-fEnci+Immw6lKMFI8sqzjlATTyjLkRa6axrEgLV2yHTfv8r+h1wjFbV6xeRtd4rUV1cS4EpR9rwp3Rci7TRWDw==", "dev": true, "license": "MIT", "dependencies": { "@braintree/sanitize-url": "^7.1.1", - "@iconify/utils": "^3.0.1", - "@mermaid-js/parser": "^1.0.0", + "@iconify/utils": "^3.0.2", + "@mermaid-js/parser": "^1.0.1", "@types/d3": "^7.4.3", - "cytoscape": "^3.29.3", + "@upsetjs/venn.js": "^2.0.0", + "cytoscape": "^3.33.1", "cytoscape-cose-bilkent": "^4.1.0", "cytoscape-fcose": "^2.2.0", "d3": "^7.9.0", "d3-sankey": "^0.12.3", - "dagre-d3-es": "7.0.13", - "dayjs": "^1.11.18", - "dompurify": "^3.2.5", - "katex": "^0.16.22", + "dagre-d3-es": "7.0.14", + "dayjs": "^1.11.19", + "dompurify": "^3.3.1", + "katex": "^0.16.25", "khroma": "^2.1.0", "lodash-es": "^4.17.23", - "marked": "^16.2.1", + "marked": "^16.3.0", "roughjs": "^4.6.6", "stylis": "^4.3.6", "ts-dedent": "^2.2.0", "uuid": "^11.1.0" } }, + "node_modules/mermaid/node_modules/marked": { + "version": "16.4.2", + "resolved": "https://registry.npmjs.org/marked/-/marked-16.4.2.tgz", + "integrity": "sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA==", + "dev": true, + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 20" + } + }, "node_modules/mermaid/node_modules/uuid": { "version": "11.1.0", "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", @@ -10964,7 +11523,6 @@ "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "minimist": "^1.2.6" }, @@ -11005,10 +11563,11 @@ "license": "MIT" }, "node_modules/mysql2": { - "version": "3.18.2", - "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.18.2.tgz", - "integrity": "sha512-UfEShBFAZZEAKjySnTUuE7BgqkYT4mx+RjoJ5aqtmwSSvNcJ/QxQPXz/y3jSxNiVRedPfgccmuBtiPCSiEEytw==", + "version": "3.20.0", + "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.20.0.tgz", + "integrity": "sha512-eCLUs7BNbgA6nf/MZXsaBO1SfGs0LtLVrJD3WeWq+jPLDWkSufTD+aGMwykfUVPdZnblaUK1a8G/P63cl9FkKg==", "license": "MIT", + "peer": true, "dependencies": { "aws-ssl-profiles": "^1.1.2", "denque": "^2.1.0", @@ -11145,13 +11704,10 @@ } }, "node_modules/node-cron": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/node-cron/-/node-cron-3.0.3.tgz", - "integrity": "sha512-dOal67//nohNgYWb+nWmg5dkFdIwDm8EpeGYMekPMrngV3637lqnX0lbUcCtgibHTz6SEz7DAIjKvKDFYCnO1A==", + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/node-cron/-/node-cron-4.2.1.tgz", + "integrity": "sha512-lgimEHPE/QDgFlywTd8yTR61ptugX3Qer29efeyWw2rv259HtGBNn1vZVmp8lB9uo9wC0t/AT4iGqXxia+CJFg==", "license": "ISC", - "dependencies": { - "uuid": "8.3.2" - }, "engines": { "node": ">=6.0.0" } @@ -11228,9 +11784,9 @@ "license": "MIT" }, "node_modules/nodemailer": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.1.tgz", - "integrity": "sha512-5kcldIXmaEjZcHR6F28IKGSgpmZHaF1IXLWFTG+Xh3S+Cce4MiakLtWY+PlBU69fLbRa8HlaGIrC/QolUpHkhg==", + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.3.tgz", + "integrity": "sha512-JQNBqvK+bj3NMhUFR3wmCl3SYcOeMotDiwDBvIoCuQdF0PvlIY0BH+FJ2CG7u4cXKPChplE78oowlH/Otsc4ZQ==", "license": "MIT-0", "engines": { "node": ">=6.0.0" @@ -11423,6 +11979,32 @@ "integrity": "sha512-JyPSBnkTJ0AI8GGJLfMXvKq42cj5c006fnLz6fXy6zfoVjJizi8BNTpu8on8ziI1cKy9d9DGNuY17Ce7wuejpQ==", "license": "MIT" }, + "node_modules/parse5": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz", + "integrity": "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5/node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/path-browserify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", @@ -11554,6 +12136,7 @@ "resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz", "integrity": "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==", "license": "MIT", + "peer": true, "dependencies": { "pg-connection-string": "^2.12.0", "pg-pool": "^3.13.0", @@ -11651,6 +12234,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -11831,7 +12415,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "dependencies": { "commander": "^9.4.0" }, @@ -11849,7 +12432,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "engines": { "node": "^12.20.0 || >=14" } @@ -12037,6 +12619,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -12049,6 +12632,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -12074,37 +12658,43 @@ } }, "node_modules/react-router": { - "version": "6.30.3", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.3.tgz", - "integrity": "sha512-XRnlbKMTmktBkjCLE8/XcZFlnHvr2Ltdr1eJX4idL55/9BbORzyZEaIkBFDhFGCEWBBItsVrDxwx3gnisMitdw==", + "version": "7.13.1", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.13.1.tgz", + "integrity": "sha512-td+xP4X2/6BJvZoX6xw++A2DdEi++YypA69bJUV5oVvqf6/9/9nNlD70YO1e9d3MyamJEBQFEzk6mbfDYbqrSA==", "dev": true, "license": "MIT", "dependencies": { - "@remix-run/router": "1.23.2" + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" }, "engines": { - "node": ">=14.0.0" + "node": ">=20.0.0" }, "peerDependencies": { - "react": ">=16.8" + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } } }, "node_modules/react-router-dom": { - "version": "6.30.3", - "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.3.tgz", - "integrity": "sha512-pxPcv1AczD4vso7G4Z3TKcvlxK7g7TNt3/FNGMhfqyntocvYKj+GCatfigGDjbLozC4baguJ0ReCigoDJXb0ag==", + "version": "7.13.1", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.13.1.tgz", + "integrity": "sha512-UJnV3Rxc5TgUPJt2KJpo1Jpy0OKQr0AjgbZzBFjaPJcFOb2Y8jA5H3LT8HUJAiRLlWrEXWHbF1Z4SCZaQjWDHw==", "dev": true, "license": "MIT", "dependencies": { - "@remix-run/router": "1.23.2", - "react-router": "6.30.3" + "react-router": "7.13.1" }, "engines": { - "node": ">=14.0.0" + "node": ">=20.0.0" }, "peerDependencies": { - "react": ">=16.8", - "react-dom": ">=16.8" + "react": ">=18", + "react-dom": ">=18" } }, "node_modules/react-shallow-renderer": { @@ -12331,7 +12921,6 @@ "deprecated": "Rimraf versions prior to v4 are no longer supported", "dev": true, "license": "ISC", - "peer": true, "dependencies": { "glob": "^7.1.3" }, @@ -12344,8 +12933,7 @@ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/rimraf/node_modules/brace-expansion": { "version": "1.1.12", @@ -12353,7 +12941,6 @@ "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -12366,7 +12953,6 @@ "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", "dev": true, "license": "ISC", - "peer": true, "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", @@ -12388,7 +12974,6 @@ "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, "license": "ISC", - "peer": true, "dependencies": { "brace-expansion": "^1.1.7" }, @@ -12568,6 +13153,19 @@ "node": ">=11.0.0" } }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/scheduler": { "version": "0.23.2", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", @@ -12837,9 +13435,9 @@ } }, "node_modules/simple-statistics": { - "version": "7.8.8", - "resolved": "https://registry.npmjs.org/simple-statistics/-/simple-statistics-7.8.8.tgz", - "integrity": "sha512-CUtP0+uZbcbsFpqEyvNDYjJCl+612fNgjT8GaVuvMG7tBuJg8gXGpsP5M7X658zy0IcepWOZ6nPBu1Qb9ezA1w==", + "version": "7.8.9", + "resolved": "https://registry.npmjs.org/simple-statistics/-/simple-statistics-7.8.9.tgz", + "integrity": "sha512-YT6MLqYsz7y1rQZOLFlOCCgSRpCi6bqY417yhoOLI7aVoBi29dD39EPrOE03W9DY25H0J0jizVsHZnkLzyGJFg==", "license": "ISC", "engines": { "node": "*" @@ -12949,7 +13547,6 @@ "version": "4.2.0", "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", - "dev": true, "license": "MIT", "engines": { "node": ">= 6.0.0", @@ -12960,7 +13557,6 @@ "version": "2.8.7", "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.7.tgz", "integrity": "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==", - "dev": true, "license": "MIT", "dependencies": { "ip-address": "^10.0.1", @@ -13271,6 +13867,13 @@ "url": "https://github.com/chalk/supports-color?sponsor=1" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, "node_modules/tabbable": { "version": "6.4.0", "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.4.0.tgz", @@ -13279,9 +13882,9 @@ "license": "MIT" }, "node_modules/tailwindcss": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.1.tgz", - "integrity": "sha512-/tBrSQ36vCleJkAOsy9kbNTgaxvGbyOamC30PRePTQe/o1MFwEKHQk4Cn7BNGaPtjp+PuUrByJehM1hgxfq4sw==", + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.2.tgz", + "integrity": "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==", "dev": true, "license": "MIT" }, @@ -13370,7 +13973,6 @@ "integrity": "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "mkdirp": "^0.5.1", "rimraf": "~2.6.2" @@ -13534,6 +14136,26 @@ "node": ">=14.0.0" } }, + "node_modules/tldts": { + "version": "7.0.26", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.26.tgz", + "integrity": "sha512-WiGwQjr0qYdNNG8KpMKlSvpxz652lqa3Rd+/hSaDcY4Uo6SKWZq2LAF+hsAhUewTtYhXlorBKgNF3Kk8hnjGoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.0.26" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.0.26", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.26.tgz", + "integrity": "sha512-5WJ2SqFsv4G2Dwi7ZFVRnz6b2H1od39QME1lc2y5Ew3eWiZMAeqOAfWpRP9jHvhUl881406QtZTODvjttJs+ew==", + "dev": true, + "license": "MIT" + }, "node_modules/tmp": { "version": "0.2.5", "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz", @@ -13598,6 +14220,32 @@ "geo2topo": "bin/geo2topo" } }, + "node_modules/tough-cookie": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz", + "integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/tree-kill": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", @@ -14136,6 +14784,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -14152,9 +14801,9 @@ "license": "MIT" }, "node_modules/undici": { - "version": "6.23.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-6.23.0.tgz", - "integrity": "sha512-VfQPToRA5FZs/qJxLIinmU59u0r7LXqoJkCzinq3ckNJp3vKEh7jTWN589YQ5+aoAC/TGRLyJLCPKcLQbM8r9g==", + "version": "6.24.1", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.24.1.tgz", + "integrity": "sha512-sC+b0tB1whOCzbtlx20fx3WgCXwkW627p4EA9uM+/tNNPkSS+eSEld6pAs9nDv7WbY1UUljBMYPtu9BCOrCWKA==", "license": "MIT", "engines": { "node": ">=18.17" @@ -14329,15 +14978,6 @@ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "license": "MIT" }, - "node_modules/uuid": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", - "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", - "license": "MIT", - "bin": { - "uuid": "dist/bin/uuid" - } - }, "node_modules/verror": { "version": "1.10.1", "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.1.tgz", @@ -14398,6 +15038,7 @@ "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", @@ -14980,530 +15621,46 @@ } } }, - "node_modules/vite/node_modules/@esbuild/aix-ppc64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", - "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", - "cpu": [ - "ppc64" - ], + "node_modules/vitepress": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/vitepress/-/vitepress-1.6.4.tgz", + "integrity": "sha512-+2ym1/+0VVrbhNyRoFFesVvBvHAVMZMK0rw60E3X/5349M1GuVdKeazuksqopEdvkKwKGs21Q729jX81/bkBJg==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/android-arm": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", - "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/android-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", - "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/android-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", - "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/darwin-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", - "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/darwin-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", - "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/freebsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", - "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/freebsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", - "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-arm": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", - "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", - "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-ia32": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", - "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-loong64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", - "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-mips64el": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", - "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-ppc64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", - "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-riscv64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", - "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-s390x": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", - "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", - "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/netbsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", - "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/netbsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", - "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/openbsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", - "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/openbsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", - "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/openharmony-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", - "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/sunos-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", - "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/win32-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", - "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/win32-ia32": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", - "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/win32-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", - "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/esbuild": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", - "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.25.12", - "@esbuild/android-arm": "0.25.12", - "@esbuild/android-arm64": "0.25.12", - "@esbuild/android-x64": "0.25.12", - "@esbuild/darwin-arm64": "0.25.12", - "@esbuild/darwin-x64": "0.25.12", - "@esbuild/freebsd-arm64": "0.25.12", - "@esbuild/freebsd-x64": "0.25.12", - "@esbuild/linux-arm": "0.25.12", - "@esbuild/linux-arm64": "0.25.12", - "@esbuild/linux-ia32": "0.25.12", - "@esbuild/linux-loong64": "0.25.12", - "@esbuild/linux-mips64el": "0.25.12", - "@esbuild/linux-ppc64": "0.25.12", - "@esbuild/linux-riscv64": "0.25.12", - "@esbuild/linux-s390x": "0.25.12", - "@esbuild/linux-x64": "0.25.12", - "@esbuild/netbsd-arm64": "0.25.12", - "@esbuild/netbsd-x64": "0.25.12", - "@esbuild/openbsd-arm64": "0.25.12", - "@esbuild/openbsd-x64": "0.25.12", - "@esbuild/openharmony-arm64": "0.25.12", - "@esbuild/sunos-x64": "0.25.12", - "@esbuild/win32-arm64": "0.25.12", - "@esbuild/win32-ia32": "0.25.12", - "@esbuild/win32-x64": "0.25.12" - } - }, - "node_modules/vitepress": { - "version": "1.6.4", - "resolved": "https://registry.npmjs.org/vitepress/-/vitepress-1.6.4.tgz", - "integrity": "sha512-+2ym1/+0VVrbhNyRoFFesVvBvHAVMZMK0rw60E3X/5349M1GuVdKeazuksqopEdvkKwKGs21Q729jX81/bkBJg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@docsearch/css": "3.8.2", - "@docsearch/js": "3.8.2", - "@iconify-json/simple-icons": "^1.2.21", - "@shikijs/core": "^2.1.0", - "@shikijs/transformers": "^2.1.0", - "@shikijs/types": "^2.1.0", - "@types/markdown-it": "^14.1.2", - "@vitejs/plugin-vue": "^5.2.1", - "@vue/devtools-api": "^7.7.0", - "@vue/shared": "^3.5.13", - "@vueuse/core": "^12.4.0", - "@vueuse/integrations": "^12.4.0", - "focus-trap": "^7.6.4", - "mark.js": "8.11.1", - "minisearch": "^7.1.1", - "shiki": "^2.1.0", - "vite": "^5.4.14", - "vue": "^3.5.13" - }, - "bin": { - "vitepress": "bin/vitepress.js" - }, - "peerDependencies": { - "markdown-it-mathjax3": "^4", - "postcss": "^8" - }, - "peerDependenciesMeta": { - "markdown-it-mathjax3": { - "optional": true - }, - "postcss": { - "optional": true - } + "dependencies": { + "@docsearch/css": "3.8.2", + "@docsearch/js": "3.8.2", + "@iconify-json/simple-icons": "^1.2.21", + "@shikijs/core": "^2.1.0", + "@shikijs/transformers": "^2.1.0", + "@shikijs/types": "^2.1.0", + "@types/markdown-it": "^14.1.2", + "@vitejs/plugin-vue": "^5.2.1", + "@vue/devtools-api": "^7.7.0", + "@vue/shared": "^3.5.13", + "@vueuse/core": "^12.4.0", + "@vueuse/integrations": "^12.4.0", + "focus-trap": "^7.6.4", + "mark.js": "8.11.1", + "minisearch": "^7.1.1", + "shiki": "^2.1.0", + "vite": "^5.4.14", + "vue": "^3.5.13" + }, + "bin": { + "vitepress": "bin/vitepress.js" + }, + "peerDependencies": { + "markdown-it-mathjax3": "^4", + "postcss": "^8" + }, + "peerDependenciesMeta": { + "markdown-it-mathjax3": { + "optional": true + }, + "postcss": { + "optional": true + } } }, "node_modules/vitepress-plugin-mermaid": { @@ -16539,6 +16696,7 @@ "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", @@ -16654,6 +16812,7 @@ "integrity": "sha512-BZqN4Ze6mDQVNAni0IHeMJ5mwr8VAJ3MQC9FmprRhcBYENw+wOAAjRj8jfmN6FLl0j96OXbR+CjWhmAmM+QGnA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vue/compiler-dom": "3.5.29", "@vue/compiler-sfc": "3.5.29", @@ -16670,16 +16829,29 @@ } } }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/wait-on": { - "version": "8.0.5", - "resolved": "https://registry.npmjs.org/wait-on/-/wait-on-8.0.5.tgz", - "integrity": "sha512-J3WlS0txVHkhLRb2FsmRg3dkMTCV1+M6Xra3Ho7HzZDHpE7DCOnoSoCJsZotrmW3uRMhvIJGSKUKrh/MeF4iag==", + "version": "9.0.4", + "resolved": "https://registry.npmjs.org/wait-on/-/wait-on-9.0.4.tgz", + "integrity": "sha512-k8qrgfwrPVJXTeFY8tl6BxVHiclK11u72DVKhpybHfUL/K6KM4bdyK9EhIVYGytB5MJe/3lq4Tf0hrjM+pvJZQ==", "dev": true, "license": "MIT", "dependencies": { - "axios": "^1.12.1", - "joi": "^18.0.1", - "lodash": "^4.17.21", + "axios": "^1.13.5", + "joi": "^18.0.2", + "lodash": "^4.17.23", "minimist": "^1.2.8", "rxjs": "^7.8.2" }, @@ -16687,7 +16859,7 @@ "wait-on": "bin/wait-on" }, "engines": { - "node": ">=12.0.0" + "node": ">=20.0.0" } }, "node_modules/wcwidth": { @@ -16700,6 +16872,41 @@ "defaults": "^1.0.3" } }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -16843,6 +17050,37 @@ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "license": "ISC" }, + "node_modules/ws": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", + "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, "node_modules/xmlbuilder": { "version": "15.1.1", "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz", @@ -16853,6 +17091,13 @@ "node": ">=8.0" } }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", diff --git a/package.json b/package.json index fd62d4a1..f9d944a6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "metapi", - "version": "1.2.1", + "version": "1.2.3", "description": "Meta-layer management and unified proxy for AI API aggregation platforms", "main": "dist/desktop/main.js", "keywords": [ @@ -21,17 +21,28 @@ "url": "git+https://github.com/cita-777/metapi.git" }, "license": "MIT", + "engines": { + "node": ">=22.15.0" + }, "type": "module", "scripts": { "dev:server": "tsx scripts/dev/run-server.ts --watch", "dev": "concurrently \"npm run dev:server\" \"vite\"", "dev:desktop": "concurrently \"npm run dev:server\" \"vite\" \"tsc -p tsconfig.desktop.json --watch --preserveWatchOutput\" \"wait-on tcp:127.0.0.1:4000 http://127.0.0.1:5173 file:dist/desktop/main.js && cross-env METAPI_DESKTOP_DEV_SERVER_URL=http://127.0.0.1:5173 METAPI_DESKTOP_EXTERNAL_SERVER_URL=http://127.0.0.1:4000 electron dist/desktop/main.js\"", - "build:web": "vite build", - "build:server": "tsc -p tsconfig.server.json", + "desktop:icons": "node scripts/desktop/generate-icons.mjs", + "build:web": "npm run desktop:icons && vite build", + "build:server": "tsc -p tsconfig.server.json && tsx scripts/dev/copy-runtime-db-generated.ts", "build:desktop": "tsc -p tsconfig.desktop.json", "build": "npm run build:web && npm run build:server && npm run build:desktop", + "typecheck:web": "tsc -p tsconfig.web.json", + "typecheck:web:test": "tsc -p tsconfig.web.test.json", + "typecheck:server": "tsc --noEmit -p tsconfig.server.json", + "typecheck:desktop": "tsc --noEmit -p tsconfig.desktop.json", + "typecheck": "npm run typecheck:web && npm run typecheck:web:test && npm run typecheck:server && npm run typecheck:desktop", "dist:desktop": "npm run build && electron-builder --config electron-builder.yml --publish never", - "package:desktop": "electron-builder --config electron-builder.yml --publish never", + "dist:desktop:mac:intel": "npm run build && electron-builder --config electron-builder.yml --publish never --mac --x64", + "package:desktop": "npm run desktop:icons && electron-builder --config electron-builder.yml --publish never", + "package:desktop:mac:intel": "npm run desktop:icons && electron-builder --config electron-builder.yml --publish never --mac --x64", "docs:dev": "vitepress dev docs --host 0.0.0.0 --port 4173", "docs:build": "vitepress build docs", "docs:preview": "vitepress preview docs --host 0.0.0.0 --port 4173", @@ -39,6 +50,13 @@ "start:desktop": "electron dist/desktop/main.js", "db:generate": "drizzle-kit generate", "db:migrate": "tsx src/server/db/migrate.ts", + "schema:contract": "tsx scripts/dev/generate-schema-contract.ts", + "schema:generate": "npm run db:generate && npm run schema:contract", + "repo:drift-check": "tsx scripts/dev/repo-drift-check.ts", + "test:schema:unit": "vitest run --root . src/server/db/schemaContract.test.ts src/server/db/schemaArtifactGenerator.test.ts src/server/db/schemaIntrospection.test.ts src/server/db/schemaParity.test.ts", + "test:schema:parity": "vitest run --root . src/server/db/schemaParity.live.test.ts", + "test:schema:upgrade": "vitest run --root . src/server/db/schemaUpgrade.live.test.ts", + "test:schema:runtime": "vitest run --root . src/server/db/runtimeSchemaBootstrap.live.test.ts", "smoke:db": "tsx scripts/dev/db-smoke.ts", "smoke:db:sqlite": "tsx scripts/dev/db-smoke.ts --db-type sqlite", "smoke:db:mysql": "tsx scripts/dev/db-smoke.ts --db-type mysql", @@ -52,24 +70,28 @@ "@dnd-kit/utilities": "^3.2.2", "@fastify/cors": "^11.2.0", "@fastify/static": "^9.0.0", - "@visactor/react-vchart": "^2.0.16", - "better-sqlite3": "^11.3.0", - "dotenv": "^16.4.5", - "drizzle-orm": "^0.36.4", + "@visactor/react-vchart": "^2.0.19", + "better-sqlite3": "^12.8.0", + "dotenv": "^17.3.1", + "drizzle-orm": "^0.45.1", "electron-log": "^5.2.4", "electron-updater": "^6.6.2", - "fastify": "^5.7.4", + "fastify": "^5.8.2", "get-port": "^7.1.0", + "marked": "^17.0.4", "minimatch": "^10.2.4", "minimist": "^1.2.8", - "mysql2": "^3.15.3", - "node-cron": "^3.0.3", - "nodemailer": "^8.0.1", + "mysql2": "^3.20.0", + "node-cron": "^4.2.1", + "nodemailer": "^8.0.3", "pg": "^8.16.3", - "undici": "^6.20.1" + "socks": "^2.8.7", + "undici": "^6.24.1", + "ws": "^8.19.0" }, "devDependencies": { - "@tailwindcss/vite": "^4.0.0", + "@electron/notarize": "^3.1.0", + "@tailwindcss/vite": "^4.2.2", "@types/better-sqlite3": "^7.6.11", "@types/node": "^22.10.1", "@types/node-cron": "^3.0.11", @@ -77,17 +99,18 @@ "@types/pg": "^8.15.6", "@types/react": "^18.3.12", "@types/react-dom": "^18.3.1", + "@types/react-test-renderer": "^19.1.0", "@vitejs/plugin-react": "^4.3.4", - "@electron/notarize": "^3.1.0", "concurrently": "^9.1.0", "cross-env": "^7.0.3", - "drizzle-kit": "^0.28.1", - "electron": "^35.0.1", + "drizzle-kit": "^0.31.10", + "electron": "^41.0.3", "electron-builder": "^26.0.12", - "mermaid": "^11.12.3", + "jsdom": "^29.0.1", + "mermaid": "^11.13.0", "react": "^18.3.1", "react-dom": "^18.3.1", - "react-router-dom": "^6.28.0", + "react-router-dom": "^7.13.1", "react-test-renderer": "^18.3.1", "sharp": "^0.34.5", "tailwindcss": "^4.0.0", @@ -97,7 +120,7 @@ "vitepress": "^1.6.4", "vitepress-plugin-mermaid": "^2.0.17", "vitest": "^2.1.8", - "wait-on": "^8.0.3" + "wait-on": "^9.0.4" }, "overrides": { "minimist": "^1.2.8" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 00000000..1738722c --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,9706 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@dnd-kit/core': + specifier: ^6.3.1 + version: 6.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@dnd-kit/sortable': + specifier: ^10.0.0 + version: 10.0.0(@dnd-kit/core@6.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1) + '@dnd-kit/utilities': + specifier: ^3.2.2 + version: 3.2.2(react@18.3.1) + '@fastify/cors': + specifier: ^11.2.0 + version: 11.2.0 + '@fastify/static': + specifier: ^9.0.0 + version: 9.0.0 + '@visactor/react-vchart': + specifier: ^2.0.19 + version: 2.0.19(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + better-sqlite3: + specifier: ^12.8.0 + version: 12.8.0 + dotenv: + specifier: ^17.3.1 + version: 17.3.1 + drizzle-orm: + specifier: ^0.45.1 + version: 0.45.1(@types/better-sqlite3@7.6.13)(@types/pg@8.18.0)(better-sqlite3@12.8.0)(mysql2@3.20.0(@types/node@22.19.15))(pg@8.20.0) + electron-log: + specifier: ^5.2.4 + version: 5.4.3 + electron-updater: + specifier: ^6.6.2 + version: 6.8.3 + fastify: + specifier: ^5.8.2 + version: 5.8.2 + get-port: + specifier: ^7.1.0 + version: 7.1.0 + marked: + specifier: ^17.0.4 + version: 17.0.4 + minimatch: + specifier: ^10.2.4 + version: 10.2.4 + minimist: + specifier: ^1.2.8 + version: 1.2.8 + mysql2: + specifier: ^3.20.0 + version: 3.20.0(@types/node@22.19.15) + node-cron: + specifier: ^4.2.1 + version: 4.2.1 + nodemailer: + specifier: ^8.0.3 + version: 8.0.3 + pg: + specifier: ^8.16.3 + version: 8.20.0 + socks: + specifier: ^2.8.7 + version: 2.8.7 + undici: + specifier: ^6.24.1 + version: 6.24.1 + ws: + specifier: ^8.19.0 + version: 8.19.0 + devDependencies: + '@electron/notarize': + specifier: ^3.1.0 + version: 3.1.1 + '@tailwindcss/vite': + specifier: ^4.2.2 + version: 4.2.2(vite@6.4.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)) + '@types/better-sqlite3': + specifier: ^7.6.11 + version: 7.6.13 + '@types/node': + specifier: ^22.10.1 + version: 22.19.15 + '@types/node-cron': + specifier: ^3.0.11 + version: 3.0.11 + '@types/nodemailer': + specifier: ^7.0.11 + version: 7.0.11 + '@types/pg': + specifier: ^8.15.6 + version: 8.18.0 + '@types/react': + specifier: ^18.3.12 + version: 18.3.28 + '@types/react-dom': + specifier: ^18.3.1 + version: 18.3.7(@types/react@18.3.28) + '@vitejs/plugin-react': + specifier: ^4.3.4 + version: 4.7.0(vite@6.4.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)) + concurrently: + specifier: ^9.1.0 + version: 9.2.1 + cross-env: + specifier: ^7.0.3 + version: 7.0.3 + drizzle-kit: + specifier: ^0.31.10 + version: 0.31.10 + electron: + specifier: ^41.0.3 + version: 41.0.3 + electron-builder: + specifier: ^26.0.12 + version: 26.8.1(electron-builder-squirrel-windows@26.8.1) + jsdom: + specifier: ^29.0.1 + version: 29.0.1 + mermaid: + specifier: ^11.13.0 + version: 11.13.0 + react: + specifier: ^18.3.1 + version: 18.3.1 + react-dom: + specifier: ^18.3.1 + version: 18.3.1(react@18.3.1) + react-router-dom: + specifier: ^7.13.1 + version: 7.13.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react-test-renderer: + specifier: ^18.3.1 + version: 18.3.1(react@18.3.1) + sharp: + specifier: ^0.34.5 + version: 0.34.5 + tailwindcss: + specifier: ^4.0.0 + version: 4.2.1 + tsx: + specifier: ^4.19.2 + version: 4.21.0 + typescript: + specifier: ^5.7.2 + version: 5.9.3 + vite: + specifier: ^6.0.3 + version: 6.4.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0) + vitepress: + specifier: ^1.6.4 + version: 1.6.4(@algolia/client-search@5.49.1)(@types/node@22.19.15)(@types/react@18.3.28)(axios@1.13.6)(lightningcss@1.32.0)(postcss@8.5.8)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(search-insights@2.17.3)(typescript@5.9.3) + vitepress-plugin-mermaid: + specifier: ^2.0.17 + version: 2.0.17(mermaid@11.13.0)(vitepress@1.6.4(@algolia/client-search@5.49.1)(@types/node@22.19.15)(@types/react@18.3.28)(axios@1.13.6)(lightningcss@1.32.0)(postcss@8.5.8)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(search-insights@2.17.3)(typescript@5.9.3)) + vitest: + specifier: ^2.1.8 + version: 2.1.9(@types/node@22.19.15)(jsdom@29.0.1)(lightningcss@1.32.0) + wait-on: + specifier: ^9.0.4 + version: 9.0.4 + +packages: + + 7zip-bin@5.2.0: + resolution: {integrity: sha512-ukTPVhqG4jNzMro2qA9HSCSSVJN3aN7tlb+hfqYCt3ER0yWroeA2VR38MNrOHLQ/cVj+DaIMad0kFCtWWowh/A==} + + '@algolia/abtesting@1.15.1': + resolution: {integrity: sha512-2yuIC48rUuHGhU1U5qJ9kJHaxYpJ0jpDHJVI5ekOxSMYXlH4+HP+pA31G820lsAznfmu2nzDV7n5RO44zIY1zw==} + engines: {node: '>= 14.0.0'} + + '@algolia/autocomplete-core@1.17.7': + resolution: {integrity: sha512-BjiPOW6ks90UKl7TwMv7oNQMnzU+t/wk9mgIDi6b1tXpUek7MW0lbNOUHpvam9pe3lVCf4xPFT+lK7s+e+fs7Q==} + + '@algolia/autocomplete-plugin-algolia-insights@1.17.7': + resolution: {integrity: sha512-Jca5Ude6yUOuyzjnz57og7Et3aXjbwCSDf/8onLHSQgw1qW3ALl9mrMWaXb5FmPVkV3EtkD2F/+NkT6VHyPu9A==} + peerDependencies: + search-insights: '>= 1 < 3' + + '@algolia/autocomplete-preset-algolia@1.17.7': + resolution: {integrity: sha512-ggOQ950+nwbWROq2MOCIL71RE0DdQZsceqrg32UqnhDz8FlO9rL8ONHNsI2R1MH0tkgVIDKI/D0sMiUchsFdWA==} + peerDependencies: + '@algolia/client-search': '>= 4.9.1 < 6' + algoliasearch: '>= 4.9.1 < 6' + + '@algolia/autocomplete-shared@1.17.7': + resolution: {integrity: sha512-o/1Vurr42U/qskRSuhBH+VKxMvkkUVTLU6WZQr+L5lGZZLYWyhdzWjW0iGXY7EkwRTjBqvN2EsR81yCTGV/kmg==} + peerDependencies: + '@algolia/client-search': '>= 4.9.1 < 6' + algoliasearch: '>= 4.9.1 < 6' + + '@algolia/client-abtesting@5.49.1': + resolution: {integrity: sha512-h6M7HzPin+45/l09q0r2dYmocSSt2MMGOOk5c4O5K/bBBlEwf1BKfN6z+iX4b8WXcQQhf7rgQwC52kBZJt/ZZw==} + engines: {node: '>= 14.0.0'} + + '@algolia/client-analytics@5.49.1': + resolution: {integrity: sha512-048T9/Z8OeLmTk8h76QUqaNFp7Rq2VgS2Zm6Y2tNMYGQ1uNuzePY/udB5l5krlXll7ZGflyCjFvRiOtlPZpE9g==} + engines: {node: '>= 14.0.0'} + + '@algolia/client-common@5.49.1': + resolution: {integrity: sha512-vp5/a9ikqvf3mn9QvHN8PRekn8hW34aV9eX+O0J5mKPZXeA6Pd5OQEh2ZWf7gJY6yyfTlLp5LMFzQUAU+Fpqpg==} + engines: {node: '>= 14.0.0'} + + '@algolia/client-insights@5.49.1': + resolution: {integrity: sha512-B6N7PgkvYrul3bntTz/l6uXnhQ2bvP+M7NqTcayh681tSqPaA5cJCUBp/vrP7vpPRpej4Eeyx2qz5p0tE/2N2g==} + engines: {node: '>= 14.0.0'} + + '@algolia/client-personalization@5.49.1': + resolution: {integrity: sha512-v+4DN+lkYfBd01Hbnb9ZrCHe7l+mvihyx218INRX/kaCXROIWUDIT1cs3urQxfE7kXBFnLsqYeOflQALv/gA5w==} + engines: {node: '>= 14.0.0'} + + '@algolia/client-query-suggestions@5.49.1': + resolution: {integrity: sha512-Un11cab6ZCv0W+Jiak8UktGIqoa4+gSNgEZNfG8m8eTsXGqwIEr370H3Rqwj87zeNSlFpH2BslMXJ/cLNS1qtg==} + engines: {node: '>= 14.0.0'} + + '@algolia/client-search@5.49.1': + resolution: {integrity: sha512-Nt9hri7nbOo0RipAsGjIssHkpLMHHN/P7QqENywAq5TLsoYDzUyJGny8FEiD/9KJUxtGH8blGpMedilI6kK3rA==} + engines: {node: '>= 14.0.0'} + + '@algolia/ingestion@1.49.1': + resolution: {integrity: sha512-b5hUXwDqje0Y4CpU6VL481DXgPgxpTD5sYMnfQTHKgUispGnaCLCm2/T9WbJo1YNUbX3iHtYDArp804eD6CmRQ==} + engines: {node: '>= 14.0.0'} + + '@algolia/monitoring@1.49.1': + resolution: {integrity: sha512-bvrXwZ0WsL3rN6Q4m4QqxsXFCo6WAew7sAdrpMQMK4Efn4/W920r9ptOuckejOSSvyLr9pAWgC5rsHhR2FYuYw==} + engines: {node: '>= 14.0.0'} + + '@algolia/recommend@5.49.1': + resolution: {integrity: sha512-h2yz3AGeGkQwNgbLmoe3bxYs8fac4An1CprKTypYyTU/k3Q+9FbIvJ8aS1DoBKaTjSRZVoyQS7SZQio6GaHbZw==} + engines: {node: '>= 14.0.0'} + + '@algolia/requester-browser-xhr@5.49.1': + resolution: {integrity: sha512-2UPyRuUR/qpqSqH8mxFV5uBZWEpxhGPHLlx9Xf6OVxr79XO2ctzZQAhsmTZ6X22x+N8MBWpB9UEky7YU2HGFgA==} + engines: {node: '>= 14.0.0'} + + '@algolia/requester-fetch@5.49.1': + resolution: {integrity: sha512-N+xlE4lN+wpuT+4vhNEwPVlrfN+DWAZmSX9SYhbz986Oq8AMsqdntOqUyiOXVxYsQtfLwmiej24vbvJGYv1Qtw==} + engines: {node: '>= 14.0.0'} + + '@algolia/requester-node-http@5.49.1': + resolution: {integrity: sha512-zA5bkUOB5PPtTr182DJmajCiizHp0rCJQ0Chf96zNFvkdESKYlDeYA3tQ7r2oyHbu/8DiohAQ5PZ85edctzbXA==} + engines: {node: '>= 14.0.0'} + + '@antfu/install-pkg@1.1.0': + resolution: {integrity: sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==} + + '@asamuzakjp/css-color@5.0.1': + resolution: {integrity: sha512-2SZFvqMyvboVV1d15lMf7XiI3m7SDqXUuKaTymJYLN6dSGadqp+fVojqJlVoMlbZnlTmu3S0TLwLTJpvBMO1Aw==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + '@asamuzakjp/dom-selector@7.0.3': + resolution: {integrity: sha512-Q6mU0Z6bfj6YvnX2k9n0JxiIwrCFN59x/nWmYQnAqP000ruX/yV+5bp/GRcF5T8ncvfwJQ7fgfP74DlpKExILA==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + '@asamuzakjp/nwsapi@2.3.9': + resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==} + + '@babel/code-frame@7.29.0': + resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} + engines: {node: '>=6.9.0'} + + '@babel/compat-data@7.29.0': + resolution: {integrity: sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==} + engines: {node: '>=6.9.0'} + + '@babel/core@7.29.0': + resolution: {integrity: sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==} + engines: {node: '>=6.9.0'} + + '@babel/generator@7.29.1': + resolution: {integrity: sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-compilation-targets@7.28.6': + resolution: {integrity: sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-globals@7.28.0': + resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.28.6': + resolution: {integrity: sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-transforms@7.28.6': + resolution: {integrity: sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-plugin-utils@7.28.6': + resolution: {integrity: sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==} + engines: {node: '>=6.9.0'} + + '@babel/helper-string-parser@7.27.1': + resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.28.5': + resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-option@7.27.1': + resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==} + engines: {node: '>=6.9.0'} + + '@babel/helpers@7.28.6': + resolution: {integrity: sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.0': + resolution: {integrity: sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/plugin-transform-react-jsx-self@7.27.1': + resolution: {integrity: sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-react-jsx-source@7.27.1': + resolution: {integrity: sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/template@7.28.6': + resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==} + engines: {node: '>=6.9.0'} + + '@babel/traverse@7.29.0': + resolution: {integrity: sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.29.0': + resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} + engines: {node: '>=6.9.0'} + + '@braintree/sanitize-url@6.0.4': + resolution: {integrity: sha512-s3jaWicZd0pkP0jf5ysyHUI/RE7MHos6qlToFcGWXVp+ykHOy77OUMrfbgJ9it2C5bow7OIQwYYaHjk9XlBQ2A==} + + '@braintree/sanitize-url@7.1.2': + resolution: {integrity: sha512-jigsZK+sMF/cuiB7sERuo9V7N9jx+dhmHHnQyDSVdpZwVutaBu7WvNYqMDLSgFgfB30n452TP3vjDAvFC973mA==} + + '@bramus/specificity@2.4.2': + resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==} + hasBin: true + + '@chevrotain/cst-dts-gen@11.1.2': + resolution: {integrity: sha512-XTsjvDVB5nDZBQB8o0o/0ozNelQtn2KrUVteIHSlPd2VAV2utEb6JzyCJaJ8tGxACR4RiBNWy5uYUHX2eji88Q==} + + '@chevrotain/gast@11.1.2': + resolution: {integrity: sha512-Z9zfXR5jNZb1Hlsd/p+4XWeUFugrHirq36bKzPWDSIacV+GPSVXdk+ahVWZTwjhNwofAWg/sZg58fyucKSQx5g==} + + '@chevrotain/regexp-to-ast@11.1.2': + resolution: {integrity: sha512-nMU3Uj8naWer7xpZTYJdxbAs6RIv/dxYzkYU8GSwgUtcAAlzjcPfX1w+RKRcYG8POlzMeayOQ/znfwxEGo5ulw==} + + '@chevrotain/types@11.1.2': + resolution: {integrity: sha512-U+HFai5+zmJCkK86QsaJtoITlboZHBqrVketcO2ROv865xfCMSFpELQoz1GkX5GzME8pTa+3kbKrZHQtI0gdbw==} + + '@chevrotain/utils@11.1.2': + resolution: {integrity: sha512-4mudFAQ6H+MqBTfqLmU7G1ZwRzCLfJEooL/fsF6rCX5eePMbGhoy5n4g+G4vlh2muDcsCTJtL+uKbOzWxs5LHA==} + + '@csstools/color-helpers@6.0.2': + resolution: {integrity: sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==} + engines: {node: '>=20.19.0'} + + '@csstools/css-calc@3.1.1': + resolution: {integrity: sha512-HJ26Z/vmsZQqs/o3a6bgKslXGFAungXGbinULZO3eMsOyNJHeBBZfup5FiZInOghgoM4Hwnmw+OgbJCNg1wwUQ==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-color-parser@4.0.2': + resolution: {integrity: sha512-0GEfbBLmTFf0dJlpsNU7zwxRIH0/BGEMuXLTCvFYxuL1tNhqzTbtnFICyJLTNK4a+RechKP75e7w42ClXSnJQw==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-parser-algorithms@4.0.0': + resolution: {integrity: sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.1': + resolution: {integrity: sha512-BvqN0AMWNAnLk9G8jnUT77D+mUbY/H2b3uDTvg2isJkHaOufUE2R3AOwxWo7VBQKT1lOdwdvorddo2B/lk64+w==} + peerDependencies: + css-tree: ^3.2.1 + peerDependenciesMeta: + css-tree: + optional: true + + '@csstools/css-tokenizer@4.0.0': + resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==} + engines: {node: '>=20.19.0'} + + '@develar/schema-utils@2.6.5': + resolution: {integrity: sha512-0cp4PsWQ/9avqTVMCtZ+GirikIA36ikvjtHweU4/j8yLtgObI0+JUPhYFScgwlteveGB1rt3Cm8UhN04XayDig==} + engines: {node: '>= 8.9.0'} + + '@dnd-kit/accessibility@3.1.1': + resolution: {integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==} + peerDependencies: + react: '>=16.8.0' + + '@dnd-kit/core@6.3.1': + resolution: {integrity: sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + + '@dnd-kit/sortable@10.0.0': + resolution: {integrity: sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==} + peerDependencies: + '@dnd-kit/core': ^6.3.0 + react: '>=16.8.0' + + '@dnd-kit/utilities@3.2.2': + resolution: {integrity: sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==} + peerDependencies: + react: '>=16.8.0' + + '@docsearch/css@3.8.2': + resolution: {integrity: sha512-y05ayQFyUmCXze79+56v/4HpycYF3uFqB78pLPrSV5ZKAlDuIAAJNhaRi8tTdRNXh05yxX/TyNnzD6LwSM89vQ==} + + '@docsearch/js@3.8.2': + resolution: {integrity: sha512-Q5wY66qHn0SwA7Taa0aDbHiJvaFJLOJyHmooQ7y8hlwwQLQ/5WwCcoX0g7ii04Qi2DJlHsd0XXzJ8Ypw9+9YmQ==} + + '@docsearch/react@3.8.2': + resolution: {integrity: sha512-xCRrJQlTt8N9GU0DG4ptwHRkfnSnD/YpdeaXe02iKfqs97TkZJv60yE+1eq/tjPcVnTW8dP5qLP7itifFVV5eg==} + peerDependencies: + '@types/react': '>= 16.8.0 < 19.0.0' + react: '>= 16.8.0 < 19.0.0' + react-dom: '>= 16.8.0 < 19.0.0' + search-insights: '>= 1 < 3' + peerDependenciesMeta: + '@types/react': + optional: true + react: + optional: true + react-dom: + optional: true + search-insights: + optional: true + + '@drizzle-team/brocli@0.10.2': + resolution: {integrity: sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==} + + '@electron/asar@3.4.1': + resolution: {integrity: sha512-i4/rNPRS84t0vSRa2HorerGRXWyF4vThfHesw0dmcWHp+cspK743UanA0suA5Q5y8kzY2y6YKrvbIUn69BCAiA==} + engines: {node: '>=10.12.0'} + hasBin: true + + '@electron/fuses@1.8.0': + resolution: {integrity: sha512-zx0EIq78WlY/lBb1uXlziZmDZI4ubcCXIMJ4uGjXzZW0nS19TjSPeXPAjzzTmKQlJUZm0SbmZhPKP7tuQ1SsEw==} + hasBin: true + + '@electron/get@2.0.3': + resolution: {integrity: sha512-Qkzpg2s9GnVV2I2BjRksUi43U5e6+zaQMcjoJy0C+C5oxaKl+fmckGDQFtRpZpZV0NQekuZZ+tGz7EA9TVnQtQ==} + engines: {node: '>=12'} + + '@electron/get@3.1.0': + resolution: {integrity: sha512-F+nKc0xW+kVbBRhFzaMgPy3KwmuNTYX1fx6+FxxoSnNgwYX6LD7AKBTWkU0MQ6IBoe7dz069CNkR673sPAgkCQ==} + engines: {node: '>=14'} + + '@electron/notarize@2.5.0': + resolution: {integrity: sha512-jNT8nwH1f9X5GEITXaQ8IF/KdskvIkOFfB2CvwumsveVidzpSc+mvhhTMdAGSYF3O+Nq49lJ7y+ssODRXu06+A==} + engines: {node: '>= 10.0.0'} + + '@electron/notarize@3.1.1': + resolution: {integrity: sha512-uQQSlOiJnqRkTL1wlEBAxe90nVN/Fc/hEmk0bqpKk8nKjV1if/tXLHKUPePtv9Xsx90PtZU8aidx5lAiOpjkQQ==} + engines: {node: '>= 22.12.0'} + + '@electron/osx-sign@1.3.3': + resolution: {integrity: sha512-KZ8mhXvWv2rIEgMbWZ4y33bDHyUKMXnx4M0sTyPNK/vcB81ImdeY9Ggdqy0SWbMDgmbqyQ+phgejh6V3R2QuSg==} + engines: {node: '>=12.0.0'} + hasBin: true + + '@electron/rebuild@4.0.3': + resolution: {integrity: sha512-u9vpTHRMkOYCs/1FLiSVAFZ7FbjsXK+bQuzviJZa+lG7BHZl1nz52/IcGvwa3sk80/fc3llutBkbCq10Vh8WQA==} + engines: {node: '>=22.12.0'} + hasBin: true + + '@electron/universal@2.0.3': + resolution: {integrity: sha512-Wn9sPYIVFRFl5HmwMJkARCCf7rqK/EurkfQ/rJZ14mHP3iYTjZSIOSVonEAnhWeAXwtw7zOekGRlc6yTtZ0t+g==} + engines: {node: '>=16.4'} + + '@electron/windows-sign@1.2.2': + resolution: {integrity: sha512-dfZeox66AvdPtb2lD8OsIIQh12Tp0GNCRUDfBHIKGpbmopZto2/A8nSpYYLoedPIHpqkeblZ/k8OV0Gy7PYuyQ==} + engines: {node: '>=14.14'} + hasBin: true + + '@emnapi/runtime@1.8.1': + resolution: {integrity: sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==} + + '@esbuild-kit/core-utils@3.3.2': + resolution: {integrity: sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ==} + deprecated: 'Merged into tsx: https://tsx.is' + + '@esbuild-kit/esm-loader@2.6.5': + resolution: {integrity: sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA==} + deprecated: 'Merged into tsx: https://tsx.is' + + '@esbuild/aix-ppc64@0.21.5': + resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [aix] + + '@esbuild/aix-ppc64@0.25.12': + resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/aix-ppc64@0.27.3': + resolution: {integrity: sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.18.20': + resolution: {integrity: sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm64@0.21.5': + resolution: {integrity: sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm64@0.25.12': + resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm64@0.27.3': + resolution: {integrity: sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.18.20': + resolution: {integrity: sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw==} + engines: {node: '>=12'} + cpu: [arm] + os: [android] + + '@esbuild/android-arm@0.21.5': + resolution: {integrity: sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==} + engines: {node: '>=12'} + cpu: [arm] + os: [android] + + '@esbuild/android-arm@0.25.12': + resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-arm@0.27.3': + resolution: {integrity: sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.18.20': + resolution: {integrity: sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + + '@esbuild/android-x64@0.21.5': + resolution: {integrity: sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + + '@esbuild/android-x64@0.25.12': + resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/android-x64@0.27.3': + resolution: {integrity: sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.18.20': + resolution: {integrity: sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-arm64@0.21.5': + resolution: {integrity: sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-arm64@0.25.12': + resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-arm64@0.27.3': + resolution: {integrity: sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.18.20': + resolution: {integrity: sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + + '@esbuild/darwin-x64@0.21.5': + resolution: {integrity: sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + + '@esbuild/darwin-x64@0.25.12': + resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/darwin-x64@0.27.3': + resolution: {integrity: sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.18.20': + resolution: {integrity: sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-arm64@0.21.5': + resolution: {integrity: sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-arm64@0.25.12': + resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-arm64@0.27.3': + resolution: {integrity: sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.18.20': + resolution: {integrity: sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.21.5': + resolution: {integrity: sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.25.12': + resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.27.3': + resolution: {integrity: sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.18.20': + resolution: {integrity: sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm64@0.21.5': + resolution: {integrity: sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm64@0.25.12': + resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm64@0.27.3': + resolution: {integrity: sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.18.20': + resolution: {integrity: sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-arm@0.21.5': + resolution: {integrity: sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-arm@0.25.12': + resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-arm@0.27.3': + resolution: {integrity: sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.18.20': + resolution: {integrity: sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA==} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-ia32@0.21.5': + resolution: {integrity: sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-ia32@0.25.12': + resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-ia32@0.27.3': + resolution: {integrity: sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.18.20': + resolution: {integrity: sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg==} + engines: {node: '>=12'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-loong64@0.21.5': + resolution: {integrity: sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==} + engines: {node: '>=12'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-loong64@0.25.12': + resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-loong64@0.27.3': + resolution: {integrity: sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.18.20': + resolution: {integrity: sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-mips64el@0.21.5': + resolution: {integrity: sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-mips64el@0.25.12': + resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-mips64el@0.27.3': + resolution: {integrity: sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.18.20': + resolution: {integrity: sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-ppc64@0.21.5': + resolution: {integrity: sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-ppc64@0.25.12': + resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-ppc64@0.27.3': + resolution: {integrity: sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.18.20': + resolution: {integrity: sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-riscv64@0.21.5': + resolution: {integrity: sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-riscv64@0.25.12': + resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-riscv64@0.27.3': + resolution: {integrity: sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.18.20': + resolution: {integrity: sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-s390x@0.21.5': + resolution: {integrity: sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-s390x@0.25.12': + resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-s390x@0.27.3': + resolution: {integrity: sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.18.20': + resolution: {integrity: sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + + '@esbuild/linux-x64@0.21.5': + resolution: {integrity: sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + + '@esbuild/linux-x64@0.25.12': + resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/linux-x64@0.27.3': + resolution: {integrity: sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.25.12': + resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-arm64@0.27.3': + resolution: {integrity: sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.18.20': + resolution: {integrity: sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.21.5': + resolution: {integrity: sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.25.12': + resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.27.3': + resolution: {integrity: sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.25.12': + resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-arm64@0.27.3': + resolution: {integrity: sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.18.20': + resolution: {integrity: sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.21.5': + resolution: {integrity: sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.25.12': + resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.27.3': + resolution: {integrity: sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.25.12': + resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/openharmony-arm64@0.27.3': + resolution: {integrity: sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.18.20': + resolution: {integrity: sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + + '@esbuild/sunos-x64@0.21.5': + resolution: {integrity: sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + + '@esbuild/sunos-x64@0.25.12': + resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/sunos-x64@0.27.3': + resolution: {integrity: sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.18.20': + resolution: {integrity: sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-arm64@0.21.5': + resolution: {integrity: sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-arm64@0.25.12': + resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-arm64@0.27.3': + resolution: {integrity: sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.18.20': + resolution: {integrity: sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-ia32@0.21.5': + resolution: {integrity: sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-ia32@0.25.12': + resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-ia32@0.27.3': + resolution: {integrity: sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.18.20': + resolution: {integrity: sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + + '@esbuild/win32-x64@0.21.5': + resolution: {integrity: sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + + '@esbuild/win32-x64@0.25.12': + resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@esbuild/win32-x64@0.27.3': + resolution: {integrity: sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@exodus/bytes@1.15.0': + resolution: {integrity: sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + peerDependencies: + '@noble/hashes': ^1.8.0 || ^2.0.0 + peerDependenciesMeta: + '@noble/hashes': + optional: true + + '@fastify/accept-negotiator@2.0.1': + resolution: {integrity: sha512-/c/TW2bO/v9JeEgoD/g1G5GxGeCF1Hafdf79WPmUlgYiBXummY0oX3VVq4yFkKKVBKDNlaDUYoab7g38RpPqCQ==} + + '@fastify/ajv-compiler@4.0.5': + resolution: {integrity: sha512-KoWKW+MhvfTRWL4qrhUwAAZoaChluo0m0vbiJlGMt2GXvL4LVPQEjt8kSpHI3IBq5Rez8fg+XeH3cneztq+C7A==} + + '@fastify/cors@11.2.0': + resolution: {integrity: sha512-LbLHBuSAdGdSFZYTLVA3+Ch2t+sA6nq3Ejc6XLAKiQ6ViS2qFnvicpj0htsx03FyYeLs04HfRNBsz/a8SvbcUw==} + + '@fastify/error@4.2.0': + resolution: {integrity: sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ==} + + '@fastify/fast-json-stringify-compiler@5.0.3': + resolution: {integrity: sha512-uik7yYHkLr6fxd8hJSZ8c+xF4WafPK+XzneQDPU+D10r5X19GW8lJcom2YijX2+qtFF1ENJlHXKFM9ouXNJYgQ==} + + '@fastify/forwarded@3.0.1': + resolution: {integrity: sha512-JqDochHFqXs3C3Ml3gOY58zM7OqO9ENqPo0UqAjAjH8L01fRZqwX9iLeX34//kiJubF7r2ZQHtBRU36vONbLlw==} + + '@fastify/merge-json-schemas@0.2.1': + resolution: {integrity: sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A==} + + '@fastify/proxy-addr@5.1.0': + resolution: {integrity: sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==} + + '@fastify/send@4.1.0': + resolution: {integrity: sha512-TMYeQLCBSy2TOFmV95hQWkiTYgC/SEx7vMdV+wnZVX4tt8VBLKzmH8vV9OzJehV0+XBfg+WxPMt5wp+JBUKsVw==} + + '@fastify/static@9.0.0': + resolution: {integrity: sha512-r64H8Woe/vfilg5RTy7lwWlE8ZZcTrc3kebYFMEUBrMqlydhQyoiExQXdYAy2REVpST/G35+stAM8WYp1WGmMA==} + + '@hapi/address@5.1.1': + resolution: {integrity: sha512-A+po2d/dVoY7cYajycYI43ZbYMXukuopIsqCjh5QzsBCipDtdofHntljDlpccMjIfTy6UOkg+5KPriwYch2bXA==} + engines: {node: '>=14.0.0'} + + '@hapi/formula@3.0.2': + resolution: {integrity: sha512-hY5YPNXzw1He7s0iqkRQi+uMGh383CGdyyIGYtB+W5N3KHPXoqychklvHhKCC9M3Xtv0OCs/IHw+r4dcHtBYWw==} + + '@hapi/hoek@11.0.7': + resolution: {integrity: sha512-HV5undWkKzcB4RZUusqOpcgxOaq6VOAH7zhhIr2g3G8NF/MlFO75SjOr2NfuSx0Mh40+1FqCkagKLJRykUWoFQ==} + + '@hapi/pinpoint@2.0.1': + resolution: {integrity: sha512-EKQmr16tM8s16vTT3cA5L0kZZcTMU5DUOZTuvpnY738m+jyP3JIUj+Mm1xc1rsLkGBQ/gVnfKYPwOmPg1tUR4Q==} + + '@hapi/tlds@1.1.6': + resolution: {integrity: sha512-xdi7A/4NZokvV0ewovme3aUO5kQhW9pQ2YD1hRqZGhhSi5rBv4usHYidVocXSi9eihYsznZxLtAiEYYUL6VBGw==} + engines: {node: '>=14.0.0'} + + '@hapi/topo@6.0.2': + resolution: {integrity: sha512-KR3rD5inZbGMrHmgPxsJ9dbi6zEK+C3ZwUwTa+eMwWLz7oijWUTWD2pMSNNYJAU6Qq+65NkxXjqHr/7LM2Xkqg==} + + '@iconify-json/simple-icons@1.2.72': + resolution: {integrity: sha512-wkcixntHvaCoqPqerGrNFcHQ3Yx1ux4ZkhscCDK0DEHpP62XCH+cxq1HTsRjbUiQl/M9K8bj03HF6Wgn5iE2rQ==} + + '@iconify/types@2.0.0': + resolution: {integrity: sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==} + + '@iconify/utils@3.1.0': + resolution: {integrity: sha512-Zlzem1ZXhI1iHeeERabLNzBHdOa4VhQbqAcOQaMKuTuyZCpwKbC2R4Dd0Zo3g9EAc+Y4fiarO8HIHRAth7+skw==} + + '@img/colour@1.1.0': + resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} + engines: {node: '>=18'} + + '@img/sharp-darwin-arm64@0.34.5': + resolution: {integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [darwin] + + '@img/sharp-darwin-x64@0.34.5': + resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-darwin-arm64@1.2.4': + resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==} + cpu: [arm64] + os: [darwin] + + '@img/sharp-libvips-darwin-x64@1.2.4': + resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-linux-arm64@1.2.4': + resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} + cpu: [arm64] + os: [linux] + + '@img/sharp-libvips-linux-arm@1.2.4': + resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} + cpu: [arm] + os: [linux] + + '@img/sharp-libvips-linux-ppc64@1.2.4': + resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} + cpu: [ppc64] + os: [linux] + + '@img/sharp-libvips-linux-riscv64@1.2.4': + resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} + cpu: [riscv64] + os: [linux] + + '@img/sharp-libvips-linux-s390x@1.2.4': + resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} + cpu: [s390x] + os: [linux] + + '@img/sharp-libvips-linux-x64@1.2.4': + resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} + cpu: [x64] + os: [linux] + + '@img/sharp-libvips-linuxmusl-arm64@1.2.4': + resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} + cpu: [arm64] + os: [linux] + + '@img/sharp-libvips-linuxmusl-x64@1.2.4': + resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} + cpu: [x64] + os: [linux] + + '@img/sharp-linux-arm64@0.34.5': + resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + + '@img/sharp-linux-arm@0.34.5': + resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm] + os: [linux] + + '@img/sharp-linux-ppc64@0.34.5': + resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [ppc64] + os: [linux] + + '@img/sharp-linux-riscv64@0.34.5': + resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [riscv64] + os: [linux] + + '@img/sharp-linux-s390x@0.34.5': + resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [s390x] + os: [linux] + + '@img/sharp-linux-x64@0.34.5': + resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + + '@img/sharp-linuxmusl-arm64@0.34.5': + resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + + '@img/sharp-linuxmusl-x64@0.34.5': + resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + + '@img/sharp-wasm32@0.34.5': + resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [wasm32] + + '@img/sharp-win32-arm64@0.34.5': + resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [win32] + + '@img/sharp-win32-ia32@0.34.5': + resolution: {integrity: sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [ia32] + os: [win32] + + '@img/sharp-win32-x64@0.34.5': + resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [win32] + + '@isaacs/cliui@8.0.2': + resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} + engines: {node: '>=12'} + + '@isaacs/fs-minipass@4.0.1': + resolution: {integrity: sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==} + engines: {node: '>=18.0.0'} + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@lukeed/ms@2.0.2': + resolution: {integrity: sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==} + engines: {node: '>=8'} + + '@malept/cross-spawn-promise@2.0.0': + resolution: {integrity: sha512-1DpKU0Z5ThltBwjNySMC14g0CkbyhCaz9FkhxqNsZI6uAPJXFS8cMXlBKo26FJ8ZuW6S9GCMcR9IO5k2X5/9Fg==} + engines: {node: '>= 12.13.0'} + + '@malept/flatpak-bundler@0.4.0': + resolution: {integrity: sha512-9QOtNffcOF/c1seMCDnjckb3R9WHcG34tky+FHpNKKCW0wc/scYLwMtO+ptyGUfMW0/b/n4qRiALlaFHc9Oj7Q==} + engines: {node: '>= 10.0.0'} + + '@mermaid-js/mermaid-mindmap@9.3.0': + resolution: {integrity: sha512-IhtYSVBBRYviH1Ehu8gk69pMDF8DSRqXBRDMWrEfHoaMruHeaP2DXA3PBnuwsMaCdPQhlUUcy/7DBLAEIXvCAw==} + + '@mermaid-js/parser@1.0.1': + resolution: {integrity: sha512-opmV19kN1JsK0T6HhhokHpcVkqKpF+x2pPDKKM2ThHtZAB5F4PROopk0amuVYK5qMrIA4erzpNm8gmPNJgMDxQ==} + + '@npmcli/agent@3.0.0': + resolution: {integrity: sha512-S79NdEgDQd/NGCay6TCoVzXSj74skRZIKJcpJjC5lOq34SZzyI6MqtiiWoiVWoVrTcGjNeC4ipbh1VIHlpfF5Q==} + engines: {node: ^18.17.0 || >=20.5.0} + + '@npmcli/fs@4.0.0': + resolution: {integrity: sha512-/xGlezI6xfGO9NwuJlnwz/K14qD1kCSAGtacBHnGzeAIuJGazcp45KP5NuyARXoKb7cwulAGWVsbeSxdG/cb0Q==} + engines: {node: ^18.17.0 || >=20.5.0} + + '@pinojs/redact@0.4.0': + resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} + + '@pkgjs/parseargs@0.11.0': + resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} + engines: {node: '>=14'} + + '@resvg/resvg-js-android-arm-eabi@2.4.1': + resolution: {integrity: sha512-AA6f7hS0FAPpvQMhBCf6f1oD1LdlqNXKCxAAPpKh6tR11kqV0YIB9zOlIYgITM14mq2YooLFl6XIbbvmY+jwUw==} + engines: {node: '>= 10'} + cpu: [arm] + os: [android] + + '@resvg/resvg-js-android-arm64@2.4.1': + resolution: {integrity: sha512-/QleoRdPfsEuH9jUjilYcDtKK/BkmWcK+1LXM8L2nsnf/CI8EnFyv7ZzCj4xAIvZGAy9dTYr/5NZBcTwxG2HQg==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [android] + + '@resvg/resvg-js-darwin-arm64@2.4.1': + resolution: {integrity: sha512-U1oMNhea+kAXgiEXgzo7EbFGCD1Edq5aSlQoe6LMly6UjHzgx2W3N5kEXCwU/CgN5FiQhZr7PlSJSlcr7mdhfg==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@resvg/resvg-js-darwin-x64@2.4.1': + resolution: {integrity: sha512-avyVh6DpebBfHHtTQTZYSr6NG1Ur6TEilk1+H0n7V+g4F7x7WPOo8zL00ZhQCeRQ5H4f8WXNWIEKL8fwqcOkYw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@resvg/resvg-js-linux-arm-gnueabihf@2.4.1': + resolution: {integrity: sha512-isY/mdKoBWH4VB5v621co+8l101jxxYjuTkwOLsbW+5RK9EbLciPlCB02M99ThAHzI2MYxIUjXNmNgOW8btXvw==} + engines: {node: '>= 10'} + cpu: [arm] + os: [linux] + + '@resvg/resvg-js-linux-arm64-gnu@2.4.1': + resolution: {integrity: sha512-uY5voSCrFI8TH95vIYBm5blpkOtltLxLRODyhKJhGfskOI7XkRw5/t1u0sWAGYD8rRSNX+CA+np86otKjubrNg==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@resvg/resvg-js-linux-arm64-musl@2.4.1': + resolution: {integrity: sha512-6mT0+JBCsermKMdi/O2mMk3m7SqOjwi9TKAwSngRZ/nQoL3Z0Z5zV+572ztgbWr0GODB422uD8e9R9zzz38dRQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@resvg/resvg-js-linux-x64-gnu@2.4.1': + resolution: {integrity: sha512-60KnrscLj6VGhkYOJEmmzPlqqfcw1keDh6U+vMcNDjPhV3B5vRSkpP/D/a8sfokyeh4VEacPSYkWGezvzS2/mg==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@resvg/resvg-js-linux-x64-musl@2.4.1': + resolution: {integrity: sha512-0AMyZSICC1D7ge115cOZQW8Pcad6PjWuZkBFF3FJuSxC6Dgok0MQnLTs2MfMdKBlAcwO9dXsf3bv9tJZj8pATA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@resvg/resvg-js-win32-arm64-msvc@2.4.1': + resolution: {integrity: sha512-76XDFOFSa3d0QotmcNyChh2xHwk+JTFiEQBVxMlHpHMeq7hNrQJ1IpE1zcHSQvrckvkdfLboKRrlGB86B10Qjw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@resvg/resvg-js-win32-ia32-msvc@2.4.1': + resolution: {integrity: sha512-odyVFGrEWZIzzJ89KdaFtiYWaIJh9hJRW/frcEcG3agJ464VXkN/2oEVF5ulD+5mpGlug9qJg7htzHcKxDN8sg==} + engines: {node: '>= 10'} + cpu: [ia32] + os: [win32] + + '@resvg/resvg-js-win32-x64-msvc@2.4.1': + resolution: {integrity: sha512-vY4kTLH2S3bP+puU5x7hlAxHv+ulFgcK6Zn3efKSr0M0KnZ9A3qeAjZteIpkowEFfUeMPNg2dvvoFRJA9zqxSw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@resvg/resvg-js@2.4.1': + resolution: {integrity: sha512-wTOf1zerZX8qYcMmLZw3czR4paI4hXqPjShNwJRh5DeHxvgffUS5KM7XwxtbIheUW6LVYT5fhT2AJiP6mU7U4A==} + engines: {node: '>= 10'} + + '@rolldown/pluginutils@1.0.0-beta.27': + resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==} + + '@rollup/rollup-android-arm-eabi@4.59.0': + resolution: {integrity: sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.59.0': + resolution: {integrity: sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.59.0': + resolution: {integrity: sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.59.0': + resolution: {integrity: sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.59.0': + resolution: {integrity: sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.59.0': + resolution: {integrity: sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.59.0': + resolution: {integrity: sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm-musleabihf@4.59.0': + resolution: {integrity: sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm64-gnu@4.59.0': + resolution: {integrity: sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-arm64-musl@4.59.0': + resolution: {integrity: sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-loong64-gnu@4.59.0': + resolution: {integrity: sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-loong64-musl@4.59.0': + resolution: {integrity: sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-ppc64-gnu@4.59.0': + resolution: {integrity: sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-ppc64-musl@4.59.0': + resolution: {integrity: sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-riscv64-gnu@4.59.0': + resolution: {integrity: sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-riscv64-musl@4.59.0': + resolution: {integrity: sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-s390x-gnu@4.59.0': + resolution: {integrity: sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==} + cpu: [s390x] + os: [linux] + + '@rollup/rollup-linux-x64-gnu@4.59.0': + resolution: {integrity: sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-linux-x64-musl@4.59.0': + resolution: {integrity: sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-openbsd-x64@4.59.0': + resolution: {integrity: sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.59.0': + resolution: {integrity: sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.59.0': + resolution: {integrity: sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.59.0': + resolution: {integrity: sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.59.0': + resolution: {integrity: sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.59.0': + resolution: {integrity: sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==} + cpu: [x64] + os: [win32] + + '@shikijs/core@2.5.0': + resolution: {integrity: sha512-uu/8RExTKtavlpH7XqnVYBrfBkUc20ngXiX9NSrBhOVZYv/7XQRKUyhtkeflY5QsxC0GbJThCerruZfsUaSldg==} + + '@shikijs/engine-javascript@2.5.0': + resolution: {integrity: sha512-VjnOpnQf8WuCEZtNUdjjwGUbtAVKuZkVQ/5cHy/tojVVRIRtlWMYVjyWhxOmIq05AlSOv72z7hRNRGVBgQOl0w==} + + '@shikijs/engine-oniguruma@2.5.0': + resolution: {integrity: sha512-pGd1wRATzbo/uatrCIILlAdFVKdxImWJGQ5rFiB5VZi2ve5xj3Ax9jny8QvkaV93btQEwR/rSz5ERFpC5mKNIw==} + + '@shikijs/langs@2.5.0': + resolution: {integrity: sha512-Qfrrt5OsNH5R+5tJ/3uYBBZv3SuGmnRPejV9IlIbFH3HTGLDlkqgHymAlzklVmKBjAaVmkPkyikAV/sQ1wSL+w==} + + '@shikijs/themes@2.5.0': + resolution: {integrity: sha512-wGrk+R8tJnO0VMzmUExHR+QdSaPUl/NKs+a4cQQRWyoc3YFbUzuLEi/KWK1hj+8BfHRKm2jNhhJck1dfstJpiw==} + + '@shikijs/transformers@2.5.0': + resolution: {integrity: sha512-SI494W5X60CaUwgi8u4q4m4s3YAFSxln3tzNjOSYqq54wlVgz0/NbbXEb3mdLbqMBztcmS7bVTaEd2w0qMmfeg==} + + '@shikijs/types@2.5.0': + resolution: {integrity: sha512-ygl5yhxki9ZLNuNpPitBWvcy9fsSKKaRuO4BAlMyagszQidxcpLAr0qiW/q43DtSIDxO6hEbtYLiFZNXO/hdGw==} + + '@shikijs/vscode-textmate@10.0.2': + resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==} + + '@sindresorhus/is@4.6.0': + resolution: {integrity: sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==} + engines: {node: '>=10'} + + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + + '@szmarczak/http-timer@4.0.6': + resolution: {integrity: sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==} + engines: {node: '>=10'} + + '@tailwindcss/node@4.2.2': + resolution: {integrity: sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA==} + + '@tailwindcss/oxide-android-arm64@4.2.2': + resolution: {integrity: sha512-dXGR1n+P3B6748jZO/SvHZq7qBOqqzQ+yFrXpoOWWALWndF9MoSKAT3Q0fYgAzYzGhxNYOoysRvYlpixRBBoDg==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [android] + + '@tailwindcss/oxide-darwin-arm64@4.2.2': + resolution: {integrity: sha512-iq9Qjr6knfMpZHj55/37ouZeykwbDqF21gPFtfnhCCKGDcPI/21FKC9XdMO/XyBM7qKORx6UIhGgg6jLl7BZlg==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [darwin] + + '@tailwindcss/oxide-darwin-x64@4.2.2': + resolution: {integrity: sha512-BlR+2c3nzc8f2G639LpL89YY4bdcIdUmiOOkv2GQv4/4M0vJlpXEa0JXNHhCHU7VWOKWT/CjqHdTP8aUuDJkuw==} + engines: {node: '>= 20'} + cpu: [x64] + os: [darwin] + + '@tailwindcss/oxide-freebsd-x64@4.2.2': + resolution: {integrity: sha512-YUqUgrGMSu2CDO82hzlQ5qSb5xmx3RUrke/QgnoEx7KvmRJHQuZHZmZTLSuuHwFf0DJPybFMXMYf+WJdxHy/nQ==} + engines: {node: '>= 20'} + cpu: [x64] + os: [freebsd] + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.2.2': + resolution: {integrity: sha512-FPdhvsW6g06T9BWT0qTwiVZYE2WIFo2dY5aCSpjG/S/u1tby+wXoslXS0kl3/KXnULlLr1E3NPRRw0g7t2kgaQ==} + engines: {node: '>= 20'} + cpu: [arm] + os: [linux] + + '@tailwindcss/oxide-linux-arm64-gnu@4.2.2': + resolution: {integrity: sha512-4og1V+ftEPXGttOO7eCmW7VICmzzJWgMx+QXAJRAhjrSjumCwWqMfkDrNu1LXEQzNAwz28NCUpucgQPrR4S2yw==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [linux] + + '@tailwindcss/oxide-linux-arm64-musl@4.2.2': + resolution: {integrity: sha512-oCfG/mS+/+XRlwNjnsNLVwnMWYH7tn/kYPsNPh+JSOMlnt93mYNCKHYzylRhI51X+TbR+ufNhhKKzm6QkqX8ag==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [linux] + + '@tailwindcss/oxide-linux-x64-gnu@4.2.2': + resolution: {integrity: sha512-rTAGAkDgqbXHNp/xW0iugLVmX62wOp2PoE39BTCGKjv3Iocf6AFbRP/wZT/kuCxC9QBh9Pu8XPkv/zCZB2mcMg==} + engines: {node: '>= 20'} + cpu: [x64] + os: [linux] + + '@tailwindcss/oxide-linux-x64-musl@4.2.2': + resolution: {integrity: sha512-XW3t3qwbIwiSyRCggeO2zxe3KWaEbM0/kW9e8+0XpBgyKU4ATYzcVSMKteZJ1iukJ3HgHBjbg9P5YPRCVUxlnQ==} + engines: {node: '>= 20'} + cpu: [x64] + os: [linux] + + '@tailwindcss/oxide-wasm32-wasi@4.2.2': + resolution: {integrity: sha512-eKSztKsmEsn1O5lJ4ZAfyn41NfG7vzCg496YiGtMDV86jz1q/irhms5O0VrY6ZwTUkFy/EKG3RfWgxSI3VbZ8Q==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + bundledDependencies: + - '@napi-rs/wasm-runtime' + - '@emnapi/core' + - '@emnapi/runtime' + - '@tybys/wasm-util' + - '@emnapi/wasi-threads' + - tslib + + '@tailwindcss/oxide-win32-arm64-msvc@4.2.2': + resolution: {integrity: sha512-qPmaQM4iKu5mxpsrWZMOZRgZv1tOZpUm+zdhhQP0VhJfyGGO3aUKdbh3gDZc/dPLQwW4eSqWGrrcWNBZWUWaXQ==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [win32] + + '@tailwindcss/oxide-win32-x64-msvc@4.2.2': + resolution: {integrity: sha512-1T/37VvI7WyH66b+vqHj/cLwnCxt7Qt3WFu5Q8hk65aOvlwAhs7rAp1VkulBJw/N4tMirXjVnylTR72uI0HGcA==} + engines: {node: '>= 20'} + cpu: [x64] + os: [win32] + + '@tailwindcss/oxide@4.2.2': + resolution: {integrity: sha512-qEUA07+E5kehxYp9BVMpq9E8vnJuBHfJEC0vPC5e7iL/hw7HR61aDKoVoKzrG+QKp56vhNZe4qwkRmMC0zDLvg==} + engines: {node: '>= 20'} + + '@tailwindcss/vite@4.2.2': + resolution: {integrity: sha512-mEiF5HO1QqCLXoNEfXVA1Tzo+cYsrqV7w9Juj2wdUFyW07JRenqMG225MvPwr3ZD9N1bFQj46X7r33iHxLUW0w==} + peerDependencies: + vite: ^5.2.0 || ^6 || ^7 || ^8 + + '@turf/boolean-clockwise@6.5.0': + resolution: {integrity: sha512-45+C7LC5RMbRWrxh3Z0Eihsc8db1VGBO5d9BLTOAwU4jR6SgsunTfRWR16X7JUwIDYlCVEmnjcXJNi/kIU3VIw==} + + '@turf/clone@6.5.0': + resolution: {integrity: sha512-mzVtTFj/QycXOn6ig+annKrM6ZlimreKYz6f/GSERytOpgzodbQyOgkfwru100O1KQhhjSudKK4DsQ0oyi9cTw==} + + '@turf/flatten@6.5.0': + resolution: {integrity: sha512-IBZVwoNLVNT6U/bcUUllubgElzpMsNoCw8tLqBw6dfYg9ObGmpEjf9BIYLr7a2Yn5ZR4l7YIj2T7kD5uJjZADQ==} + + '@turf/helpers@6.5.0': + resolution: {integrity: sha512-VbI1dV5bLFzohYYdgqwikdMVpe7pJ9X3E+dlr425wa2/sMJqYDhTO++ec38/pcPvPE6oD9WEEeU3Xu3gza+VPw==} + + '@turf/invariant@6.5.0': + resolution: {integrity: sha512-Wv8PRNCtPD31UVbdJE/KVAWKe7l6US+lJItRR/HOEW3eh+U/JwRCSUl/KZ7bmjM/C+zLNoreM2TU6OoLACs4eg==} + + '@turf/meta@3.14.0': + resolution: {integrity: sha512-OtXqLQuR9hlQ/HkAF/OdzRea7E0eZK1ay8y8CBXkoO2R6v34CsDrWYLMSo0ZzMsaQDpKo76NPP2GGo+PyG1cSg==} + + '@turf/meta@6.5.0': + resolution: {integrity: sha512-RrArvtsV0vdsCBegoBtOalgdSOfkBrTJ07VkpiCnq/491W67hnMWmDu7e6Ztw0C3WldRYTXkg3SumfdzZxLBHA==} + + '@turf/rewind@6.5.0': + resolution: {integrity: sha512-IoUAMcHWotBWYwSYuYypw/LlqZmO+wcBpn8ysrBNbazkFNkLf3btSDZMkKJO/bvOzl55imr/Xj4fi3DdsLsbzQ==} + + '@types/babel__core@7.20.5': + resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} + + '@types/babel__generator@7.27.0': + resolution: {integrity: sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==} + + '@types/babel__template@7.4.4': + resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==} + + '@types/babel__traverse@7.28.0': + resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + + '@types/better-sqlite3@7.6.13': + resolution: {integrity: sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==} + + '@types/cacheable-request@6.0.3': + resolution: {integrity: sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==} + + '@types/d3-array@3.2.2': + resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==} + + '@types/d3-axis@3.0.6': + resolution: {integrity: sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw==} + + '@types/d3-brush@3.0.6': + resolution: {integrity: sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A==} + + '@types/d3-chord@3.0.6': + resolution: {integrity: sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg==} + + '@types/d3-color@3.1.3': + resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==} + + '@types/d3-contour@3.0.6': + resolution: {integrity: sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg==} + + '@types/d3-delaunay@6.0.4': + resolution: {integrity: sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==} + + '@types/d3-dispatch@3.0.7': + resolution: {integrity: sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA==} + + '@types/d3-drag@3.0.7': + resolution: {integrity: sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==} + + '@types/d3-dsv@3.0.7': + resolution: {integrity: sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==} + + '@types/d3-ease@3.0.2': + resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==} + + '@types/d3-fetch@3.0.7': + resolution: {integrity: sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA==} + + '@types/d3-force@3.0.10': + resolution: {integrity: sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==} + + '@types/d3-format@3.0.4': + resolution: {integrity: sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==} + + '@types/d3-geo@3.1.0': + resolution: {integrity: sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==} + + '@types/d3-hierarchy@3.1.7': + resolution: {integrity: sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==} + + '@types/d3-interpolate@3.0.4': + resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==} + + '@types/d3-path@3.1.1': + resolution: {integrity: sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==} + + '@types/d3-polygon@3.0.2': + resolution: {integrity: sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA==} + + '@types/d3-quadtree@3.0.6': + resolution: {integrity: sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg==} + + '@types/d3-random@3.0.3': + resolution: {integrity: sha512-Imagg1vJ3y76Y2ea0871wpabqp613+8/r0mCLEBfdtqC7xMSfj9idOnmBYyMoULfHePJyxMAw3nWhJxzc+LFwQ==} + + '@types/d3-scale-chromatic@3.1.0': + resolution: {integrity: sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==} + + '@types/d3-scale@4.0.9': + resolution: {integrity: sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==} + + '@types/d3-selection@3.0.11': + resolution: {integrity: sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==} + + '@types/d3-shape@3.1.8': + resolution: {integrity: sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==} + + '@types/d3-time-format@4.0.3': + resolution: {integrity: sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg==} + + '@types/d3-time@3.0.4': + resolution: {integrity: sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==} + + '@types/d3-timer@3.0.2': + resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==} + + '@types/d3-transition@3.0.9': + resolution: {integrity: sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==} + + '@types/d3-zoom@3.0.8': + resolution: {integrity: sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==} + + '@types/d3@7.4.3': + resolution: {integrity: sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==} + + '@types/debug@4.1.12': + resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==} + + '@types/estree@1.0.8': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + + '@types/fs-extra@9.0.13': + resolution: {integrity: sha512-nEnwB++1u5lVDM2UI4c1+5R+FYaKfaAzS4OococimjVm3nQw3TuzH5UNsocrcTBbhnerblyHj4A49qXbIiZdpA==} + + '@types/geojson@7946.0.16': + resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==} + + '@types/hast@3.0.4': + resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==} + + '@types/http-cache-semantics@4.2.0': + resolution: {integrity: sha512-L3LgimLHXtGkWikKnsPg0/VFx9OGZaC+eN1u4r+OB1XRqH3meBIAVC2zr1WdMH+RHmnRkqliQAOHNJ/E0j/e0Q==} + + '@types/keyv@3.1.4': + resolution: {integrity: sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg==} + + '@types/linkify-it@5.0.0': + resolution: {integrity: sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==} + + '@types/markdown-it@14.1.2': + resolution: {integrity: sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==} + + '@types/mdast@4.0.4': + resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==} + + '@types/mdurl@2.0.0': + resolution: {integrity: sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==} + + '@types/ms@2.1.0': + resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} + + '@types/node-cron@3.0.11': + resolution: {integrity: sha512-0ikrnug3/IyneSHqCBeslAhlK2aBfYek1fGo4bP4QnZPmiqSGRK+Oy7ZMisLWkesffJvQ1cqAcBnJC+8+nxIAg==} + + '@types/node@22.19.15': + resolution: {integrity: sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==} + + '@types/node@24.12.0': + resolution: {integrity: sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ==} + + '@types/nodemailer@7.0.11': + resolution: {integrity: sha512-E+U4RzR2dKrx+u3N4DlsmLaDC6mMZOM/TPROxA0UAPiTgI0y4CEFBmZE+coGWTjakDriRsXG368lNk1u9Q0a2g==} + + '@types/pg@8.18.0': + resolution: {integrity: sha512-gT+oueVQkqnj6ajGJXblFR4iavIXWsGAFCk3dP4Kki5+a9R4NMt0JARdk6s8cUKcfUoqP5dAtDSLU8xYUTFV+Q==} + + '@types/plist@3.0.5': + resolution: {integrity: sha512-E6OCaRmAe4WDmWNsL/9RMqdkkzDCY1etutkflWk4c+AcjDU07Pcz1fQwTX0TQz+Pxqn9i4L1TU3UFpjnrcDgxA==} + + '@types/prop-types@15.7.15': + resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==} + + '@types/react-dom@18.3.7': + resolution: {integrity: sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==} + peerDependencies: + '@types/react': ^18.0.0 + + '@types/react@18.3.28': + resolution: {integrity: sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==} + + '@types/responselike@1.0.3': + resolution: {integrity: sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw==} + + '@types/trusted-types@2.0.7': + resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} + + '@types/unist@3.0.3': + resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} + + '@types/verror@1.10.11': + resolution: {integrity: sha512-RlDm9K7+o5stv0Co8i8ZRGxDbrTxhJtgjqjFyVh/tXQyl/rYtTKlnTvZ88oSTeYREWurwx20Js4kTuKCsFkUtg==} + + '@types/web-bluetooth@0.0.21': + resolution: {integrity: sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==} + + '@types/yauzl@2.10.3': + resolution: {integrity: sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==} + + '@ungap/structured-clone@1.3.0': + resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} + + '@upsetjs/venn.js@2.0.0': + resolution: {integrity: sha512-WbBhLrooyePuQ1VZxrJjtLvTc4NVfpOyKx0sKqioq9bX1C1m7Jgykkn8gLrtwumBioXIqam8DLxp88Adbue6Hw==} + + '@visactor/react-vchart@2.0.19': + resolution: {integrity: sha512-gLEJ8Q6DhI21xX6PDUEmhSUu4Ft8+Foho7TktiMMX6Nll5RYgXagjtjRAI1kSSKm6t/7ZXbBsmP4RdL+KaGtNA==} + peerDependencies: + react: '>=16.0.0' + react-dom: '>=16.0.0' + + '@visactor/vchart-extension@2.0.19': + resolution: {integrity: sha512-SCFysGMRGdNK6wYFKDrvZzGdYbVjGhdbqlWA5t2fdbuVcCN7wNBTDKI+pL4yUL3n9M1ZtVxQp5uOw9I0AjFHZQ==} + + '@visactor/vchart@2.0.19': + resolution: {integrity: sha512-pPNAHUuNlw1FNgbSSwT9comeJUXpaTAe8yed1MVI5rT+TspIg9Wpio29ZlUjrevUxosQqka5VAD63jQS6PMaOg==} + + '@visactor/vdataset@1.0.23': + resolution: {integrity: sha512-zrLk9FBUWJoW6b30XnPKzXwAXl8USdLDfed6QZLsmdkylRU8V7yZeXE2aKwU8Lg1U4HmQngqmqOx7/QlbX44Tg==} + + '@visactor/vlayouts@1.0.23': + resolution: {integrity: sha512-fK1f5LmuumhYanLArk5yrT4BZxu4IAmdc8WMwfB/KAvV+2dTPFuBUMWbWnDl0siQoU9SX9l/bLozUnI9n7BwBQ==} + + '@visactor/vrender-animate@1.0.41': + resolution: {integrity: sha512-kdMoIh7OEo6z4rZfnJHX7d+izBhGVWq6MR22uppk0LPilfdBd/1hSNAEKO6C9JWAy5uROGFpEkh+kk+ar/zSZg==} + + '@visactor/vrender-components@1.0.41': + resolution: {integrity: sha512-B7iXJE1TdkYapPZN6DNxoaErY4FzGf5AbcbG/z6Q0hnzO4Iw1hKdtlTGOIYA1+JXhehDWcyy+D0bnoNQNf+rgw==} + + '@visactor/vrender-core@1.0.41': + resolution: {integrity: sha512-P7YVUJ45vwqPA460W6JJjg201ThvxBrjEgTBJ4tHKaHZPP/nVyF8rPyUOCL8QesEFDB+R0e/sUJUVs+ckHhd2w==} + + '@visactor/vrender-kits@1.0.41': + resolution: {integrity: sha512-ffJlKkNseOsnRjvBmxKxQ9dsQXu+K288NFCtB0ZZ2N/mtcTsZUlH5SDCcsAvDhPGEvJ22Ye4MFKcXSEDctpGzA==} + + '@visactor/vscale@1.0.23': + resolution: {integrity: sha512-XePhYuRoNAp+8MeSMuEOOvhVAlOwvM1sDT2yFxE6zdwVB2GjZk8mH+5N2xQGQWk75YmGJjlJASFtgwjlb1yWxw==} + + '@visactor/vutils-extension@2.0.19': + resolution: {integrity: sha512-uRYCZFw7bYFdrSPQNIrYYsEGKuVn7lTMd4zDKBA2qnAfAZ/Y4WeXzZhQKdXgTFu5UGa292CqmVqMlVKFNWjC+A==} + + '@visactor/vutils@1.0.23': + resolution: {integrity: sha512-M8SLqgdHhKN8QmQKTWD1gzEaHptpIV9pvMYvC6+VeOsqYvZZ6UdhSCAAczTYVo+m/uwcEC2JHSUspbrs8rzlRQ==} + + '@vitejs/plugin-react@4.7.0': + resolution: {integrity: sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==} + engines: {node: ^14.18.0 || >=16.0.0} + peerDependencies: + vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 + + '@vitejs/plugin-vue@5.2.4': + resolution: {integrity: sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==} + engines: {node: ^18.0.0 || >=20.0.0} + peerDependencies: + vite: ^5.0.0 || ^6.0.0 + vue: ^3.2.25 + + '@vitest/expect@2.1.9': + resolution: {integrity: sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==} + + '@vitest/mocker@2.1.9': + resolution: {integrity: sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==} + peerDependencies: + msw: ^2.4.9 + vite: ^5.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@2.1.9': + resolution: {integrity: sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==} + + '@vitest/runner@2.1.9': + resolution: {integrity: sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==} + + '@vitest/snapshot@2.1.9': + resolution: {integrity: sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==} + + '@vitest/spy@2.1.9': + resolution: {integrity: sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==} + + '@vitest/utils@2.1.9': + resolution: {integrity: sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==} + + '@vue/compiler-core@3.5.30': + resolution: {integrity: sha512-s3DfdZkcu/qExZ+td75015ljzHc6vE+30cFMGRPROYjqkroYI5NV2X1yAMX9UeyBNWB9MxCfPcsjpLS11nzkkw==} + + '@vue/compiler-dom@3.5.30': + resolution: {integrity: sha512-eCFYESUEVYHhiMuK4SQTldO3RYxyMR/UQL4KdGD1Yrkfdx4m/HYuZ9jSfPdA+nWJY34VWndiYdW/wZXyiPEB9g==} + + '@vue/compiler-sfc@3.5.30': + resolution: {integrity: sha512-LqmFPDn89dtU9vI3wHJnwaV6GfTRD87AjWpTWpyrdVOObVtjIuSeZr181z5C4PmVx/V3j2p+0f7edFKGRMpQ5A==} + + '@vue/compiler-ssr@3.5.30': + resolution: {integrity: sha512-NsYK6OMTnx109PSL2IAyf62JP6EUdk4Dmj6AkWcJGBvN0dQoMYtVekAmdqgTtWQgEJo+Okstbf/1p7qZr5H+bA==} + + '@vue/devtools-api@7.7.9': + resolution: {integrity: sha512-kIE8wvwlcZ6TJTbNeU2HQNtaxLx3a84aotTITUuL/4bzfPxzajGBOoqjMhwZJ8L9qFYDU/lAYMEEm11dnZOD6g==} + + '@vue/devtools-kit@7.7.9': + resolution: {integrity: sha512-PyQ6odHSgiDVd4hnTP+aDk2X4gl2HmLDfiyEnn3/oV+ckFDuswRs4IbBT7vacMuGdwY/XemxBoh302ctbsptuA==} + + '@vue/devtools-shared@7.7.9': + resolution: {integrity: sha512-iWAb0v2WYf0QWmxCGy0seZNDPdO3Sp5+u78ORnyeonS6MT4PC7VPrryX2BpMJrwlDeaZ6BD4vP4XKjK0SZqaeA==} + + '@vue/reactivity@3.5.30': + resolution: {integrity: sha512-179YNgKATuwj9gB+66snskRDOitDiuOZqkYia7mHKJaidOMo/WJxHKF8DuGc4V4XbYTJANlfEKb0yxTQotnx4Q==} + + '@vue/runtime-core@3.5.30': + resolution: {integrity: sha512-e0Z+8PQsUTdwV8TtEsLzUM7SzC7lQwYKePydb7K2ZnmS6jjND+WJXkmmfh/swYzRyfP1EY3fpdesyYoymCzYfg==} + + '@vue/runtime-dom@3.5.30': + resolution: {integrity: sha512-2UIGakjU4WSQ0T4iwDEW0W7vQj6n7AFn7taqZ9Cvm0Q/RA2FFOziLESrDL4GmtI1wV3jXg5nMoJSYO66egDUBw==} + + '@vue/server-renderer@3.5.30': + resolution: {integrity: sha512-v+R34icapydRwbZRD0sXwtHqrQJv38JuMB4JxbOxd8NEpGLny7cncMp53W9UH/zo4j8eDHjQ1dEJXwzFQknjtQ==} + peerDependencies: + vue: 3.5.30 + + '@vue/shared@3.5.30': + resolution: {integrity: sha512-YXgQ7JjaO18NeK2K9VTbDHaFy62WrObMa6XERNfNOkAhD1F1oDSf3ZJ7K6GqabZ0BvSDHajp8qfS5Sa2I9n8uQ==} + + '@vueuse/core@12.8.2': + resolution: {integrity: sha512-HbvCmZdzAu3VGi/pWYm5Ut+Kd9mn1ZHnn4L5G8kOQTPs/IwIAmJoBrmYk2ckLArgMXZj0AW3n5CAejLUO+PhdQ==} + + '@vueuse/integrations@12.8.2': + resolution: {integrity: sha512-fbGYivgK5uBTRt7p5F3zy6VrETlV9RtZjBqd1/HxGdjdckBgBM4ugP8LHpjolqTj14TXTxSK1ZfgPbHYyGuH7g==} + peerDependencies: + async-validator: ^4 + axios: ^1 + change-case: ^5 + drauu: ^0.4 + focus-trap: ^7 + fuse.js: ^7 + idb-keyval: ^6 + jwt-decode: ^4 + nprogress: ^0.2 + qrcode: ^1.5 + sortablejs: ^1 + universal-cookie: ^7 + peerDependenciesMeta: + async-validator: + optional: true + axios: + optional: true + change-case: + optional: true + drauu: + optional: true + focus-trap: + optional: true + fuse.js: + optional: true + idb-keyval: + optional: true + jwt-decode: + optional: true + nprogress: + optional: true + qrcode: + optional: true + sortablejs: + optional: true + universal-cookie: + optional: true + + '@vueuse/metadata@12.8.2': + resolution: {integrity: sha512-rAyLGEuoBJ/Il5AmFHiziCPdQzRt88VxR+Y/A/QhJ1EWtWqPBBAxTAFaSkviwEuOEZNtW8pvkPgoCZQ+HxqW1A==} + + '@vueuse/shared@12.8.2': + resolution: {integrity: sha512-dznP38YzxZoNloI0qpEfpkms8knDtaoQ6Y/sfS0L7Yki4zh40LFHEhur0odJC6xTHG5dxWVPiUWBXn+wCG2s5w==} + + '@xmldom/xmldom@0.8.11': + resolution: {integrity: sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==} + engines: {node: '>=10.0.0'} + + abbrev@3.0.1: + resolution: {integrity: sha512-AO2ac6pjRB3SJmGJo+v5/aK6Omggp6fsLrs6wN9bd35ulu4cCwaAU9+7ZhXjeqHVkaHThLuzH0nZr0YpCDhygg==} + engines: {node: ^18.17.0 || >=20.5.0} + + abs-svg-path@0.1.1: + resolution: {integrity: sha512-d8XPSGjfyzlXC3Xx891DJRyZfqk5JU0BJrDQcsWomFIV1/BIzPW5HDH5iDdWpqWaav0YVIEzT1RHTwWr0FFshA==} + + abstract-logging@2.0.1: + resolution: {integrity: sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==} + + acorn@8.16.0: + resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==} + engines: {node: '>=0.4.0'} + hasBin: true + + agent-base@7.1.4: + resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} + engines: {node: '>= 14'} + + ajv-formats@3.0.1: + resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + + ajv-keywords@3.5.2: + resolution: {integrity: sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==} + peerDependencies: + ajv: ^6.9.1 + + ajv@6.14.0: + resolution: {integrity: sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==} + + ajv@8.18.0: + resolution: {integrity: sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==} + + algoliasearch@5.49.1: + resolution: {integrity: sha512-X3Pp2aRQhg4xUC6PQtkubn5NpRKuUPQ9FPDQlx36SmpFwwH2N0/tw4c+NXV3nw3PsgeUs+BuWGP0gjz3TvENLQ==} + engines: {node: '>= 14.0.0'} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-regex@6.2.2: + resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} + engines: {node: '>=12'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + ansi-styles@6.2.3: + resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} + engines: {node: '>=12'} + + app-builder-bin@5.0.0-alpha.12: + resolution: {integrity: sha512-j87o0j6LqPL3QRr8yid6c+Tt5gC7xNfYo6uQIQkorAC6MpeayVMZrEDzKmJJ/Hlv7EnOQpaRm53k6ktDYZyB6w==} + + app-builder-lib@26.8.1: + resolution: {integrity: sha512-p0Im/Dx5C4tmz8QEE1Yn4MkuPC8PrnlRneMhWJj7BBXQfNTJUshM/bp3lusdEsDbvvfJZpXWnYesgSLvwtM2Zw==} + engines: {node: '>=14.0.0'} + peerDependencies: + dmg-builder: 26.8.1 + electron-builder-squirrel-windows: 26.8.1 + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + + array-source@0.0.4: + resolution: {integrity: sha512-frNdc+zBn80vipY+GdcJkLEbMWj3xmzArYApmUGxoiV8uAu/ygcs9icPdsGdA26h0MkHUMW6EN2piIvVx+M5Mw==} + + assert-plus@1.0.0: + resolution: {integrity: sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==} + engines: {node: '>=0.8'} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + astral-regex@2.0.0: + resolution: {integrity: sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==} + engines: {node: '>=8'} + + async-exit-hook@2.0.1: + resolution: {integrity: sha512-NW2cX8m1Q7KPA7a5M2ULQeZ2wR5qI5PAbw5L0UOMxdioVk9PMZ0h1TmyZEkPYrCvYjDlFICusOu1dlEKAAeXBw==} + engines: {node: '>=0.12.0'} + + async@3.2.6: + resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==} + + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + + at-least-node@1.0.0: + resolution: {integrity: sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==} + engines: {node: '>= 4.0.0'} + + atomic-sleep@1.0.0: + resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==} + engines: {node: '>=8.0.0'} + + avvio@9.2.0: + resolution: {integrity: sha512-2t/sy01ArdHHE0vRH5Hsay+RtCZt3dLPji7W7/MMOCEgze5b7SNDC4j5H6FnVgPkI1MTNFGzHdHrVXDDl7QSSQ==} + + aws-ssl-profiles@1.1.2: + resolution: {integrity: sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==} + engines: {node: '>= 6.0.0'} + + axios@1.13.6: + resolution: {integrity: sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==} + + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + + base64-js@1.5.1: + resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + + baseline-browser-mapping@2.10.0: + resolution: {integrity: sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA==} + engines: {node: '>=6.0.0'} + hasBin: true + + better-sqlite3@12.8.0: + resolution: {integrity: sha512-RxD2Vd96sQDjQr20kdP+F+dK/1OUNiVOl200vKBZY8u0vTwysfolF6Hq+3ZK2+h8My9YvZhHsF+RSGZW2VYrPQ==} + engines: {node: 20.x || 22.x || 23.x || 24.x || 25.x} + + bidi-js@1.0.3: + resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==} + + bindings@1.5.0: + resolution: {integrity: sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==} + + birpc@2.9.0: + resolution: {integrity: sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==} + + bl@4.1.0: + resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} + + boolean@3.2.0: + resolution: {integrity: sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. + + brace-expansion@1.1.12: + resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} + + brace-expansion@2.0.2: + resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==} + + brace-expansion@5.0.4: + resolution: {integrity: sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==} + engines: {node: 18 || 20 || >=22} + + browserslist@4.28.1: + resolution: {integrity: sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + buffer-crc32@0.2.13: + resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==} + + buffer-from@1.1.2: + resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + + buffer@5.7.1: + resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} + + builder-util-runtime@9.5.1: + resolution: {integrity: sha512-qt41tMfgHTllhResqM5DcnHyDIWNgzHvuY2jDcYP9iaGpkWxTUzV6GQjDeLnlR1/DtdlcsWQbA7sByMpmJFTLQ==} + engines: {node: '>=12.0.0'} + + builder-util@26.8.1: + resolution: {integrity: sha512-pm1lTYbGyc90DHgCDO7eo8Rl4EqKLciayNbZqGziqnH9jrlKe8ZANGdityLZU+pJh16dfzjAx2xQq9McuIPEtw==} + + cac@6.7.14: + resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} + engines: {node: '>=8'} + + cacache@19.0.1: + resolution: {integrity: sha512-hdsUxulXCi5STId78vRVYEtDAjq99ICAUktLTeTYsLoTE6Z8dS0c8pWNCxwdrk9YfJeobDZc2Y186hD/5ZQgFQ==} + engines: {node: ^18.17.0 || >=20.5.0} + + cacheable-lookup@5.0.4: + resolution: {integrity: sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA==} + engines: {node: '>=10.6.0'} + + cacheable-request@7.0.4: + resolution: {integrity: sha512-v+p6ongsrp0yTGbJXjgxPow2+DL93DASP4kXCDKb8/bwRtt9OEF3whggkkDkGNzgcWy2XaF4a8nZglC7uElscg==} + engines: {node: '>=8'} + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + caniuse-lite@1.0.30001777: + resolution: {integrity: sha512-tmN+fJxroPndC74efCdp12j+0rk0RHwV5Jwa1zWaFVyw2ZxAuPeG8ZgWC3Wz7uSjT3qMRQ5XHZ4COgQmsCMJAQ==} + + ccount@2.0.1: + resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} + + chai@5.3.3: + resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} + engines: {node: '>=18'} + + chalk@4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} + + character-entities-html4@2.1.0: + resolution: {integrity: sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==} + + character-entities-legacy@3.0.0: + resolution: {integrity: sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==} + + check-error@2.1.3: + resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} + engines: {node: '>= 16'} + + chevrotain-allstar@0.3.1: + resolution: {integrity: sha512-b7g+y9A0v4mxCW1qUhf3BSVPg+/NvGErk/dOkrDaHA0nQIQGAtrOjlX//9OQtRlSCy+x9rfB5N8yC71lH1nvMw==} + peerDependencies: + chevrotain: ^11.0.0 + + chevrotain@11.1.2: + resolution: {integrity: sha512-opLQzEVriiH1uUQ4Kctsd49bRoFDXGGSC4GUqj7pGyxM3RehRhvTlZJc1FL/Flew2p5uwxa1tUDWKzI4wNM8pg==} + + chownr@1.1.4: + resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==} + + chownr@3.0.0: + resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==} + engines: {node: '>=18'} + + chromium-pickle-js@0.2.0: + resolution: {integrity: sha512-1R5Fho+jBq0DDydt+/vHWj5KJNJCKdARKOCwZUen84I5BreWoLqRLANH1U87eJy1tiASPtMnGqJJq0ZsLoRPOw==} + + ci-info@4.3.1: + resolution: {integrity: sha512-Wdy2Igu8OcBpI2pZePZ5oWjPC38tmDVx5WKUXKwlLYkA0ozo85sLsLvkBbBn/sZaSCMFOGZJ14fvW9t5/d7kdA==} + engines: {node: '>=8'} + + ci-info@4.4.0: + resolution: {integrity: sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==} + engines: {node: '>=8'} + + cli-cursor@3.1.0: + resolution: {integrity: sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==} + engines: {node: '>=8'} + + cli-spinners@2.9.2: + resolution: {integrity: sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==} + engines: {node: '>=6'} + + cli-truncate@2.1.0: + resolution: {integrity: sha512-n8fOixwDD6b/ObinzTrp1ZKFzbgvKZvuz/TvejnLn1aQfC6r52XEx85FmuC+3HI+JM7coBRXUvNqEU2PHVrHpg==} + engines: {node: '>=8'} + + cliui@8.0.1: + resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} + engines: {node: '>=12'} + + clone-response@1.0.3: + resolution: {integrity: sha512-ROoL94jJH2dUVML2Y/5PEDNaSHgeOdSDicUyS7izcF63G6sTc/FTjLub4b8Il9S8S0beOfYt0TaA5qvFK+w0wA==} + + clone@1.0.4: + resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==} + engines: {node: '>=0.8'} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + + comma-separated-tokens@2.0.3: + resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==} + + commander@2.20.3: + resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} + + commander@5.1.0: + resolution: {integrity: sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg==} + engines: {node: '>= 6'} + + commander@7.2.0: + resolution: {integrity: sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==} + engines: {node: '>= 10'} + + commander@8.3.0: + resolution: {integrity: sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==} + engines: {node: '>= 12'} + + commander@9.5.0: + resolution: {integrity: sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==} + engines: {node: ^12.20.0 || >=14} + + compare-version@0.1.2: + resolution: {integrity: sha512-pJDh5/4wrEnXX/VWRZvruAGHkzKdr46z11OlTPN+VrATlWWhSKewNCJ1futCO5C7eJB3nPMFZA1LeYtcFboZ2A==} + engines: {node: '>=0.10.0'} + + concat-map@0.0.1: + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + + concat-stream@1.4.11: + resolution: {integrity: sha512-X3JMh8+4je3U1cQpG87+f9lXHDrqcb2MVLg9L7o8b1UZ0DzhRrUpdn65ttzu10PpJPPI3MQNkis+oha6TSA9Mw==} + engines: {'0': node >= 0.8} + + concat-stream@2.0.0: + resolution: {integrity: sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==} + engines: {'0': node >= 6.0} + + concurrently@9.2.1: + resolution: {integrity: sha512-fsfrO0MxV64Znoy8/l1vVIjjHa29SZyyqPgQBwhiDcaW8wJc2W3XWVOGx4M3oJBnv/zdUZIIp1gDeS98GzP8Ng==} + engines: {node: '>=18'} + hasBin: true + + confbox@0.1.8: + resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==} + + content-disposition@1.0.1: + resolution: {integrity: sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==} + engines: {node: '>=18'} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + cookie@1.1.1: + resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} + engines: {node: '>=18'} + + copy-anything@4.0.5: + resolution: {integrity: sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==} + engines: {node: '>=18'} + + core-util-is@1.0.2: + resolution: {integrity: sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==} + + core-util-is@1.0.3: + resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} + + cose-base@1.0.3: + resolution: {integrity: sha512-s9whTXInMSgAp/NVXVNuVxVKzGH2qck3aQlVHxDCdAEPgtMKwc4Wq6/QKhgdEdgbLSi9rBTAcPoRa6JpiG4ksg==} + + cose-base@2.2.0: + resolution: {integrity: sha512-AzlgcsCbUMymkADOJtQm3wO9S3ltPfYOFD5033keQn9NJzIbtnZj+UdBJe7DYml/8TdbtHJW3j58SOnKhWY/5g==} + + crc@3.8.0: + resolution: {integrity: sha512-iX3mfgcTMIq3ZKLIsVFAbv7+Mc10kxabAGQb8HvjA1o3T1PIYprbakQ65d3I+2HGHt6nSKkM9PYjgoJO2KcFBQ==} + + cross-dirname@0.1.0: + resolution: {integrity: sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==} + + cross-env@7.0.3: + resolution: {integrity: sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==} + engines: {node: '>=10.14', npm: '>=6', yarn: '>=1'} + hasBin: true + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + css-tree@3.2.1: + resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + cytoscape-cose-bilkent@4.1.0: + resolution: {integrity: sha512-wgQlVIUJF13Quxiv5e1gstZ08rnZj2XaLHGoFMYXz7SkNfCDOOteKBE6SYRfA9WxxI/iBc3ajfDoc6hb/MRAHQ==} + peerDependencies: + cytoscape: ^3.2.0 + + cytoscape-fcose@2.2.0: + resolution: {integrity: sha512-ki1/VuRIHFCzxWNrsshHYPs6L7TvLu3DL+TyIGEsRcvVERmxokbf5Gdk7mFxZnTdiGtnA4cfSmjZJMviqSuZrQ==} + peerDependencies: + cytoscape: ^3.2.0 + + cytoscape@3.33.1: + resolution: {integrity: sha512-iJc4TwyANnOGR1OmWhsS9ayRS3s+XQ185FmuHObThD+5AeJCakAAbWv8KimMTt08xCCLNgneQwFp+JRJOr9qGQ==} + engines: {node: '>=0.10'} + + d3-array@1.2.4: + resolution: {integrity: sha512-KHW6M86R+FUPYGb3R5XiYjXPq7VzwxZ22buHhAEVG5ztoEcZZMLov530mmccaqA1GghZArjQV46fuc8kUqhhHw==} + + d3-array@2.12.1: + resolution: {integrity: sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ==} + + d3-array@3.2.4: + resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==} + engines: {node: '>=12'} + + d3-axis@3.0.0: + resolution: {integrity: sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==} + engines: {node: '>=12'} + + d3-brush@3.0.0: + resolution: {integrity: sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==} + engines: {node: '>=12'} + + d3-chord@3.0.1: + resolution: {integrity: sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g==} + engines: {node: '>=12'} + + d3-color@3.1.0: + resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==} + engines: {node: '>=12'} + + d3-contour@4.0.2: + resolution: {integrity: sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA==} + engines: {node: '>=12'} + + d3-delaunay@6.0.4: + resolution: {integrity: sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==} + engines: {node: '>=12'} + + d3-dispatch@3.0.1: + resolution: {integrity: sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==} + engines: {node: '>=12'} + + d3-drag@3.0.0: + resolution: {integrity: sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==} + engines: {node: '>=12'} + + d3-dsv@2.0.0: + resolution: {integrity: sha512-E+Pn8UJYx9mViuIUkoc93gJGGYut6mSDKy2+XaPwccwkRGlR+LO97L2VCCRjQivTwLHkSnAJG7yo00BWY6QM+w==} + hasBin: true + + d3-dsv@3.0.1: + resolution: {integrity: sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==} + engines: {node: '>=12'} + hasBin: true + + d3-ease@3.0.1: + resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==} + engines: {node: '>=12'} + + d3-fetch@3.0.1: + resolution: {integrity: sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==} + engines: {node: '>=12'} + + d3-force@3.0.0: + resolution: {integrity: sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==} + engines: {node: '>=12'} + + d3-format@3.1.2: + resolution: {integrity: sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==} + engines: {node: '>=12'} + + d3-geo@1.12.1: + resolution: {integrity: sha512-XG4d1c/UJSEX9NfU02KwBL6BYPj8YKHxgBEw5om2ZnTRSbIcego6dhHwcxuSR3clxh0EpE38os1DVPOmnYtTPg==} + + d3-geo@3.1.1: + resolution: {integrity: sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==} + engines: {node: '>=12'} + + d3-hexbin@0.2.2: + resolution: {integrity: sha512-KS3fUT2ReD4RlGCjvCEm1RgMtp2NFZumdMu4DBzQK8AZv3fXRM6Xm8I4fSU07UXvH4xxg03NwWKWdvxfS/yc4w==} + + d3-hierarchy@3.1.2: + resolution: {integrity: sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==} + engines: {node: '>=12'} + + d3-interpolate@3.0.1: + resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==} + engines: {node: '>=12'} + + d3-path@1.0.9: + resolution: {integrity: sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg==} + + d3-path@3.1.0: + resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==} + engines: {node: '>=12'} + + d3-polygon@3.0.1: + resolution: {integrity: sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg==} + engines: {node: '>=12'} + + d3-quadtree@3.0.1: + resolution: {integrity: sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==} + engines: {node: '>=12'} + + d3-random@3.0.1: + resolution: {integrity: sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==} + engines: {node: '>=12'} + + d3-sankey@0.12.3: + resolution: {integrity: sha512-nQhsBRmM19Ax5xEIPLMY9ZmJ/cDvd1BG3UVvt5h3WRxKg5zGRbvnteTyWAbzeSvlh3tW7ZEmq4VwR5mB3tutmQ==} + + d3-scale-chromatic@3.1.0: + resolution: {integrity: sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==} + engines: {node: '>=12'} + + d3-scale@4.0.2: + resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==} + engines: {node: '>=12'} + + d3-selection@3.0.0: + resolution: {integrity: sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==} + engines: {node: '>=12'} + + d3-shape@1.3.7: + resolution: {integrity: sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw==} + + d3-shape@3.2.0: + resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==} + engines: {node: '>=12'} + + d3-time-format@4.1.0: + resolution: {integrity: sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==} + engines: {node: '>=12'} + + d3-time@3.1.0: + resolution: {integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==} + engines: {node: '>=12'} + + d3-timer@3.0.1: + resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==} + engines: {node: '>=12'} + + d3-transition@3.0.1: + resolution: {integrity: sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==} + engines: {node: '>=12'} + peerDependencies: + d3-selection: 2 - 3 + + d3-zoom@3.0.0: + resolution: {integrity: sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==} + engines: {node: '>=12'} + + d3@7.9.0: + resolution: {integrity: sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==} + engines: {node: '>=12'} + + dagre-d3-es@7.0.14: + resolution: {integrity: sha512-P4rFMVq9ESWqmOgK+dlXvOtLwYg0i7u0HBGJER0LZDJT2VHIPAMZ/riPxqJceWMStH5+E61QxFra9kIS3AqdMg==} + + data-urls@7.0.0: + resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + dayjs@1.11.20: + resolution: {integrity: sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + + decompress-response@6.0.0: + resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==} + engines: {node: '>=10'} + + deep-eql@5.0.2: + resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} + engines: {node: '>=6'} + + deep-extend@0.6.0: + resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==} + engines: {node: '>=4.0.0'} + + defaults@1.0.4: + resolution: {integrity: sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==} + + defer-to-connect@2.0.1: + resolution: {integrity: sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==} + engines: {node: '>=10'} + + define-data-property@1.1.4: + resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} + engines: {node: '>= 0.4'} + + define-properties@1.2.1: + resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} + engines: {node: '>= 0.4'} + + delaunator@5.0.1: + resolution: {integrity: sha512-8nvh+XBe96aCESrGOqMp/84b13H9cdKbG5P2ejQCh4d4sK9RL4371qou9drQjMhvnPmhWl5hnmqbEE0fXr9Xnw==} + + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + + denque@2.1.0: + resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==} + engines: {node: '>=0.10'} + + depd@2.0.0: + resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} + engines: {node: '>= 0.8'} + + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + detect-node@2.1.0: + resolution: {integrity: sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==} + + devlop@1.1.0: + resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} + + dir-compare@4.2.0: + resolution: {integrity: sha512-2xMCmOoMrdQIPHdsTawECdNPwlVFB9zGcz3kuhmBO6U3oU+UQjsue0i8ayLKpgBcm+hcXPMVSGUN9d+pvJ6+VQ==} + + dmg-builder@26.8.1: + resolution: {integrity: sha512-glMJgnTreo8CFINujtAhCgN96QAqApDMZ8Vl1r8f0QT8QprvC1UCltV4CcWj20YoIyLZx6IUskaJZ0NV8fokcg==} + + dmg-license@1.0.11: + resolution: {integrity: sha512-ZdzmqwKmECOWJpqefloC5OJy1+WZBBse5+MR88z9g9Zn4VY+WYUkAyojmhzJckH5YbbZGcYIuGAkY5/Ys5OM2Q==} + engines: {node: '>=8'} + os: [darwin] + hasBin: true + + dompurify@3.3.3: + resolution: {integrity: sha512-Oj6pzI2+RqBfFG+qOaOLbFXLQ90ARpcGG6UePL82bJLtdsa6CYJD7nmiU8MW9nQNOtCHV3lZ/Bzq1X0QYbBZCA==} + + dotenv-expand@11.0.7: + resolution: {integrity: sha512-zIHwmZPRshsCdpMDyVsqGmgyP0yT8GAgXUnkdAoJisxvf33k7yO6OuoKmcTGuXPWSsm8Oh88nZicRLA9Y0rUeA==} + engines: {node: '>=12'} + + dotenv@16.6.1: + resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==} + engines: {node: '>=12'} + + dotenv@17.3.1: + resolution: {integrity: sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA==} + engines: {node: '>=12'} + + drizzle-kit@0.31.10: + resolution: {integrity: sha512-7OZcmQUrdGI+DUNNsKBn1aW8qSoKuTH7d0mYgSP8bAzdFzKoovxEFnoGQp2dVs82EOJeYycqRtciopszwUf8bw==} + hasBin: true + + drizzle-orm@0.45.1: + resolution: {integrity: sha512-Te0FOdKIistGNPMq2jscdqngBRfBpC8uMFVwqjf6gtTVJHIQ/dosgV/CLBU2N4ZJBsXL5savCba9b0YJskKdcA==} + peerDependencies: + '@aws-sdk/client-rds-data': '>=3' + '@cloudflare/workers-types': '>=4' + '@electric-sql/pglite': '>=0.2.0' + '@libsql/client': '>=0.10.0' + '@libsql/client-wasm': '>=0.10.0' + '@neondatabase/serverless': '>=0.10.0' + '@op-engineering/op-sqlite': '>=2' + '@opentelemetry/api': ^1.4.1 + '@planetscale/database': '>=1.13' + '@prisma/client': '*' + '@tidbcloud/serverless': '*' + '@types/better-sqlite3': '*' + '@types/pg': '*' + '@types/sql.js': '*' + '@upstash/redis': '>=1.34.7' + '@vercel/postgres': '>=0.8.0' + '@xata.io/client': '*' + better-sqlite3: '>=7' + bun-types: '*' + expo-sqlite: '>=14.0.0' + gel: '>=2' + knex: '*' + kysely: '*' + mysql2: '>=2' + pg: '>=8' + postgres: '>=3' + prisma: '*' + sql.js: '>=1' + sqlite3: '>=5' + peerDependenciesMeta: + '@aws-sdk/client-rds-data': + optional: true + '@cloudflare/workers-types': + optional: true + '@electric-sql/pglite': + optional: true + '@libsql/client': + optional: true + '@libsql/client-wasm': + optional: true + '@neondatabase/serverless': + optional: true + '@op-engineering/op-sqlite': + optional: true + '@opentelemetry/api': + optional: true + '@planetscale/database': + optional: true + '@prisma/client': + optional: true + '@tidbcloud/serverless': + optional: true + '@types/better-sqlite3': + optional: true + '@types/pg': + optional: true + '@types/sql.js': + optional: true + '@upstash/redis': + optional: true + '@vercel/postgres': + optional: true + '@xata.io/client': + optional: true + better-sqlite3: + optional: true + bun-types: + optional: true + expo-sqlite: + optional: true + gel: + optional: true + knex: + optional: true + kysely: + optional: true + mysql2: + optional: true + pg: + optional: true + postgres: + optional: true + prisma: + optional: true + sql.js: + optional: true + sqlite3: + optional: true + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + eastasianwidth@0.2.0: + resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + + ejs@3.1.10: + resolution: {integrity: sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==} + engines: {node: '>=0.10.0'} + hasBin: true + + electron-builder-squirrel-windows@26.8.1: + resolution: {integrity: sha512-o288fIdgPLHA76eDrFADHPoo7VyGkDCYbLV1GzndaMSAVBoZrGvM9m2IehdcVMzdAZJ2eV9bgyissQXHv5tGzA==} + + electron-builder@26.8.1: + resolution: {integrity: sha512-uWhx1r74NGpCagG0ULs/P9Nqv2nsoo+7eo4fLUOB8L8MdWltq9odW/uuLXMFCDGnPafknYLZgjNX0ZIFRzOQAw==} + engines: {node: '>=14.0.0'} + hasBin: true + + electron-log@5.4.3: + resolution: {integrity: sha512-sOUsM3LjZdugatazSQ/XTyNcw8dfvH1SYhXWiJyfYodAAKOZdHs0txPiLDXFzOZbhXgAgshQkshH2ccq0feyLQ==} + engines: {node: '>= 14'} + + electron-publish@26.8.1: + resolution: {integrity: sha512-q+jrSTIh/Cv4eGZa7oVR+grEJo/FoLMYBAnSL5GCtqwUpr1T+VgKB/dn1pnzxIxqD8S/jP1yilT9VrwCqINR4w==} + + electron-to-chromium@1.5.307: + resolution: {integrity: sha512-5z3uFKBWjiNR44nFcYdkcXjKMbg5KXNdciu7mhTPo9tB7NbqSNP2sSnGR+fqknZSCwKkBN+oxiiajWs4dT6ORg==} + + electron-updater@6.8.3: + resolution: {integrity: sha512-Z6sgw3jgbikWKXei1ENdqFOxBP0WlXg3TtKfz0rgw2vIZFJUyI4pD7ZN7jrkm7EoMK+tcm/qTnPUdqfZukBlBQ==} + + electron-winstaller@5.4.0: + resolution: {integrity: sha512-bO3y10YikuUwUuDUQRM4KfwNkKhnpVO7IPdbsrejwN9/AABJzzTQ4GeHwyzNSrVO+tEH3/Np255a3sVZpZDjvg==} + engines: {node: '>=8.0.0'} + + electron@41.0.3: + resolution: {integrity: sha512-IDjx8liW1q+r7+MOip5W1Eo1eMwJzVObmYrd9yz2dPCkS7XlgLq3qPVMR80TpiROFp73iY30kTzMdpA6fEVs3A==} + engines: {node: '>= 12.20.55'} + hasBin: true + + emoji-regex-xs@1.0.0: + resolution: {integrity: sha512-LRlerrMYoIDrT6jgpeZ2YYl/L8EulRTt5hQcYjy5AInh7HWXKimpqx68aknBFpGL2+/IcogTcaydJEgaTmOpDg==} + + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + + emoji-regex@9.2.2: + resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} + + encoding@0.1.13: + resolution: {integrity: sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==} + + end-of-stream@1.4.5: + resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} + + enhanced-resolve@5.20.1: + resolution: {integrity: sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==} + engines: {node: '>=10.13.0'} + + entities@6.0.1: + resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} + engines: {node: '>=0.12'} + + entities@7.0.1: + resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} + engines: {node: '>=0.12'} + + env-paths@2.2.1: + resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} + engines: {node: '>=6'} + + err-code@2.0.3: + resolution: {integrity: sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + + es-object-atoms@1.1.1: + resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} + engines: {node: '>= 0.4'} + + es-set-tostringtag@2.1.0: + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} + engines: {node: '>= 0.4'} + + es6-error@4.1.1: + resolution: {integrity: sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==} + + esbuild@0.18.20: + resolution: {integrity: sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA==} + engines: {node: '>=12'} + hasBin: true + + esbuild@0.21.5: + resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==} + engines: {node: '>=12'} + hasBin: true + + esbuild@0.25.12: + resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} + engines: {node: '>=18'} + hasBin: true + + esbuild@0.27.3: + resolution: {integrity: sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==} + engines: {node: '>=18'} + hasBin: true + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + escape-html@1.0.3: + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + + estree-walker@2.0.2: + resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + eventemitter3@4.0.7: + resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==} + + expand-template@2.0.3: + resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==} + engines: {node: '>=6'} + + expect-type@1.3.0: + resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} + engines: {node: '>=12.0.0'} + + exponential-backoff@3.1.3: + resolution: {integrity: sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==} + + extract-zip@2.0.1: + resolution: {integrity: sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==} + engines: {node: '>= 10.17.0'} + hasBin: true + + extsprintf@1.4.1: + resolution: {integrity: sha512-Wrk35e8ydCKDj/ArClo1VrPVmN8zph5V4AtHwIuHhvMXsKf73UT3BOD+azBIW+3wOJ4FhEH7zyaJCFvChjYvMA==} + engines: {'0': node >=0.6.0} + + fast-decode-uri-component@1.0.1: + resolution: {integrity: sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-json-stable-stringify@2.1.0: + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + + fast-json-stringify@6.3.0: + resolution: {integrity: sha512-oRCntNDY/329HJPlmdNLIdogNtt6Vyjb1WuT01Soss3slIdyUp8kAcDU3saQTOquEK8KFVfwIIF7FebxUAu+yA==} + + fast-querystring@1.1.2: + resolution: {integrity: sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==} + + fast-uri@3.1.0: + resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} + + fastify-plugin@5.1.0: + resolution: {integrity: sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==} + + fastify@5.8.2: + resolution: {integrity: sha512-lZmt3navvZG915IE+f7/TIVamxIwmBd+OMB+O9WBzcpIwOo6F0LTh0sluoMFk5VkrKTvvrwIaoJPkir4Z+jtAg==} + + fastq@1.20.1: + resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} + + fd-slicer@1.1.0: + resolution: {integrity: sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + file-source@0.6.1: + resolution: {integrity: sha512-1R1KneL7eTXmXfKxC10V/9NeGOdbsAXJ+lQ//fvvcHUgtaZcZDWNJNblxAoVOyV1cj45pOtUrR3vZTBwqcW8XA==} + + file-uri-to-path@1.0.0: + resolution: {integrity: sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==} + + filelist@1.0.6: + resolution: {integrity: sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA==} + + find-my-way@9.5.0: + resolution: {integrity: sha512-VW2RfnmscZO5KgBY5XVyKREMW5nMZcxDy+buTOsL+zIPnBlbKm+00sgzoQzq1EVh4aALZLfKdwv6atBGcjvjrQ==} + engines: {node: '>=20'} + + focus-trap@7.8.0: + resolution: {integrity: sha512-/yNdlIkpWbM0ptxno3ONTuf+2g318kh2ez3KSeZN5dZ8YC6AAmgeWz+GasYYiBJPFaYcSAPeu4GfhUaChzIJXA==} + + follow-redirects@1.15.11: + resolution: {integrity: sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==} + engines: {node: '>=4.0'} + peerDependencies: + debug: '*' + peerDependenciesMeta: + debug: + optional: true + + foreground-child@3.3.1: + resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} + engines: {node: '>=14'} + + form-data@4.0.5: + resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} + engines: {node: '>= 6'} + + fs-constants@1.0.0: + resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} + + fs-extra@10.1.0: + resolution: {integrity: sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==} + engines: {node: '>=12'} + + fs-extra@11.3.4: + resolution: {integrity: sha512-CTXd6rk/M3/ULNQj8FBqBWHYBVYybQ3VPBw0xGKFe3tuH7ytT6ACnvzpIQ3UZtB8yvUKC2cXn1a+x+5EVQLovA==} + engines: {node: '>=14.14'} + + fs-extra@7.0.1: + resolution: {integrity: sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==} + engines: {node: '>=6 <7 || >=8'} + + fs-extra@8.1.0: + resolution: {integrity: sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==} + engines: {node: '>=6 <7 || >=8'} + + fs-extra@9.1.0: + resolution: {integrity: sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==} + engines: {node: '>=10'} + + fs-minipass@3.0.3: + resolution: {integrity: sha512-XUBA9XClHbnJWSfBzjkm6RvPsyg3sryZt06BEQoXcF7EK/xpGaQYJgQKDJSUH5SGZ76Y7pFx1QBnXz09rU5Fbw==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + + fs.realpath@1.0.0: + resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + generate-function@2.3.1: + resolution: {integrity: sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==} + + gensync@1.0.0-beta.2: + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + engines: {node: '>=6.9.0'} + + geobuf@3.0.2: + resolution: {integrity: sha512-ASgKwEAQQRnyNFHNvpd5uAwstbVYmiTW0Caw3fBb509tNTqXyAAPMyFs5NNihsLZhLxU1j/kjFhkhLWA9djuVg==} + hasBin: true + + geojson-dissolve@3.1.0: + resolution: {integrity: sha512-JXHfn+A3tU392HA703gJbjmuHaQOAE/C1KzbELCczFRFux+GdY6zt1nKb1VMBHp4LWeE7gUY2ql+g06vJqhiwQ==} + + geojson-flatten@0.2.4: + resolution: {integrity: sha512-LiX6Jmot8adiIdZ/fthbcKKPOfWjTQchX/ggHnwMZ2e4b0I243N1ANUos0LvnzepTEsj0+D4fIJ5bKhBrWnAHA==} + hasBin: true + + geojson-linestring-dissolve@0.0.1: + resolution: {integrity: sha512-Y8I2/Ea28R/Xeki7msBcpMvJL2TaPfaPKP8xqueJfQ9/jEhps+iOJxOR2XCBGgVb12Z6XnDb1CMbaPfLepsLaw==} + + get-caller-file@2.0.5: + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-port@7.1.0: + resolution: {integrity: sha512-QB9NKEeDg3xxVwCCwJQ9+xycaz6pBB6iQ76wiWMl1927n0Kir6alPiP+yuiICLLU4jpMe08dXfpebuQppFA2zw==} + engines: {node: '>=16'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + get-stdin@6.0.0: + resolution: {integrity: sha512-jp4tHawyV7+fkkSKyvjuLZswblUtz+SQKzSWnBbii16BuZksJlU1wuBYXY75r+duh/llF1ur6oNwi+2ZzjKZ7g==} + engines: {node: '>=4'} + + get-stream@5.2.0: + resolution: {integrity: sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==} + engines: {node: '>=8'} + + get-tsconfig@4.13.6: + resolution: {integrity: sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==} + + gifuct-js@2.1.2: + resolution: {integrity: sha512-rI2asw77u0mGgwhV3qA+OEgYqaDn5UNqgs+Bx0FGwSpuqfYn+Ir6RQY5ENNQ8SbIiG/m5gVa7CD5RriO4f4Lsg==} + + github-from-package@0.0.0: + resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==} + + glob@10.5.0: + resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + hasBin: true + + glob@13.0.6: + resolution: {integrity: sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==} + engines: {node: 18 || 20 || >=22} + + glob@7.2.3: + resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + + global-agent@3.0.0: + resolution: {integrity: sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==} + engines: {node: '>=10.0'} + + globalthis@1.0.4: + resolution: {integrity: sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==} + engines: {node: '>= 0.4'} + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + got@11.8.6: + resolution: {integrity: sha512-6tfZ91bOr7bOXnK7PRDCGBLa1H4U080YHNaAQ2KsMGlLEzRbk44nsZF2E1IeRc3vtJHPVbKCYgdFbaGO2ljd8g==} + engines: {node: '>=10.19.0'} + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + hachure-fill@0.5.2: + resolution: {integrity: sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg==} + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + has-property-descriptors@1.0.2: + resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + + hasown@2.0.2: + resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} + engines: {node: '>= 0.4'} + + hast-util-to-html@9.0.5: + resolution: {integrity: sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==} + + hast-util-whitespace@3.0.0: + resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==} + + hookable@5.5.3: + resolution: {integrity: sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==} + + hosted-git-info@4.1.0: + resolution: {integrity: sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA==} + engines: {node: '>=10'} + + html-encoding-sniffer@6.0.0: + resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + html-void-elements@3.0.0: + resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==} + + http-cache-semantics@4.2.0: + resolution: {integrity: sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==} + + http-errors@2.0.1: + resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} + engines: {node: '>= 0.8'} + + http-proxy-agent@7.0.2: + resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} + engines: {node: '>= 14'} + + http2-wrapper@1.0.3: + resolution: {integrity: sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg==} + engines: {node: '>=10.19.0'} + + https-proxy-agent@7.0.6: + resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} + engines: {node: '>= 14'} + + iconv-corefoundation@1.1.7: + resolution: {integrity: sha512-T10qvkw0zz4wnm560lOEg0PovVqUXuOFhhHAkixw8/sycy7TJt7v/RrkEKEQnAw2viPSJu6iAkErxnzR0g8PpQ==} + engines: {node: ^8.11.2 || >=10} + os: [darwin] + + iconv-lite@0.4.24: + resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==} + engines: {node: '>=0.10.0'} + + iconv-lite@0.6.3: + resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} + engines: {node: '>=0.10.0'} + + iconv-lite@0.7.2: + resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} + engines: {node: '>=0.10.0'} + + ieee754@1.2.1: + resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} + + imurmurhash@0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} + + inflight@1.0.6: + resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} + deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + ini@1.3.8: + resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} + + internmap@1.0.1: + resolution: {integrity: sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==} + + internmap@2.0.3: + resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} + engines: {node: '>=12'} + + ip-address@10.1.0: + resolution: {integrity: sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==} + engines: {node: '>= 12'} + + ipaddr.js@2.3.0: + resolution: {integrity: sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg==} + engines: {node: '>= 10'} + + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + + is-interactive@1.0.0: + resolution: {integrity: sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==} + engines: {node: '>=8'} + + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + + is-property@1.0.2: + resolution: {integrity: sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==} + + is-unicode-supported@0.1.0: + resolution: {integrity: sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==} + engines: {node: '>=10'} + + is-what@5.5.0: + resolution: {integrity: sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw==} + engines: {node: '>=18'} + + isarray@0.0.1: + resolution: {integrity: sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ==} + + isbinaryfile@4.0.10: + resolution: {integrity: sha512-iHrqe5shvBUcFbmZq9zOQHBoeOhZJu6RQGrDpBgenUm/Am+F3JM2MgQj+rK3Z601fzrL5gLZWtAPH2OBaSVcyw==} + engines: {node: '>= 8.0.0'} + + isbinaryfile@5.0.7: + resolution: {integrity: sha512-gnWD14Jh3FzS3CPhF0AxNOJ8CxqeblPTADzI38r0wt8ZyQl5edpy75myt08EG2oKvpyiqSqsx+Wkz9vtkbTqYQ==} + engines: {node: '>= 18.0.0'} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + isexe@3.1.5: + resolution: {integrity: sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==} + engines: {node: '>=18'} + + jackspeak@3.4.3: + resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} + + jake@10.9.4: + resolution: {integrity: sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA==} + engines: {node: '>=10'} + hasBin: true + + jiti@2.6.1: + resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} + hasBin: true + + joi@18.0.2: + resolution: {integrity: sha512-RuCOQMIt78LWnktPoeBL0GErkNaJPTBGcYuyaBvUOQSpcpcLfWrHPPihYdOGbV5pam9VTWbeoF7TsGiHugcjGA==} + engines: {node: '>= 20'} + + js-binary-schema-parser@2.0.3: + resolution: {integrity: sha512-xezGJmOb4lk/M1ZZLTR/jaBHQ4gG/lqQnJqdIv4721DMggsa1bDVlHXNeHYogaIEHD9vCRv0fcL4hMA+Coarkg==} + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + js-yaml@4.1.1: + resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} + hasBin: true + + jsdom@29.0.1: + resolution: {integrity: sha512-z6JOK5gRO7aMybVq/y/MlIpKh8JIi68FBKMUtKkK2KH/wMSRlCxQ682d08LB9fYXplyY/UXG8P4XXTScmdjApg==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24.0.0} + peerDependencies: + canvas: ^3.0.0 + peerDependenciesMeta: + canvas: + optional: true + + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + + json-buffer@3.0.1: + resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + + json-schema-ref-resolver@3.0.0: + resolution: {integrity: sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A==} + + json-schema-traverse@0.4.1: + resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + + json-stringify-safe@5.0.1: + resolution: {integrity: sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==} + + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + + jsonfile@4.0.0: + resolution: {integrity: sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==} + + jsonfile@6.2.0: + resolution: {integrity: sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==} + + katex@0.16.39: + resolution: {integrity: sha512-FR2f6y85+81ZLO0GPhyQ+EJl/E5ILNWltJhpAeOTzRny952Z13x2867lTFDmvMZix//Ux3CuMQ2VkLXRbUwOFg==} + hasBin: true + + keyv@4.5.4: + resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + + khroma@2.1.0: + resolution: {integrity: sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw==} + + langium@4.2.1: + resolution: {integrity: sha512-zu9QWmjpzJcomzdJQAHgDVhLGq5bLosVak1KVa40NzQHXfqr4eAHupvnPOVXEoLkg6Ocefvf/93d//SB7du4YQ==} + engines: {node: '>=20.10.0', npm: '>=10.2.3'} + + layout-base@1.0.2: + resolution: {integrity: sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg==} + + layout-base@2.0.1: + resolution: {integrity: sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg==} + + lazy-val@1.0.5: + resolution: {integrity: sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q==} + + light-my-request@6.6.0: + resolution: {integrity: sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A==} + + lightningcss-android-arm64@1.32.0: + resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.32.0: + resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.32.0: + resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.32.0: + resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.32.0: + resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.32.0: + resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-arm64-musl@1.32.0: + resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-x64-gnu@1.32.0: + resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-linux-x64-musl@1.32.0: + resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-win32-arm64-msvc@1.32.0: + resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.32.0: + resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.32.0: + resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} + engines: {node: '>= 12.0.0'} + + lodash-es@4.17.23: + resolution: {integrity: sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg==} + + lodash.escaperegexp@4.1.2: + resolution: {integrity: sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw==} + + lodash.isequal@4.5.0: + resolution: {integrity: sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==} + deprecated: This package is deprecated. Use require('node:util').isDeepStrictEqual instead. + + lodash@4.17.23: + resolution: {integrity: sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==} + + log-symbols@4.1.0: + resolution: {integrity: sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==} + engines: {node: '>=10'} + + long@5.3.2: + resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==} + + loose-envify@1.4.0: + resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} + hasBin: true + + lottie-web@5.13.0: + resolution: {integrity: sha512-+gfBXl6sxXMPe8tKQm7qzLnUy5DUPJPKIyRHwtpCpyUEYjHYRJC/5gjUvdkuO2c3JllrPtHXH5UJJK8LRYl5yQ==} + + loupe@3.2.1: + resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} + + lowercase-keys@2.0.0: + resolution: {integrity: sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==} + engines: {node: '>=8'} + + lru-cache@10.4.3: + resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + + lru-cache@11.2.7: + resolution: {integrity: sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==} + engines: {node: 20 || >=22} + + lru-cache@5.1.1: + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + + lru-cache@6.0.0: + resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==} + engines: {node: '>=10'} + + lru.min@1.1.4: + resolution: {integrity: sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA==} + engines: {bun: '>=1.0.0', deno: '>=1.30.0', node: '>=8.0.0'} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + make-fetch-happen@14.0.3: + resolution: {integrity: sha512-QMjGbFTP0blj97EeidG5hk/QhKQ3T4ICckQGLgz38QF7Vgbk6e6FTARN8KhKxyBbWn8R0HU+bnw8aSoFPD4qtQ==} + engines: {node: ^18.17.0 || >=20.5.0} + + mark.js@8.11.1: + resolution: {integrity: sha512-1I+1qpDt4idfgLQG+BNWmrqku+7/2bi5nLf4YwF8y8zXvmfiTBY3PV3ZibfrjBueCByROpuBjLLFCajqkgYoLQ==} + + marked@16.4.2: + resolution: {integrity: sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA==} + engines: {node: '>= 20'} + hasBin: true + + marked@17.0.4: + resolution: {integrity: sha512-NOmVMM+KAokHMvjWmC5N/ZOvgmSWuqJB8FoYI019j4ogb/PeRMKoKIjReZ2w3376kkA8dSJIP8uD993Kxc0iRQ==} + engines: {node: '>= 20'} + hasBin: true + + matcher@3.0.0: + resolution: {integrity: sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==} + engines: {node: '>=10'} + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + mdast-util-to-hast@13.2.1: + resolution: {integrity: sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==} + + mdn-data@2.27.1: + resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} + + mermaid@11.13.0: + resolution: {integrity: sha512-fEnci+Immw6lKMFI8sqzjlATTyjLkRa6axrEgLV2yHTfv8r+h1wjFbV6xeRtd4rUV1cS4EpR9rwp3Rci7TRWDw==} + + micromark-util-character@2.1.1: + resolution: {integrity: sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==} + + micromark-util-encode@2.0.1: + resolution: {integrity: sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==} + + micromark-util-sanitize-uri@2.0.1: + resolution: {integrity: sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==} + + micromark-util-symbol@2.0.1: + resolution: {integrity: sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==} + + micromark-util-types@2.0.2: + resolution: {integrity: sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==} + + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + + mime@2.6.0: + resolution: {integrity: sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==} + engines: {node: '>=4.0.0'} + hasBin: true + + mime@3.0.0: + resolution: {integrity: sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==} + engines: {node: '>=10.0.0'} + hasBin: true + + mimic-fn@2.1.0: + resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} + engines: {node: '>=6'} + + mimic-response@1.0.1: + resolution: {integrity: sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==} + engines: {node: '>=4'} + + mimic-response@3.1.0: + resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==} + engines: {node: '>=10'} + + minimatch@10.2.4: + resolution: {integrity: sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==} + engines: {node: 18 || 20 || >=22} + + minimatch@3.1.5: + resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} + + minimatch@5.1.9: + resolution: {integrity: sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==} + engines: {node: '>=10'} + + minimatch@9.0.9: + resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==} + engines: {node: '>=16 || 14 >=14.17'} + + minimist@1.2.0: + resolution: {integrity: sha512-7Wl+Jz+IGWuSdgsQEJ4JunV0si/iMhg42MnQQG6h1R6TNeVenp4U9x5CC5v/gYqz/fENLQITAWXidNtVL0NNbw==} + + minimist@1.2.6: + resolution: {integrity: sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q==} + + minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + + minipass-collect@2.0.1: + resolution: {integrity: sha512-D7V8PO9oaz7PWGLbCACuI1qEOsq7UKfLotx/C0Aet43fCUB/wfQ7DYeq2oR/svFJGYDHPr38SHATeaj/ZoKHKw==} + engines: {node: '>=16 || 14 >=14.17'} + + minipass-fetch@4.0.1: + resolution: {integrity: sha512-j7U11C5HXigVuutxebFadoYBbd7VSdZWggSe64NVdvWNBqGAiXPL2QVCehjmw7lY1oF9gOllYbORh+hiNgfPgQ==} + engines: {node: ^18.17.0 || >=20.5.0} + + minipass-flush@1.0.5: + resolution: {integrity: sha512-JmQSYYpPUqX5Jyn1mXaRwOda1uQ8HP5KAT/oDSLCzt1BYRhQU0/hDtsB1ufZfEEzMZ9aAVmsBw8+FWsIXlClWw==} + engines: {node: '>= 8'} + + minipass-pipeline@1.2.4: + resolution: {integrity: sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A==} + engines: {node: '>=8'} + + minipass-sized@1.0.3: + resolution: {integrity: sha512-MbkQQ2CTiBMlA2Dm/5cY+9SWFEN8pzzOXi6rlM5Xxq0Yqbda5ZQy9sU75a673FE9ZK0Zsbr6Y5iP6u9nktfg2g==} + engines: {node: '>=8'} + + minipass@3.3.6: + resolution: {integrity: sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==} + engines: {node: '>=8'} + + minipass@7.1.3: + resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} + engines: {node: '>=16 || 14 >=14.17'} + + minisearch@7.2.0: + resolution: {integrity: sha512-dqT2XBYUOZOiC5t2HRnwADjhNS2cecp9u+TJRiJ1Qp/f5qjkeT5APcGPjHw+bz89Ms8Jp+cG4AlE+QZ/QnDglg==} + + minizlib@3.1.0: + resolution: {integrity: sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==} + engines: {node: '>= 18'} + + mitt@3.0.1: + resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==} + + mkdirp-classic@0.5.3: + resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==} + + mkdirp@0.5.6: + resolution: {integrity: sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==} + hasBin: true + + mlly@1.8.2: + resolution: {integrity: sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + mysql2@3.20.0: + resolution: {integrity: sha512-eCLUs7BNbgA6nf/MZXsaBO1SfGs0LtLVrJD3WeWq+jPLDWkSufTD+aGMwykfUVPdZnblaUK1a8G/P63cl9FkKg==} + engines: {node: '>= 8.0'} + peerDependencies: + '@types/node': '>= 8' + + named-placeholders@1.1.6: + resolution: {integrity: sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==} + engines: {node: '>=8.0.0'} + + nanoid@3.3.11: + resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + napi-build-utils@2.0.0: + resolution: {integrity: sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==} + + negotiator@1.0.0: + resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} + engines: {node: '>= 0.6'} + + node-abi@3.89.0: + resolution: {integrity: sha512-6u9UwL0HlAl21+agMN3YAMXcKByMqwGx+pq+P76vii5f7hTPtKDp08/H9py6DY+cfDw7kQNTGEj/rly3IgbNQA==} + engines: {node: '>=10'} + + node-abi@4.26.0: + resolution: {integrity: sha512-8QwIZqikRvDIkXS2S93LjzhsSPJuIbfaMETWH+Bx8oOT9Sa9UsUtBFQlc3gBNd1+QINjaTloitXr1W3dQLi9Iw==} + engines: {node: '>=22.12.0'} + + node-addon-api@1.7.2: + resolution: {integrity: sha512-ibPK3iA+vaY1eEjESkQkM0BbCqFOaZMiXRTtdB0u7b4djtY6JnsjvPdUHVMg6xQt3B8fpTTWHI9A+ADjM9frzg==} + + node-api-version@0.2.1: + resolution: {integrity: sha512-2xP/IGGMmmSQpI1+O/k72jF/ykvZ89JeuKX3TLJAYPDVLUalrshrLHkeVcCCZqG/eEa635cr8IBYzgnDvM2O8Q==} + + node-cron@4.2.1: + resolution: {integrity: sha512-lgimEHPE/QDgFlywTd8yTR61ptugX3Qer29efeyWw2rv259HtGBNn1vZVmp8lB9uo9wC0t/AT4iGqXxia+CJFg==} + engines: {node: '>=6.0.0'} + + node-gyp@11.5.0: + resolution: {integrity: sha512-ra7Kvlhxn5V9Slyus0ygMa2h+UqExPqUIkfk7Pc8QTLT956JLSy51uWFwHtIYy0vI8cB4BDhc/S03+880My/LQ==} + engines: {node: ^18.17.0 || >=20.5.0} + hasBin: true + + node-releases@2.0.36: + resolution: {integrity: sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==} + + nodemailer@8.0.3: + resolution: {integrity: sha512-JQNBqvK+bj3NMhUFR3wmCl3SYcOeMotDiwDBvIoCuQdF0PvlIY0BH+FJ2CG7u4cXKPChplE78oowlH/Otsc4ZQ==} + engines: {node: '>=6.0.0'} + + non-layered-tidy-tree-layout@2.0.2: + resolution: {integrity: sha512-gkXMxRzUH+PB0ax9dUN0yYF0S25BqeAYqhgMaLUFmpXLEk7Fcu8f4emJuOAY0V8kjDICxROIKsTAKsV/v355xw==} + + nopt@8.1.0: + resolution: {integrity: sha512-ieGu42u/Qsa4TFktmaKEwM6MQH0pOWnaB3htzh0JRtx84+Mebc0cbZYN5bC+6WTZ4+77xrL9Pn5m7CV6VIkV7A==} + engines: {node: ^18.17.0 || >=20.5.0} + hasBin: true + + normalize-url@6.1.0: + resolution: {integrity: sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==} + engines: {node: '>=10'} + + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + + object-keys@1.1.1: + resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==} + engines: {node: '>= 0.4'} + + on-exit-leak-free@2.1.2: + resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==} + engines: {node: '>=14.0.0'} + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + + onetime@5.1.2: + resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} + engines: {node: '>=6'} + + oniguruma-to-es@3.1.1: + resolution: {integrity: sha512-bUH8SDvPkH3ho3dvwJwfonjlQ4R80vjyvrU8YpxuROddv55vAEJrTuCuCVUhhsHbtlD9tGGbaNApGQckXhS8iQ==} + + ora@5.4.1: + resolution: {integrity: sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==} + engines: {node: '>=10'} + + p-cancelable@2.1.1: + resolution: {integrity: sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==} + engines: {node: '>=8'} + + p-limit@3.1.0: + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} + engines: {node: '>=10'} + + p-map@7.0.4: + resolution: {integrity: sha512-tkAQEw8ysMzmkhgw8k+1U/iPhWNhykKnSk4Rd5zLoPJCuJaGRPo6YposrZgaxHKzDHdDWWZvE/Sk7hsL2X/CpQ==} + engines: {node: '>=18'} + + package-json-from-dist@1.0.1: + resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} + + package-manager-detector@1.6.0: + resolution: {integrity: sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==} + + parse-svg-path@0.1.2: + resolution: {integrity: sha512-JyPSBnkTJ0AI8GGJLfMXvKq42cj5c006fnLz6fXy6zfoVjJizi8BNTpu8on8ziI1cKy9d9DGNuY17Ce7wuejpQ==} + + parse5@8.0.0: + resolution: {integrity: sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==} + + path-browserify@1.0.1: + resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==} + + path-data-parser@0.1.0: + resolution: {integrity: sha512-NOnmBpt5Y2RWbuv0LMzsayp3lVylAHLPUTut412ZA3l+C4uw4ZVkQbjShYCQ8TCpUMdPapr4YjUqLYD6v68j+w==} + + path-is-absolute@1.0.1: + resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} + engines: {node: '>=0.10.0'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-scurry@1.11.1: + resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} + engines: {node: '>=16 || 14 >=14.18'} + + path-scurry@2.0.2: + resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==} + engines: {node: 18 || 20 || >=22} + + path-source@0.1.3: + resolution: {integrity: sha512-dWRHm5mIw5kw0cs3QZLNmpUWty48f5+5v9nWD2dw3Y0Hf+s01Ag8iJEWV0Sm0kocE8kK27DrIowha03e1YR+Qw==} + + pathe@1.1.2: + resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + pathval@2.0.1: + resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} + engines: {node: '>= 14.16'} + + pbf@3.3.0: + resolution: {integrity: sha512-XDF38WCH3z5OV/OVa8GKUNtLAyneuzbCisx7QUCF8Q6Nutx0WnJrQe5O+kOtBlLfRNUws98Y58Lblp+NJG5T4Q==} + hasBin: true + + pe-library@0.4.1: + resolution: {integrity: sha512-eRWB5LBz7PpDu4PUlwT0PhnQfTQJlDDdPa35urV4Osrm0t0AqQFGn+UIkU3klZvwJ8KPO3VbBFsXquA6p6kqZw==} + engines: {node: '>=12', npm: '>=6'} + + pend@1.2.0: + resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==} + + perfect-debounce@1.0.0: + resolution: {integrity: sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==} + + pg-cloudflare@1.3.0: + resolution: {integrity: sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==} + + pg-connection-string@2.12.0: + resolution: {integrity: sha512-U7qg+bpswf3Cs5xLzRqbXbQl85ng0mfSV/J0nnA31MCLgvEaAo7CIhmeyrmJpOr7o+zm0rXK+hNnT5l9RHkCkQ==} + + pg-int8@1.0.1: + resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==} + engines: {node: '>=4.0.0'} + + pg-pool@3.13.0: + resolution: {integrity: sha512-gB+R+Xud1gLFuRD/QgOIgGOBE2KCQPaPwkzBBGC9oG69pHTkhQeIuejVIk3/cnDyX39av2AxomQiyPT13WKHQA==} + peerDependencies: + pg: '>=8.0' + + pg-protocol@1.13.0: + resolution: {integrity: sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w==} + + pg-types@2.2.0: + resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==} + engines: {node: '>=4'} + + pg@8.20.0: + resolution: {integrity: sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==} + engines: {node: '>= 16.0.0'} + peerDependencies: + pg-native: '>=3.0.1' + peerDependenciesMeta: + pg-native: + optional: true + + pgpass@1.0.5: + resolution: {integrity: sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.3: + resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} + engines: {node: '>=12'} + + pino-abstract-transport@3.0.0: + resolution: {integrity: sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==} + + pino-std-serializers@7.1.0: + resolution: {integrity: sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==} + + pino@10.3.1: + resolution: {integrity: sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==} + hasBin: true + + pkg-types@1.3.1: + resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} + + plist@3.1.0: + resolution: {integrity: sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ==} + engines: {node: '>=10.4.0'} + + point-at-length@1.1.0: + resolution: {integrity: sha512-nNHDk9rNEh/91o2Y8kHLzBLNpLf80RYd2gCun9ss+V0ytRSf6XhryBTx071fesktjbachRmGuUbId+JQmzhRXw==} + + points-on-curve@0.2.0: + resolution: {integrity: sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A==} + + points-on-path@0.2.1: + resolution: {integrity: sha512-25ClnWWuw7JbWZcgqY/gJ4FQWadKxGWk+3kR/7kD0tCaDtPPMj7oHu2ToLaVhfpnHrZzYby2w6tUA0eOIuUg8g==} + + postcss@8.5.8: + resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==} + engines: {node: ^10 || ^12 || >=14} + + postgres-array@2.0.0: + resolution: {integrity: sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==} + engines: {node: '>=4'} + + postgres-bytea@1.0.1: + resolution: {integrity: sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==} + engines: {node: '>=0.10.0'} + + postgres-date@1.0.7: + resolution: {integrity: sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==} + engines: {node: '>=0.10.0'} + + postgres-interval@1.2.0: + resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==} + engines: {node: '>=0.10.0'} + + postject@1.0.0-alpha.6: + resolution: {integrity: sha512-b9Eb8h2eVqNE8edvKdwqkrY6O7kAwmI8kcnBv1NScolYJbo59XUF0noFq+lxbC1yN20bmC0WBEbDC5H/7ASb0A==} + engines: {node: '>=14.0.0'} + hasBin: true + + preact@10.28.4: + resolution: {integrity: sha512-uKFfOHWuSNpRFVTnljsCluEFq57OKT+0QdOiQo8XWnQ/pSvg7OpX5eNOejELXJMWy+BwM2nobz0FkvzmnpCNsQ==} + + prebuild-install@7.1.3: + resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==} + engines: {node: '>=10'} + deprecated: No longer maintained. Please contact the author of the relevant native addon; alternatives are available. + hasBin: true + + proc-log@5.0.0: + resolution: {integrity: sha512-Azwzvl90HaF0aCz1JrDdXQykFakSSNPaPoiZ9fm5qJIMHioDZEi7OAdRwSm6rSoPtY3Qutnm3L7ogmg3dc+wbQ==} + engines: {node: ^18.17.0 || >=20.5.0} + + process-warning@4.0.1: + resolution: {integrity: sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q==} + + process-warning@5.0.0: + resolution: {integrity: sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==} + + progress@2.0.3: + resolution: {integrity: sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==} + engines: {node: '>=0.4.0'} + + promise-retry@2.0.1: + resolution: {integrity: sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==} + engines: {node: '>=10'} + + proper-lockfile@4.1.2: + resolution: {integrity: sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==} + + property-information@7.1.0: + resolution: {integrity: sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==} + + protocol-buffers-schema@3.6.0: + resolution: {integrity: sha512-TdDRD+/QNdrCGCE7v8340QyuXd4kIWIgapsE2+n/SaGiSSbomYl4TjHlvIoCWRpE7wFt02EpB35VVA2ImcBVqw==} + + proxy-from-env@1.1.0: + resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} + + pump@3.0.4: + resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==} + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + + quick-format-unescaped@4.0.4: + resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==} + + quick-lru@5.1.1: + resolution: {integrity: sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==} + engines: {node: '>=10'} + + rc@1.2.8: + resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} + hasBin: true + + react-dom@18.3.1: + resolution: {integrity: sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==} + peerDependencies: + react: ^18.3.1 + + react-is@18.3.1: + resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} + + react-refresh@0.17.0: + resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==} + engines: {node: '>=0.10.0'} + + react-router-dom@7.13.1: + resolution: {integrity: sha512-UJnV3Rxc5TgUPJt2KJpo1Jpy0OKQr0AjgbZzBFjaPJcFOb2Y8jA5H3LT8HUJAiRLlWrEXWHbF1Z4SCZaQjWDHw==} + engines: {node: '>=20.0.0'} + peerDependencies: + react: '>=18' + react-dom: '>=18' + + react-router@7.13.1: + resolution: {integrity: sha512-td+xP4X2/6BJvZoX6xw++A2DdEi++YypA69bJUV5oVvqf6/9/9nNlD70YO1e9d3MyamJEBQFEzk6mbfDYbqrSA==} + engines: {node: '>=20.0.0'} + peerDependencies: + react: '>=18' + react-dom: '>=18' + peerDependenciesMeta: + react-dom: + optional: true + + react-shallow-renderer@16.15.0: + resolution: {integrity: sha512-oScf2FqQ9LFVQgA73vr86xl2NaOIX73rh+YFqcOp68CWj56tSfgtGKrEbyhCj0rSijyG9M1CYprTh39fBi5hzA==} + peerDependencies: + react: ^16.0.0 || ^17.0.0 || ^18.0.0 + + react-test-renderer@18.3.1: + resolution: {integrity: sha512-KkAgygexHUkQqtvvx/otwxtuFu5cVjfzTCtjXLH9boS19/Nbtg84zS7wIQn39G8IlrhThBpQsMKkq5ZHZIYFXA==} + peerDependencies: + react: ^18.3.1 + + react@18.3.1: + resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==} + engines: {node: '>=0.10.0'} + + read-binary-file-arch@1.0.6: + resolution: {integrity: sha512-BNg9EN3DD3GsDXX7Aa8O4p92sryjkmzYYgmgTAc6CA4uGLEDzFfxOxugu21akOxpcXHiEgsYkC6nPsQvLLLmEg==} + hasBin: true + + readable-stream@1.1.14: + resolution: {integrity: sha512-+MeVjFf4L44XUkhM1eYbD8fyEsxcV81pqMSR5gblfcLCHfZvbrqy4/qYHE+/R5HoBUT11WV5O08Cr1n3YXkWVQ==} + + readable-stream@3.6.2: + resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} + engines: {node: '>= 6'} + + real-require@0.2.0: + resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==} + engines: {node: '>= 12.13.0'} + + regex-recursion@6.0.2: + resolution: {integrity: sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==} + + regex-utilities@2.3.0: + resolution: {integrity: sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==} + + regex@6.1.0: + resolution: {integrity: sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==} + + require-directory@2.1.1: + resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} + engines: {node: '>=0.10.0'} + + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + + resedit@1.7.2: + resolution: {integrity: sha512-vHjcY2MlAITJhC0eRD/Vv8Vlgmu9Sd3LX9zZvtGzU5ZImdTN3+d6e/4mnTyV8vEbyf1sgNIrWxhWlrys52OkEA==} + engines: {node: '>=12', npm: '>=6'} + + resolve-alpn@1.2.1: + resolution: {integrity: sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==} + + resolve-pkg-maps@1.0.0: + resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + + resolve-protobuf-schema@2.1.0: + resolution: {integrity: sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ==} + + responselike@2.0.1: + resolution: {integrity: sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw==} + + restore-cursor@3.1.0: + resolution: {integrity: sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==} + engines: {node: '>=8'} + + ret@0.5.0: + resolution: {integrity: sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==} + engines: {node: '>=10'} + + retry@0.12.0: + resolution: {integrity: sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==} + engines: {node: '>= 4'} + + reusify@1.1.0: + resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} + engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + + rfdc@1.4.1: + resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} + + rimraf@2.6.3: + resolution: {integrity: sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA==} + deprecated: Rimraf versions prior to v4 are no longer supported + hasBin: true + + roarr@2.15.4: + resolution: {integrity: sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==} + engines: {node: '>=8.0'} + + robust-predicates@3.0.2: + resolution: {integrity: sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg==} + + rollup@4.59.0: + resolution: {integrity: sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + roughjs@4.6.6: + resolution: {integrity: sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ==} + + rw@1.3.3: + resolution: {integrity: sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==} + + rxjs@7.8.2: + resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==} + + safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + + safe-regex2@5.0.0: + resolution: {integrity: sha512-YwJwe5a51WlK7KbOJREPdjNrpViQBI3p4T50lfwPuDhZnE3XGVTlGvi+aolc5+RvxDD6bnUmjVsU9n1eboLUYw==} + + safe-stable-stringify@2.5.0: + resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==} + engines: {node: '>=10'} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + sanitize-filename@1.6.3: + resolution: {integrity: sha512-y/52Mcy7aw3gRm7IrcGDFx/bCk4AhRh2eI9luHOQM86nZsqwiRkkq2GekHXBBD+SmPidc8i2PqtYZl+pWJ8Oeg==} + + sax@1.5.0: + resolution: {integrity: sha512-21IYA3Q5cQf089Z6tgaUTr7lDAyzoTPx5HRtbhsME8Udispad8dC/+sziTNugOEx54ilvatQ9YCzl4KQLPcRHA==} + engines: {node: '>=11.0.0'} + + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + + scheduler@0.23.2: + resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==} + + search-insights@2.17.3: + resolution: {integrity: sha512-RQPdCYTa8A68uM2jwxoY842xDhvx3E5LFL1LxvxCNMev4o5mLuokczhzjAgGwUZBAmOKZknArSxLKmXtIi2AxQ==} + + secure-json-parse@4.1.0: + resolution: {integrity: sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==} + + semver-compare@1.0.0: + resolution: {integrity: sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==} + + semver@5.7.2: + resolution: {integrity: sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==} + hasBin: true + + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + + semver@7.7.4: + resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} + engines: {node: '>=10'} + hasBin: true + + serialize-error@7.0.1: + resolution: {integrity: sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==} + engines: {node: '>=10'} + + set-cookie-parser@2.7.2: + resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==} + + setprototypeof@1.2.0: + resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + + shapefile@0.6.6: + resolution: {integrity: sha512-rLGSWeK2ufzCVx05wYd+xrWnOOdSV7xNUW5/XFgx3Bc02hBkpMlrd2F1dDII7/jhWzv0MSyBFh5uJIy9hLdfuw==} + hasBin: true + + sharp@0.34.5: + resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + shell-quote@1.8.3: + resolution: {integrity: sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==} + engines: {node: '>= 0.4'} + + shiki@2.5.0: + resolution: {integrity: sha512-mI//trrsaiCIPsja5CNfsyNOqgAZUb6VpJA+340toL42UpzQlXpwRV9nch69X6gaUxrr9kaOOa6e3y3uAkGFxQ==} + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + signal-exit@3.0.7: + resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} + + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + + simple-concat@1.0.1: + resolution: {integrity: sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==} + + simple-get@4.0.1: + resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==} + + simple-statistics@7.8.9: + resolution: {integrity: sha512-YT6MLqYsz7y1rQZOLFlOCCgSRpCi6bqY417yhoOLI7aVoBi29dD39EPrOE03W9DY25H0J0jizVsHZnkLzyGJFg==} + + simple-update-notifier@2.0.0: + resolution: {integrity: sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==} + engines: {node: '>=10'} + + simplify-geojson@1.0.5: + resolution: {integrity: sha512-02l1W4UipP5ivNVq6kX15mAzCRIV1oI3tz0FUEyOsNiv1ltuFDjbNhO+nbv/xhbDEtKqWLYuzpWhUsJrjR/ypA==} + hasBin: true + + simplify-geometry@0.0.2: + resolution: {integrity: sha512-ZEyrplkqgCqDlL7V8GbbYgTLlcnNF+MWWUdy8s8ZeJru50bnI71rDew/I+HG36QS2mPOYAq1ZjwNXxHJ8XOVBw==} + + slice-ansi@3.0.0: + resolution: {integrity: sha512-pSyv7bSTC7ig9Dcgbw9AuRNUb5k5V6oDudjZoMBSr13qpLBG7tB+zgCkARjq7xIUgdz5P1Qe8u+rSGdouOOIyQ==} + engines: {node: '>=8'} + + slice-source@0.4.1: + resolution: {integrity: sha512-YiuPbxpCj4hD9Qs06hGAz/OZhQ0eDuALN0lRWJez0eD/RevzKqGdUx1IOMUnXgpr+sXZLq3g8ERwbAH0bCb8vg==} + + smart-buffer@4.2.0: + resolution: {integrity: sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==} + engines: {node: '>= 6.0.0', npm: '>= 3.0.0'} + + socks-proxy-agent@8.0.5: + resolution: {integrity: sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==} + engines: {node: '>= 14'} + + socks@2.8.7: + resolution: {integrity: sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==} + engines: {node: '>= 10.0.0', npm: '>= 3.0.0'} + + sonic-boom@4.2.1: + resolution: {integrity: sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + source-map-support@0.5.21: + resolution: {integrity: sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==} + + source-map@0.6.1: + resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} + engines: {node: '>=0.10.0'} + + space-separated-tokens@2.0.2: + resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==} + + speakingurl@14.0.1: + resolution: {integrity: sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==} + engines: {node: '>=0.10.0'} + + split2@4.2.0: + resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} + engines: {node: '>= 10.x'} + + sprintf-js@1.1.3: + resolution: {integrity: sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==} + + sql-escaper@1.3.3: + resolution: {integrity: sha512-BsTCV265VpTp8tm1wyIm1xqQCS+Q9NHx2Sr+WcnUrgLrQ6yiDIvHYJV5gHxsj1lMBy2zm5twLaZao8Jd+S8JJw==} + engines: {bun: '>=1.0.0', deno: '>=2.0.0', node: '>=12.0.0'} + + ssri@12.0.0: + resolution: {integrity: sha512-S7iGNosepx9RadX82oimUkvr0Ct7IjJbEbs4mJcTxst8um95J3sDYU1RBEOvdu6oL1Wek2ODI5i4MAw+dZ6cAQ==} + engines: {node: ^18.17.0 || >=20.5.0} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + stat-mode@1.0.0: + resolution: {integrity: sha512-jH9EhtKIjuXZ2cWxmXS8ZP80XyC3iasQxMDV8jzhNJpfDb7VbQLVW4Wvsxz9QZvzV+G4YoSfBUVKDOyxLzi/sg==} + engines: {node: '>= 6'} + + statuses@2.0.2: + resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} + engines: {node: '>= 0.8'} + + std-env@3.10.0: + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + + stream-source@0.3.5: + resolution: {integrity: sha512-ZuEDP9sgjiAwUVoDModftG0JtYiLUV8K4ljYD1VyUMRWtbVf92474o4kuuul43iZ8t/hRuiDAx1dIJSvirrK/g==} + + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + string-width@5.1.2: + resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} + engines: {node: '>=12'} + + string_decoder@0.10.31: + resolution: {integrity: sha512-ev2QzSzWPYmy9GuqfIVildA4OdcGLeFZQrq5ys6RtiuF+RQQiZWr8TZNyAcuVXyQRYfEO+MsoB/1BuQVhOJuoQ==} + + string_decoder@1.3.0: + resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} + + stringify-entities@4.0.4: + resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + strip-ansi@7.2.0: + resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} + engines: {node: '>=12'} + + strip-json-comments@2.0.1: + resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==} + engines: {node: '>=0.10.0'} + + stylis@4.3.6: + resolution: {integrity: sha512-yQ3rwFWRfwNUY7H5vpU0wfdkNSnvnJinhF9830Swlaxl03zsOjCfmX0ugac+3LtK0lYSgwL/KXc8oYL3mG4YFQ==} + + sumchecker@3.0.1: + resolution: {integrity: sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg==} + engines: {node: '>= 8.0'} + + superjson@2.2.6: + resolution: {integrity: sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA==} + engines: {node: '>=16'} + + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + + supports-color@8.1.1: + resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==} + engines: {node: '>=10'} + + symbol-tree@3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + + tabbable@6.4.0: + resolution: {integrity: sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==} + + tailwindcss@4.2.1: + resolution: {integrity: sha512-/tBrSQ36vCleJkAOsy9kbNTgaxvGbyOamC30PRePTQe/o1MFwEKHQk4Cn7BNGaPtjp+PuUrByJehM1hgxfq4sw==} + + tailwindcss@4.2.2: + resolution: {integrity: sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==} + + tapable@2.3.0: + resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==} + engines: {node: '>=6'} + + tar-fs@2.1.4: + resolution: {integrity: sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==} + + tar-stream@2.2.0: + resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==} + engines: {node: '>=6'} + + tar@7.5.11: + resolution: {integrity: sha512-ChjMH33/KetonMTAtpYdgUFr0tbz69Fp2v7zWxQfYZX4g5ZN2nOBXm1R2xyA+lMIKrLKIoKAwFj93jE/avX9cQ==} + engines: {node: '>=18'} + + temp-file@3.4.0: + resolution: {integrity: sha512-C5tjlC/HCtVUOi3KWVokd4vHVViOmGjtLwIh4MuzPo/nMYTV/p1urt3RnMz2IWXDdKEGJH3k5+KPxtqRsUYGtg==} + + temp@0.9.4: + resolution: {integrity: sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==} + engines: {node: '>=6.0.0'} + + text-encoding@0.6.4: + resolution: {integrity: sha512-hJnc6Qg3dWoOMkqP53F0dzRIgtmsAge09kxUIqGrEUS4qr5rWLckGYaQAVr+opBrIMRErGgy6f5aPnyPpyGRfg==} + deprecated: no longer maintained + + thread-stream@4.0.0: + resolution: {integrity: sha512-4iMVL6HAINXWf1ZKZjIPcz5wYaOdPhtO8ATvZ+Xqp3BTdaqtAwQkNmKORqcIo5YkQqGXq5cwfswDwMqqQNrpJA==} + engines: {node: '>=20'} + + tiny-async-pool@1.3.0: + resolution: {integrity: sha512-01EAw5EDrcVrdgyCLgoSPvqznC0sVxDSVeiOz09FUpjh71G79VCqneOr+xvt7T1r76CF6ZZfPjHorN2+d+3mqA==} + + tiny-typed-emitter@2.1.0: + resolution: {integrity: sha512-qVtvMxeXbVej0cQWKqVSSAHmKZEHAvxdF8HEUBFWts8h+xEo5m/lEiPakuyZ3BnCBjOD8i24kzNOiOLLgsSxhA==} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@0.3.2: + resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + + tinyexec@1.0.4: + resolution: {integrity: sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw==} + engines: {node: '>=18'} + + tinyglobby@0.2.15: + resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} + engines: {node: '>=12.0.0'} + + tinypool@1.1.1: + resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} + engines: {node: ^18.0.0 || >=20.0.0} + + tinyrainbow@1.2.0: + resolution: {integrity: sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==} + engines: {node: '>=14.0.0'} + + tinyspy@3.0.2: + resolution: {integrity: sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==} + engines: {node: '>=14.0.0'} + + tldts-core@7.0.26: + resolution: {integrity: sha512-5WJ2SqFsv4G2Dwi7ZFVRnz6b2H1od39QME1lc2y5Ew3eWiZMAeqOAfWpRP9jHvhUl881406QtZTODvjttJs+ew==} + + tldts@7.0.26: + resolution: {integrity: sha512-WiGwQjr0qYdNNG8KpMKlSvpxz652lqa3Rd+/hSaDcY4Uo6SKWZq2LAF+hsAhUewTtYhXlorBKgNF3Kk8hnjGoQ==} + hasBin: true + + tmp-promise@3.0.3: + resolution: {integrity: sha512-RwM7MoPojPxsOBYnyd2hy0bxtIlVrihNs9pj5SUvY8Zz1sQcQG2tG1hSr8PDxfgEB8RNKDhqbIlroIarSNDNsQ==} + + tmp@0.2.5: + resolution: {integrity: sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==} + engines: {node: '>=14.14'} + + toad-cache@3.7.0: + resolution: {integrity: sha512-/m8M+2BJUpoJdgAHoG+baCwBT+tf2VraSfkBgl0Y00qIWt41DJ8R5B8nsEw0I58YwF5IZH6z24/2TobDKnqSWw==} + engines: {node: '>=12'} + + toidentifier@1.0.1: + resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} + engines: {node: '>=0.6'} + + topojson-client@3.1.0: + resolution: {integrity: sha512-605uxS6bcYxGXw9qi62XyrV6Q3xwbndjachmNxu8HWTtVPxZfEJN9fd/SZS1Q54Sn2y0TMyMxFj/cJINqGHrKw==} + hasBin: true + + topojson-server@3.0.1: + resolution: {integrity: sha512-/VS9j/ffKr2XAOjlZ9CgyyeLmgJ9dMwq6Y0YEON8O7p/tGGk+dCWnrE03zEdu7i4L7YsFZLEPZPzCvcB7lEEXw==} + hasBin: true + + tough-cookie@6.0.1: + resolution: {integrity: sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==} + engines: {node: '>=16'} + + tr46@6.0.0: + resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==} + engines: {node: '>=20'} + + tree-kill@1.2.2: + resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} + hasBin: true + + trim-lines@3.0.1: + resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==} + + truncate-utf8-bytes@1.0.2: + resolution: {integrity: sha512-95Pu1QXQvruGEhv62XCMO3Mm90GscOCClvrIUwCM0PYOXK3kaF3l3sIHxx71ThJfcbM2O5Au6SO3AWCSEfW4mQ==} + + ts-dedent@2.2.0: + resolution: {integrity: sha512-q5W7tVM71e2xjHZTlgfTDoPF/SmqKG5hddq9SzR49CH2hayqRKJtQ4mtRlSxKaJlR/+9rEM+mnBHf7I2/BQcpQ==} + engines: {node: '>=6.10'} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + tsx@4.21.0: + resolution: {integrity: sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==} + engines: {node: '>=18.0.0'} + hasBin: true + + tunnel-agent@0.6.0: + resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==} + + type-fest@0.13.1: + resolution: {integrity: sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==} + engines: {node: '>=10'} + + typedarray@0.0.6: + resolution: {integrity: sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==} + + typedarray@0.0.7: + resolution: {integrity: sha512-ueeb9YybpjhivjbHP2LdFDAjbS948fGEPj+ACAMs4xCMmh72OCOMQWBQKlaN4ZNQ04yfLSDLSx1tGRIoWimObQ==} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + ufo@1.6.3: + resolution: {integrity: sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==} + + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + + undici-types@7.16.0: + resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} + + undici@6.24.1: + resolution: {integrity: sha512-sC+b0tB1whOCzbtlx20fx3WgCXwkW627p4EA9uM+/tNNPkSS+eSEld6pAs9nDv7WbY1UUljBMYPtu9BCOrCWKA==} + engines: {node: '>=18.17'} + + undici@7.24.5: + resolution: {integrity: sha512-3IWdCpjgxp15CbJnsi/Y9TCDE7HWVN19j1hmzVhoAkY/+CJx449tVxT5wZc1Gwg8J+P0LWvzlBzxYRnHJ+1i7Q==} + engines: {node: '>=20.18.1'} + + unique-filename@4.0.0: + resolution: {integrity: sha512-XSnEewXmQ+veP7xX2dS5Q4yZAvO40cBN2MWkJ7D/6sW4Dg6wYBNwM1Vrnz1FhH5AdeLIlUXRI9e28z1YZi71NQ==} + engines: {node: ^18.17.0 || >=20.5.0} + + unique-slug@5.0.0: + resolution: {integrity: sha512-9OdaqO5kwqR+1kVgHAhsp5vPNU0hnxRa26rBFNfNgM7M6pNtgzeBn3s/xbyCQL3dcjzOatcef6UUHpB/6MaETg==} + engines: {node: ^18.17.0 || >=20.5.0} + + unist-util-is@6.0.1: + resolution: {integrity: sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==} + + unist-util-position@5.0.0: + resolution: {integrity: sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==} + + unist-util-stringify-position@4.0.0: + resolution: {integrity: sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==} + + unist-util-visit-parents@6.0.2: + resolution: {integrity: sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==} + + unist-util-visit@5.1.0: + resolution: {integrity: sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==} + + universalify@0.1.2: + resolution: {integrity: sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==} + engines: {node: '>= 4.0.0'} + + universalify@2.0.1: + resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} + engines: {node: '>= 10.0.0'} + + update-browserslist-db@1.2.3: + resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + + uri-js@4.4.1: + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + + utf8-byte-length@1.0.5: + resolution: {integrity: sha512-Xn0w3MtiQ6zoz2vFyUVruaCL53O/DwUvkEeOvj+uulMm0BkUGYWmBYVyElqZaSLhY6ZD0ulfU3aBra2aVT4xfA==} + + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + + uuid@11.1.0: + resolution: {integrity: sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==} + hasBin: true + + verror@1.10.1: + resolution: {integrity: sha512-veufcmxri4e3XSrT0xwfUR7kguIkaxBeosDg00yDWhk49wdwkSUrvvsm7nc75e1PUyvIeZj6nS8VQRYz2/S4Xg==} + engines: {node: '>=0.6.0'} + + vfile-message@4.0.3: + resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==} + + vfile@6.0.3: + resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} + + vite-node@2.1.9: + resolution: {integrity: sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + + vite@5.4.21: + resolution: {integrity: sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@types/node': ^18.0.0 || >=20.0.0 + less: '*' + lightningcss: ^1.21.0 + sass: '*' + sass-embedded: '*' + stylus: '*' + sugarss: '*' + terser: ^5.4.0 + peerDependenciesMeta: + '@types/node': + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + + vite@6.4.1: + resolution: {integrity: sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + peerDependencies: + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + jiti: '>=1.21.0' + less: '*' + lightningcss: ^1.21.0 + sass: '*' + sass-embedded: '*' + stylus: '*' + sugarss: '*' + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitepress-plugin-mermaid@2.0.17: + resolution: {integrity: sha512-IUzYpwf61GC6k0XzfmAmNrLvMi9TRrVRMsUyCA8KNXhg/mQ1VqWnO0/tBVPiX5UoKF1mDUwqn5QV4qAJl6JnUg==} + peerDependencies: + mermaid: 10 || 11 + vitepress: ^1.0.0 || ^1.0.0-alpha + + vitepress@1.6.4: + resolution: {integrity: sha512-+2ym1/+0VVrbhNyRoFFesVvBvHAVMZMK0rw60E3X/5349M1GuVdKeazuksqopEdvkKwKGs21Q729jX81/bkBJg==} + hasBin: true + peerDependencies: + markdown-it-mathjax3: ^4 + postcss: ^8 + peerDependenciesMeta: + markdown-it-mathjax3: + optional: true + postcss: + optional: true + + vitest@2.1.9: + resolution: {integrity: sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@types/node': ^18.0.0 || >=20.0.0 + '@vitest/browser': 2.1.9 + '@vitest/ui': 2.1.9 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@types/node': + optional: true + '@vitest/browser': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + vscode-jsonrpc@8.2.0: + resolution: {integrity: sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA==} + engines: {node: '>=14.0.0'} + + vscode-languageserver-protocol@3.17.5: + resolution: {integrity: sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg==} + + vscode-languageserver-textdocument@1.0.12: + resolution: {integrity: sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA==} + + vscode-languageserver-types@3.17.5: + resolution: {integrity: sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==} + + vscode-languageserver@9.0.1: + resolution: {integrity: sha512-woByF3PDpkHFUreUa7Hos7+pUWdeWMXRd26+ZX2A8cFx6v/JPTtd4/uN0/jB6XQHYaOlHbio03NTHCqrgG5n7g==} + hasBin: true + + vscode-uri@3.1.0: + resolution: {integrity: sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==} + + vue@3.5.30: + resolution: {integrity: sha512-hTHLc6VNZyzzEH/l7PFGjpcTvUgiaPK5mdLkbjrTeWSRcEfxFrv56g/XckIYlE9ckuobsdwqd5mk2g1sBkMewg==} + peerDependencies: + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + w3c-xmlserializer@5.0.0: + resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} + engines: {node: '>=18'} + + wait-on@9.0.4: + resolution: {integrity: sha512-k8qrgfwrPVJXTeFY8tl6BxVHiclK11u72DVKhpybHfUL/K6KM4bdyK9EhIVYGytB5MJe/3lq4Tf0hrjM+pvJZQ==} + engines: {node: '>=20.0.0'} + hasBin: true + + wcwidth@1.0.1: + resolution: {integrity: sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==} + + webidl-conversions@8.0.1: + resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==} + engines: {node: '>=20'} + + whatwg-mimetype@5.0.0: + resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==} + engines: {node: '>=20'} + + whatwg-url@16.0.1: + resolution: {integrity: sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + which@5.0.0: + resolution: {integrity: sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ==} + engines: {node: ^18.17.0 || >=20.5.0} + hasBin: true + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + + wrap-ansi@8.1.0: + resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} + engines: {node: '>=12'} + + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + + ws@8.19.0: + resolution: {integrity: sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + xml-name-validator@5.0.0: + resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} + engines: {node: '>=18'} + + xmlbuilder@15.1.1: + resolution: {integrity: sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==} + engines: {node: '>=8.0'} + + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + + xtend@4.0.2: + resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} + engines: {node: '>=0.4'} + + y18n@5.0.8: + resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} + engines: {node: '>=10'} + + yallist@3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + + yallist@4.0.0: + resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==} + + yallist@5.0.0: + resolution: {integrity: sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==} + engines: {node: '>=18'} + + yargs-parser@21.1.1: + resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} + engines: {node: '>=12'} + + yargs@17.7.2: + resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} + engines: {node: '>=12'} + + yauzl@2.10.0: + resolution: {integrity: sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==} + + yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + + zwitch@2.0.4: + resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} + +snapshots: + + 7zip-bin@5.2.0: {} + + '@algolia/abtesting@1.15.1': + dependencies: + '@algolia/client-common': 5.49.1 + '@algolia/requester-browser-xhr': 5.49.1 + '@algolia/requester-fetch': 5.49.1 + '@algolia/requester-node-http': 5.49.1 + + '@algolia/autocomplete-core@1.17.7(@algolia/client-search@5.49.1)(algoliasearch@5.49.1)(search-insights@2.17.3)': + dependencies: + '@algolia/autocomplete-plugin-algolia-insights': 1.17.7(@algolia/client-search@5.49.1)(algoliasearch@5.49.1)(search-insights@2.17.3) + '@algolia/autocomplete-shared': 1.17.7(@algolia/client-search@5.49.1)(algoliasearch@5.49.1) + transitivePeerDependencies: + - '@algolia/client-search' + - algoliasearch + - search-insights + + '@algolia/autocomplete-plugin-algolia-insights@1.17.7(@algolia/client-search@5.49.1)(algoliasearch@5.49.1)(search-insights@2.17.3)': + dependencies: + '@algolia/autocomplete-shared': 1.17.7(@algolia/client-search@5.49.1)(algoliasearch@5.49.1) + search-insights: 2.17.3 + transitivePeerDependencies: + - '@algolia/client-search' + - algoliasearch + + '@algolia/autocomplete-preset-algolia@1.17.7(@algolia/client-search@5.49.1)(algoliasearch@5.49.1)': + dependencies: + '@algolia/autocomplete-shared': 1.17.7(@algolia/client-search@5.49.1)(algoliasearch@5.49.1) + '@algolia/client-search': 5.49.1 + algoliasearch: 5.49.1 + + '@algolia/autocomplete-shared@1.17.7(@algolia/client-search@5.49.1)(algoliasearch@5.49.1)': + dependencies: + '@algolia/client-search': 5.49.1 + algoliasearch: 5.49.1 + + '@algolia/client-abtesting@5.49.1': + dependencies: + '@algolia/client-common': 5.49.1 + '@algolia/requester-browser-xhr': 5.49.1 + '@algolia/requester-fetch': 5.49.1 + '@algolia/requester-node-http': 5.49.1 + + '@algolia/client-analytics@5.49.1': + dependencies: + '@algolia/client-common': 5.49.1 + '@algolia/requester-browser-xhr': 5.49.1 + '@algolia/requester-fetch': 5.49.1 + '@algolia/requester-node-http': 5.49.1 + + '@algolia/client-common@5.49.1': {} + + '@algolia/client-insights@5.49.1': + dependencies: + '@algolia/client-common': 5.49.1 + '@algolia/requester-browser-xhr': 5.49.1 + '@algolia/requester-fetch': 5.49.1 + '@algolia/requester-node-http': 5.49.1 + + '@algolia/client-personalization@5.49.1': + dependencies: + '@algolia/client-common': 5.49.1 + '@algolia/requester-browser-xhr': 5.49.1 + '@algolia/requester-fetch': 5.49.1 + '@algolia/requester-node-http': 5.49.1 + + '@algolia/client-query-suggestions@5.49.1': + dependencies: + '@algolia/client-common': 5.49.1 + '@algolia/requester-browser-xhr': 5.49.1 + '@algolia/requester-fetch': 5.49.1 + '@algolia/requester-node-http': 5.49.1 + + '@algolia/client-search@5.49.1': + dependencies: + '@algolia/client-common': 5.49.1 + '@algolia/requester-browser-xhr': 5.49.1 + '@algolia/requester-fetch': 5.49.1 + '@algolia/requester-node-http': 5.49.1 + + '@algolia/ingestion@1.49.1': + dependencies: + '@algolia/client-common': 5.49.1 + '@algolia/requester-browser-xhr': 5.49.1 + '@algolia/requester-fetch': 5.49.1 + '@algolia/requester-node-http': 5.49.1 + + '@algolia/monitoring@1.49.1': + dependencies: + '@algolia/client-common': 5.49.1 + '@algolia/requester-browser-xhr': 5.49.1 + '@algolia/requester-fetch': 5.49.1 + '@algolia/requester-node-http': 5.49.1 + + '@algolia/recommend@5.49.1': + dependencies: + '@algolia/client-common': 5.49.1 + '@algolia/requester-browser-xhr': 5.49.1 + '@algolia/requester-fetch': 5.49.1 + '@algolia/requester-node-http': 5.49.1 + + '@algolia/requester-browser-xhr@5.49.1': + dependencies: + '@algolia/client-common': 5.49.1 + + '@algolia/requester-fetch@5.49.1': + dependencies: + '@algolia/client-common': 5.49.1 + + '@algolia/requester-node-http@5.49.1': + dependencies: + '@algolia/client-common': 5.49.1 + + '@antfu/install-pkg@1.1.0': + dependencies: + package-manager-detector: 1.6.0 + tinyexec: 1.0.4 + + '@asamuzakjp/css-color@5.0.1': + dependencies: + '@csstools/css-calc': 3.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-color-parser': 4.0.2(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + lru-cache: 11.2.7 + + '@asamuzakjp/dom-selector@7.0.3': + dependencies: + '@asamuzakjp/nwsapi': 2.3.9 + bidi-js: 1.0.3 + css-tree: 3.2.1 + is-potential-custom-element-name: 1.0.1 + lru-cache: 11.2.7 + + '@asamuzakjp/nwsapi@2.3.9': {} + + '@babel/code-frame@7.29.0': + dependencies: + '@babel/helper-validator-identifier': 7.28.5 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/compat-data@7.29.0': {} + + '@babel/core@7.29.0': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/generator': 7.29.1 + '@babel/helper-compilation-targets': 7.28.6 + '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0) + '@babel/helpers': 7.28.6 + '@babel/parser': 7.29.0 + '@babel/template': 7.28.6 + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + '@jridgewell/remapping': 2.3.5 + convert-source-map: 2.0.0 + debug: 4.4.3 + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/generator@7.29.1': + dependencies: + '@babel/parser': 7.29.0 + '@babel/types': 7.29.0 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + + '@babel/helper-compilation-targets@7.28.6': + dependencies: + '@babel/compat-data': 7.29.0 + '@babel/helper-validator-option': 7.27.1 + browserslist: 4.28.1 + lru-cache: 5.1.1 + semver: 6.3.1 + + '@babel/helper-globals@7.28.0': {} + + '@babel/helper-module-imports@7.28.6': + dependencies: + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-transforms@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-module-imports': 7.28.6 + '@babel/helper-validator-identifier': 7.28.5 + '@babel/traverse': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/helper-plugin-utils@7.28.6': {} + + '@babel/helper-string-parser@7.27.1': {} + + '@babel/helper-validator-identifier@7.28.5': {} + + '@babel/helper-validator-option@7.27.1': {} + + '@babel/helpers@7.28.6': + dependencies: + '@babel/template': 7.28.6 + '@babel/types': 7.29.0 + + '@babel/parser@7.29.0': + dependencies: + '@babel/types': 7.29.0 + + '@babel/plugin-transform-react-jsx-self@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-react-jsx-source@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/template@7.28.6': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/parser': 7.29.0 + '@babel/types': 7.29.0 + + '@babel/traverse@7.29.0': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/generator': 7.29.1 + '@babel/helper-globals': 7.28.0 + '@babel/parser': 7.29.0 + '@babel/template': 7.28.6 + '@babel/types': 7.29.0 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + '@babel/types@7.29.0': + dependencies: + '@babel/helper-string-parser': 7.27.1 + '@babel/helper-validator-identifier': 7.28.5 + + '@braintree/sanitize-url@6.0.4': + optional: true + + '@braintree/sanitize-url@7.1.2': {} + + '@bramus/specificity@2.4.2': + dependencies: + css-tree: 3.2.1 + + '@chevrotain/cst-dts-gen@11.1.2': + dependencies: + '@chevrotain/gast': 11.1.2 + '@chevrotain/types': 11.1.2 + lodash-es: 4.17.23 + + '@chevrotain/gast@11.1.2': + dependencies: + '@chevrotain/types': 11.1.2 + lodash-es: 4.17.23 + + '@chevrotain/regexp-to-ast@11.1.2': {} + + '@chevrotain/types@11.1.2': {} + + '@chevrotain/utils@11.1.2': {} + + '@csstools/color-helpers@6.0.2': {} + + '@csstools/css-calc@3.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-color-parser@4.0.2(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/color-helpers': 6.0.2 + '@csstools/css-calc': 3.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.1(css-tree@3.2.1)': + optionalDependencies: + css-tree: 3.2.1 + + '@csstools/css-tokenizer@4.0.0': {} + + '@develar/schema-utils@2.6.5': + dependencies: + ajv: 6.14.0 + ajv-keywords: 3.5.2(ajv@6.14.0) + + '@dnd-kit/accessibility@3.1.1(react@18.3.1)': + dependencies: + react: 18.3.1 + tslib: 2.8.1 + + '@dnd-kit/core@6.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@dnd-kit/accessibility': 3.1.1(react@18.3.1) + '@dnd-kit/utilities': 3.2.2(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + tslib: 2.8.1 + + '@dnd-kit/sortable@10.0.0(@dnd-kit/core@6.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)': + dependencies: + '@dnd-kit/core': 6.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@dnd-kit/utilities': 3.2.2(react@18.3.1) + react: 18.3.1 + tslib: 2.8.1 + + '@dnd-kit/utilities@3.2.2(react@18.3.1)': + dependencies: + react: 18.3.1 + tslib: 2.8.1 + + '@docsearch/css@3.8.2': {} + + '@docsearch/js@3.8.2(@algolia/client-search@5.49.1)(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(search-insights@2.17.3)': + dependencies: + '@docsearch/react': 3.8.2(@algolia/client-search@5.49.1)(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(search-insights@2.17.3) + preact: 10.28.4 + transitivePeerDependencies: + - '@algolia/client-search' + - '@types/react' + - react + - react-dom + - search-insights + + '@docsearch/react@3.8.2(@algolia/client-search@5.49.1)(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(search-insights@2.17.3)': + dependencies: + '@algolia/autocomplete-core': 1.17.7(@algolia/client-search@5.49.1)(algoliasearch@5.49.1)(search-insights@2.17.3) + '@algolia/autocomplete-preset-algolia': 1.17.7(@algolia/client-search@5.49.1)(algoliasearch@5.49.1) + '@docsearch/css': 3.8.2 + algoliasearch: 5.49.1 + optionalDependencies: + '@types/react': 18.3.28 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + search-insights: 2.17.3 + transitivePeerDependencies: + - '@algolia/client-search' + + '@drizzle-team/brocli@0.10.2': {} + + '@electron/asar@3.4.1': + dependencies: + commander: 5.1.0 + glob: 7.2.3 + minimatch: 3.1.5 + + '@electron/fuses@1.8.0': + dependencies: + chalk: 4.1.2 + fs-extra: 9.1.0 + minimist: 1.2.8 + + '@electron/get@2.0.3': + dependencies: + debug: 4.4.3 + env-paths: 2.2.1 + fs-extra: 8.1.0 + got: 11.8.6 + progress: 2.0.3 + semver: 6.3.1 + sumchecker: 3.0.1 + optionalDependencies: + global-agent: 3.0.0 + transitivePeerDependencies: + - supports-color + + '@electron/get@3.1.0': + dependencies: + debug: 4.4.3 + env-paths: 2.2.1 + fs-extra: 8.1.0 + got: 11.8.6 + progress: 2.0.3 + semver: 6.3.1 + sumchecker: 3.0.1 + optionalDependencies: + global-agent: 3.0.0 + transitivePeerDependencies: + - supports-color + + '@electron/notarize@2.5.0': + dependencies: + debug: 4.4.3 + fs-extra: 9.1.0 + promise-retry: 2.0.1 + transitivePeerDependencies: + - supports-color + + '@electron/notarize@3.1.1': + dependencies: + debug: 4.4.3 + promise-retry: 2.0.1 + transitivePeerDependencies: + - supports-color + + '@electron/osx-sign@1.3.3': + dependencies: + compare-version: 0.1.2 + debug: 4.4.3 + fs-extra: 10.1.0 + isbinaryfile: 4.0.10 + minimist: 1.2.8 + plist: 3.1.0 + transitivePeerDependencies: + - supports-color + + '@electron/rebuild@4.0.3': + dependencies: + '@malept/cross-spawn-promise': 2.0.0 + debug: 4.4.3 + detect-libc: 2.1.2 + got: 11.8.6 + graceful-fs: 4.2.11 + node-abi: 4.26.0 + node-api-version: 0.2.1 + node-gyp: 11.5.0 + ora: 5.4.1 + read-binary-file-arch: 1.0.6 + semver: 7.7.4 + tar: 7.5.11 + yargs: 17.7.2 + transitivePeerDependencies: + - supports-color + + '@electron/universal@2.0.3': + dependencies: + '@electron/asar': 3.4.1 + '@malept/cross-spawn-promise': 2.0.0 + debug: 4.4.3 + dir-compare: 4.2.0 + fs-extra: 11.3.4 + minimatch: 9.0.9 + plist: 3.1.0 + transitivePeerDependencies: + - supports-color + + '@electron/windows-sign@1.2.2': + dependencies: + cross-dirname: 0.1.0 + debug: 4.4.3 + fs-extra: 11.3.4 + minimist: 1.2.8 + postject: 1.0.0-alpha.6 + transitivePeerDependencies: + - supports-color + optional: true + + '@emnapi/runtime@1.8.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@esbuild-kit/core-utils@3.3.2': + dependencies: + esbuild: 0.18.20 + source-map-support: 0.5.21 + + '@esbuild-kit/esm-loader@2.6.5': + dependencies: + '@esbuild-kit/core-utils': 3.3.2 + get-tsconfig: 4.13.6 + + '@esbuild/aix-ppc64@0.21.5': + optional: true + + '@esbuild/aix-ppc64@0.25.12': + optional: true + + '@esbuild/aix-ppc64@0.27.3': + optional: true + + '@esbuild/android-arm64@0.18.20': + optional: true + + '@esbuild/android-arm64@0.21.5': + optional: true + + '@esbuild/android-arm64@0.25.12': + optional: true + + '@esbuild/android-arm64@0.27.3': + optional: true + + '@esbuild/android-arm@0.18.20': + optional: true + + '@esbuild/android-arm@0.21.5': + optional: true + + '@esbuild/android-arm@0.25.12': + optional: true + + '@esbuild/android-arm@0.27.3': + optional: true + + '@esbuild/android-x64@0.18.20': + optional: true + + '@esbuild/android-x64@0.21.5': + optional: true + + '@esbuild/android-x64@0.25.12': + optional: true + + '@esbuild/android-x64@0.27.3': + optional: true + + '@esbuild/darwin-arm64@0.18.20': + optional: true + + '@esbuild/darwin-arm64@0.21.5': + optional: true + + '@esbuild/darwin-arm64@0.25.12': + optional: true + + '@esbuild/darwin-arm64@0.27.3': + optional: true + + '@esbuild/darwin-x64@0.18.20': + optional: true + + '@esbuild/darwin-x64@0.21.5': + optional: true + + '@esbuild/darwin-x64@0.25.12': + optional: true + + '@esbuild/darwin-x64@0.27.3': + optional: true + + '@esbuild/freebsd-arm64@0.18.20': + optional: true + + '@esbuild/freebsd-arm64@0.21.5': + optional: true + + '@esbuild/freebsd-arm64@0.25.12': + optional: true + + '@esbuild/freebsd-arm64@0.27.3': + optional: true + + '@esbuild/freebsd-x64@0.18.20': + optional: true + + '@esbuild/freebsd-x64@0.21.5': + optional: true + + '@esbuild/freebsd-x64@0.25.12': + optional: true + + '@esbuild/freebsd-x64@0.27.3': + optional: true + + '@esbuild/linux-arm64@0.18.20': + optional: true + + '@esbuild/linux-arm64@0.21.5': + optional: true + + '@esbuild/linux-arm64@0.25.12': + optional: true + + '@esbuild/linux-arm64@0.27.3': + optional: true + + '@esbuild/linux-arm@0.18.20': + optional: true + + '@esbuild/linux-arm@0.21.5': + optional: true + + '@esbuild/linux-arm@0.25.12': + optional: true + + '@esbuild/linux-arm@0.27.3': + optional: true + + '@esbuild/linux-ia32@0.18.20': + optional: true + + '@esbuild/linux-ia32@0.21.5': + optional: true + + '@esbuild/linux-ia32@0.25.12': + optional: true + + '@esbuild/linux-ia32@0.27.3': + optional: true + + '@esbuild/linux-loong64@0.18.20': + optional: true + + '@esbuild/linux-loong64@0.21.5': + optional: true + + '@esbuild/linux-loong64@0.25.12': + optional: true + + '@esbuild/linux-loong64@0.27.3': + optional: true + + '@esbuild/linux-mips64el@0.18.20': + optional: true + + '@esbuild/linux-mips64el@0.21.5': + optional: true + + '@esbuild/linux-mips64el@0.25.12': + optional: true + + '@esbuild/linux-mips64el@0.27.3': + optional: true + + '@esbuild/linux-ppc64@0.18.20': + optional: true + + '@esbuild/linux-ppc64@0.21.5': + optional: true + + '@esbuild/linux-ppc64@0.25.12': + optional: true + + '@esbuild/linux-ppc64@0.27.3': + optional: true + + '@esbuild/linux-riscv64@0.18.20': + optional: true + + '@esbuild/linux-riscv64@0.21.5': + optional: true + + '@esbuild/linux-riscv64@0.25.12': + optional: true + + '@esbuild/linux-riscv64@0.27.3': + optional: true + + '@esbuild/linux-s390x@0.18.20': + optional: true + + '@esbuild/linux-s390x@0.21.5': + optional: true + + '@esbuild/linux-s390x@0.25.12': + optional: true + + '@esbuild/linux-s390x@0.27.3': + optional: true + + '@esbuild/linux-x64@0.18.20': + optional: true + + '@esbuild/linux-x64@0.21.5': + optional: true + + '@esbuild/linux-x64@0.25.12': + optional: true + + '@esbuild/linux-x64@0.27.3': + optional: true + + '@esbuild/netbsd-arm64@0.25.12': + optional: true + + '@esbuild/netbsd-arm64@0.27.3': + optional: true + + '@esbuild/netbsd-x64@0.18.20': + optional: true + + '@esbuild/netbsd-x64@0.21.5': + optional: true + + '@esbuild/netbsd-x64@0.25.12': + optional: true + + '@esbuild/netbsd-x64@0.27.3': + optional: true + + '@esbuild/openbsd-arm64@0.25.12': + optional: true + + '@esbuild/openbsd-arm64@0.27.3': + optional: true + + '@esbuild/openbsd-x64@0.18.20': + optional: true + + '@esbuild/openbsd-x64@0.21.5': + optional: true + + '@esbuild/openbsd-x64@0.25.12': + optional: true + + '@esbuild/openbsd-x64@0.27.3': + optional: true + + '@esbuild/openharmony-arm64@0.25.12': + optional: true + + '@esbuild/openharmony-arm64@0.27.3': + optional: true + + '@esbuild/sunos-x64@0.18.20': + optional: true + + '@esbuild/sunos-x64@0.21.5': + optional: true + + '@esbuild/sunos-x64@0.25.12': + optional: true + + '@esbuild/sunos-x64@0.27.3': + optional: true + + '@esbuild/win32-arm64@0.18.20': + optional: true + + '@esbuild/win32-arm64@0.21.5': + optional: true + + '@esbuild/win32-arm64@0.25.12': + optional: true + + '@esbuild/win32-arm64@0.27.3': + optional: true + + '@esbuild/win32-ia32@0.18.20': + optional: true + + '@esbuild/win32-ia32@0.21.5': + optional: true + + '@esbuild/win32-ia32@0.25.12': + optional: true + + '@esbuild/win32-ia32@0.27.3': + optional: true + + '@esbuild/win32-x64@0.18.20': + optional: true + + '@esbuild/win32-x64@0.21.5': + optional: true + + '@esbuild/win32-x64@0.25.12': + optional: true + + '@esbuild/win32-x64@0.27.3': + optional: true + + '@exodus/bytes@1.15.0': {} + + '@fastify/accept-negotiator@2.0.1': {} + + '@fastify/ajv-compiler@4.0.5': + dependencies: + ajv: 8.18.0 + ajv-formats: 3.0.1(ajv@8.18.0) + fast-uri: 3.1.0 + + '@fastify/cors@11.2.0': + dependencies: + fastify-plugin: 5.1.0 + toad-cache: 3.7.0 + + '@fastify/error@4.2.0': {} + + '@fastify/fast-json-stringify-compiler@5.0.3': + dependencies: + fast-json-stringify: 6.3.0 + + '@fastify/forwarded@3.0.1': {} + + '@fastify/merge-json-schemas@0.2.1': + dependencies: + dequal: 2.0.3 + + '@fastify/proxy-addr@5.1.0': + dependencies: + '@fastify/forwarded': 3.0.1 + ipaddr.js: 2.3.0 + + '@fastify/send@4.1.0': + dependencies: + '@lukeed/ms': 2.0.2 + escape-html: 1.0.3 + fast-decode-uri-component: 1.0.1 + http-errors: 2.0.1 + mime: 3.0.0 + + '@fastify/static@9.0.0': + dependencies: + '@fastify/accept-negotiator': 2.0.1 + '@fastify/send': 4.1.0 + content-disposition: 1.0.1 + fastify-plugin: 5.1.0 + fastq: 1.20.1 + glob: 13.0.6 + + '@hapi/address@5.1.1': + dependencies: + '@hapi/hoek': 11.0.7 + + '@hapi/formula@3.0.2': {} + + '@hapi/hoek@11.0.7': {} + + '@hapi/pinpoint@2.0.1': {} + + '@hapi/tlds@1.1.6': {} + + '@hapi/topo@6.0.2': + dependencies: + '@hapi/hoek': 11.0.7 + + '@iconify-json/simple-icons@1.2.72': + dependencies: + '@iconify/types': 2.0.0 + + '@iconify/types@2.0.0': {} + + '@iconify/utils@3.1.0': + dependencies: + '@antfu/install-pkg': 1.1.0 + '@iconify/types': 2.0.0 + mlly: 1.8.2 + + '@img/colour@1.1.0': {} + + '@img/sharp-darwin-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-darwin-arm64': 1.2.4 + optional: true + + '@img/sharp-darwin-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-darwin-x64': 1.2.4 + optional: true + + '@img/sharp-libvips-darwin-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-darwin-x64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-arm@1.2.4': + optional: true + + '@img/sharp-libvips-linux-ppc64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-riscv64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-s390x@1.2.4': + optional: true + + '@img/sharp-libvips-linux-x64@1.2.4': + optional: true + + '@img/sharp-libvips-linuxmusl-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-linuxmusl-x64@1.2.4': + optional: true + + '@img/sharp-linux-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm64': 1.2.4 + optional: true + + '@img/sharp-linux-arm@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm': 1.2.4 + optional: true + + '@img/sharp-linux-ppc64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-ppc64': 1.2.4 + optional: true + + '@img/sharp-linux-riscv64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-riscv64': 1.2.4 + optional: true + + '@img/sharp-linux-s390x@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-s390x': 1.2.4 + optional: true + + '@img/sharp-linux-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-x64': 1.2.4 + optional: true + + '@img/sharp-linuxmusl-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 + optional: true + + '@img/sharp-linuxmusl-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-x64': 1.2.4 + optional: true + + '@img/sharp-wasm32@0.34.5': + dependencies: + '@emnapi/runtime': 1.8.1 + optional: true + + '@img/sharp-win32-arm64@0.34.5': + optional: true + + '@img/sharp-win32-ia32@0.34.5': + optional: true + + '@img/sharp-win32-x64@0.34.5': + optional: true + + '@isaacs/cliui@8.0.2': + dependencies: + string-width: 5.1.2 + string-width-cjs: string-width@4.2.3 + strip-ansi: 7.2.0 + strip-ansi-cjs: strip-ansi@6.0.1 + wrap-ansi: 8.1.0 + wrap-ansi-cjs: wrap-ansi@7.0.0 + + '@isaacs/fs-minipass@4.0.1': + dependencies: + minipass: 7.1.3 + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@lukeed/ms@2.0.2': {} + + '@malept/cross-spawn-promise@2.0.0': + dependencies: + cross-spawn: 7.0.6 + + '@malept/flatpak-bundler@0.4.0': + dependencies: + debug: 4.4.3 + fs-extra: 9.1.0 + lodash: 4.17.23 + tmp-promise: 3.0.3 + transitivePeerDependencies: + - supports-color + + '@mermaid-js/mermaid-mindmap@9.3.0': + dependencies: + '@braintree/sanitize-url': 6.0.4 + cytoscape: 3.33.1 + cytoscape-cose-bilkent: 4.1.0(cytoscape@3.33.1) + cytoscape-fcose: 2.2.0(cytoscape@3.33.1) + d3: 7.9.0 + khroma: 2.1.0 + non-layered-tidy-tree-layout: 2.0.2 + optional: true + + '@mermaid-js/parser@1.0.1': + dependencies: + langium: 4.2.1 + + '@npmcli/agent@3.0.0': + dependencies: + agent-base: 7.1.4 + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + lru-cache: 10.4.3 + socks-proxy-agent: 8.0.5 + transitivePeerDependencies: + - supports-color + + '@npmcli/fs@4.0.0': + dependencies: + semver: 7.7.4 + + '@pinojs/redact@0.4.0': {} + + '@pkgjs/parseargs@0.11.0': + optional: true + + '@resvg/resvg-js-android-arm-eabi@2.4.1': + optional: true + + '@resvg/resvg-js-android-arm64@2.4.1': + optional: true + + '@resvg/resvg-js-darwin-arm64@2.4.1': + optional: true + + '@resvg/resvg-js-darwin-x64@2.4.1': + optional: true + + '@resvg/resvg-js-linux-arm-gnueabihf@2.4.1': + optional: true + + '@resvg/resvg-js-linux-arm64-gnu@2.4.1': + optional: true + + '@resvg/resvg-js-linux-arm64-musl@2.4.1': + optional: true + + '@resvg/resvg-js-linux-x64-gnu@2.4.1': + optional: true + + '@resvg/resvg-js-linux-x64-musl@2.4.1': + optional: true + + '@resvg/resvg-js-win32-arm64-msvc@2.4.1': + optional: true + + '@resvg/resvg-js-win32-ia32-msvc@2.4.1': + optional: true + + '@resvg/resvg-js-win32-x64-msvc@2.4.1': + optional: true + + '@resvg/resvg-js@2.4.1': + optionalDependencies: + '@resvg/resvg-js-android-arm-eabi': 2.4.1 + '@resvg/resvg-js-android-arm64': 2.4.1 + '@resvg/resvg-js-darwin-arm64': 2.4.1 + '@resvg/resvg-js-darwin-x64': 2.4.1 + '@resvg/resvg-js-linux-arm-gnueabihf': 2.4.1 + '@resvg/resvg-js-linux-arm64-gnu': 2.4.1 + '@resvg/resvg-js-linux-arm64-musl': 2.4.1 + '@resvg/resvg-js-linux-x64-gnu': 2.4.1 + '@resvg/resvg-js-linux-x64-musl': 2.4.1 + '@resvg/resvg-js-win32-arm64-msvc': 2.4.1 + '@resvg/resvg-js-win32-ia32-msvc': 2.4.1 + '@resvg/resvg-js-win32-x64-msvc': 2.4.1 + + '@rolldown/pluginutils@1.0.0-beta.27': {} + + '@rollup/rollup-android-arm-eabi@4.59.0': + optional: true + + '@rollup/rollup-android-arm64@4.59.0': + optional: true + + '@rollup/rollup-darwin-arm64@4.59.0': + optional: true + + '@rollup/rollup-darwin-x64@4.59.0': + optional: true + + '@rollup/rollup-freebsd-arm64@4.59.0': + optional: true + + '@rollup/rollup-freebsd-x64@4.59.0': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.59.0': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.59.0': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.59.0': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.59.0': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.59.0': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.59.0': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.59.0': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.59.0': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.59.0': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.59.0': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.59.0': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.59.0': + optional: true + + '@rollup/rollup-linux-x64-musl@4.59.0': + optional: true + + '@rollup/rollup-openbsd-x64@4.59.0': + optional: true + + '@rollup/rollup-openharmony-arm64@4.59.0': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.59.0': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.59.0': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.59.0': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.59.0': + optional: true + + '@shikijs/core@2.5.0': + dependencies: + '@shikijs/engine-javascript': 2.5.0 + '@shikijs/engine-oniguruma': 2.5.0 + '@shikijs/types': 2.5.0 + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.4 + hast-util-to-html: 9.0.5 + + '@shikijs/engine-javascript@2.5.0': + dependencies: + '@shikijs/types': 2.5.0 + '@shikijs/vscode-textmate': 10.0.2 + oniguruma-to-es: 3.1.1 + + '@shikijs/engine-oniguruma@2.5.0': + dependencies: + '@shikijs/types': 2.5.0 + '@shikijs/vscode-textmate': 10.0.2 + + '@shikijs/langs@2.5.0': + dependencies: + '@shikijs/types': 2.5.0 + + '@shikijs/themes@2.5.0': + dependencies: + '@shikijs/types': 2.5.0 + + '@shikijs/transformers@2.5.0': + dependencies: + '@shikijs/core': 2.5.0 + '@shikijs/types': 2.5.0 + + '@shikijs/types@2.5.0': + dependencies: + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.4 + + '@shikijs/vscode-textmate@10.0.2': {} + + '@sindresorhus/is@4.6.0': {} + + '@standard-schema/spec@1.1.0': {} + + '@szmarczak/http-timer@4.0.6': + dependencies: + defer-to-connect: 2.0.1 + + '@tailwindcss/node@4.2.2': + dependencies: + '@jridgewell/remapping': 2.3.5 + enhanced-resolve: 5.20.1 + jiti: 2.6.1 + lightningcss: 1.32.0 + magic-string: 0.30.21 + source-map-js: 1.2.1 + tailwindcss: 4.2.2 + + '@tailwindcss/oxide-android-arm64@4.2.2': + optional: true + + '@tailwindcss/oxide-darwin-arm64@4.2.2': + optional: true + + '@tailwindcss/oxide-darwin-x64@4.2.2': + optional: true + + '@tailwindcss/oxide-freebsd-x64@4.2.2': + optional: true + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.2.2': + optional: true + + '@tailwindcss/oxide-linux-arm64-gnu@4.2.2': + optional: true + + '@tailwindcss/oxide-linux-arm64-musl@4.2.2': + optional: true + + '@tailwindcss/oxide-linux-x64-gnu@4.2.2': + optional: true + + '@tailwindcss/oxide-linux-x64-musl@4.2.2': + optional: true + + '@tailwindcss/oxide-wasm32-wasi@4.2.2': + optional: true + + '@tailwindcss/oxide-win32-arm64-msvc@4.2.2': + optional: true + + '@tailwindcss/oxide-win32-x64-msvc@4.2.2': + optional: true + + '@tailwindcss/oxide@4.2.2': + optionalDependencies: + '@tailwindcss/oxide-android-arm64': 4.2.2 + '@tailwindcss/oxide-darwin-arm64': 4.2.2 + '@tailwindcss/oxide-darwin-x64': 4.2.2 + '@tailwindcss/oxide-freebsd-x64': 4.2.2 + '@tailwindcss/oxide-linux-arm-gnueabihf': 4.2.2 + '@tailwindcss/oxide-linux-arm64-gnu': 4.2.2 + '@tailwindcss/oxide-linux-arm64-musl': 4.2.2 + '@tailwindcss/oxide-linux-x64-gnu': 4.2.2 + '@tailwindcss/oxide-linux-x64-musl': 4.2.2 + '@tailwindcss/oxide-wasm32-wasi': 4.2.2 + '@tailwindcss/oxide-win32-arm64-msvc': 4.2.2 + '@tailwindcss/oxide-win32-x64-msvc': 4.2.2 + + '@tailwindcss/vite@4.2.2(vite@6.4.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0))': + dependencies: + '@tailwindcss/node': 4.2.2 + '@tailwindcss/oxide': 4.2.2 + tailwindcss: 4.2.2 + vite: 6.4.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0) + + '@turf/boolean-clockwise@6.5.0': + dependencies: + '@turf/helpers': 6.5.0 + '@turf/invariant': 6.5.0 + + '@turf/clone@6.5.0': + dependencies: + '@turf/helpers': 6.5.0 + + '@turf/flatten@6.5.0': + dependencies: + '@turf/helpers': 6.5.0 + '@turf/meta': 6.5.0 + + '@turf/helpers@6.5.0': {} + + '@turf/invariant@6.5.0': + dependencies: + '@turf/helpers': 6.5.0 + + '@turf/meta@3.14.0': {} + + '@turf/meta@6.5.0': + dependencies: + '@turf/helpers': 6.5.0 + + '@turf/rewind@6.5.0': + dependencies: + '@turf/boolean-clockwise': 6.5.0 + '@turf/clone': 6.5.0 + '@turf/helpers': 6.5.0 + '@turf/invariant': 6.5.0 + '@turf/meta': 6.5.0 + + '@types/babel__core@7.20.5': + dependencies: + '@babel/parser': 7.29.0 + '@babel/types': 7.29.0 + '@types/babel__generator': 7.27.0 + '@types/babel__template': 7.4.4 + '@types/babel__traverse': 7.28.0 + + '@types/babel__generator@7.27.0': + dependencies: + '@babel/types': 7.29.0 + + '@types/babel__template@7.4.4': + dependencies: + '@babel/parser': 7.29.0 + '@babel/types': 7.29.0 + + '@types/babel__traverse@7.28.0': + dependencies: + '@babel/types': 7.29.0 + + '@types/better-sqlite3@7.6.13': + dependencies: + '@types/node': 22.19.15 + + '@types/cacheable-request@6.0.3': + dependencies: + '@types/http-cache-semantics': 4.2.0 + '@types/keyv': 3.1.4 + '@types/node': 22.19.15 + '@types/responselike': 1.0.3 + + '@types/d3-array@3.2.2': {} + + '@types/d3-axis@3.0.6': + dependencies: + '@types/d3-selection': 3.0.11 + + '@types/d3-brush@3.0.6': + dependencies: + '@types/d3-selection': 3.0.11 + + '@types/d3-chord@3.0.6': {} + + '@types/d3-color@3.1.3': {} + + '@types/d3-contour@3.0.6': + dependencies: + '@types/d3-array': 3.2.2 + '@types/geojson': 7946.0.16 + + '@types/d3-delaunay@6.0.4': {} + + '@types/d3-dispatch@3.0.7': {} + + '@types/d3-drag@3.0.7': + dependencies: + '@types/d3-selection': 3.0.11 + + '@types/d3-dsv@3.0.7': {} + + '@types/d3-ease@3.0.2': {} + + '@types/d3-fetch@3.0.7': + dependencies: + '@types/d3-dsv': 3.0.7 + + '@types/d3-force@3.0.10': {} + + '@types/d3-format@3.0.4': {} + + '@types/d3-geo@3.1.0': + dependencies: + '@types/geojson': 7946.0.16 + + '@types/d3-hierarchy@3.1.7': {} + + '@types/d3-interpolate@3.0.4': + dependencies: + '@types/d3-color': 3.1.3 + + '@types/d3-path@3.1.1': {} + + '@types/d3-polygon@3.0.2': {} + + '@types/d3-quadtree@3.0.6': {} + + '@types/d3-random@3.0.3': {} + + '@types/d3-scale-chromatic@3.1.0': {} + + '@types/d3-scale@4.0.9': + dependencies: + '@types/d3-time': 3.0.4 + + '@types/d3-selection@3.0.11': {} + + '@types/d3-shape@3.1.8': + dependencies: + '@types/d3-path': 3.1.1 + + '@types/d3-time-format@4.0.3': {} + + '@types/d3-time@3.0.4': {} + + '@types/d3-timer@3.0.2': {} + + '@types/d3-transition@3.0.9': + dependencies: + '@types/d3-selection': 3.0.11 + + '@types/d3-zoom@3.0.8': + dependencies: + '@types/d3-interpolate': 3.0.4 + '@types/d3-selection': 3.0.11 + + '@types/d3@7.4.3': + dependencies: + '@types/d3-array': 3.2.2 + '@types/d3-axis': 3.0.6 + '@types/d3-brush': 3.0.6 + '@types/d3-chord': 3.0.6 + '@types/d3-color': 3.1.3 + '@types/d3-contour': 3.0.6 + '@types/d3-delaunay': 6.0.4 + '@types/d3-dispatch': 3.0.7 + '@types/d3-drag': 3.0.7 + '@types/d3-dsv': 3.0.7 + '@types/d3-ease': 3.0.2 + '@types/d3-fetch': 3.0.7 + '@types/d3-force': 3.0.10 + '@types/d3-format': 3.0.4 + '@types/d3-geo': 3.1.0 + '@types/d3-hierarchy': 3.1.7 + '@types/d3-interpolate': 3.0.4 + '@types/d3-path': 3.1.1 + '@types/d3-polygon': 3.0.2 + '@types/d3-quadtree': 3.0.6 + '@types/d3-random': 3.0.3 + '@types/d3-scale': 4.0.9 + '@types/d3-scale-chromatic': 3.1.0 + '@types/d3-selection': 3.0.11 + '@types/d3-shape': 3.1.8 + '@types/d3-time': 3.0.4 + '@types/d3-time-format': 4.0.3 + '@types/d3-timer': 3.0.2 + '@types/d3-transition': 3.0.9 + '@types/d3-zoom': 3.0.8 + + '@types/debug@4.1.12': + dependencies: + '@types/ms': 2.1.0 + + '@types/estree@1.0.8': {} + + '@types/fs-extra@9.0.13': + dependencies: + '@types/node': 22.19.15 + + '@types/geojson@7946.0.16': {} + + '@types/hast@3.0.4': + dependencies: + '@types/unist': 3.0.3 + + '@types/http-cache-semantics@4.2.0': {} + + '@types/keyv@3.1.4': + dependencies: + '@types/node': 22.19.15 + + '@types/linkify-it@5.0.0': {} + + '@types/markdown-it@14.1.2': + dependencies: + '@types/linkify-it': 5.0.0 + '@types/mdurl': 2.0.0 + + '@types/mdast@4.0.4': + dependencies: + '@types/unist': 3.0.3 + + '@types/mdurl@2.0.0': {} + + '@types/ms@2.1.0': {} + + '@types/node-cron@3.0.11': {} + + '@types/node@22.19.15': + dependencies: + undici-types: 6.21.0 + + '@types/node@24.12.0': + dependencies: + undici-types: 7.16.0 + + '@types/nodemailer@7.0.11': + dependencies: + '@types/node': 22.19.15 + + '@types/pg@8.18.0': + dependencies: + '@types/node': 22.19.15 + pg-protocol: 1.13.0 + pg-types: 2.2.0 + + '@types/plist@3.0.5': + dependencies: + '@types/node': 22.19.15 + xmlbuilder: 15.1.1 + optional: true + + '@types/prop-types@15.7.15': {} + + '@types/react-dom@18.3.7(@types/react@18.3.28)': + dependencies: + '@types/react': 18.3.28 + + '@types/react@18.3.28': + dependencies: + '@types/prop-types': 15.7.15 + csstype: 3.2.3 + + '@types/responselike@1.0.3': + dependencies: + '@types/node': 22.19.15 + + '@types/trusted-types@2.0.7': + optional: true + + '@types/unist@3.0.3': {} + + '@types/verror@1.10.11': + optional: true + + '@types/web-bluetooth@0.0.21': {} + + '@types/yauzl@2.10.3': + dependencies: + '@types/node': 22.19.15 + optional: true + + '@ungap/structured-clone@1.3.0': {} + + '@upsetjs/venn.js@2.0.0': + optionalDependencies: + d3-selection: 3.0.0 + d3-transition: 3.0.1(d3-selection@3.0.0) + + '@visactor/react-vchart@2.0.19(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@visactor/vchart': 2.0.19 + '@visactor/vchart-extension': 2.0.19 + '@visactor/vrender-core': 1.0.41 + '@visactor/vrender-kits': 1.0.41 + '@visactor/vutils': 1.0.23 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + react-is: 18.3.1 + + '@visactor/vchart-extension@2.0.19': + dependencies: + '@visactor/vchart': 2.0.19 + '@visactor/vdataset': 1.0.23 + '@visactor/vlayouts': 1.0.23 + '@visactor/vrender-animate': 1.0.41 + '@visactor/vrender-components': 1.0.41 + '@visactor/vrender-core': 1.0.41 + '@visactor/vrender-kits': 1.0.41 + '@visactor/vutils': 1.0.23 + + '@visactor/vchart@2.0.19': + dependencies: + '@visactor/vdataset': 1.0.23 + '@visactor/vlayouts': 1.0.23 + '@visactor/vrender-animate': 1.0.41 + '@visactor/vrender-components': 1.0.41 + '@visactor/vrender-core': 1.0.41 + '@visactor/vrender-kits': 1.0.41 + '@visactor/vscale': 1.0.23 + '@visactor/vutils': 1.0.23 + '@visactor/vutils-extension': 2.0.19 + + '@visactor/vdataset@1.0.23': + dependencies: + '@turf/flatten': 6.5.0 + '@turf/helpers': 6.5.0 + '@turf/rewind': 6.5.0 + '@visactor/vutils': 1.0.23 + d3-dsv: 2.0.0 + d3-geo: 1.12.1 + d3-hexbin: 0.2.2 + d3-hierarchy: 3.1.2 + eventemitter3: 4.0.7 + geobuf: 3.0.2 + geojson-dissolve: 3.1.0 + path-browserify: 1.0.1 + pbf: 3.3.0 + point-at-length: 1.1.0 + simple-statistics: 7.8.9 + simplify-geojson: 1.0.5 + topojson-client: 3.1.0 + + '@visactor/vlayouts@1.0.23': + dependencies: + '@turf/helpers': 6.5.0 + '@turf/invariant': 6.5.0 + '@visactor/vscale': 1.0.23 + '@visactor/vutils': 1.0.23 + eventemitter3: 4.0.7 + + '@visactor/vrender-animate@1.0.41': + dependencies: + '@visactor/vrender-core': 1.0.41 + '@visactor/vutils': 1.0.23 + + '@visactor/vrender-components@1.0.41': + dependencies: + '@visactor/vrender-animate': 1.0.41 + '@visactor/vrender-core': 1.0.41 + '@visactor/vrender-kits': 1.0.41 + '@visactor/vscale': 1.0.23 + '@visactor/vutils': 1.0.23 + + '@visactor/vrender-core@1.0.41': + dependencies: + '@visactor/vutils': 1.0.23 + color-convert: 2.0.1 + + '@visactor/vrender-kits@1.0.41': + dependencies: + '@resvg/resvg-js': 2.4.1 + '@visactor/vrender-core': 1.0.41 + '@visactor/vutils': 1.0.23 + gifuct-js: 2.1.2 + lottie-web: 5.13.0 + roughjs: 4.6.6 + + '@visactor/vscale@1.0.23': + dependencies: + '@visactor/vutils': 1.0.23 + + '@visactor/vutils-extension@2.0.19': + dependencies: + '@visactor/vdataset': 1.0.23 + '@visactor/vutils': 1.0.23 + + '@visactor/vutils@1.0.23': + dependencies: + '@turf/helpers': 6.5.0 + '@turf/invariant': 6.5.0 + eventemitter3: 4.0.7 + + '@vitejs/plugin-react@4.7.0(vite@6.4.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0))': + dependencies: + '@babel/core': 7.29.0 + '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-react-jsx-source': 7.27.1(@babel/core@7.29.0) + '@rolldown/pluginutils': 1.0.0-beta.27 + '@types/babel__core': 7.20.5 + react-refresh: 0.17.0 + vite: 6.4.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0) + transitivePeerDependencies: + - supports-color + + '@vitejs/plugin-vue@5.2.4(vite@5.4.21(@types/node@22.19.15)(lightningcss@1.32.0))(vue@3.5.30(typescript@5.9.3))': + dependencies: + vite: 5.4.21(@types/node@22.19.15)(lightningcss@1.32.0) + vue: 3.5.30(typescript@5.9.3) + + '@vitest/expect@2.1.9': + dependencies: + '@vitest/spy': 2.1.9 + '@vitest/utils': 2.1.9 + chai: 5.3.3 + tinyrainbow: 1.2.0 + + '@vitest/mocker@2.1.9(vite@5.4.21(@types/node@22.19.15)(lightningcss@1.32.0))': + dependencies: + '@vitest/spy': 2.1.9 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 5.4.21(@types/node@22.19.15)(lightningcss@1.32.0) + + '@vitest/pretty-format@2.1.9': + dependencies: + tinyrainbow: 1.2.0 + + '@vitest/runner@2.1.9': + dependencies: + '@vitest/utils': 2.1.9 + pathe: 1.1.2 + + '@vitest/snapshot@2.1.9': + dependencies: + '@vitest/pretty-format': 2.1.9 + magic-string: 0.30.21 + pathe: 1.1.2 + + '@vitest/spy@2.1.9': + dependencies: + tinyspy: 3.0.2 + + '@vitest/utils@2.1.9': + dependencies: + '@vitest/pretty-format': 2.1.9 + loupe: 3.2.1 + tinyrainbow: 1.2.0 + + '@vue/compiler-core@3.5.30': + dependencies: + '@babel/parser': 7.29.0 + '@vue/shared': 3.5.30 + entities: 7.0.1 + estree-walker: 2.0.2 + source-map-js: 1.2.1 + + '@vue/compiler-dom@3.5.30': + dependencies: + '@vue/compiler-core': 3.5.30 + '@vue/shared': 3.5.30 + + '@vue/compiler-sfc@3.5.30': + dependencies: + '@babel/parser': 7.29.0 + '@vue/compiler-core': 3.5.30 + '@vue/compiler-dom': 3.5.30 + '@vue/compiler-ssr': 3.5.30 + '@vue/shared': 3.5.30 + estree-walker: 2.0.2 + magic-string: 0.30.21 + postcss: 8.5.8 + source-map-js: 1.2.1 + + '@vue/compiler-ssr@3.5.30': + dependencies: + '@vue/compiler-dom': 3.5.30 + '@vue/shared': 3.5.30 + + '@vue/devtools-api@7.7.9': + dependencies: + '@vue/devtools-kit': 7.7.9 + + '@vue/devtools-kit@7.7.9': + dependencies: + '@vue/devtools-shared': 7.7.9 + birpc: 2.9.0 + hookable: 5.5.3 + mitt: 3.0.1 + perfect-debounce: 1.0.0 + speakingurl: 14.0.1 + superjson: 2.2.6 + + '@vue/devtools-shared@7.7.9': + dependencies: + rfdc: 1.4.1 + + '@vue/reactivity@3.5.30': + dependencies: + '@vue/shared': 3.5.30 + + '@vue/runtime-core@3.5.30': + dependencies: + '@vue/reactivity': 3.5.30 + '@vue/shared': 3.5.30 + + '@vue/runtime-dom@3.5.30': + dependencies: + '@vue/reactivity': 3.5.30 + '@vue/runtime-core': 3.5.30 + '@vue/shared': 3.5.30 + csstype: 3.2.3 + + '@vue/server-renderer@3.5.30(vue@3.5.30(typescript@5.9.3))': + dependencies: + '@vue/compiler-ssr': 3.5.30 + '@vue/shared': 3.5.30 + vue: 3.5.30(typescript@5.9.3) + + '@vue/shared@3.5.30': {} + + '@vueuse/core@12.8.2(typescript@5.9.3)': + dependencies: + '@types/web-bluetooth': 0.0.21 + '@vueuse/metadata': 12.8.2 + '@vueuse/shared': 12.8.2(typescript@5.9.3) + vue: 3.5.30(typescript@5.9.3) + transitivePeerDependencies: + - typescript + + '@vueuse/integrations@12.8.2(axios@1.13.6)(focus-trap@7.8.0)(typescript@5.9.3)': + dependencies: + '@vueuse/core': 12.8.2(typescript@5.9.3) + '@vueuse/shared': 12.8.2(typescript@5.9.3) + vue: 3.5.30(typescript@5.9.3) + optionalDependencies: + axios: 1.13.6 + focus-trap: 7.8.0 + transitivePeerDependencies: + - typescript + + '@vueuse/metadata@12.8.2': {} + + '@vueuse/shared@12.8.2(typescript@5.9.3)': + dependencies: + vue: 3.5.30(typescript@5.9.3) + transitivePeerDependencies: + - typescript + + '@xmldom/xmldom@0.8.11': {} + + abbrev@3.0.1: {} + + abs-svg-path@0.1.1: {} + + abstract-logging@2.0.1: {} + + acorn@8.16.0: {} + + agent-base@7.1.4: {} + + ajv-formats@3.0.1(ajv@8.18.0): + optionalDependencies: + ajv: 8.18.0 + + ajv-keywords@3.5.2(ajv@6.14.0): + dependencies: + ajv: 6.14.0 + + ajv@6.14.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-json-stable-stringify: 2.1.0 + json-schema-traverse: 0.4.1 + uri-js: 4.4.1 + + ajv@8.18.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.0 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + + algoliasearch@5.49.1: + dependencies: + '@algolia/abtesting': 1.15.1 + '@algolia/client-abtesting': 5.49.1 + '@algolia/client-analytics': 5.49.1 + '@algolia/client-common': 5.49.1 + '@algolia/client-insights': 5.49.1 + '@algolia/client-personalization': 5.49.1 + '@algolia/client-query-suggestions': 5.49.1 + '@algolia/client-search': 5.49.1 + '@algolia/ingestion': 1.49.1 + '@algolia/monitoring': 1.49.1 + '@algolia/recommend': 5.49.1 + '@algolia/requester-browser-xhr': 5.49.1 + '@algolia/requester-fetch': 5.49.1 + '@algolia/requester-node-http': 5.49.1 + + ansi-regex@5.0.1: {} + + ansi-regex@6.2.2: {} + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + ansi-styles@6.2.3: {} + + app-builder-bin@5.0.0-alpha.12: {} + + app-builder-lib@26.8.1(dmg-builder@26.8.1)(electron-builder-squirrel-windows@26.8.1): + dependencies: + '@develar/schema-utils': 2.6.5 + '@electron/asar': 3.4.1 + '@electron/fuses': 1.8.0 + '@electron/get': 3.1.0 + '@electron/notarize': 2.5.0 + '@electron/osx-sign': 1.3.3 + '@electron/rebuild': 4.0.3 + '@electron/universal': 2.0.3 + '@malept/flatpak-bundler': 0.4.0 + '@types/fs-extra': 9.0.13 + async-exit-hook: 2.0.1 + builder-util: 26.8.1 + builder-util-runtime: 9.5.1 + chromium-pickle-js: 0.2.0 + ci-info: 4.3.1 + debug: 4.4.3 + dmg-builder: 26.8.1(electron-builder-squirrel-windows@26.8.1) + dotenv: 16.6.1 + dotenv-expand: 11.0.7 + ejs: 3.1.10 + electron-builder-squirrel-windows: 26.8.1(dmg-builder@26.8.1) + electron-publish: 26.8.1 + fs-extra: 10.1.0 + hosted-git-info: 4.1.0 + isbinaryfile: 5.0.7 + jiti: 2.6.1 + js-yaml: 4.1.1 + json5: 2.2.3 + lazy-val: 1.0.5 + minimatch: 10.2.4 + plist: 3.1.0 + proper-lockfile: 4.1.2 + resedit: 1.7.2 + semver: 7.7.4 + tar: 7.5.11 + temp-file: 3.4.0 + tiny-async-pool: 1.3.0 + which: 5.0.0 + transitivePeerDependencies: + - supports-color + + argparse@2.0.1: {} + + array-source@0.0.4: {} + + assert-plus@1.0.0: + optional: true + + assertion-error@2.0.1: {} + + astral-regex@2.0.0: + optional: true + + async-exit-hook@2.0.1: {} + + async@3.2.6: {} + + asynckit@0.4.0: {} + + at-least-node@1.0.0: {} + + atomic-sleep@1.0.0: {} + + avvio@9.2.0: + dependencies: + '@fastify/error': 4.2.0 + fastq: 1.20.1 + + aws-ssl-profiles@1.1.2: {} + + axios@1.13.6: + dependencies: + follow-redirects: 1.15.11 + form-data: 4.0.5 + proxy-from-env: 1.1.0 + transitivePeerDependencies: + - debug + + balanced-match@1.0.2: {} + + balanced-match@4.0.4: {} + + base64-js@1.5.1: {} + + baseline-browser-mapping@2.10.0: {} + + better-sqlite3@12.8.0: + dependencies: + bindings: 1.5.0 + prebuild-install: 7.1.3 + + bidi-js@1.0.3: + dependencies: + require-from-string: 2.0.2 + + bindings@1.5.0: + dependencies: + file-uri-to-path: 1.0.0 + + birpc@2.9.0: {} + + bl@4.1.0: + dependencies: + buffer: 5.7.1 + inherits: 2.0.4 + readable-stream: 3.6.2 + + boolean@3.2.0: + optional: true + + brace-expansion@1.1.12: + dependencies: + balanced-match: 1.0.2 + concat-map: 0.0.1 + + brace-expansion@2.0.2: + dependencies: + balanced-match: 1.0.2 + + brace-expansion@5.0.4: + dependencies: + balanced-match: 4.0.4 + + browserslist@4.28.1: + dependencies: + baseline-browser-mapping: 2.10.0 + caniuse-lite: 1.0.30001777 + electron-to-chromium: 1.5.307 + node-releases: 2.0.36 + update-browserslist-db: 1.2.3(browserslist@4.28.1) + + buffer-crc32@0.2.13: {} + + buffer-from@1.1.2: {} + + buffer@5.7.1: + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + + builder-util-runtime@9.5.1: + dependencies: + debug: 4.4.3 + sax: 1.5.0 + transitivePeerDependencies: + - supports-color + + builder-util@26.8.1: + dependencies: + 7zip-bin: 5.2.0 + '@types/debug': 4.1.12 + app-builder-bin: 5.0.0-alpha.12 + builder-util-runtime: 9.5.1 + chalk: 4.1.2 + cross-spawn: 7.0.6 + debug: 4.4.3 + fs-extra: 10.1.0 + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + js-yaml: 4.1.1 + sanitize-filename: 1.6.3 + source-map-support: 0.5.21 + stat-mode: 1.0.0 + temp-file: 3.4.0 + tiny-async-pool: 1.3.0 + transitivePeerDependencies: + - supports-color + + cac@6.7.14: {} + + cacache@19.0.1: + dependencies: + '@npmcli/fs': 4.0.0 + fs-minipass: 3.0.3 + glob: 10.5.0 + lru-cache: 10.4.3 + minipass: 7.1.3 + minipass-collect: 2.0.1 + minipass-flush: 1.0.5 + minipass-pipeline: 1.2.4 + p-map: 7.0.4 + ssri: 12.0.0 + tar: 7.5.11 + unique-filename: 4.0.0 + + cacheable-lookup@5.0.4: {} + + cacheable-request@7.0.4: + dependencies: + clone-response: 1.0.3 + get-stream: 5.2.0 + http-cache-semantics: 4.2.0 + keyv: 4.5.4 + lowercase-keys: 2.0.0 + normalize-url: 6.1.0 + responselike: 2.0.1 + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + caniuse-lite@1.0.30001777: {} + + ccount@2.0.1: {} + + chai@5.3.3: + dependencies: + assertion-error: 2.0.1 + check-error: 2.1.3 + deep-eql: 5.0.2 + loupe: 3.2.1 + pathval: 2.0.1 + + chalk@4.1.2: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + + character-entities-html4@2.1.0: {} + + character-entities-legacy@3.0.0: {} + + check-error@2.1.3: {} + + chevrotain-allstar@0.3.1(chevrotain@11.1.2): + dependencies: + chevrotain: 11.1.2 + lodash-es: 4.17.23 + + chevrotain@11.1.2: + dependencies: + '@chevrotain/cst-dts-gen': 11.1.2 + '@chevrotain/gast': 11.1.2 + '@chevrotain/regexp-to-ast': 11.1.2 + '@chevrotain/types': 11.1.2 + '@chevrotain/utils': 11.1.2 + lodash-es: 4.17.23 + + chownr@1.1.4: {} + + chownr@3.0.0: {} + + chromium-pickle-js@0.2.0: {} + + ci-info@4.3.1: {} + + ci-info@4.4.0: {} + + cli-cursor@3.1.0: + dependencies: + restore-cursor: 3.1.0 + + cli-spinners@2.9.2: {} + + cli-truncate@2.1.0: + dependencies: + slice-ansi: 3.0.0 + string-width: 4.2.3 + optional: true + + cliui@8.0.1: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 7.0.0 + + clone-response@1.0.3: + dependencies: + mimic-response: 1.0.1 + + clone@1.0.4: {} + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + + comma-separated-tokens@2.0.3: {} + + commander@2.20.3: {} + + commander@5.1.0: {} + + commander@7.2.0: {} + + commander@8.3.0: {} + + commander@9.5.0: + optional: true + + compare-version@0.1.2: {} + + concat-map@0.0.1: {} + + concat-stream@1.4.11: + dependencies: + inherits: 2.0.4 + readable-stream: 1.1.14 + typedarray: 0.0.7 + + concat-stream@2.0.0: + dependencies: + buffer-from: 1.1.2 + inherits: 2.0.4 + readable-stream: 3.6.2 + typedarray: 0.0.6 + + concurrently@9.2.1: + dependencies: + chalk: 4.1.2 + rxjs: 7.8.2 + shell-quote: 1.8.3 + supports-color: 8.1.1 + tree-kill: 1.2.2 + yargs: 17.7.2 + + confbox@0.1.8: {} + + content-disposition@1.0.1: {} + + convert-source-map@2.0.0: {} + + cookie@1.1.1: {} + + copy-anything@4.0.5: + dependencies: + is-what: 5.5.0 + + core-util-is@1.0.2: + optional: true + + core-util-is@1.0.3: {} + + cose-base@1.0.3: + dependencies: + layout-base: 1.0.2 + + cose-base@2.2.0: + dependencies: + layout-base: 2.0.1 + + crc@3.8.0: + dependencies: + buffer: 5.7.1 + optional: true + + cross-dirname@0.1.0: + optional: true + + cross-env@7.0.3: + dependencies: + cross-spawn: 7.0.6 + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + css-tree@3.2.1: + dependencies: + mdn-data: 2.27.1 + source-map-js: 1.2.1 + + csstype@3.2.3: {} + + cytoscape-cose-bilkent@4.1.0(cytoscape@3.33.1): + dependencies: + cose-base: 1.0.3 + cytoscape: 3.33.1 + + cytoscape-fcose@2.2.0(cytoscape@3.33.1): + dependencies: + cose-base: 2.2.0 + cytoscape: 3.33.1 + + cytoscape@3.33.1: {} + + d3-array@1.2.4: {} + + d3-array@2.12.1: + dependencies: + internmap: 1.0.1 + + d3-array@3.2.4: + dependencies: + internmap: 2.0.3 + + d3-axis@3.0.0: {} + + d3-brush@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-drag: 3.0.0 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-transition: 3.0.1(d3-selection@3.0.0) + + d3-chord@3.0.1: + dependencies: + d3-path: 3.1.0 + + d3-color@3.1.0: {} + + d3-contour@4.0.2: + dependencies: + d3-array: 3.2.4 + + d3-delaunay@6.0.4: + dependencies: + delaunator: 5.0.1 + + d3-dispatch@3.0.1: {} + + d3-drag@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-selection: 3.0.0 + + d3-dsv@2.0.0: + dependencies: + commander: 2.20.3 + iconv-lite: 0.4.24 + rw: 1.3.3 + + d3-dsv@3.0.1: + dependencies: + commander: 7.2.0 + iconv-lite: 0.6.3 + rw: 1.3.3 + + d3-ease@3.0.1: {} + + d3-fetch@3.0.1: + dependencies: + d3-dsv: 3.0.1 + + d3-force@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-quadtree: 3.0.1 + d3-timer: 3.0.1 + + d3-format@3.1.2: {} + + d3-geo@1.12.1: + dependencies: + d3-array: 1.2.4 + + d3-geo@3.1.1: + dependencies: + d3-array: 3.2.4 + + d3-hexbin@0.2.2: {} + + d3-hierarchy@3.1.2: {} + + d3-interpolate@3.0.1: + dependencies: + d3-color: 3.1.0 + + d3-path@1.0.9: {} + + d3-path@3.1.0: {} + + d3-polygon@3.0.1: {} + + d3-quadtree@3.0.1: {} + + d3-random@3.0.1: {} + + d3-sankey@0.12.3: + dependencies: + d3-array: 2.12.1 + d3-shape: 1.3.7 + + d3-scale-chromatic@3.1.0: + dependencies: + d3-color: 3.1.0 + d3-interpolate: 3.0.1 + + d3-scale@4.0.2: + dependencies: + d3-array: 3.2.4 + d3-format: 3.1.2 + d3-interpolate: 3.0.1 + d3-time: 3.1.0 + d3-time-format: 4.1.0 + + d3-selection@3.0.0: {} + + d3-shape@1.3.7: + dependencies: + d3-path: 1.0.9 + + d3-shape@3.2.0: + dependencies: + d3-path: 3.1.0 + + d3-time-format@4.1.0: + dependencies: + d3-time: 3.1.0 + + d3-time@3.1.0: + dependencies: + d3-array: 3.2.4 + + d3-timer@3.0.1: {} + + d3-transition@3.0.1(d3-selection@3.0.0): + dependencies: + d3-color: 3.1.0 + d3-dispatch: 3.0.1 + d3-ease: 3.0.1 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-timer: 3.0.1 + + d3-zoom@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-drag: 3.0.0 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-transition: 3.0.1(d3-selection@3.0.0) + + d3@7.9.0: + dependencies: + d3-array: 3.2.4 + d3-axis: 3.0.0 + d3-brush: 3.0.0 + d3-chord: 3.0.1 + d3-color: 3.1.0 + d3-contour: 4.0.2 + d3-delaunay: 6.0.4 + d3-dispatch: 3.0.1 + d3-drag: 3.0.0 + d3-dsv: 3.0.1 + d3-ease: 3.0.1 + d3-fetch: 3.0.1 + d3-force: 3.0.0 + d3-format: 3.1.2 + d3-geo: 3.1.1 + d3-hierarchy: 3.1.2 + d3-interpolate: 3.0.1 + d3-path: 3.1.0 + d3-polygon: 3.0.1 + d3-quadtree: 3.0.1 + d3-random: 3.0.1 + d3-scale: 4.0.2 + d3-scale-chromatic: 3.1.0 + d3-selection: 3.0.0 + d3-shape: 3.2.0 + d3-time: 3.1.0 + d3-time-format: 4.1.0 + d3-timer: 3.0.1 + d3-transition: 3.0.1(d3-selection@3.0.0) + d3-zoom: 3.0.0 + + dagre-d3-es@7.0.14: + dependencies: + d3: 7.9.0 + lodash-es: 4.17.23 + + data-urls@7.0.0: + dependencies: + whatwg-mimetype: 5.0.0 + whatwg-url: 16.0.1 + transitivePeerDependencies: + - '@noble/hashes' + + dayjs@1.11.20: {} + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + decimal.js@10.6.0: {} + + decompress-response@6.0.0: + dependencies: + mimic-response: 3.1.0 + + deep-eql@5.0.2: {} + + deep-extend@0.6.0: {} + + defaults@1.0.4: + dependencies: + clone: 1.0.4 + + defer-to-connect@2.0.1: {} + + define-data-property@1.1.4: + dependencies: + es-define-property: 1.0.1 + es-errors: 1.3.0 + gopd: 1.2.0 + optional: true + + define-properties@1.2.1: + dependencies: + define-data-property: 1.1.4 + has-property-descriptors: 1.0.2 + object-keys: 1.1.1 + optional: true + + delaunator@5.0.1: + dependencies: + robust-predicates: 3.0.2 + + delayed-stream@1.0.0: {} + + denque@2.1.0: {} + + depd@2.0.0: {} + + dequal@2.0.3: {} + + detect-libc@2.1.2: {} + + detect-node@2.1.0: + optional: true + + devlop@1.1.0: + dependencies: + dequal: 2.0.3 + + dir-compare@4.2.0: + dependencies: + minimatch: 3.1.5 + p-limit: 3.1.0 + + dmg-builder@26.8.1(electron-builder-squirrel-windows@26.8.1): + dependencies: + app-builder-lib: 26.8.1(dmg-builder@26.8.1)(electron-builder-squirrel-windows@26.8.1) + builder-util: 26.8.1 + fs-extra: 10.1.0 + iconv-lite: 0.6.3 + js-yaml: 4.1.1 + optionalDependencies: + dmg-license: 1.0.11 + transitivePeerDependencies: + - electron-builder-squirrel-windows + - supports-color + + dmg-license@1.0.11: + dependencies: + '@types/plist': 3.0.5 + '@types/verror': 1.10.11 + ajv: 6.14.0 + crc: 3.8.0 + iconv-corefoundation: 1.1.7 + plist: 3.1.0 + smart-buffer: 4.2.0 + verror: 1.10.1 + optional: true + + dompurify@3.3.3: + optionalDependencies: + '@types/trusted-types': 2.0.7 + + dotenv-expand@11.0.7: + dependencies: + dotenv: 16.6.1 + + dotenv@16.6.1: {} + + dotenv@17.3.1: {} + + drizzle-kit@0.31.10: + dependencies: + '@drizzle-team/brocli': 0.10.2 + '@esbuild-kit/esm-loader': 2.6.5 + esbuild: 0.25.12 + tsx: 4.21.0 + + drizzle-orm@0.45.1(@types/better-sqlite3@7.6.13)(@types/pg@8.18.0)(better-sqlite3@12.8.0)(mysql2@3.20.0(@types/node@22.19.15))(pg@8.20.0): + optionalDependencies: + '@types/better-sqlite3': 7.6.13 + '@types/pg': 8.18.0 + better-sqlite3: 12.8.0 + mysql2: 3.20.0(@types/node@22.19.15) + pg: 8.20.0 + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + eastasianwidth@0.2.0: {} + + ejs@3.1.10: + dependencies: + jake: 10.9.4 + + electron-builder-squirrel-windows@26.8.1(dmg-builder@26.8.1): + dependencies: + app-builder-lib: 26.8.1(dmg-builder@26.8.1)(electron-builder-squirrel-windows@26.8.1) + builder-util: 26.8.1 + electron-winstaller: 5.4.0 + transitivePeerDependencies: + - dmg-builder + - supports-color + + electron-builder@26.8.1(electron-builder-squirrel-windows@26.8.1): + dependencies: + app-builder-lib: 26.8.1(dmg-builder@26.8.1)(electron-builder-squirrel-windows@26.8.1) + builder-util: 26.8.1 + builder-util-runtime: 9.5.1 + chalk: 4.1.2 + ci-info: 4.4.0 + dmg-builder: 26.8.1(electron-builder-squirrel-windows@26.8.1) + fs-extra: 10.1.0 + lazy-val: 1.0.5 + simple-update-notifier: 2.0.0 + yargs: 17.7.2 + transitivePeerDependencies: + - electron-builder-squirrel-windows + - supports-color + + electron-log@5.4.3: {} + + electron-publish@26.8.1: + dependencies: + '@types/fs-extra': 9.0.13 + builder-util: 26.8.1 + builder-util-runtime: 9.5.1 + chalk: 4.1.2 + form-data: 4.0.5 + fs-extra: 10.1.0 + lazy-val: 1.0.5 + mime: 2.6.0 + transitivePeerDependencies: + - supports-color + + electron-to-chromium@1.5.307: {} + + electron-updater@6.8.3: + dependencies: + builder-util-runtime: 9.5.1 + fs-extra: 10.1.0 + js-yaml: 4.1.1 + lazy-val: 1.0.5 + lodash.escaperegexp: 4.1.2 + lodash.isequal: 4.5.0 + semver: 7.7.4 + tiny-typed-emitter: 2.1.0 + transitivePeerDependencies: + - supports-color + + electron-winstaller@5.4.0: + dependencies: + '@electron/asar': 3.4.1 + debug: 4.4.3 + fs-extra: 7.0.1 + lodash: 4.17.23 + temp: 0.9.4 + optionalDependencies: + '@electron/windows-sign': 1.2.2 + transitivePeerDependencies: + - supports-color + + electron@41.0.3: + dependencies: + '@electron/get': 2.0.3 + '@types/node': 24.12.0 + extract-zip: 2.0.1 + transitivePeerDependencies: + - supports-color + + emoji-regex-xs@1.0.0: {} + + emoji-regex@8.0.0: {} + + emoji-regex@9.2.2: {} + + encoding@0.1.13: + dependencies: + iconv-lite: 0.6.3 + optional: true + + end-of-stream@1.4.5: + dependencies: + once: 1.4.0 + + enhanced-resolve@5.20.1: + dependencies: + graceful-fs: 4.2.11 + tapable: 2.3.0 + + entities@6.0.1: {} + + entities@7.0.1: {} + + env-paths@2.2.1: {} + + err-code@2.0.3: {} + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-module-lexer@1.7.0: {} + + es-object-atoms@1.1.1: + dependencies: + es-errors: 1.3.0 + + es-set-tostringtag@2.1.0: + dependencies: + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + has-tostringtag: 1.0.2 + hasown: 2.0.2 + + es6-error@4.1.1: + optional: true + + esbuild@0.18.20: + optionalDependencies: + '@esbuild/android-arm': 0.18.20 + '@esbuild/android-arm64': 0.18.20 + '@esbuild/android-x64': 0.18.20 + '@esbuild/darwin-arm64': 0.18.20 + '@esbuild/darwin-x64': 0.18.20 + '@esbuild/freebsd-arm64': 0.18.20 + '@esbuild/freebsd-x64': 0.18.20 + '@esbuild/linux-arm': 0.18.20 + '@esbuild/linux-arm64': 0.18.20 + '@esbuild/linux-ia32': 0.18.20 + '@esbuild/linux-loong64': 0.18.20 + '@esbuild/linux-mips64el': 0.18.20 + '@esbuild/linux-ppc64': 0.18.20 + '@esbuild/linux-riscv64': 0.18.20 + '@esbuild/linux-s390x': 0.18.20 + '@esbuild/linux-x64': 0.18.20 + '@esbuild/netbsd-x64': 0.18.20 + '@esbuild/openbsd-x64': 0.18.20 + '@esbuild/sunos-x64': 0.18.20 + '@esbuild/win32-arm64': 0.18.20 + '@esbuild/win32-ia32': 0.18.20 + '@esbuild/win32-x64': 0.18.20 + + esbuild@0.21.5: + optionalDependencies: + '@esbuild/aix-ppc64': 0.21.5 + '@esbuild/android-arm': 0.21.5 + '@esbuild/android-arm64': 0.21.5 + '@esbuild/android-x64': 0.21.5 + '@esbuild/darwin-arm64': 0.21.5 + '@esbuild/darwin-x64': 0.21.5 + '@esbuild/freebsd-arm64': 0.21.5 + '@esbuild/freebsd-x64': 0.21.5 + '@esbuild/linux-arm': 0.21.5 + '@esbuild/linux-arm64': 0.21.5 + '@esbuild/linux-ia32': 0.21.5 + '@esbuild/linux-loong64': 0.21.5 + '@esbuild/linux-mips64el': 0.21.5 + '@esbuild/linux-ppc64': 0.21.5 + '@esbuild/linux-riscv64': 0.21.5 + '@esbuild/linux-s390x': 0.21.5 + '@esbuild/linux-x64': 0.21.5 + '@esbuild/netbsd-x64': 0.21.5 + '@esbuild/openbsd-x64': 0.21.5 + '@esbuild/sunos-x64': 0.21.5 + '@esbuild/win32-arm64': 0.21.5 + '@esbuild/win32-ia32': 0.21.5 + '@esbuild/win32-x64': 0.21.5 + + esbuild@0.25.12: + optionalDependencies: + '@esbuild/aix-ppc64': 0.25.12 + '@esbuild/android-arm': 0.25.12 + '@esbuild/android-arm64': 0.25.12 + '@esbuild/android-x64': 0.25.12 + '@esbuild/darwin-arm64': 0.25.12 + '@esbuild/darwin-x64': 0.25.12 + '@esbuild/freebsd-arm64': 0.25.12 + '@esbuild/freebsd-x64': 0.25.12 + '@esbuild/linux-arm': 0.25.12 + '@esbuild/linux-arm64': 0.25.12 + '@esbuild/linux-ia32': 0.25.12 + '@esbuild/linux-loong64': 0.25.12 + '@esbuild/linux-mips64el': 0.25.12 + '@esbuild/linux-ppc64': 0.25.12 + '@esbuild/linux-riscv64': 0.25.12 + '@esbuild/linux-s390x': 0.25.12 + '@esbuild/linux-x64': 0.25.12 + '@esbuild/netbsd-arm64': 0.25.12 + '@esbuild/netbsd-x64': 0.25.12 + '@esbuild/openbsd-arm64': 0.25.12 + '@esbuild/openbsd-x64': 0.25.12 + '@esbuild/openharmony-arm64': 0.25.12 + '@esbuild/sunos-x64': 0.25.12 + '@esbuild/win32-arm64': 0.25.12 + '@esbuild/win32-ia32': 0.25.12 + '@esbuild/win32-x64': 0.25.12 + + esbuild@0.27.3: + optionalDependencies: + '@esbuild/aix-ppc64': 0.27.3 + '@esbuild/android-arm': 0.27.3 + '@esbuild/android-arm64': 0.27.3 + '@esbuild/android-x64': 0.27.3 + '@esbuild/darwin-arm64': 0.27.3 + '@esbuild/darwin-x64': 0.27.3 + '@esbuild/freebsd-arm64': 0.27.3 + '@esbuild/freebsd-x64': 0.27.3 + '@esbuild/linux-arm': 0.27.3 + '@esbuild/linux-arm64': 0.27.3 + '@esbuild/linux-ia32': 0.27.3 + '@esbuild/linux-loong64': 0.27.3 + '@esbuild/linux-mips64el': 0.27.3 + '@esbuild/linux-ppc64': 0.27.3 + '@esbuild/linux-riscv64': 0.27.3 + '@esbuild/linux-s390x': 0.27.3 + '@esbuild/linux-x64': 0.27.3 + '@esbuild/netbsd-arm64': 0.27.3 + '@esbuild/netbsd-x64': 0.27.3 + '@esbuild/openbsd-arm64': 0.27.3 + '@esbuild/openbsd-x64': 0.27.3 + '@esbuild/openharmony-arm64': 0.27.3 + '@esbuild/sunos-x64': 0.27.3 + '@esbuild/win32-arm64': 0.27.3 + '@esbuild/win32-ia32': 0.27.3 + '@esbuild/win32-x64': 0.27.3 + + escalade@3.2.0: {} + + escape-html@1.0.3: {} + + escape-string-regexp@4.0.0: + optional: true + + estree-walker@2.0.2: {} + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.8 + + eventemitter3@4.0.7: {} + + expand-template@2.0.3: {} + + expect-type@1.3.0: {} + + exponential-backoff@3.1.3: {} + + extract-zip@2.0.1: + dependencies: + debug: 4.4.3 + get-stream: 5.2.0 + yauzl: 2.10.0 + optionalDependencies: + '@types/yauzl': 2.10.3 + transitivePeerDependencies: + - supports-color + + extsprintf@1.4.1: + optional: true + + fast-decode-uri-component@1.0.1: {} + + fast-deep-equal@3.1.3: {} + + fast-json-stable-stringify@2.1.0: {} + + fast-json-stringify@6.3.0: + dependencies: + '@fastify/merge-json-schemas': 0.2.1 + ajv: 8.18.0 + ajv-formats: 3.0.1(ajv@8.18.0) + fast-uri: 3.1.0 + json-schema-ref-resolver: 3.0.0 + rfdc: 1.4.1 + + fast-querystring@1.1.2: + dependencies: + fast-decode-uri-component: 1.0.1 + + fast-uri@3.1.0: {} + + fastify-plugin@5.1.0: {} + + fastify@5.8.2: + dependencies: + '@fastify/ajv-compiler': 4.0.5 + '@fastify/error': 4.2.0 + '@fastify/fast-json-stringify-compiler': 5.0.3 + '@fastify/proxy-addr': 5.1.0 + abstract-logging: 2.0.1 + avvio: 9.2.0 + fast-json-stringify: 6.3.0 + find-my-way: 9.5.0 + light-my-request: 6.6.0 + pino: 10.3.1 + process-warning: 5.0.0 + rfdc: 1.4.1 + secure-json-parse: 4.1.0 + semver: 7.7.4 + toad-cache: 3.7.0 + + fastq@1.20.1: + dependencies: + reusify: 1.1.0 + + fd-slicer@1.1.0: + dependencies: + pend: 1.2.0 + + fdir@6.5.0(picomatch@4.0.3): + optionalDependencies: + picomatch: 4.0.3 + + file-source@0.6.1: + dependencies: + stream-source: 0.3.5 + + file-uri-to-path@1.0.0: {} + + filelist@1.0.6: + dependencies: + minimatch: 5.1.9 + + find-my-way@9.5.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-querystring: 1.1.2 + safe-regex2: 5.0.0 + + focus-trap@7.8.0: + dependencies: + tabbable: 6.4.0 + + follow-redirects@1.15.11: {} + + foreground-child@3.3.1: + dependencies: + cross-spawn: 7.0.6 + signal-exit: 4.1.0 + + form-data@4.0.5: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.2 + mime-types: 2.1.35 + + fs-constants@1.0.0: {} + + fs-extra@10.1.0: + dependencies: + graceful-fs: 4.2.11 + jsonfile: 6.2.0 + universalify: 2.0.1 + + fs-extra@11.3.4: + dependencies: + graceful-fs: 4.2.11 + jsonfile: 6.2.0 + universalify: 2.0.1 + + fs-extra@7.0.1: + dependencies: + graceful-fs: 4.2.11 + jsonfile: 4.0.0 + universalify: 0.1.2 + + fs-extra@8.1.0: + dependencies: + graceful-fs: 4.2.11 + jsonfile: 4.0.0 + universalify: 0.1.2 + + fs-extra@9.1.0: + dependencies: + at-least-node: 1.0.0 + graceful-fs: 4.2.11 + jsonfile: 6.2.0 + universalify: 2.0.1 + + fs-minipass@3.0.3: + dependencies: + minipass: 7.1.3 + + fs.realpath@1.0.0: {} + + fsevents@2.3.3: + optional: true + + function-bind@1.1.2: {} + + generate-function@2.3.1: + dependencies: + is-property: 1.0.2 + + gensync@1.0.0-beta.2: {} + + geobuf@3.0.2: + dependencies: + concat-stream: 2.0.0 + pbf: 3.3.0 + shapefile: 0.6.6 + + geojson-dissolve@3.1.0: + dependencies: + '@turf/meta': 3.14.0 + geojson-flatten: 0.2.4 + geojson-linestring-dissolve: 0.0.1 + topojson-client: 3.1.0 + topojson-server: 3.0.1 + + geojson-flatten@0.2.4: + dependencies: + get-stdin: 6.0.0 + minimist: 1.2.0 + + geojson-linestring-dissolve@0.0.1: {} + + get-caller-file@2.0.5: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.2 + math-intrinsics: 1.1.0 + + get-port@7.1.0: {} + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.1 + + get-stdin@6.0.0: {} + + get-stream@5.2.0: + dependencies: + pump: 3.0.4 + + get-tsconfig@4.13.6: + dependencies: + resolve-pkg-maps: 1.0.0 + + gifuct-js@2.1.2: + dependencies: + js-binary-schema-parser: 2.0.3 + + github-from-package@0.0.0: {} + + glob@10.5.0: + dependencies: + foreground-child: 3.3.1 + jackspeak: 3.4.3 + minimatch: 9.0.9 + minipass: 7.1.3 + package-json-from-dist: 1.0.1 + path-scurry: 1.11.1 + + glob@13.0.6: + dependencies: + minimatch: 10.2.4 + minipass: 7.1.3 + path-scurry: 2.0.2 + + glob@7.2.3: + dependencies: + fs.realpath: 1.0.0 + inflight: 1.0.6 + inherits: 2.0.4 + minimatch: 3.1.5 + once: 1.4.0 + path-is-absolute: 1.0.1 + + global-agent@3.0.0: + dependencies: + boolean: 3.2.0 + es6-error: 4.1.1 + matcher: 3.0.0 + roarr: 2.15.4 + semver: 7.7.4 + serialize-error: 7.0.1 + optional: true + + globalthis@1.0.4: + dependencies: + define-properties: 1.2.1 + gopd: 1.2.0 + optional: true + + gopd@1.2.0: {} + + got@11.8.6: + dependencies: + '@sindresorhus/is': 4.6.0 + '@szmarczak/http-timer': 4.0.6 + '@types/cacheable-request': 6.0.3 + '@types/responselike': 1.0.3 + cacheable-lookup: 5.0.4 + cacheable-request: 7.0.4 + decompress-response: 6.0.0 + http2-wrapper: 1.0.3 + lowercase-keys: 2.0.0 + p-cancelable: 2.1.1 + responselike: 2.0.1 + + graceful-fs@4.2.11: {} + + hachure-fill@0.5.2: {} + + has-flag@4.0.0: {} + + has-property-descriptors@1.0.2: + dependencies: + es-define-property: 1.0.1 + optional: true + + has-symbols@1.1.0: {} + + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + + hasown@2.0.2: + dependencies: + function-bind: 1.1.2 + + hast-util-to-html@9.0.5: + dependencies: + '@types/hast': 3.0.4 + '@types/unist': 3.0.3 + ccount: 2.0.1 + comma-separated-tokens: 2.0.3 + hast-util-whitespace: 3.0.0 + html-void-elements: 3.0.0 + mdast-util-to-hast: 13.2.1 + property-information: 7.1.0 + space-separated-tokens: 2.0.2 + stringify-entities: 4.0.4 + zwitch: 2.0.4 + + hast-util-whitespace@3.0.0: + dependencies: + '@types/hast': 3.0.4 + + hookable@5.5.3: {} + + hosted-git-info@4.1.0: + dependencies: + lru-cache: 6.0.0 + + html-encoding-sniffer@6.0.0: + dependencies: + '@exodus/bytes': 1.15.0 + transitivePeerDependencies: + - '@noble/hashes' + + html-void-elements@3.0.0: {} + + http-cache-semantics@4.2.0: {} + + http-errors@2.0.1: + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.2 + toidentifier: 1.0.1 + + http-proxy-agent@7.0.2: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + http2-wrapper@1.0.3: + dependencies: + quick-lru: 5.1.1 + resolve-alpn: 1.2.1 + + https-proxy-agent@7.0.6: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + iconv-corefoundation@1.1.7: + dependencies: + cli-truncate: 2.1.0 + node-addon-api: 1.7.2 + optional: true + + iconv-lite@0.4.24: + dependencies: + safer-buffer: 2.1.2 + + iconv-lite@0.6.3: + dependencies: + safer-buffer: 2.1.2 + + iconv-lite@0.7.2: + dependencies: + safer-buffer: 2.1.2 + + ieee754@1.2.1: {} + + imurmurhash@0.1.4: {} + + inflight@1.0.6: + dependencies: + once: 1.4.0 + wrappy: 1.0.2 + + inherits@2.0.4: {} + + ini@1.3.8: {} + + internmap@1.0.1: {} + + internmap@2.0.3: {} + + ip-address@10.1.0: {} + + ipaddr.js@2.3.0: {} + + is-fullwidth-code-point@3.0.0: {} + + is-interactive@1.0.0: {} + + is-potential-custom-element-name@1.0.1: {} + + is-property@1.0.2: {} + + is-unicode-supported@0.1.0: {} + + is-what@5.5.0: {} + + isarray@0.0.1: {} + + isbinaryfile@4.0.10: {} + + isbinaryfile@5.0.7: {} + + isexe@2.0.0: {} + + isexe@3.1.5: {} + + jackspeak@3.4.3: + dependencies: + '@isaacs/cliui': 8.0.2 + optionalDependencies: + '@pkgjs/parseargs': 0.11.0 + + jake@10.9.4: + dependencies: + async: 3.2.6 + filelist: 1.0.6 + picocolors: 1.1.1 + + jiti@2.6.1: {} + + joi@18.0.2: + dependencies: + '@hapi/address': 5.1.1 + '@hapi/formula': 3.0.2 + '@hapi/hoek': 11.0.7 + '@hapi/pinpoint': 2.0.1 + '@hapi/tlds': 1.1.6 + '@hapi/topo': 6.0.2 + '@standard-schema/spec': 1.1.0 + + js-binary-schema-parser@2.0.3: {} + + js-tokens@4.0.0: {} + + js-yaml@4.1.1: + dependencies: + argparse: 2.0.1 + + jsdom@29.0.1: + dependencies: + '@asamuzakjp/css-color': 5.0.1 + '@asamuzakjp/dom-selector': 7.0.3 + '@bramus/specificity': 2.4.2 + '@csstools/css-syntax-patches-for-csstree': 1.1.1(css-tree@3.2.1) + '@exodus/bytes': 1.15.0 + css-tree: 3.2.1 + data-urls: 7.0.0 + decimal.js: 10.6.0 + html-encoding-sniffer: 6.0.0 + is-potential-custom-element-name: 1.0.1 + lru-cache: 11.2.7 + parse5: 8.0.0 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 6.0.1 + undici: 7.24.5 + w3c-xmlserializer: 5.0.0 + webidl-conversions: 8.0.1 + whatwg-mimetype: 5.0.0 + whatwg-url: 16.0.1 + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - '@noble/hashes' + + jsesc@3.1.0: {} + + json-buffer@3.0.1: {} + + json-schema-ref-resolver@3.0.0: + dependencies: + dequal: 2.0.3 + + json-schema-traverse@0.4.1: {} + + json-schema-traverse@1.0.0: {} + + json-stringify-safe@5.0.1: + optional: true + + json5@2.2.3: {} + + jsonfile@4.0.0: + optionalDependencies: + graceful-fs: 4.2.11 + + jsonfile@6.2.0: + dependencies: + universalify: 2.0.1 + optionalDependencies: + graceful-fs: 4.2.11 + + katex@0.16.39: + dependencies: + commander: 8.3.0 + + keyv@4.5.4: + dependencies: + json-buffer: 3.0.1 + + khroma@2.1.0: {} + + langium@4.2.1: + dependencies: + chevrotain: 11.1.2 + chevrotain-allstar: 0.3.1(chevrotain@11.1.2) + vscode-languageserver: 9.0.1 + vscode-languageserver-textdocument: 1.0.12 + vscode-uri: 3.1.0 + + layout-base@1.0.2: {} + + layout-base@2.0.1: {} + + lazy-val@1.0.5: {} + + light-my-request@6.6.0: + dependencies: + cookie: 1.1.1 + process-warning: 4.0.1 + set-cookie-parser: 2.7.2 + + lightningcss-android-arm64@1.32.0: + optional: true + + lightningcss-darwin-arm64@1.32.0: + optional: true + + lightningcss-darwin-x64@1.32.0: + optional: true + + lightningcss-freebsd-x64@1.32.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.32.0: + optional: true + + lightningcss-linux-arm64-gnu@1.32.0: + optional: true + + lightningcss-linux-arm64-musl@1.32.0: + optional: true + + lightningcss-linux-x64-gnu@1.32.0: + optional: true + + lightningcss-linux-x64-musl@1.32.0: + optional: true + + lightningcss-win32-arm64-msvc@1.32.0: + optional: true + + lightningcss-win32-x64-msvc@1.32.0: + optional: true + + lightningcss@1.32.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.32.0 + lightningcss-darwin-arm64: 1.32.0 + lightningcss-darwin-x64: 1.32.0 + lightningcss-freebsd-x64: 1.32.0 + lightningcss-linux-arm-gnueabihf: 1.32.0 + lightningcss-linux-arm64-gnu: 1.32.0 + lightningcss-linux-arm64-musl: 1.32.0 + lightningcss-linux-x64-gnu: 1.32.0 + lightningcss-linux-x64-musl: 1.32.0 + lightningcss-win32-arm64-msvc: 1.32.0 + lightningcss-win32-x64-msvc: 1.32.0 + + lodash-es@4.17.23: {} + + lodash.escaperegexp@4.1.2: {} + + lodash.isequal@4.5.0: {} + + lodash@4.17.23: {} + + log-symbols@4.1.0: + dependencies: + chalk: 4.1.2 + is-unicode-supported: 0.1.0 + + long@5.3.2: {} + + loose-envify@1.4.0: + dependencies: + js-tokens: 4.0.0 + + lottie-web@5.13.0: {} + + loupe@3.2.1: {} + + lowercase-keys@2.0.0: {} + + lru-cache@10.4.3: {} + + lru-cache@11.2.7: {} + + lru-cache@5.1.1: + dependencies: + yallist: 3.1.1 + + lru-cache@6.0.0: + dependencies: + yallist: 4.0.0 + + lru.min@1.1.4: {} + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + make-fetch-happen@14.0.3: + dependencies: + '@npmcli/agent': 3.0.0 + cacache: 19.0.1 + http-cache-semantics: 4.2.0 + minipass: 7.1.3 + minipass-fetch: 4.0.1 + minipass-flush: 1.0.5 + minipass-pipeline: 1.2.4 + negotiator: 1.0.0 + proc-log: 5.0.0 + promise-retry: 2.0.1 + ssri: 12.0.0 + transitivePeerDependencies: + - supports-color + + mark.js@8.11.1: {} + + marked@16.4.2: {} + + marked@17.0.4: {} + + matcher@3.0.0: + dependencies: + escape-string-regexp: 4.0.0 + optional: true + + math-intrinsics@1.1.0: {} + + mdast-util-to-hast@13.2.1: + dependencies: + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + '@ungap/structured-clone': 1.3.0 + devlop: 1.1.0 + micromark-util-sanitize-uri: 2.0.1 + trim-lines: 3.0.1 + unist-util-position: 5.0.0 + unist-util-visit: 5.1.0 + vfile: 6.0.3 + + mdn-data@2.27.1: {} + + mermaid@11.13.0: + dependencies: + '@braintree/sanitize-url': 7.1.2 + '@iconify/utils': 3.1.0 + '@mermaid-js/parser': 1.0.1 + '@types/d3': 7.4.3 + '@upsetjs/venn.js': 2.0.0 + cytoscape: 3.33.1 + cytoscape-cose-bilkent: 4.1.0(cytoscape@3.33.1) + cytoscape-fcose: 2.2.0(cytoscape@3.33.1) + d3: 7.9.0 + d3-sankey: 0.12.3 + dagre-d3-es: 7.0.14 + dayjs: 1.11.20 + dompurify: 3.3.3 + katex: 0.16.39 + khroma: 2.1.0 + lodash-es: 4.17.23 + marked: 16.4.2 + roughjs: 4.6.6 + stylis: 4.3.6 + ts-dedent: 2.2.0 + uuid: 11.1.0 + + micromark-util-character@2.1.1: + dependencies: + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-encode@2.0.1: {} + + micromark-util-sanitize-uri@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-encode: 2.0.1 + micromark-util-symbol: 2.0.1 + + micromark-util-symbol@2.0.1: {} + + micromark-util-types@2.0.2: {} + + mime-db@1.52.0: {} + + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + + mime@2.6.0: {} + + mime@3.0.0: {} + + mimic-fn@2.1.0: {} + + mimic-response@1.0.1: {} + + mimic-response@3.1.0: {} + + minimatch@10.2.4: + dependencies: + brace-expansion: 5.0.4 + + minimatch@3.1.5: + dependencies: + brace-expansion: 1.1.12 + + minimatch@5.1.9: + dependencies: + brace-expansion: 2.0.2 + + minimatch@9.0.9: + dependencies: + brace-expansion: 2.0.2 + + minimist@1.2.0: {} + + minimist@1.2.6: {} + + minimist@1.2.8: {} + + minipass-collect@2.0.1: + dependencies: + minipass: 7.1.3 + + minipass-fetch@4.0.1: + dependencies: + minipass: 7.1.3 + minipass-sized: 1.0.3 + minizlib: 3.1.0 + optionalDependencies: + encoding: 0.1.13 + + minipass-flush@1.0.5: + dependencies: + minipass: 3.3.6 + + minipass-pipeline@1.2.4: + dependencies: + minipass: 3.3.6 + + minipass-sized@1.0.3: + dependencies: + minipass: 3.3.6 + + minipass@3.3.6: + dependencies: + yallist: 4.0.0 + + minipass@7.1.3: {} + + minisearch@7.2.0: {} + + minizlib@3.1.0: + dependencies: + minipass: 7.1.3 + + mitt@3.0.1: {} + + mkdirp-classic@0.5.3: {} + + mkdirp@0.5.6: + dependencies: + minimist: 1.2.8 + + mlly@1.8.2: + dependencies: + acorn: 8.16.0 + pathe: 2.0.3 + pkg-types: 1.3.1 + ufo: 1.6.3 + + ms@2.1.3: {} + + mysql2@3.20.0(@types/node@22.19.15): + dependencies: + '@types/node': 22.19.15 + aws-ssl-profiles: 1.1.2 + denque: 2.1.0 + generate-function: 2.3.1 + iconv-lite: 0.7.2 + long: 5.3.2 + lru.min: 1.1.4 + named-placeholders: 1.1.6 + sql-escaper: 1.3.3 + + named-placeholders@1.1.6: + dependencies: + lru.min: 1.1.4 + + nanoid@3.3.11: {} + + napi-build-utils@2.0.0: {} + + negotiator@1.0.0: {} + + node-abi@3.89.0: + dependencies: + semver: 7.7.4 + + node-abi@4.26.0: + dependencies: + semver: 7.7.4 + + node-addon-api@1.7.2: + optional: true + + node-api-version@0.2.1: + dependencies: + semver: 7.7.4 + + node-cron@4.2.1: {} + + node-gyp@11.5.0: + dependencies: + env-paths: 2.2.1 + exponential-backoff: 3.1.3 + graceful-fs: 4.2.11 + make-fetch-happen: 14.0.3 + nopt: 8.1.0 + proc-log: 5.0.0 + semver: 7.7.4 + tar: 7.5.11 + tinyglobby: 0.2.15 + which: 5.0.0 + transitivePeerDependencies: + - supports-color + + node-releases@2.0.36: {} + + nodemailer@8.0.3: {} + + non-layered-tidy-tree-layout@2.0.2: + optional: true + + nopt@8.1.0: + dependencies: + abbrev: 3.0.1 + + normalize-url@6.1.0: {} + + object-assign@4.1.1: {} + + object-keys@1.1.1: + optional: true + + on-exit-leak-free@2.1.2: {} + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + + onetime@5.1.2: + dependencies: + mimic-fn: 2.1.0 + + oniguruma-to-es@3.1.1: + dependencies: + emoji-regex-xs: 1.0.0 + regex: 6.1.0 + regex-recursion: 6.0.2 + + ora@5.4.1: + dependencies: + bl: 4.1.0 + chalk: 4.1.2 + cli-cursor: 3.1.0 + cli-spinners: 2.9.2 + is-interactive: 1.0.0 + is-unicode-supported: 0.1.0 + log-symbols: 4.1.0 + strip-ansi: 6.0.1 + wcwidth: 1.0.1 + + p-cancelable@2.1.1: {} + + p-limit@3.1.0: + dependencies: + yocto-queue: 0.1.0 + + p-map@7.0.4: {} + + package-json-from-dist@1.0.1: {} + + package-manager-detector@1.6.0: {} + + parse-svg-path@0.1.2: {} + + parse5@8.0.0: + dependencies: + entities: 6.0.1 + + path-browserify@1.0.1: {} + + path-data-parser@0.1.0: {} + + path-is-absolute@1.0.1: {} + + path-key@3.1.1: {} + + path-scurry@1.11.1: + dependencies: + lru-cache: 10.4.3 + minipass: 7.1.3 + + path-scurry@2.0.2: + dependencies: + lru-cache: 11.2.7 + minipass: 7.1.3 + + path-source@0.1.3: + dependencies: + array-source: 0.0.4 + file-source: 0.6.1 + + pathe@1.1.2: {} + + pathe@2.0.3: {} + + pathval@2.0.1: {} + + pbf@3.3.0: + dependencies: + ieee754: 1.2.1 + resolve-protobuf-schema: 2.1.0 + + pe-library@0.4.1: {} + + pend@1.2.0: {} + + perfect-debounce@1.0.0: {} + + pg-cloudflare@1.3.0: + optional: true + + pg-connection-string@2.12.0: {} + + pg-int8@1.0.1: {} + + pg-pool@3.13.0(pg@8.20.0): + dependencies: + pg: 8.20.0 + + pg-protocol@1.13.0: {} + + pg-types@2.2.0: + dependencies: + pg-int8: 1.0.1 + postgres-array: 2.0.0 + postgres-bytea: 1.0.1 + postgres-date: 1.0.7 + postgres-interval: 1.2.0 + + pg@8.20.0: + dependencies: + pg-connection-string: 2.12.0 + pg-pool: 3.13.0(pg@8.20.0) + pg-protocol: 1.13.0 + pg-types: 2.2.0 + pgpass: 1.0.5 + optionalDependencies: + pg-cloudflare: 1.3.0 + + pgpass@1.0.5: + dependencies: + split2: 4.2.0 + + picocolors@1.1.1: {} + + picomatch@4.0.3: {} + + pino-abstract-transport@3.0.0: + dependencies: + split2: 4.2.0 + + pino-std-serializers@7.1.0: {} + + pino@10.3.1: + dependencies: + '@pinojs/redact': 0.4.0 + atomic-sleep: 1.0.0 + on-exit-leak-free: 2.1.2 + pino-abstract-transport: 3.0.0 + pino-std-serializers: 7.1.0 + process-warning: 5.0.0 + quick-format-unescaped: 4.0.4 + real-require: 0.2.0 + safe-stable-stringify: 2.5.0 + sonic-boom: 4.2.1 + thread-stream: 4.0.0 + + pkg-types@1.3.1: + dependencies: + confbox: 0.1.8 + mlly: 1.8.2 + pathe: 2.0.3 + + plist@3.1.0: + dependencies: + '@xmldom/xmldom': 0.8.11 + base64-js: 1.5.1 + xmlbuilder: 15.1.1 + + point-at-length@1.1.0: + dependencies: + abs-svg-path: 0.1.1 + isarray: 0.0.1 + parse-svg-path: 0.1.2 + + points-on-curve@0.2.0: {} + + points-on-path@0.2.1: + dependencies: + path-data-parser: 0.1.0 + points-on-curve: 0.2.0 + + postcss@8.5.8: + dependencies: + nanoid: 3.3.11 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + postgres-array@2.0.0: {} + + postgres-bytea@1.0.1: {} + + postgres-date@1.0.7: {} + + postgres-interval@1.2.0: + dependencies: + xtend: 4.0.2 + + postject@1.0.0-alpha.6: + dependencies: + commander: 9.5.0 + optional: true + + preact@10.28.4: {} + + prebuild-install@7.1.3: + dependencies: + detect-libc: 2.1.2 + expand-template: 2.0.3 + github-from-package: 0.0.0 + minimist: 1.2.8 + mkdirp-classic: 0.5.3 + napi-build-utils: 2.0.0 + node-abi: 3.89.0 + pump: 3.0.4 + rc: 1.2.8 + simple-get: 4.0.1 + tar-fs: 2.1.4 + tunnel-agent: 0.6.0 + + proc-log@5.0.0: {} + + process-warning@4.0.1: {} + + process-warning@5.0.0: {} + + progress@2.0.3: {} + + promise-retry@2.0.1: + dependencies: + err-code: 2.0.3 + retry: 0.12.0 + + proper-lockfile@4.1.2: + dependencies: + graceful-fs: 4.2.11 + retry: 0.12.0 + signal-exit: 3.0.7 + + property-information@7.1.0: {} + + protocol-buffers-schema@3.6.0: {} + + proxy-from-env@1.1.0: {} + + pump@3.0.4: + dependencies: + end-of-stream: 1.4.5 + once: 1.4.0 + + punycode@2.3.1: {} + + quick-format-unescaped@4.0.4: {} + + quick-lru@5.1.1: {} + + rc@1.2.8: + dependencies: + deep-extend: 0.6.0 + ini: 1.3.8 + minimist: 1.2.8 + strip-json-comments: 2.0.1 + + react-dom@18.3.1(react@18.3.1): + dependencies: + loose-envify: 1.4.0 + react: 18.3.1 + scheduler: 0.23.2 + + react-is@18.3.1: {} + + react-refresh@0.17.0: {} + + react-router-dom@7.13.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + react-router: 7.13.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + + react-router@7.13.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + cookie: 1.1.1 + react: 18.3.1 + set-cookie-parser: 2.7.2 + optionalDependencies: + react-dom: 18.3.1(react@18.3.1) + + react-shallow-renderer@16.15.0(react@18.3.1): + dependencies: + object-assign: 4.1.1 + react: 18.3.1 + react-is: 18.3.1 + + react-test-renderer@18.3.1(react@18.3.1): + dependencies: + react: 18.3.1 + react-is: 18.3.1 + react-shallow-renderer: 16.15.0(react@18.3.1) + scheduler: 0.23.2 + + react@18.3.1: + dependencies: + loose-envify: 1.4.0 + + read-binary-file-arch@1.0.6: + dependencies: + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + readable-stream@1.1.14: + dependencies: + core-util-is: 1.0.3 + inherits: 2.0.4 + isarray: 0.0.1 + string_decoder: 0.10.31 + + readable-stream@3.6.2: + dependencies: + inherits: 2.0.4 + string_decoder: 1.3.0 + util-deprecate: 1.0.2 + + real-require@0.2.0: {} + + regex-recursion@6.0.2: + dependencies: + regex-utilities: 2.3.0 + + regex-utilities@2.3.0: {} + + regex@6.1.0: + dependencies: + regex-utilities: 2.3.0 + + require-directory@2.1.1: {} + + require-from-string@2.0.2: {} + + resedit@1.7.2: + dependencies: + pe-library: 0.4.1 + + resolve-alpn@1.2.1: {} + + resolve-pkg-maps@1.0.0: {} + + resolve-protobuf-schema@2.1.0: + dependencies: + protocol-buffers-schema: 3.6.0 + + responselike@2.0.1: + dependencies: + lowercase-keys: 2.0.0 + + restore-cursor@3.1.0: + dependencies: + onetime: 5.1.2 + signal-exit: 3.0.7 + + ret@0.5.0: {} + + retry@0.12.0: {} + + reusify@1.1.0: {} + + rfdc@1.4.1: {} + + rimraf@2.6.3: + dependencies: + glob: 7.2.3 + + roarr@2.15.4: + dependencies: + boolean: 3.2.0 + detect-node: 2.1.0 + globalthis: 1.0.4 + json-stringify-safe: 5.0.1 + semver-compare: 1.0.0 + sprintf-js: 1.1.3 + optional: true + + robust-predicates@3.0.2: {} + + rollup@4.59.0: + dependencies: + '@types/estree': 1.0.8 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.59.0 + '@rollup/rollup-android-arm64': 4.59.0 + '@rollup/rollup-darwin-arm64': 4.59.0 + '@rollup/rollup-darwin-x64': 4.59.0 + '@rollup/rollup-freebsd-arm64': 4.59.0 + '@rollup/rollup-freebsd-x64': 4.59.0 + '@rollup/rollup-linux-arm-gnueabihf': 4.59.0 + '@rollup/rollup-linux-arm-musleabihf': 4.59.0 + '@rollup/rollup-linux-arm64-gnu': 4.59.0 + '@rollup/rollup-linux-arm64-musl': 4.59.0 + '@rollup/rollup-linux-loong64-gnu': 4.59.0 + '@rollup/rollup-linux-loong64-musl': 4.59.0 + '@rollup/rollup-linux-ppc64-gnu': 4.59.0 + '@rollup/rollup-linux-ppc64-musl': 4.59.0 + '@rollup/rollup-linux-riscv64-gnu': 4.59.0 + '@rollup/rollup-linux-riscv64-musl': 4.59.0 + '@rollup/rollup-linux-s390x-gnu': 4.59.0 + '@rollup/rollup-linux-x64-gnu': 4.59.0 + '@rollup/rollup-linux-x64-musl': 4.59.0 + '@rollup/rollup-openbsd-x64': 4.59.0 + '@rollup/rollup-openharmony-arm64': 4.59.0 + '@rollup/rollup-win32-arm64-msvc': 4.59.0 + '@rollup/rollup-win32-ia32-msvc': 4.59.0 + '@rollup/rollup-win32-x64-gnu': 4.59.0 + '@rollup/rollup-win32-x64-msvc': 4.59.0 + fsevents: 2.3.3 + + roughjs@4.6.6: + dependencies: + hachure-fill: 0.5.2 + path-data-parser: 0.1.0 + points-on-curve: 0.2.0 + points-on-path: 0.2.1 + + rw@1.3.3: {} + + rxjs@7.8.2: + dependencies: + tslib: 2.8.1 + + safe-buffer@5.2.1: {} + + safe-regex2@5.0.0: + dependencies: + ret: 0.5.0 + + safe-stable-stringify@2.5.0: {} + + safer-buffer@2.1.2: {} + + sanitize-filename@1.6.3: + dependencies: + truncate-utf8-bytes: 1.0.2 + + sax@1.5.0: {} + + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + + scheduler@0.23.2: + dependencies: + loose-envify: 1.4.0 + + search-insights@2.17.3: {} + + secure-json-parse@4.1.0: {} + + semver-compare@1.0.0: + optional: true + + semver@5.7.2: {} + + semver@6.3.1: {} + + semver@7.7.4: {} + + serialize-error@7.0.1: + dependencies: + type-fest: 0.13.1 + optional: true + + set-cookie-parser@2.7.2: {} + + setprototypeof@1.2.0: {} + + shapefile@0.6.6: + dependencies: + array-source: 0.0.4 + commander: 2.20.3 + path-source: 0.1.3 + slice-source: 0.4.1 + stream-source: 0.3.5 + text-encoding: 0.6.4 + + sharp@0.34.5: + dependencies: + '@img/colour': 1.1.0 + detect-libc: 2.1.2 + semver: 7.7.4 + optionalDependencies: + '@img/sharp-darwin-arm64': 0.34.5 + '@img/sharp-darwin-x64': 0.34.5 + '@img/sharp-libvips-darwin-arm64': 1.2.4 + '@img/sharp-libvips-darwin-x64': 1.2.4 + '@img/sharp-libvips-linux-arm': 1.2.4 + '@img/sharp-libvips-linux-arm64': 1.2.4 + '@img/sharp-libvips-linux-ppc64': 1.2.4 + '@img/sharp-libvips-linux-riscv64': 1.2.4 + '@img/sharp-libvips-linux-s390x': 1.2.4 + '@img/sharp-libvips-linux-x64': 1.2.4 + '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 + '@img/sharp-libvips-linuxmusl-x64': 1.2.4 + '@img/sharp-linux-arm': 0.34.5 + '@img/sharp-linux-arm64': 0.34.5 + '@img/sharp-linux-ppc64': 0.34.5 + '@img/sharp-linux-riscv64': 0.34.5 + '@img/sharp-linux-s390x': 0.34.5 + '@img/sharp-linux-x64': 0.34.5 + '@img/sharp-linuxmusl-arm64': 0.34.5 + '@img/sharp-linuxmusl-x64': 0.34.5 + '@img/sharp-wasm32': 0.34.5 + '@img/sharp-win32-arm64': 0.34.5 + '@img/sharp-win32-ia32': 0.34.5 + '@img/sharp-win32-x64': 0.34.5 + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + shell-quote@1.8.3: {} + + shiki@2.5.0: + dependencies: + '@shikijs/core': 2.5.0 + '@shikijs/engine-javascript': 2.5.0 + '@shikijs/engine-oniguruma': 2.5.0 + '@shikijs/langs': 2.5.0 + '@shikijs/themes': 2.5.0 + '@shikijs/types': 2.5.0 + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.4 + + siginfo@2.0.0: {} + + signal-exit@3.0.7: {} + + signal-exit@4.1.0: {} + + simple-concat@1.0.1: {} + + simple-get@4.0.1: + dependencies: + decompress-response: 6.0.0 + once: 1.4.0 + simple-concat: 1.0.1 + + simple-statistics@7.8.9: {} + + simple-update-notifier@2.0.0: + dependencies: + semver: 7.7.4 + + simplify-geojson@1.0.5: + dependencies: + concat-stream: 1.4.11 + minimist: 1.2.6 + simplify-geometry: 0.0.2 + + simplify-geometry@0.0.2: {} + + slice-ansi@3.0.0: + dependencies: + ansi-styles: 4.3.0 + astral-regex: 2.0.0 + is-fullwidth-code-point: 3.0.0 + optional: true + + slice-source@0.4.1: {} + + smart-buffer@4.2.0: {} + + socks-proxy-agent@8.0.5: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + socks: 2.8.7 + transitivePeerDependencies: + - supports-color + + socks@2.8.7: + dependencies: + ip-address: 10.1.0 + smart-buffer: 4.2.0 + + sonic-boom@4.2.1: + dependencies: + atomic-sleep: 1.0.0 + + source-map-js@1.2.1: {} + + source-map-support@0.5.21: + dependencies: + buffer-from: 1.1.2 + source-map: 0.6.1 + + source-map@0.6.1: {} + + space-separated-tokens@2.0.2: {} + + speakingurl@14.0.1: {} + + split2@4.2.0: {} + + sprintf-js@1.1.3: + optional: true + + sql-escaper@1.3.3: {} + + ssri@12.0.0: + dependencies: + minipass: 7.1.3 + + stackback@0.0.2: {} + + stat-mode@1.0.0: {} + + statuses@2.0.2: {} + + std-env@3.10.0: {} + + stream-source@0.3.5: {} + + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + string-width@5.1.2: + dependencies: + eastasianwidth: 0.2.0 + emoji-regex: 9.2.2 + strip-ansi: 7.2.0 + + string_decoder@0.10.31: {} + + string_decoder@1.3.0: + dependencies: + safe-buffer: 5.2.1 + + stringify-entities@4.0.4: + dependencies: + character-entities-html4: 2.1.0 + character-entities-legacy: 3.0.0 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + strip-ansi@7.2.0: + dependencies: + ansi-regex: 6.2.2 + + strip-json-comments@2.0.1: {} + + stylis@4.3.6: {} + + sumchecker@3.0.1: + dependencies: + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + superjson@2.2.6: + dependencies: + copy-anything: 4.0.5 + + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + + supports-color@8.1.1: + dependencies: + has-flag: 4.0.0 + + symbol-tree@3.2.4: {} + + tabbable@6.4.0: {} + + tailwindcss@4.2.1: {} + + tailwindcss@4.2.2: {} + + tapable@2.3.0: {} + + tar-fs@2.1.4: + dependencies: + chownr: 1.1.4 + mkdirp-classic: 0.5.3 + pump: 3.0.4 + tar-stream: 2.2.0 + + tar-stream@2.2.0: + dependencies: + bl: 4.1.0 + end-of-stream: 1.4.5 + fs-constants: 1.0.0 + inherits: 2.0.4 + readable-stream: 3.6.2 + + tar@7.5.11: + dependencies: + '@isaacs/fs-minipass': 4.0.1 + chownr: 3.0.0 + minipass: 7.1.3 + minizlib: 3.1.0 + yallist: 5.0.0 + + temp-file@3.4.0: + dependencies: + async-exit-hook: 2.0.1 + fs-extra: 10.1.0 + + temp@0.9.4: + dependencies: + mkdirp: 0.5.6 + rimraf: 2.6.3 + + text-encoding@0.6.4: {} + + thread-stream@4.0.0: + dependencies: + real-require: 0.2.0 + + tiny-async-pool@1.3.0: + dependencies: + semver: 5.7.2 + + tiny-typed-emitter@2.1.0: {} + + tinybench@2.9.0: {} + + tinyexec@0.3.2: {} + + tinyexec@1.0.4: {} + + tinyglobby@0.2.15: + dependencies: + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + + tinypool@1.1.1: {} + + tinyrainbow@1.2.0: {} + + tinyspy@3.0.2: {} + + tldts-core@7.0.26: {} + + tldts@7.0.26: + dependencies: + tldts-core: 7.0.26 + + tmp-promise@3.0.3: + dependencies: + tmp: 0.2.5 + + tmp@0.2.5: {} + + toad-cache@3.7.0: {} + + toidentifier@1.0.1: {} + + topojson-client@3.1.0: + dependencies: + commander: 2.20.3 + + topojson-server@3.0.1: + dependencies: + commander: 2.20.3 + + tough-cookie@6.0.1: + dependencies: + tldts: 7.0.26 + + tr46@6.0.0: + dependencies: + punycode: 2.3.1 + + tree-kill@1.2.2: {} + + trim-lines@3.0.1: {} + + truncate-utf8-bytes@1.0.2: + dependencies: + utf8-byte-length: 1.0.5 + + ts-dedent@2.2.0: {} + + tslib@2.8.1: {} + + tsx@4.21.0: + dependencies: + esbuild: 0.27.3 + get-tsconfig: 4.13.6 + optionalDependencies: + fsevents: 2.3.3 + + tunnel-agent@0.6.0: + dependencies: + safe-buffer: 5.2.1 + + type-fest@0.13.1: + optional: true + + typedarray@0.0.6: {} + + typedarray@0.0.7: {} + + typescript@5.9.3: {} + + ufo@1.6.3: {} + + undici-types@6.21.0: {} + + undici-types@7.16.0: {} + + undici@6.24.1: {} + + undici@7.24.5: {} + + unique-filename@4.0.0: + dependencies: + unique-slug: 5.0.0 + + unique-slug@5.0.0: + dependencies: + imurmurhash: 0.1.4 + + unist-util-is@6.0.1: + dependencies: + '@types/unist': 3.0.3 + + unist-util-position@5.0.0: + dependencies: + '@types/unist': 3.0.3 + + unist-util-stringify-position@4.0.0: + dependencies: + '@types/unist': 3.0.3 + + unist-util-visit-parents@6.0.2: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + + unist-util-visit@5.1.0: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + unist-util-visit-parents: 6.0.2 + + universalify@0.1.2: {} + + universalify@2.0.1: {} + + update-browserslist-db@1.2.3(browserslist@4.28.1): + dependencies: + browserslist: 4.28.1 + escalade: 3.2.0 + picocolors: 1.1.1 + + uri-js@4.4.1: + dependencies: + punycode: 2.3.1 + + utf8-byte-length@1.0.5: {} + + util-deprecate@1.0.2: {} + + uuid@11.1.0: {} + + verror@1.10.1: + dependencies: + assert-plus: 1.0.0 + core-util-is: 1.0.2 + extsprintf: 1.4.1 + optional: true + + vfile-message@4.0.3: + dependencies: + '@types/unist': 3.0.3 + unist-util-stringify-position: 4.0.0 + + vfile@6.0.3: + dependencies: + '@types/unist': 3.0.3 + vfile-message: 4.0.3 + + vite-node@2.1.9(@types/node@22.19.15)(lightningcss@1.32.0): + dependencies: + cac: 6.7.14 + debug: 4.4.3 + es-module-lexer: 1.7.0 + pathe: 1.1.2 + vite: 5.4.21(@types/node@22.19.15)(lightningcss@1.32.0) + transitivePeerDependencies: + - '@types/node' + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + + vite@5.4.21(@types/node@22.19.15)(lightningcss@1.32.0): + dependencies: + esbuild: 0.21.5 + postcss: 8.5.8 + rollup: 4.59.0 + optionalDependencies: + '@types/node': 22.19.15 + fsevents: 2.3.3 + lightningcss: 1.32.0 + + vite@6.4.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0): + dependencies: + esbuild: 0.25.12 + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + postcss: 8.5.8 + rollup: 4.59.0 + tinyglobby: 0.2.15 + optionalDependencies: + '@types/node': 22.19.15 + fsevents: 2.3.3 + jiti: 2.6.1 + lightningcss: 1.32.0 + tsx: 4.21.0 + + vitepress-plugin-mermaid@2.0.17(mermaid@11.13.0)(vitepress@1.6.4(@algolia/client-search@5.49.1)(@types/node@22.19.15)(@types/react@18.3.28)(axios@1.13.6)(lightningcss@1.32.0)(postcss@8.5.8)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(search-insights@2.17.3)(typescript@5.9.3)): + dependencies: + mermaid: 11.13.0 + vitepress: 1.6.4(@algolia/client-search@5.49.1)(@types/node@22.19.15)(@types/react@18.3.28)(axios@1.13.6)(lightningcss@1.32.0)(postcss@8.5.8)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(search-insights@2.17.3)(typescript@5.9.3) + optionalDependencies: + '@mermaid-js/mermaid-mindmap': 9.3.0 + + vitepress@1.6.4(@algolia/client-search@5.49.1)(@types/node@22.19.15)(@types/react@18.3.28)(axios@1.13.6)(lightningcss@1.32.0)(postcss@8.5.8)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(search-insights@2.17.3)(typescript@5.9.3): + dependencies: + '@docsearch/css': 3.8.2 + '@docsearch/js': 3.8.2(@algolia/client-search@5.49.1)(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(search-insights@2.17.3) + '@iconify-json/simple-icons': 1.2.72 + '@shikijs/core': 2.5.0 + '@shikijs/transformers': 2.5.0 + '@shikijs/types': 2.5.0 + '@types/markdown-it': 14.1.2 + '@vitejs/plugin-vue': 5.2.4(vite@5.4.21(@types/node@22.19.15)(lightningcss@1.32.0))(vue@3.5.30(typescript@5.9.3)) + '@vue/devtools-api': 7.7.9 + '@vue/shared': 3.5.30 + '@vueuse/core': 12.8.2(typescript@5.9.3) + '@vueuse/integrations': 12.8.2(axios@1.13.6)(focus-trap@7.8.0)(typescript@5.9.3) + focus-trap: 7.8.0 + mark.js: 8.11.1 + minisearch: 7.2.0 + shiki: 2.5.0 + vite: 5.4.21(@types/node@22.19.15)(lightningcss@1.32.0) + vue: 3.5.30(typescript@5.9.3) + optionalDependencies: + postcss: 8.5.8 + transitivePeerDependencies: + - '@algolia/client-search' + - '@types/node' + - '@types/react' + - async-validator + - axios + - change-case + - drauu + - fuse.js + - idb-keyval + - jwt-decode + - less + - lightningcss + - nprogress + - qrcode + - react + - react-dom + - sass + - sass-embedded + - search-insights + - sortablejs + - stylus + - sugarss + - terser + - typescript + - universal-cookie + + vitest@2.1.9(@types/node@22.19.15)(jsdom@29.0.1)(lightningcss@1.32.0): + dependencies: + '@vitest/expect': 2.1.9 + '@vitest/mocker': 2.1.9(vite@5.4.21(@types/node@22.19.15)(lightningcss@1.32.0)) + '@vitest/pretty-format': 2.1.9 + '@vitest/runner': 2.1.9 + '@vitest/snapshot': 2.1.9 + '@vitest/spy': 2.1.9 + '@vitest/utils': 2.1.9 + chai: 5.3.3 + debug: 4.4.3 + expect-type: 1.3.0 + magic-string: 0.30.21 + pathe: 1.1.2 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinypool: 1.1.1 + tinyrainbow: 1.2.0 + vite: 5.4.21(@types/node@22.19.15)(lightningcss@1.32.0) + vite-node: 2.1.9(@types/node@22.19.15)(lightningcss@1.32.0) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 22.19.15 + jsdom: 29.0.1 + transitivePeerDependencies: + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + + vscode-jsonrpc@8.2.0: {} + + vscode-languageserver-protocol@3.17.5: + dependencies: + vscode-jsonrpc: 8.2.0 + vscode-languageserver-types: 3.17.5 + + vscode-languageserver-textdocument@1.0.12: {} + + vscode-languageserver-types@3.17.5: {} + + vscode-languageserver@9.0.1: + dependencies: + vscode-languageserver-protocol: 3.17.5 + + vscode-uri@3.1.0: {} + + vue@3.5.30(typescript@5.9.3): + dependencies: + '@vue/compiler-dom': 3.5.30 + '@vue/compiler-sfc': 3.5.30 + '@vue/runtime-dom': 3.5.30 + '@vue/server-renderer': 3.5.30(vue@3.5.30(typescript@5.9.3)) + '@vue/shared': 3.5.30 + optionalDependencies: + typescript: 5.9.3 + + w3c-xmlserializer@5.0.0: + dependencies: + xml-name-validator: 5.0.0 + + wait-on@9.0.4: + dependencies: + axios: 1.13.6 + joi: 18.0.2 + lodash: 4.17.23 + minimist: 1.2.8 + rxjs: 7.8.2 + transitivePeerDependencies: + - debug + + wcwidth@1.0.1: + dependencies: + defaults: 1.0.4 + + webidl-conversions@8.0.1: {} + + whatwg-mimetype@5.0.0: {} + + whatwg-url@16.0.1: + dependencies: + '@exodus/bytes': 1.15.0 + tr46: 6.0.0 + webidl-conversions: 8.0.1 + transitivePeerDependencies: + - '@noble/hashes' + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + which@5.0.0: + dependencies: + isexe: 3.1.5 + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + wrap-ansi@8.1.0: + dependencies: + ansi-styles: 6.2.3 + string-width: 5.1.2 + strip-ansi: 7.2.0 + + wrappy@1.0.2: {} + + ws@8.19.0: {} + + xml-name-validator@5.0.0: {} + + xmlbuilder@15.1.1: {} + + xmlchars@2.2.0: {} + + xtend@4.0.2: {} + + y18n@5.0.8: {} + + yallist@3.1.1: {} + + yallist@4.0.0: {} + + yallist@5.0.0: {} + + yargs-parser@21.1.1: {} + + yargs@17.7.2: + dependencies: + cliui: 8.0.1 + escalade: 3.2.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + string-width: 4.2.3 + y18n: 5.0.8 + yargs-parser: 21.1.1 + + yauzl@2.10.0: + dependencies: + buffer-crc32: 0.2.13 + fd-slicer: 1.1.0 + + yocto-queue@0.1.0: {} + + zwitch@2.0.4: {} diff --git a/render.yaml b/render.yaml new file mode 100644 index 00000000..fde4697c --- /dev/null +++ b/render.yaml @@ -0,0 +1,31 @@ +services: + - type: web + name: metapi + runtime: docker + repo: https://github.com/cita-777/metapi + dockerfilePath: ./docker/Dockerfile + dockerContext: . + plan: free + envVars: + - key: AUTH_TOKEN + sync: false + - key: PROXY_TOKEN + sync: false + - key: TZ + value: Asia/Shanghai + - key: PORT + value: "4000" + - key: DATA_DIR + value: /app/data + - key: NODE_ENV + value: production + - key: DB_TYPE + value: mysql + - key: DB_URL + sync: false + - key: DB_SSL + value: "true" + - key: CHECKIN_CRON + value: "0 8 * * *" + - key: BALANCE_REFRESH_CRON + value: "0 * * * *" diff --git a/scripts/desktop/generate-icons.mjs b/scripts/desktop/generate-icons.mjs new file mode 100644 index 00000000..81b67220 --- /dev/null +++ b/scripts/desktop/generate-icons.mjs @@ -0,0 +1,126 @@ +import { mkdir } from 'node:fs/promises'; +import { dirname, join } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import sharp from 'sharp'; + +export const DESKTOP_ICON_SIZE = 512; +export const DESKTOP_ICON_PADDING = 40; +export const DESKTOP_ICON_RADIUS = 96; +export const DESKTOP_TRAY_TEMPLATE_PADDING = 152; + +function createRoundedMask(size, cornerRadius) { + return Buffer.from( + `<svg width="${size}" height="${size}" viewBox="0 0 ${size} ${size}" xmlns="http://www.w3.org/2000/svg"><rect width="${size}" height="${size}" rx="${cornerRadius}" ry="${cornerRadius}" fill="#fff"/></svg>`, + ); +} + +async function renderDesktopIconBuffer({ + sourcePath, + size = DESKTOP_ICON_SIZE, + padding = DESKTOP_ICON_PADDING, + cornerRadius = DESKTOP_ICON_RADIUS, +}) { + const innerSize = size - padding * 2; + const roundedMask = createRoundedMask(innerSize, Math.min(cornerRadius, Math.floor(innerSize / 2))); + + const roundedInner = await sharp(sourcePath) + .resize(innerSize, innerSize, { + fit: 'contain', + background: { r: 0, g: 0, b: 0, alpha: 0 }, + }) + .composite([{ input: roundedMask, blend: 'dest-in' }]) + .png() + .toBuffer(); + + return sharp({ + create: { + width: size, + height: size, + channels: 4, + background: { r: 0, g: 0, b: 0, alpha: 0 }, + }, + }) + .composite([{ input: roundedInner, left: padding, top: padding }]) + .png() + .toBuffer(); +} + +async function renderTrayTemplateIconBuffer({ + sourcePath, + size = DESKTOP_ICON_SIZE, + padding = DESKTOP_TRAY_TEMPLATE_PADDING, +}) { + const innerSize = Math.max(1, size - padding * 2); + const alphaMask = await sharp(sourcePath) + .resize(innerSize, innerSize, { + fit: 'contain', + background: { r: 0, g: 0, b: 0, alpha: 0 }, + }) + .ensureAlpha() + .extractChannel('alpha') + .png() + .toBuffer(); + + return sharp({ + create: { + width: size, + height: size, + channels: 4, + background: { r: 0, g: 0, b: 0, alpha: 1 }, + }, + }) + .composite([{ input: alphaMask, left: padding, top: padding, blend: 'dest-in' }]) + .png() + .toBuffer(); +} + +export async function generateDesktopIconAssets({ + sourcePath = join(process.cwd(), 'src', 'web', 'public', 'logo.png'), + buildOutputPath = join(process.cwd(), 'build', 'desktop-icon.png'), + webOutputPath = join(process.cwd(), 'src', 'web', 'public', 'desktop-icon.png'), + trayTemplateOutputPath = join(process.cwd(), 'src', 'web', 'public', 'desktop-tray-template.png'), + size = DESKTOP_ICON_SIZE, + padding = DESKTOP_ICON_PADDING, + cornerRadius = DESKTOP_ICON_RADIUS, +} = {}) { + const [outputBuffer, trayTemplateBuffer] = await Promise.all([ + renderDesktopIconBuffer({ + sourcePath, + size, + padding, + cornerRadius, + }), + renderTrayTemplateIconBuffer({ + sourcePath, + size, + }), + ]); + + await Promise.all([ + mkdir(dirname(buildOutputPath), { recursive: true }), + mkdir(dirname(webOutputPath), { recursive: true }), + mkdir(dirname(trayTemplateOutputPath), { recursive: true }), + ]); + + await Promise.all([ + sharp(outputBuffer).toFile(buildOutputPath), + sharp(outputBuffer).toFile(webOutputPath), + sharp(trayTemplateBuffer).toFile(trayTemplateOutputPath), + ]); + + return { + buildOutputPath, + webOutputPath, + trayTemplateOutputPath, + }; +} + +const isDirectRun = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href; + +if (isDirectRun) { + const outputs = await generateDesktopIconAssets(); + console.log(`[metapi-desktop] Generated desktop icons: +- ${outputs.buildOutputPath} +- ${outputs.webOutputPath} +- ${outputs.trayTemplateOutputPath}`); +} diff --git a/scripts/desktop/generate-icons.test.ts b/scripts/desktop/generate-icons.test.ts new file mode 100644 index 00000000..e2e6151d --- /dev/null +++ b/scripts/desktop/generate-icons.test.ts @@ -0,0 +1,86 @@ +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import sharp from 'sharp'; +import { afterEach, describe, expect, it } from 'vitest'; + +const tempDirs: string[] = []; + +async function makeTempDir() { + const dir = await mkdtemp(join(tmpdir(), 'metapi-desktop-icons-')); + tempDirs.push(dir); + return dir; +} + +function alphaAt(buffer: Buffer, width: number, x: number, y: number) { + return buffer[(y * width + x) * 4 + 3]; +} + +afterEach(async () => { + await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))); +}); + +describe('generateDesktopIconAssets', () => { + it('writes rounded desktop icon outputs and a tray template asset', async () => { + const { generateDesktopIconAssets } = await import('./generate-icons.mjs'); + + const dir = await makeTempDir(); + const sourcePath = join(dir, 'logo.png'); + const buildOutputPath = join(dir, 'build.png'); + const webOutputPath = join(dir, 'desktop-icon.png'); + const trayTemplateOutputPath = join(dir, 'desktop-tray-template.png'); + + await sharp({ + create: { + width: 512, + height: 512, + channels: 4, + background: { r: 255, g: 112, b: 48, alpha: 1 }, + }, + }).png().toFile(sourcePath); + + await generateDesktopIconAssets({ + sourcePath, + buildOutputPath, + webOutputPath, + trayTemplateOutputPath, + }); + + const buildMeta = await sharp(buildOutputPath).metadata(); + const webMeta = await sharp(webOutputPath).metadata(); + const trayMeta = await sharp(trayTemplateOutputPath).metadata(); + + expect(buildMeta.width).toBe(512); + expect(buildMeta.height).toBe(512); + expect(webMeta.width).toBe(512); + expect(webMeta.height).toBe(512); + expect(trayMeta.width).toBe(512); + expect(trayMeta.height).toBe(512); + + const { data, info } = await sharp(buildOutputPath) + .ensureAlpha() + .raw() + .toBuffer({ resolveWithObject: true }); + + expect(alphaAt(data, info.width, 0, 0)).toBe(0); + expect(alphaAt(data, info.width, 12, 12)).toBe(0); + expect(alphaAt(data, info.width, Math.floor(info.width / 2), 6)).toBe(0); + expect(alphaAt(data, info.width, Math.floor(info.width / 2), Math.floor(info.height / 2))).toBe(255); + + expect(await sharp(buildOutputPath).png().toBuffer()) + .toEqual(await sharp(webOutputPath).png().toBuffer()); + + const trayBuffer = await sharp(trayTemplateOutputPath) + .ensureAlpha() + .raw() + .toBuffer({ resolveWithObject: true }); + + expect(alphaAt(trayBuffer.data, trayBuffer.info.width, 0, 0)).toBe(0); + expect(alphaAt(trayBuffer.data, trayBuffer.info.width, Math.floor(trayBuffer.info.width / 2), Math.floor(trayBuffer.info.height / 2))).toBe(255); + + const trayCenterOffset = (Math.floor(trayBuffer.info.height / 2) * trayBuffer.info.width + Math.floor(trayBuffer.info.width / 2)) * 4; + expect(trayBuffer.data[trayCenterOffset]).toBe(0); + expect(trayBuffer.data[trayCenterOffset + 1]).toBe(0); + expect(trayBuffer.data[trayCenterOffset + 2]).toBe(0); + }); +}); diff --git a/scripts/desktop/release.workflow.test.ts b/scripts/desktop/release.workflow.test.ts new file mode 100644 index 00000000..e8a4b6fe --- /dev/null +++ b/scripts/desktop/release.workflow.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +describe('release workflow', () => { + it('builds macOS arm64 and x64 on dedicated runners and verifies packaged app architecture', () => { + const workflow = readFileSync(resolve(process.cwd(), '.github/workflows/release.yml'), 'utf8'); + + expect(workflow).toContain('runner: macos-15-intel'); + expect(workflow).toContain('runner: macos-15'); + expect(workflow).toContain('expectedMacArch: x64'); + expect(workflow).toContain('expectedMacArch: arm64'); + expect(workflow).toContain('Verify packaged macOS architecture'); + expect(workflow).toContain('node scripts/desktop/verifyMacArchitecture.mjs'); + }); +}); diff --git a/scripts/desktop/verifyMacArchitecture.mjs b/scripts/desktop/verifyMacArchitecture.mjs new file mode 100644 index 00000000..650609a2 --- /dev/null +++ b/scripts/desktop/verifyMacArchitecture.mjs @@ -0,0 +1,122 @@ +import { execFileSync } from 'node:child_process'; +import { readdirSync } from 'node:fs'; +import { join, resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; + +const MAC_BINARY_SEGMENTS = ['Metapi.app', 'Contents', 'MacOS', 'Metapi']; + +function walkDirectories(rootDir) { + const queue = [rootDir]; + const files = []; + + while (queue.length > 0) { + const currentDir = queue.shift(); + if (!currentDir) { + continue; + } + + for (const entry of readdirSync(currentDir, { withFileTypes: true })) { + const entryPath = join(currentDir, entry.name); + if (entry.isDirectory()) { + queue.push(entryPath); + continue; + } + + if (entry.isFile()) { + files.push(entryPath); + } + } + } + + return files; +} + +export function findPackagedMacBinaries(releaseDir) { + const normalizedSuffix = MAC_BINARY_SEGMENTS.join('/'); + + return walkDirectories(releaseDir).filter((filePath) => + filePath.replaceAll('\\', '/').endsWith(normalizedSuffix), + ); +} + +export function normalizeExpectedMacArch(expectedArch) { + if (expectedArch === 'x64') { + return 'x86_64'; + } + + if (expectedArch === 'arm64') { + return 'arm64'; + } + + throw new Error(`Unsupported expected mac architecture: ${expectedArch}`); +} + +export function inspectBinaryArchsWithLipo(binaryPath) { + try { + return execFileSync('lipo', ['-archs', binaryPath], { encoding: 'utf8' }).trim(); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error(`Unable to detect architecture via lipo for ${binaryPath}: ${message}`); + } +} + +export function verifyMacArchitecture({ + releaseDir, + expectedArch, + inspectBinaryArchs = inspectBinaryArchsWithLipo, +}) { + const binaries = findPackagedMacBinaries(releaseDir); + if (binaries.length === 0) { + throw new Error(`No packaged macOS app binary found under ${releaseDir}`); + } + + const expectedBinaryArch = normalizeExpectedMacArch(expectedArch); + + return binaries.map((binaryPath) => { + const archs = inspectBinaryArchs(binaryPath).trim().replace(/\s+/g, ' '); + if (!archs) { + throw new Error(`Unable to detect architecture via lipo for ${binaryPath}`); + } + + if (archs !== expectedBinaryArch) { + throw new Error(`Expected ${expectedBinaryArch}-only binary but got: ${archs}`); + } + + return { binaryPath, archs }; + }); +} + +function parseArgValue(args, name) { + const index = args.indexOf(name); + if (index === -1) { + return undefined; + } + + return args[index + 1]; +} + +function main() { + const args = process.argv.slice(2); + const releaseDirArg = parseArgValue(args, '--release-dir'); + const expectedArch = parseArgValue(args, '--expected-arch'); + + if (!releaseDirArg || !expectedArch) { + throw new Error('Usage: node scripts/desktop/verifyMacArchitecture.mjs --release-dir <dir> --expected-arch <x64|arm64>'); + } + + const releaseDir = resolve(releaseDirArg); + const verifiedBinaries = verifyMacArchitecture({ releaseDir, expectedArch }); + + for (const { binaryPath, archs } of verifiedBinaries) { + console.log(`${binaryPath} => ${archs}`); + } +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + try { + main(); + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 1; + } +} diff --git a/scripts/desktop/verifyMacArchitecture.test.ts b/scripts/desktop/verifyMacArchitecture.test.ts new file mode 100644 index 00000000..f02cb282 --- /dev/null +++ b/scripts/desktop/verifyMacArchitecture.test.ts @@ -0,0 +1,54 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { tmpdir } from 'node:os'; + +import { verifyMacArchitecture } from './verifyMacArchitecture.mjs'; + +const tempDirs: string[] = []; + +function createReleaseBinary() { + const releaseDir = mkdtempSync(join(tmpdir(), 'metapi-mac-arch-')); + tempDirs.push(releaseDir); + + const binaryPath = join(releaseDir, 'mac-x64', 'Metapi.app', 'Contents', 'MacOS', 'Metapi'); + mkdirSync(dirname(binaryPath), { recursive: true }); + writeFileSync(binaryPath, 'binary'); + + return { releaseDir, binaryPath }; +} + +afterEach(() => { + while (tempDirs.length > 0) { + const dir = tempDirs.pop(); + if (dir) { + rmSync(dir, { recursive: true, force: true }); + } + } +}); + +describe('verifyMacArchitecture', () => { + it('accepts x64 binaries when lipo reports x86_64', () => { + const { releaseDir, binaryPath } = createReleaseBinary(); + + expect( + verifyMacArchitecture({ + releaseDir, + expectedArch: 'x64', + inspectBinaryArchs: () => 'x86_64', + }), + ).toEqual([{ archs: 'x86_64', binaryPath }]); + }); + + it('rejects x64 binaries when lipo reports arm64', () => { + const { releaseDir } = createReleaseBinary(); + + expect(() => + verifyMacArchitecture({ + releaseDir, + expectedArch: 'x64', + inspectBinaryArchs: () => 'arm64', + }), + ).toThrow(/Expected x86_64-only binary but got: arm64/); + }); +}); diff --git a/scripts/dev/copy-runtime-db-generated.test.ts b/scripts/dev/copy-runtime-db-generated.test.ts new file mode 100644 index 00000000..61a7d987 --- /dev/null +++ b/scripts/dev/copy-runtime-db-generated.test.ts @@ -0,0 +1,55 @@ +import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { afterEach, describe, expect, it } from 'vitest'; +import { copyRuntimeDbGeneratedAssets } from './copy-runtime-db-generated.js'; + +const tempDirs: string[] = []; + +afterEach(() => { + while (tempDirs.length > 0) { + const dir = tempDirs.pop(); + if (dir) { + rmSync(dir, { recursive: true, force: true }); + } + } +}); + +describe('copyRuntimeDbGeneratedAssets', () => { + it('copies generated runtime db artifacts and shared runtime modules into dist', () => { + const repoRoot = mkdtempSync(join(tmpdir(), 'metapi-runtime-db-assets-')); + tempDirs.push(repoRoot); + + const sourceDir = join(repoRoot, 'src', 'server', 'db', 'generated'); + const nestedDir = join(sourceDir, 'fixtures'); + const sharedDir = join(repoRoot, 'src', 'shared'); + const staleSharedDistDir = join(repoRoot, 'dist', 'shared'); + mkdirSync(nestedDir, { recursive: true }); + mkdirSync(sharedDir, { recursive: true }); + mkdirSync(staleSharedDistDir, { recursive: true }); + writeFileSync(join(sourceDir, 'mysql.bootstrap.sql'), 'create table demo ();'); + writeFileSync(join(nestedDir, 'baseline.json'), '{"ok":true}'); + writeFileSync(join(sharedDir, 'tokenRouteContract.d.ts'), 'export declare const demo: number;\n'); + writeFileSync(join(sharedDir, 'tokenRouteContract.js'), 'export const demo = 1;\n'); + writeFileSync(join(sharedDir, 'tokenRouteContract.test.ts'), 'throw new Error("should not ship");\n'); + writeFileSync(join(staleSharedDistDir, 'tokenRouteContract.test.ts'), 'stale test artifact\n'); + + copyRuntimeDbGeneratedAssets(repoRoot); + + expect( + readFileSync(join(repoRoot, 'dist', 'server', 'db', 'generated', 'mysql.bootstrap.sql'), 'utf8'), + ).toBe('create table demo ();'); + expect( + readFileSync(join(repoRoot, 'dist', 'server', 'db', 'generated', 'fixtures', 'baseline.json'), 'utf8'), + ).toBe('{"ok":true}'); + expect( + readFileSync(join(repoRoot, 'dist', 'shared', 'tokenRouteContract.js'), 'utf8'), + ).toBe('export const demo = 1;\n'); + expect( + readFileSync(join(repoRoot, 'dist', 'shared', 'tokenRouteContract.d.ts'), 'utf8'), + ).toBe('export declare const demo: number;\n'); + expect( + existsSync(join(repoRoot, 'dist', 'shared', 'tokenRouteContract.test.ts')), + ).toBe(false); + }); +}); diff --git a/scripts/dev/copy-runtime-db-generated.ts b/scripts/dev/copy-runtime-db-generated.ts new file mode 100644 index 00000000..ca75128a --- /dev/null +++ b/scripts/dev/copy-runtime-db-generated.ts @@ -0,0 +1,41 @@ +import { cpSync, existsSync, mkdirSync, rmSync, statSync } from 'node:fs'; +import { basename, dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +function resolveRepoRoot(): string { + return resolve(dirname(fileURLToPath(import.meta.url)), '../..'); +} + +function shouldCopySharedArtifact(sourcePath: string): boolean { + if (statSync(sourcePath).isDirectory()) return true; + const filename = basename(sourcePath); + return filename.endsWith('.js') || filename.endsWith('.d.ts'); +} + +export function copyRuntimeDbGeneratedAssets(repoRoot: string = resolveRepoRoot()): void { + const sourceDir = resolve(repoRoot, 'src/server/db/generated'); + const targetDir = resolve(repoRoot, 'dist/server/db/generated'); + const sharedSourceDir = resolve(repoRoot, 'src/shared'); + const sharedTargetDir = resolve(repoRoot, 'dist/shared'); + + if (!existsSync(sourceDir)) { + throw new Error(`Runtime DB generated assets directory does not exist: ${sourceDir}`); + } + + mkdirSync(dirname(targetDir), { recursive: true }); + cpSync(sourceDir, targetDir, { recursive: true, force: true }); + + if (existsSync(sharedSourceDir)) { + rmSync(sharedTargetDir, { recursive: true, force: true }); + mkdirSync(dirname(sharedTargetDir), { recursive: true }); + cpSync(sharedSourceDir, sharedTargetDir, { + recursive: true, + force: true, + filter: (sourcePath) => shouldCopySharedArtifact(sourcePath), + }); + } +} + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + copyRuntimeDbGeneratedAssets(); +} diff --git a/scripts/dev/db-smoke.ts b/scripts/dev/db-smoke.ts index 4518a558..289192a0 100644 --- a/scripts/dev/db-smoke.ts +++ b/scripts/dev/db-smoke.ts @@ -52,6 +52,22 @@ function normalizeFirstScalar(value: unknown): number | string | null { return null; } +async function selectScalar(db: any, query: ReturnType<typeof sql>): Promise<number | string | null> { + if (typeof db?.get === 'function') { + return normalizeFirstScalar(await db.get(query)); + } + if (typeof db?.all === 'function') { + return normalizeFirstScalar(await db.all(query)); + } + if (typeof db?.values === 'function') { + return normalizeFirstScalar(await db.values(query)); + } + if (typeof db?.execute === 'function') { + return normalizeFirstScalar(await db.execute(query)); + } + throw new Error('database client does not expose a scalar query method'); +} + async function main() { const options = parseArgs(process.argv.slice(2)); if (options.dbType) process.env.DB_TYPE = options.dbType; @@ -75,26 +91,23 @@ async function main() { console.log('[db-smoke] dbUrl=(empty, using default sqlite path)'); } - const pingRows = await db.execute(sql`select 1 as ok`); - const pingScalar = normalizeFirstScalar(pingRows); + const pingScalar = await selectScalar(db, sql`select 1 as ok`); if (Number(pingScalar) !== 1) { - throw new Error(`unexpected ping result: ${JSON.stringify(pingRows)}`); + throw new Error(`unexpected ping result: ${JSON.stringify(pingScalar)}`); } console.log('[db-smoke] ping ok'); - const txRows = await db.transaction(async (tx: any) => tx.execute(sql`select 1 as ok`)); - const txScalar = normalizeFirstScalar(txRows); + const txScalar = await db.transaction(async (tx: any) => selectScalar(tx, sql`select 1 as ok`)); if (Number(txScalar) !== 1) { - throw new Error(`unexpected transaction ping result: ${JSON.stringify(txRows)}`); + throw new Error(`unexpected transaction ping result: ${JSON.stringify(txScalar)}`); } console.log('[db-smoke] transaction ok'); - const versionRows = dbType === 'sqlite' - ? await db.execute(sql`select sqlite_version() as v`) - : await db.execute(sql`select version() as v`); - const version = normalizeFirstScalar(versionRows); + const version = dbType === 'sqlite' + ? await selectScalar(db, sql`select sqlite_version() as v`) + : await selectScalar(db, sql`select version() as v`); if (typeof version !== 'string' || version.trim().length === 0) { - throw new Error(`failed to read server version: ${JSON.stringify(versionRows)}`); + throw new Error(`failed to read server version: ${JSON.stringify(version)}`); } console.log(`[db-smoke] version=${version.slice(0, 120)}`); diff --git a/scripts/dev/docker.workflow.test.ts b/scripts/dev/docker.workflow.test.ts new file mode 100644 index 00000000..2242fb51 --- /dev/null +++ b/scripts/dev/docker.workflow.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +describe('docker workflows', () => { + it('publishes armv7 docker images in ci and release workflows', () => { + const ciWorkflow = readFileSync(resolve(process.cwd(), '.github/workflows/ci.yml'), 'utf8'); + const releaseWorkflow = readFileSync(resolve(process.cwd(), '.github/workflows/release.yml'), 'utf8'); + + expect(ciWorkflow).toContain('arch: armv7'); + expect(ciWorkflow).toContain('platform: linux/arm/v7'); + expect(ciWorkflow).toContain('"${tag}-armv7"'); + + expect(releaseWorkflow).toContain('arch: armv7'); + expect(releaseWorkflow).toContain('platform: linux/arm/v7'); + expect(releaseWorkflow).toContain('"${tag}-armv7"'); + }); + + it('uses an armv7-capable node base image in the Dockerfile', () => { + const dockerfile = readFileSync(resolve(process.cwd(), 'docker/Dockerfile'), 'utf8'); + + expect(dockerfile).toContain('FROM node:22-bookworm-slim AS builder'); + expect(dockerfile).toContain('FROM node:22-bookworm-slim'); + }); + + it('keeps server docker builds isolated from desktop packaging dependencies', () => { + const dockerfile = readFileSync(resolve(process.cwd(), 'docker/Dockerfile'), 'utf8'); + + expect(dockerfile).toContain('npm ci --ignore-scripts --no-audit --no-fund'); + expect(dockerfile).toContain('npm rebuild esbuild sharp better-sqlite3 --no-audit --no-fund'); + expect(dockerfile).not.toContain('npm ci --no-audit --no-fund'); + expect(dockerfile).toContain('RUN npm run build:web && npm run build:server'); + expect(dockerfile).toContain('npm prune --omit=dev --no-audit --no-fund'); + }); +}); diff --git a/scripts/dev/generate-schema-contract.ts b/scripts/dev/generate-schema-contract.ts new file mode 100644 index 00000000..5ecb5740 --- /dev/null +++ b/scripts/dev/generate-schema-contract.ts @@ -0,0 +1,23 @@ +import { existsSync, readFileSync } from 'node:fs'; +import { writeDialectArtifactFiles } from '../../src/server/db/schemaArtifactGenerator.js'; +import { + resolveGeneratedSchemaContractPath, + type SchemaContract, + writeSchemaContractFile, +} from '../../src/server/db/schemaContract.js'; + +function readPreviousSchemaContract(): SchemaContract | null { + const contractPath = resolveGeneratedSchemaContractPath(); + if (!existsSync(contractPath)) { + return null; + } + + return JSON.parse(readFileSync(contractPath, 'utf8')) as SchemaContract; +} + +const previousContract = readPreviousSchemaContract(); +const contract = writeSchemaContractFile(); +writeDialectArtifactFiles(contract, previousContract); +const tableCount = Object.keys(contract.tables).length; + +console.log(`[schema:contract] wrote ${tableCount} tables and dialect artifacts`); diff --git a/scripts/dev/generate-upgrade-fixture.ts b/scripts/dev/generate-upgrade-fixture.ts new file mode 100644 index 00000000..8dabcbfe --- /dev/null +++ b/scripts/dev/generate-upgrade-fixture.ts @@ -0,0 +1,113 @@ +import { execFileSync } from 'node:child_process'; +import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { + resolveGeneratedSchemaContractPath, + type SchemaContract, +} from '../../src/server/db/schemaContract.js'; + +interface FixtureScriptOptions { + fromRef?: string; + outputPath: string; + dropTables: string[]; + dropColumns: string[]; +} + +function parseArgs(argv: string[]): FixtureScriptOptions { + const options: FixtureScriptOptions = { + outputPath: 'src/server/db/generated/fixtures/2026-03-14-baseline.schemaContract.json', + dropTables: [], + dropColumns: [], + }; + + for (let index = 0; index < argv.length; index += 1) { + const argument = argv[index]; + if (argument === '--from-ref') { + options.fromRef = argv[index + 1]; + index += 1; + continue; + } + if (argument === '--output') { + options.outputPath = argv[index + 1]; + index += 1; + continue; + } + if (argument === '--drop-table') { + options.dropTables.push(argv[index + 1]); + index += 1; + continue; + } + if (argument === '--drop-column') { + options.dropColumns.push(argv[index + 1]); + index += 1; + } + } + + return options; +} + +function pruneContract(contract: SchemaContract, options: FixtureScriptOptions): SchemaContract { + const droppedTables = new Set(options.dropTables); + const droppedColumns = new Map<string, Set<string>>(); + + for (const entry of options.dropColumns) { + const [tableName, columnName] = entry.split('.', 2); + if (!tableName || !columnName) { + throw new Error(`invalid --drop-column value: ${entry}`); + } + const tableColumns = droppedColumns.get(tableName) ?? new Set<string>(); + tableColumns.add(columnName); + droppedColumns.set(tableName, tableColumns); + } + + const tables = Object.fromEntries( + Object.entries(contract.tables) + .filter(([tableName]) => !droppedTables.has(tableName)) + .map(([tableName, table]) => { + const removedColumns = droppedColumns.get(tableName) ?? new Set<string>(); + const columns = Object.fromEntries( + Object.entries(table.columns).filter(([columnName]) => !removedColumns.has(columnName)), + ); + return [tableName, { columns }]; + }), + ); + + const hasColumn = (tableName: string, columnName: string): boolean => + !!tables[tableName]?.columns[columnName]; + + return { + tables, + indexes: contract.indexes.filter((index) => + !!tables[index.table] && index.columns.every((columnName) => hasColumn(index.table, columnName))), + uniques: contract.uniques.filter((unique) => + !!tables[unique.table] && unique.columns.every((columnName) => hasColumn(unique.table, columnName))), + foreignKeys: contract.foreignKeys.filter((foreignKey) => + !!tables[foreignKey.table] + && !!tables[foreignKey.referencedTable] + && foreignKey.columns.every((columnName) => hasColumn(foreignKey.table, columnName)) + && foreignKey.referencedColumns.every((columnName) => hasColumn(foreignKey.referencedTable, columnName))), + }; +} + +function readContractFromRef(fromRef: string): SchemaContract { + const contractJson = execFileSync( + 'git', + ['-c', 'safe.directory=.', 'show', `${fromRef}:src/server/db/generated/schemaContract.json`], + { encoding: 'utf8' }, + ); + return JSON.parse(contractJson) as SchemaContract; +} + +function readCurrentContract(): SchemaContract { + return JSON.parse(readFileSync(resolveGeneratedSchemaContractPath(), 'utf8')) as SchemaContract; +} + +const options = parseArgs(process.argv.slice(2)); +const sourceContract = options.fromRef ? readContractFromRef(options.fromRef) : readCurrentContract(); +const contract = pruneContract(sourceContract, options); +const outputPath = resolve(process.cwd(), options.outputPath); + +mkdirSync(dirname(outputPath), { recursive: true }); +writeFileSync(outputPath, `${JSON.stringify(contract, null, 2)}\n`, 'utf8'); + +console.log(`[schema:upgrade-fixture] wrote ${outputPath}`); diff --git a/scripts/dev/harness.workflow.test.ts b/scripts/dev/harness.workflow.test.ts new file mode 100644 index 00000000..e0a9a451 --- /dev/null +++ b/scripts/dev/harness.workflow.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +describe('harness workflows', () => { + it('keeps repo drift checks wired into ci and scheduled reporting', () => { + const ciWorkflow = readFileSync(resolve(process.cwd(), '.github/workflows/ci.yml'), 'utf8'); + const driftWorkflow = readFileSync(resolve(process.cwd(), '.github/workflows/harness-drift-report.yml'), 'utf8'); + + expect(ciWorkflow).toContain('name: Repo Drift Check'); + expect(ciWorkflow).toContain('npm run repo:drift-check'); + expect(ciWorkflow).toContain('name: Test Core'); + expect(ciWorkflow).toContain('name: Build Web'); + expect(ciWorkflow).toContain('name: Typecheck'); + + expect(driftWorkflow).toContain('schedule:'); + expect(driftWorkflow).toContain('workflow_dispatch:'); + expect(driftWorkflow).toContain('npm run repo:drift-check -- --format markdown --output tmp/repo-drift-report.md --report-only'); + expect(driftWorkflow).toContain('actions/upload-artifact@v4'); + expect(driftWorkflow).toContain('repo-drift-report'); + }); +}); diff --git a/scripts/dev/repo-drift-check.test.ts b/scripts/dev/repo-drift-check.test.ts new file mode 100644 index 00000000..5e187c6f --- /dev/null +++ b/scripts/dev/repo-drift-check.test.ts @@ -0,0 +1,64 @@ +import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { formatRepoDriftReport, runRepoDriftCheck } from './repo-drift-check.js'; + +function writeWorkspaceFiles(root: string, files: Record<string, string>): void { + for (const [relativePath, contents] of Object.entries(files)) { + const fullPath = join(root, relativePath); + mkdirSync(dirname(fullPath), { recursive: true }); + writeFileSync(fullPath, contents); + } +} + +describe('repo drift check', () => { + it('separates new violations from tracked debt', () => { + const root = mkdtempSync(join(tmpdir(), 'metapi-repo-drift-')); + writeWorkspaceFiles(root, { + 'src/server/transformers/openai/responses/routeCompatibility.ts': "import type { EndpointAttemptContext } from '../../../routes/proxy/endpointFlow.js';\n", + 'src/server/proxy-core/surfaces/chatSurface.ts': 'const payload = await upstream.text();\n', + 'src/server/proxy-core/surfaces/sharedSurface.ts': "import { dispatchRuntimeRequest } from '../../routes/proxy/runtimeExecutor.js';\n", + 'src/web/pages/Accounts.tsx': "import { TokensPanel } from './Tokens.js';\n", + 'src/web/pages/Tokens.tsx': 'export const TokensPanel = () => null;\n', + }); + + const report = runRepoDriftCheck({ root }); + + expect(report.violations).toEqual(expect.arrayContaining([ + expect.objectContaining({ + ruleId: 'proxy-surface-body-read', + file: 'src/server/proxy-core/surfaces/chatSurface.ts', + }), + expect.objectContaining({ + ruleId: 'transformers-route-blind', + file: 'src/server/transformers/openai/responses/routeCompatibility.ts', + }), + ])); + expect(report.trackedDebt).toEqual(expect.arrayContaining([ + expect.objectContaining({ + ruleId: 'proxy-core-routes-proxy-import', + file: 'src/server/proxy-core/surfaces/sharedSurface.ts', + }), + expect.objectContaining({ + ruleId: 'web-page-to-page-import', + file: 'src/web/pages/Accounts.tsx', + }), + ])); + }); + + it('keeps the current repository within the first-wave ratchet', () => { + const report = runRepoDriftCheck({ root: process.cwd() }); + expect(report.violations).toEqual([]); + expect(report.trackedDebt).toEqual(expect.any(Array)); + }); + + it('can render markdown reports for scheduled cleanup jobs', () => { + const report = runRepoDriftCheck({ root: process.cwd() }); + const markdown = formatRepoDriftReport(report, 'markdown'); + + expect(markdown).toContain('# Repo Drift Report'); + expect(markdown).toContain('## Violations'); + expect(markdown).toContain('## Tracked Debt'); + }); +}); diff --git a/scripts/dev/repo-drift-check.ts b/scripts/dev/repo-drift-check.ts new file mode 100644 index 00000000..f258fa6f --- /dev/null +++ b/scripts/dev/repo-drift-check.ts @@ -0,0 +1,338 @@ +import { lstatSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { dirname, extname, relative, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import process from 'node:process'; + +type ReportFormat = 'text' | 'markdown'; +type Severity = 'violation' | 'tracked_debt'; + +type DriftFinding = { + ruleId: string; + severity: Severity; + file: string; + line: number; + message: string; + excerpt: string; +}; + +type DriftReport = { + root: string; + generatedAt: string; + violations: DriftFinding[]; + trackedDebt: DriftFinding[]; +}; + +type RuleSpec = { + id: string; + description: string; + fileFilter: (file: string) => boolean; + lineMatch: (line: string, file: string) => boolean; + message: string | ((file: string, line: string) => string); + allowlistedFiles?: Set<string>; +}; + +type RunOptions = { + root: string; +}; + +const SOURCE_EXTENSIONS = new Set(['.ts', '.tsx', '.js', '.jsx']); + +const ROUTES_PROXY_IMPORT_ALLOWLIST = new Set([ + 'src/server/proxy-core/surfaces/chatSurface.ts', + 'src/server/proxy-core/surfaces/filesSurface.ts', + 'src/server/proxy-core/surfaces/geminiSurface.ts', + 'src/server/proxy-core/surfaces/openAiResponsesSurface.ts', + 'src/server/proxy-core/surfaces/sharedSurface.ts', +]); + +const TOP_LEVEL_PAGE_IMPORT_ALLOWLIST = new Set([ + 'src/web/pages/Accounts.tsx', +]); + +function normalizeRelativePath(root: string, fullPath: string): string { + return relative(root, fullPath).replaceAll('\\', '/'); +} + +function walkFiles(root: string, currentDir = root): string[] { + const entries = readdirSync(currentDir).sort((left, right) => left.localeCompare(right, 'en')); + const files: string[] = []; + + for (const entry of entries) { + if (entry === '.git' || entry === 'node_modules' || entry === 'dist' || entry === 'coverage') { + continue; + } + + const fullPath = resolve(currentDir, entry); + const stat = lstatSync(fullPath); + if (stat.isSymbolicLink()) { + continue; + } + if (stat.isDirectory()) { + files.push(...walkFiles(root, fullPath)); + continue; + } + + const extension = extname(entry); + if (SOURCE_EXTENSIONS.has(extension)) { + files.push(fullPath); + } + } + + return files; +} + +function readWorkspaceLines(root: string, file: string): Array<{ lineNumber: number; text: string }> { + const source = readFileSync(resolve(root, file), 'utf8').replaceAll('\r\n', '\n'); + return source.split('\n').map((text, index) => ({ + lineNumber: index + 1, + text, + })); +} + +function isNonTestSource(file: string): boolean { + return !file.endsWith('.test.ts') + && !file.endsWith('.test.tsx') + && !file.endsWith('.test.js') + && !file.endsWith('.test.jsx'); +} + +function isTopLevelPageFile(file: string): boolean { + return /^src\/web\/pages\/[^/]+\.(ts|tsx|js|jsx)$/.test(file) + && isNonTestSource(file); +} + +function createRules(): RuleSpec[] { + return [ + { + id: 'transformers-route-blind', + description: 'Transformers must not import route-layer proxy helpers', + fileFilter: (file) => file.startsWith('src/server/transformers/') + && isNonTestSource(file), + lineMatch: (line) => /from\s+['"][^'"]*routes\/proxy\//.test(line), + message: 'transformer imports route-layer proxy code', + }, + { + id: 'proxy-surface-body-read', + description: 'Proxy-core surfaces should use readRuntimeResponseText() for whole-body reads', + fileFilter: (file) => file.startsWith('src/server/proxy-core/surfaces/') + && isNonTestSource(file), + lineMatch: (line) => /\.text\(/.test(line), + message: 'proxy-core surface reads a full upstream body via .text()', + }, + { + id: 'proxy-core-routes-proxy-import', + description: 'Proxy-core imports from routes/proxy are tracked debt and should not grow', + fileFilter: (file) => file.startsWith('src/server/proxy-core/') + && isNonTestSource(file), + lineMatch: (line) => /from\s+['"][^'"]*routes\/proxy\//.test(line), + message: 'proxy-core imports a helper from routes/proxy', + allowlistedFiles: ROUTES_PROXY_IMPORT_ALLOWLIST, + }, + { + id: 'web-page-to-page-import', + description: 'Top-level route pages should not import other top-level route pages', + fileFilter: (file) => isTopLevelPageFile(file), + lineMatch: (line, file) => { + const match = line.match(/from\s+['"]\.\/([^/'"]+?)(?:\.(?:js|ts|tsx|jsx))?['"]/); + if (!match) return false; + const importedPage = match[1]; + const currentPage = file.replace(/^src\/web\/pages\//, '').replace(/\.(ts|tsx|js|jsx)$/, ''); + return importedPage !== currentPage; + }, + message: (file, line) => { + const match = line.match(/from\s+['"](\.\/[^'"]+)['"]/); + const imported = match?.[1] ?? 'another page file'; + return `top-level page imports ${imported}`; + }, + allowlistedFiles: TOP_LEVEL_PAGE_IMPORT_ALLOWLIST, + }, + ]; +} + +export function runRepoDriftCheck(options: Partial<RunOptions> = {}): DriftReport { + const root = resolve(options.root ?? process.cwd()); + const report: DriftReport = { + root, + generatedAt: new Date().toISOString(), + violations: [], + trackedDebt: [], + }; + + const files = walkFiles(root).map((file) => normalizeRelativePath(root, file)); + const rules = createRules(); + + for (const rule of rules) { + for (const file of files) { + if (!rule.fileFilter(file)) continue; + + for (const { lineNumber, text } of readWorkspaceLines(root, file)) { + if (!rule.lineMatch(text, file)) continue; + + const finding: DriftFinding = { + ruleId: rule.id, + severity: rule.allowlistedFiles?.has(file) ? 'tracked_debt' : 'violation', + file, + line: lineNumber, + message: typeof rule.message === 'function' ? rule.message(file, text) : rule.message, + excerpt: text.trim(), + }; + + if (finding.severity === 'tracked_debt') { + report.trackedDebt.push(finding); + } else { + report.violations.push(finding); + } + } + } + } + + return report; +} + +function formatFindingText(finding: DriftFinding): string { + return `- [${finding.ruleId}] ${finding.file}:${finding.line} ${finding.message}\n ${finding.excerpt}`; +} + +function formatFindingMarkdownRow(finding: DriftFinding): string { + const escapedMessage = finding.message.replaceAll('|', '\\|'); + const escapedExcerpt = finding.excerpt.replaceAll('|', '\\|'); + return `| \`${finding.ruleId}\` | \`${finding.file}:${finding.line}\` | ${escapedMessage} | \`${escapedExcerpt}\` |`; +} + +export function formatRepoDriftReport(report: DriftReport, format: ReportFormat = 'text'): string { + if (format === 'markdown') { + const lines: string[] = [ + '# Repo Drift Report', + '', + `- Root: \`${report.root}\``, + `- Generated at: \`${report.generatedAt}\``, + `- Violations: **${report.violations.length}**`, + `- Tracked debt: **${report.trackedDebt.length}**`, + '', + ]; + + if (report.violations.length > 0) { + lines.push('## Violations', '', '| Rule | Location | Message | Excerpt |', '| --- | --- | --- | --- |'); + for (const finding of report.violations) { + lines.push(formatFindingMarkdownRow(finding)); + } + lines.push(''); + } else { + lines.push('## Violations', '', 'No new violations found.', ''); + } + + if (report.trackedDebt.length > 0) { + lines.push('## Tracked Debt', '', '| Rule | Location | Message | Excerpt |', '| --- | --- | --- | --- |'); + for (const finding of report.trackedDebt) { + lines.push(formatFindingMarkdownRow(finding)); + } + lines.push(''); + } else { + lines.push('## Tracked Debt', '', 'No tracked debt entries were observed.', ''); + } + + return lines.join('\n'); + } + + const lines: string[] = [ + `Repo drift report for ${report.root}`, + `Generated at ${report.generatedAt}`, + '', + `Violations: ${report.violations.length}`, + ]; + + if (report.violations.length > 0) { + lines.push(...report.violations.map(formatFindingText)); + } else { + lines.push('- none'); + } + + lines.push('', `Tracked debt: ${report.trackedDebt.length}`); + if (report.trackedDebt.length > 0) { + lines.push(...report.trackedDebt.map(formatFindingText)); + } else { + lines.push('- none'); + } + + return lines.join('\n'); +} + +type CliOptions = { + format: ReportFormat; + output?: string; + reportOnly: boolean; + root?: string; +}; + +function parseCliOptions(argv: string[]): CliOptions { + const options: CliOptions = { + format: 'text', + reportOnly: false, + }; + + const readRequiredValue = (flag: string, index: number): string => { + const value = argv[index + 1]; + if (!value || value.startsWith('--')) { + throw new Error(`${flag} requires a value`); + } + return value; + }; + + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + if (arg === '--format') { + const value = readRequiredValue(arg, index); + if (value !== 'text' && value !== 'markdown') { + throw new Error(`--format must be one of: text, markdown`); + } + options.format = value; + index += 1; + continue; + } + if (arg === '--output') { + options.output = readRequiredValue(arg, index); + index += 1; + continue; + } + if (arg === '--report-only') { + options.reportOnly = true; + continue; + } + if (arg === '--root') { + options.root = readRequiredValue(arg, index); + index += 1; + } + } + + return options; +} + +function maybeWriteReport(outputPath: string | undefined, contents: string): void { + if (!outputPath) return; + const resolved = resolve(outputPath); + mkdirSync(dirname(resolved), { recursive: true }); + writeFileSync(resolved, contents); +} + +const isMainModule = (() => { + try { + return process.argv[1] != null && fileURLToPath(import.meta.url) === resolve(process.argv[1]); + } catch { + return false; + } +})(); + +if (isMainModule) { + try { + const options = parseCliOptions(process.argv.slice(2)); + const report = runRepoDriftCheck({ root: options.root }); + const contents = formatRepoDriftReport(report, options.format); + maybeWriteReport(options.output, contents); + console.log(contents); + process.exit(report.violations.length > 0 && !options.reportOnly ? 1 : 0); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.error(message); + process.exit(1); + } +} diff --git a/src/desktop/iconAssets.test.ts b/src/desktop/iconAssets.test.ts new file mode 100644 index 00000000..caea7524 --- /dev/null +++ b/src/desktop/iconAssets.test.ts @@ -0,0 +1,29 @@ +import { readFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; + +describe('desktop icon assets', () => { + it('keeps runtime icon paths aligned to the generated desktop icon', async () => { + const { + DESKTOP_BUILD_ICON_RELATIVE_PATH, + DESKTOP_RUNTIME_ICON_RELATIVE_PATH, + DESKTOP_TRAY_TEMPLATE_ICON_RELATIVE_PATH, + getDesktopRuntimeIconPath, + getDesktopTrayIconPath, + } = await import('./iconAssets.js'); + + expect(DESKTOP_RUNTIME_ICON_RELATIVE_PATH).toBe(join('dist', 'web', 'desktop-icon.png')); + expect(DESKTOP_BUILD_ICON_RELATIVE_PATH).toBe(join('build', 'desktop-icon.png')); + expect(DESKTOP_TRAY_TEMPLATE_ICON_RELATIVE_PATH).toBe(join('dist', 'web', 'desktop-tray-template.png')); + expect(getDesktopRuntimeIconPath('/app')).toBe(join('/app', 'dist', 'web', 'desktop-icon.png')); + expect(getDesktopTrayIconPath('/app', 'darwin')).toBe(join('/app', 'dist', 'web', 'desktop-tray-template.png')); + expect(getDesktopTrayIconPath('/app', 'win32')).toBe(join('/app', 'dist', 'web', 'desktop-icon.png')); + }); + + it('points electron-builder at the generated desktop package icon', async () => { + const configPath = join(process.cwd(), 'electron-builder.yml'); + const config = await readFile(configPath, 'utf8'); + + expect(config).toContain('icon: build/desktop-icon.png'); + }); +}); diff --git a/src/desktop/iconAssets.ts b/src/desktop/iconAssets.ts new file mode 100644 index 00000000..92ec1481 --- /dev/null +++ b/src/desktop/iconAssets.ts @@ -0,0 +1,20 @@ +import { join } from 'node:path'; + +export const DESKTOP_RUNTIME_ICON_RELATIVE_PATH = join('dist', 'web', 'desktop-icon.png'); +export const DESKTOP_BUILD_ICON_RELATIVE_PATH = join('build', 'desktop-icon.png'); +export const DESKTOP_TRAY_TEMPLATE_ICON_RELATIVE_PATH = join('dist', 'web', 'desktop-tray-template.png'); +export const DESKTOP_ICON_PUBLIC_PATH = '/desktop-icon.png'; + +export function getDesktopRuntimeIconPath(appPath: string) { + return join(appPath, DESKTOP_RUNTIME_ICON_RELATIVE_PATH); +} + +export function getDesktopTrayTemplateIconPath(appPath: string) { + return join(appPath, DESKTOP_TRAY_TEMPLATE_ICON_RELATIVE_PATH); +} + +export function getDesktopTrayIconPath(appPath: string, platform = process.platform) { + return platform === 'darwin' + ? getDesktopTrayTemplateIconPath(appPath) + : getDesktopRuntimeIconPath(appPath); +} diff --git a/src/desktop/main.ts b/src/desktop/main.ts index f6d7ffe0..b0296739 100644 --- a/src/desktop/main.ts +++ b/src/desktop/main.ts @@ -9,7 +9,6 @@ import { } from 'electron'; import log from 'electron-log'; import electronUpdater from 'electron-updater'; -import getPort, { portNumbers } from 'get-port'; import { spawn, type ChildProcess } from 'node:child_process'; import { existsSync, mkdirSync } from 'node:fs'; import { join } from 'node:path'; @@ -18,9 +17,11 @@ import { createDesktopHealthUrl, createDesktopServerUrl, isFatalServerExit, + resolveDesktopServerPort, resolveDesktopServerWorkingDir, waitForServerReady, } from './runtime.js'; +import { getDesktopRuntimeIconPath, getDesktopTrayIconPath } from './iconAssets.js'; const { autoUpdater } = electronUpdater; @@ -51,7 +52,7 @@ function getServerEntryPath() { } function getTrayIconPath() { - return join(app.getAppPath(), 'dist', 'web', 'logo.png'); + return getDesktopTrayIconPath(app.getAppPath(), process.platform); } function getWindowIconPath() { @@ -68,12 +69,6 @@ function resolveExternalServerUrl() { return (process.env.METAPI_DESKTOP_EXTERNAL_SERVER_URL || '').trim(); } -async function resolveServerPort() { - const forcedPort = Number.parseInt(process.env.METAPI_DESKTOP_SERVER_PORT || '', 10); - if (Number.isFinite(forcedPort) && forcedPort > 0) return forcedPort; - return getPort({ port: portNumbers(4310, 4399) }); -} - function showMainWindow() { if (!mainWindow) return; if (mainWindow.isMinimized()) mainWindow.restore(); @@ -126,7 +121,15 @@ function buildTrayMenu() { function setupTray() { if (tray) return; - const trayImage = nativeImage.createFromPath(getTrayIconPath()); + let trayImage = nativeImage.createFromPath(getTrayIconPath()); + if (process.platform === 'darwin' && !trayImage.isEmpty()) { + trayImage = trayImage.resize({ + width: 18, + height: 18, + quality: 'best', + }); + trayImage.setTemplateImage(true); + } tray = new Tray(trayImage); tray.setToolTip('Metapi'); tray.setContextMenu(buildTrayMenu()); @@ -185,7 +188,7 @@ async function waitForManagedServerReady(url: string) { async function startManagedBackend() { ensureDesktopDirs(); const serverEntryPath = getServerEntryPath(); - const port = await resolveServerPort(); + const port = resolveDesktopServerPort(process.env); serverUrl = createDesktopServerUrl(port); const env = buildDesktopServerEnv({ diff --git a/src/desktop/runtime.test.ts b/src/desktop/runtime.test.ts index f2bf9575..1dbea804 100644 --- a/src/desktop/runtime.test.ts +++ b/src/desktop/runtime.test.ts @@ -3,12 +3,13 @@ import { buildDesktopServerEnv, createDesktopServerUrl, isFatalServerExit, + resolveDesktopServerPort, resolveDesktopServerWorkingDir, waitForServerReady, } from './runtime.js'; describe('desktop runtime helpers', () => { - it('builds desktop server env with loopback host and app directories', () => { + it('builds desktop server env with external listen host and app directories', () => { const env = buildDesktopServerEnv({ inheritedEnv: { AUTH_TOKEN: 'admin-token', @@ -19,7 +20,7 @@ describe('desktop runtime helpers', () => { port: 4312, }); - expect(env.HOST).toBe('127.0.0.1'); + expect(env.HOST).toBe('0.0.0.0'); expect(env.PORT).toBe('4312'); expect(env.DATA_DIR).toBe('/tmp/metapi-data'); expect(env.METAPI_LOG_DIR).toBe('/tmp/metapi-logs'); @@ -31,6 +32,16 @@ describe('desktop runtime helpers', () => { expect(createDesktopServerUrl(4312)).toBe('http://127.0.0.1:4312'); }); + it('defaults desktop backend port to 4000', () => { + expect(resolveDesktopServerPort({})).toBe(4000); + }); + + it('honors explicit desktop backend port override', () => { + expect(resolveDesktopServerPort({ + METAPI_DESKTOP_SERVER_PORT: '4312', + })).toBe(4312); + }); + it('uses resources path as backend cwd for packaged desktop builds', () => { expect(resolveDesktopServerWorkingDir({ appPath: 'C:/Users/test/AppData/Local/Programs/Metapi/resources/app.asar', diff --git a/src/desktop/runtime.ts b/src/desktop/runtime.ts index 71d0d2a7..1ae4022a 100644 --- a/src/desktop/runtime.ts +++ b/src/desktop/runtime.ts @@ -23,6 +23,7 @@ type DesktopServerWorkingDirInput = { isPackaged: boolean; }; +const DEFAULT_DESKTOP_SERVER_PORT = 4000; const DEFAULT_READY_TIMEOUT_MS = 30_000; const DEFAULT_READY_INTERVAL_MS = 250; @@ -31,9 +32,11 @@ function delay(ms: number) { } export function buildDesktopServerEnv(input: DesktopServerEnvInput): NodeJS.ProcessEnv { + const host = (input.inheritedEnv?.HOST || '0.0.0.0').trim() || '0.0.0.0'; + return { ...(input.inheritedEnv || {}), - HOST: '127.0.0.1', + HOST: host, PORT: String(input.port), DATA_DIR: input.userDataDir, METAPI_DESKTOP: '1', @@ -49,6 +52,12 @@ export function createDesktopHealthUrl(port: number): string { return `${createDesktopServerUrl(port)}/api/desktop/health`; } +export function resolveDesktopServerPort(env?: NodeJS.ProcessEnv): number { + const forcedPort = Number.parseInt(env?.METAPI_DESKTOP_SERVER_PORT || '', 10); + if (Number.isFinite(forcedPort) && forcedPort > 0) return forcedPort; + return DEFAULT_DESKTOP_SERVER_PORT; +} + export function resolveDesktopServerWorkingDir(input: DesktopServerWorkingDirInput): string { return input.isPackaged ? input.resourcesPath : input.appPath; } diff --git a/src/server/config.test.ts b/src/server/config.test.ts index 515759ac..53ea86c5 100644 --- a/src/server/config.test.ts +++ b/src/server/config.test.ts @@ -11,7 +11,7 @@ describe('buildConfig', () => { expect(config.dataDir).toBe('./data'); }); - it('keeps desktop deployments bound to loopback', () => { + it('aligns desktop deployments with server deployments for listen host', () => { const config = buildConfig({ HOST: '0.0.0.0', METAPI_DESKTOP: '1', @@ -19,7 +19,7 @@ describe('buildConfig', () => { DATA_DIR: '/tmp/metapi-data', }); - expect(config.listenHost).toBe('127.0.0.1'); + expect(config.listenHost).toBe('0.0.0.0'); expect(config.port).toBe(4312); expect(config.dataDir).toBe('/tmp/metapi-data'); }); @@ -32,6 +32,40 @@ describe('buildConfig', () => { expect(config.listenHost).toBe('127.0.0.1'); }); + it('defaults telegram api base url to the official endpoint', () => { + const config = buildConfig({}); + + expect(config.telegramApiBaseUrl).toBe('https://api.telegram.org'); + expect(config.telegramMessageThreadId).toBe(''); + }); + + it('accepts telegram message thread id from environment', () => { + const config = buildConfig({ + TELEGRAM_MESSAGE_THREAD_ID: '77', + }); + + expect(config.telegramMessageThreadId).toBe('77'); + }); + + it('ships CLI-aligned OAuth defaults', () => { + const config = buildConfig({}); + + expect(config.codexClientId).toBe('app_EMoamEEZ73f0CkXaXp7hrann'); + expect(config.codexResponsesWebsocketBeta).toBe('responses_websockets=2026-02-06'); + expect(config.claudeClientId).toBe('9d1c250a-e61b-44d9-88ed-5944d1962f5e'); + expect(config.claudeClientSecret).toBe(''); + expect(config.geminiCliClientId).toBe('681255809395-oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.googleusercontent.com'); + expect(config.geminiCliClientSecret).toBe('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'); + }); + + it('allows overriding the codex websocket beta gate from environment', () => { + const config = buildConfig({ + CODEX_RESPONSES_WEBSOCKET_BETA: 'responses_websockets=2099-01-01', + }); + + expect(config.codexResponsesWebsocketBeta).toBe('responses_websockets=2099-01-01'); + }); + it('accepts JSON request bodies larger than Fastify default 1 MiB', async () => { const app = Fastify(buildFastifyOptions(buildConfig({}))); const largeText = 'a'.repeat(2 * 1024 * 1024); @@ -51,4 +85,25 @@ describe('buildConfig', () => { expect(response.json()).toEqual({ textLength: largeText.length }); await app.close(); }); + + it('trusts forwarded client IP headers for reverse-proxy deployments', async () => { + const app = Fastify(buildFastifyOptions(buildConfig({}))); + + app.get('/ip', async (request) => ({ + ip: request.ip, + })); + + const response = await app.inject({ + method: 'GET', + url: '/ip', + remoteAddress: '10.0.0.8', + headers: { + 'x-forwarded-for': '203.0.113.5, 10.0.0.8', + }, + }); + + expect(response.statusCode).toBe(200); + expect(response.json()).toEqual({ ip: '203.0.113.5' }); + await app.close(); + }); }); diff --git a/src/server/config.ts b/src/server/config.ts index 5bef4d12..5fa73016 100644 --- a/src/server/config.ts +++ b/src/server/config.ts @@ -1,7 +1,12 @@ import 'dotenv/config'; import type { FastifyServerOptions } from 'fastify'; +import { normalizePayloadRulesConfig } from './services/payloadRules.js'; const DEFAULT_REQUEST_BODY_LIMIT = 20 * 1024 * 1024; +const DEFAULT_CODEX_CLIENT_ID = 'app_EMoamEEZ73f0CkXaXp7hrann'; +const DEFAULT_CLAUDE_CLIENT_ID = '9d1c250a-e61b-44d9-88ed-5944d1962f5e'; +const DEFAULT_GEMINI_CLI_CLIENT_ID = '681255809395-oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.googleusercontent.com'; +const DEFAULT_GEMINI_CLI_CLIENT_SECRET = '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'; function parseBoolean(value: string | undefined, fallback = false): boolean { if (value === undefined) return fallback; @@ -24,6 +29,19 @@ function parseCsvList(value: string | undefined): string[] { .filter((item) => item.length > 0); } +function parseOptionalSecret(value: string | undefined): string { + return (value || '').trim(); +} + +function parseJsonValue(value: string | undefined): unknown { + if (!value) return undefined; + try { + return JSON.parse(value); + } catch { + return undefined; + } +} + function parseDbType(value: string | undefined): 'sqlite' | 'mysql' | 'postgres' { const normalized = (value || 'sqlite').trim().toLowerCase(); if (normalized === 'mysql') return 'mysql'; @@ -32,11 +50,7 @@ function parseDbType(value: string | undefined): 'sqlite' | 'mysql' | 'postgres' } function parseListenHost(env: NodeJS.ProcessEnv): string { - const requestedHost = (env.HOST || '0.0.0.0').trim() || '0.0.0.0'; - if (parseBoolean(env.METAPI_DESKTOP, false)) { - return '127.0.0.1'; - } - return requestedHost; + return (env.HOST || '0.0.0.0').trim() || '0.0.0.0'; } export function buildConfig(env: NodeJS.ProcessEnv) { @@ -45,10 +59,24 @@ export function buildConfig(env: NodeJS.ProcessEnv) { return { authToken: env.AUTH_TOKEN || 'change-me-admin-token', proxyToken: env.PROXY_TOKEN || 'change-me-proxy-sk-token', + codexClientId: parseOptionalSecret(env.CODEX_CLIENT_ID) || DEFAULT_CODEX_CLIENT_ID, + claudeClientId: parseOptionalSecret(env.CLAUDE_CLIENT_ID) || DEFAULT_CLAUDE_CLIENT_ID, + claudeClientSecret: parseOptionalSecret(env.CLAUDE_CLIENT_SECRET), + geminiCliClientId: parseOptionalSecret(env.GEMINI_CLI_CLIENT_ID) || DEFAULT_GEMINI_CLI_CLIENT_ID, + geminiCliClientSecret: parseOptionalSecret(env.GEMINI_CLI_CLIENT_SECRET) || DEFAULT_GEMINI_CLI_CLIENT_SECRET, systemProxyUrl: env.SYSTEM_PROXY_URL || '', accountCredentialSecret: env.ACCOUNT_CREDENTIAL_SECRET || env.AUTH_TOKEN || 'change-me-admin-token', checkinCron: env.CHECKIN_CRON || '0 8 * * *', + checkinScheduleMode: (env.CHECKIN_SCHEDULE_MODE || 'cron').trim().toLowerCase() === 'interval' + ? 'interval' as const + : 'cron' as const, + checkinIntervalHours: Math.min(24, Math.max(1, Math.trunc(parseNumber(env.CHECKIN_INTERVAL_HOURS, 6)))), balanceRefreshCron: env.BALANCE_REFRESH_CRON || '0 * * * *', + logCleanupCron: env.LOG_CLEANUP_CRON || '0 6 * * *', + logCleanupConfigured: false, + logCleanupUsageLogsEnabled: parseBoolean(env.LOG_CLEANUP_USAGE_LOGS_ENABLED, false), + logCleanupProgramLogsEnabled: parseBoolean(env.LOG_CLEANUP_PROGRAM_LOGS_ENABLED, false), + logCleanupRetentionDays: Math.max(1, Math.trunc(parseNumber(env.LOG_CLEANUP_RETENTION_DAYS, 30))), webhookUrl: env.WEBHOOK_URL || '', barkUrl: env.BARK_URL || '', webhookEnabled: parseBoolean(env.WEBHOOK_ENABLED, true), @@ -56,8 +84,11 @@ export function buildConfig(env: NodeJS.ProcessEnv) { serverChanEnabled: parseBoolean(env.SERVERCHAN_ENABLED, true), serverChanKey: env.SERVERCHAN_KEY || '', telegramEnabled: parseBoolean(env.TELEGRAM_ENABLED, false), + telegramApiBaseUrl: 'https://api.telegram.org', telegramBotToken: env.TELEGRAM_BOT_TOKEN || '', telegramChatId: env.TELEGRAM_CHAT_ID || '', + telegramUseSystemProxy: parseBoolean(env.TELEGRAM_USE_SYSTEM_PROXY, false), + telegramMessageThreadId: (env.TELEGRAM_MESSAGE_THREAD_ID || '').trim(), smtpEnabled: parseBoolean(env.SMTP_ENABLED, false), smtpHost: env.SMTP_HOST || '', smtpPort: parseInt(env.SMTP_PORT || '587'), @@ -77,8 +108,28 @@ export function buildConfig(env: NodeJS.ProcessEnv) { requestBodyLimit: DEFAULT_REQUEST_BODY_LIMIT, routingFallbackUnitCost: Math.max(1e-6, parseNumber(env.ROUTING_FALLBACK_UNIT_COST, 1)), tokenRouterCacheTtlMs: Math.max(100, Math.trunc(parseNumber(env.TOKEN_ROUTER_CACHE_TTL_MS, 1_500))), + proxyMaxChannelAttempts: Math.max(1, Math.trunc(parseNumber(env.PROXY_MAX_CHANNEL_ATTEMPTS, 3))), + proxyStickySessionEnabled: parseBoolean(env.PROXY_STICKY_SESSION_ENABLED, true), + proxyStickySessionTtlMs: Math.max(30_000, Math.trunc(parseNumber(env.PROXY_STICKY_SESSION_TTL_MS, 30 * 60 * 1000))), + proxySessionChannelConcurrencyLimit: Math.max(0, Math.trunc(parseNumber(env.PROXY_SESSION_CHANNEL_CONCURRENCY_LIMIT, 2))), + proxySessionChannelQueueWaitMs: Math.max(0, Math.trunc(parseNumber(env.PROXY_SESSION_CHANNEL_QUEUE_WAIT_MS, 1_500))), + proxySessionChannelLeaseTtlMs: Math.max(5_000, Math.trunc(parseNumber(env.PROXY_SESSION_CHANNEL_LEASE_TTL_MS, 90_000))), + proxySessionChannelLeaseKeepaliveMs: Math.max(1_000, Math.trunc(parseNumber(env.PROXY_SESSION_CHANNEL_LEASE_KEEPALIVE_MS, 15_000))), + codexUpstreamWebsocketEnabled: parseBoolean(env.CODEX_UPSTREAM_WEBSOCKET_ENABLED, false), proxyLogRetentionDays: Math.max(0, Math.trunc(parseNumber(env.PROXY_LOG_RETENTION_DAYS, 30))), proxyLogRetentionPruneIntervalMinutes: Math.max(1, Math.trunc(parseNumber(env.PROXY_LOG_RETENTION_PRUNE_INTERVAL_MINUTES, 30))), + proxyFileRetentionDays: Math.max(0, Math.trunc(parseNumber(env.PROXY_FILE_RETENTION_DAYS, 30))), + proxyFileRetentionPruneIntervalMinutes: Math.max(1, Math.trunc(parseNumber(env.PROXY_FILE_RETENTION_PRUNE_INTERVAL_MINUTES, 60))), + proxyErrorKeywords: parseCsvList(env.PROXY_ERROR_KEYWORDS), + proxyEmptyContentFailEnabled: parseBoolean(env.PROXY_EMPTY_CONTENT_FAIL, false), + globalBlockedBrands: [] as string[], + globalAllowedModels: [] as string[], + codexResponsesWebsocketBeta: parseOptionalSecret(env.CODEX_RESPONSES_WEBSOCKET_BETA) || 'responses_websockets=2026-02-06', + codexHeaderDefaults: { + userAgent: parseOptionalSecret(env.CODEX_HEADER_DEFAULTS_USER_AGENT), + betaFeatures: parseOptionalSecret(env.CODEX_HEADER_DEFAULTS_BETA_FEATURES), + }, + payloadRules: normalizePayloadRulesConfig(parseJsonValue(env.PAYLOAD_RULES_JSON || env.PAYLOAD_RULES)), routingWeights: { baseWeightFactor: parseNumber(env.BASE_WEIGHT_FACTOR, 0.5), valueScoreFactor: parseNumber(env.VALUE_SCORE_FACTOR, 0.5), @@ -96,6 +147,7 @@ export function buildFastifyOptions( ): FastifyServerOptions { return { logger: true, + trustProxy: true, bodyLimit: appConfig.requestBodyLimit, }; } diff --git a/src/server/db/accountTokenSchemaCompatibility.ts b/src/server/db/accountTokenSchemaCompatibility.ts new file mode 100644 index 00000000..256ab2ea --- /dev/null +++ b/src/server/db/accountTokenSchemaCompatibility.ts @@ -0,0 +1,73 @@ +export type AccountTokenSchemaDialect = 'sqlite' | 'mysql' | 'postgres'; + +export interface AccountTokenSchemaInspector { + dialect: AccountTokenSchemaDialect; + tableExists(table: string): Promise<boolean>; + columnExists(table: string, column: string): Promise<boolean>; + execute(sqlText: string): Promise<void>; +} + +export type AccountTokenColumnCompatibilitySpec = { + table: 'account_tokens'; + column: string; + addSql: Record<AccountTokenSchemaDialect, string>; +}; + +export const ACCOUNT_TOKEN_COLUMN_COMPATIBILITY_SPECS: AccountTokenColumnCompatibilitySpec[] = [ + { + table: 'account_tokens', + column: 'token_group', + addSql: { + sqlite: 'ALTER TABLE account_tokens ADD COLUMN token_group text;', + mysql: 'ALTER TABLE `account_tokens` ADD COLUMN `token_group` TEXT NULL', + postgres: 'ALTER TABLE "account_tokens" ADD COLUMN "token_group" TEXT', + }, + }, + { + table: 'account_tokens', + column: 'value_status', + addSql: { + sqlite: "ALTER TABLE account_tokens ADD COLUMN value_status text NOT NULL DEFAULT 'ready';", + mysql: "ALTER TABLE `account_tokens` ADD COLUMN `value_status` VARCHAR(191) NOT NULL DEFAULT 'ready'", + postgres: "ALTER TABLE \"account_tokens\" ADD COLUMN \"value_status\" TEXT NOT NULL DEFAULT 'ready'", + }, + }, +]; + +function normalizeSchemaErrorMessage(error: unknown): string { + if (typeof error === 'object' && error && 'message' in error) { + return String((error as { message?: unknown }).message || ''); + } + return String(error || ''); +} + +function isDuplicateColumnError(error: unknown): boolean { + const lowered = normalizeSchemaErrorMessage(error).toLowerCase(); + return lowered.includes('duplicate column') + || lowered.includes('already exists') + || lowered.includes('duplicate column name'); +} + +async function executeAddColumn(inspector: AccountTokenSchemaInspector, sqlText: string): Promise<void> { + try { + await inspector.execute(sqlText); + } catch (error) { + if (!isDuplicateColumnError(error)) { + throw error; + } + } +} + +export async function ensureAccountTokenSchemaCompatibility(inspector: AccountTokenSchemaInspector): Promise<void> { + for (const spec of ACCOUNT_TOKEN_COLUMN_COMPATIBILITY_SPECS) { + const hasTable = await inspector.tableExists(spec.table); + if (!hasTable) { + continue; + } + + const hasColumn = await inspector.columnExists(spec.table, spec.column); + if (!hasColumn) { + await executeAddColumn(inspector, spec.addSql[inspector.dialect]); + } + } +} diff --git a/src/server/db/generated/fixtures/2026-03-14-baseline.schemaContract.json b/src/server/db/generated/fixtures/2026-03-14-baseline.schemaContract.json new file mode 100644 index 00000000..648bf4bf --- /dev/null +++ b/src/server/db/generated/fixtures/2026-03-14-baseline.schemaContract.json @@ -0,0 +1,1120 @@ +{ + "tables": { + "account_tokens": { + "columns": { + "id": { + "logicalType": "integer", + "notNull": true, + "defaultValue": null, + "primaryKey": true + }, + "account_id": { + "logicalType": "integer", + "notNull": true, + "defaultValue": null, + "primaryKey": false + }, + "name": { + "logicalType": "text", + "notNull": true, + "defaultValue": null, + "primaryKey": false + }, + "token": { + "logicalType": "text", + "notNull": true, + "defaultValue": null, + "primaryKey": false + }, + "source": { + "logicalType": "text", + "notNull": false, + "defaultValue": "'manual'", + "primaryKey": false + }, + "enabled": { + "logicalType": "boolean", + "notNull": false, + "defaultValue": "true", + "primaryKey": false + }, + "is_default": { + "logicalType": "boolean", + "notNull": false, + "defaultValue": "false", + "primaryKey": false + }, + "created_at": { + "logicalType": "datetime", + "notNull": false, + "defaultValue": "datetime('now')", + "primaryKey": false + }, + "updated_at": { + "logicalType": "datetime", + "notNull": false, + "defaultValue": "datetime('now')", + "primaryKey": false + }, + "token_group": { + "logicalType": "text", + "notNull": false, + "defaultValue": null, + "primaryKey": false + } + } + }, + "accounts": { + "columns": { + "id": { + "logicalType": "integer", + "notNull": true, + "defaultValue": null, + "primaryKey": true + }, + "site_id": { + "logicalType": "integer", + "notNull": true, + "defaultValue": null, + "primaryKey": false + }, + "username": { + "logicalType": "text", + "notNull": false, + "defaultValue": null, + "primaryKey": false + }, + "access_token": { + "logicalType": "text", + "notNull": true, + "defaultValue": null, + "primaryKey": false + }, + "api_token": { + "logicalType": "text", + "notNull": false, + "defaultValue": null, + "primaryKey": false + }, + "balance": { + "logicalType": "real", + "notNull": false, + "defaultValue": "0", + "primaryKey": false + }, + "balance_used": { + "logicalType": "real", + "notNull": false, + "defaultValue": "0", + "primaryKey": false + }, + "quota": { + "logicalType": "real", + "notNull": false, + "defaultValue": "0", + "primaryKey": false + }, + "unit_cost": { + "logicalType": "real", + "notNull": false, + "defaultValue": null, + "primaryKey": false + }, + "value_score": { + "logicalType": "real", + "notNull": false, + "defaultValue": "0", + "primaryKey": false + }, + "status": { + "logicalType": "text", + "notNull": false, + "defaultValue": "'active'", + "primaryKey": false + }, + "checkin_enabled": { + "logicalType": "boolean", + "notNull": false, + "defaultValue": "true", + "primaryKey": false + }, + "last_checkin_at": { + "logicalType": "datetime", + "notNull": false, + "defaultValue": null, + "primaryKey": false + }, + "last_balance_refresh": { + "logicalType": "datetime", + "notNull": false, + "defaultValue": null, + "primaryKey": false + }, + "extra_config": { + "logicalType": "json", + "notNull": false, + "defaultValue": null, + "primaryKey": false + }, + "created_at": { + "logicalType": "datetime", + "notNull": false, + "defaultValue": "datetime('now')", + "primaryKey": false + }, + "updated_at": { + "logicalType": "datetime", + "notNull": false, + "defaultValue": "datetime('now')", + "primaryKey": false + }, + "is_pinned": { + "logicalType": "boolean", + "notNull": false, + "defaultValue": "false", + "primaryKey": false + }, + "sort_order": { + "logicalType": "integer", + "notNull": false, + "defaultValue": "0", + "primaryKey": false + } + } + }, + "checkin_logs": { + "columns": { + "id": { + "logicalType": "integer", + "notNull": true, + "defaultValue": null, + "primaryKey": true + }, + "account_id": { + "logicalType": "integer", + "notNull": true, + "defaultValue": null, + "primaryKey": false + }, + "status": { + "logicalType": "text", + "notNull": true, + "defaultValue": null, + "primaryKey": false + }, + "message": { + "logicalType": "text", + "notNull": false, + "defaultValue": null, + "primaryKey": false + }, + "reward": { + "logicalType": "text", + "notNull": false, + "defaultValue": null, + "primaryKey": false + }, + "created_at": { + "logicalType": "datetime", + "notNull": false, + "defaultValue": "datetime('now')", + "primaryKey": false + } + } + }, + "events": { + "columns": { + "id": { + "logicalType": "integer", + "notNull": true, + "defaultValue": null, + "primaryKey": true + }, + "type": { + "logicalType": "text", + "notNull": true, + "defaultValue": null, + "primaryKey": false + }, + "title": { + "logicalType": "text", + "notNull": true, + "defaultValue": null, + "primaryKey": false + }, + "message": { + "logicalType": "text", + "notNull": false, + "defaultValue": null, + "primaryKey": false + }, + "level": { + "logicalType": "text", + "notNull": true, + "defaultValue": "'info'", + "primaryKey": false + }, + "read": { + "logicalType": "boolean", + "notNull": false, + "defaultValue": "false", + "primaryKey": false + }, + "related_id": { + "logicalType": "integer", + "notNull": false, + "defaultValue": null, + "primaryKey": false + }, + "related_type": { + "logicalType": "text", + "notNull": false, + "defaultValue": null, + "primaryKey": false + }, + "created_at": { + "logicalType": "datetime", + "notNull": false, + "defaultValue": "datetime('now')", + "primaryKey": false + } + } + }, + "model_availability": { + "columns": { + "id": { + "logicalType": "integer", + "notNull": true, + "defaultValue": null, + "primaryKey": true + }, + "account_id": { + "logicalType": "integer", + "notNull": true, + "defaultValue": null, + "primaryKey": false + }, + "model_name": { + "logicalType": "text", + "notNull": true, + "defaultValue": null, + "primaryKey": false + }, + "available": { + "logicalType": "boolean", + "notNull": false, + "defaultValue": null, + "primaryKey": false + }, + "latency_ms": { + "logicalType": "integer", + "notNull": false, + "defaultValue": null, + "primaryKey": false + }, + "checked_at": { + "logicalType": "datetime", + "notNull": false, + "defaultValue": "datetime('now')", + "primaryKey": false + } + } + }, + "proxy_logs": { + "columns": { + "id": { + "logicalType": "integer", + "notNull": true, + "defaultValue": null, + "primaryKey": true + }, + "route_id": { + "logicalType": "integer", + "notNull": false, + "defaultValue": null, + "primaryKey": false + }, + "channel_id": { + "logicalType": "integer", + "notNull": false, + "defaultValue": null, + "primaryKey": false + }, + "account_id": { + "logicalType": "integer", + "notNull": false, + "defaultValue": null, + "primaryKey": false + }, + "model_requested": { + "logicalType": "text", + "notNull": false, + "defaultValue": null, + "primaryKey": false + }, + "model_actual": { + "logicalType": "text", + "notNull": false, + "defaultValue": null, + "primaryKey": false + }, + "status": { + "logicalType": "text", + "notNull": false, + "defaultValue": null, + "primaryKey": false + }, + "http_status": { + "logicalType": "integer", + "notNull": false, + "defaultValue": null, + "primaryKey": false + }, + "latency_ms": { + "logicalType": "integer", + "notNull": false, + "defaultValue": null, + "primaryKey": false + }, + "prompt_tokens": { + "logicalType": "integer", + "notNull": false, + "defaultValue": null, + "primaryKey": false + }, + "completion_tokens": { + "logicalType": "integer", + "notNull": false, + "defaultValue": null, + "primaryKey": false + }, + "total_tokens": { + "logicalType": "integer", + "notNull": false, + "defaultValue": null, + "primaryKey": false + }, + "estimated_cost": { + "logicalType": "real", + "notNull": false, + "defaultValue": null, + "primaryKey": false + }, + "error_message": { + "logicalType": "text", + "notNull": false, + "defaultValue": null, + "primaryKey": false + }, + "retry_count": { + "logicalType": "integer", + "notNull": false, + "defaultValue": "0", + "primaryKey": false + }, + "created_at": { + "logicalType": "datetime", + "notNull": false, + "defaultValue": "datetime('now')", + "primaryKey": false + }, + "billing_details": { + "logicalType": "json", + "notNull": false, + "defaultValue": null, + "primaryKey": false + } + } + }, + "route_channels": { + "columns": { + "id": { + "logicalType": "integer", + "notNull": true, + "defaultValue": null, + "primaryKey": true + }, + "route_id": { + "logicalType": "integer", + "notNull": true, + "defaultValue": null, + "primaryKey": false + }, + "account_id": { + "logicalType": "integer", + "notNull": true, + "defaultValue": null, + "primaryKey": false + }, + "token_id": { + "logicalType": "integer", + "notNull": false, + "defaultValue": null, + "primaryKey": false + }, + "priority": { + "logicalType": "integer", + "notNull": false, + "defaultValue": "0", + "primaryKey": false + }, + "weight": { + "logicalType": "integer", + "notNull": false, + "defaultValue": "10", + "primaryKey": false + }, + "enabled": { + "logicalType": "boolean", + "notNull": false, + "defaultValue": "true", + "primaryKey": false + }, + "manual_override": { + "logicalType": "boolean", + "notNull": false, + "defaultValue": "false", + "primaryKey": false + }, + "success_count": { + "logicalType": "integer", + "notNull": false, + "defaultValue": "0", + "primaryKey": false + }, + "fail_count": { + "logicalType": "integer", + "notNull": false, + "defaultValue": "0", + "primaryKey": false + }, + "total_latency_ms": { + "logicalType": "integer", + "notNull": false, + "defaultValue": "0", + "primaryKey": false + }, + "total_cost": { + "logicalType": "real", + "notNull": false, + "defaultValue": "0", + "primaryKey": false + }, + "last_used_at": { + "logicalType": "datetime", + "notNull": false, + "defaultValue": null, + "primaryKey": false + }, + "last_fail_at": { + "logicalType": "datetime", + "notNull": false, + "defaultValue": null, + "primaryKey": false + }, + "cooldown_until": { + "logicalType": "datetime", + "notNull": false, + "defaultValue": null, + "primaryKey": false + } + } + }, + "settings": { + "columns": { + "key": { + "logicalType": "text", + "notNull": true, + "defaultValue": null, + "primaryKey": true + }, + "value": { + "logicalType": "text", + "notNull": false, + "defaultValue": null, + "primaryKey": false + } + } + }, + "site_disabled_models": { + "columns": { + "id": { + "logicalType": "integer", + "notNull": true, + "defaultValue": null, + "primaryKey": true + }, + "site_id": { + "logicalType": "integer", + "notNull": true, + "defaultValue": null, + "primaryKey": false + }, + "model_name": { + "logicalType": "text", + "notNull": true, + "defaultValue": null, + "primaryKey": false + }, + "created_at": { + "logicalType": "datetime", + "notNull": false, + "defaultValue": "datetime('now')", + "primaryKey": false + } + } + }, + "sites": { + "columns": { + "id": { + "logicalType": "integer", + "notNull": true, + "defaultValue": null, + "primaryKey": true + }, + "name": { + "logicalType": "text", + "notNull": true, + "defaultValue": null, + "primaryKey": false + }, + "url": { + "logicalType": "text", + "notNull": true, + "defaultValue": null, + "primaryKey": false + }, + "platform": { + "logicalType": "text", + "notNull": true, + "defaultValue": null, + "primaryKey": false + }, + "status": { + "logicalType": "text", + "notNull": true, + "defaultValue": "'active'", + "primaryKey": false + }, + "api_key": { + "logicalType": "text", + "notNull": false, + "defaultValue": null, + "primaryKey": false + }, + "created_at": { + "logicalType": "datetime", + "notNull": false, + "defaultValue": "datetime('now')", + "primaryKey": false + }, + "updated_at": { + "logicalType": "datetime", + "notNull": false, + "defaultValue": "datetime('now')", + "primaryKey": false + }, + "is_pinned": { + "logicalType": "boolean", + "notNull": false, + "defaultValue": "false", + "primaryKey": false + }, + "sort_order": { + "logicalType": "integer", + "notNull": false, + "defaultValue": "0", + "primaryKey": false + } + } + }, + "token_model_availability": { + "columns": { + "id": { + "logicalType": "integer", + "notNull": true, + "defaultValue": null, + "primaryKey": true + }, + "token_id": { + "logicalType": "integer", + "notNull": true, + "defaultValue": null, + "primaryKey": false + }, + "model_name": { + "logicalType": "text", + "notNull": true, + "defaultValue": null, + "primaryKey": false + }, + "available": { + "logicalType": "boolean", + "notNull": false, + "defaultValue": null, + "primaryKey": false + }, + "latency_ms": { + "logicalType": "integer", + "notNull": false, + "defaultValue": null, + "primaryKey": false + }, + "checked_at": { + "logicalType": "datetime", + "notNull": false, + "defaultValue": "datetime('now')", + "primaryKey": false + } + } + }, + "token_routes": { + "columns": { + "id": { + "logicalType": "integer", + "notNull": true, + "defaultValue": null, + "primaryKey": true + }, + "model_pattern": { + "logicalType": "text", + "notNull": true, + "defaultValue": null, + "primaryKey": false + }, + "model_mapping": { + "logicalType": "json", + "notNull": false, + "defaultValue": null, + "primaryKey": false + }, + "enabled": { + "logicalType": "boolean", + "notNull": false, + "defaultValue": "true", + "primaryKey": false + }, + "created_at": { + "logicalType": "datetime", + "notNull": false, + "defaultValue": "datetime('now')", + "primaryKey": false + }, + "updated_at": { + "logicalType": "datetime", + "notNull": false, + "defaultValue": "datetime('now')", + "primaryKey": false + } + } + } + }, + "indexes": [ + { + "name": "account_tokens_account_enabled_idx", + "table": "account_tokens", + "columns": [ + "account_id", + "enabled" + ], + "unique": false + }, + { + "name": "account_tokens_account_id_idx", + "table": "account_tokens", + "columns": [ + "account_id" + ], + "unique": false + }, + { + "name": "account_tokens_enabled_idx", + "table": "account_tokens", + "columns": [ + "enabled" + ], + "unique": false + }, + { + "name": "accounts_site_id_idx", + "table": "accounts", + "columns": [ + "site_id" + ], + "unique": false + }, + { + "name": "accounts_site_status_idx", + "table": "accounts", + "columns": [ + "site_id", + "status" + ], + "unique": false + }, + { + "name": "accounts_status_idx", + "table": "accounts", + "columns": [ + "status" + ], + "unique": false + }, + { + "name": "checkin_logs_account_created_at_idx", + "table": "checkin_logs", + "columns": [ + "account_id", + "created_at" + ], + "unique": false + }, + { + "name": "checkin_logs_created_at_idx", + "table": "checkin_logs", + "columns": [ + "created_at" + ], + "unique": false + }, + { + "name": "checkin_logs_status_idx", + "table": "checkin_logs", + "columns": [ + "status" + ], + "unique": false + }, + { + "name": "events_created_at_idx", + "table": "events", + "columns": [ + "created_at" + ], + "unique": false + }, + { + "name": "events_read_created_at_idx", + "table": "events", + "columns": [ + "read", + "created_at" + ], + "unique": false + }, + { + "name": "events_type_created_at_idx", + "table": "events", + "columns": [ + "type", + "created_at" + ], + "unique": false + }, + { + "name": "model_availability_account_available_idx", + "table": "model_availability", + "columns": [ + "account_id", + "available" + ], + "unique": false + }, + { + "name": "model_availability_account_model_unique", + "table": "model_availability", + "columns": [ + "account_id", + "model_name" + ], + "unique": true + }, + { + "name": "model_availability_model_name_idx", + "table": "model_availability", + "columns": [ + "model_name" + ], + "unique": false + }, + { + "name": "proxy_logs_account_created_at_idx", + "table": "proxy_logs", + "columns": [ + "account_id", + "created_at" + ], + "unique": false + }, + { + "name": "proxy_logs_created_at_idx", + "table": "proxy_logs", + "columns": [ + "created_at" + ], + "unique": false + }, + { + "name": "proxy_logs_model_actual_created_at_idx", + "table": "proxy_logs", + "columns": [ + "model_actual", + "created_at" + ], + "unique": false + }, + { + "name": "proxy_logs_status_created_at_idx", + "table": "proxy_logs", + "columns": [ + "status", + "created_at" + ], + "unique": false + }, + { + "name": "route_channels_account_id_idx", + "table": "route_channels", + "columns": [ + "account_id" + ], + "unique": false + }, + { + "name": "route_channels_route_enabled_idx", + "table": "route_channels", + "columns": [ + "route_id", + "enabled" + ], + "unique": false + }, + { + "name": "route_channels_route_id_idx", + "table": "route_channels", + "columns": [ + "route_id" + ], + "unique": false + }, + { + "name": "route_channels_route_token_idx", + "table": "route_channels", + "columns": [ + "route_id", + "token_id" + ], + "unique": false + }, + { + "name": "route_channels_token_id_idx", + "table": "route_channels", + "columns": [ + "token_id" + ], + "unique": false + }, + { + "name": "site_disabled_models_site_id_idx", + "table": "site_disabled_models", + "columns": [ + "site_id" + ], + "unique": false + }, + { + "name": "site_disabled_models_site_model_unique", + "table": "site_disabled_models", + "columns": [ + "site_id", + "model_name" + ], + "unique": true + }, + { + "name": "sites_status_idx", + "table": "sites", + "columns": [ + "status" + ], + "unique": false + }, + { + "name": "token_model_availability_available_idx", + "table": "token_model_availability", + "columns": [ + "available" + ], + "unique": false + }, + { + "name": "token_model_availability_model_name_idx", + "table": "token_model_availability", + "columns": [ + "model_name" + ], + "unique": false + }, + { + "name": "token_model_availability_token_available_idx", + "table": "token_model_availability", + "columns": [ + "token_id", + "available" + ], + "unique": false + }, + { + "name": "token_model_availability_token_model_unique", + "table": "token_model_availability", + "columns": [ + "token_id", + "model_name" + ], + "unique": true + }, + { + "name": "token_routes_enabled_idx", + "table": "token_routes", + "columns": [ + "enabled" + ], + "unique": false + }, + { + "name": "token_routes_model_pattern_idx", + "table": "token_routes", + "columns": [ + "model_pattern" + ], + "unique": false + } + ], + "uniques": [ + { + "name": "model_availability_account_model_unique", + "table": "model_availability", + "columns": [ + "account_id", + "model_name" + ] + }, + { + "name": "site_disabled_models_site_model_unique", + "table": "site_disabled_models", + "columns": [ + "site_id", + "model_name" + ] + }, + { + "name": "token_model_availability_token_model_unique", + "table": "token_model_availability", + "columns": [ + "token_id", + "model_name" + ] + } + ], + "foreignKeys": [ + { + "table": "account_tokens", + "columns": [ + "account_id" + ], + "referencedTable": "accounts", + "referencedColumns": [ + "id" + ], + "onDelete": "CASCADE" + }, + { + "table": "accounts", + "columns": [ + "site_id" + ], + "referencedTable": "sites", + "referencedColumns": [ + "id" + ], + "onDelete": "CASCADE" + }, + { + "table": "checkin_logs", + "columns": [ + "account_id" + ], + "referencedTable": "accounts", + "referencedColumns": [ + "id" + ], + "onDelete": "CASCADE" + }, + { + "table": "model_availability", + "columns": [ + "account_id" + ], + "referencedTable": "accounts", + "referencedColumns": [ + "id" + ], + "onDelete": "CASCADE" + }, + { + "table": "route_channels", + "columns": [ + "account_id" + ], + "referencedTable": "accounts", + "referencedColumns": [ + "id" + ], + "onDelete": "CASCADE" + }, + { + "table": "route_channels", + "columns": [ + "route_id" + ], + "referencedTable": "token_routes", + "referencedColumns": [ + "id" + ], + "onDelete": "CASCADE" + }, + { + "table": "route_channels", + "columns": [ + "token_id" + ], + "referencedTable": "account_tokens", + "referencedColumns": [ + "id" + ], + "onDelete": "SET NULL" + }, + { + "table": "site_disabled_models", + "columns": [ + "site_id" + ], + "referencedTable": "sites", + "referencedColumns": [ + "id" + ], + "onDelete": "CASCADE" + }, + { + "table": "token_model_availability", + "columns": [ + "token_id" + ], + "referencedTable": "account_tokens", + "referencedColumns": [ + "id" + ], + "onDelete": "CASCADE" + } + ] +} diff --git a/src/server/db/generated/mysql.bootstrap.sql b/src/server/db/generated/mysql.bootstrap.sql new file mode 100644 index 00000000..b00926bf --- /dev/null +++ b/src/server/db/generated/mysql.bootstrap.sql @@ -0,0 +1,70 @@ +CREATE TABLE IF NOT EXISTS `sites` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `name` TEXT NOT NULL, `url` TEXT NOT NULL, `platform` TEXT NOT NULL, `status` VARCHAR(191) NOT NULL DEFAULT 'active', `api_key` TEXT, `created_at` VARCHAR(191) DEFAULT (DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s')), `updated_at` VARCHAR(191) DEFAULT (DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s')), `is_pinned` BOOLEAN DEFAULT false, `sort_order` INT DEFAULT 0, `proxy_url` TEXT, `use_system_proxy` BOOLEAN DEFAULT false, `custom_headers` JSON, `external_checkin_url` TEXT, `global_weight` DOUBLE DEFAULT 1); +CREATE TABLE IF NOT EXISTS `accounts` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `site_id` INT NOT NULL, `username` TEXT, `access_token` TEXT NOT NULL, `api_token` TEXT, `balance` DOUBLE DEFAULT 0, `balance_used` DOUBLE DEFAULT 0, `quota` DOUBLE DEFAULT 0, `unit_cost` DOUBLE, `value_score` DOUBLE DEFAULT 0, `status` VARCHAR(191) DEFAULT 'active', `checkin_enabled` BOOLEAN DEFAULT true, `last_checkin_at` VARCHAR(191), `last_balance_refresh` VARCHAR(191), `extra_config` JSON, `created_at` VARCHAR(191) DEFAULT (DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s')), `updated_at` VARCHAR(191) DEFAULT (DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s')), `is_pinned` BOOLEAN DEFAULT false, `sort_order` INT DEFAULT 0, `oauth_provider` TEXT, `oauth_account_key` TEXT, `oauth_project_id` TEXT, FOREIGN KEY (`site_id`) REFERENCES `sites`(`id`) ON DELETE CASCADE); +CREATE TABLE IF NOT EXISTS `account_tokens` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `account_id` INT NOT NULL, `name` TEXT NOT NULL, `token` TEXT NOT NULL, `source` VARCHAR(191) DEFAULT 'manual', `enabled` BOOLEAN DEFAULT true, `is_default` BOOLEAN DEFAULT false, `created_at` VARCHAR(191) DEFAULT (DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s')), `updated_at` VARCHAR(191) DEFAULT (DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s')), `token_group` TEXT, `value_status` VARCHAR(191) NOT NULL DEFAULT 'ready', FOREIGN KEY (`account_id`) REFERENCES `accounts`(`id`) ON DELETE CASCADE); +CREATE TABLE IF NOT EXISTS `checkin_logs` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `account_id` INT NOT NULL, `status` TEXT NOT NULL, `message` TEXT, `reward` TEXT, `created_at` VARCHAR(191) DEFAULT (DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s')), FOREIGN KEY (`account_id`) REFERENCES `accounts`(`id`) ON DELETE CASCADE); +CREATE TABLE IF NOT EXISTS `downstream_api_keys` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `name` TEXT NOT NULL, `key` TEXT NOT NULL, `description` TEXT, `enabled` BOOLEAN DEFAULT true, `expires_at` VARCHAR(191), `max_cost` DOUBLE, `used_cost` DOUBLE DEFAULT 0, `max_requests` INT, `used_requests` INT DEFAULT 0, `supported_models` JSON, `allowed_route_ids` JSON, `site_weight_multipliers` JSON, `last_used_at` VARCHAR(191), `created_at` VARCHAR(191) DEFAULT (DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s')), `updated_at` VARCHAR(191) DEFAULT (DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s')), `group_name` TEXT, `tags` TEXT); +CREATE TABLE IF NOT EXISTS `events` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `type` TEXT NOT NULL, `title` TEXT NOT NULL, `message` TEXT, `level` VARCHAR(191) NOT NULL DEFAULT 'info', `read` BOOLEAN DEFAULT false, `related_id` INT, `related_type` TEXT, `created_at` VARCHAR(191) DEFAULT (DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s'))); +CREATE TABLE IF NOT EXISTS `model_availability` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `account_id` INT NOT NULL, `model_name` TEXT NOT NULL, `available` BOOLEAN, `latency_ms` INT, `checked_at` VARCHAR(191) DEFAULT (DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s')), `is_manual` BOOLEAN DEFAULT false, FOREIGN KEY (`account_id`) REFERENCES `accounts`(`id`) ON DELETE CASCADE); +CREATE TABLE IF NOT EXISTS `proxy_files` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `public_id` TEXT NOT NULL, `owner_type` TEXT NOT NULL, `owner_id` TEXT NOT NULL, `filename` TEXT NOT NULL, `mime_type` TEXT NOT NULL, `purpose` TEXT, `byte_size` INT NOT NULL, `sha256` TEXT NOT NULL, `content_base64` TEXT NOT NULL, `created_at` VARCHAR(191) DEFAULT (DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s')), `updated_at` VARCHAR(191) DEFAULT (DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s')), `deleted_at` VARCHAR(191)); +CREATE TABLE IF NOT EXISTS `proxy_logs` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `route_id` INT, `channel_id` INT, `account_id` INT, `model_requested` TEXT, `model_actual` TEXT, `status` TEXT, `http_status` INT, `latency_ms` INT, `prompt_tokens` INT, `completion_tokens` INT, `total_tokens` INT, `estimated_cost` DOUBLE, `error_message` TEXT, `retry_count` INT DEFAULT 0, `created_at` VARCHAR(191) DEFAULT (DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s')), `billing_details` JSON, `downstream_api_key_id` INT, `client_family` TEXT, `client_app_id` TEXT, `client_app_name` TEXT, `client_confidence` TEXT); +CREATE TABLE IF NOT EXISTS `proxy_video_tasks` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `public_id` TEXT NOT NULL, `upstream_video_id` TEXT NOT NULL, `site_url` TEXT NOT NULL, `token_value` TEXT NOT NULL, `requested_model` TEXT, `actual_model` TEXT, `channel_id` INT, `account_id` INT, `status_snapshot` JSON, `upstream_response_meta` JSON, `last_upstream_status` INT, `last_polled_at` VARCHAR(191), `created_at` VARCHAR(191) DEFAULT (DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s')), `updated_at` VARCHAR(191) DEFAULT (DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s'))); +CREATE TABLE IF NOT EXISTS `token_routes` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `model_pattern` TEXT NOT NULL, `model_mapping` JSON, `enabled` BOOLEAN DEFAULT true, `created_at` VARCHAR(191) DEFAULT (DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s')), `updated_at` VARCHAR(191) DEFAULT (DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s')), `display_name` TEXT, `display_icon` TEXT, `decision_snapshot` JSON, `decision_refreshed_at` VARCHAR(191), `routing_strategy` VARCHAR(191) DEFAULT 'weighted', `route_mode` VARCHAR(191) DEFAULT 'pattern'); +CREATE TABLE IF NOT EXISTS `route_channels` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `route_id` INT NOT NULL, `account_id` INT NOT NULL, `token_id` INT, `source_model` TEXT, `priority` INT DEFAULT 0, `weight` INT DEFAULT 10, `enabled` BOOLEAN DEFAULT true, `manual_override` BOOLEAN DEFAULT false, `success_count` INT DEFAULT 0, `fail_count` INT DEFAULT 0, `total_latency_ms` INT DEFAULT 0, `total_cost` DOUBLE DEFAULT 0, `last_used_at` VARCHAR(191), `last_selected_at` VARCHAR(191), `last_fail_at` VARCHAR(191), `consecutive_fail_count` INT NOT NULL DEFAULT 0, `cooldown_level` INT NOT NULL DEFAULT 0, `cooldown_until` VARCHAR(191), FOREIGN KEY (`account_id`) REFERENCES `accounts`(`id`) ON DELETE CASCADE, FOREIGN KEY (`route_id`) REFERENCES `token_routes`(`id`) ON DELETE CASCADE, FOREIGN KEY (`token_id`) REFERENCES `account_tokens`(`id`) ON DELETE SET NULL); +CREATE TABLE IF NOT EXISTS `route_group_sources` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `group_route_id` INT NOT NULL, `source_route_id` INT NOT NULL, FOREIGN KEY (`group_route_id`) REFERENCES `token_routes`(`id`) ON DELETE CASCADE, FOREIGN KEY (`source_route_id`) REFERENCES `token_routes`(`id`) ON DELETE CASCADE); +CREATE TABLE IF NOT EXISTS `settings` (`key` VARCHAR(191) NOT NULL PRIMARY KEY, `value` TEXT); +CREATE TABLE IF NOT EXISTS `site_announcements` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `site_id` INT NOT NULL, `platform` TEXT NOT NULL, `source_key` TEXT NOT NULL, `title` TEXT NOT NULL, `content` TEXT NOT NULL, `level` VARCHAR(191) NOT NULL DEFAULT 'info', `source_url` TEXT, `starts_at` VARCHAR(191), `ends_at` VARCHAR(191), `upstream_created_at` VARCHAR(191), `upstream_updated_at` VARCHAR(191), `first_seen_at` VARCHAR(191) DEFAULT (DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s')), `last_seen_at` VARCHAR(191) DEFAULT (DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s')), `read_at` VARCHAR(191), `dismissed_at` VARCHAR(191), `raw_payload` TEXT, FOREIGN KEY (`site_id`) REFERENCES `sites`(`id`) ON DELETE CASCADE); +CREATE TABLE IF NOT EXISTS `site_disabled_models` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `site_id` INT NOT NULL, `model_name` TEXT NOT NULL, `created_at` VARCHAR(191) DEFAULT (DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s')), FOREIGN KEY (`site_id`) REFERENCES `sites`(`id`) ON DELETE CASCADE); +CREATE TABLE IF NOT EXISTS `token_model_availability` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `token_id` INT NOT NULL, `model_name` TEXT NOT NULL, `available` BOOLEAN, `latency_ms` INT, `checked_at` VARCHAR(191) DEFAULT (DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s')), FOREIGN KEY (`token_id`) REFERENCES `account_tokens`(`id`) ON DELETE CASCADE); +CREATE UNIQUE INDEX `downstream_api_keys_key_unique` ON `downstream_api_keys` (`key`(191)); +CREATE UNIQUE INDEX `model_availability_account_model_unique` ON `model_availability` (`account_id`, `model_name`(191)); +CREATE UNIQUE INDEX `proxy_files_public_id_unique` ON `proxy_files` (`public_id`(191)); +CREATE UNIQUE INDEX `proxy_video_tasks_public_id_unique` ON `proxy_video_tasks` (`public_id`(191)); +CREATE UNIQUE INDEX `route_group_sources_group_source_unique` ON `route_group_sources` (`group_route_id`, `source_route_id`); +CREATE UNIQUE INDEX `site_announcements_site_source_key_unique` ON `site_announcements` (`site_id`, `source_key`(191)); +CREATE UNIQUE INDEX `site_disabled_models_site_model_unique` ON `site_disabled_models` (`site_id`, `model_name`(191)); +CREATE UNIQUE INDEX `sites_platform_url_unique` ON `sites` (`platform`(191), `url`(191)); +CREATE UNIQUE INDEX `token_model_availability_token_model_unique` ON `token_model_availability` (`token_id`, `model_name`(191)); +CREATE INDEX `account_tokens_account_enabled_idx` ON `account_tokens` (`account_id`, `enabled`); +CREATE INDEX `account_tokens_account_id_idx` ON `account_tokens` (`account_id`); +CREATE INDEX `account_tokens_enabled_idx` ON `account_tokens` (`enabled`); +CREATE INDEX `accounts_oauth_identity_idx` ON `accounts` (`oauth_provider`(191), `oauth_account_key`(191), `oauth_project_id`(191)); +CREATE INDEX `accounts_oauth_provider_idx` ON `accounts` (`oauth_provider`(191)); +CREATE INDEX `accounts_site_id_idx` ON `accounts` (`site_id`); +CREATE INDEX `accounts_site_status_idx` ON `accounts` (`site_id`, `status`(191)); +CREATE INDEX `accounts_status_idx` ON `accounts` (`status`(191)); +CREATE INDEX `checkin_logs_account_created_at_idx` ON `checkin_logs` (`account_id`, `created_at`); +CREATE INDEX `checkin_logs_created_at_idx` ON `checkin_logs` (`created_at`); +CREATE INDEX `checkin_logs_status_idx` ON `checkin_logs` (`status`(191)); +CREATE INDEX `downstream_api_keys_enabled_idx` ON `downstream_api_keys` (`enabled`); +CREATE INDEX `downstream_api_keys_expires_at_idx` ON `downstream_api_keys` (`expires_at`); +CREATE INDEX `downstream_api_keys_name_idx` ON `downstream_api_keys` (`name`(191)); +CREATE INDEX `events_created_at_idx` ON `events` (`created_at`); +CREATE INDEX `events_read_created_at_idx` ON `events` (`read`, `created_at`); +CREATE INDEX `events_type_created_at_idx` ON `events` (`type`(191), `created_at`); +CREATE INDEX `model_availability_account_available_idx` ON `model_availability` (`account_id`, `available`); +CREATE INDEX `model_availability_model_name_idx` ON `model_availability` (`model_name`(191)); +CREATE INDEX `proxy_files_owner_lookup_idx` ON `proxy_files` (`owner_type`(191), `owner_id`(191), `deleted_at`); +CREATE INDEX `proxy_logs_account_created_at_idx` ON `proxy_logs` (`account_id`, `created_at`); +CREATE INDEX `proxy_logs_client_app_id_created_at_idx` ON `proxy_logs` (`client_app_id`(191), `created_at`); +CREATE INDEX `proxy_logs_client_family_created_at_idx` ON `proxy_logs` (`client_family`(191), `created_at`); +CREATE INDEX `proxy_logs_created_at_idx` ON `proxy_logs` (`created_at`); +CREATE INDEX `proxy_logs_downstream_api_key_created_at_idx` ON `proxy_logs` (`downstream_api_key_id`, `created_at`); +CREATE INDEX `proxy_logs_model_actual_created_at_idx` ON `proxy_logs` (`model_actual`(191), `created_at`); +CREATE INDEX `proxy_logs_status_created_at_idx` ON `proxy_logs` (`status`(191), `created_at`); +CREATE INDEX `proxy_video_tasks_created_at_idx` ON `proxy_video_tasks` (`created_at`); +CREATE INDEX `proxy_video_tasks_upstream_video_id_idx` ON `proxy_video_tasks` (`upstream_video_id`(191)); +CREATE INDEX `route_channels_account_id_idx` ON `route_channels` (`account_id`); +CREATE INDEX `route_channels_route_enabled_idx` ON `route_channels` (`route_id`, `enabled`); +CREATE INDEX `route_channels_route_id_idx` ON `route_channels` (`route_id`); +CREATE INDEX `route_channels_route_token_idx` ON `route_channels` (`route_id`, `token_id`); +CREATE INDEX `route_channels_token_id_idx` ON `route_channels` (`token_id`); +CREATE INDEX `route_group_sources_source_route_id_idx` ON `route_group_sources` (`source_route_id`); +CREATE INDEX `site_announcements_read_at_idx` ON `site_announcements` (`read_at`); +CREATE INDEX `site_announcements_site_id_first_seen_at_idx` ON `site_announcements` (`site_id`, `first_seen_at`); +CREATE INDEX `site_disabled_models_site_id_idx` ON `site_disabled_models` (`site_id`); +CREATE INDEX `sites_status_idx` ON `sites` (`status`(191)); +CREATE INDEX `token_model_availability_available_idx` ON `token_model_availability` (`available`); +CREATE INDEX `token_model_availability_model_name_idx` ON `token_model_availability` (`model_name`(191)); +CREATE INDEX `token_model_availability_token_available_idx` ON `token_model_availability` (`token_id`, `available`); +CREATE INDEX `token_routes_enabled_idx` ON `token_routes` (`enabled`); +CREATE INDEX `token_routes_model_pattern_idx` ON `token_routes` (`model_pattern`(191)); diff --git a/src/server/db/generated/mysql.upgrade.sql b/src/server/db/generated/mysql.upgrade.sql new file mode 100644 index 00000000..39c10184 --- /dev/null +++ b/src/server/db/generated/mysql.upgrade.sql @@ -0,0 +1 @@ +-- no schema changes detected for mysql diff --git a/src/server/db/generated/postgres.bootstrap.sql b/src/server/db/generated/postgres.bootstrap.sql new file mode 100644 index 00000000..f188938a --- /dev/null +++ b/src/server/db/generated/postgres.bootstrap.sql @@ -0,0 +1,70 @@ +CREATE TABLE IF NOT EXISTS "sites" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "name" TEXT NOT NULL, "url" TEXT NOT NULL, "platform" TEXT NOT NULL, "status" TEXT NOT NULL DEFAULT 'active', "api_key" TEXT, "created_at" TEXT DEFAULT to_char(timezone('UTC', CURRENT_TIMESTAMP), 'YYYY-MM-DD HH24:MI:SS'), "updated_at" TEXT DEFAULT to_char(timezone('UTC', CURRENT_TIMESTAMP), 'YYYY-MM-DD HH24:MI:SS'), "is_pinned" BOOLEAN DEFAULT false, "sort_order" INTEGER DEFAULT 0, "proxy_url" TEXT, "use_system_proxy" BOOLEAN DEFAULT false, "custom_headers" JSONB, "external_checkin_url" TEXT, "global_weight" DOUBLE PRECISION DEFAULT 1); +CREATE TABLE IF NOT EXISTS "accounts" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "site_id" INTEGER NOT NULL, "username" TEXT, "access_token" TEXT NOT NULL, "api_token" TEXT, "balance" DOUBLE PRECISION DEFAULT 0, "balance_used" DOUBLE PRECISION DEFAULT 0, "quota" DOUBLE PRECISION DEFAULT 0, "unit_cost" DOUBLE PRECISION, "value_score" DOUBLE PRECISION DEFAULT 0, "status" TEXT DEFAULT 'active', "checkin_enabled" BOOLEAN DEFAULT true, "last_checkin_at" TEXT, "last_balance_refresh" TEXT, "extra_config" JSONB, "created_at" TEXT DEFAULT to_char(timezone('UTC', CURRENT_TIMESTAMP), 'YYYY-MM-DD HH24:MI:SS'), "updated_at" TEXT DEFAULT to_char(timezone('UTC', CURRENT_TIMESTAMP), 'YYYY-MM-DD HH24:MI:SS'), "is_pinned" BOOLEAN DEFAULT false, "sort_order" INTEGER DEFAULT 0, "oauth_provider" TEXT, "oauth_account_key" TEXT, "oauth_project_id" TEXT, FOREIGN KEY ("site_id") REFERENCES "sites"("id") ON DELETE CASCADE); +CREATE TABLE IF NOT EXISTS "account_tokens" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "account_id" INTEGER NOT NULL, "name" TEXT NOT NULL, "token" TEXT NOT NULL, "source" TEXT DEFAULT 'manual', "enabled" BOOLEAN DEFAULT true, "is_default" BOOLEAN DEFAULT false, "created_at" TEXT DEFAULT to_char(timezone('UTC', CURRENT_TIMESTAMP), 'YYYY-MM-DD HH24:MI:SS'), "updated_at" TEXT DEFAULT to_char(timezone('UTC', CURRENT_TIMESTAMP), 'YYYY-MM-DD HH24:MI:SS'), "token_group" TEXT, "value_status" TEXT NOT NULL DEFAULT 'ready', FOREIGN KEY ("account_id") REFERENCES "accounts"("id") ON DELETE CASCADE); +CREATE TABLE IF NOT EXISTS "checkin_logs" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "account_id" INTEGER NOT NULL, "status" TEXT NOT NULL, "message" TEXT, "reward" TEXT, "created_at" TEXT DEFAULT to_char(timezone('UTC', CURRENT_TIMESTAMP), 'YYYY-MM-DD HH24:MI:SS'), FOREIGN KEY ("account_id") REFERENCES "accounts"("id") ON DELETE CASCADE); +CREATE TABLE IF NOT EXISTS "downstream_api_keys" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "name" TEXT NOT NULL, "key" TEXT NOT NULL, "description" TEXT, "enabled" BOOLEAN DEFAULT true, "expires_at" TEXT, "max_cost" DOUBLE PRECISION, "used_cost" DOUBLE PRECISION DEFAULT 0, "max_requests" INTEGER, "used_requests" INTEGER DEFAULT 0, "supported_models" JSONB, "allowed_route_ids" JSONB, "site_weight_multipliers" JSONB, "last_used_at" TEXT, "created_at" TEXT DEFAULT to_char(timezone('UTC', CURRENT_TIMESTAMP), 'YYYY-MM-DD HH24:MI:SS'), "updated_at" TEXT DEFAULT to_char(timezone('UTC', CURRENT_TIMESTAMP), 'YYYY-MM-DD HH24:MI:SS'), "group_name" TEXT, "tags" TEXT); +CREATE TABLE IF NOT EXISTS "events" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "type" TEXT NOT NULL, "title" TEXT NOT NULL, "message" TEXT, "level" TEXT NOT NULL DEFAULT 'info', "read" BOOLEAN DEFAULT false, "related_id" INTEGER, "related_type" TEXT, "created_at" TEXT DEFAULT to_char(timezone('UTC', CURRENT_TIMESTAMP), 'YYYY-MM-DD HH24:MI:SS')); +CREATE TABLE IF NOT EXISTS "model_availability" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "account_id" INTEGER NOT NULL, "model_name" TEXT NOT NULL, "available" BOOLEAN, "latency_ms" INTEGER, "checked_at" TEXT DEFAULT to_char(timezone('UTC', CURRENT_TIMESTAMP), 'YYYY-MM-DD HH24:MI:SS'), "is_manual" BOOLEAN DEFAULT false, FOREIGN KEY ("account_id") REFERENCES "accounts"("id") ON DELETE CASCADE); +CREATE TABLE IF NOT EXISTS "proxy_files" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "public_id" TEXT NOT NULL, "owner_type" TEXT NOT NULL, "owner_id" TEXT NOT NULL, "filename" TEXT NOT NULL, "mime_type" TEXT NOT NULL, "purpose" TEXT, "byte_size" INTEGER NOT NULL, "sha256" TEXT NOT NULL, "content_base64" TEXT NOT NULL, "created_at" TEXT DEFAULT to_char(timezone('UTC', CURRENT_TIMESTAMP), 'YYYY-MM-DD HH24:MI:SS'), "updated_at" TEXT DEFAULT to_char(timezone('UTC', CURRENT_TIMESTAMP), 'YYYY-MM-DD HH24:MI:SS'), "deleted_at" TEXT); +CREATE TABLE IF NOT EXISTS "proxy_logs" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "route_id" INTEGER, "channel_id" INTEGER, "account_id" INTEGER, "model_requested" TEXT, "model_actual" TEXT, "status" TEXT, "http_status" INTEGER, "latency_ms" INTEGER, "prompt_tokens" INTEGER, "completion_tokens" INTEGER, "total_tokens" INTEGER, "estimated_cost" DOUBLE PRECISION, "error_message" TEXT, "retry_count" INTEGER DEFAULT 0, "created_at" TEXT DEFAULT to_char(timezone('UTC', CURRENT_TIMESTAMP), 'YYYY-MM-DD HH24:MI:SS'), "billing_details" JSONB, "downstream_api_key_id" INTEGER, "client_family" TEXT, "client_app_id" TEXT, "client_app_name" TEXT, "client_confidence" TEXT); +CREATE TABLE IF NOT EXISTS "proxy_video_tasks" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "public_id" TEXT NOT NULL, "upstream_video_id" TEXT NOT NULL, "site_url" TEXT NOT NULL, "token_value" TEXT NOT NULL, "requested_model" TEXT, "actual_model" TEXT, "channel_id" INTEGER, "account_id" INTEGER, "status_snapshot" JSONB, "upstream_response_meta" JSONB, "last_upstream_status" INTEGER, "last_polled_at" TEXT, "created_at" TEXT DEFAULT to_char(timezone('UTC', CURRENT_TIMESTAMP), 'YYYY-MM-DD HH24:MI:SS'), "updated_at" TEXT DEFAULT to_char(timezone('UTC', CURRENT_TIMESTAMP), 'YYYY-MM-DD HH24:MI:SS')); +CREATE TABLE IF NOT EXISTS "token_routes" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "model_pattern" TEXT NOT NULL, "model_mapping" JSONB, "enabled" BOOLEAN DEFAULT true, "created_at" TEXT DEFAULT to_char(timezone('UTC', CURRENT_TIMESTAMP), 'YYYY-MM-DD HH24:MI:SS'), "updated_at" TEXT DEFAULT to_char(timezone('UTC', CURRENT_TIMESTAMP), 'YYYY-MM-DD HH24:MI:SS'), "display_name" TEXT, "display_icon" TEXT, "decision_snapshot" JSONB, "decision_refreshed_at" TEXT, "routing_strategy" TEXT DEFAULT 'weighted', "route_mode" TEXT DEFAULT 'pattern'); +CREATE TABLE IF NOT EXISTS "route_channels" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "route_id" INTEGER NOT NULL, "account_id" INTEGER NOT NULL, "token_id" INTEGER, "source_model" TEXT, "priority" INTEGER DEFAULT 0, "weight" INTEGER DEFAULT 10, "enabled" BOOLEAN DEFAULT true, "manual_override" BOOLEAN DEFAULT false, "success_count" INTEGER DEFAULT 0, "fail_count" INTEGER DEFAULT 0, "total_latency_ms" INTEGER DEFAULT 0, "total_cost" DOUBLE PRECISION DEFAULT 0, "last_used_at" TEXT, "last_selected_at" TEXT, "last_fail_at" TEXT, "consecutive_fail_count" INTEGER NOT NULL DEFAULT 0, "cooldown_level" INTEGER NOT NULL DEFAULT 0, "cooldown_until" TEXT, FOREIGN KEY ("account_id") REFERENCES "accounts"("id") ON DELETE CASCADE, FOREIGN KEY ("route_id") REFERENCES "token_routes"("id") ON DELETE CASCADE, FOREIGN KEY ("token_id") REFERENCES "account_tokens"("id") ON DELETE SET NULL); +CREATE TABLE IF NOT EXISTS "route_group_sources" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "group_route_id" INTEGER NOT NULL, "source_route_id" INTEGER NOT NULL, FOREIGN KEY ("group_route_id") REFERENCES "token_routes"("id") ON DELETE CASCADE, FOREIGN KEY ("source_route_id") REFERENCES "token_routes"("id") ON DELETE CASCADE); +CREATE TABLE IF NOT EXISTS "settings" ("key" TEXT NOT NULL PRIMARY KEY, "value" TEXT); +CREATE TABLE IF NOT EXISTS "site_announcements" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "site_id" INTEGER NOT NULL, "platform" TEXT NOT NULL, "source_key" TEXT NOT NULL, "title" TEXT NOT NULL, "content" TEXT NOT NULL, "level" TEXT NOT NULL DEFAULT 'info', "source_url" TEXT, "starts_at" TEXT, "ends_at" TEXT, "upstream_created_at" TEXT, "upstream_updated_at" TEXT, "first_seen_at" TEXT DEFAULT to_char(timezone('UTC', CURRENT_TIMESTAMP), 'YYYY-MM-DD HH24:MI:SS'), "last_seen_at" TEXT DEFAULT to_char(timezone('UTC', CURRENT_TIMESTAMP), 'YYYY-MM-DD HH24:MI:SS'), "read_at" TEXT, "dismissed_at" TEXT, "raw_payload" TEXT, FOREIGN KEY ("site_id") REFERENCES "sites"("id") ON DELETE CASCADE); +CREATE TABLE IF NOT EXISTS "site_disabled_models" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "site_id" INTEGER NOT NULL, "model_name" TEXT NOT NULL, "created_at" TEXT DEFAULT to_char(timezone('UTC', CURRENT_TIMESTAMP), 'YYYY-MM-DD HH24:MI:SS'), FOREIGN KEY ("site_id") REFERENCES "sites"("id") ON DELETE CASCADE); +CREATE TABLE IF NOT EXISTS "token_model_availability" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "token_id" INTEGER NOT NULL, "model_name" TEXT NOT NULL, "available" BOOLEAN, "latency_ms" INTEGER, "checked_at" TEXT DEFAULT to_char(timezone('UTC', CURRENT_TIMESTAMP), 'YYYY-MM-DD HH24:MI:SS'), FOREIGN KEY ("token_id") REFERENCES "account_tokens"("id") ON DELETE CASCADE); +CREATE UNIQUE INDEX "downstream_api_keys_key_unique" ON "downstream_api_keys" ("key"); +CREATE UNIQUE INDEX "model_availability_account_model_unique" ON "model_availability" ("account_id", "model_name"); +CREATE UNIQUE INDEX "proxy_files_public_id_unique" ON "proxy_files" ("public_id"); +CREATE UNIQUE INDEX "proxy_video_tasks_public_id_unique" ON "proxy_video_tasks" ("public_id"); +CREATE UNIQUE INDEX "route_group_sources_group_source_unique" ON "route_group_sources" ("group_route_id", "source_route_id"); +CREATE UNIQUE INDEX "site_announcements_site_source_key_unique" ON "site_announcements" ("site_id", "source_key"); +CREATE UNIQUE INDEX "site_disabled_models_site_model_unique" ON "site_disabled_models" ("site_id", "model_name"); +CREATE UNIQUE INDEX "sites_platform_url_unique" ON "sites" ("platform", "url"); +CREATE UNIQUE INDEX "token_model_availability_token_model_unique" ON "token_model_availability" ("token_id", "model_name"); +CREATE INDEX "account_tokens_account_enabled_idx" ON "account_tokens" ("account_id", "enabled"); +CREATE INDEX "account_tokens_account_id_idx" ON "account_tokens" ("account_id"); +CREATE INDEX "account_tokens_enabled_idx" ON "account_tokens" ("enabled"); +CREATE INDEX "accounts_oauth_identity_idx" ON "accounts" ("oauth_provider", "oauth_account_key", "oauth_project_id"); +CREATE INDEX "accounts_oauth_provider_idx" ON "accounts" ("oauth_provider"); +CREATE INDEX "accounts_site_id_idx" ON "accounts" ("site_id"); +CREATE INDEX "accounts_site_status_idx" ON "accounts" ("site_id", "status"); +CREATE INDEX "accounts_status_idx" ON "accounts" ("status"); +CREATE INDEX "checkin_logs_account_created_at_idx" ON "checkin_logs" ("account_id", "created_at"); +CREATE INDEX "checkin_logs_created_at_idx" ON "checkin_logs" ("created_at"); +CREATE INDEX "checkin_logs_status_idx" ON "checkin_logs" ("status"); +CREATE INDEX "downstream_api_keys_enabled_idx" ON "downstream_api_keys" ("enabled"); +CREATE INDEX "downstream_api_keys_expires_at_idx" ON "downstream_api_keys" ("expires_at"); +CREATE INDEX "downstream_api_keys_name_idx" ON "downstream_api_keys" ("name"); +CREATE INDEX "events_created_at_idx" ON "events" ("created_at"); +CREATE INDEX "events_read_created_at_idx" ON "events" ("read", "created_at"); +CREATE INDEX "events_type_created_at_idx" ON "events" ("type", "created_at"); +CREATE INDEX "model_availability_account_available_idx" ON "model_availability" ("account_id", "available"); +CREATE INDEX "model_availability_model_name_idx" ON "model_availability" ("model_name"); +CREATE INDEX "proxy_files_owner_lookup_idx" ON "proxy_files" ("owner_type", "owner_id", "deleted_at"); +CREATE INDEX "proxy_logs_account_created_at_idx" ON "proxy_logs" ("account_id", "created_at"); +CREATE INDEX "proxy_logs_client_app_id_created_at_idx" ON "proxy_logs" ("client_app_id", "created_at"); +CREATE INDEX "proxy_logs_client_family_created_at_idx" ON "proxy_logs" ("client_family", "created_at"); +CREATE INDEX "proxy_logs_created_at_idx" ON "proxy_logs" ("created_at"); +CREATE INDEX "proxy_logs_downstream_api_key_created_at_idx" ON "proxy_logs" ("downstream_api_key_id", "created_at"); +CREATE INDEX "proxy_logs_model_actual_created_at_idx" ON "proxy_logs" ("model_actual", "created_at"); +CREATE INDEX "proxy_logs_status_created_at_idx" ON "proxy_logs" ("status", "created_at"); +CREATE INDEX "proxy_video_tasks_created_at_idx" ON "proxy_video_tasks" ("created_at"); +CREATE INDEX "proxy_video_tasks_upstream_video_id_idx" ON "proxy_video_tasks" ("upstream_video_id"); +CREATE INDEX "route_channels_account_id_idx" ON "route_channels" ("account_id"); +CREATE INDEX "route_channels_route_enabled_idx" ON "route_channels" ("route_id", "enabled"); +CREATE INDEX "route_channels_route_id_idx" ON "route_channels" ("route_id"); +CREATE INDEX "route_channels_route_token_idx" ON "route_channels" ("route_id", "token_id"); +CREATE INDEX "route_channels_token_id_idx" ON "route_channels" ("token_id"); +CREATE INDEX "route_group_sources_source_route_id_idx" ON "route_group_sources" ("source_route_id"); +CREATE INDEX "site_announcements_read_at_idx" ON "site_announcements" ("read_at"); +CREATE INDEX "site_announcements_site_id_first_seen_at_idx" ON "site_announcements" ("site_id", "first_seen_at"); +CREATE INDEX "site_disabled_models_site_id_idx" ON "site_disabled_models" ("site_id"); +CREATE INDEX "sites_status_idx" ON "sites" ("status"); +CREATE INDEX "token_model_availability_available_idx" ON "token_model_availability" ("available"); +CREATE INDEX "token_model_availability_model_name_idx" ON "token_model_availability" ("model_name"); +CREATE INDEX "token_model_availability_token_available_idx" ON "token_model_availability" ("token_id", "available"); +CREATE INDEX "token_routes_enabled_idx" ON "token_routes" ("enabled"); +CREATE INDEX "token_routes_model_pattern_idx" ON "token_routes" ("model_pattern"); diff --git a/src/server/db/generated/postgres.upgrade.sql b/src/server/db/generated/postgres.upgrade.sql new file mode 100644 index 00000000..bf33dfaf --- /dev/null +++ b/src/server/db/generated/postgres.upgrade.sql @@ -0,0 +1 @@ +-- no schema changes detected for postgres diff --git a/src/server/db/generated/schemaContract.json b/src/server/db/generated/schemaContract.json new file mode 100644 index 00000000..25db470a --- /dev/null +++ b/src/server/db/generated/schemaContract.json @@ -0,0 +1,1935 @@ +{ + "tables": { + "account_tokens": { + "columns": { + "id": { + "logicalType": "integer", + "notNull": true, + "defaultValue": null, + "primaryKey": true + }, + "account_id": { + "logicalType": "integer", + "notNull": true, + "defaultValue": null, + "primaryKey": false + }, + "name": { + "logicalType": "text", + "notNull": true, + "defaultValue": null, + "primaryKey": false + }, + "token": { + "logicalType": "text", + "notNull": true, + "defaultValue": null, + "primaryKey": false + }, + "source": { + "logicalType": "text", + "notNull": false, + "defaultValue": "'manual'", + "primaryKey": false + }, + "enabled": { + "logicalType": "boolean", + "notNull": false, + "defaultValue": "true", + "primaryKey": false + }, + "is_default": { + "logicalType": "boolean", + "notNull": false, + "defaultValue": "false", + "primaryKey": false + }, + "created_at": { + "logicalType": "datetime", + "notNull": false, + "defaultValue": "datetime('now')", + "primaryKey": false + }, + "updated_at": { + "logicalType": "datetime", + "notNull": false, + "defaultValue": "datetime('now')", + "primaryKey": false + }, + "token_group": { + "logicalType": "text", + "notNull": false, + "defaultValue": null, + "primaryKey": false + }, + "value_status": { + "logicalType": "text", + "notNull": true, + "defaultValue": "'ready'", + "primaryKey": false + } + } + }, + "accounts": { + "columns": { + "id": { + "logicalType": "integer", + "notNull": true, + "defaultValue": null, + "primaryKey": true + }, + "site_id": { + "logicalType": "integer", + "notNull": true, + "defaultValue": null, + "primaryKey": false + }, + "username": { + "logicalType": "text", + "notNull": false, + "defaultValue": null, + "primaryKey": false + }, + "access_token": { + "logicalType": "text", + "notNull": true, + "defaultValue": null, + "primaryKey": false + }, + "api_token": { + "logicalType": "text", + "notNull": false, + "defaultValue": null, + "primaryKey": false + }, + "balance": { + "logicalType": "real", + "notNull": false, + "defaultValue": "0", + "primaryKey": false + }, + "balance_used": { + "logicalType": "real", + "notNull": false, + "defaultValue": "0", + "primaryKey": false + }, + "quota": { + "logicalType": "real", + "notNull": false, + "defaultValue": "0", + "primaryKey": false + }, + "unit_cost": { + "logicalType": "real", + "notNull": false, + "defaultValue": null, + "primaryKey": false + }, + "value_score": { + "logicalType": "real", + "notNull": false, + "defaultValue": "0", + "primaryKey": false + }, + "status": { + "logicalType": "text", + "notNull": false, + "defaultValue": "'active'", + "primaryKey": false + }, + "checkin_enabled": { + "logicalType": "boolean", + "notNull": false, + "defaultValue": "true", + "primaryKey": false + }, + "last_checkin_at": { + "logicalType": "datetime", + "notNull": false, + "defaultValue": null, + "primaryKey": false + }, + "last_balance_refresh": { + "logicalType": "datetime", + "notNull": false, + "defaultValue": null, + "primaryKey": false + }, + "extra_config": { + "logicalType": "json", + "notNull": false, + "defaultValue": null, + "primaryKey": false + }, + "created_at": { + "logicalType": "datetime", + "notNull": false, + "defaultValue": "datetime('now')", + "primaryKey": false + }, + "updated_at": { + "logicalType": "datetime", + "notNull": false, + "defaultValue": "datetime('now')", + "primaryKey": false + }, + "is_pinned": { + "logicalType": "boolean", + "notNull": false, + "defaultValue": "false", + "primaryKey": false + }, + "sort_order": { + "logicalType": "integer", + "notNull": false, + "defaultValue": "0", + "primaryKey": false + }, + "oauth_provider": { + "logicalType": "text", + "notNull": false, + "defaultValue": null, + "primaryKey": false + }, + "oauth_account_key": { + "logicalType": "text", + "notNull": false, + "defaultValue": null, + "primaryKey": false + }, + "oauth_project_id": { + "logicalType": "text", + "notNull": false, + "defaultValue": null, + "primaryKey": false + } + } + }, + "checkin_logs": { + "columns": { + "id": { + "logicalType": "integer", + "notNull": true, + "defaultValue": null, + "primaryKey": true + }, + "account_id": { + "logicalType": "integer", + "notNull": true, + "defaultValue": null, + "primaryKey": false + }, + "status": { + "logicalType": "text", + "notNull": true, + "defaultValue": null, + "primaryKey": false + }, + "message": { + "logicalType": "text", + "notNull": false, + "defaultValue": null, + "primaryKey": false + }, + "reward": { + "logicalType": "text", + "notNull": false, + "defaultValue": null, + "primaryKey": false + }, + "created_at": { + "logicalType": "datetime", + "notNull": false, + "defaultValue": "datetime('now')", + "primaryKey": false + } + } + }, + "downstream_api_keys": { + "columns": { + "id": { + "logicalType": "integer", + "notNull": true, + "defaultValue": null, + "primaryKey": true + }, + "name": { + "logicalType": "text", + "notNull": true, + "defaultValue": null, + "primaryKey": false + }, + "key": { + "logicalType": "text", + "notNull": true, + "defaultValue": null, + "primaryKey": false + }, + "description": { + "logicalType": "text", + "notNull": false, + "defaultValue": null, + "primaryKey": false + }, + "enabled": { + "logicalType": "boolean", + "notNull": false, + "defaultValue": "true", + "primaryKey": false + }, + "expires_at": { + "logicalType": "datetime", + "notNull": false, + "defaultValue": null, + "primaryKey": false + }, + "max_cost": { + "logicalType": "real", + "notNull": false, + "defaultValue": null, + "primaryKey": false + }, + "used_cost": { + "logicalType": "real", + "notNull": false, + "defaultValue": "0", + "primaryKey": false + }, + "max_requests": { + "logicalType": "integer", + "notNull": false, + "defaultValue": null, + "primaryKey": false + }, + "used_requests": { + "logicalType": "integer", + "notNull": false, + "defaultValue": "0", + "primaryKey": false + }, + "supported_models": { + "logicalType": "json", + "notNull": false, + "defaultValue": null, + "primaryKey": false + }, + "allowed_route_ids": { + "logicalType": "json", + "notNull": false, + "defaultValue": null, + "primaryKey": false + }, + "site_weight_multipliers": { + "logicalType": "json", + "notNull": false, + "defaultValue": null, + "primaryKey": false + }, + "last_used_at": { + "logicalType": "datetime", + "notNull": false, + "defaultValue": null, + "primaryKey": false + }, + "created_at": { + "logicalType": "datetime", + "notNull": false, + "defaultValue": "datetime('now')", + "primaryKey": false + }, + "updated_at": { + "logicalType": "datetime", + "notNull": false, + "defaultValue": "datetime('now')", + "primaryKey": false + }, + "group_name": { + "logicalType": "text", + "notNull": false, + "defaultValue": null, + "primaryKey": false + }, + "tags": { + "logicalType": "text", + "notNull": false, + "defaultValue": null, + "primaryKey": false + } + } + }, + "events": { + "columns": { + "id": { + "logicalType": "integer", + "notNull": true, + "defaultValue": null, + "primaryKey": true + }, + "type": { + "logicalType": "text", + "notNull": true, + "defaultValue": null, + "primaryKey": false + }, + "title": { + "logicalType": "text", + "notNull": true, + "defaultValue": null, + "primaryKey": false + }, + "message": { + "logicalType": "text", + "notNull": false, + "defaultValue": null, + "primaryKey": false + }, + "level": { + "logicalType": "text", + "notNull": true, + "defaultValue": "'info'", + "primaryKey": false + }, + "read": { + "logicalType": "boolean", + "notNull": false, + "defaultValue": "false", + "primaryKey": false + }, + "related_id": { + "logicalType": "integer", + "notNull": false, + "defaultValue": null, + "primaryKey": false + }, + "related_type": { + "logicalType": "text", + "notNull": false, + "defaultValue": null, + "primaryKey": false + }, + "created_at": { + "logicalType": "datetime", + "notNull": false, + "defaultValue": "datetime('now')", + "primaryKey": false + } + } + }, + "model_availability": { + "columns": { + "id": { + "logicalType": "integer", + "notNull": true, + "defaultValue": null, + "primaryKey": true + }, + "account_id": { + "logicalType": "integer", + "notNull": true, + "defaultValue": null, + "primaryKey": false + }, + "model_name": { + "logicalType": "text", + "notNull": true, + "defaultValue": null, + "primaryKey": false + }, + "available": { + "logicalType": "boolean", + "notNull": false, + "defaultValue": null, + "primaryKey": false + }, + "latency_ms": { + "logicalType": "integer", + "notNull": false, + "defaultValue": null, + "primaryKey": false + }, + "checked_at": { + "logicalType": "datetime", + "notNull": false, + "defaultValue": "datetime('now')", + "primaryKey": false + }, + "is_manual": { + "logicalType": "boolean", + "notNull": false, + "defaultValue": "false", + "primaryKey": false + } + } + }, + "proxy_files": { + "columns": { + "id": { + "logicalType": "integer", + "notNull": true, + "defaultValue": null, + "primaryKey": true + }, + "public_id": { + "logicalType": "text", + "notNull": true, + "defaultValue": null, + "primaryKey": false + }, + "owner_type": { + "logicalType": "text", + "notNull": true, + "defaultValue": null, + "primaryKey": false + }, + "owner_id": { + "logicalType": "text", + "notNull": true, + "defaultValue": null, + "primaryKey": false + }, + "filename": { + "logicalType": "text", + "notNull": true, + "defaultValue": null, + "primaryKey": false + }, + "mime_type": { + "logicalType": "text", + "notNull": true, + "defaultValue": null, + "primaryKey": false + }, + "purpose": { + "logicalType": "text", + "notNull": false, + "defaultValue": null, + "primaryKey": false + }, + "byte_size": { + "logicalType": "integer", + "notNull": true, + "defaultValue": null, + "primaryKey": false + }, + "sha256": { + "logicalType": "text", + "notNull": true, + "defaultValue": null, + "primaryKey": false + }, + "content_base64": { + "logicalType": "text", + "notNull": true, + "defaultValue": null, + "primaryKey": false + }, + "created_at": { + "logicalType": "datetime", + "notNull": false, + "defaultValue": "datetime('now')", + "primaryKey": false + }, + "updated_at": { + "logicalType": "datetime", + "notNull": false, + "defaultValue": "datetime('now')", + "primaryKey": false + }, + "deleted_at": { + "logicalType": "datetime", + "notNull": false, + "defaultValue": null, + "primaryKey": false + } + } + }, + "proxy_logs": { + "columns": { + "id": { + "logicalType": "integer", + "notNull": true, + "defaultValue": null, + "primaryKey": true + }, + "route_id": { + "logicalType": "integer", + "notNull": false, + "defaultValue": null, + "primaryKey": false + }, + "channel_id": { + "logicalType": "integer", + "notNull": false, + "defaultValue": null, + "primaryKey": false + }, + "account_id": { + "logicalType": "integer", + "notNull": false, + "defaultValue": null, + "primaryKey": false + }, + "model_requested": { + "logicalType": "text", + "notNull": false, + "defaultValue": null, + "primaryKey": false + }, + "model_actual": { + "logicalType": "text", + "notNull": false, + "defaultValue": null, + "primaryKey": false + }, + "status": { + "logicalType": "text", + "notNull": false, + "defaultValue": null, + "primaryKey": false + }, + "http_status": { + "logicalType": "integer", + "notNull": false, + "defaultValue": null, + "primaryKey": false + }, + "latency_ms": { + "logicalType": "integer", + "notNull": false, + "defaultValue": null, + "primaryKey": false + }, + "prompt_tokens": { + "logicalType": "integer", + "notNull": false, + "defaultValue": null, + "primaryKey": false + }, + "completion_tokens": { + "logicalType": "integer", + "notNull": false, + "defaultValue": null, + "primaryKey": false + }, + "total_tokens": { + "logicalType": "integer", + "notNull": false, + "defaultValue": null, + "primaryKey": false + }, + "estimated_cost": { + "logicalType": "real", + "notNull": false, + "defaultValue": null, + "primaryKey": false + }, + "error_message": { + "logicalType": "text", + "notNull": false, + "defaultValue": null, + "primaryKey": false + }, + "retry_count": { + "logicalType": "integer", + "notNull": false, + "defaultValue": "0", + "primaryKey": false + }, + "created_at": { + "logicalType": "datetime", + "notNull": false, + "defaultValue": "datetime('now')", + "primaryKey": false + }, + "billing_details": { + "logicalType": "json", + "notNull": false, + "defaultValue": null, + "primaryKey": false + }, + "downstream_api_key_id": { + "logicalType": "integer", + "notNull": false, + "defaultValue": null, + "primaryKey": false + }, + "client_family": { + "logicalType": "text", + "notNull": false, + "defaultValue": null, + "primaryKey": false + }, + "client_app_id": { + "logicalType": "text", + "notNull": false, + "defaultValue": null, + "primaryKey": false + }, + "client_app_name": { + "logicalType": "text", + "notNull": false, + "defaultValue": null, + "primaryKey": false + }, + "client_confidence": { + "logicalType": "text", + "notNull": false, + "defaultValue": null, + "primaryKey": false + } + } + }, + "proxy_video_tasks": { + "columns": { + "id": { + "logicalType": "integer", + "notNull": true, + "defaultValue": null, + "primaryKey": true + }, + "public_id": { + "logicalType": "text", + "notNull": true, + "defaultValue": null, + "primaryKey": false + }, + "upstream_video_id": { + "logicalType": "text", + "notNull": true, + "defaultValue": null, + "primaryKey": false + }, + "site_url": { + "logicalType": "text", + "notNull": true, + "defaultValue": null, + "primaryKey": false + }, + "token_value": { + "logicalType": "text", + "notNull": true, + "defaultValue": null, + "primaryKey": false + }, + "requested_model": { + "logicalType": "text", + "notNull": false, + "defaultValue": null, + "primaryKey": false + }, + "actual_model": { + "logicalType": "text", + "notNull": false, + "defaultValue": null, + "primaryKey": false + }, + "channel_id": { + "logicalType": "integer", + "notNull": false, + "defaultValue": null, + "primaryKey": false + }, + "account_id": { + "logicalType": "integer", + "notNull": false, + "defaultValue": null, + "primaryKey": false + }, + "status_snapshot": { + "logicalType": "json", + "notNull": false, + "defaultValue": null, + "primaryKey": false + }, + "upstream_response_meta": { + "logicalType": "json", + "notNull": false, + "defaultValue": null, + "primaryKey": false + }, + "last_upstream_status": { + "logicalType": "integer", + "notNull": false, + "defaultValue": null, + "primaryKey": false + }, + "last_polled_at": { + "logicalType": "datetime", + "notNull": false, + "defaultValue": null, + "primaryKey": false + }, + "created_at": { + "logicalType": "datetime", + "notNull": false, + "defaultValue": "datetime('now')", + "primaryKey": false + }, + "updated_at": { + "logicalType": "datetime", + "notNull": false, + "defaultValue": "datetime('now')", + "primaryKey": false + } + } + }, + "route_channels": { + "columns": { + "id": { + "logicalType": "integer", + "notNull": true, + "defaultValue": null, + "primaryKey": true + }, + "route_id": { + "logicalType": "integer", + "notNull": true, + "defaultValue": null, + "primaryKey": false + }, + "account_id": { + "logicalType": "integer", + "notNull": true, + "defaultValue": null, + "primaryKey": false + }, + "token_id": { + "logicalType": "integer", + "notNull": false, + "defaultValue": null, + "primaryKey": false + }, + "source_model": { + "logicalType": "text", + "notNull": false, + "defaultValue": null, + "primaryKey": false + }, + "priority": { + "logicalType": "integer", + "notNull": false, + "defaultValue": "0", + "primaryKey": false + }, + "weight": { + "logicalType": "integer", + "notNull": false, + "defaultValue": "10", + "primaryKey": false + }, + "enabled": { + "logicalType": "boolean", + "notNull": false, + "defaultValue": "true", + "primaryKey": false + }, + "manual_override": { + "logicalType": "boolean", + "notNull": false, + "defaultValue": "false", + "primaryKey": false + }, + "success_count": { + "logicalType": "integer", + "notNull": false, + "defaultValue": "0", + "primaryKey": false + }, + "fail_count": { + "logicalType": "integer", + "notNull": false, + "defaultValue": "0", + "primaryKey": false + }, + "total_latency_ms": { + "logicalType": "integer", + "notNull": false, + "defaultValue": "0", + "primaryKey": false + }, + "total_cost": { + "logicalType": "real", + "notNull": false, + "defaultValue": "0", + "primaryKey": false + }, + "last_used_at": { + "logicalType": "datetime", + "notNull": false, + "defaultValue": null, + "primaryKey": false + }, + "last_selected_at": { + "logicalType": "datetime", + "notNull": false, + "defaultValue": null, + "primaryKey": false + }, + "last_fail_at": { + "logicalType": "datetime", + "notNull": false, + "defaultValue": null, + "primaryKey": false + }, + "consecutive_fail_count": { + "logicalType": "integer", + "notNull": true, + "defaultValue": "0", + "primaryKey": false + }, + "cooldown_level": { + "logicalType": "integer", + "notNull": true, + "defaultValue": "0", + "primaryKey": false + }, + "cooldown_until": { + "logicalType": "datetime", + "notNull": false, + "defaultValue": null, + "primaryKey": false + } + } + }, + "route_group_sources": { + "columns": { + "id": { + "logicalType": "integer", + "notNull": true, + "defaultValue": null, + "primaryKey": true + }, + "group_route_id": { + "logicalType": "integer", + "notNull": true, + "defaultValue": null, + "primaryKey": false + }, + "source_route_id": { + "logicalType": "integer", + "notNull": true, + "defaultValue": null, + "primaryKey": false + } + } + }, + "settings": { + "columns": { + "key": { + "logicalType": "text", + "notNull": true, + "defaultValue": null, + "primaryKey": true + }, + "value": { + "logicalType": "text", + "notNull": false, + "defaultValue": null, + "primaryKey": false + } + } + }, + "site_announcements": { + "columns": { + "id": { + "logicalType": "integer", + "notNull": true, + "defaultValue": null, + "primaryKey": true + }, + "site_id": { + "logicalType": "integer", + "notNull": true, + "defaultValue": null, + "primaryKey": false + }, + "platform": { + "logicalType": "text", + "notNull": true, + "defaultValue": null, + "primaryKey": false + }, + "source_key": { + "logicalType": "text", + "notNull": true, + "defaultValue": null, + "primaryKey": false + }, + "title": { + "logicalType": "text", + "notNull": true, + "defaultValue": null, + "primaryKey": false + }, + "content": { + "logicalType": "text", + "notNull": true, + "defaultValue": null, + "primaryKey": false + }, + "level": { + "logicalType": "text", + "notNull": true, + "defaultValue": "'info'", + "primaryKey": false + }, + "source_url": { + "logicalType": "text", + "notNull": false, + "defaultValue": null, + "primaryKey": false + }, + "starts_at": { + "logicalType": "datetime", + "notNull": false, + "defaultValue": null, + "primaryKey": false + }, + "ends_at": { + "logicalType": "datetime", + "notNull": false, + "defaultValue": null, + "primaryKey": false + }, + "upstream_created_at": { + "logicalType": "datetime", + "notNull": false, + "defaultValue": null, + "primaryKey": false + }, + "upstream_updated_at": { + "logicalType": "datetime", + "notNull": false, + "defaultValue": null, + "primaryKey": false + }, + "first_seen_at": { + "logicalType": "datetime", + "notNull": false, + "defaultValue": "datetime('now')", + "primaryKey": false + }, + "last_seen_at": { + "logicalType": "datetime", + "notNull": false, + "defaultValue": "datetime('now')", + "primaryKey": false + }, + "read_at": { + "logicalType": "datetime", + "notNull": false, + "defaultValue": null, + "primaryKey": false + }, + "dismissed_at": { + "logicalType": "datetime", + "notNull": false, + "defaultValue": null, + "primaryKey": false + }, + "raw_payload": { + "logicalType": "text", + "notNull": false, + "defaultValue": null, + "primaryKey": false + } + } + }, + "site_disabled_models": { + "columns": { + "id": { + "logicalType": "integer", + "notNull": true, + "defaultValue": null, + "primaryKey": true + }, + "site_id": { + "logicalType": "integer", + "notNull": true, + "defaultValue": null, + "primaryKey": false + }, + "model_name": { + "logicalType": "text", + "notNull": true, + "defaultValue": null, + "primaryKey": false + }, + "created_at": { + "logicalType": "datetime", + "notNull": false, + "defaultValue": "datetime('now')", + "primaryKey": false + } + } + }, + "sites": { + "columns": { + "id": { + "logicalType": "integer", + "notNull": true, + "defaultValue": null, + "primaryKey": true + }, + "name": { + "logicalType": "text", + "notNull": true, + "defaultValue": null, + "primaryKey": false + }, + "url": { + "logicalType": "text", + "notNull": true, + "defaultValue": null, + "primaryKey": false + }, + "platform": { + "logicalType": "text", + "notNull": true, + "defaultValue": null, + "primaryKey": false + }, + "status": { + "logicalType": "text", + "notNull": true, + "defaultValue": "'active'", + "primaryKey": false + }, + "api_key": { + "logicalType": "text", + "notNull": false, + "defaultValue": null, + "primaryKey": false + }, + "created_at": { + "logicalType": "datetime", + "notNull": false, + "defaultValue": "datetime('now')", + "primaryKey": false + }, + "updated_at": { + "logicalType": "datetime", + "notNull": false, + "defaultValue": "datetime('now')", + "primaryKey": false + }, + "is_pinned": { + "logicalType": "boolean", + "notNull": false, + "defaultValue": "false", + "primaryKey": false + }, + "sort_order": { + "logicalType": "integer", + "notNull": false, + "defaultValue": "0", + "primaryKey": false + }, + "proxy_url": { + "logicalType": "text", + "notNull": false, + "defaultValue": null, + "primaryKey": false + }, + "use_system_proxy": { + "logicalType": "boolean", + "notNull": false, + "defaultValue": "false", + "primaryKey": false + }, + "custom_headers": { + "logicalType": "json", + "notNull": false, + "defaultValue": null, + "primaryKey": false + }, + "external_checkin_url": { + "logicalType": "text", + "notNull": false, + "defaultValue": null, + "primaryKey": false + }, + "global_weight": { + "logicalType": "real", + "notNull": false, + "defaultValue": "1", + "primaryKey": false + } + } + }, + "token_model_availability": { + "columns": { + "id": { + "logicalType": "integer", + "notNull": true, + "defaultValue": null, + "primaryKey": true + }, + "token_id": { + "logicalType": "integer", + "notNull": true, + "defaultValue": null, + "primaryKey": false + }, + "model_name": { + "logicalType": "text", + "notNull": true, + "defaultValue": null, + "primaryKey": false + }, + "available": { + "logicalType": "boolean", + "notNull": false, + "defaultValue": null, + "primaryKey": false + }, + "latency_ms": { + "logicalType": "integer", + "notNull": false, + "defaultValue": null, + "primaryKey": false + }, + "checked_at": { + "logicalType": "datetime", + "notNull": false, + "defaultValue": "datetime('now')", + "primaryKey": false + } + } + }, + "token_routes": { + "columns": { + "id": { + "logicalType": "integer", + "notNull": true, + "defaultValue": null, + "primaryKey": true + }, + "model_pattern": { + "logicalType": "text", + "notNull": true, + "defaultValue": null, + "primaryKey": false + }, + "model_mapping": { + "logicalType": "json", + "notNull": false, + "defaultValue": null, + "primaryKey": false + }, + "enabled": { + "logicalType": "boolean", + "notNull": false, + "defaultValue": "true", + "primaryKey": false + }, + "created_at": { + "logicalType": "datetime", + "notNull": false, + "defaultValue": "datetime('now')", + "primaryKey": false + }, + "updated_at": { + "logicalType": "datetime", + "notNull": false, + "defaultValue": "datetime('now')", + "primaryKey": false + }, + "display_name": { + "logicalType": "text", + "notNull": false, + "defaultValue": null, + "primaryKey": false + }, + "display_icon": { + "logicalType": "text", + "notNull": false, + "defaultValue": null, + "primaryKey": false + }, + "decision_snapshot": { + "logicalType": "json", + "notNull": false, + "defaultValue": null, + "primaryKey": false + }, + "decision_refreshed_at": { + "logicalType": "datetime", + "notNull": false, + "defaultValue": null, + "primaryKey": false + }, + "routing_strategy": { + "logicalType": "text", + "notNull": false, + "defaultValue": "'weighted'", + "primaryKey": false + }, + "route_mode": { + "logicalType": "text", + "notNull": false, + "defaultValue": "'pattern'", + "primaryKey": false + } + } + } + }, + "indexes": [ + { + "name": "account_tokens_account_enabled_idx", + "table": "account_tokens", + "columns": [ + "account_id", + "enabled" + ], + "unique": false + }, + { + "name": "account_tokens_account_id_idx", + "table": "account_tokens", + "columns": [ + "account_id" + ], + "unique": false + }, + { + "name": "account_tokens_enabled_idx", + "table": "account_tokens", + "columns": [ + "enabled" + ], + "unique": false + }, + { + "name": "accounts_oauth_identity_idx", + "table": "accounts", + "columns": [ + "oauth_provider", + "oauth_account_key", + "oauth_project_id" + ], + "unique": false + }, + { + "name": "accounts_oauth_provider_idx", + "table": "accounts", + "columns": [ + "oauth_provider" + ], + "unique": false + }, + { + "name": "accounts_site_id_idx", + "table": "accounts", + "columns": [ + "site_id" + ], + "unique": false + }, + { + "name": "accounts_site_status_idx", + "table": "accounts", + "columns": [ + "site_id", + "status" + ], + "unique": false + }, + { + "name": "accounts_status_idx", + "table": "accounts", + "columns": [ + "status" + ], + "unique": false + }, + { + "name": "checkin_logs_account_created_at_idx", + "table": "checkin_logs", + "columns": [ + "account_id", + "created_at" + ], + "unique": false + }, + { + "name": "checkin_logs_created_at_idx", + "table": "checkin_logs", + "columns": [ + "created_at" + ], + "unique": false + }, + { + "name": "checkin_logs_status_idx", + "table": "checkin_logs", + "columns": [ + "status" + ], + "unique": false + }, + { + "name": "downstream_api_keys_enabled_idx", + "table": "downstream_api_keys", + "columns": [ + "enabled" + ], + "unique": false + }, + { + "name": "downstream_api_keys_expires_at_idx", + "table": "downstream_api_keys", + "columns": [ + "expires_at" + ], + "unique": false + }, + { + "name": "downstream_api_keys_key_unique", + "table": "downstream_api_keys", + "columns": [ + "key" + ], + "unique": true + }, + { + "name": "downstream_api_keys_name_idx", + "table": "downstream_api_keys", + "columns": [ + "name" + ], + "unique": false + }, + { + "name": "events_created_at_idx", + "table": "events", + "columns": [ + "created_at" + ], + "unique": false + }, + { + "name": "events_read_created_at_idx", + "table": "events", + "columns": [ + "read", + "created_at" + ], + "unique": false + }, + { + "name": "events_type_created_at_idx", + "table": "events", + "columns": [ + "type", + "created_at" + ], + "unique": false + }, + { + "name": "model_availability_account_available_idx", + "table": "model_availability", + "columns": [ + "account_id", + "available" + ], + "unique": false + }, + { + "name": "model_availability_account_model_unique", + "table": "model_availability", + "columns": [ + "account_id", + "model_name" + ], + "unique": true + }, + { + "name": "model_availability_model_name_idx", + "table": "model_availability", + "columns": [ + "model_name" + ], + "unique": false + }, + { + "name": "proxy_files_owner_lookup_idx", + "table": "proxy_files", + "columns": [ + "owner_type", + "owner_id", + "deleted_at" + ], + "unique": false + }, + { + "name": "proxy_files_public_id_unique", + "table": "proxy_files", + "columns": [ + "public_id" + ], + "unique": true + }, + { + "name": "proxy_logs_account_created_at_idx", + "table": "proxy_logs", + "columns": [ + "account_id", + "created_at" + ], + "unique": false + }, + { + "name": "proxy_logs_client_app_id_created_at_idx", + "table": "proxy_logs", + "columns": [ + "client_app_id", + "created_at" + ], + "unique": false + }, + { + "name": "proxy_logs_client_family_created_at_idx", + "table": "proxy_logs", + "columns": [ + "client_family", + "created_at" + ], + "unique": false + }, + { + "name": "proxy_logs_created_at_idx", + "table": "proxy_logs", + "columns": [ + "created_at" + ], + "unique": false + }, + { + "name": "proxy_logs_downstream_api_key_created_at_idx", + "table": "proxy_logs", + "columns": [ + "downstream_api_key_id", + "created_at" + ], + "unique": false + }, + { + "name": "proxy_logs_model_actual_created_at_idx", + "table": "proxy_logs", + "columns": [ + "model_actual", + "created_at" + ], + "unique": false + }, + { + "name": "proxy_logs_status_created_at_idx", + "table": "proxy_logs", + "columns": [ + "status", + "created_at" + ], + "unique": false + }, + { + "name": "proxy_video_tasks_created_at_idx", + "table": "proxy_video_tasks", + "columns": [ + "created_at" + ], + "unique": false + }, + { + "name": "proxy_video_tasks_public_id_unique", + "table": "proxy_video_tasks", + "columns": [ + "public_id" + ], + "unique": true + }, + { + "name": "proxy_video_tasks_upstream_video_id_idx", + "table": "proxy_video_tasks", + "columns": [ + "upstream_video_id" + ], + "unique": false + }, + { + "name": "route_channels_account_id_idx", + "table": "route_channels", + "columns": [ + "account_id" + ], + "unique": false + }, + { + "name": "route_channels_route_enabled_idx", + "table": "route_channels", + "columns": [ + "route_id", + "enabled" + ], + "unique": false + }, + { + "name": "route_channels_route_id_idx", + "table": "route_channels", + "columns": [ + "route_id" + ], + "unique": false + }, + { + "name": "route_channels_route_token_idx", + "table": "route_channels", + "columns": [ + "route_id", + "token_id" + ], + "unique": false + }, + { + "name": "route_channels_token_id_idx", + "table": "route_channels", + "columns": [ + "token_id" + ], + "unique": false + }, + { + "name": "route_group_sources_group_source_unique", + "table": "route_group_sources", + "columns": [ + "group_route_id", + "source_route_id" + ], + "unique": true + }, + { + "name": "route_group_sources_source_route_id_idx", + "table": "route_group_sources", + "columns": [ + "source_route_id" + ], + "unique": false + }, + { + "name": "site_announcements_read_at_idx", + "table": "site_announcements", + "columns": [ + "read_at" + ], + "unique": false + }, + { + "name": "site_announcements_site_id_first_seen_at_idx", + "table": "site_announcements", + "columns": [ + "site_id", + "first_seen_at" + ], + "unique": false + }, + { + "name": "site_announcements_site_source_key_unique", + "table": "site_announcements", + "columns": [ + "site_id", + "source_key" + ], + "unique": true + }, + { + "name": "site_disabled_models_site_id_idx", + "table": "site_disabled_models", + "columns": [ + "site_id" + ], + "unique": false + }, + { + "name": "site_disabled_models_site_model_unique", + "table": "site_disabled_models", + "columns": [ + "site_id", + "model_name" + ], + "unique": true + }, + { + "name": "sites_platform_url_unique", + "table": "sites", + "columns": [ + "platform", + "url" + ], + "unique": true + }, + { + "name": "sites_status_idx", + "table": "sites", + "columns": [ + "status" + ], + "unique": false + }, + { + "name": "token_model_availability_available_idx", + "table": "token_model_availability", + "columns": [ + "available" + ], + "unique": false + }, + { + "name": "token_model_availability_model_name_idx", + "table": "token_model_availability", + "columns": [ + "model_name" + ], + "unique": false + }, + { + "name": "token_model_availability_token_available_idx", + "table": "token_model_availability", + "columns": [ + "token_id", + "available" + ], + "unique": false + }, + { + "name": "token_model_availability_token_model_unique", + "table": "token_model_availability", + "columns": [ + "token_id", + "model_name" + ], + "unique": true + }, + { + "name": "token_routes_enabled_idx", + "table": "token_routes", + "columns": [ + "enabled" + ], + "unique": false + }, + { + "name": "token_routes_model_pattern_idx", + "table": "token_routes", + "columns": [ + "model_pattern" + ], + "unique": false + } + ], + "uniques": [ + { + "name": "downstream_api_keys_key_unique", + "table": "downstream_api_keys", + "columns": [ + "key" + ] + }, + { + "name": "model_availability_account_model_unique", + "table": "model_availability", + "columns": [ + "account_id", + "model_name" + ] + }, + { + "name": "proxy_files_public_id_unique", + "table": "proxy_files", + "columns": [ + "public_id" + ] + }, + { + "name": "proxy_video_tasks_public_id_unique", + "table": "proxy_video_tasks", + "columns": [ + "public_id" + ] + }, + { + "name": "route_group_sources_group_source_unique", + "table": "route_group_sources", + "columns": [ + "group_route_id", + "source_route_id" + ] + }, + { + "name": "site_announcements_site_source_key_unique", + "table": "site_announcements", + "columns": [ + "site_id", + "source_key" + ] + }, + { + "name": "site_disabled_models_site_model_unique", + "table": "site_disabled_models", + "columns": [ + "site_id", + "model_name" + ] + }, + { + "name": "sites_platform_url_unique", + "table": "sites", + "columns": [ + "platform", + "url" + ] + }, + { + "name": "token_model_availability_token_model_unique", + "table": "token_model_availability", + "columns": [ + "token_id", + "model_name" + ] + } + ], + "foreignKeys": [ + { + "table": "account_tokens", + "columns": [ + "account_id" + ], + "referencedTable": "accounts", + "referencedColumns": [ + "id" + ], + "onDelete": "CASCADE" + }, + { + "table": "accounts", + "columns": [ + "site_id" + ], + "referencedTable": "sites", + "referencedColumns": [ + "id" + ], + "onDelete": "CASCADE" + }, + { + "table": "checkin_logs", + "columns": [ + "account_id" + ], + "referencedTable": "accounts", + "referencedColumns": [ + "id" + ], + "onDelete": "CASCADE" + }, + { + "table": "model_availability", + "columns": [ + "account_id" + ], + "referencedTable": "accounts", + "referencedColumns": [ + "id" + ], + "onDelete": "CASCADE" + }, + { + "table": "route_channels", + "columns": [ + "account_id" + ], + "referencedTable": "accounts", + "referencedColumns": [ + "id" + ], + "onDelete": "CASCADE" + }, + { + "table": "route_channels", + "columns": [ + "route_id" + ], + "referencedTable": "token_routes", + "referencedColumns": [ + "id" + ], + "onDelete": "CASCADE" + }, + { + "table": "route_channels", + "columns": [ + "token_id" + ], + "referencedTable": "account_tokens", + "referencedColumns": [ + "id" + ], + "onDelete": "SET NULL" + }, + { + "table": "route_group_sources", + "columns": [ + "group_route_id" + ], + "referencedTable": "token_routes", + "referencedColumns": [ + "id" + ], + "onDelete": "CASCADE" + }, + { + "table": "route_group_sources", + "columns": [ + "source_route_id" + ], + "referencedTable": "token_routes", + "referencedColumns": [ + "id" + ], + "onDelete": "CASCADE" + }, + { + "table": "site_announcements", + "columns": [ + "site_id" + ], + "referencedTable": "sites", + "referencedColumns": [ + "id" + ], + "onDelete": "CASCADE" + }, + { + "table": "site_disabled_models", + "columns": [ + "site_id" + ], + "referencedTable": "sites", + "referencedColumns": [ + "id" + ], + "onDelete": "CASCADE" + }, + { + "table": "token_model_availability", + "columns": [ + "token_id" + ], + "referencedTable": "account_tokens", + "referencedColumns": [ + "id" + ], + "onDelete": "CASCADE" + } + ] +} diff --git a/src/server/db/index.default-path.test.ts b/src/server/db/index.default-path.test.ts new file mode 100644 index 00000000..bac23b87 --- /dev/null +++ b/src/server/db/index.default-path.test.ts @@ -0,0 +1,58 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { tmpdir } from 'node:os'; +import { resolve } from 'node:path'; + +type DbModule = typeof import('./index.js'); + +describe('sqlite default path resolution', () => { + let dbModule: DbModule | null = null; + + afterEach(async () => { + if (dbModule) { + await dbModule.closeDbConnections(); + dbModule = null; + } + delete process.env.DATA_DIR; + delete process.env.DB_URL; + vi.resetModules(); + }); + + it('uses an isolated temp sqlite path under vitest when no db env is configured', async () => { + delete process.env.DATA_DIR; + delete process.env.DB_URL; + vi.resetModules(); + + dbModule = await import('./index.js'); + const sqlitePath = dbModule.__dbProxyTestUtils.resolveSqlitePath(); + const sharedRepoPath = resolve('./data/hub.db'); + + expect(sqlitePath).not.toBe(sharedRepoPath); + expect(sqlitePath).toContain(tmpdir()); + expect(sqlitePath).toContain('metapi-vitest'); + }); + + it('still honors explicit DATA_DIR when provided', async () => { + process.env.DATA_DIR = resolve(tmpdir(), 'metapi-explicit-data-dir'); + delete process.env.DB_URL; + vi.resetModules(); + + dbModule = await import('./index.js'); + const sqlitePath = dbModule.__dbProxyTestUtils.resolveSqlitePath(); + + expect(sqlitePath).toBe(resolve(process.env.DATA_DIR, 'hub.db')); + }); + + it('ignores the default repo DATA_DIR under vitest and still isolates sqlite', async () => { + process.env.DATA_DIR = './data'; + delete process.env.DB_URL; + vi.resetModules(); + + dbModule = await import('./index.js'); + const sqlitePath = dbModule.__dbProxyTestUtils.resolveSqlitePath(); + const sharedRepoPath = resolve('./data/hub.db'); + + expect(sqlitePath).not.toBe(sharedRepoPath); + expect(sqlitePath).toContain(tmpdir()); + expect(sqlitePath).toContain('metapi-vitest'); + }); +}); diff --git a/src/server/db/index.proxy-wrap.test.ts b/src/server/db/index.proxy-wrap.test.ts index 7e68a0eb..74f5d25b 100644 --- a/src/server/db/index.proxy-wrap.test.ts +++ b/src/server/db/index.proxy-wrap.test.ts @@ -1,7 +1,7 @@ import { mkdtempSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { beforeAll, describe, expect, it, vi } from 'vitest'; +import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; type DbModule = typeof import('./index.js'); @@ -14,6 +14,10 @@ describe('db proxy query wrapper', () => { testUtils = dbModule.__dbProxyTestUtils; }); + beforeEach(() => { + testUtils.resetPostgresJsonTextParsersInstallStateForTests(); + }); + it('wraps thenable query builders and provides all/get shims', async () => { const execute = vi.fn(async () => [{ id: 1, name: 'demo' }]); const queryLike = { @@ -53,5 +57,46 @@ describe('db proxy query wrapper', () => { expect(runResult).toEqual({ changes: 3, lastInsertRowid: 9 }); expect(testUtils.shouldWrapObject(Promise.resolve())).toBe(false); }); -}); + it('builds mysql pool options with jsonStrings enabled', () => { + expect(testUtils.buildMysqlPoolOptions('mysql://root:pass@db.example.com:3306/metapi', false)).toMatchObject({ + uri: 'mysql://root:pass@db.example.com:3306/metapi', + jsonStrings: true, + }); + expect(testUtils.buildMysqlPoolOptions('mysql://root:pass@db.example.com:3306/metapi', true)).toMatchObject({ + uri: 'mysql://root:pass@db.example.com:3306/metapi', + jsonStrings: true, + ssl: { rejectUnauthorized: false }, + }); + }); + + it('builds postgres pool options with ssl when requested', () => { + expect(testUtils.buildPostgresPoolOptions('postgres://user:pass@db.example.com:5432/metapi', false)).toEqual({ + connectionString: 'postgres://user:pass@db.example.com:5432/metapi', + }); + expect(testUtils.buildPostgresPoolOptions('postgres://user:pass@db.example.com:5432/metapi', true)).toMatchObject({ + connectionString: 'postgres://user:pass@db.example.com:5432/metapi', + ssl: { rejectUnauthorized: false }, + }); + }); + + it('installs postgres JSON text parsers idempotently', () => { + const setTypeParser = vi.fn(); + const fakeTypes = { + builtins: { + JSON: 114, + JSONB: 3802, + }, + setTypeParser, + }; + + testUtils.installPostgresJsonTextParsers(fakeTypes as any); + testUtils.installPostgresJsonTextParsers(fakeTypes as any); + + expect(setTypeParser).toHaveBeenCalledTimes(2); + expect(setTypeParser).toHaveBeenNthCalledWith(1, 114, 'text', expect.any(Function)); + expect(setTypeParser).toHaveBeenNthCalledWith(2, 3802, 'text', expect.any(Function)); + const parser = setTypeParser.mock.calls[0][2] as (value: string) => string; + expect(parser('{"ok":true}')).toBe('{"ok":true}'); + }); +}); diff --git a/src/server/db/index.ts b/src/server/db/index.ts index 40fe93cc..a9660396 100644 --- a/src/server/db/index.ts +++ b/src/server/db/index.ts @@ -5,12 +5,20 @@ import { drizzle as drizzleSqliteProxy } from 'drizzle-orm/sqlite-proxy'; import { drizzle as drizzleMysqlProxy } from 'drizzle-orm/mysql-proxy'; import { drizzle as drizzlePgProxy } from 'drizzle-orm/pg-proxy'; import * as schema from './schema.js'; +import { + installPostgresJsonTextParsers, + resetPostgresJsonTextParsersInstallStateForTests, +} from './postgresJsonTextParsers.js'; import { ensureSiteSchemaCompatibility, type SiteSchemaInspector } from './siteSchemaCompatibility.js'; import { ensureRouteGroupingSchemaCompatibility } from './routeGroupingSchemaCompatibility.js'; import { ensureProxyFileSchemaCompatibility } from './proxyFileSchemaCompatibility.js'; +import { executeLegacyCompat, executeLegacyCompatSync } from './legacySchemaCompat.js'; import { config } from '../config.js'; +import { ensureRuntimeDatabaseReady } from '../runtimeDatabaseBootstrap.js'; import { mkdirSync } from 'fs'; +import { tmpdir } from 'os'; import { dirname, resolve } from 'path'; +import { threadId } from 'worker_threads'; export type RuntimeDbDialect = 'sqlite' | 'mysql' | 'postgres'; type SqlMethod = 'all' | 'get' | 'run' | 'values' | 'execute'; @@ -23,11 +31,13 @@ const TABLES_WITH_NUMERIC_ID = new Set([ 'model_availability', 'token_model_availability', 'token_routes', + 'route_group_sources', 'route_channels', 'proxy_logs', 'proxy_video_tasks', 'proxy_files', 'downstream_api_keys', + 'site_announcements', 'events', ]); @@ -37,10 +47,43 @@ let sqliteConnection: Database.Database | null = null; let mysqlPool: mysql.Pool | null = null; let pgPool: pg.Pool | null = null; let proxyLogBillingDetailsColumnAvailable: boolean | null = null; +let proxyLogDownstreamApiKeyIdColumnAvailable: boolean | null = null; +let proxyLogClientColumnsAvailable: boolean | null = null; + +function buildMysqlPoolOptions( + connectionString = config.dbUrl, + sslEnabled = config.dbSsl, +): mysql.PoolOptions { + const poolOptions: mysql.PoolOptions = { + uri: connectionString, + jsonStrings: true, + }; + if (sslEnabled) { + poolOptions.ssl = { rejectUnauthorized: false }; + } + return poolOptions; +} + +function buildPostgresPoolOptions( + connectionString = config.dbUrl, + sslEnabled = config.dbSsl, +): pg.PoolConfig { + const poolOptions: pg.PoolConfig = { connectionString }; + if (sslEnabled) { + poolOptions.ssl = { rejectUnauthorized: false }; + } + return poolOptions; +} function resolveSqlitePath(): string { const raw = (config.dbUrl || '').trim(); - if (!raw) return resolve(`${config.dataDir}/hub.db`); + if (!raw) { + const isolatedVitestPath = resolveVitestSqlitePath(); + if (isolatedVitestPath) { + return isolatedVitestPath; + } + return resolve(`${config.dataDir}/hub.db`); + } if (raw === ':memory:') return raw; if (raw.startsWith('file://')) { const parsed = new URL(raw); @@ -52,6 +95,41 @@ function resolveSqlitePath(): string { return resolve(raw); } +function isVitestRuntime(): boolean { + if ((process.env.VITEST_POOL_ID || '').trim()) { + return true; + } + if ((process.env.VITEST_WORKER_ID || '').trim()) { + return true; + } + const runtimeArgs = [...process.argv, ...process.execArgv] + .map((value) => String(value || '').toLowerCase()); + return runtimeArgs.some((value) => value.includes('vitest')); +} + +function isDefaultRepoDataDir(value: string | undefined): boolean { + const trimmed = (value || '').trim(); + if (!trimmed) return false; + return resolve(trimmed) === resolve('./data'); +} + +function resolveVitestSqlitePath(): string | null { + if (!isVitestRuntime()) { + return null; + } + if ((process.env.DB_URL || '').trim()) { + return null; + } + if ((process.env.DATA_DIR || '').trim() && !isDefaultRepoDataDir(process.env.DATA_DIR)) { + return null; + } + + const workerTag = process.env.VITEST_POOL_ID + || process.env.VITEST_WORKER_ID + || `${process.pid}-${threadId}`; + return resolve(tmpdir(), `metapi-vitest-${workerTag}`, 'hub.db'); +} + function requireSqliteConnection(): Database.Database { if (!sqliteConnection) { throw new Error('SQLite connection is not initialized'); @@ -72,19 +150,33 @@ function tableColumnExists(table: string, column: string): boolean { return rows.some((row) => row.name === column); } -function ensureTokenManagementSchema() { +function tableIndexExists(indexName: string): boolean { const sqlite = requireSqliteConnection(); + const row = sqlite.prepare("SELECT name FROM sqlite_master WHERE type = 'index' AND name = ? LIMIT 1") + .get(indexName) as { name?: string } | undefined; + return !!row?.name; +} + +function execSqliteStatement(sqlText: string): void { + requireSqliteConnection().exec(sqlText); +} + +function execSqliteLegacyCompat(sqlText: string): void { + executeLegacyCompatSync(execSqliteStatement, sqlText); +} + +function ensureTokenManagementSchema() { if (!tableExists('accounts') || !tableExists('route_channels')) { return; } - - sqlite.exec(` + execSqliteLegacyCompat(` CREATE TABLE IF NOT EXISTS account_tokens ( id integer PRIMARY KEY AUTOINCREMENT NOT NULL, account_id integer NOT NULL, name text NOT NULL, token text NOT NULL, token_group text, + value_status text NOT NULL DEFAULT 'ready', source text DEFAULT 'manual', enabled integer DEFAULT true, is_default integer DEFAULT false, @@ -93,16 +185,18 @@ function ensureTokenManagementSchema() { FOREIGN KEY (account_id) REFERENCES accounts(id) ON DELETE cascade ); `); - if (!tableColumnExists('route_channels', 'token_id')) { - sqlite.exec('ALTER TABLE route_channels ADD COLUMN token_id integer;'); + execSqliteLegacyCompat('ALTER TABLE route_channels ADD COLUMN token_id integer;'); } if (!tableColumnExists('account_tokens', 'token_group')) { - sqlite.exec('ALTER TABLE account_tokens ADD COLUMN token_group text;'); + execSqliteLegacyCompat('ALTER TABLE account_tokens ADD COLUMN token_group text;'); + } + if (!tableColumnExists('account_tokens', 'value_status')) { + execSqliteLegacyCompat("ALTER TABLE account_tokens ADD COLUMN value_status text NOT NULL DEFAULT 'ready';"); } - sqlite.exec(` + execSqliteStatement(` INSERT INTO account_tokens (account_id, name, token, source, enabled, is_default, created_at, updated_at) SELECT a.id, @@ -117,6 +211,8 @@ function ensureTokenManagementSchema() { WHERE a.api_token IS NOT NULL AND trim(a.api_token) <> '' + AND a.access_token IS NOT NULL + AND trim(a.access_token) <> '' AND NOT EXISTS ( SELECT 1 FROM account_tokens AS t WHERE t.account_id = a.id @@ -124,7 +220,7 @@ function ensureTokenManagementSchema() { ); `); - sqlite.exec(` + execSqliteLegacyCompat(` CREATE TABLE IF NOT EXISTS token_model_availability ( id integer PRIMARY KEY AUTOINCREMENT NOT NULL, token_id integer NOT NULL, @@ -136,15 +232,14 @@ function ensureTokenManagementSchema() { ); `); - sqlite.exec(` + execSqliteLegacyCompat(` CREATE UNIQUE INDEX IF NOT EXISTS token_model_availability_token_model_unique ON token_model_availability(token_id, model_name); `); } function ensureProxyVideoTaskSchema() { - const sqlite = requireSqliteConnection(); - sqlite.exec(` + execSqliteLegacyCompat(` CREATE TABLE IF NOT EXISTS proxy_video_tasks ( id integer PRIMARY KEY AUTOINCREMENT NOT NULL, public_id text NOT NULL, @@ -164,30 +259,29 @@ function ensureProxyVideoTaskSchema() { ); `); if (!tableColumnExists('proxy_video_tasks', 'status_snapshot')) { - sqlite.exec('ALTER TABLE proxy_video_tasks ADD COLUMN status_snapshot text;'); + execSqliteLegacyCompat('ALTER TABLE proxy_video_tasks ADD COLUMN status_snapshot text;'); } if (!tableColumnExists('proxy_video_tasks', 'upstream_response_meta')) { - sqlite.exec('ALTER TABLE proxy_video_tasks ADD COLUMN upstream_response_meta text;'); + execSqliteLegacyCompat('ALTER TABLE proxy_video_tasks ADD COLUMN upstream_response_meta text;'); } if (!tableColumnExists('proxy_video_tasks', 'last_upstream_status')) { - sqlite.exec('ALTER TABLE proxy_video_tasks ADD COLUMN last_upstream_status integer;'); + execSqliteLegacyCompat('ALTER TABLE proxy_video_tasks ADD COLUMN last_upstream_status integer;'); } if (!tableColumnExists('proxy_video_tasks', 'last_polled_at')) { - sqlite.exec('ALTER TABLE proxy_video_tasks ADD COLUMN last_polled_at text;'); + execSqliteLegacyCompat('ALTER TABLE proxy_video_tasks ADD COLUMN last_polled_at text;'); } - sqlite.exec(` + execSqliteLegacyCompat(` CREATE UNIQUE INDEX IF NOT EXISTS proxy_video_tasks_public_id_unique ON proxy_video_tasks(public_id); `); - sqlite.exec(` + execSqliteLegacyCompat(` CREATE INDEX IF NOT EXISTS proxy_video_tasks_upstream_video_id_idx ON proxy_video_tasks(upstream_video_id); `); } function ensureProxyFileSchema() { - const sqlite = requireSqliteConnection(); - sqlite.exec(` + execSqliteLegacyCompat(` CREATE TABLE IF NOT EXISTS proxy_files ( id integer PRIMARY KEY AUTOINCREMENT NOT NULL, public_id text NOT NULL, @@ -204,27 +298,26 @@ function ensureProxyFileSchema() { deleted_at text ); `); - sqlite.exec(` + execSqliteLegacyCompat(` CREATE UNIQUE INDEX IF NOT EXISTS proxy_files_public_id_unique ON proxy_files(public_id); `); - sqlite.exec(` + execSqliteLegacyCompat(` CREATE INDEX IF NOT EXISTS proxy_files_owner_lookup_idx ON proxy_files(owner_type, owner_id, deleted_at); `); } function ensureSiteStatusSchema() { - const sqlite = requireSqliteConnection(); if (!tableExists('sites')) { return; } if (!tableColumnExists('sites', 'status')) { - sqlite.exec(`ALTER TABLE sites ADD COLUMN status text DEFAULT 'active';`); + execSqliteLegacyCompat(`ALTER TABLE sites ADD COLUMN status text DEFAULT 'active';`); } - sqlite.exec(` + execSqliteStatement(` UPDATE sites SET status = lower(trim(status)) WHERE status IS NOT NULL @@ -232,7 +325,7 @@ function ensureSiteStatusSchema() { AND status != lower(trim(status)); `); - sqlite.exec(` + execSqliteStatement(` UPDATE sites SET status = 'active' WHERE status IS NULL @@ -242,55 +335,61 @@ function ensureSiteStatusSchema() { } function ensureSiteProxySchema() { - const sqlite = requireSqliteConnection(); if (!tableExists('sites')) { return; } if (!tableColumnExists('sites', 'proxy_url')) { - sqlite.exec(`ALTER TABLE sites ADD COLUMN proxy_url text;`); + execSqliteLegacyCompat(`ALTER TABLE sites ADD COLUMN proxy_url text;`); } } function ensureSiteUseSystemProxySchema() { - const sqlite = requireSqliteConnection(); if (!tableExists('sites')) { return; } if (!tableColumnExists('sites', 'use_system_proxy')) { - sqlite.exec(`ALTER TABLE sites ADD COLUMN use_system_proxy integer DEFAULT 0;`); + execSqliteLegacyCompat(`ALTER TABLE sites ADD COLUMN use_system_proxy integer DEFAULT 0;`); } - sqlite.exec(` + execSqliteLegacyCompat(` UPDATE sites SET use_system_proxy = 0 WHERE use_system_proxy IS NULL; `); } +function ensureSiteCustomHeadersSchema() { + if (!tableExists('sites')) { + return; + } + + if (!tableColumnExists('sites', 'custom_headers')) { + execSqliteLegacyCompat(`ALTER TABLE sites ADD COLUMN custom_headers text;`); + } +} + function ensureSiteExternalCheckinUrlSchema() { - const sqlite = requireSqliteConnection(); if (!tableExists('sites')) { return; } if (!tableColumnExists('sites', 'external_checkin_url')) { - sqlite.exec(`ALTER TABLE sites ADD COLUMN external_checkin_url text;`); + execSqliteLegacyCompat(`ALTER TABLE sites ADD COLUMN external_checkin_url text;`); } } function ensureSiteGlobalWeightSchema() { - const sqlite = requireSqliteConnection(); if (!tableExists('sites')) { return; } if (!tableColumnExists('sites', 'global_weight')) { - sqlite.exec(`ALTER TABLE sites ADD COLUMN global_weight real DEFAULT 1;`); + execSqliteLegacyCompat(`ALTER TABLE sites ADD COLUMN global_weight real DEFAULT 1;`); } - sqlite.exec(` + execSqliteLegacyCompat(` UPDATE sites SET global_weight = 1 WHERE global_weight IS NULL @@ -311,7 +410,7 @@ function createSqliteSchemaInspector(): RuntimeSchemaInspector { tableExists: async (table) => tableExists(table), columnExists: async (table, column) => tableColumnExists(table, column), execute: async (sqlText) => { - requireSqliteConnection().exec(sqlText); + executeLegacyCompatSync(execSqliteStatement, sqlText); }, }; } @@ -335,7 +434,7 @@ function createMysqlSchemaInspector(): RuntimeSchemaInspector | null { return Array.isArray(rows) && rows.length > 0; }, execute: async (sqlText) => { - await mysqlPool!.query(sqlText); + await executeLegacyCompat((statement) => mysqlPool!.query(statement).then(() => undefined), sqlText); }, }; } @@ -359,7 +458,7 @@ function createPostgresSchemaInspector(): RuntimeSchemaInspector | null { return Number(result.rowCount || 0) > 0; }, execute: async (sqlText) => { - await pgPool!.query(sqlText); + await executeLegacyCompat((statement) => pgPool!.query(statement).then(() => undefined), sqlText); }, }; } @@ -393,40 +492,76 @@ export async function ensureProxyFileCompatibilityColumns(): Promise<void> { } function ensureRouteGroupingSchema() { - const sqlite = requireSqliteConnection(); if (!tableExists('token_routes') || !tableExists('route_channels')) { return; } if (!tableColumnExists('token_routes', 'display_name')) { - sqlite.exec(`ALTER TABLE token_routes ADD COLUMN display_name text;`); + execSqliteLegacyCompat(`ALTER TABLE token_routes ADD COLUMN display_name text;`); } if (!tableColumnExists('token_routes', 'display_icon')) { - sqlite.exec(`ALTER TABLE token_routes ADD COLUMN display_icon text;`); + execSqliteLegacyCompat(`ALTER TABLE token_routes ADD COLUMN display_icon text;`); + } + + if (!tableColumnExists('token_routes', 'route_mode')) { + execSqliteLegacyCompat(`ALTER TABLE token_routes ADD COLUMN route_mode text DEFAULT 'pattern';`); } if (!tableColumnExists('token_routes', 'decision_snapshot')) { - sqlite.exec(`ALTER TABLE token_routes ADD COLUMN decision_snapshot text;`); + execSqliteLegacyCompat(`ALTER TABLE token_routes ADD COLUMN decision_snapshot text;`); } if (!tableColumnExists('token_routes', 'decision_refreshed_at')) { - sqlite.exec(`ALTER TABLE token_routes ADD COLUMN decision_refreshed_at text;`); + execSqliteLegacyCompat(`ALTER TABLE token_routes ADD COLUMN decision_refreshed_at text;`); + } + + if (!tableColumnExists('token_routes', 'routing_strategy')) { + execSqliteLegacyCompat(`ALTER TABLE token_routes ADD COLUMN routing_strategy text DEFAULT 'weighted';`); } if (!tableColumnExists('route_channels', 'source_model')) { - sqlite.exec(`ALTER TABLE route_channels ADD COLUMN source_model text;`); + execSqliteLegacyCompat(`ALTER TABLE route_channels ADD COLUMN source_model text;`); } + + if (!tableColumnExists('route_channels', 'last_selected_at')) { + execSqliteLegacyCompat(`ALTER TABLE route_channels ADD COLUMN last_selected_at text;`); + } + + if (!tableColumnExists('route_channels', 'consecutive_fail_count')) { + execSqliteLegacyCompat(`ALTER TABLE route_channels ADD COLUMN consecutive_fail_count integer NOT NULL DEFAULT 0;`); + } + + if (!tableColumnExists('route_channels', 'cooldown_level')) { + execSqliteLegacyCompat(`ALTER TABLE route_channels ADD COLUMN cooldown_level integer NOT NULL DEFAULT 0;`); + } + + execSqliteLegacyCompat(` + CREATE TABLE IF NOT EXISTS route_group_sources ( + id integer PRIMARY KEY AUTOINCREMENT NOT NULL, + group_route_id integer NOT NULL REFERENCES token_routes(id) ON DELETE cascade, + source_route_id integer NOT NULL REFERENCES token_routes(id) ON DELETE cascade + ); + `); + execSqliteLegacyCompat(` + CREATE UNIQUE INDEX IF NOT EXISTS route_group_sources_group_source_unique + ON route_group_sources(group_route_id, source_route_id); + `); + execSqliteLegacyCompat(` + CREATE INDEX IF NOT EXISTS route_group_sources_source_route_id_idx + ON route_group_sources(source_route_id); + `); } function ensureDownstreamApiKeySchema() { - const sqlite = requireSqliteConnection(); - sqlite.exec(` + execSqliteLegacyCompat(` CREATE TABLE IF NOT EXISTS downstream_api_keys ( id integer PRIMARY KEY AUTOINCREMENT NOT NULL, name text NOT NULL, key text NOT NULL, description text, + group_name text, + tags text, enabled integer DEFAULT true, expires_at text, max_cost real, @@ -442,37 +577,90 @@ function ensureDownstreamApiKeySchema() { ); `); - sqlite.exec(` + execSqliteLegacyCompat(` CREATE UNIQUE INDEX IF NOT EXISTS downstream_api_keys_key_unique ON downstream_api_keys(key); `); - sqlite.exec(` + execSqliteLegacyCompat(` CREATE INDEX IF NOT EXISTS downstream_api_keys_name_idx ON downstream_api_keys(name); `); - sqlite.exec(` + execSqliteLegacyCompat(` CREATE INDEX IF NOT EXISTS downstream_api_keys_enabled_idx ON downstream_api_keys(enabled); `); - sqlite.exec(` + execSqliteLegacyCompat(` CREATE INDEX IF NOT EXISTS downstream_api_keys_expires_at_idx ON downstream_api_keys(expires_at); `); + + if (!tableColumnExists('downstream_api_keys', 'group_name')) { + execSqliteLegacyCompat('ALTER TABLE downstream_api_keys ADD COLUMN group_name text;'); + } + + if (!tableColumnExists('downstream_api_keys', 'tags')) { + execSqliteLegacyCompat('ALTER TABLE downstream_api_keys ADD COLUMN tags text;'); + } } function ensureProxyLogBillingDetailsSchema() { - const sqlite = requireSqliteConnection(); if (!tableExists('proxy_logs')) { return; } if (!tableColumnExists('proxy_logs', 'billing_details')) { - sqlite.exec('ALTER TABLE proxy_logs ADD COLUMN billing_details text;'); + execSqliteLegacyCompat('ALTER TABLE proxy_logs ADD COLUMN billing_details text;'); } proxyLogBillingDetailsColumnAvailable = true; } +function ensureProxyLogDownstreamApiKeyIdSchema() { + if (!tableExists('proxy_logs')) { + return; + } + + if (!tableColumnExists('proxy_logs', 'downstream_api_key_id')) { + execSqliteLegacyCompat('ALTER TABLE proxy_logs ADD COLUMN downstream_api_key_id integer;'); + } + + proxyLogDownstreamApiKeyIdColumnAvailable = true; +} + +function ensureProxyLogClientSchema() { + if (!tableExists('proxy_logs')) { + return; + } + + if (!tableColumnExists('proxy_logs', 'client_family')) { + execSqliteLegacyCompat('ALTER TABLE proxy_logs ADD COLUMN client_family text;'); + } + if (!tableColumnExists('proxy_logs', 'client_app_id')) { + execSqliteLegacyCompat('ALTER TABLE proxy_logs ADD COLUMN client_app_id text;'); + } + if (!tableColumnExists('proxy_logs', 'client_app_name')) { + execSqliteLegacyCompat('ALTER TABLE proxy_logs ADD COLUMN client_app_name text;'); + } + if (!tableColumnExists('proxy_logs', 'client_confidence')) { + execSqliteLegacyCompat('ALTER TABLE proxy_logs ADD COLUMN client_confidence text;'); + } + + if (!tableIndexExists('proxy_logs_client_app_id_created_at_idx')) { + execSqliteLegacyCompat(` + CREATE INDEX IF NOT EXISTS proxy_logs_client_app_id_created_at_idx + ON proxy_logs(client_app_id, created_at); + `); + } + if (!tableIndexExists('proxy_logs_client_family_created_at_idx')) { + execSqliteLegacyCompat(` + CREATE INDEX IF NOT EXISTS proxy_logs_client_family_created_at_idx + ON proxy_logs(client_family, created_at); + `); + } + + proxyLogClientColumnsAvailable = true; +} + function normalizeSchemaErrorMessage(error: unknown): string { if (typeof error === 'object' && error && 'message' in error) { return String((error as { message?: unknown }).message || ''); @@ -485,6 +673,14 @@ function isDuplicateColumnError(error: unknown): boolean { return lowered.includes('duplicate column') || lowered.includes('already exists'); } +function isDuplicateIndexError(error: unknown): boolean { + const lowered = normalizeSchemaErrorMessage(error).toLowerCase(); + return lowered.includes('duplicate key name') + || lowered.includes('already exists') + || lowered.includes('relation') + || lowered.includes('duplicate index'); +} + export async function hasProxyLogBillingDetailsColumn(): Promise<boolean> { if (proxyLogBillingDetailsColumnAvailable !== null) { return proxyLogBillingDetailsColumnAvailable; @@ -527,10 +723,16 @@ export async function ensureProxyLogBillingDetailsColumn(): Promise<boolean> { try { if (runtimeDbDialect === 'mysql') { if (!mysqlPool) return false; - await mysqlPool.query('ALTER TABLE `proxy_logs` ADD COLUMN `billing_details` TEXT NULL'); + await executeLegacyCompat( + (statement) => mysqlPool!.query(statement).then(() => undefined), + 'ALTER TABLE `proxy_logs` ADD COLUMN `billing_details` TEXT NULL', + ); } else { if (!pgPool) return false; - await pgPool.query('ALTER TABLE "proxy_logs" ADD COLUMN "billing_details" TEXT'); + await executeLegacyCompat( + (statement) => pgPool!.query(statement).then(() => undefined), + 'ALTER TABLE "proxy_logs" ADD COLUMN "billing_details" TEXT', + ); } proxyLogBillingDetailsColumnAvailable = true; return true; @@ -545,8 +747,249 @@ export async function ensureProxyLogBillingDetailsColumn(): Promise<boolean> { } } +export async function hasProxyLogDownstreamApiKeyIdColumn(): Promise<boolean> { + if (proxyLogDownstreamApiKeyIdColumnAvailable !== null) { + return proxyLogDownstreamApiKeyIdColumnAvailable; + } + + if (runtimeDbDialect === 'sqlite') { + proxyLogDownstreamApiKeyIdColumnAvailable = tableExists('proxy_logs') + && tableColumnExists('proxy_logs', 'downstream_api_key_id'); + return proxyLogDownstreamApiKeyIdColumnAvailable; + } + + if (runtimeDbDialect === 'mysql') { + if (!mysqlPool) return false; + const [rows] = await mysqlPool.query('SHOW COLUMNS FROM `proxy_logs` LIKE ?', ['downstream_api_key_id']); + proxyLogDownstreamApiKeyIdColumnAvailable = Array.isArray(rows) && rows.length > 0; + return proxyLogDownstreamApiKeyIdColumnAvailable; + } + + if (!pgPool) return false; + const result = await pgPool.query( + 'SELECT 1 FROM information_schema.columns WHERE table_schema = current_schema() AND table_name = $1 AND column_name = $2 LIMIT 1', + ['proxy_logs', 'downstream_api_key_id'], + ); + proxyLogDownstreamApiKeyIdColumnAvailable = Number(result.rowCount || 0) > 0; + return proxyLogDownstreamApiKeyIdColumnAvailable; +} + +export async function ensureProxyLogDownstreamApiKeyIdColumn(): Promise<boolean> { + if (runtimeDbDialect === 'sqlite') { + ensureProxyLogDownstreamApiKeyIdSchema(); + proxyLogDownstreamApiKeyIdColumnAvailable = tableExists('proxy_logs') + && tableColumnExists('proxy_logs', 'downstream_api_key_id'); + return proxyLogDownstreamApiKeyIdColumnAvailable; + } + + if (await hasProxyLogDownstreamApiKeyIdColumn()) { + return true; + } + + try { + if (runtimeDbDialect === 'mysql') { + if (!mysqlPool) return false; + await executeLegacyCompat( + (statement) => mysqlPool!.query(statement).then(() => undefined), + 'ALTER TABLE `proxy_logs` ADD COLUMN `downstream_api_key_id` INT NULL', + ); + } else { + if (!pgPool) return false; + await executeLegacyCompat( + (statement) => pgPool!.query(statement).then(() => undefined), + 'ALTER TABLE "proxy_logs" ADD COLUMN "downstream_api_key_id" INTEGER', + ); + } + proxyLogDownstreamApiKeyIdColumnAvailable = true; + return true; + } catch (error) { + if (isDuplicateColumnError(error)) { + proxyLogDownstreamApiKeyIdColumnAvailable = true; + return true; + } + proxyLogDownstreamApiKeyIdColumnAvailable = false; + console.warn('[db] failed to ensure proxy_logs.downstream_api_key_id column', error); + return false; + } +} + +async function hasMysqlIndex(indexName: string): Promise<boolean> { + if (!mysqlPool) return false; + const [rows] = await mysqlPool.query( + 'SELECT 1 FROM information_schema.statistics WHERE table_schema = DATABASE() AND table_name = ? AND index_name = ? LIMIT 1', + ['proxy_logs', indexName], + ); + return Array.isArray(rows) && rows.length > 0; +} + +async function hasPostgresIndex(indexName: string): Promise<boolean> { + if (!pgPool) return false; + const result = await pgPool.query( + 'SELECT 1 FROM pg_indexes WHERE schemaname = current_schema() AND tablename = $1 AND indexname = $2 LIMIT 1', + ['proxy_logs', indexName], + ); + return Number(result.rowCount || 0) > 0; +} + +export async function hasProxyLogClientColumns(): Promise<boolean> { + if (proxyLogClientColumnsAvailable !== null) { + return proxyLogClientColumnsAvailable; + } + + const requiredColumns = [ + 'client_family', + 'client_app_id', + 'client_app_name', + 'client_confidence', + ]; + + if (runtimeDbDialect === 'sqlite') { + proxyLogClientColumnsAvailable = tableExists('proxy_logs') + && requiredColumns.every((columnName) => tableColumnExists('proxy_logs', columnName)); + return proxyLogClientColumnsAvailable; + } + + if (runtimeDbDialect === 'mysql') { + if (!mysqlPool) return false; + const [rows] = await mysqlPool.query( + 'SELECT column_name FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = ? AND column_name IN (?, ?, ?, ?)', + ['proxy_logs', ...requiredColumns], + ) as [Array<{ column_name?: string }>, unknown]; + const available = new Set( + Array.isArray(rows) + ? rows.map((row) => String(row?.column_name || '').trim().toLowerCase()).filter(Boolean) + : [], + ); + proxyLogClientColumnsAvailable = requiredColumns.every((columnName) => available.has(columnName)); + return proxyLogClientColumnsAvailable; + } + + if (!pgPool) return false; + const result = await pgPool.query( + 'SELECT column_name FROM information_schema.columns WHERE table_schema = current_schema() AND table_name = $1 AND column_name = ANY($2::text[])', + ['proxy_logs', requiredColumns], + ); + const available = new Set( + result.rows.map((row) => String((row as { column_name?: string }).column_name || '').trim().toLowerCase()).filter(Boolean), + ); + proxyLogClientColumnsAvailable = requiredColumns.every((columnName) => available.has(columnName)); + return proxyLogClientColumnsAvailable; +} + +export async function ensureProxyLogClientColumns(): Promise<boolean> { + const requiredColumns = [ + { name: 'client_family', sqliteType: 'text', mysqlType: 'TEXT NULL', postgresType: 'TEXT' }, + { name: 'client_app_id', sqliteType: 'text', mysqlType: 'TEXT NULL', postgresType: 'TEXT' }, + { name: 'client_app_name', sqliteType: 'text', mysqlType: 'TEXT NULL', postgresType: 'TEXT' }, + { name: 'client_confidence', sqliteType: 'text', mysqlType: 'TEXT NULL', postgresType: 'TEXT' }, + ]; + const requiredIndexes = [ + { + name: 'proxy_logs_client_app_id_created_at_idx', + sqliteSql: 'CREATE INDEX IF NOT EXISTS proxy_logs_client_app_id_created_at_idx ON proxy_logs(client_app_id, created_at);', + mysqlSql: 'CREATE INDEX `proxy_logs_client_app_id_created_at_idx` ON `proxy_logs` (`client_app_id`(191), `created_at`(191))', + postgresSql: 'CREATE INDEX "proxy_logs_client_app_id_created_at_idx" ON "proxy_logs" ("client_app_id", "created_at")', + }, + { + name: 'proxy_logs_client_family_created_at_idx', + sqliteSql: 'CREATE INDEX IF NOT EXISTS proxy_logs_client_family_created_at_idx ON proxy_logs(client_family, created_at);', + mysqlSql: 'CREATE INDEX `proxy_logs_client_family_created_at_idx` ON `proxy_logs` (`client_family`(191), `created_at`(191))', + postgresSql: 'CREATE INDEX "proxy_logs_client_family_created_at_idx" ON "proxy_logs" ("client_family", "created_at")', + }, + ]; + + if (runtimeDbDialect === 'sqlite') { + ensureProxyLogClientSchema(); + proxyLogClientColumnsAvailable = tableExists('proxy_logs') + && requiredColumns.every((column) => tableColumnExists('proxy_logs', column.name)); + return proxyLogClientColumnsAvailable; + } + + if (await hasProxyLogClientColumns()) { + for (const requiredIndex of requiredIndexes) { + const indexExists = runtimeDbDialect === 'mysql' + ? await hasMysqlIndex(requiredIndex.name) + : await hasPostgresIndex(requiredIndex.name); + if (indexExists) continue; + try { + if (runtimeDbDialect === 'mysql') { + if (!mysqlPool) return false; + await executeLegacyCompat( + (statement) => mysqlPool!.query(statement).then(() => undefined), + requiredIndex.mysqlSql, + ); + } else { + if (!pgPool) return false; + await executeLegacyCompat( + (statement) => pgPool!.query(statement).then(() => undefined), + requiredIndex.postgresSql, + ); + } + } catch (error) { + if (!isDuplicateIndexError(error)) { + console.warn(`[db] failed to ensure ${requiredIndex.name}`, error); + } + } + } + return true; + } + + try { + if (runtimeDbDialect === 'mysql') { + if (!mysqlPool) return false; + for (const column of requiredColumns) { + const [rows] = await mysqlPool.query('SHOW COLUMNS FROM `proxy_logs` LIKE ?', [column.name]); + if (Array.isArray(rows) && rows.length > 0) continue; + await executeLegacyCompat( + (statement) => mysqlPool!.query(statement).then(() => undefined), + `ALTER TABLE \`proxy_logs\` ADD COLUMN \`${column.name}\` ${column.mysqlType}`, + ); + } + for (const requiredIndex of requiredIndexes) { + if (await hasMysqlIndex(requiredIndex.name)) continue; + await executeLegacyCompat( + (statement) => mysqlPool!.query(statement).then(() => undefined), + requiredIndex.mysqlSql, + ); + } + } else { + if (!pgPool) return false; + for (const column of requiredColumns) { + const result = await pgPool.query( + 'SELECT 1 FROM information_schema.columns WHERE table_schema = current_schema() AND table_name = $1 AND column_name = $2 LIMIT 1', + ['proxy_logs', column.name], + ); + if (Number(result.rowCount || 0) > 0) continue; + await executeLegacyCompat( + (statement) => pgPool!.query(statement).then(() => undefined), + `ALTER TABLE "proxy_logs" ADD COLUMN "${column.name}" ${column.postgresType}`, + ); + } + for (const requiredIndex of requiredIndexes) { + if (await hasPostgresIndex(requiredIndex.name)) continue; + await executeLegacyCompat( + (statement) => pgPool!.query(statement).then(() => undefined), + requiredIndex.postgresSql, + ); + } + } + proxyLogClientColumnsAvailable = true; + return true; + } catch (error) { + if (isDuplicateColumnError(error) || isDuplicateIndexError(error)) { + proxyLogClientColumnsAvailable = await hasProxyLogClientColumns(); + return proxyLogClientColumnsAvailable; + } + proxyLogClientColumnsAvailable = false; + console.warn('[db] failed to ensure proxy_logs client columns', error); + return false; + } +} + function resetSchemaCapabilityCache() { proxyLogBillingDetailsColumnAvailable = null; + proxyLogDownstreamApiKeyIdColumnAvailable = null; + proxyLogClientColumnsAvailable = null; } async function sqliteProxyQuery(sqlText: string, params: unknown[], method: SqlMethod) { @@ -801,11 +1244,13 @@ function initSqliteDb() { ensureSiteStatusSchema(); ensureSiteProxySchema(); ensureSiteUseSystemProxySchema(); + ensureSiteCustomHeadersSchema(); ensureSiteExternalCheckinUrlSchema(); ensureSiteGlobalWeightSchema(); ensureRouteGroupingSchema(); ensureDownstreamApiKeySchema(); ensureProxyLogBillingDetailsSchema(); + ensureProxyLogClientSchema(); ensureProxyVideoTaskSchema(); ensureProxyFileSchema(); @@ -822,11 +1267,7 @@ function initMysqlDb(): AppDb { if (!config.dbUrl) { throw new Error('DB_URL is required when DB_TYPE=mysql'); } - const poolOptions: mysql.PoolOptions = { uri: config.dbUrl }; - if (config.dbSsl) { - poolOptions.ssl = { rejectUnauthorized: false }; - } - mysqlPool = mysql.createPool(poolOptions); + mysqlPool = mysql.createPool(buildMysqlPoolOptions()); const rawDb = drizzleMysqlProxy( (sqlText, params, method) => mysqlProxyQuery(mysqlPool!, sqlText, params, method as SqlMethod), @@ -858,10 +1299,8 @@ function initPostgresDb(): AppDb { if (!config.dbUrl) { throw new Error('DB_URL is required when DB_TYPE=postgres'); } - const poolOptions: pg.PoolConfig = { connectionString: config.dbUrl }; - if (config.dbSsl) { - poolOptions.ssl = { rejectUnauthorized: false }; - } + installPostgresJsonTextParsers(); + const poolOptions = buildPostgresPoolOptions(); pgPool = new pg.Pool(poolOptions); const rawDb = drizzlePgProxy( @@ -938,6 +1377,11 @@ export async function switchRuntimeDatabase(nextDialect: RuntimeDbDialect, nextD try { activeDb = initDb(); + await ensureRuntimeDatabaseReady({ + dialect: nextDialect, + connectionString: nextDbUrl, + ssl: config.dbSsl, + }); } catch (error) { await closeDbConnections(); runtimeDbDialect = previousDialect; @@ -952,4 +1396,12 @@ export async function switchRuntimeDatabase(nextDialect: RuntimeDbDialect, nextD export const __dbProxyTestUtils = { wrapQueryLike, shouldWrapObject, + resolveSqlitePath, + resolveVitestSqlitePath, + buildMysqlPoolOptions, + buildPostgresPoolOptions, + installPostgresJsonTextParsers, + ensurePostgresJsonTextParsers: installPostgresJsonTextParsers, + resetPostgresJsonTextParsersInstallStateForTests, + pg, }; diff --git a/src/server/db/legacySchemaCompat.architecture.test.ts b/src/server/db/legacySchemaCompat.architecture.test.ts new file mode 100644 index 00000000..568a4fe2 --- /dev/null +++ b/src/server/db/legacySchemaCompat.architecture.test.ts @@ -0,0 +1,18 @@ +import { readFileSync } from 'node:fs'; +import { describe, expect, it } from 'vitest'; + +function readSource(relativePath: string): string { + return readFileSync(new URL(relativePath, import.meta.url), 'utf8'); +} + +describe('legacySchemaCompat architecture boundaries', () => { + it('derives feature-owned legacy mutations from compatibility specs instead of hardcoding a second full whitelist', () => { + const source = readSource('./legacySchemaCompat.ts'); + + expect(source).toContain('ACCOUNT_TOKEN_COLUMN_COMPATIBILITY_SPECS'); + expect(source).toContain('PROXY_FILE_COLUMN_COMPATIBILITY_SPECS'); + expect(source).toContain('ROUTE_GROUPING_COLUMN_COMPATIBILITY_SPECS'); + expect(source).toContain('SITE_COLUMN_COMPATIBILITY_SPECS'); + expect(source).toContain('SITE_TABLE_COMPATIBILITY_SPECS'); + }); +}); diff --git a/src/server/db/legacySchemaCompat.test.ts b/src/server/db/legacySchemaCompat.test.ts new file mode 100644 index 00000000..6cd7ce5b --- /dev/null +++ b/src/server/db/legacySchemaCompat.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from 'vitest'; +import { classifyLegacyCompatMutation } from './legacySchemaCompat.js'; + +describe('legacy schema compat boundary', () => { + it('allows only explicitly registered legacy upgrade shims', () => { + expect(classifyLegacyCompatMutation('ALTER TABLE proxy_logs ADD COLUMN billing_details text;')).toBe('legacy'); + expect(classifyLegacyCompatMutation('ALTER TABLE proxy_logs ADD COLUMN client_app_id text;')).toBe('legacy'); + expect(classifyLegacyCompatMutation('CREATE INDEX proxy_logs_client_app_id_created_at_idx ON proxy_logs(client_app_id, created_at);')).toBe('legacy'); + expect(classifyLegacyCompatMutation('UPDATE "sites" SET "use_system_proxy" = FALSE WHERE "use_system_proxy" IS NULL')).toBe('legacy'); + expect(classifyLegacyCompatMutation('ALTER TABLE sites ADD COLUMN brand_new_column text;')).toBe('forbidden'); + expect(classifyLegacyCompatMutation('UPDATE "sites" SET "brand_new_column" = 1')).toBe('forbidden'); + }); +}); diff --git a/src/server/db/legacySchemaCompat.ts b/src/server/db/legacySchemaCompat.ts new file mode 100644 index 00000000..57b68451 --- /dev/null +++ b/src/server/db/legacySchemaCompat.ts @@ -0,0 +1,191 @@ +import { + ACCOUNT_TOKEN_COLUMN_COMPATIBILITY_SPECS, + ensureAccountTokenSchemaCompatibility, + type AccountTokenSchemaInspector, +} from './accountTokenSchemaCompatibility.js'; +import { + ensureProxyFileSchemaCompatibility, + PROXY_FILE_COLUMN_COMPATIBILITY_SPECS, + PROXY_FILE_INDEX_COMPATIBILITY_SPECS, + PROXY_FILE_TABLE_COMPATIBILITY_SPECS, + type ProxyFileSchemaInspector, +} from './proxyFileSchemaCompatibility.js'; +import { + ensureRouteGroupingSchemaCompatibility, + ROUTE_GROUPING_COLUMN_COMPATIBILITY_SPECS, + ROUTE_GROUPING_TABLE_COMPATIBILITY_SPECS, + type RouteGroupingSchemaInspector, +} from './routeGroupingSchemaCompatibility.js'; +import { + ensureSharedIndexSchemaCompatibility, + SHARED_INDEX_COMPATIBILITY_SPECS, + type SharedIndexSchemaInspector, +} from './sharedIndexSchemaCompatibility.js'; +import { + ensureSiteSchemaCompatibility, + SITE_COLUMN_COMPATIBILITY_SPECS, + SITE_TABLE_COMPATIBILITY_SPECS, + type SiteSchemaInspector, +} from './siteSchemaCompatibility.js'; + +export type LegacySchemaCompatClassification = 'legacy' | 'forbidden'; + +export interface LegacySchemaCompatInspector extends + SiteSchemaInspector, + RouteGroupingSchemaInspector, + ProxyFileSchemaInspector, + AccountTokenSchemaInspector, + SharedIndexSchemaInspector {} + +const BOOTSTRAP_OWNED_LEGACY_TABLES = [ + 'account_tokens', + 'token_model_availability', + 'proxy_video_tasks', + 'downstream_api_keys', +]; + +const BOOTSTRAP_OWNED_LEGACY_COLUMNS = [ + 'sites.status', + 'route_channels.token_id', + 'proxy_video_tasks.status_snapshot', + 'proxy_video_tasks.upstream_response_meta', + 'proxy_video_tasks.last_upstream_status', + 'proxy_video_tasks.last_polled_at', + 'downstream_api_keys.group_name', + 'downstream_api_keys.tags', + 'proxy_logs.billing_details', + 'proxy_logs.client_family', + 'proxy_logs.client_app_id', + 'proxy_logs.client_app_name', + 'proxy_logs.client_confidence', + 'proxy_logs.downstream_api_key_id', +]; + +const BOOTSTRAP_OWNED_LEGACY_INDEXES = [ + 'token_model_availability_token_model_unique', + 'proxy_video_tasks_public_id_unique', + 'proxy_video_tasks_upstream_video_id_idx', + 'downstream_api_keys_key_unique', + 'downstream_api_keys_name_idx', + 'downstream_api_keys_enabled_idx', + 'downstream_api_keys_expires_at_idx', + 'proxy_logs_client_app_id_created_at_idx', + 'proxy_logs_client_family_created_at_idx', + 'proxy_logs_downstream_api_key_created_at_idx', +]; + +function normalizeSqlText(sqlText: string): string { + return sqlText.trim().replace(/\s+/g, ' ').toLowerCase(); +} + +function extractIndexName(sqlText: string): string | null { + const match = normalizeSqlText(sqlText).match( + /^create (?:unique )?index(?: if not exists)? [`"]?([a-z0-9_]+)[`"]?/i, + ); + return match?.[1] ?? null; +} + +const LEGACY_COMPAT_TABLES = new Set([ + ...SITE_TABLE_COMPATIBILITY_SPECS.map((spec) => spec.table), + ...ROUTE_GROUPING_TABLE_COMPATIBILITY_SPECS.map((spec) => spec.table), + ...PROXY_FILE_TABLE_COMPATIBILITY_SPECS.map((spec) => spec.table), + ...BOOTSTRAP_OWNED_LEGACY_TABLES, +]); + +const LEGACY_COMPAT_COLUMNS = new Set([ + ...SITE_COLUMN_COMPATIBILITY_SPECS.map((spec) => `sites.${spec.column}`), + ...ACCOUNT_TOKEN_COLUMN_COMPATIBILITY_SPECS.map((spec) => `${spec.table}.${spec.column}`), + ...ROUTE_GROUPING_COLUMN_COMPATIBILITY_SPECS.map((spec) => `${spec.table}.${spec.column}`), + ...PROXY_FILE_COLUMN_COMPATIBILITY_SPECS.map((spec) => `${spec.table}.${spec.column}`), + ...BOOTSTRAP_OWNED_LEGACY_COLUMNS, +]); + +const LEGACY_COMPAT_INDEXES = new Set([ + ...SITE_TABLE_COMPATIBILITY_SPECS.flatMap((spec) => spec.postCreateSql ? Object.values(spec.postCreateSql) : []) + .flat() + .map((sqlText) => extractIndexName(sqlText)) + .filter((indexName): indexName is string => Boolean(indexName)), + ...ROUTE_GROUPING_TABLE_COMPATIBILITY_SPECS.flatMap((spec) => Object.values(spec.createSql)) + .flat() + .map((sqlText) => extractIndexName(sqlText)) + .filter((indexName): indexName is string => Boolean(indexName)), + ...PROXY_FILE_INDEX_COMPATIBILITY_SPECS.map((spec) => spec.indexName), + ...BOOTSTRAP_OWNED_LEGACY_INDEXES, + ...SHARED_INDEX_COMPATIBILITY_SPECS.map((spec) => spec.indexName), +]); + +const LEGACY_COMPAT_UPDATES = new Set( + SITE_COLUMN_COMPATIBILITY_SPECS + .flatMap((spec) => spec.normalizeSql ? Object.values(spec.normalizeSql) : []) + .map((sqlText) => normalizeSqlText(sqlText)), +); + +export function classifyLegacyCompatMutation(sqlText: string): LegacySchemaCompatClassification { + const normalized = normalizeSqlText(sqlText); + + if (LEGACY_COMPAT_UPDATES.has(normalized)) { + return 'legacy'; + } + + const createTableMatch = normalized.match(/^create table if not exists [`"]?([a-z0-9_]+)[`"]?/i); + if (createTableMatch) { + return LEGACY_COMPAT_TABLES.has(createTableMatch[1]) ? 'legacy' : 'forbidden'; + } + + const alterTableMatch = normalized.match( + /^alter table [`"]?([a-z0-9_]+)[`"]? add column [`"]?([a-z0-9_]+)[`"]?/i, + ); + if (alterTableMatch) { + const [, tableName, columnName] = alterTableMatch; + return LEGACY_COMPAT_COLUMNS.has(`${tableName}.${columnName}`) ? 'legacy' : 'forbidden'; + } + + const createIndexMatch = normalized.match( + /^create (?:unique )?index(?: if not exists)? [`"]?([a-z0-9_]+)[`"]?/i, + ); + if (createIndexMatch) { + return LEGACY_COMPAT_INDEXES.has(createIndexMatch[1]) ? 'legacy' : 'forbidden'; + } + + return 'forbidden'; +} + +function assertLegacyCompatMutation(sqlText: string): void { + if (classifyLegacyCompatMutation(sqlText) === 'forbidden') { + throw new Error(`Forbidden legacy schema mutation: ${sqlText}`); + } +} + +export async function executeLegacyCompat( + execute: (sqlText: string) => Promise<void>, + sqlText: string, +): Promise<void> { + assertLegacyCompatMutation(sqlText); + await execute(sqlText); +} + +export function executeLegacyCompatSync( + execute: (sqlText: string) => void, + sqlText: string, +): void { + assertLegacyCompatMutation(sqlText); + execute(sqlText); +} + +function wrapLegacyCompatInspector(inspector: LegacySchemaCompatInspector): LegacySchemaCompatInspector { + return { + ...inspector, + execute: async (sqlText: string) => { + await executeLegacyCompat((statement) => inspector.execute(statement), sqlText); + }, + }; +} + +export async function ensureLegacySchemaCompatibility(inspector: LegacySchemaCompatInspector): Promise<void> { + const wrappedInspector = wrapLegacyCompatInspector(inspector); + await ensureSiteSchemaCompatibility(wrappedInspector); + await ensureRouteGroupingSchemaCompatibility(wrappedInspector); + await ensureProxyFileSchemaCompatibility(wrappedInspector); + await ensureAccountTokenSchemaCompatibility(wrappedInspector); + await ensureSharedIndexSchemaCompatibility(wrappedInspector); +} diff --git a/src/server/db/migrate.test.ts b/src/server/db/migrate.test.ts index 419257ad..08695c5a 100644 --- a/src/server/db/migrate.test.ts +++ b/src/server/db/migrate.test.ts @@ -1,6 +1,7 @@ import Database from 'better-sqlite3'; import { afterEach, describe, expect, it, vi } from 'vitest'; -import { mkdtempSync, readFileSync } from 'node:fs'; +import { createHash } from 'node:crypto'; +import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -29,6 +30,26 @@ function applyMigrationSql(sqlite: Database.Database, sqlText: string) { } } +function recordAppliedMigrations( + sqlite: Database.Database, + journalEntries: MigrationJournalEntry[], +) { + sqlite.exec(` + CREATE TABLE IF NOT EXISTS "__drizzle_migrations" ( + id SERIAL PRIMARY KEY, + hash text NOT NULL, + created_at numeric + ) + `); + + const insert = sqlite.prepare('INSERT INTO "__drizzle_migrations" ("hash", "created_at") VALUES (?, ?)'); + for (const entry of journalEntries) { + const sqlText = readFileSync(join(migrationsDir, `${entry.tag}.sql`), 'utf8'); + const hash = createHash('sha256').update(sqlText).digest('hex'); + insert.run(hash, entry.when); + } +} + describe('sqlite migrate bootstrap', () => { afterEach(() => { delete process.env.DATA_DIR; @@ -67,4 +88,380 @@ describe('sqlite migrate bootstrap', () => { verified.close(); }); + + it('recovers from duplicate-column errors for single-statement migrations', async () => { + const dataDir = mkdtempSync(join(tmpdir(), 'metapi-migrate-recover-')); + process.env.DATA_DIR = dataDir; + vi.resetModules(); + + const migrateModule = await import('./migrate.js'); + const { __migrateTestUtils } = migrateModule; + + const sqlite = new Database(':memory:'); + sqlite.exec(` + CREATE TABLE account_tokens ( + id integer PRIMARY KEY AUTOINCREMENT NOT NULL, + token_group text + ); + `); + + const tempMigrationsDir = mkdtempSync(join(tmpdir(), 'metapi-migration-files-')); + mkdirSync(join(tempMigrationsDir, 'meta'), { recursive: true }); + + writeFileSync( + join(tempMigrationsDir, 'meta', '_journal.json'), + JSON.stringify({ + entries: [ + { + tag: '0007_account_token_group', + when: 1772500000000, + }, + ], + }), + ); + + writeFileSync( + join(tempMigrationsDir, '0007_account_token_group.sql'), + 'ALTER TABLE `account_tokens` ADD `token_group` text;\n', + ); + + const duplicateColumnError = new Error( + "DrizzleError: Failed to run the query 'ALTER TABLE `account_tokens` ADD `token_group` text;\n' duplicate column name: token_group", + ); + + const recovered = __migrateTestUtils.tryRecoverDuplicateColumnMigrationError( + sqlite, + tempMigrationsDir, + duplicateColumnError, + ); + + expect(recovered).toBe(true); + + const applied = sqlite + .prepare('SELECT hash, created_at FROM __drizzle_migrations') + .all() as Array<{ hash: string; created_at: number }>; + + expect(applied).toHaveLength(1); + expect(Number(applied[0].created_at)).toBe(1772500000000); + + sqlite.close(); + }); + + it('recovers when duplicate-column message appears only in error cause', async () => { + const dataDir = mkdtempSync(join(tmpdir(), 'metapi-migrate-recover-cause-')); + process.env.DATA_DIR = dataDir; + vi.resetModules(); + + const migrateModule = await import('./migrate.js'); + const { __migrateTestUtils } = migrateModule; + + const sqlite = new Database(':memory:'); + sqlite.exec(` + CREATE TABLE account_tokens ( + id integer PRIMARY KEY AUTOINCREMENT NOT NULL, + token_group text + ); + `); + + const tempMigrationsDir = mkdtempSync(join(tmpdir(), 'metapi-migration-files-cause-')); + mkdirSync(join(tempMigrationsDir, 'meta'), { recursive: true }); + + writeFileSync( + join(tempMigrationsDir, 'meta', '_journal.json'), + JSON.stringify({ + entries: [ + { + tag: '0007_account_token_group', + when: 1772500000001, + }, + ], + }), + ); + + writeFileSync( + join(tempMigrationsDir, '0007_account_token_group.sql'), + 'ALTER TABLE `account_tokens` ADD `token_group` text;\n', + ); + + const drizzleLikeError = { + message: "DrizzleError: Failed to run the query 'ALTER TABLE `account_tokens` ADD `token_group` text;\n'", + cause: { + message: 'SqliteError: duplicate column name: token_group', + }, + }; + + const recovered = __migrateTestUtils.tryRecoverDuplicateColumnMigrationError( + sqlite, + tempMigrationsDir, + drizzleLikeError, + ); + + expect(recovered).toBe(true); + + const applied = sqlite + .prepare('SELECT hash, created_at FROM __drizzle_migrations') + .all() as Array<{ hash: string; created_at: number }>; + + expect(applied).toHaveLength(1); + expect(Number(applied[0].created_at)).toBe(1772500000001); + + sqlite.close(); + }); + + it('recovers duplicate-column errors when the failed SQL contains quoted literals', async () => { + const dataDir = mkdtempSync(join(tmpdir(), 'metapi-migrate-recover-quoted-')); + process.env.DATA_DIR = dataDir; + vi.resetModules(); + + const migrateModule = await import('./migrate.js'); + const { __migrateTestUtils } = migrateModule; + + const sqlite = new Database(':memory:'); + sqlite.exec(` + CREATE TABLE account_tokens ( + id integer PRIMARY KEY AUTOINCREMENT NOT NULL, + value_status text DEFAULT 'ready' NOT NULL + ); + `); + + const tempMigrationsDir = mkdtempSync(join(tmpdir(), 'metapi-migration-files-quoted-')); + mkdirSync(join(tempMigrationsDir, 'meta'), { recursive: true }); + + writeFileSync( + join(tempMigrationsDir, 'meta', '_journal.json'), + JSON.stringify({ + entries: [ + { + tag: '0012_account_token_value_status', + when: 1773665311013, + }, + ], + }), + ); + + writeFileSync( + join(tempMigrationsDir, '0012_account_token_value_status.sql'), + "ALTER TABLE `account_tokens` ADD `value_status` text DEFAULT 'ready' NOT NULL;\n", + ); + + const duplicateColumnError = new Error( + "DrizzleError: Failed to run the query 'ALTER TABLE `account_tokens` ADD `value_status` text DEFAULT 'ready' NOT NULL;\n' duplicate column name: value_status", + ); + + const recovered = __migrateTestUtils.tryRecoverDuplicateColumnMigrationError( + sqlite, + tempMigrationsDir, + duplicateColumnError, + ); + + expect(recovered).toBe(true); + + const applied = sqlite + .prepare('SELECT hash, created_at FROM __drizzle_migrations') + .all() as Array<{ hash: string; created_at: number }>; + + expect(applied).toHaveLength(1); + expect(Number(applied[0].created_at)).toBe(1773665311013); + + sqlite.close(); + }); + + it('recovers duplicate-column errors inside multi-statement migrations by replaying the full migration', async () => { + const dataDir = mkdtempSync(join(tmpdir(), 'metapi-migrate-recover-multi-')); + process.env.DATA_DIR = dataDir; + vi.resetModules(); + + const migrateModule = await import('./migrate.js'); + const { __migrateTestUtils } = migrateModule; + + const sqlite = new Database(':memory:'); + sqlite.exec(` + CREATE TABLE account_tokens ( + id integer PRIMARY KEY AUTOINCREMENT NOT NULL, + token_group text + ); + `); + + const tempMigrationsDir = mkdtempSync(join(tmpdir(), 'metapi-migration-files-multi-')); + mkdirSync(join(tempMigrationsDir, 'meta'), { recursive: true }); + + writeFileSync( + join(tempMigrationsDir, 'meta', '_journal.json'), + JSON.stringify({ + entries: [ + { + tag: '0009_model_availability_is_manual', + when: 1772600000000, + }, + ], + }), + ); + + writeFileSync( + join(tempMigrationsDir, '0009_model_availability_is_manual.sql'), + [ + 'CREATE TABLE IF NOT EXISTS `downstream_api_keys` (`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL);', + 'ALTER TABLE `account_tokens` ADD `token_group` text;', + ].join('\n--> statement-breakpoint\n'), + ); + + const duplicateColumnError = new Error( + "DrizzleError: Failed to run the query 'ALTER TABLE `account_tokens` ADD `token_group` text;\n' duplicate column name: token_group", + ); + + const recovered = __migrateTestUtils.tryRecoverDuplicateColumnMigrationError( + sqlite, + tempMigrationsDir, + duplicateColumnError, + ); + + expect(recovered).toBe(true); + + const createdTable = sqlite + .prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'downstream_api_keys'") + .get() as { name?: string } | undefined; + const applied = sqlite + .prepare('SELECT hash, created_at FROM __drizzle_migrations') + .all() as Array<{ hash: string; created_at: number }>; + + expect(createdTable?.name).toBe('downstream_api_keys'); + expect(applied).toHaveLength(1); + expect(Number(applied[0].created_at)).toBe(1772600000000); + + sqlite.close(); + }); + + it('replays missing migrations before marking a duplicate-column migration as applied', async () => { + const dataDir = mkdtempSync(join(tmpdir(), 'metapi-migrate-partial-journal-')); + const dbPath = join(dataDir, 'hub.db'); + const sqlite = new Database(dbPath); + const journalEntries = readMigrationJournalEntries(); + const missingTags = new Set([ + '0006_site_disabled_models', + '0007_account_token_group', + '0008_sqlite_schema_backfill', + '0009_model_availability_is_manual', + '0010_proxy_logs_downstream_api_key', + '0011_downstream_api_key_metadata', + '0012_account_token_value_status', + '0013_oauth_multi_provider', + ]); + const appliedEntries = journalEntries.filter((entry) => !missingTags.has(entry.tag)); + + for (const entry of appliedEntries) { + const sqlText = readFileSync(join(migrationsDir, `${entry.tag}.sql`), 'utf8'); + applyMigrationSql(sqlite, sqlText); + } + recordAppliedMigrations(sqlite, appliedEntries); + + // Simulate legacy compatibility code adding token_group before the formal 0007 migration ran. + sqlite.exec('ALTER TABLE account_tokens ADD COLUMN token_group text;'); + sqlite.close(); + + process.env.DATA_DIR = dataDir; + vi.resetModules(); + + await expect(import('./migrate.js')).resolves.toMatchObject({ + runSqliteMigrations: expect.any(Function), + }); + + const verified = new Database(dbPath, { readonly: true }); + const appliedRows = verified + .prepare('SELECT created_at FROM __drizzle_migrations ORDER BY created_at ASC') + .all() as Array<{ created_at: number }>; + const disabledModelsTable = verified + .prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'site_disabled_models'") + .get() as { name?: string } | undefined; + const downstreamApiKeysTable = verified + .prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'downstream_api_keys'") + .get() as { name?: string } | undefined; + + expect(disabledModelsTable?.name).toBe('site_disabled_models'); + expect(downstreamApiKeysTable?.name).toBe('downstream_api_keys'); + expect(appliedRows.map((row) => Number(row.created_at))).toEqual( + journalEntries.map((entry) => entry.when), + ); + + verified.close(); + }); + + it('deduplicates legacy duplicate sites before applying the oauth site unique index', async () => { + const dataDir = mkdtempSync(join(tmpdir(), 'metapi-migrate-duplicate-sites-')); + const dbPath = join(dataDir, 'hub.db'); + const sqlite = new Database(dbPath); + const journalEntries = readMigrationJournalEntries(); + const appliedEntries = journalEntries.filter((entry) => entry.tag !== '0013_oauth_multi_provider'); + + for (const entry of appliedEntries) { + const sqlText = readFileSync(join(migrationsDir, `${entry.tag}.sql`), 'utf8'); + applyMigrationSql(sqlite, sqlText); + } + recordAppliedMigrations(sqlite, appliedEntries); + + sqlite.exec(` + INSERT INTO sites (id, name, url, platform, status, is_pinned, sort_order, global_weight) + VALUES + (101, 'Primary Codex', 'https://chatgpt.com/backend-api/codex', 'codex', 'active', 0, 0, 1), + (202, 'Duplicate Codex', 'https://chatgpt.com/backend-api/codex', 'codex', 'disabled', 1, 9, 3); + + INSERT INTO accounts (site_id, username, access_token, status, checkin_enabled) + VALUES + (101, 'first@example.com', 'token-a', 'active', 0), + (202, 'second@example.com', 'token-b', 'disabled', 0); + + INSERT INTO site_disabled_models (site_id, model_name) + VALUES + (101, 'gpt-5'), + (202, 'gpt-5'), + (202, 'gpt-5-mini'); + `); + + sqlite.close(); + + process.env.DATA_DIR = dataDir; + vi.resetModules(); + + await expect(import('./migrate.js')).resolves.toMatchObject({ + runSqliteMigrations: expect.any(Function), + }); + + const verified = new Database(dbPath, { readonly: true }); + const sites = verified + .prepare('SELECT id, name, url, platform, status, is_pinned, sort_order, global_weight FROM sites ORDER BY id ASC') + .all() as Array<{ + id: number; + name: string; + url: string; + platform: string; + status: string; + is_pinned: number; + sort_order: number; + global_weight: number; + }>; + const accounts = verified + .prepare('SELECT username, site_id FROM accounts ORDER BY username ASC') + .all() as Array<{ username: string; site_id: number }>; + const disabledModels = verified + .prepare('SELECT site_id, model_name FROM site_disabled_models ORDER BY site_id ASC, model_name ASC') + .all() as Array<{ site_id: number; model_name: string }>; + + expect(sites).toEqual([ + expect.objectContaining({ + id: 101, + name: 'Primary Codex', + url: 'https://chatgpt.com/backend-api/codex', + platform: 'codex', + }), + ]); + expect(accounts).toEqual([ + { username: 'first@example.com', site_id: 101 }, + { username: 'second@example.com', site_id: 101 }, + ]); + expect(disabledModels).toEqual([ + { site_id: 101, model_name: 'gpt-5' }, + { site_id: 101, model_name: 'gpt-5-mini' }, + ]); + + verified.close(); + }); }); diff --git a/src/server/db/migrate.ts b/src/server/db/migrate.ts index 1237f4a7..a2758d44 100644 --- a/src/server/db/migrate.ts +++ b/src/server/db/migrate.ts @@ -26,7 +26,21 @@ type MigrationRecord = { hash: string; }; -const VERIFIED_BOOTSTRAP_TAG = '0004_sorting_preferences'; +type RecoveryMigrationRecord = MigrationRecord & { + tag: string; +}; + +type RecoveryMigration = RecoveryMigrationRecord & { + statements: string[]; +}; + +type LegacySiteRow = { + id: number; + platform: string; + url: string; +}; + +const VERIFIED_BOOTSTRAP_TAG = '0012_account_token_value_status'; const VERIFIED_SCHEMA_MARKERS: SchemaMarker[] = [ { table: 'sites' }, { table: 'settings' }, @@ -43,8 +57,22 @@ const VERIFIED_SCHEMA_MARKERS: SchemaMarker[] = [ { table: 'sites', column: 'sort_order' }, { table: 'accounts', column: 'is_pinned' }, { table: 'accounts', column: 'sort_order' }, + // 0006: site_disabled_models table + { table: 'site_disabled_models' }, + // 0007: token_group column on account_tokens + { table: 'account_tokens', column: 'token_group' }, + // 0009: is_manual column on model_availability + { table: 'model_availability', column: 'is_manual' }, + // 0010: downstream_api_key_id column on proxy_logs + { table: 'proxy_logs', column: 'downstream_api_key_id' }, + // 0011: downstream key metadata columns + { table: 'downstream_api_keys', column: 'group_name' }, + { table: 'downstream_api_keys', column: 'tags' }, + // 0012: value_status column on account_tokens + { table: 'account_tokens', column: 'value_status' }, ]; + function resolveSqliteDbPath(): string { const raw = (config.dbUrl || '').trim(); if (!raw) return resolve(`${config.dataDir}/hub.db`); @@ -108,6 +136,422 @@ function readVerifiedMigrationRecords(migrationsFolder: string): MigrationRecord return []; } +function splitMigrationStatements(sqlText: string): string[] { + return sqlText + .split('--> statement-breakpoint') + .map((statement) => statement.trim()) + .filter((statement) => statement.length > 0); +} + +function normalizeSqlForMatch(sqlText: string): string { + return sqlText + .replace(/[\n\r\t]+/g, ' ') + .replace(/["`]/g, '') + .replace(/\s+/g, ' ') + .trim() + .replace(/;+$/g, '') + .toLowerCase(); +} + +function extractFailedSqlFromError(error: unknown): string | null { + const message = normalizeSchemaErrorMessage(error); + const matched = message.match(/Failed to run the query '([\s\S]*?)'/i); + const sqlText = matched?.[1]?.trim(); + return sqlText && sqlText.length > 0 ? sqlText : null; +} + +function findMatchingSingleStatementMigration( + migrationsFolder: string, + failedSqlText: string, +): RecoveryMigrationRecord | null { + const journalPath = resolve(migrationsFolder, 'meta', '_journal.json'); + const journal = JSON.parse(readFileSync(journalPath, 'utf8')) as MigrationJournalFile; + const normalizedFailedSql = normalizeSqlForMatch(failedSqlText); + + for (const entry of journal.entries ?? []) { + const migrationSql = readFileSync(resolve(migrationsFolder, `${entry.tag}.sql`), 'utf8'); + const statements = splitMigrationStatements(migrationSql); + if (statements.length !== 1) { + continue; + } + + if (normalizeSqlForMatch(statements[0]) !== normalizedFailedSql) { + continue; + } + + return { + tag: entry.tag, + createdAt: Number(entry.when), + hash: createHash('sha256').update(migrationSql).digest('hex'), + }; + } + + return null; +} + +function findMatchingMigrationByStatement( + migrationsFolder: string, + failedSqlText: string, +): RecoveryMigrationRecord | null { + const normalizedFailedSql = normalizeSqlForMatch(failedSqlText); + const migrations = readRecoveryMigrations(migrationsFolder); + + for (const migration of migrations) { + if (!migration.statements.some((statement) => normalizeSqlForMatch(statement) === normalizedFailedSql)) { + continue; + } + + return { + tag: migration.tag, + createdAt: migration.createdAt, + hash: migration.hash, + }; + } + + return null; +} + +function findMatchingMigrationByErrorMessage( + migrationsFolder: string, + error: unknown, +): RecoveryMigrationRecord | null { + const normalizedErrorMessage = normalizeSqlForMatch(normalizeSchemaErrorMessage(error)); + const migrations = readRecoveryMigrations(migrationsFolder); + + for (const migration of migrations) { + if (!migration.statements.some((statement) => normalizedErrorMessage.includes(normalizeSqlForMatch(statement)))) { + continue; + } + + return { + tag: migration.tag, + createdAt: migration.createdAt, + hash: migration.hash, + }; + } + + return null; +} + +function readRecoveryMigrations(migrationsFolder: string): RecoveryMigration[] { + const journalPath = resolve(migrationsFolder, 'meta', '_journal.json'); + const journal = JSON.parse(readFileSync(journalPath, 'utf8')) as MigrationJournalFile; + + return (journal.entries ?? []).map((entry) => { + const migrationSql = readFileSync(resolve(migrationsFolder, `${entry.tag}.sql`), 'utf8'); + return { + tag: entry.tag, + createdAt: Number(entry.when), + hash: createHash('sha256').update(migrationSql).digest('hex'), + statements: splitMigrationStatements(migrationSql), + }; + }); +} + +function ensureDrizzleMigrationsTable(sqlite: Database.Database): void { + sqlite.exec(` + CREATE TABLE IF NOT EXISTS "__drizzle_migrations" ( + id SERIAL PRIMARY KEY, + hash text NOT NULL, + created_at numeric + ) + `); +} + +function markMigrationRecordIfMissing(sqlite: Database.Database, record: MigrationRecord): boolean { + ensureDrizzleMigrationsTable(sqlite); + const existing = sqlite + .prepare('SELECT 1 FROM "__drizzle_migrations" WHERE "hash" = ? LIMIT 1') + .get(record.hash); + if (existing) { + return false; + } + + sqlite + .prepare('INSERT INTO "__drizzle_migrations" ("hash", "created_at") VALUES (?, ?)') + .run(record.hash, record.createdAt); + + return true; +} + +function hasMigrationRecord(sqlite: Database.Database, record: MigrationRecord): boolean { + if (!tableExists(sqlite, '__drizzle_migrations')) return false; + const row = sqlite + .prepare('SELECT 1 FROM "__drizzle_migrations" WHERE "hash" = ? LIMIT 1') + .get(record.hash); + return !!row; +} + +function normalizeSchemaErrorMessage(error: unknown): string { + if (!error || typeof error !== 'object') { + return String(error || ''); + } + + const collected: string[] = []; + let cursor: unknown = error; + let depth = 0; + + while (cursor && typeof cursor === 'object' && depth < 8) { + const current = cursor as { message?: unknown; cause?: unknown }; + if (current.message !== undefined && current.message !== null) { + const text = String(current.message).trim(); + if (text.length > 0) { + collected.push(text); + } + } + + cursor = current.cause; + depth += 1; + } + + if (collected.length > 0) { + return collected.join(' | '); + } + + return String(error || ''); +} + +function isDuplicateColumnError(error: unknown): boolean { + const lowered = normalizeSchemaErrorMessage(error).toLowerCase(); + return lowered.includes('duplicate column') + || lowered.includes('already exists') + || lowered.includes('duplicate column name'); +} + +function isRecoverableSchemaConflictError(error: unknown): boolean { + const lowered = normalizeSchemaErrorMessage(error).toLowerCase(); + return lowered.includes('duplicate column') + || lowered.includes('duplicate column name') + || lowered.includes('already exists'); +} + +function isSitesPlatformUrlUniqueConflictError(error: unknown): boolean { + const lowered = normalizeSchemaErrorMessage(error).toLowerCase(); + if (!lowered.includes('unique constraint failed: sites.platform, sites.url')) { + return false; + } + + const failedSqlText = extractFailedSqlFromError(error); + if (!failedSqlText) { + return true; + } + + return normalizeSqlForMatch(failedSqlText) + === normalizeSqlForMatch('CREATE UNIQUE INDEX `sites_platform_url_unique` ON `sites` (`platform`,`url`);'); +} + +function replayMigrationStatements(sqlite: Database.Database, statements: string[]): void { + for (const statement of statements) { + try { + sqlite.exec(statement); + } catch (error) { + if (isRecoverableSchemaConflictError(error)) { + continue; + } + + if (isSitesPlatformUrlUniqueConflictError(error) && deduplicateLegacySitesForUniqueIndex(sqlite)) { + try { + sqlite.exec(statement); + continue; + } catch (retryError) { + if (isRecoverableSchemaConflictError(retryError)) { + continue; + } + throw retryError; + } + } + + throw error; + } + } +} + +function recoverMigrationSequence( + sqlite: Database.Database, + migrationsFolder: string, + failedMigrationTag: string, +): boolean { + const migrations = readRecoveryMigrations(migrationsFolder); + const failedMigrationIndex = migrations.findIndex((migration) => migration.tag === failedMigrationTag); + if (failedMigrationIndex < 0) { + return false; + } + + for (const migration of migrations.slice(0, failedMigrationIndex + 1)) { + if (hasMigrationRecord(sqlite, migration)) { + continue; + } + + replayMigrationStatements(sqlite, migration.statements); + markMigrationRecordIfMissing(sqlite, migration); + } + + return true; +} + +function backfillMissingRecordedMigrations(sqlite: Database.Database, migrationsFolder: string): number { + if (!tableExists(sqlite, '__drizzle_migrations')) return 0; + + let recoveredCount = 0; + for (const migration of readRecoveryMigrations(migrationsFolder)) { + if (hasMigrationRecord(sqlite, migration)) { + continue; + } + + replayMigrationStatements(sqlite, migration.statements); + if (markMigrationRecordIfMissing(sqlite, migration)) { + recoveredCount += 1; + } + } + + if (recoveredCount > 0) { + console.warn(`[db] Backfilled ${recoveredCount} missing drizzle migration record(s).`); + } + + return recoveredCount; +} + +function tryRecoverDuplicateColumnMigrationError( + sqlite: Database.Database, + migrationsFolder: string, + error: unknown, +): boolean { + if (!isDuplicateColumnError(error)) { + return false; + } + + const failedSqlText = extractFailedSqlFromError(error); + const matchedMigration = failedSqlText + ? findMatchingMigrationByStatement(migrationsFolder, failedSqlText) + ?? findMatchingMigrationByErrorMessage(migrationsFolder, error) + : findMatchingMigrationByErrorMessage(migrationsFolder, error); + if (!matchedMigration) { + return false; + } + + const recovered = recoverMigrationSequence(sqlite, migrationsFolder, matchedMigration.tag); + if (recovered) { + console.warn(`[db] Recovered duplicate-column migration sequence through ${matchedMigration.tag}.`); + } + return recovered; +} + +function rewriteDownstreamSiteWeightMultipliers( + sqlite: Database.Database, + siteIdMapping: Map<number, number>, +): void { + if (siteIdMapping.size <= 0) return; + if (!tableExists(sqlite, 'downstream_api_keys')) return; + if (!columnExists(sqlite, 'downstream_api_keys', 'site_weight_multipliers')) return; + + const rows = sqlite.prepare(` + SELECT id, site_weight_multipliers + FROM downstream_api_keys + WHERE site_weight_multipliers IS NOT NULL + AND TRIM(site_weight_multipliers) <> '' + `).all() as Array<{ id: number; site_weight_multipliers: string | null }>; + + const update = sqlite.prepare('UPDATE downstream_api_keys SET site_weight_multipliers = ? WHERE id = ?'); + for (const row of rows) { + if (!row.site_weight_multipliers) continue; + + let parsed: unknown; + try { + parsed = JSON.parse(row.site_weight_multipliers); + } catch { + continue; + } + + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) continue; + const nextValue = { ...(parsed as Record<string, unknown>) }; + let changed = false; + + for (const [fromSiteId, toSiteId] of siteIdMapping.entries()) { + const fromKey = String(fromSiteId); + const toKey = String(toSiteId); + if (!(fromKey in nextValue)) continue; + if (!(toKey in nextValue)) { + nextValue[toKey] = nextValue[fromKey]; + } + delete nextValue[fromKey]; + changed = true; + } + + if (!changed) continue; + update.run(JSON.stringify(nextValue), row.id); + } +} + +function deduplicateLegacySitesForUniqueIndex(sqlite: Database.Database): boolean { + const duplicateGroups = sqlite.prepare(` + SELECT platform, url + FROM sites + GROUP BY platform, url + HAVING COUNT(*) > 1 + `).all() as Array<{ platform: string; url: string }>; + + if (duplicateGroups.length <= 0) { + return false; + } + + const selectSitesByIdentity = sqlite.prepare(` + SELECT id, platform, url + FROM sites + WHERE platform = ? AND url = ? + ORDER BY id ASC + `); + const rebindAccounts = sqlite.prepare('UPDATE accounts SET site_id = ? WHERE site_id = ?'); + const mergeDisabledModels = sqlite.prepare(` + INSERT OR IGNORE INTO site_disabled_models (site_id, model_name, created_at) + SELECT ?, model_name, created_at + FROM site_disabled_models + WHERE site_id = ? + `); + const deleteDisabledModels = sqlite.prepare('DELETE FROM site_disabled_models WHERE site_id = ?'); + const deleteSite = sqlite.prepare('DELETE FROM sites WHERE id = ?'); + + const siteIdMapping = new Map<number, number>(); + + const transaction = sqlite.transaction(() => { + for (const group of duplicateGroups) { + const sites = selectSitesByIdentity.all(group.platform, group.url) as LegacySiteRow[]; + if (sites.length <= 1) continue; + + const canonicalSiteId = sites[0]!.id; + for (const site of sites.slice(1)) { + mergeDisabledModels.run(canonicalSiteId, site.id); + deleteDisabledModels.run(site.id); + rebindAccounts.run(canonicalSiteId, site.id); + siteIdMapping.set(site.id, canonicalSiteId); + deleteSite.run(site.id); + } + } + + rewriteDownstreamSiteWeightMultipliers(sqlite, siteIdMapping); + }); + + transaction(); + if (siteIdMapping.size > 0) { + console.warn(`[db] Deduplicated ${siteIdMapping.size} legacy site entries before applying sites_platform_url_unique.`); + } + return siteIdMapping.size > 0; +} + +export const __migrateTestUtils = { + splitMigrationStatements, + normalizeSqlForMatch, + extractFailedSqlFromError, + findMatchingSingleStatementMigration, + findMatchingMigrationByStatement, + findMatchingMigrationByErrorMessage, + readRecoveryMigrations, + markMigrationRecordIfMissing, + recoverMigrationSequence, + tryRecoverDuplicateColumnMigrationError, + isSitesPlatformUrlUniqueConflictError, + deduplicateLegacySitesForUniqueIndex, +}; + function bootstrapLegacyDrizzleMigrations(sqlite: Database.Database, migrationsFolder: string): boolean { if (hasRecordedDrizzleMigrations(sqlite)) return false; if (!hasVerifiedLegacySchema(sqlite)) return false; @@ -144,8 +588,24 @@ export function runSqliteMigrations(): void { const sqlite = new Database(dbPath); bootstrapLegacyDrizzleMigrations(sqlite, migrationsFolder); - const db = drizzle(sqlite); - migrate(db, { migrationsFolder }); + backfillMissingRecordedMigrations(sqlite, migrationsFolder); + + try { + migrate(drizzle(sqlite), { migrationsFolder }); + } catch (error) { + const recoveredDuplicateColumns = tryRecoverDuplicateColumnMigrationError(sqlite, migrationsFolder, error); + const recoveredDuplicateSites = ( + !recoveredDuplicateColumns + && isSitesPlatformUrlUniqueConflictError(error) + && deduplicateLegacySitesForUniqueIndex(sqlite) + ); + if (!recoveredDuplicateColumns && !recoveredDuplicateSites) { + sqlite.close(); + throw error; + } + migrate(drizzle(sqlite), { migrationsFolder }); + } + sqlite.close(); console.log('Migration complete.'); } diff --git a/src/server/db/postgresJsonTextParsers.ts b/src/server/db/postgresJsonTextParsers.ts new file mode 100644 index 00000000..d5eafe7a --- /dev/null +++ b/src/server/db/postgresJsonTextParsers.ts @@ -0,0 +1,23 @@ +import pg from 'pg'; + +export type PgTypesLike = { + builtins: { + JSON: number; + JSONB: number; + }; + setTypeParser(oid: number, format: 'text', parser: (value: string) => string): void; +}; + +let postgresJsonTextParsersInstalled = false; + +export function installPostgresJsonTextParsers(typesRegistry: PgTypesLike = pg.types as PgTypesLike): void { + if (postgresJsonTextParsersInstalled) return; + const identity = (value: string) => value; + typesRegistry.setTypeParser(typesRegistry.builtins.JSON, 'text', identity); + typesRegistry.setTypeParser(typesRegistry.builtins.JSONB, 'text', identity); + postgresJsonTextParsersInstalled = true; +} + +export function resetPostgresJsonTextParsersInstallStateForTests(): void { + postgresJsonTextParsersInstalled = false; +} diff --git a/src/server/db/proxyFileSchemaCompatibility.test.ts b/src/server/db/proxyFileSchemaCompatibility.test.ts index 7bbaf685..13b448ba 100644 --- a/src/server/db/proxyFileSchemaCompatibility.test.ts +++ b/src/server/db/proxyFileSchemaCompatibility.test.ts @@ -62,6 +62,19 @@ describe('ensureProxyFileSchemaCompatibility', () => { expect(executedSql.some((sqlText) => ownerIndexPattern.test(sqlText))).toBe(true); }); + it('emits MySQL indexes compatible with generated bootstrap SQL', async () => { + const { inspector, executedSql } = createInspector('mysql'); + + await ensureProxyFileSchemaCompatibility(inspector); + + expect(executedSql).toContain( + 'CREATE UNIQUE INDEX `proxy_files_public_id_unique` ON `proxy_files` (`public_id`(191))', + ); + expect(executedSql).toContain( + 'CREATE INDEX `proxy_files_owner_lookup_idx` ON `proxy_files` (`owner_type`(64), `owner_id`(191), `deleted_at`(191))', + ); + }); + it('adds missing columns on existing table before ensuring indexes', async () => { const { inspector, executedSql } = createInspector('postgres', { hasTable: true, diff --git a/src/server/db/proxyFileSchemaCompatibility.ts b/src/server/db/proxyFileSchemaCompatibility.ts index 52c0a0b1..9a8e6de6 100644 --- a/src/server/db/proxyFileSchemaCompatibility.ts +++ b/src/server/db/proxyFileSchemaCompatibility.ts @@ -8,42 +8,57 @@ export interface ProxyFileSchemaInspector { } type ProxyFileColumnCompatibilitySpec = { + table: 'proxy_files'; column: string; addSql: Record<ProxyFileSchemaDialect, string>; }; -const CREATE_TABLE_SQL: Record<ProxyFileSchemaDialect, string> = { - sqlite: 'CREATE TABLE IF NOT EXISTS "proxy_files" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "public_id" TEXT NOT NULL, "owner_type" TEXT NOT NULL, "owner_id" TEXT NOT NULL, "filename" TEXT NOT NULL, "mime_type" TEXT NOT NULL, "purpose" TEXT, "byte_size" INTEGER NOT NULL, "sha256" TEXT NOT NULL, "content_base64" TEXT NOT NULL, "created_at" TEXT, "updated_at" TEXT, "deleted_at" TEXT)', - postgres: 'CREATE TABLE IF NOT EXISTS "proxy_files" ("id" SERIAL PRIMARY KEY, "public_id" TEXT NOT NULL, "owner_type" TEXT NOT NULL, "owner_id" TEXT NOT NULL, "filename" TEXT NOT NULL, "mime_type" TEXT NOT NULL, "purpose" TEXT, "byte_size" INTEGER NOT NULL, "sha256" TEXT NOT NULL, "content_base64" TEXT NOT NULL, "created_at" TEXT, "updated_at" TEXT, "deleted_at" TEXT)', - mysql: 'CREATE TABLE IF NOT EXISTS `proxy_files` (`id` INTEGER NOT NULL AUTO_INCREMENT PRIMARY KEY, `public_id` TEXT NOT NULL, `owner_type` TEXT NOT NULL, `owner_id` TEXT NOT NULL, `filename` TEXT NOT NULL, `mime_type` TEXT NOT NULL, `purpose` TEXT NULL, `byte_size` INTEGER NOT NULL, `sha256` TEXT NOT NULL, `content_base64` LONGTEXT NOT NULL, `created_at` TEXT NULL, `updated_at` TEXT NULL, `deleted_at` TEXT NULL)', +export type ProxyFileTableCompatibilitySpec = { + table: 'proxy_files'; + createSql: Record<ProxyFileSchemaDialect, string>; }; -const COLUMN_COMPATIBILITY_SPECS: ProxyFileColumnCompatibilitySpec[] = [ +export const PROXY_FILE_TABLE_COMPATIBILITY_SPECS: ProxyFileTableCompatibilitySpec[] = [ { + table: 'proxy_files', + createSql: { + sqlite: 'CREATE TABLE IF NOT EXISTS "proxy_files" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "public_id" TEXT NOT NULL, "owner_type" TEXT NOT NULL, "owner_id" TEXT NOT NULL, "filename" TEXT NOT NULL, "mime_type" TEXT NOT NULL, "purpose" TEXT, "byte_size" INTEGER NOT NULL, "sha256" TEXT NOT NULL, "content_base64" TEXT NOT NULL, "created_at" TEXT, "updated_at" TEXT, "deleted_at" TEXT)', + postgres: 'CREATE TABLE IF NOT EXISTS "proxy_files" ("id" SERIAL PRIMARY KEY, "public_id" TEXT NOT NULL, "owner_type" TEXT NOT NULL, "owner_id" TEXT NOT NULL, "filename" TEXT NOT NULL, "mime_type" TEXT NOT NULL, "purpose" TEXT, "byte_size" INTEGER NOT NULL, "sha256" TEXT NOT NULL, "content_base64" TEXT NOT NULL, "created_at" TEXT, "updated_at" TEXT, "deleted_at" TEXT)', + mysql: 'CREATE TABLE IF NOT EXISTS `proxy_files` (`id` INTEGER NOT NULL AUTO_INCREMENT PRIMARY KEY, `public_id` VARCHAR(191) NOT NULL, `owner_type` VARCHAR(64) NOT NULL, `owner_id` VARCHAR(191) NOT NULL, `filename` TEXT NOT NULL, `mime_type` VARCHAR(191) NOT NULL, `purpose` TEXT NULL, `byte_size` INTEGER NOT NULL, `sha256` VARCHAR(191) NOT NULL, `content_base64` LONGTEXT NOT NULL, `created_at` TEXT NULL, `updated_at` TEXT NULL, `deleted_at` TEXT NULL)', + }, + }, +]; + +export const PROXY_FILE_COLUMN_COMPATIBILITY_SPECS: ProxyFileColumnCompatibilitySpec[] = [ + { + table: 'proxy_files', column: 'public_id', addSql: { sqlite: 'ALTER TABLE "proxy_files" ADD COLUMN "public_id" TEXT', postgres: 'ALTER TABLE "proxy_files" ADD COLUMN "public_id" TEXT', - mysql: 'ALTER TABLE `proxy_files` ADD COLUMN `public_id` TEXT NULL', + mysql: 'ALTER TABLE `proxy_files` ADD COLUMN `public_id` VARCHAR(191) NULL', }, }, { + table: 'proxy_files', column: 'owner_type', addSql: { sqlite: 'ALTER TABLE "proxy_files" ADD COLUMN "owner_type" TEXT', postgres: 'ALTER TABLE "proxy_files" ADD COLUMN "owner_type" TEXT', - mysql: 'ALTER TABLE `proxy_files` ADD COLUMN `owner_type` TEXT NULL', + mysql: 'ALTER TABLE `proxy_files` ADD COLUMN `owner_type` VARCHAR(64) NULL', }, }, { + table: 'proxy_files', column: 'owner_id', addSql: { sqlite: 'ALTER TABLE "proxy_files" ADD COLUMN "owner_id" TEXT', postgres: 'ALTER TABLE "proxy_files" ADD COLUMN "owner_id" TEXT', - mysql: 'ALTER TABLE `proxy_files` ADD COLUMN `owner_id` TEXT NULL', + mysql: 'ALTER TABLE `proxy_files` ADD COLUMN `owner_id` VARCHAR(191) NULL', }, }, { + table: 'proxy_files', column: 'filename', addSql: { sqlite: 'ALTER TABLE "proxy_files" ADD COLUMN "filename" TEXT', @@ -52,14 +67,16 @@ const COLUMN_COMPATIBILITY_SPECS: ProxyFileColumnCompatibilitySpec[] = [ }, }, { + table: 'proxy_files', column: 'mime_type', addSql: { sqlite: 'ALTER TABLE "proxy_files" ADD COLUMN "mime_type" TEXT', postgres: 'ALTER TABLE "proxy_files" ADD COLUMN "mime_type" TEXT', - mysql: 'ALTER TABLE `proxy_files` ADD COLUMN `mime_type` TEXT NULL', + mysql: 'ALTER TABLE `proxy_files` ADD COLUMN `mime_type` VARCHAR(191) NULL', }, }, { + table: 'proxy_files', column: 'purpose', addSql: { sqlite: 'ALTER TABLE "proxy_files" ADD COLUMN "purpose" TEXT', @@ -68,6 +85,7 @@ const COLUMN_COMPATIBILITY_SPECS: ProxyFileColumnCompatibilitySpec[] = [ }, }, { + table: 'proxy_files', column: 'byte_size', addSql: { sqlite: 'ALTER TABLE "proxy_files" ADD COLUMN "byte_size" INTEGER', @@ -76,14 +94,16 @@ const COLUMN_COMPATIBILITY_SPECS: ProxyFileColumnCompatibilitySpec[] = [ }, }, { + table: 'proxy_files', column: 'sha256', addSql: { sqlite: 'ALTER TABLE "proxy_files" ADD COLUMN "sha256" TEXT', postgres: 'ALTER TABLE "proxy_files" ADD COLUMN "sha256" TEXT', - mysql: 'ALTER TABLE `proxy_files` ADD COLUMN `sha256` TEXT NULL', + mysql: 'ALTER TABLE `proxy_files` ADD COLUMN `sha256` VARCHAR(191) NULL', }, }, { + table: 'proxy_files', column: 'content_base64', addSql: { sqlite: 'ALTER TABLE "proxy_files" ADD COLUMN "content_base64" TEXT', @@ -92,6 +112,7 @@ const COLUMN_COMPATIBILITY_SPECS: ProxyFileColumnCompatibilitySpec[] = [ }, }, { + table: 'proxy_files', column: 'created_at', addSql: { sqlite: 'ALTER TABLE "proxy_files" ADD COLUMN "created_at" TEXT', @@ -100,6 +121,7 @@ const COLUMN_COMPATIBILITY_SPECS: ProxyFileColumnCompatibilitySpec[] = [ }, }, { + table: 'proxy_files', column: 'updated_at', addSql: { sqlite: 'ALTER TABLE "proxy_files" ADD COLUMN "updated_at" TEXT', @@ -108,6 +130,7 @@ const COLUMN_COMPATIBILITY_SPECS: ProxyFileColumnCompatibilitySpec[] = [ }, }, { + table: 'proxy_files', column: 'deleted_at', addSql: { sqlite: 'ALTER TABLE "proxy_files" ADD COLUMN "deleted_at" TEXT', @@ -117,21 +140,33 @@ const COLUMN_COMPATIBILITY_SPECS: ProxyFileColumnCompatibilitySpec[] = [ }, ]; -const CREATE_INDEX_SQL: Record<ProxyFileSchemaDialect, string[]> = { - sqlite: [ - 'CREATE UNIQUE INDEX IF NOT EXISTS "proxy_files_public_id_unique" ON "proxy_files" ("public_id")', - 'CREATE INDEX IF NOT EXISTS "proxy_files_owner_lookup_idx" ON "proxy_files" ("owner_type", "owner_id", "deleted_at")', - ], - postgres: [ - 'CREATE UNIQUE INDEX IF NOT EXISTS "proxy_files_public_id_unique" ON "proxy_files" ("public_id")', - 'CREATE INDEX IF NOT EXISTS "proxy_files_owner_lookup_idx" ON "proxy_files" ("owner_type", "owner_id", "deleted_at")', - ], - mysql: [ - 'CREATE UNIQUE INDEX `proxy_files_public_id_unique` ON `proxy_files` (`public_id`(191))', - 'CREATE INDEX `proxy_files_owner_lookup_idx` ON `proxy_files` (`owner_type`(191), `owner_id`(191), `deleted_at`(191))', - ], +export type ProxyFileIndexCompatibilitySpec = { + table: 'proxy_files'; + indexName: string; + createSql: Record<ProxyFileSchemaDialect, string>; }; +export const PROXY_FILE_INDEX_COMPATIBILITY_SPECS: ProxyFileIndexCompatibilitySpec[] = [ + { + table: 'proxy_files', + indexName: 'proxy_files_public_id_unique', + createSql: { + sqlite: 'CREATE UNIQUE INDEX IF NOT EXISTS "proxy_files_public_id_unique" ON "proxy_files" ("public_id")', + postgres: 'CREATE UNIQUE INDEX IF NOT EXISTS "proxy_files_public_id_unique" ON "proxy_files" ("public_id")', + mysql: 'CREATE UNIQUE INDEX `proxy_files_public_id_unique` ON `proxy_files` (`public_id`(191))', + }, + }, + { + table: 'proxy_files', + indexName: 'proxy_files_owner_lookup_idx', + createSql: { + sqlite: 'CREATE INDEX IF NOT EXISTS "proxy_files_owner_lookup_idx" ON "proxy_files" ("owner_type", "owner_id", "deleted_at")', + postgres: 'CREATE INDEX IF NOT EXISTS "proxy_files_owner_lookup_idx" ON "proxy_files" ("owner_type", "owner_id", "deleted_at")', + mysql: 'CREATE INDEX `proxy_files_owner_lookup_idx` ON `proxy_files` (`owner_type`(64), `owner_id`(191), `deleted_at`(191))', + }, + }, +]; + function normalizeSchemaErrorMessage(error: unknown): string { if (typeof error === 'object' && error && 'message' in error) { return String((error as { message?: unknown }).message || ''); @@ -155,16 +190,17 @@ async function executeIgnoreDuplicate(inspector: ProxyFileSchemaInspector, sqlTe } export async function ensureProxyFileSchemaCompatibility(inspector: ProxyFileSchemaInspector): Promise<void> { - if (!await inspector.tableExists('proxy_files')) { - await executeIgnoreDuplicate(inspector, CREATE_TABLE_SQL[inspector.dialect]); + const [tableSpec] = PROXY_FILE_TABLE_COMPATIBILITY_SPECS; + if (!await inspector.tableExists(tableSpec.table)) { + await executeIgnoreDuplicate(inspector, tableSpec.createSql[inspector.dialect]); } else { - for (const spec of COLUMN_COMPATIBILITY_SPECS) { - if (await inspector.columnExists('proxy_files', spec.column)) continue; + for (const spec of PROXY_FILE_COLUMN_COMPATIBILITY_SPECS) { + if (await inspector.columnExists(spec.table, spec.column)) continue; await executeIgnoreDuplicate(inspector, spec.addSql[inspector.dialect]); } } - for (const sqlText of CREATE_INDEX_SQL[inspector.dialect]) { - await executeIgnoreDuplicate(inspector, sqlText); + for (const spec of PROXY_FILE_INDEX_COMPATIBILITY_SPECS) { + await executeIgnoreDuplicate(inspector, spec.createSql[inspector.dialect]); } } diff --git a/src/server/db/routeGroupingSchemaCompatibility.test.ts b/src/server/db/routeGroupingSchemaCompatibility.test.ts index 3c9560e0..bf500b9f 100644 --- a/src/server/db/routeGroupingSchemaCompatibility.test.ts +++ b/src/server/db/routeGroupingSchemaCompatibility.test.ts @@ -8,9 +8,11 @@ function createInspector( dialect: RouteGroupingSchemaInspector['dialect'], options?: { existingColumnsByTable?: Partial<Record<'token_routes' | 'route_channels', string[]>>; + existingTables?: Array<'token_routes' | 'route_channels' | 'route_group_sources'>; }, ) { const executedSql: string[] = []; + const existingTables = new Set(options?.existingTables ?? ['token_routes', 'route_channels']); const existingColumnsByTable = { token_routes: new Set(options?.existingColumnsByTable?.token_routes ?? []), route_channels: new Set(options?.existingColumnsByTable?.route_channels ?? []), @@ -19,7 +21,7 @@ function createInspector( const inspector: RouteGroupingSchemaInspector = { dialect, async tableExists(table) { - return table === 'token_routes' || table === 'route_channels'; + return existingTables.has(table as 'token_routes' | 'route_channels' | 'route_group_sources'); }, async columnExists(table, column) { if (table === 'token_routes' || table === 'route_channels') { @@ -37,24 +39,58 @@ function createInspector( describe('ensureRouteGroupingSchemaCompatibility', () => { it.each([ + { + dialect: 'sqlite' as const, + expectedSql: [ + 'CREATE TABLE IF NOT EXISTS route_group_sources (id integer PRIMARY KEY AUTOINCREMENT NOT NULL, group_route_id integer NOT NULL REFERENCES token_routes(id) ON DELETE cascade, source_route_id integer NOT NULL REFERENCES token_routes(id) ON DELETE cascade);', + 'CREATE UNIQUE INDEX IF NOT EXISTS route_group_sources_group_source_unique ON route_group_sources(group_route_id, source_route_id);', + 'CREATE INDEX IF NOT EXISTS route_group_sources_source_route_id_idx ON route_group_sources(source_route_id);', + 'ALTER TABLE token_routes ADD COLUMN display_name text;', + 'ALTER TABLE token_routes ADD COLUMN display_icon text;', + 'ALTER TABLE token_routes ADD COLUMN route_mode text DEFAULT \'pattern\';', + 'ALTER TABLE token_routes ADD COLUMN decision_snapshot text;', + 'ALTER TABLE token_routes ADD COLUMN decision_refreshed_at text;', + 'ALTER TABLE token_routes ADD COLUMN routing_strategy text DEFAULT \'weighted\';', + 'ALTER TABLE route_channels ADD COLUMN source_model text;', + 'ALTER TABLE route_channels ADD COLUMN last_selected_at text;', + 'ALTER TABLE route_channels ADD COLUMN consecutive_fail_count integer NOT NULL DEFAULT 0;', + 'ALTER TABLE route_channels ADD COLUMN cooldown_level integer NOT NULL DEFAULT 0;', + ], + }, { dialect: 'postgres' as const, expectedSql: [ + 'CREATE TABLE IF NOT EXISTS "route_group_sources" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "group_route_id" INTEGER NOT NULL, "source_route_id" INTEGER NOT NULL, CONSTRAINT "route_group_sources_group_route_id_token_routes_id_fk" FOREIGN KEY ("group_route_id") REFERENCES "token_routes"("id") ON DELETE CASCADE, CONSTRAINT "route_group_sources_source_route_id_token_routes_id_fk" FOREIGN KEY ("source_route_id") REFERENCES "token_routes"("id") ON DELETE CASCADE)', + 'CREATE UNIQUE INDEX IF NOT EXISTS "route_group_sources_group_source_unique" ON "route_group_sources" ("group_route_id", "source_route_id")', + 'CREATE INDEX IF NOT EXISTS "route_group_sources_source_route_id_idx" ON "route_group_sources" ("source_route_id")', 'ALTER TABLE "token_routes" ADD COLUMN "display_name" TEXT', 'ALTER TABLE "token_routes" ADD COLUMN "display_icon" TEXT', + 'ALTER TABLE "token_routes" ADD COLUMN "route_mode" TEXT DEFAULT \'pattern\'', 'ALTER TABLE "token_routes" ADD COLUMN "decision_snapshot" TEXT', 'ALTER TABLE "token_routes" ADD COLUMN "decision_refreshed_at" TEXT', + 'ALTER TABLE "token_routes" ADD COLUMN "routing_strategy" TEXT DEFAULT \'weighted\'', 'ALTER TABLE "route_channels" ADD COLUMN "source_model" TEXT', + 'ALTER TABLE "route_channels" ADD COLUMN "last_selected_at" TEXT', + 'ALTER TABLE "route_channels" ADD COLUMN "consecutive_fail_count" INTEGER NOT NULL DEFAULT 0', + 'ALTER TABLE "route_channels" ADD COLUMN "cooldown_level" INTEGER NOT NULL DEFAULT 0', ], }, { dialect: 'mysql' as const, expectedSql: [ + 'CREATE TABLE IF NOT EXISTS `route_group_sources` (`id` int AUTO_INCREMENT NOT NULL PRIMARY KEY, `group_route_id` int NOT NULL, `source_route_id` int NOT NULL, CONSTRAINT `route_group_sources_group_route_id_token_routes_id_fk` FOREIGN KEY (`group_route_id`) REFERENCES `token_routes`(`id`) ON DELETE cascade, CONSTRAINT `route_group_sources_source_route_id_token_routes_id_fk` FOREIGN KEY (`source_route_id`) REFERENCES `token_routes`(`id`) ON DELETE cascade)', + 'CREATE UNIQUE INDEX IF NOT EXISTS `route_group_sources_group_source_unique` ON `route_group_sources` (`group_route_id`,`source_route_id`)', + 'CREATE INDEX IF NOT EXISTS `route_group_sources_source_route_id_idx` ON `route_group_sources` (`source_route_id`)', 'ALTER TABLE `token_routes` ADD COLUMN `display_name` TEXT NULL', 'ALTER TABLE `token_routes` ADD COLUMN `display_icon` TEXT NULL', + 'ALTER TABLE `token_routes` ADD COLUMN `route_mode` VARCHAR(32) NULL DEFAULT \'pattern\'', 'ALTER TABLE `token_routes` ADD COLUMN `decision_snapshot` TEXT NULL', 'ALTER TABLE `token_routes` ADD COLUMN `decision_refreshed_at` TEXT NULL', + 'ALTER TABLE `token_routes` ADD COLUMN `routing_strategy` VARCHAR(32) NULL DEFAULT \'weighted\'', 'ALTER TABLE `route_channels` ADD COLUMN `source_model` TEXT NULL', + 'ALTER TABLE `route_channels` ADD COLUMN `last_selected_at` TEXT NULL', + 'ALTER TABLE `route_channels` ADD COLUMN `consecutive_fail_count` INT NOT NULL DEFAULT 0', + 'ALTER TABLE `route_channels` ADD COLUMN `cooldown_level` INT NOT NULL DEFAULT 0', ], }, ])('adds missing route grouping columns for $dialect', async ({ dialect, expectedSql }) => { @@ -68,9 +104,10 @@ describe('ensureRouteGroupingSchemaCompatibility', () => { it('skips existing columns', async () => { const { inspector, executedSql } = createInspector('postgres', { existingColumnsByTable: { - token_routes: ['display_name', 'display_icon', 'decision_snapshot', 'decision_refreshed_at'], - route_channels: ['source_model'], + token_routes: ['display_name', 'display_icon', 'route_mode', 'decision_snapshot', 'decision_refreshed_at', 'routing_strategy'], + route_channels: ['source_model', 'last_selected_at', 'consecutive_fail_count', 'cooldown_level'], }, + existingTables: ['token_routes', 'route_channels', 'route_group_sources'], }); await ensureRouteGroupingSchemaCompatibility(inspector); diff --git a/src/server/db/routeGroupingSchemaCompatibility.ts b/src/server/db/routeGroupingSchemaCompatibility.ts index 15e32301..4dacfb3d 100644 --- a/src/server/db/routeGroupingSchemaCompatibility.ts +++ b/src/server/db/routeGroupingSchemaCompatibility.ts @@ -7,13 +7,18 @@ export interface RouteGroupingSchemaInspector { execute(sqlText: string): Promise<void>; } -type RouteGroupingColumnCompatibilitySpec = { +export type RouteGroupingColumnCompatibilitySpec = { table: 'token_routes' | 'route_channels'; column: string; addSql: Record<RouteGroupingSchemaDialect, string>; }; -const ROUTE_GROUPING_COLUMN_COMPATIBILITY_SPECS: RouteGroupingColumnCompatibilitySpec[] = [ +export type RouteGroupingTableCompatibilitySpec = { + table: 'route_group_sources'; + createSql: Record<RouteGroupingSchemaDialect, string[]>; +}; + +export const ROUTE_GROUPING_COLUMN_COMPATIBILITY_SPECS: RouteGroupingColumnCompatibilitySpec[] = [ { table: 'token_routes', column: 'display_name', @@ -32,6 +37,15 @@ const ROUTE_GROUPING_COLUMN_COMPATIBILITY_SPECS: RouteGroupingColumnCompatibilit postgres: 'ALTER TABLE "token_routes" ADD COLUMN "display_icon" TEXT', }, }, + { + table: 'token_routes', + column: 'route_mode', + addSql: { + sqlite: 'ALTER TABLE token_routes ADD COLUMN route_mode text DEFAULT \'pattern\';', + mysql: 'ALTER TABLE `token_routes` ADD COLUMN `route_mode` VARCHAR(32) NULL DEFAULT \'pattern\'', + postgres: 'ALTER TABLE "token_routes" ADD COLUMN "route_mode" TEXT DEFAULT \'pattern\'', + }, + }, { table: 'token_routes', column: 'decision_snapshot', @@ -50,6 +64,15 @@ const ROUTE_GROUPING_COLUMN_COMPATIBILITY_SPECS: RouteGroupingColumnCompatibilit postgres: 'ALTER TABLE "token_routes" ADD COLUMN "decision_refreshed_at" TEXT', }, }, + { + table: 'token_routes', + column: 'routing_strategy', + addSql: { + sqlite: 'ALTER TABLE token_routes ADD COLUMN routing_strategy text DEFAULT \'weighted\';', + mysql: 'ALTER TABLE `token_routes` ADD COLUMN `routing_strategy` VARCHAR(32) NULL DEFAULT \'weighted\'', + postgres: 'ALTER TABLE "token_routes" ADD COLUMN "routing_strategy" TEXT DEFAULT \'weighted\'', + }, + }, { table: 'route_channels', column: 'source_model', @@ -59,6 +82,56 @@ const ROUTE_GROUPING_COLUMN_COMPATIBILITY_SPECS: RouteGroupingColumnCompatibilit postgres: 'ALTER TABLE "route_channels" ADD COLUMN "source_model" TEXT', }, }, + { + table: 'route_channels', + column: 'last_selected_at', + addSql: { + sqlite: 'ALTER TABLE route_channels ADD COLUMN last_selected_at text;', + mysql: 'ALTER TABLE `route_channels` ADD COLUMN `last_selected_at` TEXT NULL', + postgres: 'ALTER TABLE "route_channels" ADD COLUMN "last_selected_at" TEXT', + }, + }, + { + table: 'route_channels', + column: 'consecutive_fail_count', + addSql: { + sqlite: 'ALTER TABLE route_channels ADD COLUMN consecutive_fail_count integer NOT NULL DEFAULT 0;', + mysql: 'ALTER TABLE `route_channels` ADD COLUMN `consecutive_fail_count` INT NOT NULL DEFAULT 0', + postgres: 'ALTER TABLE "route_channels" ADD COLUMN "consecutive_fail_count" INTEGER NOT NULL DEFAULT 0', + }, + }, + { + table: 'route_channels', + column: 'cooldown_level', + addSql: { + sqlite: 'ALTER TABLE route_channels ADD COLUMN cooldown_level integer NOT NULL DEFAULT 0;', + mysql: 'ALTER TABLE `route_channels` ADD COLUMN `cooldown_level` INT NOT NULL DEFAULT 0', + postgres: 'ALTER TABLE "route_channels" ADD COLUMN "cooldown_level" INTEGER NOT NULL DEFAULT 0', + }, + }, +]; + +export const ROUTE_GROUPING_TABLE_COMPATIBILITY_SPECS: RouteGroupingTableCompatibilitySpec[] = [ + { + table: 'route_group_sources', + createSql: { + sqlite: [ + 'CREATE TABLE IF NOT EXISTS route_group_sources (id integer PRIMARY KEY AUTOINCREMENT NOT NULL, group_route_id integer NOT NULL REFERENCES token_routes(id) ON DELETE cascade, source_route_id integer NOT NULL REFERENCES token_routes(id) ON DELETE cascade);', + 'CREATE UNIQUE INDEX IF NOT EXISTS route_group_sources_group_source_unique ON route_group_sources(group_route_id, source_route_id);', + 'CREATE INDEX IF NOT EXISTS route_group_sources_source_route_id_idx ON route_group_sources(source_route_id);', + ], + mysql: [ + 'CREATE TABLE IF NOT EXISTS `route_group_sources` (`id` int AUTO_INCREMENT NOT NULL PRIMARY KEY, `group_route_id` int NOT NULL, `source_route_id` int NOT NULL, CONSTRAINT `route_group_sources_group_route_id_token_routes_id_fk` FOREIGN KEY (`group_route_id`) REFERENCES `token_routes`(`id`) ON DELETE cascade, CONSTRAINT `route_group_sources_source_route_id_token_routes_id_fk` FOREIGN KEY (`source_route_id`) REFERENCES `token_routes`(`id`) ON DELETE cascade)', + 'CREATE UNIQUE INDEX IF NOT EXISTS `route_group_sources_group_source_unique` ON `route_group_sources` (`group_route_id`,`source_route_id`)', + 'CREATE INDEX IF NOT EXISTS `route_group_sources_source_route_id_idx` ON `route_group_sources` (`source_route_id`)', + ], + postgres: [ + 'CREATE TABLE IF NOT EXISTS "route_group_sources" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "group_route_id" INTEGER NOT NULL, "source_route_id" INTEGER NOT NULL, CONSTRAINT "route_group_sources_group_route_id_token_routes_id_fk" FOREIGN KEY ("group_route_id") REFERENCES "token_routes"("id") ON DELETE CASCADE, CONSTRAINT "route_group_sources_source_route_id_token_routes_id_fk" FOREIGN KEY ("source_route_id") REFERENCES "token_routes"("id") ON DELETE CASCADE)', + 'CREATE UNIQUE INDEX IF NOT EXISTS "route_group_sources_group_source_unique" ON "route_group_sources" ("group_route_id", "source_route_id")', + 'CREATE INDEX IF NOT EXISTS "route_group_sources_source_route_id_idx" ON "route_group_sources" ("source_route_id")', + ], + }, + }, ]; function normalizeSchemaErrorMessage(error: unknown): string { @@ -88,6 +161,18 @@ async function executeAddColumn(inspector: RouteGroupingSchemaInspector, sqlText export async function ensureRouteGroupingSchemaCompatibility(inspector: RouteGroupingSchemaInspector): Promise<void> { const tableExistsCache = new Map<string, boolean>(); + for (const spec of ROUTE_GROUPING_TABLE_COMPATIBILITY_SPECS) { + const hasTable = await inspector.tableExists(spec.table); + tableExistsCache.set(spec.table, hasTable); + if (hasTable) { + continue; + } + for (const sqlText of spec.createSql[inspector.dialect]) { + await executeAddColumn(inspector, sqlText); + } + tableExistsCache.set(spec.table, true); + } + for (const spec of ROUTE_GROUPING_COLUMN_COMPATIBILITY_SPECS) { let hasTable = tableExistsCache.get(spec.table); if (hasTable === undefined) { diff --git a/src/server/db/runtimeSchemaBootstrap.live.test.ts b/src/server/db/runtimeSchemaBootstrap.live.test.ts new file mode 100644 index 00000000..59fd5644 --- /dev/null +++ b/src/server/db/runtimeSchemaBootstrap.live.test.ts @@ -0,0 +1,111 @@ +import baselineContract from './generated/fixtures/2026-03-14-baseline.schemaContract.json' with { type: 'json' }; +import currentContract from './generated/schemaContract.json' with { type: 'json' }; +import mysql from 'mysql2/promise'; +import pg from 'pg'; +import { describe, expect, it } from 'vitest'; +import { generateBootstrapSql } from './schemaArtifactGenerator.js'; +import { __schemaIntrospectionTestUtils, introspectLiveSchema } from './schemaIntrospection.js'; +import { bootstrapRuntimeDatabaseSchema } from './runtimeSchemaBootstrap.js'; + +const mysqlRuntime = process.env.DB_PARITY_MYSQL_URL ? it : it.skip; +const postgresRuntime = process.env.DB_PARITY_POSTGRES_URL ? it : it.skip; + +async function resetMySqlSchema(connectionString: string): Promise<void> { + const connection = await mysql.createConnection({ uri: connectionString }); + try { + await connection.query('SET FOREIGN_KEY_CHECKS = 0'); + const [rows] = await connection.query(` + SELECT table_name AS table_name + FROM information_schema.tables + WHERE table_schema = DATABASE() + AND table_type = 'BASE TABLE' + `); + for (const row of rows as Array<Record<string, unknown>>) { + const tableName = String(row.table_name || ''); + if (!tableName) continue; + await connection.query(`DROP TABLE IF EXISTS \`${tableName}\``); + } + await connection.query('SET FOREIGN_KEY_CHECKS = 1'); + } finally { + await connection.end(); + } +} + +async function applyMySqlStatements(connectionString: string, statements: string[]): Promise<void> { + const connection = await mysql.createConnection({ uri: connectionString }); + try { + for (const statement of statements) { + await connection.query(statement); + } + } finally { + await connection.end(); + } +} + +async function resetPostgresSchema(connectionString: string): Promise<void> { + const client = new pg.Client({ connectionString }); + await client.connect(); + try { + const result = await client.query(` + SELECT tablename + FROM pg_tables + WHERE schemaname = current_schema() + ORDER BY tablename ASC + `); + for (const row of result.rows as Array<{ tablename: string }>) { + await client.query(`DROP TABLE IF EXISTS "${row.tablename}" CASCADE`); + } + } finally { + await client.end(); + } +} + +async function applyPostgresStatements(connectionString: string, statements: string[]): Promise<void> { + const client = new pg.Client({ connectionString }); + await client.connect(); + try { + for (const statement of statements) { + await client.query(statement); + } + } finally { + await client.end(); + } +} + +describe('runtime schema bootstrap live upgrade path', () => { + mysqlRuntime('upgrades mysql runtime schemas from an older live contract', async () => { + const connectionString = process.env.DB_PARITY_MYSQL_URL!; + const baselineStatements = __schemaIntrospectionTestUtils.splitSqlStatements( + generateBootstrapSql('mysql', baselineContract), + ); + + await resetMySqlSchema(connectionString); + await applyMySqlStatements(connectionString, baselineStatements); + + await bootstrapRuntimeDatabaseSchema({ + dialect: 'mysql', + connectionString, + }); + + const live = await introspectLiveSchema({ dialect: 'mysql', connectionString }); + expect(live).toEqual(currentContract); + }); + + postgresRuntime('upgrades postgres runtime schemas from an older live contract', async () => { + const connectionString = process.env.DB_PARITY_POSTGRES_URL!; + const baselineStatements = __schemaIntrospectionTestUtils.splitSqlStatements( + generateBootstrapSql('postgres', baselineContract), + ); + + await resetPostgresSchema(connectionString); + await applyPostgresStatements(connectionString, baselineStatements); + + await bootstrapRuntimeDatabaseSchema({ + dialect: 'postgres', + connectionString, + }); + + const live = await introspectLiveSchema({ dialect: 'postgres', connectionString }); + expect(live).toEqual(currentContract); + }); +}); diff --git a/src/server/db/runtimeSchemaBootstrap.test.ts b/src/server/db/runtimeSchemaBootstrap.test.ts new file mode 100644 index 00000000..adcf1173 --- /dev/null +++ b/src/server/db/runtimeSchemaBootstrap.test.ts @@ -0,0 +1,304 @@ +import baselineContract from './generated/fixtures/2026-03-14-baseline.schemaContract.json' with { type: 'json' }; +import currentContract from './generated/schemaContract.json' with { type: 'json' }; +import { classifyLegacyCompatMutation } from './legacySchemaCompat.js'; +import { generateUpgradeSql } from './schemaArtifactGenerator.js'; +import type { SchemaContract, SchemaContractColumn } from './schemaContract.js'; +import { describe, expect, it } from 'vitest'; +import { + __runtimeSchemaBootstrapTestUtils, + ensureRuntimeDatabaseSchema, + type RuntimeSchemaClient, + type RuntimeSchemaDialect, +} from './runtimeSchemaBootstrap.js'; + +function createStubClient(dialect: RuntimeSchemaDialect, executedSql: string[]): RuntimeSchemaClient { + return { + dialect, + begin: async () => {}, + commit: async () => {}, + rollback: async () => {}, + execute: async (sqlText: string) => { + if (sqlText.trim().toLowerCase().startsWith('select')) { + return []; + } + executedSql.push(sqlText); + return []; + }, + queryScalar: async (sqlText: string, params: unknown[] = []) => { + if (sqlText.includes('information_schema') || sqlText.includes('sqlite_master') || sqlText.includes('pragma_table_info')) { + return 1; + } + if (params.length > 0) { + return 1; + } + return 0; + }, + close: async () => {}, + }; +} + +function makeColumn(overrides: Partial<SchemaContractColumn> = {}): SchemaContractColumn { + return { + logicalType: 'text', + notNull: false, + defaultValue: null, + primaryKey: false, + ...overrides, + }; +} + +describe('runtime schema bootstrap', () => { + it.each(['mysql', 'postgres'] as const)('executes live-schema upgrade statements for %s', async (dialect) => { + const executedSql: string[] = []; + const expectedUpgradeSql = __runtimeSchemaBootstrapTestUtils.splitSqlStatements( + generateUpgradeSql(dialect, currentContract, baselineContract), + ); + + await ensureRuntimeDatabaseSchema(createStubClient(dialect, executedSql), { + currentContract, + liveContract: baselineContract, + }); + + expect(executedSql.slice(0, expectedUpgradeSql.length)).toEqual(expectedUpgradeSql); + }); + + it('skips external schema execution when live schema already matches the current contract', async () => { + const executedSql: string[] = []; + const expectedUpgradeSql = __runtimeSchemaBootstrapTestUtils.buildExternalUpgradeStatements( + 'mysql', + currentContract, + currentContract, + ); + + await ensureRuntimeDatabaseSchema(createStubClient('mysql', executedSql), { + currentContract, + liveContract: currentContract, + }); + + expect(expectedUpgradeSql).toEqual([]); + expect(executedSql.every((sqlText) => classifyLegacyCompatMutation(sqlText) === 'legacy')).toBe(true); + }); + + it('tolerates non-additive live-schema drift and still emits additive runtime patch statements', () => { + const driftedLiveContract = __runtimeSchemaBootstrapTestUtils.cloneContract(currentContract); + + delete driftedLiveContract.tables.model_availability?.columns.is_manual; + if (driftedLiveContract.tables.sites?.columns.status) { + driftedLiveContract.tables.sites.columns.status.defaultValue = null; + } + driftedLiveContract.indexes = driftedLiveContract.indexes.filter((index) => index.name !== 'accounts_site_id_idx'); + driftedLiveContract.uniques = driftedLiveContract.uniques.filter((unique) => unique.name !== 'proxy_files_public_id_unique'); + + const statements = __runtimeSchemaBootstrapTestUtils.buildExternalUpgradeStatements( + 'mysql', + currentContract, + driftedLiveContract, + ); + + expect(statements.some((sqlText) => sqlText.includes('ALTER TABLE `model_availability` ADD COLUMN `is_manual`'))).toBe(true); + expect(statements.some((sqlText) => sqlText.includes('CREATE INDEX `accounts_site_id_idx`'))).toBe(true); + expect(statements.some((sqlText) => sqlText.includes('CREATE UNIQUE INDEX `proxy_files_public_id_unique`'))).toBe(true); + }); + + it('ignores duplicate mysql index and column errors when replaying additive schema statements', async () => { + const executedSql: string[] = []; + const duplicateColumnSql = __runtimeSchemaBootstrapTestUtils.splitSqlStatements( + generateUpgradeSql('mysql', currentContract, baselineContract), + ).find((sqlText) => sqlText.includes('ALTER TABLE `model_availability` ADD COLUMN `is_manual`')); + const duplicateIndexSql = __runtimeSchemaBootstrapTestUtils.splitSqlStatements( + generateUpgradeSql('mysql', currentContract, baselineContract), + ).find((sqlText) => sqlText.includes('proxy_files_public_id_unique')); + + expect(duplicateColumnSql).toBeDefined(); + expect(duplicateIndexSql).toBeDefined(); + + await ensureRuntimeDatabaseSchema({ + ...createStubClient('mysql', executedSql), + execute: async (sqlText: string) => { + executedSql.push(sqlText); + if (sqlText === duplicateColumnSql) { + const error = new Error("Duplicate column name 'is_manual'") as Error & { code?: string }; + error.code = 'ER_DUP_FIELDNAME'; + throw error; + } + if (sqlText === duplicateIndexSql) { + const error = new Error("Duplicate key name 'model_availability_account_model_unique'") as Error & { code?: string }; + error.code = 'ER_DUP_KEYNAME'; + throw error; + } + return []; + }, + }, { + currentContract, + liveContract: baselineContract, + }); + + expect(executedSql).toContain(duplicateColumnSql); + expect(executedSql).toContain(duplicateIndexSql); + }); + + it('ignores postgres relation-already-exists errors when replaying additive schema statements', async () => { + const executedSql: string[] = []; + const targetSql = __runtimeSchemaBootstrapTestUtils.splitSqlStatements( + generateUpgradeSql('postgres', currentContract, baselineContract), + ).find((sqlText) => sqlText.includes('proxy_files_public_id_unique')); + + expect(targetSql).toBeDefined(); + + await ensureRuntimeDatabaseSchema({ + ...createStubClient('postgres', executedSql), + execute: async (sqlText: string) => { + executedSql.push(sqlText); + if (sqlText === targetSql) { + const error = new Error('relation "model_availability_account_model_unique" already exists') as Error & { code?: string }; + error.code = '42P07'; + throw error; + } + return []; + }, + }, { + currentContract, + liveContract: baselineContract, + }); + + expect(executedSql).toContain(targetSql); + }); + + it('adds mysql text prefixes for new indexes when live datetime-like columns are still stored as text', async () => { + const executedSql: string[] = []; + const minimalContract: SchemaContract = { + tables: { + proxy_logs: { + columns: { + downstream_api_key_id: makeColumn({ logicalType: 'integer' }), + created_at: makeColumn({ logicalType: 'datetime', defaultValue: "datetime('now')" }), + }, + }, + }, + indexes: [ + { + name: 'proxy_logs_downstream_api_key_created_at_idx', + table: 'proxy_logs', + columns: ['downstream_api_key_id', 'created_at'], + unique: false, + }, + ], + uniques: [], + foreignKeys: [], + }; + + await ensureRuntimeDatabaseSchema({ + ...createStubClient('mysql', executedSql), + execute: async (sqlText: string) => { + if (sqlText.includes('FROM information_schema.columns')) { + return [[ + { + table_name: 'proxy_logs', + column_name: 'downstream_api_key_id', + data_type: 'int', + column_type: 'int', + }, + { + table_name: 'proxy_logs', + column_name: 'created_at', + data_type: 'text', + column_type: 'text', + }, + ]]; + } + + executedSql.push(sqlText); + return []; + }, + queryScalar: async (sqlText: string, params: unknown[] = []) => { + if (sqlText.includes('information_schema.tables')) { + return params[0] === 'proxy_logs' ? 1 : 0; + } + if (sqlText.includes('information_schema.columns')) { + return 1; + } + return 0; + }, + }, { + currentContract: minimalContract, + liveContract: { + ...minimalContract, + indexes: [], + }, + }); + + expect(executedSql).toContain( + 'CREATE INDEX `proxy_logs_downstream_api_key_created_at_idx` ON `proxy_logs` (`downstream_api_key_id`, `created_at`(191))', + ); + }); + + it('does not add mysql text prefixes when live indexed text columns are varchar-backed', async () => { + const executedSql: string[] = []; + const minimalContract: SchemaContract = { + tables: { + sites: { + columns: { + platform: makeColumn({ logicalType: 'text', notNull: true }), + url: makeColumn({ logicalType: 'text', notNull: true }), + }, + }, + }, + indexes: [], + uniques: [ + { + name: 'sites_platform_url_unique', + table: 'sites', + columns: ['platform', 'url'], + }, + ], + foreignKeys: [], + }; + + await ensureRuntimeDatabaseSchema({ + ...createStubClient('mysql', executedSql), + execute: async (sqlText: string) => { + if (sqlText.includes('FROM information_schema.columns')) { + return [[ + { + table_name: 'sites', + column_name: 'platform', + data_type: 'varchar', + column_type: 'varchar(32)', + }, + { + table_name: 'sites', + column_name: 'url', + data_type: 'varchar', + column_type: 'varchar(255)', + }, + ]]; + } + + executedSql.push(sqlText); + return []; + }, + queryScalar: async (sqlText: string, params: unknown[] = []) => { + if (sqlText.includes('information_schema.tables')) { + return params[0] === 'sites' ? 1 : 0; + } + if (sqlText.includes('information_schema.columns')) { + return 1; + } + return 0; + }, + }, { + currentContract: minimalContract, + liveContract: { + ...minimalContract, + uniques: [], + }, + }); + + expect(executedSql).toContain( + 'CREATE UNIQUE INDEX `sites_platform_url_unique` ON `sites` (`platform`, `url`)', + ); + expect(executedSql).not.toContain( + 'CREATE UNIQUE INDEX `sites_platform_url_unique` ON `sites` (`platform`(191), `url`(191))', + ); + }); +}); diff --git a/src/server/db/runtimeSchemaBootstrap.ts b/src/server/db/runtimeSchemaBootstrap.ts new file mode 100644 index 00000000..ab90c457 --- /dev/null +++ b/src/server/db/runtimeSchemaBootstrap.ts @@ -0,0 +1,489 @@ +import Database from 'better-sqlite3'; +import mysql from 'mysql2/promise'; +import pg from 'pg'; +import { mkdirSync, readFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { + ensureLegacySchemaCompatibility, + type LegacySchemaCompatInspector, +} from './legacySchemaCompat.js'; +import { + generateBootstrapSql, + generateUpgradeSql, + type MysqlIndexPrefixRequirementMap, +} from './schemaArtifactGenerator.js'; +import { installPostgresJsonTextParsers } from './postgresJsonTextParsers.js'; +import { introspectLiveSchema } from './schemaIntrospection.js'; +import { resolveGeneratedSchemaContractPath, type SchemaContract } from './schemaContract.js'; + +export type RuntimeSchemaDialect = 'sqlite' | 'mysql' | 'postgres'; + +export interface RuntimeSchemaClient { + dialect: RuntimeSchemaDialect; + connectionString: string; + ssl: boolean; + begin(): Promise<void>; + commit(): Promise<void>; + rollback(): Promise<void>; + execute(sqlText: string, params?: unknown[]): Promise<unknown>; + queryScalar(sqlText: string, params?: unknown[]): Promise<number>; + close(): Promise<void>; +} + +export interface RuntimeSchemaConnectionInput { + dialect: RuntimeSchemaDialect; + connectionString: string; + ssl?: boolean; +} + +function normalizeSchemaErrorMessage(error: unknown): string { + if (typeof error === 'object' && error && 'message' in error) { + return String((error as { message?: unknown }).message || ''); + } + return String(error || ''); +} + +function isExistingSchemaObjectError(error: unknown): boolean { + const lowered = normalizeSchemaErrorMessage(error).toLowerCase(); + const code = typeof error === 'object' && error && 'code' in error + ? String((error as { code?: unknown }).code || '') + : ''; + + return code === 'ER_DUP_KEYNAME' + || code === 'ER_DUP_FIELDNAME' + || code === 'ER_TABLE_EXISTS_ERROR' + || code === '42P07' + || code === '42701' + || code === '42710' + || lowered.includes('already exists') + || lowered.includes('duplicate column') + || lowered.includes('duplicate key name') + || lowered.includes('relation') && lowered.includes('already exists'); +} + +async function executeBootstrapStatement(client: RuntimeSchemaClient, sqlText: string): Promise<void> { + try { + await client.execute(sqlText); + } catch (error) { + if (!isExistingSchemaObjectError(error)) { + throw error; + } + } +} + +function validateIdentifier(identifier: string): string { + if (!/^[a-z_][a-z0-9_]*$/i.test(identifier)) { + throw new Error(`Invalid SQL identifier: ${identifier}`); + } + return identifier; +} + +function createLegacySchemaInspector(client: RuntimeSchemaClient): LegacySchemaCompatInspector { + if (client.dialect === 'sqlite') { + return { + dialect: 'sqlite', + tableExists: async (table) => { + const normalizedTable = validateIdentifier(table); + return (await client.queryScalar( + `SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = '${normalizedTable}'`, + )) > 0; + }, + columnExists: async (table, column) => { + const normalizedTable = validateIdentifier(table); + const normalizedColumn = validateIdentifier(column); + return (await client.queryScalar( + `SELECT COUNT(*) FROM pragma_table_info('${normalizedTable}') WHERE name = '${normalizedColumn}'`, + )) > 0; + }, + execute: async (sqlText) => { + await client.execute(sqlText); + }, + }; + } + + if (client.dialect === 'mysql') { + return { + dialect: 'mysql', + tableExists: async (table) => { + return (await client.queryScalar( + 'SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = ?', + [table], + )) > 0; + }, + columnExists: async (table, column) => { + return (await client.queryScalar( + 'SELECT COUNT(*) FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = ? AND column_name = ?', + [table, column], + )) > 0; + }, + execute: async (sqlText) => { + await client.execute(sqlText); + }, + }; + } + + return { + dialect: 'postgres', + tableExists: async (table) => { + return (await client.queryScalar( + 'SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = current_schema() AND table_name = $1', + [table], + )) > 0; + }, + columnExists: async (table, column) => { + return (await client.queryScalar( + 'SELECT COUNT(*) FROM information_schema.columns WHERE table_schema = current_schema() AND table_name = $1 AND column_name = $2', + [table, column], + )) > 0; + }, + execute: async (sqlText) => { + await client.execute(sqlText); + }, + }; +} + +function splitSqlStatements(sqlText: string): string[] { + const withoutCommentLines = sqlText + .split(/\r?\n/g) + .filter((line) => !line.trim().startsWith('--')) + .join('\n'); + + return withoutCommentLines + .split(/;\s*(?:\r?\n|$)/g) + .map((statement) => statement.trim()) + .filter((statement) => statement.length > 0); +} + +function readSchemaContract(): SchemaContract { + return JSON.parse(readFileSync(resolveGeneratedSchemaContractPath(), 'utf8')) as SchemaContract; +} + +function cloneContract(contract: SchemaContract): SchemaContract { + return JSON.parse(JSON.stringify(contract)) as SchemaContract; +} + +function serializeColumn(column: SchemaContract['tables'][string]['columns'][string]): string { + return [ + column.logicalType, + column.notNull ? 'not-null' : 'nullable', + column.defaultValue ?? 'default:null', + column.primaryKey ? 'pk' : 'non-pk', + ].join('|'); +} + +function serializeIndex(index: SchemaContract['indexes'][number]): string { + return [index.table, index.columns.join(','), index.unique ? 'unique' : 'non-unique'].join('|'); +} + +function serializeUnique(unique: SchemaContract['uniques'][number]): string { + return [unique.table, unique.columns.join(',')].join('|'); +} + +function serializeForeignKey(foreignKey: SchemaContract['foreignKeys'][number]): string { + return [ + foreignKey.table, + foreignKey.columns.join(','), + foreignKey.referencedTable, + foreignKey.referencedColumns.join(','), + foreignKey.onDelete ?? 'null', + ].join('|'); +} + +function buildCompatibleRuntimeBaseline( + currentContract: SchemaContract, + liveContract: SchemaContract, +): SchemaContract { + const baseline: SchemaContract = { + tables: {}, + indexes: [], + uniques: [], + foreignKeys: [], + }; + + for (const [tableName, liveTable] of Object.entries(liveContract.tables)) { + const currentTable = currentContract.tables[tableName]; + if (!currentTable) { + continue; + } + + const compatibleColumns = Object.fromEntries( + Object.entries(liveTable.columns) + .filter(([columnName, liveColumn]) => { + const currentColumn = currentTable.columns[columnName]; + return currentColumn && serializeColumn(currentColumn) === serializeColumn(liveColumn); + }), + ); + + baseline.tables[tableName] = { columns: compatibleColumns }; + } + + const currentIndexes = new Map(currentContract.indexes.map((index) => [index.name, index])); + baseline.indexes = liveContract.indexes + .filter((index) => { + const currentIndex = currentIndexes.get(index.name); + return currentIndex && serializeIndex(currentIndex) === serializeIndex(index); + }); + + const currentUniques = new Map(currentContract.uniques.map((unique) => [unique.name, unique])); + baseline.uniques = liveContract.uniques + .filter((unique) => { + const currentUnique = currentUniques.get(unique.name); + return currentUnique && serializeUnique(currentUnique) === serializeUnique(unique); + }); + + const currentForeignKeys = new Set(currentContract.foreignKeys.map(serializeForeignKey)); + baseline.foreignKeys = liveContract.foreignKeys + .filter((foreignKey) => currentForeignKeys.has(serializeForeignKey(foreignKey))); + + return baseline; +} + +function collectIndexedColumns(contract: SchemaContract): Map<string, Set<string>> { + const indexedColumns = new Map<string, Set<string>>(); + + for (const index of [...contract.indexes, ...contract.uniques]) { + let columns = indexedColumns.get(index.table); + if (!columns) { + columns = new Set<string>(); + indexedColumns.set(index.table, columns); + } + + for (const columnName of index.columns) { + columns.add(columnName); + } + } + + return indexedColumns; +} + +function requiresMysqlIndexPrefixForColumnType(columnType: string): boolean { + const normalizedType = columnType.trim().toLowerCase(); + return normalizedType.includes('text') || normalizedType.includes('blob'); +} + +async function queryRuntimeRows( + client: RuntimeSchemaClient, + sqlText: string, + params: unknown[] = [], +): Promise<Array<Record<string, unknown>>> { + const result = await client.execute(sqlText, params); + + if (!Array.isArray(result)) { + return []; + } + + const [first] = result; + if (Array.isArray(first)) { + return first as Array<Record<string, unknown>>; + } + + if (result.every((item) => typeof item === 'object' && item !== null && !Array.isArray(item))) { + return result as Array<Record<string, unknown>>; + } + + return []; +} + +async function resolveMySqlIndexPrefixRequirements( + client: RuntimeSchemaClient, + currentContract: SchemaContract, +): Promise<MysqlIndexPrefixRequirementMap> { + const indexedColumns = collectIndexedColumns(currentContract); + if (indexedColumns.size === 0) { + return {}; + } + + const rows = await queryRuntimeRows(client, ` + SELECT + table_name AS table_name, + column_name AS column_name, + data_type AS data_type, + column_type AS column_type + FROM information_schema.columns + WHERE table_schema = DATABASE() + `); + + const requirements: MysqlIndexPrefixRequirementMap = {}; + for (const row of rows) { + const tableName = String(row.table_name || ''); + const columnName = String(row.column_name || ''); + const trackedColumns = indexedColumns.get(tableName); + if (!trackedColumns || !trackedColumns.has(columnName)) { + continue; + } + + const declaredType = String(row.column_type || row.data_type || ''); + requirements[tableName] ??= {}; + requirements[tableName][columnName] = requiresMysqlIndexPrefixForColumnType(declaredType); + } + + return requirements; +} + +async function createPostgresClient(connectionString: string, ssl: boolean): Promise<RuntimeSchemaClient> { + const clientOptions: pg.ClientConfig = { connectionString }; + if (ssl) { + clientOptions.ssl = { rejectUnauthorized: false }; + } + installPostgresJsonTextParsers(); + const client = new pg.Client(clientOptions); + await client.connect(); + + return { + dialect: 'postgres', + connectionString, + ssl, + begin: async () => { await client.query('BEGIN'); }, + commit: async () => { await client.query('COMMIT'); }, + rollback: async () => { await client.query('ROLLBACK'); }, + execute: async (sqlText, params = []) => client.query(sqlText, params), + queryScalar: async (sqlText, params = []) => { + const result = await client.query(sqlText, params); + const row = result.rows[0] as Record<string, unknown> | undefined; + if (!row) return 0; + return Number(Object.values(row)[0]) || 0; + }, + close: async () => { await client.end(); }, + }; +} + +async function createMySqlClient(connectionString: string, ssl: boolean): Promise<RuntimeSchemaClient> { + const connectionOptions: mysql.ConnectionOptions = { uri: connectionString }; + if (ssl) { + connectionOptions.ssl = { rejectUnauthorized: false }; + } + const connection = await mysql.createConnection(connectionOptions); + + return { + dialect: 'mysql', + connectionString, + ssl, + begin: async () => { await connection.beginTransaction(); }, + commit: async () => { await connection.commit(); }, + rollback: async () => { await connection.rollback(); }, + execute: async (sqlText, params = []) => connection.execute(sqlText, params as any[]), + queryScalar: async (sqlText, params = []) => { + const [rows] = await connection.query(sqlText, params as any[]); + if (!Array.isArray(rows) || rows.length === 0) return 0; + const row = rows[0] as Record<string, unknown>; + return Number(Object.values(row)[0]) || 0; + }, + close: async () => { await connection.end(); }, + }; +} + +async function createSqliteClient(connectionString: string): Promise<RuntimeSchemaClient> { + const filePath = connectionString === ':memory:' ? ':memory:' : resolve(connectionString); + if (filePath !== ':memory:') { + mkdirSync(dirname(filePath), { recursive: true }); + } + const sqlite = new Database(filePath); + sqlite.pragma('journal_mode = WAL'); + sqlite.pragma('foreign_keys = ON'); + + return { + dialect: 'sqlite', + connectionString, + ssl: false, + begin: async () => { sqlite.exec('BEGIN'); }, + commit: async () => { sqlite.exec('COMMIT'); }, + rollback: async () => { sqlite.exec('ROLLBACK'); }, + execute: async (sqlText, params = []) => { + const lowered = sqlText.trim().toLowerCase(); + const statement = sqlite.prepare(sqlText); + if (lowered.startsWith('select')) return statement.all(...params); + return statement.run(...params); + }, + queryScalar: async (sqlText, params = []) => { + const row = sqlite.prepare(sqlText).get(...params) as Record<string, unknown> | undefined; + if (!row) return 0; + return Number(Object.values(row)[0]) || 0; + }, + close: async () => { sqlite.close(); }, + }; +} + +export async function createRuntimeSchemaClient(input: RuntimeSchemaConnectionInput): Promise<RuntimeSchemaClient> { + if (input.dialect === 'postgres') { + return createPostgresClient(input.connectionString, !!input.ssl); + } + if (input.dialect === 'mysql') { + return createMySqlClient(input.connectionString, !!input.ssl); + } + return createSqliteClient(input.connectionString); +} + +type EnsureRuntimeDatabaseSchemaOptions = { + currentContract?: SchemaContract; + liveContract?: SchemaContract; +}; + +async function resolveLiveContract(client: RuntimeSchemaClient, liveContract?: SchemaContract): Promise<SchemaContract> { + if (liveContract) { + return liveContract; + } + + return introspectLiveSchema({ + dialect: client.dialect, + connectionString: client.connectionString, + ssl: client.ssl, + }); +} + +function buildExternalUpgradeStatements( + dialect: Exclude<RuntimeSchemaDialect, 'sqlite'>, + currentContract: SchemaContract, + liveContract: SchemaContract, + mysqlIndexPrefixRequirements?: MysqlIndexPrefixRequirementMap, +): string[] { + const compatibleBaseline = buildCompatibleRuntimeBaseline(currentContract, liveContract); + return splitSqlStatements(generateUpgradeSql(dialect, currentContract, compatibleBaseline, { + mysqlIndexPrefixRequirements, + })); +} + +export async function ensureRuntimeDatabaseSchema( + client: RuntimeSchemaClient, + options: EnsureRuntimeDatabaseSchemaOptions = {}, +): Promise<void> { + const currentContract = options.currentContract ?? readSchemaContract(); + let statements: string[]; + + if (client.dialect === 'sqlite') { + statements = splitSqlStatements(generateBootstrapSql('sqlite', currentContract)); + } else { + const liveContract = await resolveLiveContract(client, options.liveContract); + const mysqlIndexPrefixRequirements = client.dialect === 'mysql' + ? await resolveMySqlIndexPrefixRequirements(client, currentContract) + : undefined; + + statements = buildExternalUpgradeStatements( + client.dialect, + currentContract, + liveContract, + mysqlIndexPrefixRequirements, + ); + } + + for (const sqlText of statements) { + await executeBootstrapStatement(client, sqlText); + } + + await ensureLegacySchemaCompatibility(createLegacySchemaInspector(client)); +} + +export async function bootstrapRuntimeDatabaseSchema(input: RuntimeSchemaConnectionInput): Promise<void> { + const client = await createRuntimeSchemaClient(input); + try { + await ensureRuntimeDatabaseSchema(client); + } finally { + await client.close(); + } +} + +export const __runtimeSchemaBootstrapTestUtils = { + buildCompatibleRuntimeBaseline, + cloneContract, + splitSqlStatements, + buildExternalUpgradeStatements, +}; diff --git a/src/server/db/schema.ts b/src/server/db/schema.ts index 621486a6..613a4ee2 100644 --- a/src/server/db/schema.ts +++ b/src/server/db/schema.ts @@ -6,9 +6,10 @@ export const sites = sqliteTable('sites', { name: text('name').notNull(), url: text('url').notNull(), externalCheckinUrl: text('external_checkin_url'), - platform: text('platform').notNull(), // 'new-api' | 'one-api' | 'veloera' | 'one-hub' | 'done-hub' | 'sub2api' | 'openai' | 'claude' | 'gemini' + platform: text('platform').notNull(), // 'new-api' | 'one-api' | 'veloera' | 'one-hub' | 'done-hub' | 'sub2api' | 'openai' | 'claude' | 'gemini' | 'codex' | 'gemini-cli' | 'antigravity' proxyUrl: text('proxy_url'), useSystemProxy: integer('use_system_proxy', { mode: 'boolean' }).default(false), + customHeaders: text('custom_headers'), status: text('status').notNull().default('active'), // 'active' | 'disabled' isPinned: integer('is_pinned', { mode: 'boolean' }).default(false), sortOrder: integer('sort_order').default(0), @@ -18,6 +19,17 @@ export const sites = sqliteTable('sites', { updatedAt: text('updated_at').default(sql`(datetime('now'))`), }, (table) => ({ statusIdx: index('sites_status_idx').on(table.status), + platformUrlUnique: uniqueIndex('sites_platform_url_unique').on(table.platform, table.url), +})); + +export const siteDisabledModels = sqliteTable('site_disabled_models', { + id: integer('id').primaryKey({ autoIncrement: true }), + siteId: integer('site_id').notNull().references(() => sites.id, { onDelete: 'cascade' }), + modelName: text('model_name').notNull(), + createdAt: text('created_at').default(sql`(datetime('now'))`), +}, (table) => ({ + siteModelUnique: uniqueIndex('site_disabled_models_site_model_unique').on(table.siteId, table.modelName), + siteIdIdx: index('site_disabled_models_site_id_idx').on(table.siteId), })); export const accounts = sqliteTable('accounts', { @@ -37,6 +49,9 @@ export const accounts = sqliteTable('accounts', { checkinEnabled: integer('checkin_enabled', { mode: 'boolean' }).default(true), lastCheckinAt: text('last_checkin_at'), lastBalanceRefresh: text('last_balance_refresh'), + oauthProvider: text('oauth_provider'), + oauthAccountKey: text('oauth_account_key'), + oauthProjectId: text('oauth_project_id'), extraConfig: text('extra_config'), // JSON string createdAt: text('created_at').default(sql`(datetime('now'))`), updatedAt: text('updated_at').default(sql`(datetime('now'))`), @@ -44,6 +59,8 @@ export const accounts = sqliteTable('accounts', { siteIdIdx: index('accounts_site_id_idx').on(table.siteId), statusIdx: index('accounts_status_idx').on(table.status), siteStatusIdx: index('accounts_site_status_idx').on(table.siteId, table.status), + oauthProviderIdx: index('accounts_oauth_provider_idx').on(table.oauthProvider), + oauthIdentityIdx: index('accounts_oauth_identity_idx').on(table.oauthProvider, table.oauthAccountKey, table.oauthProjectId), })); export const accountTokens = sqliteTable('account_tokens', { @@ -52,6 +69,7 @@ export const accountTokens = sqliteTable('account_tokens', { name: text('name').notNull(), token: text('token').notNull(), tokenGroup: text('token_group'), + valueStatus: text('value_status').notNull().default('ready'), source: text('source').default('manual'), // 'manual' | 'sync' | 'legacy' enabled: integer('enabled', { mode: 'boolean' }).default(true), isDefault: integer('is_default', { mode: 'boolean' }).default(false), @@ -81,6 +99,7 @@ export const modelAvailability = sqliteTable('model_availability', { accountId: integer('account_id').notNull().references(() => accounts.id, { onDelete: 'cascade' }), modelName: text('model_name').notNull(), available: integer('available', { mode: 'boolean' }), + isManual: integer('is_manual', { mode: 'boolean' }).default(false), latencyMs: integer('latency_ms'), checkedAt: text('checked_at').default(sql`(datetime('now'))`), }, (table) => ({ @@ -108,9 +127,11 @@ export const tokenRoutes = sqliteTable('token_routes', { modelPattern: text('model_pattern').notNull(), displayName: text('display_name'), displayIcon: text('display_icon'), + routeMode: text('route_mode').default('pattern'), modelMapping: text('model_mapping'), // JSON decisionSnapshot: text('decision_snapshot'), // JSON decisionRefreshedAt: text('decision_refreshed_at'), + routingStrategy: text('routing_strategy').default('weighted'), enabled: integer('enabled', { mode: 'boolean' }).default(true), createdAt: text('created_at').default(sql`(datetime('now'))`), updatedAt: text('updated_at').default(sql`(datetime('now'))`), @@ -119,6 +140,15 @@ export const tokenRoutes = sqliteTable('token_routes', { enabledIdx: index('token_routes_enabled_idx').on(table.enabled), })); +export const routeGroupSources = sqliteTable('route_group_sources', { + id: integer('id').primaryKey({ autoIncrement: true }), + groupRouteId: integer('group_route_id').notNull().references(() => tokenRoutes.id, { onDelete: 'cascade' }), + sourceRouteId: integer('source_route_id').notNull().references(() => tokenRoutes.id, { onDelete: 'cascade' }), +}, (table) => ({ + groupSourceUnique: uniqueIndex('route_group_sources_group_source_unique').on(table.groupRouteId, table.sourceRouteId), + sourceRouteIdx: index('route_group_sources_source_route_id_idx').on(table.sourceRouteId), +})); + export const routeChannels = sqliteTable('route_channels', { id: integer('id').primaryKey({ autoIncrement: true }), routeId: integer('route_id').notNull().references(() => tokenRoutes.id, { onDelete: 'cascade' }), @@ -134,7 +164,10 @@ export const routeChannels = sqliteTable('route_channels', { totalLatencyMs: integer('total_latency_ms').default(0), totalCost: real('total_cost').default(0), lastUsedAt: text('last_used_at'), + lastSelectedAt: text('last_selected_at'), lastFailAt: text('last_fail_at'), + consecutiveFailCount: integer('consecutive_fail_count').notNull().default(0), + cooldownLevel: integer('cooldown_level').notNull().default(0), cooldownUntil: text('cooldown_until'), }, (table) => ({ routeIdIdx: index('route_channels_route_id_idx').on(table.routeId), @@ -149,6 +182,7 @@ export const proxyLogs = sqliteTable('proxy_logs', { routeId: integer('route_id'), channelId: integer('channel_id'), accountId: integer('account_id'), + downstreamApiKeyId: integer('downstream_api_key_id'), modelRequested: text('model_requested'), modelActual: text('model_actual'), status: text('status'), // 'success' | 'failed' | 'retried' @@ -159,6 +193,10 @@ export const proxyLogs = sqliteTable('proxy_logs', { totalTokens: integer('total_tokens'), estimatedCost: real('estimated_cost'), billingDetails: text('billing_details'), + clientFamily: text('client_family'), + clientAppId: text('client_app_id'), + clientAppName: text('client_app_name'), + clientConfidence: text('client_confidence'), errorMessage: text('error_message'), retryCount: integer('retry_count').default(0), createdAt: text('created_at').default(sql`(datetime('now'))`), @@ -167,6 +205,9 @@ export const proxyLogs = sqliteTable('proxy_logs', { accountCreatedIdx: index('proxy_logs_account_created_at_idx').on(table.accountId, table.createdAt), statusCreatedIdx: index('proxy_logs_status_created_at_idx').on(table.status, table.createdAt), modelActualCreatedIdx: index('proxy_logs_model_actual_created_at_idx').on(table.modelActual, table.createdAt), + downstreamKeyCreatedIdx: index('proxy_logs_downstream_api_key_created_at_idx').on(table.downstreamApiKeyId, table.createdAt), + clientAppCreatedIdx: index('proxy_logs_client_app_id_created_at_idx').on(table.clientAppId, table.createdAt), + clientFamilyCreatedIdx: index('proxy_logs_client_family_created_at_idx').on(table.clientFamily, table.createdAt), })); export const proxyVideoTasks = sqliteTable('proxy_video_tasks', { @@ -220,6 +261,8 @@ export const downstreamApiKeys = sqliteTable('downstream_api_keys', { name: text('name').notNull(), key: text('key').notNull(), description: text('description'), + groupName: text('group_name'), + tags: text('tags'), // JSON array<string> enabled: integer('enabled', { mode: 'boolean' }).default(true), expiresAt: text('expires_at'), maxCost: real('max_cost'), @@ -239,6 +282,30 @@ export const downstreamApiKeys = sqliteTable('downstream_api_keys', { expiresAtIdx: index('downstream_api_keys_expires_at_idx').on(table.expiresAt), })); +export const siteAnnouncements = sqliteTable('site_announcements', { + id: integer('id').primaryKey({ autoIncrement: true }), + siteId: integer('site_id').notNull().references(() => sites.id, { onDelete: 'cascade' }), + platform: text('platform').notNull(), + sourceKey: text('source_key').notNull(), + title: text('title').notNull(), + content: text('content').notNull(), + level: text('level').notNull().default('info'), + sourceUrl: text('source_url'), + startsAt: text('starts_at'), + endsAt: text('ends_at'), + upstreamCreatedAt: text('upstream_created_at'), + upstreamUpdatedAt: text('upstream_updated_at'), + firstSeenAt: text('first_seen_at').default(sql`(datetime('now'))`), + lastSeenAt: text('last_seen_at').default(sql`(datetime('now'))`), + readAt: text('read_at'), + dismissedAt: text('dismissed_at'), + rawPayload: text('raw_payload'), +}, (table) => ({ + siteSourceKeyUnique: uniqueIndex('site_announcements_site_source_key_unique').on(table.siteId, table.sourceKey), + siteIdFirstSeenAtIdx: index('site_announcements_site_id_first_seen_at_idx').on(table.siteId, table.firstSeenAt), + readAtIdx: index('site_announcements_read_at_idx').on(table.readAt), +})); + export const events = sqliteTable('events', { id: integer('id').primaryKey({ autoIncrement: true }), type: text('type').notNull(), // 'checkin' | 'balance' | 'token' | 'proxy' | 'status' diff --git a/src/server/db/schemaArtifactGenerator.test.ts b/src/server/db/schemaArtifactGenerator.test.ts new file mode 100644 index 00000000..7a38f453 --- /dev/null +++ b/src/server/db/schemaArtifactGenerator.test.ts @@ -0,0 +1,173 @@ +import { readFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; +import { generateDialectArtifacts, generateUpgradeSql, type GeneratedDialectArtifacts } from './schemaArtifactGenerator.js'; +import type { SchemaContract, SchemaContractColumn } from './schemaContract.js'; + +const dbDir = dirname(fileURLToPath(import.meta.url)); +const schemaContractPath = resolve(dbDir, 'generated/schemaContract.json'); + +function readSchemaContract(): SchemaContract { + return JSON.parse(readFileSync(schemaContractPath, 'utf8')) as SchemaContract; +} + +function makeColumn(overrides: Partial<SchemaContractColumn> = {}): SchemaContractColumn { + return { + logicalType: 'text', + notNull: false, + defaultValue: null, + primaryKey: false, + ...overrides, + }; +} + +describe('schema artifact generator', () => { + it('generates bootstrap sql for mysql and postgres from the contract', () => { + const artifacts = generateDialectArtifacts(readSchemaContract()); + + expect(artifacts.mysqlBootstrap).toContain('CREATE TABLE IF NOT EXISTS `sites`'); + expect(artifacts.mysqlBootstrap).toContain('CREATE TABLE IF NOT EXISTS `site_disabled_models`'); + expect(artifacts.postgresBootstrap).toContain('CREATE TABLE IF NOT EXISTS "account_tokens"'); + expect(artifacts.postgresBootstrap).toContain('"token_group"'); + }); + + it('generates additive upgrade sql for newly added tables, columns, indexes, and uniques', () => { + const previousContract: SchemaContract = { + tables: { + sites: { + columns: { + id: makeColumn({ logicalType: 'integer', notNull: true, primaryKey: true }), + }, + }, + }, + indexes: [], + uniques: [], + foreignKeys: [], + }; + const currentContract: SchemaContract = { + tables: { + sites: { + columns: { + id: makeColumn({ logicalType: 'integer', notNull: true, primaryKey: true }), + status: makeColumn({ notNull: true, defaultValue: "'active'" }), + }, + }, + accounts: { + columns: { + id: makeColumn({ logicalType: 'integer', notNull: true, primaryKey: true }), + site_id: makeColumn({ logicalType: 'integer', notNull: true }), + email: makeColumn({ notNull: true }), + }, + }, + }, + indexes: [ + { name: 'sites_status_idx', table: 'sites', columns: ['status'], unique: false }, + ], + uniques: [ + { name: 'accounts_site_email_unique', table: 'accounts', columns: ['site_id', 'email'] }, + ], + foreignKeys: [ + { + table: 'accounts', + columns: ['site_id'], + referencedTable: 'sites', + referencedColumns: ['id'], + onDelete: 'cascade', + }, + ], + }; + + const artifacts: GeneratedDialectArtifacts = generateDialectArtifacts(currentContract, previousContract); + + expect(artifacts.mysqlUpgrade).toContain('CREATE TABLE IF NOT EXISTS `accounts`'); + expect(artifacts.mysqlUpgrade).toContain('ALTER TABLE `sites` ADD COLUMN `status`'); + expect(artifacts.mysqlUpgrade).toContain('CREATE INDEX `sites_status_idx` ON `sites`'); + expect(artifacts.mysqlUpgrade).toContain('CREATE UNIQUE INDEX `accounts_site_email_unique` ON `accounts`'); + expect(artifacts.postgresUpgrade).toContain('CREATE TABLE IF NOT EXISTS "accounts"'); + expect(artifacts.postgresUpgrade).toContain('ALTER TABLE "sites" ADD COLUMN "status"'); + expect(artifacts.postgresUpgrade).toContain('CREATE INDEX "sites_status_idx" ON "sites"'); + expect(artifacts.postgresUpgrade).toContain('CREATE UNIQUE INDEX "accounts_site_email_unique" ON "accounts"'); + }); + + it('orders dependent tables safely and emits executable datetime defaults for external dialects', () => { + const artifacts = generateDialectArtifacts(readSchemaContract()); + + expect( + artifacts.mysqlBootstrap.indexOf('CREATE TABLE IF NOT EXISTS `accounts`'), + ).toBeLessThan( + artifacts.mysqlBootstrap.indexOf('CREATE TABLE IF NOT EXISTS `account_tokens`'), + ); + expect( + artifacts.postgresBootstrap.indexOf('CREATE TABLE IF NOT EXISTS "accounts"'), + ).toBeLessThan( + artifacts.postgresBootstrap.indexOf('CREATE TABLE IF NOT EXISTS "account_tokens"'), + ); + expect(artifacts.mysqlBootstrap).toContain("`created_at` VARCHAR(191) DEFAULT (DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s'))"); + expect(artifacts.postgresBootstrap).toContain(`"created_at" TEXT DEFAULT to_char(timezone('UTC', CURRENT_TIMESTAMP), 'YYYY-MM-DD HH24:MI:SS')`); + }); + + it('uses varchar for mysql text primary keys so bootstrap ddl stays executable', () => { + const artifacts = generateDialectArtifacts(readSchemaContract()); + + expect(artifacts.mysqlBootstrap).toContain('CREATE TABLE IF NOT EXISTS `settings` (`key` VARCHAR(191) NOT NULL PRIMARY KEY, `value` TEXT)'); + expect(artifacts.mysqlBootstrap).not.toContain('CREATE TABLE IF NOT EXISTS `settings` (`key` TEXT NOT NULL PRIMARY KEY, `value` TEXT)'); + }); + + it('does not add mysql text prefixes to non-text index columns', () => { + const artifacts = generateDialectArtifacts(readSchemaContract()); + + expect(artifacts.mysqlBootstrap).toContain('CREATE INDEX `checkin_logs_account_created_at_idx` ON `checkin_logs` (`account_id`, `created_at`)'); + expect(artifacts.mysqlBootstrap).toContain('CREATE INDEX `events_read_created_at_idx` ON `events` (`read`, `created_at`)'); + expect(artifacts.mysqlBootstrap).not.toContain('`created_at`(191)'); + expect(artifacts.mysqlBootstrap).not.toContain('`read`(191)'); + }); + + it('allows mysql upgrade generation to force prefix lengths for live text-backed columns', () => { + const previousContract: SchemaContract = { + tables: { + proxy_logs: { + columns: { + downstream_api_key_id: makeColumn({ logicalType: 'integer' }), + created_at: makeColumn({ logicalType: 'datetime', defaultValue: "datetime('now')" }), + }, + }, + }, + indexes: [], + uniques: [], + foreignKeys: [], + }; + const currentContract: SchemaContract = { + ...previousContract, + indexes: [ + { + name: 'proxy_logs_downstream_api_key_created_at_idx', + table: 'proxy_logs', + columns: ['downstream_api_key_id', 'created_at'], + unique: false, + }, + ], + }; + + const mysqlUpgrade = generateUpgradeSql('mysql', currentContract, previousContract, { + mysqlIndexPrefixRequirements: { + proxy_logs: { + created_at: true, + }, + }, + }); + + expect(mysqlUpgrade).toContain( + 'CREATE INDEX `proxy_logs_downstream_api_key_created_at_idx` ON `proxy_logs` (`downstream_api_key_id`, `created_at`(191))', + ); + }); + + it('rejects destructive diffs when generating additive upgrades', () => { + const current = readSchemaContract(); + const previous = structuredClone(current); + + delete current.tables.sites.columns.status; + + expect(() => generateDialectArtifacts(current, previous)).toThrow(/non-additive schema diff/i); + }); +}); diff --git a/src/server/db/schemaArtifactGenerator.ts b/src/server/db/schemaArtifactGenerator.ts new file mode 100644 index 00000000..b0d87800 --- /dev/null +++ b/src/server/db/schemaArtifactGenerator.ts @@ -0,0 +1,446 @@ +import { mkdirSync, writeFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import type { + LogicalColumnType, + SchemaContract, + SchemaContractColumn, + SchemaContractForeignKey, + SchemaContractIndex, + SchemaContractUnique, +} from './schemaContract.js'; + +export interface GeneratedDialectArtifacts { + mysqlBootstrap: string; + postgresBootstrap: string; + mysqlUpgrade: string; + postgresUpgrade: string; +} + +export type MysqlIndexPrefixRequirementMap = Record<string, Record<string, boolean>>; + +type Dialect = 'mysql' | 'postgres'; +type SqlDialect = 'sqlite' | Dialect; +type SqlGenerationOptions = { + mysqlIndexPrefixRequirements?: MysqlIndexPrefixRequirementMap; +}; + +function resolveDbDir(): string { + return dirname(fileURLToPath(import.meta.url)); +} + +export function resolveGeneratedArtifactPath(filename: string): string { + return resolve(resolveDbDir(), 'generated', filename); +} + +function quoteIdentifier(dialect: SqlDialect, identifier: string): string { + return dialect === 'mysql' ? `\`${identifier}\`` : `"${identifier}"`; +} + +function escapeMysqlTextPrefix(columnType: LogicalColumnType): string { + return columnType === 'text' ? '(191)' : ''; +} + +function resolveMysqlIndexPrefix( + tableName: string, + columnName: string, + contract: SchemaContract, + options?: SqlGenerationOptions, +): string { + const override = options?.mysqlIndexPrefixRequirements?.[tableName]?.[columnName]; + if (override !== undefined) { + return override ? '(191)' : ''; + } + + const column = contract.tables[tableName]?.columns[columnName]; + return column ? escapeMysqlTextPrefix(column.logicalType) : ''; +} + +function mapColumnType(dialect: SqlDialect, columnName: string, column: SchemaContractColumn): string { + if (dialect === 'sqlite') { + switch (column.logicalType) { + case 'boolean': + case 'integer': + return 'INTEGER'; + case 'real': + return 'REAL'; + case 'datetime': + case 'json': + case 'text': + default: + return 'TEXT'; + } + } + + if (dialect === 'mysql') { + switch (column.logicalType) { + case 'boolean': + return 'BOOLEAN'; + case 'integer': + return column.primaryKey && columnName === 'id' ? 'INT AUTO_INCREMENT' : 'INT'; + case 'real': + return 'DOUBLE'; + case 'datetime': + return 'VARCHAR(191)'; + case 'json': + return 'JSON'; + case 'text': + return column.primaryKey || column.defaultValue != null ? 'VARCHAR(191)' : 'TEXT'; + default: + return 'TEXT'; + } + } + + switch (column.logicalType) { + case 'boolean': + return 'BOOLEAN'; + case 'integer': + return column.primaryKey && columnName === 'id' ? 'INTEGER GENERATED BY DEFAULT AS IDENTITY' : 'INTEGER'; + case 'real': + return 'DOUBLE PRECISION'; + case 'datetime': + return 'TEXT'; + case 'json': + return 'JSONB'; + case 'text': + default: + return 'TEXT'; + } +} + +function formatDefaultValue(dialect: SqlDialect, column: SchemaContractColumn): string { + if (column.defaultValue == null || column.primaryKey) { + return ''; + } + + if (column.logicalType === 'datetime') { + if (dialect === 'sqlite') { + return " DEFAULT (datetime('now'))"; + } + if (dialect === 'mysql') { + return " DEFAULT (DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s'))"; + } + return " DEFAULT to_char(timezone('UTC', CURRENT_TIMESTAMP), 'YYYY-MM-DD HH24:MI:SS')"; + } + + if (column.logicalType === 'boolean') { + return ` DEFAULT ${column.defaultValue.toLowerCase()}`; + } + + return ` DEFAULT ${column.defaultValue}`; +} + +function buildColumnDefinition( + dialect: SqlDialect, + columnName: string, + column: SchemaContractColumn, +): string { + const sqlType = mapColumnType(dialect, columnName, column); + const notNull = column.notNull ? ' NOT NULL' : ''; + const defaultValue = formatDefaultValue(dialect, column); + const primaryKey = column.primaryKey ? ' PRIMARY KEY' : ''; + return `${quoteIdentifier(dialect, columnName)} ${sqlType}${notNull}${defaultValue}${primaryKey}`; +} + +function buildForeignKeyClause(dialect: SqlDialect, foreignKey: SchemaContractForeignKey): string { + const sourceColumns = foreignKey.columns.map((column) => quoteIdentifier(dialect, column)).join(', '); + const targetColumns = foreignKey.referencedColumns.map((column) => quoteIdentifier(dialect, column)).join(', '); + const onDelete = foreignKey.onDelete ? ` ON DELETE ${foreignKey.onDelete.toUpperCase()}` : ''; + return `FOREIGN KEY (${sourceColumns}) REFERENCES ${quoteIdentifier(dialect, foreignKey.referencedTable)}(${targetColumns})${onDelete}`; +} + +function sortTableNamesForCreation(contract: SchemaContract): string[] { + const ordered: string[] = []; + const visited = new Set<string>(); + const visiting = new Set<string>(); + const tableNames = Object.keys(contract.tables).sort((left, right) => left.localeCompare(right, 'en')); + + const dependencies = new Map<string, string[]>(); + for (const tableName of tableNames) { + const referencedTables = contract.foreignKeys + .filter((foreignKey) => foreignKey.table === tableName) + .map((foreignKey) => foreignKey.referencedTable) + .filter((referencedTable) => referencedTable !== tableName && tableNames.includes(referencedTable)) + .sort((left, right) => left.localeCompare(right, 'en')); + dependencies.set(tableName, [...new Set(referencedTables)]); + } + + function visit(tableName: string): void { + if (visited.has(tableName) || visiting.has(tableName)) { + return; + } + visiting.add(tableName); + for (const dependency of dependencies.get(tableName) ?? []) { + visit(dependency); + } + visiting.delete(tableName); + visited.add(tableName); + ordered.push(tableName); + } + + for (const tableName of tableNames) { + visit(tableName); + } + + return ordered; +} + +function buildCreateTableStatement( + dialect: SqlDialect, + tableName: string, + contract: SchemaContract, +): string { + const table = contract.tables[tableName]; + const columnEntries = Object.entries(table.columns); + const foreignKeys = contract.foreignKeys.filter((foreignKey) => foreignKey.table === tableName); + const parts = [ + ...columnEntries.map(([columnName, column]) => buildColumnDefinition(dialect, columnName, column)), + ...foreignKeys.map((foreignKey) => buildForeignKeyClause(dialect, foreignKey)), + ]; + return `CREATE TABLE IF NOT EXISTS ${quoteIdentifier(dialect, tableName)} (${parts.join(', ')})`; +} + +function buildUniqueIndexStatement( + dialect: SqlDialect, + uniqueIndex: SchemaContractUnique, + contract: SchemaContract, + options?: SqlGenerationOptions, +): string { + const columns = uniqueIndex.columns + .map((columnName) => { + const suffix = dialect === 'mysql' + ? resolveMysqlIndexPrefix(uniqueIndex.table, columnName, contract, options) + : ''; + return `${quoteIdentifier(dialect, columnName)}${suffix}`; + }) + .join(', '); + return `CREATE UNIQUE INDEX ${quoteIdentifier(dialect, uniqueIndex.name)} ON ${quoteIdentifier(dialect, uniqueIndex.table)} (${columns})`; +} + +function buildIndexStatement( + dialect: SqlDialect, + index: SchemaContractIndex, + contract: SchemaContract, + options?: SqlGenerationOptions, +): string { + const columns = index.columns + .map((columnName) => { + const suffix = dialect === 'mysql' + ? resolveMysqlIndexPrefix(index.table, columnName, contract, options) + : ''; + return `${quoteIdentifier(dialect, columnName)}${suffix}`; + }) + .join(', '); + return `CREATE INDEX ${quoteIdentifier(dialect, index.name)} ON ${quoteIdentifier(dialect, index.table)} (${columns})`; +} + +function serializeColumn(column: SchemaContractColumn): string { + return [ + column.logicalType, + column.notNull ? 'not-null' : 'nullable', + column.defaultValue ?? 'default:null', + column.primaryKey ? 'pk' : 'non-pk', + ].join('|'); +} + +function serializeIndex(index: SchemaContractIndex): string { + return [index.table, index.columns.join(','), index.unique ? 'unique' : 'non-unique'].join('|'); +} + +function serializeUnique(unique: SchemaContractUnique): string { + return [unique.table, unique.columns.join(',')].join('|'); +} + +function serializeForeignKey(foreignKey: SchemaContractForeignKey): string { + return [ + foreignKey.table, + foreignKey.columns.join(','), + foreignKey.referencedTable, + foreignKey.referencedColumns.join(','), + foreignKey.onDelete ?? 'null', + ].join('|'); +} + +function assertAdditiveSchemaDiff(currentContract: SchemaContract, previousContract: SchemaContract): void { + const violations: string[] = []; + + for (const [tableName, previousTable] of Object.entries(previousContract.tables)) { + const currentTable = currentContract.tables[tableName]; + if (!currentTable) { + violations.push(`removed table ${tableName}`); + continue; + } + + for (const [columnName, previousColumn] of Object.entries(previousTable.columns)) { + const currentColumn = currentTable.columns[columnName]; + if (!currentColumn) { + violations.push(`removed column ${tableName}.${columnName}`); + continue; + } + + if (serializeColumn(currentColumn) !== serializeColumn(previousColumn)) { + violations.push(`changed column ${tableName}.${columnName}`); + } + } + } + + const currentIndexes = new Map(currentContract.indexes.map((index) => [index.name, index])); + for (const previousIndex of previousContract.indexes) { + const currentIndex = currentIndexes.get(previousIndex.name); + if (!currentIndex) { + violations.push(`removed index ${previousIndex.name}`); + continue; + } + if (serializeIndex(currentIndex) !== serializeIndex(previousIndex)) { + violations.push(`changed index ${previousIndex.name}`); + } + } + + const currentUniques = new Map(currentContract.uniques.map((unique) => [unique.name, unique])); + for (const previousUnique of previousContract.uniques) { + const currentUnique = currentUniques.get(previousUnique.name); + if (!currentUnique) { + violations.push(`removed unique ${previousUnique.name}`); + continue; + } + if (serializeUnique(currentUnique) !== serializeUnique(previousUnique)) { + violations.push(`changed unique ${previousUnique.name}`); + } + } + + const currentForeignKeys = new Set(currentContract.foreignKeys.map(serializeForeignKey)); + for (const previousForeignKey of previousContract.foreignKeys) { + if (!currentForeignKeys.has(serializeForeignKey(previousForeignKey))) { + violations.push(`removed foreign key ${previousForeignKey.table}(${previousForeignKey.columns.join(',')})`); + } + } + + if (violations.length > 0) { + throw new Error(`Non-additive schema diff detected:\n- ${violations.join('\n- ')}`); + } +} + +export function generateBootstrapSql(dialect: SqlDialect, contract: SchemaContract): string { + const tableStatements = sortTableNamesForCreation(contract) + .map((tableName) => buildCreateTableStatement(dialect, tableName, contract)); + + const uniqueStatements = contract.uniques + .slice() + .sort((left, right) => left.name.localeCompare(right.name, 'en')) + .map((uniqueIndex) => buildUniqueIndexStatement(dialect, uniqueIndex, contract)); + + const knownUniqueNames = new Set(contract.uniques.map((uniqueIndex) => uniqueIndex.name)); + const indexStatements = contract.indexes + .filter((index) => !knownUniqueNames.has(index.name)) + .slice() + .sort((left, right) => left.name.localeCompare(right.name, 'en')) + .map((index) => buildIndexStatement(dialect, index, contract)); + + return `${[...tableStatements, ...uniqueStatements, ...indexStatements].join(';\n')};\n`; +} + +function buildAddColumnStatement( + dialect: SqlDialect, + tableName: string, + columnName: string, + column: SchemaContractColumn, +): string { + return `ALTER TABLE ${quoteIdentifier(dialect, tableName)} ADD COLUMN ${buildColumnDefinition(dialect, columnName, column)}`; +} + +export function generateUpgradeSql( + dialect: SqlDialect, + currentContract: SchemaContract, + previousContract?: SchemaContract | null, + options?: SqlGenerationOptions, +): string { + if (!previousContract) { + return `-- no previous schema contract available for ${dialect} additive upgrade generation\n`; + } + + assertAdditiveSchemaDiff(currentContract, previousContract); + + const previousTableNames = new Set(Object.keys(previousContract.tables)); + const currentTableNames = Object.keys(currentContract.tables).sort((left, right) => left.localeCompare(right, 'en')); + const addedTableNames = currentTableNames.filter((tableName) => !previousTableNames.has(tableName)); + const addedTablesContract: SchemaContract = { + tables: Object.fromEntries(addedTableNames.map((tableName) => [tableName, currentContract.tables[tableName]])), + indexes: currentContract.indexes.filter((index) => addedTableNames.includes(index.table)), + uniques: currentContract.uniques.filter((unique) => addedTableNames.includes(unique.table)), + foreignKeys: currentContract.foreignKeys.filter((foreignKey) => addedTableNames.includes(foreignKey.table)), + }; + const addedTableStatements = sortTableNamesForCreation(addedTablesContract) + .map((tableName) => buildCreateTableStatement(dialect, tableName, currentContract)); + + const addColumnStatements: string[] = []; + for (const tableName of currentTableNames) { + if (!previousTableNames.has(tableName)) { + continue; + } + + const previousColumns = previousContract.tables[tableName]?.columns ?? {}; + const currentColumns = currentContract.tables[tableName]?.columns ?? {}; + for (const [columnName, column] of Object.entries(currentColumns)) { + if (previousColumns[columnName]) { + continue; + } + addColumnStatements.push(buildAddColumnStatement(dialect, tableName, columnName, column)); + } + } + + const previousUniqueNames = new Set(previousContract.uniques.map((unique) => unique.name)); + const uniqueStatements = currentContract.uniques + .filter((unique) => !previousUniqueNames.has(unique.name)) + .slice() + .sort((left, right) => left.name.localeCompare(right.name, 'en')) + .map((unique) => buildUniqueIndexStatement(dialect, unique, currentContract, options)); + + const currentUniqueNames = new Set(currentContract.uniques.map((unique) => unique.name)); + const previousIndexNames = new Set(previousContract.indexes.map((index) => index.name)); + const indexStatements = currentContract.indexes + .filter((index) => !currentUniqueNames.has(index.name)) + .filter((index) => !previousIndexNames.has(index.name)) + .slice() + .sort((left, right) => left.name.localeCompare(right.name, 'en')) + .map((index) => buildIndexStatement(dialect, index, currentContract, options)); + + const statements = [...addedTableStatements, ...addColumnStatements, ...uniqueStatements, ...indexStatements]; + if (statements.length === 0) { + return `-- no schema changes detected for ${dialect}\n`; + } + + return `${statements.join(';\n')};\n`; +} + +export function generateDialectArtifacts( + contract: SchemaContract, + previousContract?: SchemaContract | null, +): GeneratedDialectArtifacts { + return { + mysqlBootstrap: generateBootstrapSql('mysql', contract), + postgresBootstrap: generateBootstrapSql('postgres', contract), + mysqlUpgrade: generateUpgradeSql('mysql', contract, previousContract), + postgresUpgrade: generateUpgradeSql('postgres', contract, previousContract), + }; +} + +export function writeDialectArtifactFiles( + contract: SchemaContract, + previousContract?: SchemaContract | null, +): GeneratedDialectArtifacts { + const artifacts = generateDialectArtifacts(contract, previousContract); + const artifactEntries = [ + ['mysql.bootstrap.sql', artifacts.mysqlBootstrap], + ['postgres.bootstrap.sql', artifacts.postgresBootstrap], + ['mysql.upgrade.sql', artifacts.mysqlUpgrade], + ['postgres.upgrade.sql', artifacts.postgresUpgrade], + ] as const; + + for (const [filename, content] of artifactEntries) { + const outputPath = resolveGeneratedArtifactPath(filename); + mkdirSync(dirname(outputPath), { recursive: true }); + writeFileSync(outputPath, content, 'utf8'); + } + + return artifacts; +} diff --git a/src/server/db/schemaContract.test.ts b/src/server/db/schemaContract.test.ts new file mode 100644 index 00000000..b3961fc2 --- /dev/null +++ b/src/server/db/schemaContract.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from 'vitest'; +import { buildSchemaContractFromSqliteMigrations } from './schemaContract.js'; + +describe('schema contract generation', () => { + it('captures the current schema shape from sqlite migrations', () => { + const contract = buildSchemaContractFromSqliteMigrations(); + + expect(contract.tables.sites.columns.status).toMatchObject({ + logicalType: 'text', + notNull: true, + primaryKey: false, + }); + expect(contract.tables.account_tokens.columns.token_group).toBeDefined(); + expect(contract.tables.account_tokens.columns.value_status).toMatchObject({ + logicalType: 'text', + notNull: true, + defaultValue: "'ready'", + }); + expect(contract.tables.site_disabled_models).toBeDefined(); + expect(contract.tables.downstream_api_keys).toBeDefined(); + expect(contract.tables.proxy_files).toBeDefined(); + expect(contract.tables.proxy_video_tasks).toBeDefined(); + expect(contract.tables.route_channels.columns.source_model).toBeDefined(); + expect(contract.tables.route_channels.columns.last_selected_at).toBeDefined(); + expect(contract.tables.route_channels.columns.consecutive_fail_count).toMatchObject({ + logicalType: 'integer', + notNull: true, + defaultValue: '0', + }); + expect(contract.tables.sites.columns.use_system_proxy).toMatchObject({ + logicalType: 'boolean', + defaultValue: 'false', + }); + expect(contract.tables.token_routes.columns.routing_strategy).toMatchObject({ + logicalType: 'text', + defaultValue: "'weighted'", + }); + expect(contract.indexes).toContainEqual( + expect.objectContaining({ name: 'sites_status_idx', table: 'sites', unique: false }), + ); + expect(contract.uniques).toContainEqual( + expect.objectContaining({ + name: 'site_disabled_models_site_model_unique', + table: 'site_disabled_models', + columns: ['site_id', 'model_name'], + }), + ); + expect(contract.uniques).toContainEqual( + expect.objectContaining({ + name: 'model_availability_account_model_unique', + table: 'model_availability', + columns: ['account_id', 'model_name'], + }), + ); + expect(contract.foreignKeys).toContainEqual( + expect.objectContaining({ + table: 'site_disabled_models', + columns: ['site_id'], + referencedTable: 'sites', + referencedColumns: ['id'], + }), + ); + expect(contract.foreignKeys).toContainEqual( + expect.objectContaining({ + table: 'route_channels', + columns: ['token_id'], + referencedTable: 'account_tokens', + referencedColumns: ['id'], + }), + ); + }); +}); diff --git a/src/server/db/schemaContract.ts b/src/server/db/schemaContract.ts new file mode 100644 index 00000000..00303a8d --- /dev/null +++ b/src/server/db/schemaContract.ts @@ -0,0 +1,272 @@ +import Database from 'better-sqlite3'; +import { mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { + normalizeLogicalColumnType, + normalizeSchemaMetadataDefaultValue, + type LogicalColumnType, +} from './schemaMetadata.js'; + +export type { LogicalColumnType } from './schemaMetadata.js'; + +export interface SchemaContractColumn { + logicalType: LogicalColumnType; + notNull: boolean; + defaultValue: string | null; + primaryKey: boolean; +} + +export interface SchemaContractTable { + columns: Record<string, SchemaContractColumn>; +} + +export interface SchemaContractIndex { + name: string; + table: string; + columns: string[]; + unique: boolean; +} + +export interface SchemaContractUnique { + name: string; + table: string; + columns: string[]; +} + +export interface SchemaContractForeignKey { + table: string; + columns: string[]; + referencedTable: string; + referencedColumns: string[]; + onDelete: string | null; +} + +export interface SchemaContract { + tables: Record<string, SchemaContractTable>; + indexes: SchemaContractIndex[]; + uniques: SchemaContractUnique[]; + foreignKeys: SchemaContractForeignKey[]; +} + +type TableInfoRow = { + name: string; + type: string; + notnull: number; + dflt_value: string | null; + pk: number; +}; + +type IndexListRow = { + name: string; + unique: number; + origin: string; + partial: number; +}; + +type IndexInfoRow = { + seqno: number; + cid: number; + name: string; +}; + +type ForeignKeyRow = { + id: number; + seq: number; + table: string; + from: string; + to: string; + on_update: string; + on_delete: string; + match: string; +}; + +function resolveDbDir(): string { + return dirname(fileURLToPath(import.meta.url)); +} + +export function resolveMigrationsFolder(): string { + return resolve(resolveDbDir(), '../../../drizzle'); +} + +export function resolveGeneratedSchemaContractPath(): string { + return resolve(resolveDbDir(), 'generated/schemaContract.json'); +} + +function resolveMigrationFiles(migrationsFolder: string): string[] { + return readdirSync(migrationsFolder) + .filter((entry) => entry.endsWith('.sql')) + .sort((left, right) => left.localeCompare(right, 'en')); +} + +function splitMigrationStatements(sqlText: string): string[] { + return sqlText + .split('--> statement-breakpoint') + .map((statement) => statement.trim()) + .filter((statement) => statement.length > 0); +} + +function applySqliteMigrations(sqlite: Database.Database, migrationsFolder: string): void { + for (const migrationFile of resolveMigrationFiles(migrationsFolder)) { + const sqlText = readFileSync(join(migrationsFolder, migrationFile), 'utf8'); + for (const statement of splitMigrationStatements(sqlText)) { + sqlite.exec(statement); + } + } +} + +function normalizeDefaultValue(defaultValue: string | null): string | null { + return normalizeSchemaMetadataDefaultValue(defaultValue); +} + +function normalizeLogicalType(columnName: string, declaredType: string, defaultValue: string | null): LogicalColumnType { + return normalizeLogicalColumnType({ + columnName, + declaredType, + defaultValue, + dialect: 'sqlite', + }); +} + +function readTables(sqlite: Database.Database): Record<string, SchemaContractTable> { + const tables = sqlite.prepare(` + SELECT name + FROM sqlite_master + WHERE type = 'table' + AND name NOT LIKE 'sqlite_%' + ORDER BY name ASC + `).all() as Array<{ name: string }>; + + const result: Record<string, SchemaContractTable> = {}; + + for (const table of tables) { + const rows = sqlite.prepare(`PRAGMA table_info("${table.name}")`).all() as TableInfoRow[]; + const columns = Object.fromEntries(rows.map((row) => { + const defaultValue = normalizeDefaultValue(row.dflt_value); + return [row.name, { + logicalType: normalizeLogicalType(row.name, row.type, defaultValue), + notNull: row.notnull === 1, + defaultValue, + primaryKey: row.pk > 0, + } satisfies SchemaContractColumn]; + })); + + result[table.name] = { columns }; + } + + return result; +} + +function readIndexes(sqlite: Database.Database, tables: Record<string, SchemaContractTable>): { + indexes: SchemaContractIndex[]; + uniques: SchemaContractUnique[]; +} { + const indexes: SchemaContractIndex[] = []; + const uniques: SchemaContractUnique[] = []; + + for (const tableName of Object.keys(tables).sort((left, right) => left.localeCompare(right, 'en'))) { + const rows = sqlite.prepare(`PRAGMA index_list("${tableName}")`).all() as IndexListRow[]; + for (const row of rows) { + if (!row.name || row.name.startsWith('sqlite_autoindex')) { + continue; + } + const indexColumns = (sqlite.prepare(`PRAGMA index_info("${row.name}")`).all() as IndexInfoRow[]) + .sort((left, right) => left.seqno - right.seqno) + .map((item) => item.name) + .filter((name) => !!name); + + const index: SchemaContractIndex = { + name: row.name, + table: tableName, + columns: indexColumns, + unique: row.unique === 1, + }; + indexes.push(index); + + if (row.unique === 1) { + uniques.push({ + name: row.name, + table: tableName, + columns: indexColumns, + }); + } + } + } + + indexes.sort((left, right) => left.name.localeCompare(right.name, 'en')); + uniques.sort((left, right) => left.name.localeCompare(right.name, 'en')); + + return { indexes, uniques }; +} + +function readForeignKeys(sqlite: Database.Database, tables: Record<string, SchemaContractTable>): SchemaContractForeignKey[] { + const foreignKeys: SchemaContractForeignKey[] = []; + + for (const tableName of Object.keys(tables).sort((left, right) => left.localeCompare(right, 'en'))) { + const rows = sqlite.prepare(`PRAGMA foreign_key_list("${tableName}")`).all() as ForeignKeyRow[]; + const grouped = new Map<number, SchemaContractForeignKey>(); + + for (const row of rows) { + const existing = grouped.get(row.id); + if (existing) { + existing.columns.push(row.from); + existing.referencedColumns.push(row.to); + continue; + } + + grouped.set(row.id, { + table: tableName, + columns: [row.from], + referencedTable: row.table, + referencedColumns: [row.to], + onDelete: row.on_delete || null, + }); + } + + foreignKeys.push(...grouped.values()); + } + + foreignKeys.sort((left, right) => { + const leftKey = `${left.table}:${left.columns.join(',')}`; + const rightKey = `${right.table}:${right.columns.join(',')}`; + return leftKey.localeCompare(rightKey, 'en'); + }); + + return foreignKeys; +} + +export function buildSchemaContractFromSqliteMigrations(migrationsFolder = resolveMigrationsFolder()): SchemaContract { + const sqlite = new Database(':memory:'); + sqlite.pragma('foreign_keys = ON'); + + try { + applySqliteMigrations(sqlite, migrationsFolder); + const tables = readTables(sqlite); + const { indexes, uniques } = readIndexes(sqlite, tables); + const foreignKeys = readForeignKeys(sqlite, tables); + + return { + tables, + indexes, + uniques, + foreignKeys, + }; + } finally { + sqlite.close(); + } +} + +export function writeSchemaContractFile(outputPath = resolveGeneratedSchemaContractPath()): SchemaContract { + const contract = buildSchemaContractFromSqliteMigrations(); + mkdirSync(dirname(outputPath), { recursive: true }); + writeFileSync(outputPath, `${JSON.stringify(contract, null, 2)}\n`, 'utf8'); + return contract; +} + +export const __schemaContractTestUtils = { + splitMigrationStatements, + normalizeDefaultValue, + normalizeLogicalType, + resolveMigrationsFolder, +}; diff --git a/src/server/db/schemaIntrospection.test.ts b/src/server/db/schemaIntrospection.test.ts new file mode 100644 index 00000000..7f578a5d --- /dev/null +++ b/src/server/db/schemaIntrospection.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest'; +import { normalizeDefaultValue, normalizeSqlType, readMySqlField } from './schemaIntrospection.js'; + +describe('schema introspection normalization', () => { + it('normalizes booleans consistently across dialects', () => { + expect(normalizeSqlType('sqlite', 'INTEGER', 'use_system_proxy')).toBe('boolean'); + expect(normalizeSqlType('mysql', 'tinyint', 'use_system_proxy')).toBe('boolean'); + expect(normalizeSqlType('postgres', 'boolean', 'use_system_proxy')).toBe('boolean'); + }); + + it('normalizes common default values', () => { + expect(normalizeDefaultValue("DEFAULT 'active'")).toBe("'active'"); + expect(normalizeDefaultValue('DEFAULT FALSE')).toBe('false'); + expect(normalizeDefaultValue("datetime('now')")).toBe("datetime('now')"); + }); + + it('reads mysql information_schema fields regardless of casing', () => { + expect(readMySqlField({ COLUMN_TYPE: 'varchar(191)' }, 'column_type')).toBe('varchar(191)'); + expect(readMySqlField({ column_type: 'text' }, 'column_type')).toBe('text'); + expect(readMySqlField({ Table_Name: 'settings' }, 'table_name')).toBe('settings'); + }); +}); diff --git a/src/server/db/schemaIntrospection.ts b/src/server/db/schemaIntrospection.ts new file mode 100644 index 00000000..81a64722 --- /dev/null +++ b/src/server/db/schemaIntrospection.ts @@ -0,0 +1,875 @@ +import Database from 'better-sqlite3'; +import { mkdtempSync, mkdirSync, readFileSync, readdirSync } from 'node:fs'; +import mysql from 'mysql2/promise'; +import { tmpdir } from 'node:os'; +import pg from 'pg'; +import { join, resolve } from 'node:path'; +import { randomUUID } from 'node:crypto'; +import { + generateBootstrapSql, + generateUpgradeSql, + resolveGeneratedArtifactPath, +} from './schemaArtifactGenerator.js'; +import type { + SchemaContract, + SchemaContractColumn, + SchemaContractForeignKey, + SchemaContractIndex, + SchemaContractTable, + SchemaContractUnique, +} from './schemaContract.js'; +import { resolveMigrationsFolder } from './schemaContract.js'; +import { installPostgresJsonTextParsers } from './postgresJsonTextParsers.js'; +import { + normalizeLogicalColumnType, + type LogicalColumnType, +} from './schemaMetadata.js'; + +export type SchemaIntrospectionDialect = 'sqlite' | 'mysql' | 'postgres'; + +export interface SchemaIntrospectionInput { + dialect: SchemaIntrospectionDialect; + connectionString: string; + ssl?: boolean; +} + +export interface MaterializeFreshSchemaOptions { + connectionString?: string; + ssl?: boolean; +} + +export interface ApplyContractFixtureThenUpgradeOptions extends MaterializeFreshSchemaOptions {} + +type SqliteTableInfoRow = { + name: string; + type: string; + notnull: number; + dflt_value: string | null; + pk: number; +}; + +type SqliteIndexListRow = { + name: string; + unique: number; +}; + +type SqliteIndexInfoRow = { + seqno: number; + name: string; +}; + +type SqliteForeignKeyRow = { + id: number; + table: string; + from: string; + to: string; + on_delete: string; +}; + +type MySqlColumnRow = { + table_name: string; + column_name: string; + data_type: string; + column_type: string; + is_nullable: 'YES' | 'NO'; + column_default: string | null; +}; + +type MySqlPrimaryKeyRow = { + table_name: string; + column_name: string; +}; + +type MySqlIndexRow = { + table_name: string; + index_name: string; + column_name: string; + non_unique: number; +}; + +type MySqlForeignKeyRow = { + table_name: string; + constraint_name: string; + column_name: string; + referenced_table_name: string; + referenced_column_name: string; + delete_rule: string | null; +}; + +type PostgresColumnRow = { + table_name: string; + column_name: string; + data_type: string; + udt_name: string; + is_nullable: 'YES' | 'NO'; + column_default: string | null; +}; + +type PostgresPrimaryKeyRow = { + table_name: string; + column_name: string; +}; + +type PostgresIndexRow = { + table_name: string; + index_name: string; + is_unique: boolean; + column_name: string; +}; + +type PostgresForeignKeyRow = { + table_name: string; + constraint_name: string; + column_name: string; + referenced_table_name: string; + referenced_column_name: string; + delete_rule: string | null; +}; + +export function readMySqlField<T>(row: Record<string, unknown>, field: string): T | undefined { + const exact = row[field]; + if (exact !== undefined) return exact as T; + + const upper = row[field.toUpperCase()]; + if (upper !== undefined) return upper as T; + + const lower = row[field.toLowerCase()]; + if (lower !== undefined) return lower as T; + + const matchedKey = Object.keys(row).find((key) => key.toLowerCase() === field.toLowerCase()); + return matchedKey ? row[matchedKey] as T : undefined; +} + +function splitMigrationStatements(sqlText: string): string[] { + return sqlText + .split('--> statement-breakpoint') + .map((statement) => statement.trim()) + .filter((statement) => statement.length > 0); +} + +function splitSqlStatements(sqlText: string): string[] { + const withoutCommentLines = sqlText + .split(/\r?\n/g) + .filter((line) => !line.trim().startsWith('--')) + .join('\n'); + + return withoutCommentLines + .split(/;\s*(?:\r?\n|$)/g) + .map((statement) => statement.trim()) + .filter((statement) => statement.length > 0); +} + +function hasBalancedParentheses(value: string): boolean { + let depth = 0; + for (const char of value) { + if (char === '(') depth += 1; + if (char === ')') depth -= 1; + if (depth < 0) return false; + } + return depth === 0; +} + +function unwrapSurroundingParentheses(value: string): string { + let normalized = value.trim(); + while (normalized.startsWith('(') && normalized.endsWith(')')) { + const inner = normalized.slice(1, -1).trim(); + if (!hasBalancedParentheses(inner)) { + break; + } + normalized = inner; + } + return normalized; +} + +function normalizeDeleteRule(value: string | null | undefined): string | null { + if (!value) return null; + return value.trim().replace(/\s+/g, ' ').toUpperCase(); +} + +export function normalizeSqlType( + dialect: SchemaIntrospectionDialect, + declaredType: string, + columnName: string, + rawDefaultValue: string | null = null, +): LogicalColumnType { + return normalizeLogicalColumnType({ + dialect, + declaredType, + columnName, + defaultValue: normalizeDefaultValue(rawDefaultValue), + }); +} + +function normalizeDefaultValueForColumn( + rawDefaultValue: string | null | undefined, + logicalType: LogicalColumnType | null, +): string | null { + if (rawDefaultValue == null) return null; + + let normalized = String(rawDefaultValue).trim(); + if (!normalized) return null; + + normalized = normalized.replace(/^default\s+/i, '').trim(); + normalized = unwrapSurroundingParentheses(normalized); + normalized = normalized.replace(/::[\w\s.\[\]"]+/g, '').trim(); + normalized = unwrapSurroundingParentheses(normalized); + + const lowered = normalized.toLowerCase(); + if (lowered === 'null') return null; + + if (logicalType === 'datetime' || lowered === 'current_timestamp' || lowered === 'current_timestamp()' || lowered === 'now()' || lowered.includes("datetime('now')")) { + return "datetime('now')"; + } + + if (logicalType === 'boolean') { + if (lowered === '1' || lowered === 'true' || lowered === "b'1'") return 'true'; + if (lowered === '0' || lowered === 'false' || lowered === "b'0'") return 'false'; + } + + if (lowered === 'true' || lowered === 'false') return lowered; + if (/^-?\d+(?:\.\d+)?$/.test(normalized)) return normalized; + if (/^'.*'$/.test(normalized)) return normalized; + if (/^[a-z_][a-z0-9_]*$/i.test(normalized)) return `'${normalized}'`; + return normalized; +} + +export function normalizeDefaultValue(rawDefaultValue: string | null | undefined): string | null { + return normalizeDefaultValueForColumn(rawDefaultValue, null); +} + +function sortForeignKeys(foreignKeys: SchemaContractForeignKey[]): SchemaContractForeignKey[] { + return foreignKeys.sort((left, right) => { + const leftKey = `${left.table}:${left.columns.join(',')}`; + const rightKey = `${right.table}:${right.columns.join(',')}`; + return leftKey.localeCompare(rightKey, 'en'); + }); +} + +function buildSqliteTables(sqlite: Database.Database): Record<string, SchemaContractTable> { + const tableRows = sqlite.prepare(` + SELECT name + FROM sqlite_master + WHERE type = 'table' + AND name NOT LIKE 'sqlite_%' + ORDER BY name ASC + `).all() as Array<{ name: string }>; + + const tables: Record<string, SchemaContractTable> = {}; + for (const { name: tableName } of tableRows) { + const rows = sqlite.prepare(`PRAGMA table_info("${tableName}")`).all() as SqliteTableInfoRow[]; + const columns = Object.fromEntries(rows.map((row) => { + const logicalType = normalizeSqlType('sqlite', row.type, row.name, row.dflt_value); + return [row.name, { + logicalType, + notNull: row.notnull === 1, + defaultValue: normalizeDefaultValueForColumn(row.dflt_value, logicalType), + primaryKey: row.pk > 0, + } satisfies SchemaContractColumn]; + })); + tables[tableName] = { columns }; + } + return tables; +} + +function buildSqliteIndexes(sqlite: Database.Database, tables: Record<string, SchemaContractTable>): { + indexes: SchemaContractIndex[]; + uniques: SchemaContractUnique[]; +} { + const indexes: SchemaContractIndex[] = []; + const uniques: SchemaContractUnique[] = []; + + for (const tableName of Object.keys(tables).sort((left, right) => left.localeCompare(right, 'en'))) { + const rows = sqlite.prepare(`PRAGMA index_list("${tableName}")`).all() as SqliteIndexListRow[]; + for (const row of rows) { + if (!row.name || row.name.startsWith('sqlite_autoindex')) { + continue; + } + + const columns = (sqlite.prepare(`PRAGMA index_info("${row.name}")`).all() as SqliteIndexInfoRow[]) + .sort((left, right) => left.seqno - right.seqno) + .map((item) => item.name) + .filter(Boolean); + + indexes.push({ + name: row.name, + table: tableName, + columns, + unique: row.unique === 1, + }); + + if (row.unique === 1) { + uniques.push({ + name: row.name, + table: tableName, + columns, + }); + } + } + } + + indexes.sort((left, right) => left.name.localeCompare(right.name, 'en')); + uniques.sort((left, right) => left.name.localeCompare(right.name, 'en')); + return { indexes, uniques }; +} + +function buildSqliteForeignKeys(sqlite: Database.Database, tables: Record<string, SchemaContractTable>): SchemaContractForeignKey[] { + const foreignKeys: SchemaContractForeignKey[] = []; + + for (const tableName of Object.keys(tables).sort((left, right) => left.localeCompare(right, 'en'))) { + const rows = sqlite.prepare(`PRAGMA foreign_key_list("${tableName}")`).all() as SqliteForeignKeyRow[]; + const grouped = new Map<number, SchemaContractForeignKey>(); + + for (const row of rows) { + const existing = grouped.get(row.id); + if (existing) { + existing.columns.push(row.from); + existing.referencedColumns.push(row.to); + continue; + } + + grouped.set(row.id, { + table: tableName, + columns: [row.from], + referencedTable: row.table, + referencedColumns: [row.to], + onDelete: normalizeDeleteRule(row.on_delete), + }); + } + + foreignKeys.push(...grouped.values()); + } + + return sortForeignKeys(foreignKeys); +} + +async function introspectSqliteSchema(connectionString: string): Promise<SchemaContract> { + const sqlitePath = connectionString === ':memory:' ? ':memory:' : resolve(connectionString); + const sqlite = new Database(sqlitePath, { readonly: true }); + sqlite.pragma('foreign_keys = ON'); + + try { + const tables = buildSqliteTables(sqlite); + const { indexes, uniques } = buildSqliteIndexes(sqlite, tables); + const foreignKeys = buildSqliteForeignKeys(sqlite, tables); + return { tables, indexes, uniques, foreignKeys }; + } finally { + sqlite.close(); + } +} + +async function introspectMySqlSchema(input: SchemaIntrospectionInput): Promise<SchemaContract> { + const connectionOptions: mysql.ConnectionOptions = { uri: input.connectionString }; + if (input.ssl) { + connectionOptions.ssl = { rejectUnauthorized: false }; + } + const connection = await mysql.createConnection(connectionOptions); + + try { + const [tableRows] = await connection.query(` + SELECT table_name AS table_name + FROM information_schema.tables + WHERE table_schema = DATABASE() + AND table_type = 'BASE TABLE' + ORDER BY table_name ASC + `); + const tableNames = (tableRows as Array<Record<string, unknown>>) + .map((row) => readMySqlField<string>(row, 'table_name')) + .filter((tableName): tableName is string => typeof tableName === 'string'); + + const [primaryKeyRows] = await connection.query(` + SELECT + kcu.table_name AS table_name, + kcu.column_name AS column_name + FROM information_schema.table_constraints tc + JOIN information_schema.key_column_usage kcu + ON tc.constraint_schema = kcu.constraint_schema + AND tc.constraint_name = kcu.constraint_name + AND tc.table_name = kcu.table_name + WHERE tc.table_schema = DATABASE() + AND tc.constraint_type = 'PRIMARY KEY' + `); + const primaryKeys = new Set((primaryKeyRows as Array<Record<string, unknown>>).map((row) => { + const tableName = readMySqlField<string>(row, 'table_name'); + const columnName = readMySqlField<string>(row, 'column_name'); + return tableName && columnName ? `${tableName}.${columnName}` : null; + }).filter((value): value is string => value !== null)); + + const [columnRows] = await connection.query(` + SELECT + table_name AS table_name, + column_name AS column_name, + data_type AS data_type, + column_type AS column_type, + is_nullable AS is_nullable, + column_default AS column_default + FROM information_schema.columns + WHERE table_schema = DATABASE() + ORDER BY table_name ASC, ordinal_position ASC + `); + + const tableMap = new Map<string, SchemaContractTable>(); + for (const tableName of tableNames) { + tableMap.set(tableName, { columns: {} }); + } + + for (const row of columnRows as Array<Record<string, unknown>>) { + const tableName = readMySqlField<string>(row, 'table_name'); + const columnName = readMySqlField<string>(row, 'column_name'); + const declaredType = readMySqlField<string>(row, 'column_type') || readMySqlField<string>(row, 'data_type'); + const isNullable = readMySqlField<string>(row, 'is_nullable'); + const columnDefault = readMySqlField<string | null>(row, 'column_default') ?? null; + if (!tableName || !columnName || !declaredType) { + continue; + } + + const logicalType = normalizeSqlType('mysql', declaredType, columnName, columnDefault); + tableMap.get(tableName)!.columns[columnName] = { + logicalType, + notNull: isNullable === 'NO', + defaultValue: primaryKeys.has(`${tableName}.${columnName}`) + ? null + : normalizeDefaultValueForColumn(columnDefault, logicalType), + primaryKey: primaryKeys.has(`${tableName}.${columnName}`), + }; + } + + const tables = Object.fromEntries(tableNames.map((tableName) => [tableName, tableMap.get(tableName)!])); + + const [indexRows] = await connection.query(` + SELECT + table_name AS table_name, + index_name AS index_name, + column_name AS column_name, + non_unique AS non_unique + FROM information_schema.statistics + WHERE table_schema = DATABASE() + AND index_name <> 'PRIMARY' + ORDER BY table_name ASC, index_name ASC, seq_in_index ASC + `); + const indexGroups = new Map<string, SchemaContractIndex>(); + for (const row of indexRows as Array<Record<string, unknown>>) { + const tableName = readMySqlField<string>(row, 'table_name'); + const indexName = readMySqlField<string>(row, 'index_name'); + const columnName = readMySqlField<string>(row, 'column_name'); + const nonUnique = readMySqlField<number>(row, 'non_unique'); + if (!tableName || !indexName || !columnName) { + continue; + } + + const key = `${tableName}:${indexName}`; + const existing = indexGroups.get(key); + if (existing) { + existing.columns.push(columnName); + continue; + } + indexGroups.set(key, { + name: indexName, + table: tableName, + columns: [columnName], + unique: Number(nonUnique) === 0, + }); + } + + const indexes = [...indexGroups.values()].sort((left, right) => left.name.localeCompare(right.name, 'en')); + const uniques = indexes + .filter((index) => index.unique) + .map((index) => ({ name: index.name, table: index.table, columns: [...index.columns] })) + .sort((left, right) => left.name.localeCompare(right.name, 'en')); + + const [foreignKeyRows] = await connection.query(` + SELECT + kcu.table_name AS table_name, + kcu.constraint_name AS constraint_name, + kcu.column_name AS column_name, + kcu.referenced_table_name AS referenced_table_name, + kcu.referenced_column_name AS referenced_column_name, + rc.delete_rule AS delete_rule + FROM information_schema.key_column_usage kcu + JOIN information_schema.referential_constraints rc + ON rc.constraint_schema = kcu.constraint_schema + AND rc.constraint_name = kcu.constraint_name + WHERE kcu.table_schema = DATABASE() + AND kcu.referenced_table_name IS NOT NULL + ORDER BY kcu.table_name ASC, kcu.constraint_name ASC, kcu.ordinal_position ASC + `); + const foreignKeyGroups = new Map<string, SchemaContractForeignKey>(); + for (const row of foreignKeyRows as Array<Record<string, unknown>>) { + const tableName = readMySqlField<string>(row, 'table_name'); + const constraintName = readMySqlField<string>(row, 'constraint_name'); + const columnName = readMySqlField<string>(row, 'column_name'); + const referencedTableName = readMySqlField<string>(row, 'referenced_table_name'); + const referencedColumnName = readMySqlField<string>(row, 'referenced_column_name'); + const deleteRule = readMySqlField<string | null>(row, 'delete_rule') ?? null; + if (!tableName || !constraintName || !columnName || !referencedTableName || !referencedColumnName) { + continue; + } + + const key = `${tableName}:${constraintName}`; + const existing = foreignKeyGroups.get(key); + if (existing) { + existing.columns.push(columnName); + existing.referencedColumns.push(referencedColumnName); + continue; + } + foreignKeyGroups.set(key, { + table: tableName, + columns: [columnName], + referencedTable: referencedTableName, + referencedColumns: [referencedColumnName], + onDelete: normalizeDeleteRule(deleteRule), + }); + } + + return { + tables, + indexes, + uniques, + foreignKeys: sortForeignKeys([...foreignKeyGroups.values()]), + }; + } finally { + await connection.end(); + } +} + +async function introspectPostgresSchema(input: SchemaIntrospectionInput): Promise<SchemaContract> { + const clientOptions: pg.ClientConfig = { connectionString: input.connectionString }; + if (input.ssl) { + clientOptions.ssl = { rejectUnauthorized: false }; + } + installPostgresJsonTextParsers(); + const client = new pg.Client(clientOptions); + await client.connect(); + + try { + const tableResult = await client.query(` + SELECT table_name + FROM information_schema.tables + WHERE table_schema = current_schema() + AND table_type = 'BASE TABLE' + ORDER BY table_name ASC + `); + const tableNames = tableResult.rows.map((row) => String(row.table_name)); + + const primaryKeyResult = await client.query(` + SELECT kcu.table_name, kcu.column_name + FROM information_schema.table_constraints tc + JOIN information_schema.key_column_usage kcu + ON tc.constraint_schema = kcu.constraint_schema + AND tc.constraint_name = kcu.constraint_name + AND tc.table_name = kcu.table_name + WHERE tc.table_schema = current_schema() + AND tc.constraint_type = 'PRIMARY KEY' + `); + const primaryKeys = new Set((primaryKeyResult.rows as PostgresPrimaryKeyRow[]).map((row) => `${row.table_name}.${row.column_name}`)); + + const columnResult = await client.query(` + SELECT table_name, column_name, data_type, udt_name, is_nullable, column_default + FROM information_schema.columns + WHERE table_schema = current_schema() + ORDER BY table_name ASC, ordinal_position ASC + `); + const tableMap = new Map<string, SchemaContractTable>(); + for (const tableName of tableNames) { + tableMap.set(tableName, { columns: {} }); + } + + for (const row of columnResult.rows as PostgresColumnRow[]) { + const logicalType = normalizeSqlType('postgres', `${row.data_type} ${row.udt_name}`, row.column_name, row.column_default); + tableMap.get(row.table_name)!.columns[row.column_name] = { + logicalType, + notNull: row.is_nullable === 'NO', + defaultValue: primaryKeys.has(`${row.table_name}.${row.column_name}`) + ? null + : normalizeDefaultValueForColumn(row.column_default, logicalType), + primaryKey: primaryKeys.has(`${row.table_name}.${row.column_name}`), + }; + } + + const tables = Object.fromEntries(tableNames.map((tableName) => [tableName, tableMap.get(tableName)!])); + + const indexResult = await client.query(` + SELECT + t.relname AS table_name, + i.relname AS index_name, + ix.indisunique AS is_unique, + a.attname AS column_name + FROM pg_class t + JOIN pg_namespace ns ON ns.oid = t.relnamespace + JOIN pg_index ix ON t.oid = ix.indrelid + JOIN pg_class i ON i.oid = ix.indexrelid + JOIN LATERAL unnest(ix.indkey) WITH ORDINALITY AS ord(attnum, n) ON TRUE + JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = ord.attnum + WHERE ns.nspname = current_schema() + AND t.relkind = 'r' + AND NOT ix.indisprimary + ORDER BY t.relname ASC, i.relname ASC, ord.n ASC + `); + const indexGroups = new Map<string, SchemaContractIndex>(); + for (const row of indexResult.rows as PostgresIndexRow[]) { + const key = `${row.table_name}:${row.index_name}`; + const existing = indexGroups.get(key); + if (existing) { + existing.columns.push(row.column_name); + continue; + } + indexGroups.set(key, { + name: row.index_name, + table: row.table_name, + columns: [row.column_name], + unique: !!row.is_unique, + }); + } + + const indexes = [...indexGroups.values()].sort((left, right) => left.name.localeCompare(right.name, 'en')); + const uniques = indexes + .filter((index) => index.unique) + .map((index) => ({ name: index.name, table: index.table, columns: [...index.columns] })) + .sort((left, right) => left.name.localeCompare(right.name, 'en')); + + const foreignKeyResult = await client.query(` + SELECT + tc.table_name, + tc.constraint_name, + kcu.column_name, + ccu.table_name AS referenced_table_name, + ccu.column_name AS referenced_column_name, + rc.delete_rule + FROM information_schema.table_constraints tc + JOIN information_schema.key_column_usage kcu + ON tc.constraint_schema = kcu.constraint_schema + AND tc.constraint_name = kcu.constraint_name + AND tc.table_name = kcu.table_name + JOIN information_schema.referential_constraints rc + ON tc.constraint_schema = rc.constraint_schema + AND tc.constraint_name = rc.constraint_name + JOIN information_schema.constraint_column_usage ccu + ON rc.unique_constraint_schema = ccu.constraint_schema + AND rc.unique_constraint_name = ccu.constraint_name + WHERE tc.table_schema = current_schema() + AND tc.constraint_type = 'FOREIGN KEY' + ORDER BY tc.table_name ASC, tc.constraint_name ASC, kcu.ordinal_position ASC + `); + const foreignKeyGroups = new Map<string, SchemaContractForeignKey>(); + for (const row of foreignKeyResult.rows as PostgresForeignKeyRow[]) { + const key = `${row.table_name}:${row.constraint_name}`; + const existing = foreignKeyGroups.get(key); + if (existing) { + existing.columns.push(row.column_name); + existing.referencedColumns.push(row.referenced_column_name); + continue; + } + foreignKeyGroups.set(key, { + table: row.table_name, + columns: [row.column_name], + referencedTable: row.referenced_table_name, + referencedColumns: [row.referenced_column_name], + onDelete: normalizeDeleteRule(row.delete_rule), + }); + } + + return { + tables, + indexes, + uniques, + foreignKeys: sortForeignKeys([...foreignKeyGroups.values()]), + }; + } finally { + await client.end(); + } +} + +export async function introspectLiveSchema(input: SchemaIntrospectionInput): Promise<SchemaContract> { + if (input.dialect === 'sqlite') { + return introspectSqliteSchema(input.connectionString); + } + if (input.dialect === 'mysql') { + return introspectMySqlSchema(input); + } + return introspectPostgresSchema(input); +} + +function readBootstrapSql(dialect: Exclude<SchemaIntrospectionDialect, 'sqlite'>): string { + const filename = dialect === 'mysql' ? 'mysql.bootstrap.sql' : 'postgres.bootstrap.sql'; + return readFileSync(resolveGeneratedArtifactPath(filename), 'utf8'); +} + +async function resetMySqlSchema(connection: mysql.Connection): Promise<void> { + await connection.query('SET FOREIGN_KEY_CHECKS = 0'); + const [rows] = await connection.query(` + SELECT table_name AS table_name + FROM information_schema.tables + WHERE table_schema = DATABASE() + AND table_type = 'BASE TABLE' + `); + for (const row of rows as Array<Record<string, unknown>>) { + const tableName = readMySqlField<string>(row, 'table_name'); + if (!tableName) { + continue; + } + await connection.query(`DROP TABLE IF EXISTS \`${tableName}\``); + } + await connection.query('SET FOREIGN_KEY_CHECKS = 1'); +} + +async function resetPostgresSchema(client: pg.Client): Promise<void> { + const result = await client.query(` + SELECT tablename + FROM pg_tables + WHERE schemaname = current_schema() + ORDER BY tablename ASC + `); + for (const row of result.rows as Array<{ tablename: string }>) { + await client.query(`DROP TABLE IF EXISTS "${row.tablename}" CASCADE`); + } +} + +function applySqliteMigrations(sqlite: Database.Database): void { + const migrationsFolder = resolveMigrationsFolder(); + const migrationFiles = readdirSync(migrationsFolder) + .filter((entry) => entry.endsWith('.sql')) + .sort((left, right) => left.localeCompare(right, 'en')); + + for (const migrationFile of migrationFiles) { + const sqlText = readFileSync(join(migrationsFolder, migrationFile), 'utf8'); + for (const statement of splitMigrationStatements(sqlText)) { + sqlite.exec(statement); + } + } +} + +function createTemporarySqlitePath(): string { + const tempDir = mkdtempSync(join(tmpdir(), 'metapi-schema-parity-')); + return resolve(tempDir, `${randomUUID()}.db`); +} + +function applySqliteStatements(sqlitePath: string, statements: string[]): void { + const sqlite = new Database(sqlitePath); + sqlite.pragma('foreign_keys = ON'); + try { + for (const statement of statements) { + sqlite.exec(statement); + } + } finally { + sqlite.close(); + } +} + +async function applyMySqlStatements( + connectionString: string, + ssl: boolean | undefined, + statements: string[], + resetSchema = false, +): Promise<void> { + const connectionOptions: mysql.ConnectionOptions = { uri: connectionString }; + if (ssl) { + connectionOptions.ssl = { rejectUnauthorized: false }; + } + const connection = await mysql.createConnection(connectionOptions); + try { + if (resetSchema) { + await resetMySqlSchema(connection); + } + for (const statement of statements) { + await connection.query(statement); + } + } finally { + await connection.end(); + } +} + +async function applyPostgresStatements( + connectionString: string, + ssl: boolean | undefined, + statements: string[], + resetSchema = false, +): Promise<void> { + const clientOptions: pg.ClientConfig = { connectionString }; + if (ssl) { + clientOptions.ssl = { rejectUnauthorized: false }; + } + installPostgresJsonTextParsers(); + const client = new pg.Client(clientOptions); + await client.connect(); + try { + if (resetSchema) { + await resetPostgresSchema(client); + } + for (const statement of statements) { + await client.query(statement); + } + } finally { + await client.end(); + } +} + +export async function materializeFreshSchema( + dialect: SchemaIntrospectionDialect, + options: MaterializeFreshSchemaOptions = {}, +): Promise<string> { + if (dialect === 'sqlite') { + const sqlitePath = createTemporarySqlitePath(); + const sqlite = new Database(sqlitePath); + sqlite.pragma('foreign_keys = ON'); + try { + applySqliteMigrations(sqlite); + } finally { + sqlite.close(); + } + return sqlitePath; + } + + if (!options.connectionString) { + throw new Error(`connectionString is required to materialize ${dialect} parity schema`); + } + + const bootstrapStatements = splitSqlStatements(readBootstrapSql(dialect)); + if (dialect === 'mysql') { + await applyMySqlStatements(options.connectionString, options.ssl, bootstrapStatements, true); + return options.connectionString; + } + + await applyPostgresStatements(options.connectionString, options.ssl, bootstrapStatements, true); + return options.connectionString; +} + +export async function applyContractFixtureThenUpgrade( + dialect: SchemaIntrospectionDialect, + baselineContract: SchemaContract, + currentContract: SchemaContract, + options: ApplyContractFixtureThenUpgradeOptions = {}, +): Promise<string> { + const bootstrapStatements = splitSqlStatements(generateBootstrapSql(dialect, baselineContract)); + const upgradeStatements = splitSqlStatements(generateUpgradeSql(dialect, currentContract, baselineContract)); + + if (dialect === 'sqlite') { + const sqlitePath = createTemporarySqlitePath(); + applySqliteStatements(sqlitePath, bootstrapStatements); + if (upgradeStatements.length > 0) { + applySqliteStatements(sqlitePath, upgradeStatements); + } + return sqlitePath; + } + + if (!options.connectionString) { + throw new Error(`connectionString is required to upgrade ${dialect} parity schema`); + } + + if (dialect === 'mysql') { + await applyMySqlStatements(options.connectionString, options.ssl, bootstrapStatements, true); + if (upgradeStatements.length > 0) { + await applyMySqlStatements(options.connectionString, options.ssl, upgradeStatements, false); + } + return options.connectionString; + } + + await applyPostgresStatements(options.connectionString, options.ssl, bootstrapStatements, true); + if (upgradeStatements.length > 0) { + await applyPostgresStatements(options.connectionString, options.ssl, upgradeStatements, false); + } + return options.connectionString; +} + +export const __schemaIntrospectionTestUtils = { + splitMigrationStatements, + splitSqlStatements, +}; diff --git a/src/server/db/schemaMetadata.architecture.test.ts b/src/server/db/schemaMetadata.architecture.test.ts new file mode 100644 index 00000000..6a765381 --- /dev/null +++ b/src/server/db/schemaMetadata.architecture.test.ts @@ -0,0 +1,22 @@ +import { readFileSync } from 'node:fs'; +import { describe, expect, it } from 'vitest'; + +function readSource(relativePath: string): string { + return readFileSync(new URL(relativePath, import.meta.url), 'utf8'); +} + +describe('schema metadata architecture boundaries', () => { + it('keeps logical column type heuristics in a shared schemaMetadata helper', () => { + const contractSource = readSource('./schemaContract.ts'); + const introspectionSource = readSource('./schemaIntrospection.ts'); + + expect(contractSource).toContain("from './schemaMetadata.js'"); + expect(introspectionSource).toContain("from './schemaMetadata.js'"); + + for (const source of [contractSource, introspectionSource]) { + expect(source).not.toContain('function isBooleanLikeColumn'); + expect(source).not.toContain('function isDateTimeLikeColumn'); + expect(source).not.toContain('function isJsonLikeColumn'); + } + }); +}); diff --git a/src/server/db/schemaMetadata.ts b/src/server/db/schemaMetadata.ts new file mode 100644 index 00000000..c630ea82 --- /dev/null +++ b/src/server/db/schemaMetadata.ts @@ -0,0 +1,139 @@ +export type LogicalColumnType = + | 'integer' + | 'real' + | 'text' + | 'boolean' + | 'datetime' + | 'json'; + +export type SchemaMetadataDialect = 'sqlite' | 'mysql' | 'postgres'; + +function isBooleanLikeColumn(columnName: string, defaultValue: string | null): boolean { + const normalizedColumn = columnName.toLowerCase(); + const normalizedDefault = (defaultValue || '').trim().toLowerCase(); + if (normalizedDefault === 'true' || normalizedDefault === 'false') { + return true; + } + return normalizedColumn.startsWith('is_') + || normalizedColumn.startsWith('use_') + || normalizedColumn.startsWith('has_') + || normalizedColumn.endsWith('_enabled') + || normalizedColumn.endsWith('_available') + || normalizedColumn === 'read' + || normalizedColumn === 'enabled' + || normalizedColumn === 'available' + || normalizedColumn === 'manual_override'; +} + +function isDateTimeLikeColumn(columnName: string, defaultValue: string | null): boolean { + const normalizedColumn = columnName.toLowerCase(); + const normalizedDefault = (defaultValue || '').toLowerCase(); + return normalizedColumn.endsWith('_at') + || normalizedColumn.endsWith('_until') + || normalizedColumn.endsWith('_refresh') + || normalizedDefault.includes('datetime(') + || normalizedDefault.includes('current_timestamp') + || normalizedDefault.includes('now()'); +} + +function isJsonLikeColumn(columnName: string): boolean { + const normalizedColumn = columnName.toLowerCase(); + return normalizedColumn.endsWith('_json') + || normalizedColumn.includes('snapshot') + || normalizedColumn.includes('mapping') + || normalizedColumn.includes('headers') + || normalizedColumn.includes('config') + || normalizedColumn.includes('details') + || normalizedColumn.includes('meta') + || normalizedColumn.includes('models') + || normalizedColumn.includes('route_ids') + || normalizedColumn.includes('multipliers'); +} + +export function normalizeSchemaMetadataDefaultValue(defaultValue: string | null | undefined): string | null { + if (defaultValue == null) return null; + return String(defaultValue).trim() || null; +} + +export function normalizeLogicalColumnType(input: { + declaredType: string; + columnName: string; + defaultValue?: string | null; + dialect?: SchemaMetadataDialect; +}): LogicalColumnType { + const normalizedType = input.declaredType.trim().toLowerCase(); + const normalizedDefault = normalizeSchemaMetadataDefaultValue(input.defaultValue); + const dialect = input.dialect; + + if (dialect === 'mysql') { + if (normalizedType.includes('tinyint(1)') || normalizedType.includes('boolean') || normalizedType.includes('bool')) { + return 'boolean'; + } + if (normalizedType.includes('json')) { + return 'json'; + } + if (normalizedType.includes('timestamp') || normalizedType.includes('datetime') || normalizedType === 'date') { + return 'datetime'; + } + if (normalizedType.includes('int')) { + return isBooleanLikeColumn(input.columnName, normalizedDefault) ? 'boolean' : 'integer'; + } + if (normalizedType.includes('double') || normalizedType.includes('float') || normalizedType.includes('real') || normalizedType.includes('decimal')) { + return 'real'; + } + if (normalizedType.includes('char') || normalizedType.includes('text')) { + if (isDateTimeLikeColumn(input.columnName, normalizedDefault)) return 'datetime'; + if (isJsonLikeColumn(input.columnName)) return 'json'; + return 'text'; + } + } + + if (dialect === 'postgres') { + if (normalizedType.includes('bool')) { + return 'boolean'; + } + if (normalizedType.includes('json')) { + return 'json'; + } + if (normalizedType.includes('timestamp') || normalizedType === 'date') { + return 'datetime'; + } + if (normalizedType.includes('int')) { + return isBooleanLikeColumn(input.columnName, normalizedDefault) ? 'boolean' : 'integer'; + } + if (normalizedType.includes('double') || normalizedType.includes('real') || normalizedType.includes('numeric') || normalizedType.includes('decimal')) { + return 'real'; + } + if (normalizedType.includes('char') || normalizedType.includes('text')) { + if (isDateTimeLikeColumn(input.columnName, normalizedDefault)) return 'datetime'; + if (isJsonLikeColumn(input.columnName)) return 'json'; + return 'text'; + } + } + + if (normalizedType.includes('int')) { + return isBooleanLikeColumn(input.columnName, normalizedDefault) ? 'boolean' : 'integer'; + } + if (normalizedType.includes('real') || normalizedType.includes('double') || normalizedType.includes('float') || normalizedType.includes('decimal')) { + return 'real'; + } + if (normalizedType.includes('json')) { + return 'json'; + } + if (normalizedType.includes('timestamp') || normalizedType.includes('datetime') || normalizedType === 'date') { + return 'datetime'; + } + if (normalizedType.includes('text') || normalizedType.includes('char') || normalizedType.includes('clob')) { + if (isDateTimeLikeColumn(input.columnName, normalizedDefault)) return 'datetime'; + if (isJsonLikeColumn(input.columnName)) return 'json'; + return 'text'; + } + if (isDateTimeLikeColumn(input.columnName, normalizedDefault)) return 'datetime'; + return 'text'; +} + +export const __schemaMetadataTestUtils = { + isBooleanLikeColumn, + isDateTimeLikeColumn, + isJsonLikeColumn, +}; diff --git a/src/server/db/schemaParity.live.test.ts b/src/server/db/schemaParity.live.test.ts new file mode 100644 index 00000000..25c862a6 --- /dev/null +++ b/src/server/db/schemaParity.live.test.ts @@ -0,0 +1,39 @@ +import { readFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; +import type { SchemaContract } from './schemaContract.js'; +import { introspectLiveSchema, materializeFreshSchema } from './schemaIntrospection.js'; + +const dbDir = dirname(fileURLToPath(import.meta.url)); +const schemaContractPath = resolve(dbDir, 'generated/schemaContract.json'); +const contract = JSON.parse(readFileSync(schemaContractPath, 'utf8')) as SchemaContract; + +const skipLiveSchema = process.env.DB_PARITY_SKIP_LIVE_SCHEMA === 'true'; +const sqliteParity = !skipLiveSchema && process.env.DB_PARITY_SQLITE !== 'false' ? it : it.skip; +const mysqlParity = process.env.DB_PARITY_MYSQL_URL ? it : it.skip; +const postgresParity = process.env.DB_PARITY_POSTGRES_URL ? it : it.skip; + +describe('live schema parity', () => { + sqliteParity('matches the contract for sqlite', async () => { + const sqliteUrl = await materializeFreshSchema('sqlite'); + const live = await introspectLiveSchema({ dialect: 'sqlite', connectionString: sqliteUrl }); + expect(live).toEqual(contract); + }); + + mysqlParity('matches the contract for mysql', async () => { + const mysqlUrl = await materializeFreshSchema('mysql', { + connectionString: process.env.DB_PARITY_MYSQL_URL!, + }); + const live = await introspectLiveSchema({ dialect: 'mysql', connectionString: mysqlUrl }); + expect(live).toEqual(contract); + }); + + postgresParity('matches the contract for postgres', async () => { + const postgresUrl = await materializeFreshSchema('postgres', { + connectionString: process.env.DB_PARITY_POSTGRES_URL!, + }); + const live = await introspectLiveSchema({ dialect: 'postgres', connectionString: postgresUrl }); + expect(live).toEqual(contract); + }); +}); diff --git a/src/server/db/schemaParity.test.ts b/src/server/db/schemaParity.test.ts new file mode 100644 index 00000000..995eb49e --- /dev/null +++ b/src/server/db/schemaParity.test.ts @@ -0,0 +1,102 @@ +import { existsSync, readFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; +import type { SchemaContract } from './schemaContract.js'; +import { SHARED_INDEX_COMPATIBILITY_SPECS } from './sharedIndexSchemaCompatibility.js'; + +const dbDir = dirname(fileURLToPath(import.meta.url)); +const generatedDir = resolve(dbDir, 'generated'); +const supportPaths = [ + resolve(dbDir, 'runtimeSchemaBootstrap.ts'), + resolve(dbDir, 'siteSchemaCompatibility.ts'), + resolve(dbDir, 'routeGroupingSchemaCompatibility.ts'), + resolve(dbDir, 'proxyFileSchemaCompatibility.ts'), + resolve(dbDir, 'accountTokenSchemaCompatibility.ts'), + resolve(dbDir, 'sharedIndexSchemaCompatibility.ts'), +]; +const schemaContractPath = resolve(generatedDir, 'schemaContract.json'); + +function extractAllMatches(content: string, pattern: RegExp): string[] { + return Array.from(content.matchAll(pattern), (match) => match[1]); +} + +describe('database schema parity', () => { + it('keeps generated schema artifacts present', () => { + const artifactPaths = [ + schemaContractPath, + resolve(generatedDir, 'mysql.bootstrap.sql'), + resolve(generatedDir, 'mysql.upgrade.sql'), + resolve(generatedDir, 'postgres.bootstrap.sql'), + resolve(generatedDir, 'postgres.upgrade.sql'), + ]; + + for (const artifactPath of artifactPaths) { + expect(existsSync(artifactPath), artifactPath).toBe(true); + expect(readFileSync(artifactPath, 'utf8').trim().length).toBeGreaterThan(0); + } + }); + + it('keeps runtime support modules scoped to contract-defined tables and indexes', () => { + const contract = JSON.parse(readFileSync(schemaContractPath, 'utf8')) as SchemaContract; + const supportContent = supportPaths + .map((filePath) => readFileSync(filePath, 'utf8')) + .join('\n'); + + const knownTables = new Set(Object.keys(contract.tables)); + const knownIndexes = new Set([ + ...contract.indexes.map((index) => index.name), + ...contract.uniques.map((unique) => unique.name), + ]); + + const supportTables = extractAllMatches( + supportContent, + /(?:CREATE TABLE IF NOT EXISTS|ALTER TABLE|INSERT INTO)\s+["`]?([a-z_][a-z0-9_]*)["`]?/gi, + ); + const supportIndexes = extractAllMatches( + supportContent, + /(?:CREATE UNIQUE INDEX(?: IF NOT EXISTS)?|CREATE INDEX(?: IF NOT EXISTS)?|indexName:\s*')["`]?([a-z_][a-z0-9_]*)/gi, + ); + + const unknownTables = [...new Set(supportTables)].filter((tableName) => !knownTables.has(tableName)).sort(); + const unknownIndexes = [...new Set(supportIndexes)].filter((indexName) => !knownIndexes.has(indexName)).sort(); + + expect(unknownTables).toEqual([]); + expect(unknownIndexes).toEqual([]); + }); + + it('does not duplicate contract-defined indexes inside shared index compatibility specs', () => { + const contract = JSON.parse(readFileSync(schemaContractPath, 'utf8')) as SchemaContract; + const contractIndexNames = new Set([ + ...contract.indexes.map((index) => index.name), + ...contract.uniques.map((unique) => unique.name), + ]); + + const duplicatedSpecs = SHARED_INDEX_COMPATIBILITY_SPECS + .map((spec) => spec.indexName) + .filter((indexName) => contractIndexNames.has(indexName)); + + expect(duplicatedSpecs).toEqual([]); + }); + + it('keeps proxy_logs downstream api key schema in the generated contract artifacts', () => { + const contract = JSON.parse(readFileSync(schemaContractPath, 'utf8')) as SchemaContract; + const mysqlBootstrap = readFileSync(resolve(generatedDir, 'mysql.bootstrap.sql'), 'utf8'); + const postgresBootstrap = readFileSync(resolve(generatedDir, 'postgres.bootstrap.sql'), 'utf8'); + + expect(contract.tables.proxy_logs?.columns.downstream_api_key_id?.logicalType).toBe('integer'); + expect(contract.tables.proxy_logs?.columns.client_app_id?.logicalType).toBe('text'); + expect(contract.tables.proxy_logs?.columns.client_family?.logicalType).toBe('text'); + expect(contract.indexes.some((index) => index.name === 'proxy_logs_downstream_api_key_created_at_idx')).toBe(true); + expect(contract.indexes.some((index) => index.name === 'proxy_logs_client_app_id_created_at_idx')).toBe(true); + expect(contract.indexes.some((index) => index.name === 'proxy_logs_client_family_created_at_idx')).toBe(true); + expect(mysqlBootstrap).toContain('`downstream_api_key_id`'); + expect(mysqlBootstrap).toContain('`proxy_logs_downstream_api_key_created_at_idx`'); + expect(mysqlBootstrap).toContain('`client_app_id`'); + expect(mysqlBootstrap).toContain('`proxy_logs_client_app_id_created_at_idx`'); + expect(postgresBootstrap).toContain('"downstream_api_key_id"'); + expect(postgresBootstrap).toContain('"proxy_logs_downstream_api_key_created_at_idx"'); + expect(postgresBootstrap).toContain('"client_app_id"'); + expect(postgresBootstrap).toContain('"proxy_logs_client_app_id_created_at_idx"'); + }); +}); diff --git a/src/server/db/schemaUpgrade.live.test.ts b/src/server/db/schemaUpgrade.live.test.ts new file mode 100644 index 00000000..fe1c8e9a --- /dev/null +++ b/src/server/db/schemaUpgrade.live.test.ts @@ -0,0 +1,33 @@ +import baselineContract from './generated/fixtures/2026-03-14-baseline.schemaContract.json' with { type: 'json' }; +import currentContract from './generated/schemaContract.json' with { type: 'json' }; +import { applyContractFixtureThenUpgrade, introspectLiveSchema } from './schemaIntrospection.js'; +import { describe, expect, it } from 'vitest'; + +const skipLiveSchema = process.env.DB_PARITY_SKIP_LIVE_SCHEMA === 'true'; +const sqliteUpgrade = !skipLiveSchema && process.env.DB_PARITY_SQLITE !== 'false' ? it : it.skip; +const mysqlUpgrade = process.env.DB_PARITY_MYSQL_URL ? it : it.skip; +const postgresUpgrade = process.env.DB_PARITY_POSTGRES_URL ? it : it.skip; + +describe('schema upgrade parity', () => { + sqliteUpgrade('upgrades sqlite to the current contract', async () => { + const sqliteUrl = await applyContractFixtureThenUpgrade('sqlite', baselineContract, currentContract); + const live = await introspectLiveSchema({ dialect: 'sqlite', connectionString: sqliteUrl }); + expect(live).toEqual(currentContract); + }); + + mysqlUpgrade('upgrades mysql to the current contract', async () => { + const mysqlUrl = await applyContractFixtureThenUpgrade('mysql', baselineContract, currentContract, { + connectionString: process.env.DB_PARITY_MYSQL_URL!, + }); + const live = await introspectLiveSchema({ dialect: 'mysql', connectionString: mysqlUrl }); + expect(live).toEqual(currentContract); + }); + + postgresUpgrade('upgrades postgres to the current contract', async () => { + const postgresUrl = await applyContractFixtureThenUpgrade('postgres', baselineContract, currentContract, { + connectionString: process.env.DB_PARITY_POSTGRES_URL!, + }); + const live = await introspectLiveSchema({ dialect: 'postgres', connectionString: postgresUrl }); + expect(live).toEqual(currentContract); + }); +}); diff --git a/src/server/db/sharedIndexSchemaCompatibility.test.ts b/src/server/db/sharedIndexSchemaCompatibility.test.ts new file mode 100644 index 00000000..e7ceed5c --- /dev/null +++ b/src/server/db/sharedIndexSchemaCompatibility.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from 'vitest'; +import { ensureSharedIndexSchemaCompatibility, type SharedIndexSchemaInspector } from './sharedIndexSchemaCompatibility.js'; + +function createInspector( + dialect: SharedIndexSchemaInspector['dialect'], + options?: { + existingTables?: string[]; + }, +) { + const executedSql: string[] = []; + const existingTables = new Set(options?.existingTables ?? []); + + const inspector: SharedIndexSchemaInspector = { + dialect, + async tableExists(table) { + return existingTables.has(table); + }, + async execute(sqlText) { + executedSql.push(sqlText); + }, + }; + + return { inspector, executedSql }; +} + +describe('ensureSharedIndexSchemaCompatibility', () => { + it.each([ + 'sqlite', + 'mysql', + 'postgres', + ] as const)('is a no-op for %s now that contract-defined indexes come from runtime bootstrap', async (dialect) => { + const { inspector, executedSql } = createInspector(dialect, { + existingTables: [ + 'sites', + 'accounts', + 'account_tokens', + 'checkin_logs', + 'model_availability', + 'token_model_availability', + 'token_routes', + 'route_channels', + 'proxy_logs', + 'proxy_video_tasks', + 'downstream_api_keys', + 'events', + ], + }); + + await ensureSharedIndexSchemaCompatibility(inspector); + expect(executedSql).toEqual([]); + }); + + it('skips indexes for tables that do not exist', async () => { + const { inspector, executedSql } = createInspector('postgres', { + existingTables: ['sites'], + }); + + await ensureSharedIndexSchemaCompatibility(inspector); + + expect(executedSql).toEqual([]); + }); +}); diff --git a/src/server/db/sharedIndexSchemaCompatibility.ts b/src/server/db/sharedIndexSchemaCompatibility.ts new file mode 100644 index 00000000..f7ddd456 --- /dev/null +++ b/src/server/db/sharedIndexSchemaCompatibility.ts @@ -0,0 +1,59 @@ +export type SharedIndexSchemaDialect = 'sqlite' | 'mysql' | 'postgres'; + +export interface SharedIndexSchemaInspector { + dialect: SharedIndexSchemaDialect; + tableExists(table: string): Promise<boolean>; + execute(sqlText: string): Promise<void>; +} + +type SharedIndexCompatibilitySpec = { + table: string; + indexName: string; + createSql: Record<SharedIndexSchemaDialect, string>; +}; + +// Contract-defined indexes are owned by generated schema artifacts and +// runtimeSchemaBootstrap. This legacy hook is intentionally empty so startup +// does not maintain a second static source of index SQL. +export const SHARED_INDEX_COMPATIBILITY_SPECS: SharedIndexCompatibilitySpec[] = []; + +function normalizeSchemaErrorMessage(error: unknown): string { + if (typeof error === 'object' && error && 'message' in error) { + return String((error as { message?: unknown }).message || ''); + } + return String(error || ''); +} + +function isDuplicateSchemaError(error: unknown): boolean { + const lowered = normalizeSchemaErrorMessage(error).toLowerCase(); + return lowered.includes('already exists') + || lowered.includes('duplicate') + || lowered.includes('relation') && lowered.includes('already exists'); +} + +async function executeIgnoreDuplicate(inspector: SharedIndexSchemaInspector, sqlText: string): Promise<void> { + try { + await inspector.execute(sqlText); + } catch (error) { + if (!isDuplicateSchemaError(error)) { + throw error; + } + } +} + +export async function ensureSharedIndexSchemaCompatibility(inspector: SharedIndexSchemaInspector): Promise<void> { + const tableExistsCache = new Map<string, boolean>(); + + for (const spec of SHARED_INDEX_COMPATIBILITY_SPECS) { + let hasTable = tableExistsCache.get(spec.table); + if (hasTable === undefined) { + hasTable = await inspector.tableExists(spec.table); + tableExistsCache.set(spec.table, hasTable); + } + if (!hasTable) { + continue; + } + + await executeIgnoreDuplicate(inspector, spec.createSql[inspector.dialect]); + } +} diff --git a/src/server/db/siteAnnouncementsSchema.test.ts b/src/server/db/siteAnnouncementsSchema.test.ts new file mode 100644 index 00000000..29a438a6 --- /dev/null +++ b/src/server/db/siteAnnouncementsSchema.test.ts @@ -0,0 +1,49 @@ +import { readFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; +import { generateDialectArtifacts } from './schemaArtifactGenerator.js'; +import type { SchemaContract } from './schemaContract.js'; +import * as schema from './schema.js'; + +const dbDir = dirname(fileURLToPath(import.meta.url)); +const schemaContractPath = resolve(dbDir, 'generated/schemaContract.json'); + +function readSchemaContract(): SchemaContract { + return JSON.parse(readFileSync(schemaContractPath, 'utf8')) as SchemaContract; +} + +describe('site announcements schema', () => { + it('declares the site_announcements table in the Drizzle schema', () => { + expect(schema.siteAnnouncements).toBeDefined(); + }); + + it('keeps site_announcements in the generated schema contract', () => { + const contract = readSchemaContract(); + const table = contract.tables.site_announcements; + + expect(table).toBeDefined(); + expect(table?.columns.site_id?.logicalType).toBe('integer'); + expect(table?.columns.source_key?.logicalType).toBe('text'); + expect(table?.columns.first_seen_at?.logicalType).toBe('datetime'); + expect(table?.columns.last_seen_at?.logicalType).toBe('datetime'); + expect(table?.columns.read_at?.logicalType).toBe('datetime'); + expect(table?.columns.raw_payload?.logicalType).toBe('text'); + expect(contract.indexes.some((index) => index.name === 'site_announcements_site_id_first_seen_at_idx')).toBe(true); + expect(contract.indexes.some((index) => index.name === 'site_announcements_read_at_idx')).toBe(true); + expect(contract.uniques.some((unique) => unique.name === 'site_announcements_site_source_key_unique')).toBe(true); + }); + + it('emits bootstrap sql for the site_announcements table', () => { + const artifacts = generateDialectArtifacts(readSchemaContract()); + + expect(artifacts.mysqlBootstrap).toContain('CREATE TABLE IF NOT EXISTS `site_announcements`'); + expect(artifacts.mysqlBootstrap).toContain("`first_seen_at` VARCHAR(191) DEFAULT (DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s'))"); + expect(artifacts.mysqlBootstrap).toContain('`upstream_created_at` VARCHAR(191)'); + expect(artifacts.mysqlBootstrap).toContain('`site_announcements_site_source_key_unique`'); + expect(artifacts.postgresBootstrap).toContain('CREATE TABLE IF NOT EXISTS "site_announcements"'); + expect(artifacts.postgresBootstrap).toContain(`"first_seen_at" TEXT DEFAULT to_char(timezone('UTC', CURRENT_TIMESTAMP), 'YYYY-MM-DD HH24:MI:SS')`); + expect(artifacts.postgresBootstrap).toContain('"upstream_created_at" TEXT'); + expect(artifacts.postgresBootstrap).toContain('"site_announcements_site_source_key_unique"'); + }); +}); diff --git a/src/server/db/siteSchemaCompatibility.test.ts b/src/server/db/siteSchemaCompatibility.test.ts index 7a9a9974..c4f8efb6 100644 --- a/src/server/db/siteSchemaCompatibility.test.ts +++ b/src/server/db/siteSchemaCompatibility.test.ts @@ -5,17 +5,20 @@ function createInspector( dialect: SiteSchemaInspector['dialect'], options?: { hasSitesTable?: boolean; + existingTables?: string[]; existingColumns?: string[]; }, ) { const executedSql: string[] = []; const hasSitesTable = options?.hasSitesTable ?? true; + const existingTables = new Set(options?.existingTables ?? []); const existingColumns = new Set(options?.existingColumns ?? []); const inspector: SiteSchemaInspector = { dialect, async tableExists(table) { - return table === 'sites' && hasSitesTable; + if (table === 'sites') return hasSitesTable; + return existingTables.has(table); }, async columnExists(table, column) { return table === 'sites' && existingColumns.has(column); @@ -36,9 +39,13 @@ describe('ensureSiteSchemaCompatibility', () => { 'ALTER TABLE "sites" ADD COLUMN "proxy_url" TEXT', 'ALTER TABLE "sites" ADD COLUMN "use_system_proxy" BOOLEAN DEFAULT FALSE', 'UPDATE "sites" SET "use_system_proxy" = FALSE WHERE "use_system_proxy" IS NULL', + 'ALTER TABLE "sites" ADD COLUMN "custom_headers" TEXT', 'ALTER TABLE "sites" ADD COLUMN "external_checkin_url" TEXT', 'ALTER TABLE "sites" ADD COLUMN "global_weight" DOUBLE PRECISION DEFAULT 1', 'UPDATE "sites" SET "global_weight" = 1 WHERE "global_weight" IS NULL OR "global_weight" <= 0', + 'CREATE TABLE IF NOT EXISTS "site_disabled_models" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "site_id" INTEGER NOT NULL REFERENCES "sites"("id") ON DELETE CASCADE, "model_name" TEXT NOT NULL, "created_at" TEXT)', + 'CREATE UNIQUE INDEX IF NOT EXISTS "site_disabled_models_site_model_unique" ON "site_disabled_models" ("site_id", "model_name")', + 'CREATE INDEX IF NOT EXISTS "site_disabled_models_site_id_idx" ON "site_disabled_models" ("site_id")', ], }, { @@ -47,9 +54,13 @@ describe('ensureSiteSchemaCompatibility', () => { 'ALTER TABLE `sites` ADD COLUMN `proxy_url` TEXT NULL', 'ALTER TABLE `sites` ADD COLUMN `use_system_proxy` BOOLEAN DEFAULT FALSE', 'UPDATE `sites` SET `use_system_proxy` = FALSE WHERE `use_system_proxy` IS NULL', + 'ALTER TABLE `sites` ADD COLUMN `custom_headers` TEXT NULL', 'ALTER TABLE `sites` ADD COLUMN `external_checkin_url` TEXT NULL', 'ALTER TABLE `sites` ADD COLUMN `global_weight` DOUBLE DEFAULT 1', 'UPDATE `sites` SET `global_weight` = 1 WHERE `global_weight` IS NULL OR `global_weight` <= 0', + 'CREATE TABLE IF NOT EXISTS `site_disabled_models` (`id` INT AUTO_INCREMENT PRIMARY KEY, `site_id` INT NOT NULL, `model_name` VARCHAR(191) NOT NULL, `created_at` TEXT NULL, CONSTRAINT `site_disabled_models_site_fk` FOREIGN KEY (`site_id`) REFERENCES `sites`(`id`) ON DELETE CASCADE)', + 'CREATE UNIQUE INDEX `site_disabled_models_site_model_unique` ON `site_disabled_models` (`site_id`, `model_name`(191))', + 'CREATE INDEX `site_disabled_models_site_id_idx` ON `site_disabled_models` (`site_id`)', ], }, ])('adds missing site proxy columns for $dialect', async ({ dialect, expectedSql }) => { @@ -67,4 +78,14 @@ describe('ensureSiteSchemaCompatibility', () => { expect(executedSql).toEqual([]); }); + + it('does not recreate site_disabled_models when it already exists', async () => { + const { inspector, executedSql } = createInspector('mysql', { + existingTables: ['site_disabled_models'], + }); + + await ensureSiteSchemaCompatibility(inspector); + + expect(executedSql).not.toContain('CREATE TABLE IF NOT EXISTS `site_disabled_models` (`id` INT AUTO_INCREMENT PRIMARY KEY, `site_id` INT NOT NULL, `model_name` VARCHAR(191) NOT NULL, `created_at` TEXT NULL, CONSTRAINT `site_disabled_models_site_fk` FOREIGN KEY (`site_id`) REFERENCES `sites`(`id`) ON DELETE CASCADE)'); + }); }); diff --git a/src/server/db/siteSchemaCompatibility.ts b/src/server/db/siteSchemaCompatibility.ts index f90e61b7..4e08f578 100644 --- a/src/server/db/siteSchemaCompatibility.ts +++ b/src/server/db/siteSchemaCompatibility.ts @@ -7,13 +7,19 @@ export interface SiteSchemaInspector { execute(sqlText: string): Promise<void>; } -type SiteColumnCompatibilitySpec = { +export type SiteColumnCompatibilitySpec = { column: string; addSql: Record<SiteSchemaDialect, string>; normalizeSql?: Record<SiteSchemaDialect, string>; }; -const SITE_COLUMN_COMPATIBILITY_SPECS: SiteColumnCompatibilitySpec[] = [ +export type SiteTableCompatibilitySpec = { + table: string; + createSql: Record<SiteSchemaDialect, string>; + postCreateSql?: Record<SiteSchemaDialect, string[]>; +}; + +export const SITE_COLUMN_COMPATIBILITY_SPECS: SiteColumnCompatibilitySpec[] = [ { column: 'proxy_url', addSql: { @@ -35,6 +41,14 @@ const SITE_COLUMN_COMPATIBILITY_SPECS: SiteColumnCompatibilitySpec[] = [ postgres: 'UPDATE "sites" SET "use_system_proxy" = FALSE WHERE "use_system_proxy" IS NULL', }, }, + { + column: 'custom_headers', + addSql: { + sqlite: 'ALTER TABLE sites ADD COLUMN custom_headers text;', + mysql: 'ALTER TABLE `sites` ADD COLUMN `custom_headers` TEXT NULL', + postgres: 'ALTER TABLE "sites" ADD COLUMN "custom_headers" TEXT', + }, + }, { column: 'external_checkin_url', addSql: { @@ -58,6 +72,31 @@ const SITE_COLUMN_COMPATIBILITY_SPECS: SiteColumnCompatibilitySpec[] = [ }, ]; +export const SITE_TABLE_COMPATIBILITY_SPECS: SiteTableCompatibilitySpec[] = [ + { + table: 'site_disabled_models', + createSql: { + sqlite: 'CREATE TABLE IF NOT EXISTS site_disabled_models (id integer PRIMARY KEY AUTOINCREMENT NOT NULL, site_id integer NOT NULL REFERENCES sites(id) ON DELETE cascade, model_name text NOT NULL, created_at text DEFAULT (datetime(\'now\')));', + mysql: 'CREATE TABLE IF NOT EXISTS `site_disabled_models` (`id` INT AUTO_INCREMENT PRIMARY KEY, `site_id` INT NOT NULL, `model_name` VARCHAR(191) NOT NULL, `created_at` TEXT NULL, CONSTRAINT `site_disabled_models_site_fk` FOREIGN KEY (`site_id`) REFERENCES `sites`(`id`) ON DELETE CASCADE)', + postgres: 'CREATE TABLE IF NOT EXISTS "site_disabled_models" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "site_id" INTEGER NOT NULL REFERENCES "sites"("id") ON DELETE CASCADE, "model_name" TEXT NOT NULL, "created_at" TEXT)', + }, + postCreateSql: { + sqlite: [ + 'CREATE UNIQUE INDEX IF NOT EXISTS site_disabled_models_site_model_unique ON site_disabled_models (site_id, model_name);', + 'CREATE INDEX IF NOT EXISTS site_disabled_models_site_id_idx ON site_disabled_models (site_id);', + ], + mysql: [ + 'CREATE UNIQUE INDEX `site_disabled_models_site_model_unique` ON `site_disabled_models` (`site_id`, `model_name`(191))', + 'CREATE INDEX `site_disabled_models_site_id_idx` ON `site_disabled_models` (`site_id`)', + ], + postgres: [ + 'CREATE UNIQUE INDEX IF NOT EXISTS "site_disabled_models_site_model_unique" ON "site_disabled_models" ("site_id", "model_name")', + 'CREATE INDEX IF NOT EXISTS "site_disabled_models_site_id_idx" ON "site_disabled_models" ("site_id")', + ], + }, + }, +]; + function normalizeSchemaErrorMessage(error: unknown): string { if (typeof error === 'object' && error && 'message' in error) { return String((error as { message?: unknown }).message || ''); @@ -72,6 +111,15 @@ function isDuplicateColumnError(error: unknown): boolean { || lowered.includes('duplicate column name'); } +function isExistingSchemaObjectError(error: unknown): boolean { + const lowered = normalizeSchemaErrorMessage(error).toLowerCase(); + return lowered.includes('duplicate column') + || lowered.includes('already exists') + || lowered.includes('duplicate column name') + || lowered.includes('duplicate key name') + || (lowered.includes('relation') && lowered.includes('already exists')); +} + async function executeAddColumn(inspector: SiteSchemaInspector, sqlText: string): Promise<void> { try { await inspector.execute(sqlText); @@ -82,6 +130,16 @@ async function executeAddColumn(inspector: SiteSchemaInspector, sqlText: string) } } +async function executeCreateSchemaObject(inspector: SiteSchemaInspector, sqlText: string): Promise<void> { + try { + await inspector.execute(sqlText); + } catch (error) { + if (!isExistingSchemaObjectError(error)) { + throw error; + } + } +} + export async function ensureSiteSchemaCompatibility(inspector: SiteSchemaInspector): Promise<void> { const hasSitesTable = await inspector.tableExists('sites'); if (!hasSitesTable) { @@ -98,4 +156,15 @@ export async function ensureSiteSchemaCompatibility(inspector: SiteSchemaInspect await inspector.execute(spec.normalizeSql[inspector.dialect]); } } + + for (const spec of SITE_TABLE_COMPATIBILITY_SPECS) { + const hasTable = await inspector.tableExists(spec.table); + if (!hasTable) { + await executeCreateSchemaObject(inspector, spec.createSql[inspector.dialect]); + } + + for (const sqlText of spec.postCreateSql?.[inspector.dialect] ?? []) { + await executeCreateSchemaObject(inspector, sqlText); + } + } } diff --git a/src/server/db/upsertSetting.ts b/src/server/db/upsertSetting.ts new file mode 100644 index 00000000..c40424fe --- /dev/null +++ b/src/server/db/upsertSetting.ts @@ -0,0 +1,41 @@ +import { eq } from 'drizzle-orm'; +import { db, runtimeDbDialect, schema } from './index.js'; + +/** + * Dialect-aware upsert for the `settings` table. + * + * SQLite / PostgreSQL use `onConflictDoUpdate` while MySQL uses + * `onDuplicateKeyUpdate`. Drizzle exposes different builder types for each + * dialect, so we branch at runtime. + */ +export async function upsertSetting(key: string, value: unknown, txDb: typeof db = db): Promise<void> { + const jsonValue = JSON.stringify(value); + + if (runtimeDbDialect === 'mysql') { + // MySQL path – try update first, insert if not exists + const existing = await txDb.select({ key: schema.settings.key }) + .from(schema.settings) + .where(eq(schema.settings.key, key)) + .get(); + + if (existing) { + await txDb.update(schema.settings) + .set({ value: jsonValue }) + .where(eq(schema.settings.key, key)) + .run(); + } else { + await txDb.insert(schema.settings) + .values({ key, value: jsonValue }) + .run(); + } + } else { + // SQLite / PostgreSQL path + await (txDb.insert(schema.settings) + .values({ key, value: jsonValue }) as any) + .onConflictDoUpdate({ + target: schema.settings.key, + set: { value: jsonValue }, + }) + .run(); + } +} diff --git a/src/server/desktop.ts b/src/server/desktop.ts index bc844b03..02bb29b7 100644 --- a/src/server/desktop.ts +++ b/src/server/desktop.ts @@ -3,7 +3,7 @@ import type { FastifyInstance } from 'fastify'; const DESKTOP_HEALTH_ROUTE = '/api/desktop/health'; export function isPublicApiRoute(url: string): boolean { - return url === DESKTOP_HEALTH_ROUTE; + return url === DESKTOP_HEALTH_ROUTE || url.startsWith('/api/oauth/callback/'); } export async function registerDesktopRoutes(app: FastifyInstance) { diff --git a/src/server/index.ts b/src/server/index.ts index c555d8a2..8dfd6c80 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -2,6 +2,7 @@ import Fastify from 'fastify'; import cors from '@fastify/cors'; import fastifyStatic from '@fastify/static'; import { buildFastifyOptions, config } from './config.js'; +import { normalizePayloadRulesConfig } from './services/payloadRules.js'; import { authMiddleware } from './middleware/auth.js'; import { sitesRoutes } from './routes/api/sites.js'; import { accountsRoutes } from './routes/api/accounts.js'; @@ -17,20 +18,33 @@ import { taskRoutes } from './routes/api/tasks.js'; import { testRoutes } from './routes/api/test.js'; import { monitorRoutes } from './routes/api/monitor.js'; import { downstreamApiKeysRoutes } from './routes/api/downstreamApiKeys.js'; +import { oauthRoutes } from './routes/api/oauth.js'; +import { siteAnnouncementsRoutes } from './routes/api/siteAnnouncements.js'; import { proxyRoutes } from './routes/proxy/router.js'; import { startScheduler } from './services/checkinScheduler.js'; -import { startProxyLogRetentionService, stopProxyLogRetentionService } from './services/proxyLogRetentionService.js'; +import * as routeRefreshWorkflow from './services/routeRefreshWorkflow.js'; +import { startProxyFileRetentionService, stopProxyFileRetentionService } from './services/proxyFileRetentionService.js'; +import { setLegacyProxyLogRetentionFallbackEnabled, stopProxyLogRetentionService } from './services/proxyLogRetentionService.js'; import { buildStartupSummaryLines } from './services/startupInfo.js'; import { repairStoredCreatedAtValues } from './services/storedTimestampRepairService.js'; import { migrateSiteApiKeysToAccounts } from './services/siteApiKeyMigrationService.js'; import { ensureDefaultSitesSeeded } from './services/defaultSiteSeedService.js'; +import { ensureOauthIdentityBackfill } from './services/oauth/oauthIdentityBackfill.js'; +import { ensureOauthProviderSitesExist } from './services/oauth/oauthSiteRegistry.js'; +import { startOAuthLoopbackCallbackServers, stopOAuthLoopbackCallbackServers } from './services/oauth/localCallbackServer.js'; +import { startSiteAnnouncementPolling } from './services/siteAnnouncementPollingService.js'; +import { reloadBackupWebdavScheduler } from './services/backupService.js'; +import { ensureRuntimeDatabaseReady } from './runtimeDatabaseBootstrap.js'; import { isPublicApiRoute, registerDesktopRoutes } from './desktop.js'; import { existsSync } from 'fs'; import { fileURLToPath } from 'url'; import { dirname, normalize, resolve, sep } from 'path'; +import { normalizeLogCleanupRetentionDays } from './services/logCleanupService.js'; import { db, ensureProxyFileCompatibilityColumns, + ensureProxyLogClientColumns, + ensureProxyLogDownstreamApiKeyIdColumn, ensureProxyLogBillingDetailsColumn, ensureRouteGroupingCompatibilityColumns, ensureSiteCompatibilityColumns, @@ -40,18 +54,6 @@ import { type RuntimeDbDialect, } from './db/index.js'; -let sqliteMigrationsBootstrapped = false; - -async function ensureSqliteRuntimeMigrations() { - if (runtimeDbDialect !== 'sqlite') return; - const migrateModule = await import('./db/migrate.js'); - if (sqliteMigrationsBootstrapped) { - migrateModule.runSqliteMigrations(); - return; - } - sqliteMigrationsBootstrapped = true; -} - function toSettingsMap(rows: Array<{ key: string; value: string }>) { return new Map(rows.map((row) => [row.key, row.value])); } @@ -115,6 +117,17 @@ function extractSavedRuntimeDatabaseConfig(settingsMap: Map<string, string>): { }; } +const LOG_CLEANUP_SETTING_KEYS = [ + 'log_cleanup_cron', + 'log_cleanup_usage_logs_enabled', + 'log_cleanup_program_logs_enabled', + 'log_cleanup_retention_days', +] as const; + +function hasExplicitLogCleanupSettings(settingsMap: Map<string, string>): boolean { + return LOG_CLEANUP_SETTING_KEYS.some((key) => settingsMap.has(key)); +} + function applyRuntimeSettings(settingsMap: Map<string, string>) { const authToken = parseSettingFromMap<string>(settingsMap, 'auth_token'); if (typeof authToken === 'string' && authToken) config.authToken = authToken; @@ -125,12 +138,95 @@ function applyRuntimeSettings(settingsMap: Map<string, string>) { const systemProxyUrl = parseSettingFromMap<string>(settingsMap, 'system_proxy_url'); if (typeof systemProxyUrl === 'string') config.systemProxyUrl = systemProxyUrl; + const codexUpstreamWebsocketEnabled = parseSettingFromMap<boolean>(settingsMap, 'codex_upstream_websocket_enabled'); + if (typeof codexUpstreamWebsocketEnabled === 'boolean') { + config.codexUpstreamWebsocketEnabled = codexUpstreamWebsocketEnabled; + } + + const proxyErrorKeywords = parseSettingFromMap<string[] | string>(settingsMap, 'proxy_error_keywords'); + if (proxyErrorKeywords !== undefined) { + config.proxyErrorKeywords = toStringList(proxyErrorKeywords); + } + + const proxyEmptyContentFailEnabled = parseSettingFromMap<boolean>(settingsMap, 'proxy_empty_content_fail_enabled'); + if (typeof proxyEmptyContentFailEnabled === 'boolean') { + config.proxyEmptyContentFailEnabled = proxyEmptyContentFailEnabled; + } + + const globalBlockedBrands = parseSettingFromMap<string[]>(settingsMap, 'global_blocked_brands'); + if (Array.isArray(globalBlockedBrands)) { + config.globalBlockedBrands = globalBlockedBrands.filter((b): b is string => typeof b === 'string').map((b) => b.trim()).filter(Boolean); + } + + const codexHeaderDefaults = parseSettingFromMap<unknown>(settingsMap, 'codex_header_defaults'); + if (codexHeaderDefaults && typeof codexHeaderDefaults === 'object') { + const next = codexHeaderDefaults as Record<string, unknown>; + config.codexHeaderDefaults = { + userAgent: typeof next.userAgent === 'string' + ? next.userAgent.trim() + : (typeof next['user-agent'] === 'string' ? next['user-agent'].trim() : config.codexHeaderDefaults.userAgent), + betaFeatures: typeof next.betaFeatures === 'string' + ? next.betaFeatures.trim() + : (typeof next['beta-features'] === 'string' ? next['beta-features'].trim() : config.codexHeaderDefaults.betaFeatures), + }; + } + + if (settingsMap.has('payload_rules')) { + config.payloadRules = normalizePayloadRulesConfig(parseSettingFromMap<unknown>(settingsMap, 'payload_rules')); + } + const checkinCron = parseSettingFromMap<string>(settingsMap, 'checkin_cron'); if (typeof checkinCron === 'string' && checkinCron) config.checkinCron = checkinCron; + const checkinScheduleMode = parseSettingFromMap<string>(settingsMap, 'checkin_schedule_mode'); + if (checkinScheduleMode === 'cron' || checkinScheduleMode === 'interval') { + config.checkinScheduleMode = checkinScheduleMode; + } + + const checkinIntervalHours = parseSettingFromMap<number>(settingsMap, 'checkin_interval_hours'); + if (typeof checkinIntervalHours === 'number' && Number.isFinite(checkinIntervalHours) && checkinIntervalHours >= 1 && checkinIntervalHours <= 24) { + config.checkinIntervalHours = Math.trunc(checkinIntervalHours); + } + const balanceRefreshCron = parseSettingFromMap<string>(settingsMap, 'balance_refresh_cron'); if (typeof balanceRefreshCron === 'string' && balanceRefreshCron) config.balanceRefreshCron = balanceRefreshCron; + const logCleanupCron = parseSettingFromMap<string>(settingsMap, 'log_cleanup_cron'); + if (typeof logCleanupCron === 'string' && logCleanupCron) config.logCleanupCron = logCleanupCron; + + const logCleanupUsageLogsEnabled = parseSettingFromMap<boolean>(settingsMap, 'log_cleanup_usage_logs_enabled'); + if (typeof logCleanupUsageLogsEnabled === 'boolean') { + config.logCleanupUsageLogsEnabled = logCleanupUsageLogsEnabled; + } + + const logCleanupProgramLogsEnabled = parseSettingFromMap<boolean>(settingsMap, 'log_cleanup_program_logs_enabled'); + if (typeof logCleanupProgramLogsEnabled === 'boolean') { + config.logCleanupProgramLogsEnabled = logCleanupProgramLogsEnabled; + } + + const logCleanupRetentionDays = parseSettingFromMap<number>(settingsMap, 'log_cleanup_retention_days'); + if (typeof logCleanupRetentionDays === 'number' && Number.isFinite(logCleanupRetentionDays) && logCleanupRetentionDays >= 1) { + config.logCleanupRetentionDays = normalizeLogCleanupRetentionDays(logCleanupRetentionDays); + } + + const proxySessionChannelConcurrencyLimit = parseSettingFromMap<number>(settingsMap, 'proxy_session_channel_concurrency_limit'); + if ( + typeof proxySessionChannelConcurrencyLimit === 'number' + && Number.isFinite(proxySessionChannelConcurrencyLimit) + && proxySessionChannelConcurrencyLimit >= 0 + ) { + config.proxySessionChannelConcurrencyLimit = Math.trunc(proxySessionChannelConcurrencyLimit); + } + + const proxySessionChannelQueueWaitMs = parseSettingFromMap<number>(settingsMap, 'proxy_session_channel_queue_wait_ms'); + if ( + typeof proxySessionChannelQueueWaitMs === 'number' + && Number.isFinite(proxySessionChannelQueueWaitMs) + && proxySessionChannelQueueWaitMs >= 0 + ) { + config.proxySessionChannelQueueWaitMs = Math.trunc(proxySessionChannelQueueWaitMs); + } + const routingWeights = parseSettingFromMap<Partial<typeof config.routingWeights>>(settingsMap, 'routing_weights'); if (routingWeights && typeof routingWeights === 'object') { config.routingWeights = { @@ -156,12 +252,23 @@ function applyRuntimeSettings(settingsMap: Map<string, string>) { const telegramEnabled = parseSettingFromMap<boolean>(settingsMap, 'telegram_enabled'); if (typeof telegramEnabled === 'boolean') config.telegramEnabled = telegramEnabled; + const telegramApiBaseUrl = parseSettingFromMap<string>(settingsMap, 'telegram_api_base_url'); + if (typeof telegramApiBaseUrl === 'string' && telegramApiBaseUrl.trim()) { + config.telegramApiBaseUrl = telegramApiBaseUrl.trim().replace(/\/+$/, ''); + } + const telegramBotToken = parseSettingFromMap<string>(settingsMap, 'telegram_bot_token'); if (typeof telegramBotToken === 'string') config.telegramBotToken = telegramBotToken; const telegramChatId = parseSettingFromMap<string>(settingsMap, 'telegram_chat_id'); if (typeof telegramChatId === 'string') config.telegramChatId = telegramChatId; + const telegramUseSystemProxy = parseSettingFromMap<boolean>(settingsMap, 'telegram_use_system_proxy'); + if (typeof telegramUseSystemProxy === 'boolean') config.telegramUseSystemProxy = telegramUseSystemProxy; + + const telegramMessageThreadId = parseSettingFromMap<string>(settingsMap, 'telegram_message_thread_id'); + if (typeof telegramMessageThreadId === 'string') config.telegramMessageThreadId = telegramMessageThreadId; + const smtpEnabled = parseSettingFromMap<boolean>(settingsMap, 'smtp_enabled'); if (typeof smtpEnabled === 'boolean') config.smtpEnabled = smtpEnabled; @@ -199,8 +306,12 @@ function applyRuntimeSettings(settingsMap: Map<string, string>) { } } -// Ensure sqlite tables exist before reading runtime settings. -await ensureSqliteRuntimeMigrations(); +// Ensure the current runtime database is bootstrapped before reading settings. +await ensureRuntimeDatabaseReady({ + dialect: runtimeDbDialect, + connectionString: config.dbUrl, + ssl: config.dbSsl, +}); // Load runtime config overrides from settings try { @@ -208,12 +319,27 @@ try { const initialMap = toSettingsMap(initialRows); const savedDbConfig = extractSavedRuntimeDatabaseConfig(initialMap); const activeDbUrl = (config.dbUrl || '').trim(); + const originalRuntimeConfig = { + dialect: runtimeDbDialect, + dbUrl: activeDbUrl, + ssl: config.dbSsl, + }; if (savedDbConfig && (savedDbConfig.dialect !== runtimeDbDialect || savedDbConfig.dbUrl !== activeDbUrl || savedDbConfig.ssl !== config.dbSsl)) { try { await switchRuntimeDatabase(savedDbConfig.dialect, savedDbConfig.dbUrl, savedDbConfig.ssl); - await ensureSqliteRuntimeMigrations(); console.log(`Loaded runtime DB config from settings: ${savedDbConfig.dialect}`); } catch (error) { + const currentDbUrl = (config.dbUrl || '').trim(); + const switchedAway = runtimeDbDialect !== originalRuntimeConfig.dialect + || currentDbUrl !== originalRuntimeConfig.dbUrl + || config.dbSsl !== originalRuntimeConfig.ssl; + if (switchedAway) { + await switchRuntimeDatabase( + originalRuntimeConfig.dialect, + originalRuntimeConfig.dbUrl, + originalRuntimeConfig.ssl, + ); + } console.warn(`Failed to switch runtime DB from settings: ${(error as Error)?.message || 'unknown error'}`); } } @@ -221,19 +347,31 @@ try { await ensureSiteCompatibilityColumns(); await ensureRouteGroupingCompatibilityColumns(); await ensureProxyFileCompatibilityColumns(); + await ensureProxyLogClientColumns(); + await ensureProxyLogDownstreamApiKeyIdColumn(); const finalRows = await db.select().from(schema.settings).all(); const finalMap = toSettingsMap(finalRows); applyRuntimeSettings(finalMap); + config.logCleanupConfigured = hasExplicitLogCleanupSettings(finalMap); + if (!config.logCleanupConfigured && config.proxyLogRetentionDays > 0) { + config.logCleanupUsageLogsEnabled = true; + config.logCleanupProgramLogsEnabled = false; + config.logCleanupRetentionDays = normalizeLogCleanupRetentionDays(config.proxyLogRetentionDays); + } await ensureProxyLogBillingDetailsColumn(); await repairStoredCreatedAtValues(); await migrateSiteApiKeysToAccounts(); await ensureDefaultSitesSeeded(); + await ensureOauthIdentityBackfill(); + await routeRefreshWorkflow.rebuildRoutesOnly(); console.log('Loaded runtime settings overrides'); } catch (error) { console.warn(`Failed to load runtime settings overrides: ${(error as Error)?.message || 'unknown error'}`); } +await ensureOauthProviderSitesExist(); + const app = Fastify(buildFastifyOptions(config)); await app.register(cors); @@ -256,11 +394,13 @@ await app.register(authRoutes); await app.register(settingsRoutes); await app.register(accountTokensRoutes); await app.register(searchRoutes); -await app.register(eventsRoutes); -await app.register(taskRoutes); + await app.register(eventsRoutes); + await app.register(siteAnnouncementsRoutes); + await app.register(taskRoutes); await app.register(testRoutes); await app.register(monitorRoutes); await app.register(downstreamApiKeysRoutes); +await app.register(oauthRoutes); // Register OpenAI-compatible proxy routes await app.register(proxyRoutes); @@ -294,9 +434,19 @@ if (existsSync(webDir)) { // Start scheduler await startScheduler(); -startProxyLogRetentionService(); +await reloadBackupWebdavScheduler(); +startSiteAnnouncementPolling(); +try { + await startOAuthLoopbackCallbackServers(); +} catch (error) { + console.warn(`Failed to start OAuth callback listeners: ${(error as Error)?.message || 'unknown error'}`); +} +setLegacyProxyLogRetentionFallbackEnabled(!config.logCleanupConfigured); +startProxyFileRetentionService(); app.addHook('onClose', async () => { + stopProxyFileRetentionService(); stopProxyLogRetentionService(); + await stopOAuthLoopbackCallbackServers(); }); // Start server diff --git a/src/server/middleware/auth.proxy.test.ts b/src/server/middleware/auth.proxy.test.ts new file mode 100644 index 00000000..5a959bcb --- /dev/null +++ b/src/server/middleware/auth.proxy.test.ts @@ -0,0 +1,79 @@ +import Fastify from 'fastify'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const authorizeDownstreamTokenMock = vi.fn(); +const consumeManagedKeyRequestMock = vi.fn(); + +vi.mock('../services/downstreamApiKeyService.js', () => ({ + authorizeDownstreamToken: (...args: unknown[]) => authorizeDownstreamTokenMock(...args), + consumeManagedKeyRequest: (...args: unknown[]) => consumeManagedKeyRequestMock(...args), +})); + +describe('proxyAuthMiddleware', () => { + beforeEach(() => { + authorizeDownstreamTokenMock.mockReset(); + consumeManagedKeyRequestMock.mockReset(); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('rejects missing proxy credentials', async () => { + const { proxyAuthMiddleware } = await import('./auth.js'); + const app = Fastify(); + app.addHook('onRequest', proxyAuthMiddleware); + app.get('/v1/ping', async () => ({ ok: true })); + + const res = await app.inject({ method: 'GET', url: '/v1/ping' }); + expect(res.statusCode).toBe(401); + expect(res.json()).toMatchObject({ error: expect.stringContaining('Missing Authorization') }); + await app.close(); + }); + + it('stores managed key context and consumes request usage', async () => { + authorizeDownstreamTokenMock.mockResolvedValue({ + ok: true, + source: 'managed', + token: 'sk-managed-001', + key: { id: 12, name: 'project-key' }, + policy: { supportedModels: ['gpt-5.2'], allowedRouteIds: [3], siteWeightMultipliers: { 1: 1.2 } }, + }); + consumeManagedKeyRequestMock.mockResolvedValue(undefined); + + const { proxyAuthMiddleware, getProxyAuthContext, getProxyResourceOwner } = await import('./auth.js'); + const app = Fastify(); + app.addHook('onRequest', proxyAuthMiddleware); + app.get('/v1/ping', async (request) => ({ + auth: getProxyAuthContext(request), + owner: getProxyResourceOwner(request), + })); + + const res = await app.inject({ + method: 'GET', + url: '/v1/ping', + headers: { Authorization: 'Bearer sk-managed-001' }, + }); + + expect(res.statusCode).toBe(200); + expect(authorizeDownstreamTokenMock).toHaveBeenCalledWith('sk-managed-001'); + expect(consumeManagedKeyRequestMock).toHaveBeenCalledWith(12); + expect(res.json()).toMatchObject({ + auth: { + source: 'managed', + keyId: 12, + keyName: 'project-key', + policy: { + supportedModels: ['gpt-5.2'], + allowedRouteIds: [3], + siteWeightMultipliers: { 1: 1.2 }, + }, + }, + owner: { + ownerType: 'managed_key', + ownerId: '12', + }, + }); + await app.close(); + }); +}); diff --git a/src/server/middleware/requestRateLimit.ts b/src/server/middleware/requestRateLimit.ts new file mode 100644 index 00000000..e87b2b9a --- /dev/null +++ b/src/server/middleware/requestRateLimit.ts @@ -0,0 +1,85 @@ +import type { FastifyReply, FastifyRequest } from 'fastify'; + +type RateLimitOptions = { + bucket: string; + max: number; + windowMs: number; + message?: string; +}; + +type RateLimitEntry = { + count: number; + resetAt: number; +}; + +const DEFAULT_MESSAGE = '请求过于频繁,请稍后再试'; +const rateLimitStore = new Map<string, RateLimitEntry>(); + +function normalizeIp(rawIp: string | null | undefined): string { + const ip = (rawIp || '').trim(); + if (!ip) return 'unknown'; + if (ip.startsWith('::ffff:')) return ip.slice('::ffff:'.length).trim() || 'unknown'; + if (ip === '::1') return '127.0.0.1'; + return ip; +} + +function extractClientIp(request: FastifyRequest): string { + const forwarded = request.headers['x-forwarded-for']; + if (Array.isArray(forwarded)) { + const first = forwarded.find((value) => typeof value === 'string' && value.trim().length > 0); + if (first) return normalizeIp(first.split(',')[0]); + } + + if (typeof forwarded === 'string' && forwarded.trim().length > 0) { + return normalizeIp(forwarded.split(',')[0]); + } + + return normalizeIp(request.ip); +} + +function getRateLimitKey(bucket: string, request: FastifyRequest): string { + return `${bucket}:${extractClientIp(request)}`; +} + +function pruneExpiredEntries(nowMs: number): void { + for (const [key, entry] of rateLimitStore.entries()) { + if (entry.resetAt > nowMs) continue; + rateLimitStore.delete(key); + } +} + +export function resetRequestRateLimitStore(): void { + rateLimitStore.clear(); +} + +export function createRateLimitGuard(options: RateLimitOptions) { + const message = options.message || DEFAULT_MESSAGE; + + return async function rateLimitGuard(request: FastifyRequest, reply: FastifyReply) { + const nowMs = Date.now(); + pruneExpiredEntries(nowMs); + + const key = getRateLimitKey(options.bucket, request); + const current = rateLimitStore.get(key); + + if (!current || current.resetAt <= nowMs) { + rateLimitStore.set(key, { + count: 1, + resetAt: nowMs + options.windowMs, + }); + return; + } + + if (current.count >= options.max) { + const retryAfterSec = Math.max(1, Math.ceil((current.resetAt - nowMs) / 1000)); + reply + .code(429) + .header('retry-after', String(retryAfterSec)) + .send({ success: false, message }); + return; + } + + current.count += 1; + rateLimitStore.set(key, current); + }; +} diff --git a/src/server/proxy-core/capabilities/conversationFileCapabilities.test.ts b/src/server/proxy-core/capabilities/conversationFileCapabilities.test.ts new file mode 100644 index 00000000..f606dde5 --- /dev/null +++ b/src/server/proxy-core/capabilities/conversationFileCapabilities.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from 'vitest'; + +import { + rankConversationFileEndpoints, + resolveConversationFileEndpointCapability, + summarizeConversationFileInputsInOpenAiBody, +} from './conversationFileCapabilities.js'; + +describe('conversationFileCapabilities', () => { + it('summarizes image, audio, document, and remote file inputs from OpenAI bodies', () => { + const summary = summarizeConversationFileInputsInOpenAiBody({ + messages: [ + { + role: 'user', + content: [ + { type: 'image_url', image_url: { url: 'data:image/png;base64,AAAA' } }, + { type: 'input_audio', input_audio: { data: 'UklGRg==', format: 'wav' } }, + { + type: 'file', + file: { + filename: 'brief.pdf', + file_data: 'JVBERi0xLjc=', + }, + }, + { + type: 'input_file', + filename: 'remote.pdf', + file_url: 'https://example.com/remote.pdf', + }, + ], + }, + ], + }); + + expect(summary).toEqual({ + hasImage: true, + hasAudio: true, + hasDocument: true, + hasRemoteDocumentUrl: true, + }); + }); + + it('describes native remote-document support for claude messages and inline-only support for gemini chat paths', () => { + expect(resolveConversationFileEndpointCapability({ + sitePlatform: 'claude', + endpoint: 'messages', + })).toMatchObject({ + image: 'native', + audio: 'unsupported', + document: 'native', + preservesRemoteDocumentUrl: true, + }); + + expect(resolveConversationFileEndpointCapability({ + sitePlatform: 'gemini-cli', + endpoint: 'chat', + })).toMatchObject({ + image: 'native', + audio: 'native', + document: 'inline_only', + preservesRemoteDocumentUrl: false, + }); + + expect(resolveConversationFileEndpointCapability({ + sitePlatform: 'new-api', + endpoint: 'messages', + })).toMatchObject({ + image: 'native', + audio: 'unsupported', + document: 'inline_only', + preservesRemoteDocumentUrl: false, + }); + }); + + it('ranks document-capable endpoints ahead of lossy fallbacks', () => { + const ranked = rankConversationFileEndpoints({ + sitePlatform: 'new-api', + requestedOrder: ['chat', 'messages', 'responses'], + summary: { + hasImage: false, + hasAudio: false, + hasDocument: true, + hasRemoteDocumentUrl: false, + }, + }); + + expect(ranked).toEqual(['responses', 'messages', 'chat']); + }); +}); diff --git a/src/server/proxy-core/capabilities/conversationFileCapabilities.ts b/src/server/proxy-core/capabilities/conversationFileCapabilities.ts new file mode 100644 index 00000000..bd46e90b --- /dev/null +++ b/src/server/proxy-core/capabilities/conversationFileCapabilities.ts @@ -0,0 +1,268 @@ +import { inferInputFileMimeType, normalizeInputFileBlock } from '../../transformers/shared/inputFile.js'; +import { classifyConversationFileMimeType } from '../../../shared/conversationFileTypes.js'; + +export type ConversationFileTransport = 'unsupported' | 'inline_only' | 'native'; +export type ConversationFileEndpoint = 'chat' | 'messages' | 'responses'; + +export type ConversationFileInputSummary = { + hasImage: boolean; + hasAudio: boolean; + hasDocument: boolean; + hasRemoteDocumentUrl: boolean; +}; + +export type ConversationFileEndpointCapability = { + image: ConversationFileTransport; + audio: ConversationFileTransport; + document: ConversationFileTransport; + preservesRemoteDocumentUrl: boolean; +}; + +function isRecord(value: unknown): value is Record<string, unknown> { + return !!value && typeof value === 'object' && !Array.isArray(value); +} + +function asTrimmedString(value: unknown): string { + return typeof value === 'string' ? value.trim() : ''; +} + +function appendConversationFileSummary( + summary: ConversationFileInputSummary, + item: Record<string, unknown>, +): void { + const normalizedFile = normalizeInputFileBlock(item); + if (normalizedFile) { + const mimeType = inferInputFileMimeType(normalizedFile); + const family = classifyConversationFileMimeType(mimeType); + if (family === 'image') { + summary.hasImage = true; + return; + } + if (family === 'audio') { + summary.hasAudio = true; + return; + } + summary.hasDocument = true; + if (normalizedFile.fileUrl) { + summary.hasRemoteDocumentUrl = true; + } + return; + } + + const type = asTrimmedString(item.type).toLowerCase(); + if (type === 'image_url' || type === 'input_image') { + summary.hasImage = true; + return; + } + + if (type === 'input_audio') { + summary.hasAudio = true; + return; + } + + if (Array.isArray(item.content)) { + for (const nested of item.content) { + if (isRecord(nested)) { + appendConversationFileSummary(summary, nested); + } + } + } +} + +function createEmptySummary(): ConversationFileInputSummary { + return { + hasImage: false, + hasAudio: false, + hasDocument: false, + hasRemoteDocumentUrl: false, + }; +} + +export function summarizeConversationFileInputsInOpenAiBody( + body: Record<string, unknown>, +): ConversationFileInputSummary { + const summary = createEmptySummary(); + const messages = Array.isArray(body.messages) ? body.messages : []; + for (const message of messages) { + if (!isRecord(message)) continue; + const content = message.content; + if (Array.isArray(content)) { + for (const item of content) { + if (isRecord(item)) appendConversationFileSummary(summary, item); + } + continue; + } + if (isRecord(content)) { + appendConversationFileSummary(summary, content); + } + } + return summary; +} + +export function summarizeConversationFileInputsInResponsesBody( + body: Record<string, unknown>, +): ConversationFileInputSummary { + const summary = createEmptySummary(); + const input = body.input; + if (Array.isArray(input)) { + for (const item of input) { + if (isRecord(item)) appendConversationFileSummary(summary, item); + } + return summary; + } + if (isRecord(input)) { + appendConversationFileSummary(summary, input); + } + return summary; +} + +export function resolveConversationFileEndpointCapability(input: { + sitePlatform?: string; + endpoint: ConversationFileEndpoint; +}): ConversationFileEndpointCapability { + const platform = asTrimmedString(input.sitePlatform).toLowerCase(); + const endpoint = input.endpoint; + + if (platform === 'codex') { + if (endpoint === 'responses') { + return { + image: 'native', + audio: 'native', + document: 'native', + preservesRemoteDocumentUrl: true, + }; + } + return { + image: 'unsupported', + audio: 'unsupported', + document: 'unsupported', + preservesRemoteDocumentUrl: false, + }; + } + + if (platform === 'claude') { + if (endpoint === 'messages') { + return { + image: 'native', + audio: 'unsupported', + document: 'native', + preservesRemoteDocumentUrl: true, + }; + } + return { + image: 'unsupported', + audio: 'unsupported', + document: 'unsupported', + preservesRemoteDocumentUrl: false, + }; + } + + if (platform === 'gemini-cli' || platform === 'antigravity') { + if (endpoint === 'chat') { + return { + image: 'native', + audio: 'native', + document: 'inline_only', + preservesRemoteDocumentUrl: false, + }; + } + return { + image: 'unsupported', + audio: 'unsupported', + document: 'unsupported', + preservesRemoteDocumentUrl: false, + }; + } + + if (platform === 'gemini') { + if (endpoint === 'responses') { + return { + image: 'native', + audio: 'native', + document: 'native', + preservesRemoteDocumentUrl: true, + }; + } + if (endpoint === 'chat') { + return { + image: 'native', + audio: 'native', + document: 'inline_only', + preservesRemoteDocumentUrl: false, + }; + } + return { + image: 'unsupported', + audio: 'unsupported', + document: 'unsupported', + preservesRemoteDocumentUrl: false, + }; + } + + if (endpoint === 'responses') { + return { + image: 'native', + audio: 'native', + document: 'native', + preservesRemoteDocumentUrl: true, + }; + } + + if (endpoint === 'messages') { + return { + image: 'native', + audio: 'unsupported', + document: 'inline_only', + preservesRemoteDocumentUrl: false, + }; + } + + return { + image: 'native', + audio: 'native', + document: 'inline_only', + preservesRemoteDocumentUrl: false, + }; +} + +export function rankConversationFileEndpoints(input: { + sitePlatform?: string; + requestedOrder: ConversationFileEndpoint[]; + summary: ConversationFileInputSummary; + preferMessagesForClaudeModel?: boolean; +}): ConversationFileEndpoint[] { + if (!input.summary.hasDocument) { + return [...input.requestedOrder]; + } + + const isDocumentCompatible = (endpoint: ConversationFileEndpoint) => { + const capability = resolveConversationFileEndpointCapability({ + sitePlatform: input.sitePlatform, + endpoint, + }); + if (capability.document === 'unsupported') return false; + if (input.summary.hasRemoteDocumentUrl && !capability.preservesRemoteDocumentUrl) return false; + return true; + }; + + const preferredDocumentOrder = input.preferMessagesForClaudeModel === true + ? ['messages', 'responses', 'chat'] + : ['responses', 'messages', 'chat']; + + const supportedDocumentEndpoints = preferredDocumentOrder.filter((endpoint) => { + if (!input.requestedOrder.includes(endpoint as ConversationFileEndpoint)) return false; + return isDocumentCompatible(endpoint as ConversationFileEndpoint); + }) as ConversationFileEndpoint[]; + + if (supportedDocumentEndpoints.length <= 0) { + return [...input.requestedOrder]; + } + + return [ + ...supportedDocumentEndpoints, + ...input.requestedOrder.filter((endpoint) => ( + !supportedDocumentEndpoints.includes(endpoint) + && isDocumentCompatible(endpoint) + )), + ]; +} diff --git a/src/server/proxy-core/cliProfiles/claudeCodeProfile.ts b/src/server/proxy-core/cliProfiles/claudeCodeProfile.ts new file mode 100644 index 00000000..0f672187 --- /dev/null +++ b/src/server/proxy-core/cliProfiles/claudeCodeProfile.ts @@ -0,0 +1,56 @@ +import type { CliProfileDefinition } from './types.js'; + +function isRecord(value: unknown): value is Record<string, unknown> { + return !!value && typeof value === 'object' && !Array.isArray(value); +} + +const claudeCodeUserIdPattern = /^user_[0-9a-f]{64}_account__session_[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +function isClaudeSurface(path: string): boolean { + const normalizedPath = path.trim().toLowerCase(); + return normalizedPath === '/v1/messages' + || normalizedPath === '/anthropic/v1/messages' + || normalizedPath === '/v1/messages/count_tokens'; +} + +export function extractClaudeCodeSessionId(userId: string): string | null { + const trimmed = userId.trim(); + if (!claudeCodeUserIdPattern.test(trimmed)) return null; + + const sessionPrefix = '__session_'; + const sessionIndex = trimmed.lastIndexOf(sessionPrefix); + if (sessionIndex === -1) return null; + + const sessionId = trimmed.slice(sessionIndex + sessionPrefix.length).trim(); + return sessionId || null; +} + +export const claudeCodeCliProfile: CliProfileDefinition = { + id: 'claude_code', + capabilities: { + supportsResponsesCompact: false, + supportsResponsesWebsocketIncremental: false, + preservesContinuation: true, + supportsCountTokens: true, + echoesTurnState: false, + }, + detect(input) { + if (!isClaudeSurface(input.downstreamPath)) return null; + if (!isRecord(input.body) || !isRecord(input.body.metadata)) return null; + + const userId = typeof input.body.metadata.user_id === 'string' + ? input.body.metadata.user_id.trim() + : ''; + const sessionId = userId ? extractClaudeCodeSessionId(userId) : null; + if (!sessionId) return null; + + return { + id: 'claude_code', + sessionId, + traceHint: sessionId, + clientAppId: 'claude_code', + clientAppName: 'Claude Code', + clientConfidence: 'exact', + }; + }, +}; diff --git a/src/server/proxy-core/cliProfiles/codexProfile.test.ts b/src/server/proxy-core/cliProfiles/codexProfile.test.ts new file mode 100644 index 00000000..f8d2b75a --- /dev/null +++ b/src/server/proxy-core/cliProfiles/codexProfile.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from 'vitest'; +import { detectCodexOfficialClientApp } from './codexProfile.js'; + +describe('detectCodexOfficialClientApp', () => { + it('returns null for missing or empty headers', () => { + expect(detectCodexOfficialClientApp()).toBeNull(); + expect(detectCodexOfficialClientApp({})).toBeNull(); + }); + + it('detects official Codex clients from originator prefixes', () => { + expect(detectCodexOfficialClientApp({ + originator: 'codex_exec', + })).toEqual({ + clientAppId: 'codex_exec', + clientAppName: 'Codex Exec', + }); + }); + + it('detects official Codex clients from user-agent prefixes', () => { + expect(detectCodexOfficialClientApp({ + 'user-agent': 'Mozilla/5.0 codex_chatgpt_desktop/1.2.3', + })).toEqual({ + clientAppId: 'codex_chatgpt_desktop', + clientAppName: 'Codex Desktop', + }); + }); + + it('matches headers case-insensitively and from header arrays', () => { + expect(detectCodexOfficialClientApp({ + originator: 'CODEX_EXEC', + })).toEqual({ + clientAppId: 'codex_exec', + clientAppName: 'Codex Exec', + }); + expect(detectCodexOfficialClientApp({ + 'user-agent': 'Mozilla/5.0 CODEX_chatgpt_desktop/1.2', + })).toEqual({ + clientAppId: 'codex_chatgpt_desktop', + clientAppName: 'Codex Desktop', + }); + expect(detectCodexOfficialClientApp({ + 'user-agent': ['Mozilla/5.0', 'codex_chatgpt_desktop/1.2'], + })).toEqual({ + clientAppId: 'codex_chatgpt_desktop', + clientAppName: 'Codex Desktop', + }); + expect(detectCodexOfficialClientApp({ + originator: ['ignored', 'other-client'], + })).toBeNull(); + }); + + it('returns null for non-official Codex clients', () => { + expect(detectCodexOfficialClientApp({ + 'user-agent': 'OpenClaw/1.0', + })).toBe(null); + }); +}); diff --git a/src/server/proxy-core/cliProfiles/codexProfile.ts b/src/server/proxy-core/cliProfiles/codexProfile.ts new file mode 100644 index 00000000..291f34a0 --- /dev/null +++ b/src/server/proxy-core/cliProfiles/codexProfile.ts @@ -0,0 +1,209 @@ +import type { CliProfileDefinition, DetectCliProfileInput } from './types.js'; + +type CodexOfficialClientApp = { + clientAppId: string; + clientAppName: string; +}; + +const CODEX_OFFICIAL_CLIENT_USER_AGENT_PREFIXES = [ + 'codex_cli_rs/', + 'codex_vscode/', + 'codex_app/', + 'codex_chatgpt_desktop/', + 'codex_atlas/', + 'codex_exec/', + 'codex_sdk_ts/', + 'codex ', +]; + +const CODEX_OFFICIAL_CLIENT_ORIGINATOR_PREFIXES = [ + 'codex_', + 'codex ', +]; + +const CODEX_OFFICIAL_CLIENT_APP_RULES = [ + { + id: 'codex_cli_rs', + name: 'Codex CLI', + userAgentPrefixes: ['codex_cli_rs/'], + originatorPrefixes: ['codex_cli_rs'], + }, + { + id: 'codex_vscode', + name: 'Codex VSCode', + userAgentPrefixes: ['codex_vscode/'], + originatorPrefixes: ['codex_vscode'], + }, + { + id: 'codex_app', + name: 'Codex App', + userAgentPrefixes: ['codex_app/'], + originatorPrefixes: ['codex_app'], + }, + { + id: 'codex_chatgpt_desktop', + name: 'Codex Desktop', + userAgentPrefixes: ['codex_chatgpt_desktop/', 'codex desktop/'], + originatorPrefixes: ['codex_chatgpt_desktop', 'codex desktop'], + }, + { + id: 'codex_atlas', + name: 'Codex Atlas', + userAgentPrefixes: ['codex_atlas/'], + originatorPrefixes: ['codex_atlas'], + }, + { + id: 'codex_exec', + name: 'Codex Exec', + userAgentPrefixes: ['codex_exec/'], + originatorPrefixes: ['codex_exec'], + }, + { + id: 'codex_sdk_ts', + name: 'Codex SDK TS', + userAgentPrefixes: ['codex_sdk_ts/'], + originatorPrefixes: ['codex_sdk_ts'], + }, +] as const; + +function headerValueToStrings(value: unknown): string[] { + if (typeof value === 'string') { + const trimmed = value.trim(); + return trimmed ? [trimmed] : []; + } + + if (Array.isArray(value)) { + const values: string[] = []; + for (const item of value) { + if (typeof item !== 'string') continue; + const trimmed = item.trim(); + if (trimmed) values.push(trimmed); + } + return values; + } + + return []; +} + +function headerValueToString(value: unknown): string | null { + return headerValueToStrings(value)[0] || null; +} + +function getHeaderValue(headers: Record<string, unknown> | undefined, targetKey: string): string | null { + return getHeaderValues(headers, targetKey)[0] || null; +} + +function getHeaderValues(headers: Record<string, unknown> | undefined, targetKey: string): string[] { + if (!headers) return []; + const normalizedTarget = targetKey.trim().toLowerCase(); + const values: string[] = []; + + for (const [rawKey, rawValue] of Object.entries(headers)) { + if (rawKey.trim().toLowerCase() !== normalizedTarget) continue; + values.push(...headerValueToStrings(rawValue)); + } + return values; +} + +function hasHeaderPrefix(headers: Record<string, unknown> | undefined, prefix: string): boolean { + if (!headers) return false; + const normalizedPrefix = prefix.trim().toLowerCase(); + return Object.entries(headers).some(([rawKey, rawValue]) => { + const key = rawKey.trim().toLowerCase(); + return key.startsWith(normalizedPrefix) && !!headerValueToString(rawValue); + }); +} + +function matchesHeaderPrefixes(value: string | string[] | null, prefixes: readonly string[]): boolean { + const values = Array.isArray(value) + ? value.map((item) => item.trim().toLowerCase()).filter(Boolean) + : [value?.trim().toLowerCase() || ''].filter(Boolean); + if (values.length === 0) return false; + + return values.some((normalizedValue) => prefixes.some((prefix) => { + const normalizedPrefix = prefix.trim().toLowerCase(); + if (!normalizedPrefix) return false; + return normalizedValue.startsWith(normalizedPrefix) + || normalizedValue.includes(normalizedPrefix); + })); +} + +function isCodexPath(path: string): boolean { + const normalizedPath = path.trim().toLowerCase(); + return normalizedPath.startsWith('/v1/responses') + || normalizedPath === '/v1/chat/completions' + || normalizedPath.startsWith('/v1/messages'); +} + +export function detectCodexOfficialClientApp( + headers?: Record<string, unknown>, +): CodexOfficialClientApp | null { + for (const rule of CODEX_OFFICIAL_CLIENT_APP_RULES) { + const matchesOriginator = matchesHeaderPrefixes(getHeaderValues(headers, 'originator'), rule.originatorPrefixes); + const matchesUserAgent = matchesHeaderPrefixes(getHeaderValues(headers, 'user-agent'), rule.userAgentPrefixes); + if (!matchesOriginator && !matchesUserAgent) continue; + return { + clientAppId: rule.id, + clientAppName: rule.name, + }; + } + return null; +} + +export function isCodexResponsesSurface(headers?: Record<string, unknown>): boolean { + return isCodexRequest({ + downstreamPath: '/v1/responses', + headers, + }); +} + +export function getCodexSessionId(headers?: Record<string, unknown>): string | null { + return getHeaderValue(headers, 'session_id') || getHeaderValue(headers, 'session-id'); +} + +export function isCodexRequest(input: DetectCliProfileInput): boolean { + if (!isCodexPath(input.downstreamPath)) return false; + const headers = input.headers; + if (!headers) return false; + + const originator = getHeaderValues(headers, 'originator'); + if (matchesHeaderPrefixes(originator, CODEX_OFFICIAL_CLIENT_ORIGINATOR_PREFIXES)) return true; + if (matchesHeaderPrefixes(getHeaderValues(headers, 'user-agent'), CODEX_OFFICIAL_CLIENT_USER_AGENT_PREFIXES)) return true; + if (getHeaderValue(headers, 'openai-beta')) return true; + if (hasHeaderPrefix(headers, 'x-stainless-')) return true; + if (getCodexSessionId(headers)) return true; + if (getHeaderValue(headers, 'x-codex-turn-state')) return true; + return false; +} + +export const codexCliProfile: CliProfileDefinition = { + id: 'codex', + capabilities: { + supportsResponsesCompact: true, + supportsResponsesWebsocketIncremental: true, + preservesContinuation: true, + supportsCountTokens: false, + echoesTurnState: true, + }, + detect(input) { + if (!isCodexRequest(input)) return null; + + const sessionId = getCodexSessionId(input.headers) || undefined; + const clientApp = detectCodexOfficialClientApp(input.headers); + return { + id: 'codex', + ...(sessionId ? { sessionId, traceHint: sessionId } : {}), + ...(clientApp + ? { + clientAppId: clientApp.clientAppId, + clientAppName: clientApp.clientAppName, + clientConfidence: 'exact' as const, + } + : { + clientAppId: 'codex', + clientAppName: 'Codex', + clientConfidence: 'heuristic' as const, + }), + }; + }, +}; diff --git a/src/server/proxy-core/cliProfiles/geminiCliProfile.ts b/src/server/proxy-core/cliProfiles/geminiCliProfile.ts new file mode 100644 index 00000000..fc9c7fcf --- /dev/null +++ b/src/server/proxy-core/cliProfiles/geminiCliProfile.ts @@ -0,0 +1,40 @@ +import type { CliProfileDefinition } from './types.js'; + +function isRecord(value: unknown): value is Record<string, unknown> { + return !!value && typeof value === 'object' && !Array.isArray(value); +} + +function isGeminiCliPath(path: string): boolean { + const normalizedPath = path.trim().toLowerCase(); + return normalizedPath === '/v1internal:generatecontent' + || normalizedPath === '/v1internal:streamgeneratecontent' + || normalizedPath === '/v1internal:counttokens'; +} + +function hasGeminiCliBodyShape(body: unknown): boolean { + if (!isRecord(body)) return false; + return typeof body.model === 'string' + || Array.isArray(body.contents) + || (isRecord(body.request) && (Array.isArray(body.request.contents) || typeof body.request.model === 'string')); +} + +export const geminiCliProfile: CliProfileDefinition = { + id: 'gemini_cli', + capabilities: { + supportsResponsesCompact: false, + supportsResponsesWebsocketIncremental: false, + preservesContinuation: false, + supportsCountTokens: true, + echoesTurnState: false, + }, + detect(input) { + if (!isGeminiCliPath(input.downstreamPath)) return null; + if (input.body !== undefined && !hasGeminiCliBodyShape(input.body)) return null; + return { + id: 'gemini_cli', + clientAppId: 'gemini_cli', + clientAppName: 'Gemini CLI', + clientConfidence: 'exact', + }; + }, +}; diff --git a/src/server/proxy-core/cliProfiles/genericProfile.ts b/src/server/proxy-core/cliProfiles/genericProfile.ts new file mode 100644 index 00000000..8bda115c --- /dev/null +++ b/src/server/proxy-core/cliProfiles/genericProfile.ts @@ -0,0 +1,17 @@ +import type { CliProfileDefinition } from './types.js'; + +export const genericCliProfile: CliProfileDefinition = { + id: 'generic', + capabilities: { + supportsResponsesCompact: false, + supportsResponsesWebsocketIncremental: false, + preservesContinuation: false, + supportsCountTokens: false, + echoesTurnState: false, + }, + detect() { + return { + id: 'generic', + }; + }, +}; diff --git a/src/server/proxy-core/cliProfiles/registry.test.ts b/src/server/proxy-core/cliProfiles/registry.test.ts new file mode 100644 index 00000000..89b66a22 --- /dev/null +++ b/src/server/proxy-core/cliProfiles/registry.test.ts @@ -0,0 +1,137 @@ +import { describe, expect, it } from 'vitest'; + +import { detectCliProfile } from './registry.js'; + +describe('detectCliProfile', () => { + it('detects Codex responses requests and exposes Codex capability flags', () => { + expect(detectCliProfile({ + downstreamPath: '/v1/responses', + headers: { + originator: 'codex_cli_rs', + Session_id: 'codex-session-123', + }, + })).toEqual({ + id: 'codex', + sessionId: 'codex-session-123', + traceHint: 'codex-session-123', + clientAppId: 'codex_cli_rs', + clientAppName: 'Codex CLI', + clientConfidence: 'exact', + capabilities: { + supportsResponsesCompact: true, + supportsResponsesWebsocketIncremental: true, + preservesContinuation: true, + supportsCountTokens: false, + echoesTurnState: true, + }, + }); + }); + + it('treats x-codex-turn-state as a Codex marker even when session_id is absent', () => { + expect(detectCliProfile({ + downstreamPath: '/v1/responses', + headers: { + 'x-codex-turn-state': 'turn-state-123', + }, + })).toMatchObject({ + id: 'codex', + capabilities: { + supportsResponsesWebsocketIncremental: true, + echoesTurnState: true, + }, + }); + }); + + it('detects broader Codex official-client headers from user-agent and originator prefixes', () => { + expect(detectCliProfile({ + downstreamPath: '/v1/responses', + headers: { + 'user-agent': 'Mozilla/5.0 codex_chatgpt_desktop/1.2.3', + }, + })).toMatchObject({ + id: 'codex', + capabilities: { + supportsResponsesWebsocketIncremental: true, + echoesTurnState: true, + }, + }); + + expect(detectCliProfile({ + downstreamPath: '/v1/responses', + headers: { + originator: 'codex_exec', + }, + })).toMatchObject({ + id: 'codex', + capabilities: { + supportsResponsesWebsocketIncremental: true, + echoesTurnState: true, + }, + }); + }); + + it('detects Claude Code requests on the count_tokens surface and exposes token counting support', () => { + expect(detectCliProfile({ + downstreamPath: '/v1/messages/count_tokens', + body: { + metadata: { + user_id: 'user_20836b5653ed68aa981604f502c0a491397f6053826a93c953423632578d38ad_account__session_f25958b8-e75c-455d-8b40-f006d87cc2a4', + }, + }, + })).toEqual({ + id: 'claude_code', + sessionId: 'f25958b8-e75c-455d-8b40-f006d87cc2a4', + traceHint: 'f25958b8-e75c-455d-8b40-f006d87cc2a4', + clientAppId: 'claude_code', + clientAppName: 'Claude Code', + clientConfidence: 'exact', + capabilities: { + supportsResponsesCompact: false, + supportsResponsesWebsocketIncremental: false, + preservesContinuation: true, + supportsCountTokens: true, + echoesTurnState: false, + }, + }); + }); + + it('detects Gemini CLI internal routes and exposes Gemini CLI capability flags', () => { + expect(detectCliProfile({ + downstreamPath: '/v1internal:countTokens', + body: { + model: 'gpt-4.1', + contents: [{ role: 'user', parts: [{ text: 'hello' }] }], + }, + })).toEqual({ + id: 'gemini_cli', + clientAppId: 'gemini_cli', + clientAppName: 'Gemini CLI', + clientConfidence: 'exact', + capabilities: { + supportsResponsesCompact: false, + supportsResponsesWebsocketIncremental: false, + preservesContinuation: false, + supportsCountTokens: true, + echoesTurnState: false, + }, + }); + }); + + it('falls back to generic for native Gemini routes', () => { + expect(detectCliProfile({ + downstreamPath: '/v1beta/models/gemini-2.5-flash:generateContent', + body: { + contents: [{ role: 'user', parts: [{ text: 'hello' }] }], + }, + })).toEqual({ + id: 'generic', + capabilities: { + supportsResponsesCompact: false, + supportsResponsesWebsocketIncremental: false, + preservesContinuation: false, + supportsCountTokens: false, + echoesTurnState: false, + }, + }); + }); +}); diff --git a/src/server/proxy-core/cliProfiles/registry.ts b/src/server/proxy-core/cliProfiles/registry.ts new file mode 100644 index 00000000..4f69536a --- /dev/null +++ b/src/server/proxy-core/cliProfiles/registry.ts @@ -0,0 +1,38 @@ +import { claudeCodeCliProfile } from './claudeCodeProfile.js'; +import { codexCliProfile } from './codexProfile.js'; +import { geminiCliProfile } from './geminiCliProfile.js'; +import { genericCliProfile } from './genericProfile.js'; +import type { CliProfileDefinition, CliProfileId, DetectCliProfileInput, DetectedCliProfile } from './types.js'; + +const orderedProfiles: CliProfileDefinition[] = [ + claudeCodeCliProfile, + codexCliProfile, + geminiCliProfile, +]; + +export const cliProfileRegistry: Record<CliProfileId, CliProfileDefinition> = { + generic: genericCliProfile, + codex: codexCliProfile, + claude_code: claudeCodeCliProfile, + gemini_cli: geminiCliProfile, +}; + +export function getCliProfileDefinition(id: CliProfileId): CliProfileDefinition { + return cliProfileRegistry[id]; +} + +export function detectCliProfile(input: DetectCliProfileInput): DetectedCliProfile { + for (const profile of orderedProfiles) { + const detected = profile.detect(input); + if (!detected) continue; + return { + ...detected, + capabilities: profile.capabilities, + }; + } + + return { + id: genericCliProfile.id, + capabilities: genericCliProfile.capabilities, + }; +} diff --git a/src/server/proxy-core/cliProfiles/types.ts b/src/server/proxy-core/cliProfiles/types.ts new file mode 100644 index 00000000..5f95c67e --- /dev/null +++ b/src/server/proxy-core/cliProfiles/types.ts @@ -0,0 +1,37 @@ +export type CliProfileId = + | 'generic' + | 'codex' + | 'claude_code' + | 'gemini_cli'; + +export type CliProfileCapabilities = { + supportsResponsesCompact: boolean; + supportsResponsesWebsocketIncremental: boolean; + preservesContinuation: boolean; + supportsCountTokens: boolean; + echoesTurnState: boolean; +}; + +export type CliProfileClientConfidence = 'exact' | 'heuristic'; + +export type DetectCliProfileInput = { + downstreamPath: string; + headers?: Record<string, unknown>; + body?: unknown; +}; + +export type DetectedCliProfile = { + id: CliProfileId; + sessionId?: string; + traceHint?: string; + clientAppId?: string; + clientAppName?: string; + clientConfidence?: CliProfileClientConfidence; + capabilities: CliProfileCapabilities; +}; + +export type CliProfileDefinition = { + id: CliProfileId; + capabilities: CliProfileCapabilities; + detect(input: DetectCliProfileInput): Omit<DetectedCliProfile, 'capabilities'> | null; +}; diff --git a/src/server/proxy-core/conductor/DefaultProxyConductor.test.ts b/src/server/proxy-core/conductor/DefaultProxyConductor.test.ts new file mode 100644 index 00000000..932d6c5f --- /dev/null +++ b/src/server/proxy-core/conductor/DefaultProxyConductor.test.ts @@ -0,0 +1,241 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { DefaultProxyConductor } from './DefaultProxyConductor.js'; +import { terminalStreamFailure } from './streamTermination.js'; + +const baseSelectedChannel = { + channel: { id: 11, routeId: 22 }, + site: { id: 44, name: 'demo-site', url: 'https://upstream.example.com', platform: 'openai' }, + account: { id: 33, username: 'demo-user' }, + tokenName: 'default', + tokenValue: 'sk-demo', + actualModel: 'upstream-gpt', +}; + +describe('DefaultProxyConductor', () => { + it('returns the first selected channel when the first attempt succeeds', async () => { + const selectChannel = vi.fn().mockResolvedValue(baseSelectedChannel); + const selectNextChannel = vi.fn(); + const recordSuccess = vi.fn().mockResolvedValue(undefined); + const recordFailure = vi.fn().mockResolvedValue(undefined); + const conductor = new DefaultProxyConductor({ + selectChannel, + selectNextChannel, + recordSuccess, + recordFailure, + }); + const attempt = vi.fn().mockResolvedValue({ + ok: true, + response: new Response('ok', { status: 200 }), + latencyMs: 12, + cost: 0.25, + }); + + const result = await conductor.execute({ + requestedModel: 'gpt-5.4', + attempt, + }); + + expect(result).toMatchObject({ + ok: true, + selected: baseSelectedChannel, + attempts: 1, + }); + expect(selectChannel).toHaveBeenCalledWith('gpt-5.4', undefined); + expect(selectNextChannel).not.toHaveBeenCalled(); + expect(recordFailure).not.toHaveBeenCalled(); + expect(recordSuccess).toHaveBeenCalledWith(11, { + latencyMs: 12, + cost: 0.25, + }); + }); + + it('retries on the same channel when the attempt asks for a same-channel retry', async () => { + const selectChannel = vi.fn().mockResolvedValue(baseSelectedChannel); + const selectNextChannel = vi.fn(); + const recordSuccess = vi.fn().mockResolvedValue(undefined); + const recordFailure = vi.fn().mockResolvedValue(undefined); + const conductor = new DefaultProxyConductor({ + selectChannel, + selectNextChannel, + recordSuccess, + recordFailure, + }); + const attempt = vi.fn() + .mockResolvedValueOnce({ + ok: false, + action: 'retry_same_channel', + status: 429, + rawErrorText: 'rate limited', + }) + .mockResolvedValueOnce({ + ok: true, + response: new Response('ok', { status: 200 }), + }); + + const result = await conductor.execute({ + requestedModel: 'gpt-5.4', + attempt, + }); + + expect(result).toMatchObject({ + ok: true, + attempts: 2, + }); + expect(selectNextChannel).not.toHaveBeenCalled(); + expect(attempt).toHaveBeenCalledTimes(2); + expect(recordFailure).toHaveBeenCalledWith(11, { + status: 429, + rawErrorText: 'rate limited', + }); + }); + + it('fails over to the next channel when the attempt asks for failover', async () => { + const nextSelectedChannel = { + ...baseSelectedChannel, + channel: { id: 12, routeId: 22 }, + tokenValue: 'sk-next', + }; + const selectChannel = vi.fn().mockResolvedValue(baseSelectedChannel); + const selectNextChannel = vi.fn().mockResolvedValue(nextSelectedChannel); + const recordSuccess = vi.fn().mockResolvedValue(undefined); + const recordFailure = vi.fn().mockResolvedValue(undefined); + const conductor = new DefaultProxyConductor({ + selectChannel, + selectNextChannel, + recordSuccess, + recordFailure, + }); + const attempt = vi.fn() + .mockResolvedValueOnce({ + ok: false, + action: 'failover', + status: 503, + rawErrorText: 'upstream unavailable', + }) + .mockResolvedValueOnce({ + ok: true, + response: new Response('ok', { status: 200 }), + }); + + const result = await conductor.execute({ + requestedModel: 'gpt-5.4', + attempt, + }); + + expect(result).toMatchObject({ + ok: true, + selected: nextSelectedChannel, + attempts: 2, + }); + expect(selectNextChannel).toHaveBeenCalledWith('gpt-5.4', [11], undefined); + expect(recordFailure).toHaveBeenCalledWith(11, { + status: 503, + rawErrorText: 'upstream unavailable', + }); + expect(recordSuccess).toHaveBeenCalledWith(12, { + latencyMs: null, + cost: null, + }); + }); + + it('refreshes auth on 401 and retries the same channel with the refreshed selection', async () => { + const refreshedChannel = { + ...baseSelectedChannel, + tokenValue: 'sk-refreshed', + }; + const refreshAuth = vi.fn().mockResolvedValue(refreshedChannel); + const conductor = new DefaultProxyConductor({ + selectChannel: vi.fn().mockResolvedValue(baseSelectedChannel), + selectNextChannel: vi.fn(), + recordSuccess: vi.fn().mockResolvedValue(undefined), + recordFailure: vi.fn().mockResolvedValue(undefined), + refreshAuth, + }); + const attempt = vi.fn() + .mockResolvedValueOnce({ + ok: false, + action: 'refresh_auth', + status: 401, + rawErrorText: 'expired token', + }) + .mockResolvedValueOnce({ + ok: true, + response: new Response('ok', { status: 200 }), + }); + + const result = await conductor.execute({ + requestedModel: 'gpt-5.4', + attempt, + }); + + expect(result).toMatchObject({ + ok: true, + selected: refreshedChannel, + attempts: 2, + }); + expect(refreshAuth).toHaveBeenCalledWith(baseSelectedChannel, { + status: 401, + rawErrorText: 'expired token', + }); + }); + + it('returns a no_channel result when no channel is available', async () => { + const conductor = new DefaultProxyConductor({ + selectChannel: vi.fn().mockResolvedValue(null), + selectNextChannel: vi.fn(), + recordSuccess: vi.fn(), + recordFailure: vi.fn(), + previewSelectedChannel: vi.fn().mockResolvedValue(null), + }); + + expect(await conductor.previewSelectedChannel('gpt-5.4')).toBe(null); + + const result = await conductor.execute({ + requestedModel: 'gpt-5.4', + attempt: vi.fn(), + }); + + expect(result).toEqual({ + ok: false, + reason: 'no_channel', + attempts: 0, + }); + }); + + it('propagates terminal stream failures and calls the terminal failure hook', async () => { + const onTerminalFailure = vi.fn().mockResolvedValue(undefined); + const conductor = new DefaultProxyConductor({ + selectChannel: vi.fn().mockResolvedValue(baseSelectedChannel), + selectNextChannel: vi.fn(), + recordSuccess: vi.fn(), + recordFailure: vi.fn().mockResolvedValue(undefined), + }); + const attempt = vi.fn().mockResolvedValue({ + ok: false, + ...terminalStreamFailure({ + status: 502, + rawErrorText: 'stream disconnected before completion', + }), + }); + + const result = await conductor.execute({ + requestedModel: 'gpt-5.4', + attempt, + onTerminalFailure, + }); + + expect(result).toEqual({ + ok: false, + reason: 'terminal', + selected: baseSelectedChannel, + status: 502, + rawErrorText: 'stream disconnected before completion', + attempts: 1, + }); + expect(onTerminalFailure).toHaveBeenCalledWith(baseSelectedChannel, { + status: 502, + rawErrorText: 'stream disconnected before completion', + }); + }); +}); diff --git a/src/server/proxy-core/conductor/DefaultProxyConductor.ts b/src/server/proxy-core/conductor/DefaultProxyConductor.ts new file mode 100644 index 00000000..d95ac80c --- /dev/null +++ b/src/server/proxy-core/conductor/DefaultProxyConductor.ts @@ -0,0 +1,127 @@ +import { + failureActionOf, + isTerminalFailure, + shouldFailover, + shouldRefreshAuth, + shouldRetrySameChannel, +} from './retryPolicy.js'; +import type { ExecuteInput, ExecuteResult, ProxyConductorDependencies, SelectedChannelLike } from './types.js'; +import { recordFailedAttempt, recordSuccessfulAttempt } from './usageHooks.js'; + +export class DefaultProxyConductor { + constructor(private readonly deps: ProxyConductorDependencies) {} + + async previewSelectedChannel(requestedModel: string, downstreamPolicy?: unknown): Promise<SelectedChannelLike | null> { + if (this.deps.previewSelectedChannel) { + return this.deps.previewSelectedChannel(requestedModel, downstreamPolicy); + } + return this.deps.selectChannel(requestedModel, downstreamPolicy); + } + + async execute(input: ExecuteInput): Promise<ExecuteResult> { + const excludeChannelIds: number[] = []; + let attempts = 0; + let selected = await this.deps.selectChannel(input.requestedModel, input.downstreamPolicy); + if (!selected) { + return { + ok: false, + reason: 'no_channel', + attempts: 0, + }; + } + + while (selected) { + const result = await input.attempt({ + selected, + attemptIndex: attempts, + excludeChannelIds: [...excludeChannelIds], + }); + attempts += 1; + + if (result.ok) { + await recordSuccessfulAttempt(this.deps, selected.channel.id, { + latencyMs: result.latencyMs ?? null, + cost: result.cost ?? null, + }); + return { + ok: true, + selected, + response: result.response, + attempts, + }; + } + + const action = failureActionOf(result); + await recordFailedAttempt(this.deps, selected.channel.id, { + status: result.status, + rawErrorText: result.rawErrorText, + }); + + if (isTerminalFailure(action)) { + await input.onTerminalFailure?.(selected, { + status: result.status, + rawErrorText: result.rawErrorText, + }); + return { + ok: false, + reason: 'terminal', + selected, + status: result.status, + rawErrorText: result.rawErrorText, + attempts, + }; + } + + if (shouldRetrySameChannel(action)) { + continue; + } + + if (shouldRefreshAuth(action) && this.deps.refreshAuth) { + const refreshed = await this.deps.refreshAuth(selected, { + status: result.status, + rawErrorText: result.rawErrorText, + }); + if (refreshed) { + selected = refreshed; + continue; + } + } + + if (shouldFailover(action)) { + excludeChannelIds.push(selected.channel.id); + const next = await this.deps.selectNextChannel( + input.requestedModel, + excludeChannelIds, + input.downstreamPolicy, + ); + if (!next) { + return { + ok: false, + reason: 'failed', + selected, + status: result.status, + rawErrorText: result.rawErrorText, + attempts, + }; + } + selected = next; + continue; + } + + return { + ok: false, + reason: 'failed', + selected, + status: result.status, + rawErrorText: result.rawErrorText, + attempts, + }; + } + + return { + ok: false, + reason: 'failed', + attempts, + }; + } +} diff --git a/src/server/proxy-core/conductor/retryPolicy.ts b/src/server/proxy-core/conductor/retryPolicy.ts new file mode 100644 index 00000000..9658d8c8 --- /dev/null +++ b/src/server/proxy-core/conductor/retryPolicy.ts @@ -0,0 +1,21 @@ +import type { AttemptFailure, AttemptFailureAction } from './types.js'; + +export function failureActionOf(result: AttemptFailure): AttemptFailureAction { + return result.action; +} + +export function shouldRetrySameChannel(action: AttemptFailureAction): boolean { + return action === 'retry_same_channel'; +} + +export function shouldRefreshAuth(action: AttemptFailureAction): boolean { + return action === 'refresh_auth'; +} + +export function shouldFailover(action: AttemptFailureAction): boolean { + return action === 'failover' || action === 'refresh_auth'; +} + +export function isTerminalFailure(action: AttemptFailureAction): boolean { + return action === 'terminal'; +} diff --git a/src/server/proxy-core/conductor/streamTermination.ts b/src/server/proxy-core/conductor/streamTermination.ts new file mode 100644 index 00000000..8bbdc2ec --- /dev/null +++ b/src/server/proxy-core/conductor/streamTermination.ts @@ -0,0 +1,10 @@ +export function terminalStreamFailure(input: { + status?: number; + rawErrorText?: string; +}) { + return { + action: 'terminal' as const, + ...(input.status !== undefined ? { status: input.status } : {}), + ...(input.rawErrorText ? { rawErrorText: input.rawErrorText } : {}), + }; +} diff --git a/src/server/proxy-core/conductor/types.ts b/src/server/proxy-core/conductor/types.ts new file mode 100644 index 00000000..fef18c89 --- /dev/null +++ b/src/server/proxy-core/conductor/types.ts @@ -0,0 +1,80 @@ +export type SelectedChannelLike = { + channel: { id: number; routeId?: number }; + site: Record<string, unknown>; + account: Record<string, unknown>; + tokenName?: string; + tokenValue?: string; + actualModel?: string; +}; + +export type AttemptSuccess = { + ok: true; + response: Response; + latencyMs?: number | null; + cost?: number | null; +}; + +export type AttemptFailureAction = + | 'retry_same_channel' + | 'refresh_auth' + | 'failover' + | 'terminal' + | 'stop'; + +export type AttemptFailure = { + ok: false; + action: AttemptFailureAction; + status?: number; + rawErrorText?: string; + error?: unknown; +}; + +export type AttemptResult = AttemptSuccess | AttemptFailure; + +export type ExecuteAttemptContext = { + selected: SelectedChannelLike; + attemptIndex: number; + excludeChannelIds: number[]; +}; + +export type ProxyConductorDependencies = { + selectChannel: (requestedModel: string, downstreamPolicy?: unknown) => Promise<SelectedChannelLike | null>; + previewSelectedChannel?: (requestedModel: string, downstreamPolicy?: unknown) => Promise<SelectedChannelLike | null>; + selectNextChannel: ( + requestedModel: string, + excludeChannelIds: number[], + downstreamPolicy?: unknown, + ) => Promise<SelectedChannelLike | null>; + recordSuccess?: (channelId: number, metrics: { latencyMs: number | null; cost: number | null }) => Promise<void> | void; + recordFailure?: (channelId: number, failure: { status?: number; rawErrorText?: string }) => Promise<void> | void; + refreshAuth?: ( + selected: SelectedChannelLike, + failure: { status?: number; rawErrorText?: string }, + ) => Promise<SelectedChannelLike | null>; +}; + +export type ExecuteInput = { + requestedModel: string; + downstreamPolicy?: unknown; + attempt: (context: ExecuteAttemptContext) => Promise<AttemptResult>; + onTerminalFailure?: ( + selected: SelectedChannelLike, + failure: { status?: number; rawErrorText?: string }, + ) => Promise<void> | void; +}; + +export type ExecuteResult = + | { + ok: true; + selected: SelectedChannelLike; + response: Response; + attempts: number; + } + | { + ok: false; + reason: 'no_channel' | 'failed' | 'terminal'; + selected?: SelectedChannelLike; + status?: number; + rawErrorText?: string; + attempts: number; + }; diff --git a/src/server/proxy-core/conductor/usageHooks.ts b/src/server/proxy-core/conductor/usageHooks.ts new file mode 100644 index 00000000..2b8455da --- /dev/null +++ b/src/server/proxy-core/conductor/usageHooks.ts @@ -0,0 +1,20 @@ +import type { ProxyConductorDependencies } from './types.js'; + +export async function recordSuccessfulAttempt( + deps: ProxyConductorDependencies, + channelId: number, + metrics: { latencyMs?: number | null; cost?: number | null }, +): Promise<void> { + await deps.recordSuccess?.(channelId, { + latencyMs: metrics.latencyMs ?? null, + cost: metrics.cost ?? null, + }); +} + +export async function recordFailedAttempt( + deps: ProxyConductorDependencies, + channelId: number, + failure: { status?: number; rawErrorText?: string }, +): Promise<void> { + await deps.recordFailure?.(channelId, failure); +} diff --git a/src/server/proxy-core/executors/antigravityExecutor.ts b/src/server/proxy-core/executors/antigravityExecutor.ts new file mode 100644 index 00000000..96ac1b1f --- /dev/null +++ b/src/server/proxy-core/executors/antigravityExecutor.ts @@ -0,0 +1,212 @@ +import { createHash, randomUUID } from 'node:crypto'; +import { Response } from 'undici'; +import type { RuntimeDispatchInput, RuntimeExecutor, RuntimeResponse } from './types.js'; +import { + asTrimmedString, + materializeErrorResponse, + performFetch, + withRequestBody, +} from './types.js'; + +const ANTIGRAVITY_RUNTIME_BASE_URLS = [ + 'https://daily-cloudcode-pa.googleapis.com', + 'https://daily-cloudcode-pa.sandbox.googleapis.com', +] as const; + +function antigravityRequestType(modelName: string): 'image_gen' | 'agent' { + return modelName.includes('image') ? 'image_gen' : 'agent'; +} + +function generateAntigravityProjectId(): string { + const adjectives = ['useful', 'bright', 'swift', 'calm', 'bold']; + const nouns = ['signal', 'river', 'rocket', 'forest', 'bridge']; + const adjective = adjectives[Math.floor(Math.random() * adjectives.length)] || 'useful'; + const noun = nouns[Math.floor(Math.random() * nouns.length)] || 'signal'; + const suffix = Math.floor(100000 + Math.random() * 900000); + return `${adjective}-${noun}-${suffix}`; +} + +function extractFirstUserText(value: unknown): string { + if (!Array.isArray(value)) return ''; + for (const content of value) { + if (!content || typeof content !== 'object' || Array.isArray(content)) continue; + const record = content as Record<string, unknown>; + if (asTrimmedString(record.role) !== 'user') continue; + const parts = Array.isArray(record.parts) ? record.parts : []; + for (const part of parts) { + if (!part || typeof part !== 'object' || Array.isArray(part)) continue; + const text = asTrimmedString((part as Record<string, unknown>).text); + if (text) return text; + } + } + return ''; +} + +function generateStableAntigravitySessionId(payload: Record<string, unknown>): string { + const firstUserText = extractFirstUserText( + payload.request && typeof payload.request === 'object' && !Array.isArray(payload.request) + ? (payload.request as Record<string, unknown>).contents + : undefined, + ); + if (!firstUserText) { + return `-${BigInt(`0x${randomUUID().replace(/-/g, '').slice(0, 16)}`).toString()}`; + } + const digest = createHash('sha256').update(firstUserText).digest('hex').slice(0, 16); + const bigint = BigInt(`0x${digest}`) & BigInt('0x7fffffffffffffff'); + return `-${bigint.toString()}`; +} + +function renameParametersJsonSchema(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map((item) => renameParametersJsonSchema(item)); + } + if (!value || typeof value !== 'object') return value; + + const input = value as Record<string, unknown>; + const output: Record<string, unknown> = {}; + for (const [key, entry] of Object.entries(input)) { + const nextKey = key === 'parametersJsonSchema' ? 'parameters' : key; + output[nextKey] = renameParametersJsonSchema(entry); + } + return output; +} + +function deleteNestedMaxOutputTokens(payload: Record<string, unknown>): void { + const request = payload.request; + if (!request || typeof request !== 'object' || Array.isArray(request)) return; + const generationConfig = (request as Record<string, unknown>).generationConfig; + if (!generationConfig || typeof generationConfig !== 'object' || Array.isArray(generationConfig)) return; + delete (generationConfig as Record<string, unknown>).maxOutputTokens; +} + +function buildAntigravityRuntimeBody( + originalBody: Record<string, unknown>, + modelName: string, + action?: NonNullable<RuntimeDispatchInput['request']['runtime']>['action'], +): Record<string, unknown> { + const payload = renameParametersJsonSchema(structuredClone(originalBody)) as Record<string, unknown>; + if (action === 'countTokens') { + return payload; + } + const requestType = antigravityRequestType(modelName); + const projectId = asTrimmedString(payload.project) || generateAntigravityProjectId(); + + payload.model = modelName; + payload.project = projectId; + payload.userAgent = 'antigravity'; + payload.requestType = requestType; + payload.requestId = requestType === 'image_gen' + ? `image_gen/${Date.now()}/${randomUUID()}/12` + : `agent-${randomUUID()}`; + + const request = payload.request; + if (request && typeof request === 'object' && !Array.isArray(request)) { + delete (request as Record<string, unknown>).safetySettings; + if (requestType !== 'image_gen') { + (request as Record<string, unknown>).sessionId = generateStableAntigravitySessionId(payload); + } + if (modelName.includes('claude')) { + const toolConfig = ( + (request as Record<string, unknown>).toolConfig + && typeof (request as Record<string, unknown>).toolConfig === 'object' + && !Array.isArray((request as Record<string, unknown>).toolConfig) + ) + ? (request as Record<string, unknown>).toolConfig as Record<string, unknown> + : (((request as Record<string, unknown>).toolConfig = {}) as Record<string, unknown>); + toolConfig.functionCallingConfig = { mode: 'VALIDATED' }; + } else { + deleteNestedMaxOutputTokens(payload); + } + } + + return payload; +} + +function antigravityShouldRetryNoCapacity( + status: number, + responseText: string, +): boolean { + return status === 503 && responseText.toLowerCase().includes('no capacity available'); +} + +function antigravityNoCapacityRetryDelay(attempt: number): number { + return Math.min((attempt + 1) * 250, 2000); +} + +async function waitMs(ms: number): Promise<void> { + if (ms <= 0) return; + await new Promise((resolve) => setTimeout(resolve, ms)); +} + +export const antigravityExecutor: RuntimeExecutor = { + async dispatch(input: RuntimeDispatchInput) { + const modelName = asTrimmedString(input.request.runtime?.modelName) || asTrimmedString(input.request.body.model); + const runtimeBody = buildAntigravityRuntimeBody( + input.request.body, + modelName, + input.request.runtime?.action, + ); + const baseAttempts = 3; + let lastResponse: RuntimeResponse | null = null; + + attemptLoop: + for (let attempt = 0; attempt < baseAttempts; attempt += 1) { + for (const baseUrl of ANTIGRAVITY_RUNTIME_BASE_URLS) { + const requestUrl = `${baseUrl}${input.request.path}`; + const minimalHeaders: Record<string, string> = { + Authorization: input.request.headers.Authorization || input.request.headers.authorization || '', + 'Content-Type': 'application/json', + Accept: input.request.runtime?.stream ? 'text/event-stream' : 'application/json', + 'User-Agent': 'antigravity/1.19.6 darwin/arm64', + }; + let response: RuntimeResponse; + try { + response = await performFetch( + input, + withRequestBody(input.request, runtimeBody, minimalHeaders), + requestUrl, + ); + } catch (error) { + if (baseUrl !== ANTIGRAVITY_RUNTIME_BASE_URLS[ANTIGRAVITY_RUNTIME_BASE_URLS.length - 1]) { + continue; + } + if (attempt + 1 < baseAttempts) { + continue attemptLoop; + } + throw error; + } + if (response.ok) return response; + + const errorResponse = await materializeErrorResponse(response); + const errorText = await errorResponse.text().catch(() => ''); + lastResponse = new Response(errorText, { + status: errorResponse.status, + headers: errorResponse.headers, + }); + + if (errorResponse.status === 429) { + continue; + } + + if (antigravityShouldRetryNoCapacity(errorResponse.status, errorText)) { + if (baseUrl !== ANTIGRAVITY_RUNTIME_BASE_URLS[ANTIGRAVITY_RUNTIME_BASE_URLS.length - 1]) { + continue; + } + if (attempt + 1 < baseAttempts) { + await waitMs(antigravityNoCapacityRetryDelay(attempt)); + continue attemptLoop; + } + } + + return lastResponse; + } + } + + return lastResponse || performFetch(input, withRequestBody(input.request, runtimeBody, { + Authorization: input.request.headers.Authorization || input.request.headers.authorization || '', + 'Content-Type': 'application/json', + Accept: input.request.runtime?.stream ? 'text/event-stream' : 'application/json', + 'User-Agent': 'antigravity/1.19.6 darwin/arm64', + })); + }, +}; diff --git a/src/server/proxy-core/executors/claudeExecutor.ts b/src/server/proxy-core/executors/claudeExecutor.ts new file mode 100644 index 00000000..621a01b7 --- /dev/null +++ b/src/server/proxy-core/executors/claudeExecutor.ts @@ -0,0 +1,8 @@ +import type { RuntimeDispatchInput, RuntimeExecutor } from './types.js'; +import { performFetch } from './types.js'; + +export const claudeExecutor: RuntimeExecutor = { + async dispatch(input: RuntimeDispatchInput) { + return performFetch(input, input.request); + }, +}; diff --git a/src/server/proxy-core/executors/codexExecutor.ts b/src/server/proxy-core/executors/codexExecutor.ts new file mode 100644 index 00000000..eefefc0e --- /dev/null +++ b/src/server/proxy-core/executors/codexExecutor.ts @@ -0,0 +1,8 @@ +import type { RuntimeDispatchInput, RuntimeExecutor } from './types.js'; +import { performFetch } from './types.js'; + +export const codexExecutor: RuntimeExecutor = { + async dispatch(input: RuntimeDispatchInput) { + return performFetch(input, input.request); + }, +}; diff --git a/src/server/proxy-core/executors/geminiCliExecutor.ts b/src/server/proxy-core/executors/geminiCliExecutor.ts new file mode 100644 index 00000000..af2a18e7 --- /dev/null +++ b/src/server/proxy-core/executors/geminiCliExecutor.ts @@ -0,0 +1,60 @@ +import type { RuntimeDispatchInput, RuntimeExecutor, RuntimeResponse } from './types.js'; +import { + asTrimmedString, + materializeErrorResponse, + performFetch, + withRequestBody, +} from './types.js'; + +function replaceGeminiCliModelInUserAgent(userAgent: string | undefined, modelName: string): string | undefined { + const raw = asTrimmedString(userAgent); + if (!raw) return undefined; + return raw.replace(/^GeminiCLI\/([^/]+)\/[^ ]+ /i, `GeminiCLI/$1/${modelName} `); +} + +function buildGeminiCliAttemptRequest( + request: RuntimeDispatchInput['request'], + modelName: string, +) { + const body = structuredClone(request.body); + const action = request.runtime?.action; + if (action === 'countTokens') { + delete body.model; + delete body.project; + } else { + body.model = modelName; + } + const headers = { ...request.headers }; + const nextUserAgent = replaceGeminiCliModelInUserAgent( + headers['User-Agent'] || headers['user-agent'], + modelName, + ); + if (nextUserAgent) { + headers['User-Agent'] = nextUserAgent; + delete headers['user-agent']; + } + return withRequestBody(request, body, headers); +} + +export const geminiCliExecutor: RuntimeExecutor = { + async dispatch(input: RuntimeDispatchInput) { + const baseModel = asTrimmedString(input.request.runtime?.modelName) || asTrimmedString(input.request.body.model); + const models = [baseModel].filter(Boolean); + let lastResponse: RuntimeResponse | null = null; + + for (const modelName of models.length > 0 ? models : [baseModel || 'unknown']) { + const attemptRequest = buildGeminiCliAttemptRequest(input.request, modelName); + const response = await performFetch(input, attemptRequest); + if (response.ok) return response; + + if (response.status === 429) { + lastResponse = await materializeErrorResponse(response); + continue; + } + + return materializeErrorResponse(response); + } + + return lastResponse || performFetch(input, input.request); + }, +}; diff --git a/src/server/proxy-core/executors/types.test.ts b/src/server/proxy-core/executors/types.test.ts new file mode 100644 index 00000000..c1f36824 --- /dev/null +++ b/src/server/proxy-core/executors/types.test.ts @@ -0,0 +1,56 @@ +import { gzipSync, zstdCompressSync } from 'node:zlib'; +import { Response } from 'undici'; +import { describe, expect, it } from 'vitest'; +import { materializeErrorResponse, readRuntimeResponseText } from './types.js'; + +describe('readRuntimeResponseText', () => { + it('decompresses zstd responses before reading the body text', async () => { + const payload = JSON.stringify({ ok: true, text: 'hello zstd' }); + const response = new Response(zstdCompressSync(Buffer.from(payload)), { + status: 200, + headers: { + 'content-encoding': 'zstd', + 'content-type': 'application/json; charset=utf-8', + }, + }); + + await expect(readRuntimeResponseText(response)).resolves.toBe(payload); + }); + + it('decompresses stacked content-encodings in reverse order', async () => { + const payload = JSON.stringify({ ok: true, text: 'stacked' }); + const response = new Response( + zstdCompressSync(gzipSync(Buffer.from(payload))), + { + status: 200, + headers: { + 'content-encoding': 'gzip, zstd', + 'content-type': 'application/json; charset=utf-8', + }, + }, + ); + + await expect(readRuntimeResponseText(response)).resolves.toBe(payload); + }); +}); + +describe('materializeErrorResponse', () => { + it('decodes compressed error bodies and strips compression headers', async () => { + const payload = JSON.stringify({ error: { message: 'upstream failed' } }); + const response = new Response(zstdCompressSync(Buffer.from(payload)), { + status: 503, + headers: { + 'content-encoding': 'zstd', + 'content-length': '999', + 'content-type': 'application/json; charset=utf-8', + }, + }); + + const materialized = await materializeErrorResponse(response); + + await expect(materialized.text()).resolves.toBe(payload); + expect(materialized.headers.get('content-encoding')).toBeNull(); + expect(materialized.headers.get('content-length')).toBeNull(); + expect(materialized.headers.get('content-type')).toBe('application/json; charset=utf-8'); + }); +}); diff --git a/src/server/proxy-core/executors/types.ts b/src/server/proxy-core/executors/types.ts new file mode 100644 index 00000000..e0d3115d --- /dev/null +++ b/src/server/proxy-core/executors/types.ts @@ -0,0 +1,151 @@ +import { + brotliDecompressSync, + gunzipSync, + inflateSync, + zstdDecompressSync, +} from 'node:zlib'; +import { + Response, + fetch, + type RequestInit as UndiciRequestInit, + type Response as UndiciResponse, +} from 'undici'; + +export type ProxyRuntimeRequest = { + endpoint: 'chat' | 'messages' | 'responses'; + path: string; + headers: Record<string, string>; + body: Record<string, unknown>; + runtime?: { + executor: 'default' | 'codex' | 'gemini-cli' | 'antigravity' | 'claude'; + modelName?: string; + stream?: boolean; + oauthProjectId?: string | null; + action?: 'generateContent' | 'streamGenerateContent' | 'countTokens'; + }; +}; + +export type RuntimeDispatchInput = { + siteUrl: string; + request: ProxyRuntimeRequest; + targetUrl?: string; + buildInit: (requestUrl: string, request: ProxyRuntimeRequest) => Promise<UndiciRequestInit> | UndiciRequestInit; +}; + +export type RuntimeResponse = UndiciResponse; + +export type RuntimeExecutor = { + dispatch(input: RuntimeDispatchInput): Promise<RuntimeResponse>; +}; + +export function asTrimmedString(value: unknown): string { + return typeof value === 'string' ? value.trim() : ''; +} + +export function withRequestBody( + request: ProxyRuntimeRequest, + body: Record<string, unknown>, + headers?: Record<string, string>, +): ProxyRuntimeRequest { + return { + ...request, + headers: headers ? { ...headers } : { ...request.headers }, + body, + }; +} + +function buildUpstreamUrl(siteUrl: string, path: string): string { + const normalizedBase = siteUrl.replace(/\/+$/, ''); + const normalizedPath = path.startsWith('/') ? path : `/${path}`; + return `${normalizedBase}${normalizedPath}`; +} + +export async function performFetch( + input: RuntimeDispatchInput, + request: ProxyRuntimeRequest, + requestUrl = input.targetUrl || buildUpstreamUrl(input.siteUrl, request.path), +): Promise<RuntimeResponse> { + const init = await input.buildInit(requestUrl, request); + return fetch(requestUrl, init); +} + +function hasZstdContentEncoding(contentEncoding: string | null): boolean { + if (!contentEncoding) return false; + return contentEncoding + .split(',') + .some((encoding) => encoding.trim().toLowerCase() === 'zstd'); +} + +function looksLikeZstdFrame(buffer: Buffer): boolean { + return buffer.length >= 4 + && buffer[0] === 0x28 + && buffer[1] === 0xb5 + && buffer[2] === 0x2f + && buffer[3] === 0xfd; +} + +function decodeRuntimeResponseBuffer(buffer: Buffer, contentEncoding: string | null): Buffer { + if (!contentEncoding) return buffer; + + let decoded = buffer; + const encodings = contentEncoding + .split(',') + .map((encoding) => encoding.trim().toLowerCase()) + .filter(Boolean) + .reverse(); + + for (const encoding of encodings) { + if (encoding === 'zstd') { + decoded = zstdDecompressSync(decoded); + continue; + } + if (encoding === 'br') { + decoded = brotliDecompressSync(decoded); + continue; + } + if (encoding === 'gzip' || encoding === 'x-gzip') { + decoded = gunzipSync(decoded); + continue; + } + if (encoding === 'deflate') { + decoded = inflateSync(decoded); + continue; + } + } + + return decoded; +} + +export async function readRuntimeResponseText( + response: RuntimeResponse, +): Promise<string> { + const contentEncoding = typeof response.headers?.get === 'function' + ? response.headers.get('content-encoding') + : null; + if (!hasZstdContentEncoding(contentEncoding)) { + return typeof response.text === 'function' + ? response.text().catch(() => '') + : ''; + } + + const rawBuffer = Buffer.from(await response.arrayBuffer()); + try { + return decodeRuntimeResponseBuffer(rawBuffer, contentEncoding).toString('utf8'); + } catch { + return looksLikeZstdFrame(rawBuffer) ? '' : rawBuffer.toString('utf8'); + } +} + +export async function materializeErrorResponse( + response: RuntimeResponse, +): Promise<RuntimeResponse> { + if (response.ok) return response; + const text = await readRuntimeResponseText(response); + const headers = new Headers(response.headers); + headers.delete('content-encoding'); + headers.delete('content-length'); + return new Response(text, { + status: response.status, + headers, + }); +} diff --git a/src/server/proxy-core/providers/antigravityProviderProfile.ts b/src/server/proxy-core/providers/antigravityProviderProfile.ts new file mode 100644 index 00000000..40dd31be --- /dev/null +++ b/src/server/proxy-core/providers/antigravityProviderProfile.ts @@ -0,0 +1,47 @@ +import type { PreparedProviderRequest, PrepareProviderRequestInput, ProviderAction, ProviderProfile } from './types.js'; + +const ANTIGRAVITY_RUNTIME_USER_AGENT = 'antigravity/1.19.6 darwin/arm64'; + +function asTrimmedString(value: unknown): string { + return typeof value === 'string' ? value.trim() : ''; +} + +function resolveAction(action: ProviderAction | undefined, stream: boolean): ProviderAction { + if (action) return action; + return stream ? 'streamGenerateContent' : 'generateContent'; +} + +function resolvePath(action: ProviderAction): string { + if (action === 'countTokens') return '/v1internal:countTokens'; + if (action === 'streamGenerateContent') return '/v1internal:streamGenerateContent?alt=sse'; + return '/v1internal:generateContent'; +} + +export const antigravityProviderProfile: ProviderProfile = { + id: 'antigravity', + prepareRequest(input: PrepareProviderRequestInput): PreparedProviderRequest { + const action = resolveAction(input.action, input.stream); + const projectId = asTrimmedString(input.oauthProjectId); + return { + path: resolvePath(action), + headers: { + Authorization: input.baseHeaders.Authorization, + 'Content-Type': 'application/json', + Accept: action === 'streamGenerateContent' ? 'text/event-stream' : 'application/json', + 'User-Agent': ANTIGRAVITY_RUNTIME_USER_AGENT, + }, + body: { + project: projectId, + model: input.modelName, + request: input.body, + }, + runtime: { + executor: 'antigravity', + modelName: input.modelName, + stream: action === 'streamGenerateContent', + oauthProjectId: projectId, + action, + }, + }; + }, +}; diff --git a/src/server/proxy-core/providers/claudeProviderProfile.ts b/src/server/proxy-core/providers/claudeProviderProfile.ts new file mode 100644 index 00000000..946dcfdb --- /dev/null +++ b/src/server/proxy-core/providers/claudeProviderProfile.ts @@ -0,0 +1,36 @@ +import type { PreparedProviderRequest, PrepareProviderRequestInput, ProviderProfile } from './types.js'; +import { buildClaudeRuntimeHeaders } from './headerUtils.js'; + +export const claudeProviderProfile: ProviderProfile = { + id: 'claude', + prepareRequest(input: PrepareProviderRequestInput): PreparedProviderRequest { + const anthropicVersion = ( + input.claudeHeaders?.['anthropic-version'] + || input.baseHeaders['anthropic-version'] + || '2023-06-01' + ); + const isClaudeOauthUpstream = input.sitePlatform?.trim().toLowerCase() === 'claude' + && input.oauthProvider === 'claude'; + const isCountTokens = input.action === 'countTokens'; + + return { + path: isCountTokens ? '/v1/messages/count_tokens?beta=true' : '/v1/messages', + headers: buildClaudeRuntimeHeaders({ + baseHeaders: input.baseHeaders, + claudeHeaders: input.claudeHeaders ?? {}, + anthropicVersion, + stream: isCountTokens ? false : input.stream, + isClaudeOauthUpstream, + tokenValue: input.tokenValue, + }), + body: input.body, + runtime: { + executor: 'claude', + modelName: input.modelName, + stream: isCountTokens ? false : input.stream, + oauthProjectId: null, + ...(isCountTokens ? { action: 'countTokens' } : {}), + }, + }; + }, +}; diff --git a/src/server/proxy-core/providers/codexProviderProfile.ts b/src/server/proxy-core/providers/codexProviderProfile.ts new file mode 100644 index 00000000..726135bf --- /dev/null +++ b/src/server/proxy-core/providers/codexProviderProfile.ts @@ -0,0 +1,46 @@ +import type { PreparedProviderRequest, PrepareProviderRequestInput, ProviderProfile } from './types.js'; +import { config } from '../../config.js'; +import { buildCodexRuntimeHeaders, getInputHeader } from './headerUtils.js'; + +function asTrimmedString(value: unknown): string { + return typeof value === 'string' ? value.trim() : ''; +} + +export const codexProviderProfile: ProviderProfile = { + id: 'codex', + prepareRequest(input: PrepareProviderRequestInput): PreparedProviderRequest { + const isCodexOauth = asTrimmedString(input.oauthProvider).toLowerCase() === 'codex'; + const websocketTransport = getInputHeader(input.baseHeaders, 'x-metapi-responses-websocket-transport') === '1'; + const configuredUserAgent = isCodexOauth ? asTrimmedString(config.codexHeaderDefaults.userAgent) : ''; + const configuredBetaFeatures = ( + isCodexOauth && websocketTransport + ? asTrimmedString(config.codexHeaderDefaults.betaFeatures) + : '' + ); + const headers = buildCodexRuntimeHeaders({ + baseHeaders: input.baseHeaders, + providerHeaders: input.providerHeaders, + explicitSessionId: asTrimmedString(input.codexExplicitSessionId) || null, + continuityKey: asTrimmedString(input.codexSessionCacheKey) || null, + userAgentOverride: configuredUserAgent || null, + codexBetaFeatures: getInputHeader(input.baseHeaders, 'x-codex-beta-features') || configuredBetaFeatures, + codexTurnState: getInputHeader(input.baseHeaders, 'x-codex-turn-state'), + codexTurnMetadata: getInputHeader(input.baseHeaders, 'x-codex-turn-metadata'), + timingMetrics: getInputHeader(input.baseHeaders, 'x-responsesapi-include-timing-metrics'), + openAiBeta: getInputHeader(input.baseHeaders, 'openai-beta') + || (websocketTransport ? asTrimmedString(config.codexResponsesWebsocketBeta) : null), + }); + + return { + path: '/responses', + headers, + body: input.body, + runtime: { + executor: 'codex', + modelName: input.modelName, + stream: input.stream, + oauthProjectId: asTrimmedString(input.oauthProjectId) || null, + }, + }; + }, +}; diff --git a/src/server/proxy-core/providers/geminiCliProviderProfile.ts b/src/server/proxy-core/providers/geminiCliProviderProfile.ts new file mode 100644 index 00000000..71841462 --- /dev/null +++ b/src/server/proxy-core/providers/geminiCliProviderProfile.ts @@ -0,0 +1,50 @@ +import type { PreparedProviderRequest, PrepareProviderRequestInput, ProviderAction, ProviderProfile } from './types.js'; +import { buildGeminiCliRuntimeHeaders } from './headerUtils.js'; + +function asTrimmedString(value: unknown): string { + return typeof value === 'string' ? value.trim() : ''; +} + +function resolveAction(action: ProviderAction | undefined, stream: boolean): ProviderAction { + if (action) return action; + return stream ? 'streamGenerateContent' : 'generateContent'; +} + +function resolvePath(action: ProviderAction): string { + if (action === 'countTokens') return '/v1internal:countTokens'; + if (action === 'streamGenerateContent') return '/v1internal:streamGenerateContent?alt=sse'; + return '/v1internal:generateContent'; +} + +export const geminiCliProviderProfile: ProviderProfile = { + id: 'gemini-cli', + prepareRequest(input: PrepareProviderRequestInput): PreparedProviderRequest { + const projectId = asTrimmedString(input.oauthProjectId); + if (!projectId) { + throw new Error('gemini-cli oauth project id missing'); + } + const action = resolveAction(input.action, input.stream); + const headers = buildGeminiCliRuntimeHeaders({ + baseHeaders: input.baseHeaders, + providerHeaders: input.providerHeaders, + modelName: input.modelName, + stream: action === 'streamGenerateContent', + }); + return { + path: resolvePath(action), + headers, + body: { + project: projectId, + model: input.modelName, + request: input.body, + }, + runtime: { + executor: 'gemini-cli', + modelName: input.modelName, + stream: action === 'streamGenerateContent', + oauthProjectId: projectId, + action, + }, + }; + }, +}; diff --git a/src/server/proxy-core/providers/headerUtils.test.ts b/src/server/proxy-core/providers/headerUtils.test.ts new file mode 100644 index 00000000..b1c4398a --- /dev/null +++ b/src/server/proxy-core/providers/headerUtils.test.ts @@ -0,0 +1,126 @@ +import { describe, expect, it } from 'vitest'; + +describe('provider header utils', () => { + it('coerces header values and performs case-insensitive lookups', async () => { + const { headerValueToString, getInputHeader } = await import('./headerUtils.js'); + + expect(headerValueToString(' value ')).toBe('value'); + expect(headerValueToString(['', ' first ', 'second'])).toBe('first'); + expect(getInputHeader({ Authorization: 'Bearer test' }, 'authorization')).toBe('Bearer test'); + }); + + it('derives stable uuids from seeds', async () => { + const { uuidFromSeed } = await import('./headerUtils.js'); + + expect(uuidFromSeed('seed-1')).toBe(uuidFromSeed('seed-1')); + expect(uuidFromSeed('seed-1')).not.toBe(uuidFromSeed('seed-2')); + expect(uuidFromSeed('seed-1')).toMatch(/^[0-9a-f-]{36}$/); + }); + + it('merges claude beta headers without duplicating entries', async () => { + const { mergeClaudeBetaHeader } = await import('./headerUtils.js'); + + expect( + mergeClaudeBetaHeader(null, 'beta-a,beta-b', ['beta-b', 'beta-c']), + ).toBe('beta-a,beta-b,beta-c'); + expect( + mergeClaudeBetaHeader('custom-a,custom-b', 'beta-a,beta-b', ['beta-c']), + ).toBe('beta-a,beta-b,custom-a,custom-b,beta-c'); + }); + + it('preserves gemini cli runtime metadata when rebuilding user agents', async () => { + const { + buildGeminiCliUserAgent, + parseGeminiCliUserAgentRuntime, + } = await import('./headerUtils.js'); + + expect( + parseGeminiCliUserAgentRuntime('GeminiCLI/0.55.0/gemini-2.5-pro (darwin; arm64)'), + ).toEqual({ + version: '0.55.0', + platform: 'darwin', + arch: 'arm64', + }); + expect( + buildGeminiCliUserAgent( + 'gemini-2.5-flash', + 'GeminiCLI/0.55.0/gemini-2.5-pro (darwin; arm64)', + ), + ).toBe('GeminiCLI/0.55.0/gemini-2.5-flash (darwin; arm64)'); + }); + + it('builds codex runtime headers with continuity-derived session identifiers', async () => { + const { buildCodexRuntimeHeaders } = await import('./headerUtils.js'); + + const headers = buildCodexRuntimeHeaders({ + baseHeaders: { + authorization: 'Bearer test', + version: '0.101.0', + }, + providerHeaders: { + originator: 'codex_cli_rs', + 'chatgpt-account-id': 'acct-1', + }, + continuityKey: 'cache-key-1', + explicitSessionId: null, + }); + + expect(headers.Authorization).toBe('Bearer test'); + expect(headers.Originator).toBe('codex_cli_rs'); + expect(headers['Chatgpt-Account-Id']).toBe('acct-1'); + expect(headers.Session_id).toMatch(/^[0-9a-f-]{36}$/); + expect(headers.Conversation_id).toBe(headers.Session_id); + expect(headers.Accept).toBe('text/event-stream'); + }); + + it('builds claude runtime headers with merged betas and oauth bearer auth', async () => { + const { buildClaudeRuntimeHeaders } = await import('./headerUtils.js'); + + const headers = buildClaudeRuntimeHeaders({ + baseHeaders: { + 'Content-Type': 'application/json', + authorization: 'Bearer stale-token', + }, + claudeHeaders: { + 'anthropic-beta': 'custom-beta', + 'x-api-key': 'stale-api-key', + 'user-agent': 'custom-agent', + }, + anthropicVersion: '2023-06-01', + stream: true, + isClaudeOauthUpstream: true, + tokenValue: 'oauth-token', + }); + + expect(headers['anthropic-version']).toBe('2023-06-01'); + expect(headers['anthropic-beta']).toContain('claude-code-20250219'); + expect(headers['anthropic-beta']).toContain('custom-beta'); + expect(headers.Authorization).toBe('Bearer oauth-token'); + expect(headers.authorization).toBeUndefined(); + expect(headers['x-api-key']).toBeUndefined(); + expect(headers['user-agent']).toBeUndefined(); + expect(headers['User-Agent']).toBe('custom-agent'); + expect(headers.Accept).toBe('text/event-stream'); + }); + + it('builds gemini cli runtime headers with preserved api client metadata', async () => { + const { buildGeminiCliRuntimeHeaders } = await import('./headerUtils.js'); + + const headers = buildGeminiCliRuntimeHeaders({ + baseHeaders: { + authorization: 'Bearer test', + }, + providerHeaders: { + 'x-goog-api-client': 'gl-node/22 gccl/0.31.0', + 'user-agent': 'GeminiCLI/0.55.0/gemini-2.5-pro (darwin; arm64)', + }, + modelName: 'gemini-2.5-flash', + stream: true, + }); + + expect(headers.Authorization).toBe('Bearer test'); + expect(headers['X-Goog-Api-Client']).toBe('gl-node/22 gccl/0.31.0'); + expect(headers['User-Agent']).toBe('GeminiCLI/0.55.0/gemini-2.5-flash (darwin; arm64)'); + expect(headers.Accept).toBe('text/event-stream'); + }); +}); diff --git a/src/server/proxy-core/providers/headerUtils.ts b/src/server/proxy-core/providers/headerUtils.ts new file mode 100644 index 00000000..5846694b --- /dev/null +++ b/src/server/proxy-core/providers/headerUtils.ts @@ -0,0 +1,272 @@ +import { pbkdf2Sync, randomUUID } from 'node:crypto'; + +const CODEX_CLIENT_VERSION = '0.101.0'; +const CODEX_DEFAULT_USER_AGENT = 'codex_cli_rs/0.101.0 (Mac OS 26.0.1; arm64) Apple_Terminal/464'; +const CLAUDE_DEFAULT_USER_AGENT = 'claude-cli/2.1.63 (external, cli)'; +const CLAUDE_DEFAULT_BETA_HEADER = 'claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,context-management-2025-06-27,prompt-caching-scope-2026-01-05'; + +export function headerValueToString(value: unknown): string | null { + if (typeof value === 'string') { + const trimmed = value.trim(); + return trimmed || null; + } + if (Array.isArray(value)) { + for (const item of value) { + if (typeof item !== 'string') continue; + const trimmed = item.trim(); + if (trimmed) return trimmed; + } + } + return null; +} + +export function getInputHeader( + headers: Record<string, unknown> | Record<string, string> | undefined, + key: string, +): string | null { + if (!headers) return null; + for (const [candidateKey, candidateValue] of Object.entries(headers)) { + if (candidateKey.toLowerCase() !== key.toLowerCase()) continue; + return headerValueToString(candidateValue); + } + return null; +} + +function normalizeLowerCaseHeaderMap( + sources: Array<Record<string, unknown> | Record<string, string> | undefined>, + shouldSkip: (key: string) => boolean, +): Record<string, string> { + const normalized: Record<string, string> = {}; + for (const source of sources) { + if (!source) continue; + for (const [key, value] of Object.entries(source)) { + const normalizedValue = headerValueToString(value); + if (!normalizedValue) continue; + const normalizedKey = key.toLowerCase(); + if (shouldSkip(normalizedKey)) continue; + normalized[normalizedKey] = normalizedValue; + } + } + return normalized; +} + +export function uuidFromSeed(seed: string): string { + const derived = pbkdf2Sync(seed, 'metapi-runtime-header-seed', 10_000, 16, 'sha256'); + const bytes = new Uint8Array(derived); + bytes[6] = (bytes[6]! & 0x0f) | 0x50; + bytes[8] = (bytes[8]! & 0x3f) | 0x80; + const hex = Array.from(bytes, (value) => value.toString(16).padStart(2, '0')).join(''); + return [ + hex.slice(0, 8), + hex.slice(8, 12), + hex.slice(12, 16), + hex.slice(16, 20), + hex.slice(20, 32), + ].join('-'); +} + +export function mergeClaudeBetaHeader( + explicitValue: string | null, + defaultValue: string, + extraBetas: string[] = [], +): string { + const seen = new Set<string>(); + const merged: string[] = []; + for (const source of [defaultValue, explicitValue ?? '', ...extraBetas]) { + for (const entry of source.split(',')) { + const normalized = entry.trim(); + if (!normalized || seen.has(normalized)) continue; + seen.add(normalized); + merged.push(normalized); + } + } + return merged.join(','); +} + +export function parseGeminiCliUserAgentRuntime(userAgent: string | null): { + version: string; + platform: string; + arch: string; +} | null { + if (!userAgent) return null; + const match = /^GeminiCLI\/([^/]+)\/[^ ]+ \(([^;]+); ([^)]+)\)$/i.exec(userAgent.trim()); + if (!match) return null; + return { + version: match[1] || '0.31.0', + platform: match[2] || 'win32', + arch: match[3] || 'x64', + }; +} + +export function buildGeminiCliUserAgent(modelName: string, existingUserAgent?: string | null): string { + const parsed = parseGeminiCliUserAgentRuntime(existingUserAgent ?? null); + const version = parsed?.version || '0.31.0'; + const platform = parsed?.platform || 'win32'; + const arch = parsed?.arch || 'x64'; + const effectiveModel = typeof modelName === 'string' ? modelName.trim() : ''; + return `GeminiCLI/${version}/${effectiveModel || 'unknown'} (${platform}; ${arch})`; +} + +function asTrimmedString(value: unknown): string { + return typeof value === 'string' ? value.trim() : ''; +} + +export function buildCodexRuntimeHeaders(input: { + baseHeaders: Record<string, string>; + providerHeaders?: Record<string, string>; + explicitSessionId?: string | null; + continuityKey?: string | null; + versionDefault?: string; + userAgentDefault?: string; + userAgentOverride?: string | null; + originatorDefault?: string; + codexBetaFeatures?: string | null; + codexTurnState?: string | null; + codexTurnMetadata?: string | null; + timingMetrics?: string | null; + openAiBeta?: string | null; +}): Record<string, string> { + const authorization = ( + getInputHeader(input.baseHeaders, 'authorization') + || getInputHeader(input.baseHeaders, 'Authorization') + || '' + ); + const originator = getInputHeader(input.providerHeaders, 'originator') + || input.originatorDefault + || 'codex_cli_rs'; + const accountId = getInputHeader(input.providerHeaders, 'chatgpt-account-id'); + const version = getInputHeader(input.baseHeaders, 'version') + || input.versionDefault + || CODEX_CLIENT_VERSION; + const userAgent = input.userAgentOverride + || getInputHeader(input.baseHeaders, 'user-agent') + || input.userAgentDefault + || CODEX_DEFAULT_USER_AGENT; + const codexBetaFeatures = input.codexBetaFeatures || null; + const codexTurnState = input.codexTurnState || null; + const codexTurnMetadata = input.codexTurnMetadata || null; + const timingMetrics = input.timingMetrics || null; + const openAiBeta = input.openAiBeta || null; + const explicitSessionId = asTrimmedString(input.explicitSessionId); + const continuityKey = asTrimmedString(input.continuityKey); + const sessionId = ( + getInputHeader(input.baseHeaders, 'session_id') + || getInputHeader(input.baseHeaders, 'session-id') + || explicitSessionId + || (continuityKey ? uuidFromSeed(`metapi:codex:${continuityKey}`) : null) + || randomUUID() + ); + const conversationId = ( + getInputHeader(input.baseHeaders, 'conversation_id') + || getInputHeader(input.baseHeaders, 'conversation-id') + || explicitSessionId + || (continuityKey ? sessionId : null) + ); + + return { + ...(authorization ? { Authorization: authorization } : {}), + 'Content-Type': 'application/json', + ...(accountId ? { 'Chatgpt-Account-Id': accountId } : {}), + Originator: originator, + Version: version, + ...(codexBetaFeatures ? { 'x-codex-beta-features': codexBetaFeatures } : {}), + ...(codexTurnState ? { 'x-codex-turn-state': codexTurnState } : {}), + ...(codexTurnMetadata ? { 'x-codex-turn-metadata': codexTurnMetadata } : {}), + ...(timingMetrics ? { 'x-responsesapi-include-timing-metrics': timingMetrics } : {}), + ...(openAiBeta ? { 'OpenAI-Beta': openAiBeta } : {}), + Session_id: sessionId, + ...(conversationId ? { Conversation_id: conversationId } : {}), + 'User-Agent': userAgent, + Accept: 'text/event-stream', + Connection: 'Keep-Alive', + }; +} + +export function buildClaudeRuntimeHeaders(input: { + baseHeaders: Record<string, string>; + claudeHeaders: Record<string, string>; + anthropicVersion: string; + stream: boolean; + isClaudeOauthUpstream: boolean; + tokenValue: string; + extraBetas?: string[]; + defaultBetaHeader?: string; + defaultUserAgent?: string; +}): Record<string, string> { + const anthropicBeta = mergeClaudeBetaHeader( + getInputHeader(input.claudeHeaders, 'anthropic-beta'), + input.defaultBetaHeader || CLAUDE_DEFAULT_BETA_HEADER, + input.extraBetas, + ); + const passthroughHeaders = normalizeLowerCaseHeaderMap( + [input.baseHeaders, input.claudeHeaders], + (key) => ( + key === 'accept' + || key === 'accept-encoding' + || key === 'anthropic-beta' + || key === 'anthropic-dangerous-direct-browser-access' + || key === 'anthropic-version' + || key === 'authorization' + || key === 'connection' + || key === 'user-agent' + || key === 'x-api-key' + || key === 'x-app' + || key.startsWith('x-stainless-') + ), + ); + const headers: Record<string, string> = { + ...passthroughHeaders, + 'anthropic-version': input.anthropicVersion, + ...(anthropicBeta ? { 'anthropic-beta': anthropicBeta } : {}), + 'Anthropic-Dangerous-Direct-Browser-Access': 'true', + 'X-App': 'cli', + 'X-Stainless-Retry-Count': getInputHeader(input.claudeHeaders, 'x-stainless-retry-count') || '0', + 'X-Stainless-Runtime-Version': getInputHeader(input.claudeHeaders, 'x-stainless-runtime-version') || 'v24.3.0', + 'X-Stainless-Package-Version': getInputHeader(input.claudeHeaders, 'x-stainless-package-version') || '0.74.0', + 'X-Stainless-Runtime': getInputHeader(input.claudeHeaders, 'x-stainless-runtime') || 'node', + 'X-Stainless-Lang': getInputHeader(input.claudeHeaders, 'x-stainless-lang') || 'js', + 'X-Stainless-Arch': getInputHeader(input.claudeHeaders, 'x-stainless-arch') || 'x64', + 'X-Stainless-Os': getInputHeader(input.claudeHeaders, 'x-stainless-os') || 'Windows', + 'X-Stainless-Timeout': getInputHeader(input.claudeHeaders, 'x-stainless-timeout') || '600', + 'User-Agent': getInputHeader(input.claudeHeaders, 'user-agent') || input.defaultUserAgent || CLAUDE_DEFAULT_USER_AGENT, + Connection: 'keep-alive', + Accept: input.stream ? 'text/event-stream' : 'application/json', + 'Accept-Encoding': 'gzip, deflate, br, zstd', + }; + if (input.isClaudeOauthUpstream) { + headers.Authorization = `Bearer ${input.tokenValue}`; + } else { + headers['x-api-key'] = input.tokenValue; + } + return headers; +} + +export function buildGeminiCliRuntimeHeaders(input: { + baseHeaders: Record<string, string>; + providerHeaders?: Record<string, string>; + modelName: string; + stream: boolean; +}): Record<string, string> { + const authorization = getInputHeader(input.baseHeaders, 'authorization'); + const apiClient = ( + getInputHeader(input.providerHeaders, 'x-goog-api-client') + || getInputHeader(input.baseHeaders, 'x-goog-api-client') + ); + const userAgent = buildGeminiCliUserAgent( + input.modelName, + getInputHeader(input.providerHeaders, 'user-agent') || getInputHeader(input.baseHeaders, 'user-agent'), + ); + + const headers: Record<string, string> = { + ...(authorization ? { Authorization: authorization } : {}), + 'Content-Type': 'application/json', + 'User-Agent': userAgent, + }; + if (apiClient) { + headers['X-Goog-Api-Client'] = apiClient; + } + if (input.stream) { + headers.Accept = 'text/event-stream'; + } + return headers; +} diff --git a/src/server/proxy-core/providers/registry.test.ts b/src/server/proxy-core/providers/registry.test.ts new file mode 100644 index 00000000..87e2fd50 --- /dev/null +++ b/src/server/proxy-core/providers/registry.test.ts @@ -0,0 +1,186 @@ +import { describe, expect, it } from 'vitest'; + +import { resolveProviderProfile } from './registry.js'; + +describe('resolveProviderProfile', () => { + it('builds codex provider requests with codex-specific path, headers, and runtime metadata', () => { + const profile = resolveProviderProfile('codex'); + expect(profile?.id).toBe('codex'); + + const result = profile!.prepareRequest({ + endpoint: 'responses', + modelName: 'gpt-5.2-codex', + stream: true, + tokenValue: 'oauth-access-token', + sitePlatform: 'codex', + baseHeaders: { + Authorization: 'Bearer oauth-access-token', + }, + providerHeaders: { + Originator: 'codex_cli_rs', + 'Chatgpt-Account-Id': 'chatgpt-account-123', + }, + codexSessionCacheKey: 'gpt-5.2-codex:user-123', + body: { + model: 'gpt-5.2-codex', + stream: true, + store: false, + }, + }); + + expect(result.path).toBe('/responses'); + expect(result.headers.Authorization).toBe('Bearer oauth-access-token'); + expect(result.headers.Originator).toBe('codex_cli_rs'); + expect(result.headers['Chatgpt-Account-Id']).toBe('chatgpt-account-123'); + expect(result.headers.Session_id).toMatch(/^[0-9a-f-]{36}$/i); + expect(result.headers.Conversation_id).toBe(result.headers.Session_id); + expect(result.runtime).toMatchObject({ + executor: 'codex', + modelName: 'gpt-5.2-codex', + stream: true, + }); + expect(result.body).toEqual({ + model: 'gpt-5.2-codex', + stream: true, + store: false, + }); + }); + + it('builds claude provider requests without rebuilding protocol bodies', () => { + const profile = resolveProviderProfile('claude'); + expect(profile?.id).toBe('claude'); + + const protocolBody = { + model: 'claude-opus-4-6', + max_tokens: 256, + messages: [{ role: 'user', content: 'hello' }], + }; + + const result = profile!.prepareRequest({ + endpoint: 'messages', + modelName: 'claude-opus-4-6', + stream: false, + tokenValue: 'oauth-access-token', + oauthProvider: 'claude', + sitePlatform: 'claude', + baseHeaders: { + 'Content-Type': 'application/json', + }, + claudeHeaders: {}, + body: protocolBody, + }); + + expect(result.path).toBe('/v1/messages'); + expect(result.headers.Authorization).toBe('Bearer oauth-access-token'); + expect(result.headers['x-api-key']).toBeUndefined(); + expect(result.headers['anthropic-version']).toBe('2023-06-01'); + expect(result.headers['Accept-Encoding']).toBe('gzip, deflate, br, zstd'); + expect(result.runtime).toMatchObject({ + executor: 'claude', + modelName: 'claude-opus-4-6', + stream: false, + }); + expect(result.body).toBe(protocolBody); + }); + + it('builds gemini-cli provider requests with wrapped runtime envelope and project validation', () => { + const profile = resolveProviderProfile('gemini-cli'); + expect(profile?.id).toBe('gemini-cli'); + + const protocolBody = { + contents: [{ role: 'user', parts: [{ text: 'hello gemini cli' }] }], + }; + + const result = profile!.prepareRequest({ + endpoint: 'chat', + modelName: 'gemini-2.5-pro', + stream: false, + tokenValue: 'oauth-access-token', + oauthProvider: 'gemini-cli', + oauthProjectId: 'project-demo', + sitePlatform: 'gemini-cli', + baseHeaders: { + Authorization: 'Bearer oauth-access-token', + }, + providerHeaders: { + 'User-Agent': 'GeminiCLI/0.31.0/unknown (win32; x64)', + 'X-Goog-Api-Client': 'google-genai-sdk/1.41.0 gl-node/v22.19.0', + }, + body: protocolBody, + }); + + expect(result.path).toBe('/v1internal:generateContent'); + expect(result.headers.Authorization).toBe('Bearer oauth-access-token'); + expect(result.headers['User-Agent']).toBe('GeminiCLI/0.31.0/gemini-2.5-pro (win32; x64)'); + expect(result.headers['X-Goog-Api-Client']).toContain('google-genai-sdk/'); + expect(result.runtime).toMatchObject({ + executor: 'gemini-cli', + modelName: 'gemini-2.5-pro', + oauthProjectId: 'project-demo', + action: 'generateContent', + }); + expect(result.body).toEqual({ + project: 'project-demo', + model: 'gemini-2.5-pro', + request: protocolBody, + }); + expect((result.body as Record<string, unknown>).request).toBe(protocolBody); + + expect(() => profile!.prepareRequest({ + endpoint: 'chat', + modelName: 'gemini-2.5-pro', + stream: false, + tokenValue: 'oauth-access-token', + oauthProvider: 'gemini-cli', + sitePlatform: 'gemini-cli', + baseHeaders: { + Authorization: 'Bearer oauth-access-token', + }, + body: protocolBody, + })).toThrow(/project id missing/i); + }); + + it('builds antigravity provider requests without leaking gemini-cli client headers', () => { + const profile = resolveProviderProfile('antigravity'); + expect(profile?.id).toBe('antigravity'); + + const protocolBody = { + contents: [{ role: 'user', parts: [{ text: 'hello antigravity' }] }], + }; + + const result = profile!.prepareRequest({ + endpoint: 'chat', + modelName: 'gemini-3-pro-preview', + stream: true, + tokenValue: 'oauth-access-token', + oauthProjectId: 'project-demo', + sitePlatform: 'antigravity', + baseHeaders: { + Authorization: 'Bearer oauth-access-token', + }, + providerHeaders: { + 'User-Agent': 'GeminiCLI/0.31.0/unknown (win32; x64)', + 'X-Goog-Api-Client': 'google-genai-sdk/1.41.0 gl-node/v22.19.0', + }, + body: protocolBody, + }); + + expect(result.path).toBe('/v1internal:streamGenerateContent?alt=sse'); + expect(result.headers.Authorization).toBe('Bearer oauth-access-token'); + expect(result.headers['User-Agent']).toBe('antigravity/1.19.6 darwin/arm64'); + expect(result.headers['X-Goog-Api-Client']).toBeUndefined(); + expect(result.headers.Accept).toBe('text/event-stream'); + expect(result.runtime).toMatchObject({ + executor: 'antigravity', + modelName: 'gemini-3-pro-preview', + oauthProjectId: 'project-demo', + action: 'streamGenerateContent', + }); + expect(result.body).toEqual({ + project: 'project-demo', + model: 'gemini-3-pro-preview', + request: protocolBody, + }); + expect((result.body as Record<string, unknown>).request).toBe(protocolBody); + }); +}); diff --git a/src/server/proxy-core/providers/registry.ts b/src/server/proxy-core/providers/registry.ts new file mode 100644 index 00000000..052bc789 --- /dev/null +++ b/src/server/proxy-core/providers/registry.ts @@ -0,0 +1,17 @@ +import { antigravityProviderProfile } from './antigravityProviderProfile.js'; +import { claudeProviderProfile } from './claudeProviderProfile.js'; +import { codexProviderProfile } from './codexProviderProfile.js'; +import { geminiCliProviderProfile } from './geminiCliProviderProfile.js'; +import type { ProviderProfile } from './types.js'; + +const providerProfilesByPlatform: Record<string, ProviderProfile> = { + codex: codexProviderProfile, + claude: claudeProviderProfile, + 'gemini-cli': geminiCliProviderProfile, + antigravity: antigravityProviderProfile, +}; + +export function resolveProviderProfile(sitePlatform?: string | null): ProviderProfile | null { + const normalized = typeof sitePlatform === 'string' ? sitePlatform.trim().toLowerCase() : ''; + return providerProfilesByPlatform[normalized] ?? null; +} diff --git a/src/server/proxy-core/providers/types.ts b/src/server/proxy-core/providers/types.ts new file mode 100644 index 00000000..080b497c --- /dev/null +++ b/src/server/proxy-core/providers/types.ts @@ -0,0 +1,52 @@ +export type ProviderProfileId = + | 'codex' + | 'claude' + | 'gemini-cli' + | 'antigravity'; + +export type ProviderEndpoint = + | 'chat' + | 'messages' + | 'responses'; + +export type ProviderAction = + | 'generateContent' + | 'streamGenerateContent' + | 'countTokens'; + +export type ProviderRuntimeDescriptor = { + executor: 'default' | 'codex' | 'gemini-cli' | 'antigravity' | 'claude'; + modelName?: string; + stream?: boolean; + oauthProjectId?: string | null; + action?: ProviderAction; +}; + +export type PreparedProviderRequest = { + path: string; + headers: Record<string, string>; + body: Record<string, unknown>; + runtime: ProviderRuntimeDescriptor; +}; + +export type PrepareProviderRequestInput = { + endpoint: ProviderEndpoint; + modelName: string; + stream: boolean; + tokenValue: string; + oauthProvider?: string; + oauthProjectId?: string; + sitePlatform?: string; + baseHeaders: Record<string, string>; + providerHeaders?: Record<string, string>; + claudeHeaders?: Record<string, string>; + codexSessionCacheKey?: string | null; + codexExplicitSessionId?: string | null; + body: Record<string, unknown>; + action?: ProviderAction; +}; + +export type ProviderProfile = { + id: ProviderProfileId; + prepareRequest(input: PrepareProviderRequestInput): PreparedProviderRequest; +}; diff --git a/src/server/proxy-core/runtime/codexHttpSessionQueue.ts b/src/server/proxy-core/runtime/codexHttpSessionQueue.ts new file mode 100644 index 00000000..4225a8a9 --- /dev/null +++ b/src/server/proxy-core/runtime/codexHttpSessionQueue.ts @@ -0,0 +1,30 @@ +const codexHttpSessionQueues = new Map<string, Promise<void>>(); + +export async function runCodexHttpSessionTask<T>( + sessionId: string, + task: () => Promise<T>, +): Promise<T> { + const normalizedSessionId = sessionId.trim(); + if (!normalizedSessionId) { + return task(); + } + + const previous = codexHttpSessionQueues.get(normalizedSessionId) || Promise.resolve(); + const run = previous + .catch(() => undefined) + .then(() => task()); + const tail = run.then(() => undefined, () => undefined); + codexHttpSessionQueues.set(normalizedSessionId, tail); + + try { + return await run; + } finally { + if (codexHttpSessionQueues.get(normalizedSessionId) === tail) { + codexHttpSessionQueues.delete(normalizedSessionId); + } + } +} + +export function resetCodexHttpSessionQueue(): void { + codexHttpSessionQueues.clear(); +} diff --git a/src/server/proxy-core/runtime/codexWebsocketHeaders.ts b/src/server/proxy-core/runtime/codexWebsocketHeaders.ts new file mode 100644 index 00000000..fbae37f9 --- /dev/null +++ b/src/server/proxy-core/runtime/codexWebsocketHeaders.ts @@ -0,0 +1,38 @@ +import { config } from '../../config.js'; + +function getHeaderValue(headers: Record<string, string>, key: string): string { + const expected = key.trim().toLowerCase(); + for (const [candidateKey, candidateValue] of Object.entries(headers)) { + if (candidateKey.trim().toLowerCase() !== expected) continue; + return candidateValue; + } + return ''; +} + +export function buildCodexWebsocketHandshakeHeaders(headers: Record<string, string>): Record<string, string> { + const next = { ...headers }; + const websocketBeta = (config.codexResponsesWebsocketBeta || '').trim() || 'responses_websockets=2026-02-06'; + const openAiBeta = getHeaderValue(next, 'openai-beta').trim(); + if (!openAiBeta) { + next['OpenAI-Beta'] = websocketBeta; + return next; + } + if (!openAiBeta.includes('responses_websockets=')) { + next['OpenAI-Beta'] = `${openAiBeta},${websocketBeta}`; + } + return next; +} + +export function buildCodexWebsocketRequestBody(body: Record<string, unknown>): Record<string, unknown> { + return { + type: 'response.create', + ...body, + }; +} + +export function toCodexWebsocketUrl(requestUrl: string): string { + const parsed = new URL(requestUrl); + if (parsed.protocol === 'https:') parsed.protocol = 'wss:'; + if (parsed.protocol === 'http:') parsed.protocol = 'ws:'; + return parsed.toString(); +} diff --git a/src/server/proxy-core/runtime/codexWebsocketRuntime.test.ts b/src/server/proxy-core/runtime/codexWebsocketRuntime.test.ts new file mode 100644 index 00000000..5630e979 --- /dev/null +++ b/src/server/proxy-core/runtime/codexWebsocketRuntime.test.ts @@ -0,0 +1,423 @@ +import { AddressInfo } from 'node:net'; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; +import { WebSocketServer } from 'ws'; + +describe('codexWebsocketRuntime', () => { + let upstreamServer: WebSocketServer; + let upstreamWsUrl: string; + let upstreamConnectionCount = 0; + let upstreamRequests: Record<string, unknown>[] = []; + let upstreamMessageHandler: (socket: import('ws').WebSocket, parsed: Record<string, unknown>, requestIndex: number) => void; + + beforeAll(async () => { + upstreamServer = new WebSocketServer({ port: 0 }); + upstreamServer.on('connection', (socket) => { + upstreamConnectionCount += 1; + socket.on('message', (payload) => { + const parsed = JSON.parse(String(payload)) as Record<string, unknown>; + upstreamRequests.push(parsed); + upstreamMessageHandler(socket, parsed, upstreamRequests.length); + }); + }); + await new Promise<void>((resolve) => upstreamServer.once('listening', () => resolve())); + const address = upstreamServer.address() as AddressInfo; + upstreamWsUrl = `ws://127.0.0.1:${address.port}/backend-api/codex/responses`; + }); + + beforeEach(() => { + upstreamConnectionCount = 0; + upstreamRequests = []; + upstreamMessageHandler = (socket, parsed, requestIndex) => { + const responseId = `resp-${requestIndex}`; + socket.send(JSON.stringify({ + type: 'response.completed', + response: { + id: responseId, + object: 'response', + model: parsed.model || 'gpt-5.4', + status: 'completed', + output: [], + usage: { + input_tokens: 1, + output_tokens: 1, + total_tokens: 2, + }, + }, + })); + }; + }); + + afterAll(async () => { + await new Promise<void>((resolve) => upstreamServer.close(() => resolve())); + }); + + it('reuses the same upstream websocket connection across turns for one execution session', async () => { + const { createCodexWebsocketRuntime } = await import('./codexWebsocketRuntime.js'); + const runtime = createCodexWebsocketRuntime(); + + const first = await runtime.sendRequest({ + sessionId: 'exec-session-1', + requestUrl: upstreamWsUrl, + headers: { + Authorization: 'Bearer oauth-access-token', + 'OpenAI-Beta': 'responses_websockets=2026-02-06', + }, + body: { + model: 'gpt-5.4', + input: [], + }, + }); + + const second = await runtime.sendRequest({ + sessionId: 'exec-session-1', + requestUrl: upstreamWsUrl, + headers: { + Authorization: 'Bearer oauth-access-token', + 'OpenAI-Beta': 'responses_websockets=2026-02-06', + }, + body: { + model: 'gpt-5.4', + previous_response_id: 'resp-1', + input: [], + }, + }); + + expect(first.events[0]).toMatchObject({ + type: 'response.completed', + response: { id: 'resp-1' }, + }); + expect(second.events[0]).toMatchObject({ + type: 'response.completed', + response: { id: 'resp-2' }, + }); + expect(upstreamConnectionCount).toBe(1); + expect(upstreamRequests).toHaveLength(2); + expect(upstreamRequests[0]).toMatchObject({ + type: 'response.create', + model: 'gpt-5.4', + }); + expect(upstreamRequests[1]).toMatchObject({ + type: 'response.create', + previous_response_id: 'resp-1', + }); + + await runtime.closeSession('exec-session-1'); + }); + + it('closes the upstream websocket when the execution session is closed explicitly', async () => { + const { createCodexWebsocketRuntime } = await import('./codexWebsocketRuntime.js'); + const runtime = createCodexWebsocketRuntime(); + + await runtime.sendRequest({ + sessionId: 'exec-session-close', + requestUrl: upstreamWsUrl, + headers: { + Authorization: 'Bearer oauth-access-token', + 'OpenAI-Beta': 'responses_websockets=2026-02-06', + }, + body: { + model: 'gpt-5.4', + input: [], + }, + }); + await runtime.closeSession('exec-session-close'); + + await runtime.sendRequest({ + sessionId: 'exec-session-close', + requestUrl: upstreamWsUrl, + headers: { + Authorization: 'Bearer oauth-access-token', + 'OpenAI-Beta': 'responses_websockets=2026-02-06', + }, + body: { + model: 'gpt-5.4', + input: [], + }, + }); + + expect(upstreamConnectionCount).toBe(2); + await runtime.closeSession('exec-session-close'); + }); + + it('returns response.incomplete as a terminal websocket event without rejecting the session turn', async () => { + upstreamMessageHandler = (socket, parsed) => { + socket.send(JSON.stringify({ + type: 'response.incomplete', + response: { + id: 'resp-incomplete', + model: parsed.model || 'gpt-5.4', + status: 'incomplete', + incomplete_details: { + reason: 'max_output_tokens', + }, + }, + })); + }; + + const { createCodexWebsocketRuntime } = await import('./codexWebsocketRuntime.js'); + const runtime = createCodexWebsocketRuntime(); + + const result = await runtime.sendRequest({ + sessionId: 'exec-session-incomplete', + requestUrl: upstreamWsUrl, + headers: { + Authorization: 'Bearer oauth-access-token', + 'OpenAI-Beta': 'responses_websockets=2026-02-06', + }, + body: { + model: 'gpt-5.4', + input: [], + }, + }); + + expect(result.events).toEqual([ + expect.objectContaining({ + type: 'response.incomplete', + }), + ]); + expect(result.reusedSession).toBe(false); + + await runtime.closeSession('exec-session-incomplete'); + }); + + it('keeps the upstream websocket session alive across response.failed terminal turns', async () => { + upstreamMessageHandler = (socket, parsed, requestIndex) => { + if (requestIndex === 1) { + socket.send(JSON.stringify({ + type: 'response.failed', + response: { + id: 'resp-failed', + model: parsed.model || 'gpt-5.4', + status: 'failed', + error: { + message: 'tool execution failed', + type: 'server_error', + }, + }, + })); + return; + } + + socket.send(JSON.stringify({ + type: 'response.completed', + response: { + id: `resp-${requestIndex}`, + object: 'response', + model: parsed.model || 'gpt-5.4', + status: 'completed', + output: [], + usage: { + input_tokens: 1, + output_tokens: 1, + total_tokens: 2, + }, + }, + })); + }; + + const { createCodexWebsocketRuntime } = await import('./codexWebsocketRuntime.js'); + const runtime = createCodexWebsocketRuntime(); + + const first = await runtime.sendRequest({ + sessionId: 'exec-session-failed-turn', + requestUrl: upstreamWsUrl, + headers: { + Authorization: 'Bearer oauth-access-token', + 'OpenAI-Beta': 'responses_websockets=2026-02-06', + }, + body: { + model: 'gpt-5.4', + input: [], + }, + }); + + const second = await runtime.sendRequest({ + sessionId: 'exec-session-failed-turn', + requestUrl: upstreamWsUrl, + headers: { + Authorization: 'Bearer oauth-access-token', + 'OpenAI-Beta': 'responses_websockets=2026-02-06', + }, + body: { + model: 'gpt-5.4', + previous_response_id: 'resp-failed', + input: [], + }, + }); + + expect(first.events).toEqual([ + expect.objectContaining({ + type: 'response.failed', + response: expect.objectContaining({ + id: 'resp-failed', + error: expect.objectContaining({ + message: 'tool execution failed', + }), + }), + }), + ]); + expect(first.reusedSession).toBe(false); + expect(second.events[0]).toMatchObject({ + type: 'response.completed', + response: { id: 'resp-2' }, + }); + expect(second.reusedSession).toBe(true); + expect(upstreamConnectionCount).toBe(1); + expect(upstreamRequests).toHaveLength(2); + + await runtime.closeSession('exec-session-failed-turn'); + }); + + it('fails the current turn and opens a fresh websocket on the next turn when a reused session closes before yielding any events', async () => { + upstreamMessageHandler = (socket, parsed, requestIndex) => { + if (requestIndex === 1) { + socket.send(JSON.stringify({ + type: 'response.completed', + response: { + id: 'resp-1', + object: 'response', + model: parsed.model || 'gpt-5.4', + status: 'completed', + output: [], + usage: { + input_tokens: 1, + output_tokens: 1, + total_tokens: 2, + }, + }, + })); + return; + } + + if (requestIndex === 2) { + socket.close(); + return; + } + + socket.send(JSON.stringify({ + type: 'response.completed', + response: { + id: 'resp-3', + object: 'response', + model: parsed.model || 'gpt-5.4', + status: 'completed', + output: [], + usage: { + input_tokens: 1, + output_tokens: 1, + total_tokens: 2, + }, + }, + })); + }; + + const { createCodexWebsocketRuntime } = await import('./codexWebsocketRuntime.js'); + const runtime = createCodexWebsocketRuntime(); + + await runtime.sendRequest({ + sessionId: 'exec-session-retry-stale', + requestUrl: upstreamWsUrl, + headers: { + Authorization: 'Bearer oauth-access-token', + 'OpenAI-Beta': 'responses_websockets=2026-02-06', + }, + body: { + model: 'gpt-5.4', + input: [], + }, + }); + + await expect(runtime.sendRequest({ + sessionId: 'exec-session-retry-stale', + requestUrl: upstreamWsUrl, + headers: { + Authorization: 'Bearer oauth-access-token', + 'OpenAI-Beta': 'responses_websockets=2026-02-06', + }, + body: { + model: 'gpt-5.4', + previous_response_id: 'resp-1', + input: [], + }, + })).rejects.toThrow('stream closed before response.completed'); + + const recovered = await runtime.sendRequest({ + sessionId: 'exec-session-retry-stale', + requestUrl: upstreamWsUrl, + headers: { + Authorization: 'Bearer oauth-access-token', + 'OpenAI-Beta': 'responses_websockets=2026-02-06', + }, + body: { + model: 'gpt-5.4', + previous_response_id: 'resp-1', + input: [], + }, + }); + + expect(recovered.events[0]).toMatchObject({ + type: 'response.completed', + response: { id: 'resp-3' }, + }); + expect(recovered.reusedSession).toBe(false); + expect(upstreamConnectionCount).toBe(2); + expect(upstreamRequests).toHaveLength(3); + expect(upstreamRequests[1]).toMatchObject({ + type: 'response.create', + previous_response_id: 'resp-1', + }); + expect(upstreamRequests[2]).toMatchObject({ + type: 'response.create', + previous_response_id: 'resp-1', + }); + + await runtime.closeSession('exec-session-retry-stale'); + }); + + it('treats top-level error frames as terminal websocket failures', async () => { + upstreamMessageHandler = (socket) => { + socket.send(JSON.stringify({ + type: 'error', + error: { + message: 'account mismatch', + type: 'invalid_request_error', + }, + })); + }; + + const { createCodexWebsocketRuntime, CodexWebsocketRuntimeError } = await import('./codexWebsocketRuntime.js'); + const runtime = createCodexWebsocketRuntime(); + + let error: unknown; + try { + await runtime.sendRequest({ + sessionId: 'exec-session-error', + requestUrl: upstreamWsUrl, + headers: { + Authorization: 'Bearer oauth-access-token', + 'OpenAI-Beta': 'responses_websockets=2026-02-06', + }, + body: { + model: 'gpt-5.4', + input: [], + }, + }); + } catch (caught) { + error = caught; + } + + expect(error).toBeInstanceOf(CodexWebsocketRuntimeError); + expect(error).toMatchObject({ + message: 'account mismatch', + status: 502, + }); + expect((error as CodexWebsocketRuntimeError).events).toEqual([ + expect.objectContaining({ + type: 'error', + error: expect.objectContaining({ + message: 'account mismatch', + }), + }), + ]); + }); +}); diff --git a/src/server/proxy-core/runtime/codexWebsocketRuntime.ts b/src/server/proxy-core/runtime/codexWebsocketRuntime.ts new file mode 100644 index 00000000..4e1eba00 --- /dev/null +++ b/src/server/proxy-core/runtime/codexWebsocketRuntime.ts @@ -0,0 +1,346 @@ +import type { IncomingMessage } from 'node:http'; +import WebSocket from 'ws'; +import { + buildCodexWebsocketHandshakeHeaders, + buildCodexWebsocketRequestBody, + toCodexWebsocketUrl, +} from './codexWebsocketHeaders.js'; +import { createCodexWebsocketSessionStore } from './codexWebsocketSessionStore.js'; +import type { + CodexWebsocketRuntimeResult, + CodexWebsocketRuntimeSendInput, + CodexWebsocketSession, + CodexWebsocketSessionStore, +} from './types.js'; + +function asTrimmedString(value: unknown): string { + return typeof value === 'string' ? value.trim() : ''; +} + +function isRecord(value: unknown): value is Record<string, unknown> { + return !!value && typeof value === 'object' && !Array.isArray(value); +} + +function isTerminalEvent(payload: Record<string, unknown>): boolean { + const type = asTrimmedString(payload.type); + return type === 'response.completed' + || type === 'response.failed' + || type === 'response.incomplete' + || type === 'error'; +} + +function isRuntimeErrorEvent(payload: Record<string, unknown>): boolean { + const type = asTrimmedString(payload.type); + return type === 'error'; +} + +function asFiniteNumber(value: unknown): number | undefined { + return typeof value === 'number' && Number.isFinite(value) ? value : undefined; +} + +function extractFailureTerminalStatus(payload: Record<string, unknown>): number { + const response = isRecord(payload.response) ? payload.response : null; + const responseError = response && isRecord(response.error) ? response.error : null; + const topLevelError = isRecord(payload.error) ? payload.error : null; + const candidates = [ + payload.status, + payload.statusCode, + payload.code, + topLevelError?.status, + topLevelError?.statusCode, + topLevelError?.code, + responseError?.status, + responseError?.statusCode, + responseError?.code, + ]; + for (const candidate of candidates) { + const status = asFiniteNumber(candidate); + if (status !== undefined) return status; + } + return 502; +} + +function extractTerminalErrorMessage(payload: Record<string, unknown>): string { + const type = asTrimmedString(payload.type); + if (type === 'error' && isRecord(payload.error)) { + return asTrimmedString(payload.error.message) || 'upstream websocket error'; + } + if ((type === 'response.failed' || type === 'response.incomplete') && isRecord(payload.response)) { + if (isRecord(payload.response.error)) { + return asTrimmedString(payload.response.error.message) || `upstream ${type}`; + } + if (isRecord(payload.response.incomplete_details)) { + return asTrimmedString(payload.response.incomplete_details.reason) || `upstream ${type}`; + } + } + return `upstream ${type || 'websocket error'}`; +} + +export class CodexWebsocketRuntimeError extends Error { + events: Array<Record<string, unknown>>; + status?: number; + payload?: unknown; + + constructor( + message: string, + options?: { + events?: Array<Record<string, unknown>>; + status?: number; + payload?: unknown; + }, + ) { + super(message); + this.name = 'CodexWebsocketRuntimeError'; + this.events = options?.events ?? []; + this.status = options?.status; + this.payload = options?.payload; + } +} + +function tryParseJson(raw: string): unknown { + const trimmed = raw.trim(); + if (!trimmed) return undefined; + try { + return JSON.parse(trimmed); + } catch { + return undefined; + } +} + +async function readUnexpectedResponseBody(response: IncomingMessage): Promise<string> { + return await new Promise<string>((resolve) => { + const chunks: Buffer[] = []; + response.on('data', (chunk) => { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + }); + response.once('end', () => { + resolve(Buffer.concat(chunks).toString('utf8')); + }); + response.once('error', () => { + resolve(''); + }); + }); +} + +async function waitForSocketOpen(socket: WebSocket): Promise<void> { + if (socket.readyState === WebSocket.OPEN) return; + if (socket.readyState !== WebSocket.CONNECTING) { + throw new Error('upstream websocket is not open'); + } + await new Promise<void>((resolve, reject) => { + const cleanup = () => { + socket.off('open', onOpen); + socket.off('error', onError); + socket.off('close', onClose); + socket.off('unexpected-response', onUnexpectedResponse); + }; + const onOpen = () => { + cleanup(); + resolve(); + }; + const onError = (error: Error) => { + cleanup(); + reject(error); + }; + const onClose = () => { + cleanup(); + reject(new Error('upstream websocket closed before opening')); + }; + const onUnexpectedResponse = (_request: unknown, response: IncomingMessage) => { + void readUnexpectedResponseBody(response).then((body) => { + cleanup(); + reject(new CodexWebsocketRuntimeError( + body.trim() || response.statusMessage || `upstream websocket upgrade failed with status ${response.statusCode || 502}`, + { + status: response.statusCode || 502, + payload: tryParseJson(body), + }, + )); + }); + }; + socket.once('open', onOpen); + socket.once('error', onError); + socket.once('close', onClose); + socket.once('unexpected-response', onUnexpectedResponse); + }); +} + +async function closeSocket(socket: WebSocket | null): Promise<void> { + if (!socket) return; + if (socket.readyState === WebSocket.CLOSED) return; + await new Promise<void>((resolve) => { + const onClose = () => resolve(); + socket.once('close', onClose); + try { + socket.close(); + } catch { + socket.off('close', onClose); + resolve(); + } + setTimeout(() => { + socket.off('close', onClose); + resolve(); + }, 200); + }); +} + +function clearSessionSocket(session: CodexWebsocketSession, socket: WebSocket): void { + if (session.socket !== socket) return; + session.socket = null; + session.socketUrl = null; +} + +async function ensureSessionSocket( + session: CodexWebsocketSession, + input: CodexWebsocketRuntimeSendInput, +): Promise<{ socket: WebSocket; reusedSession: boolean }> { + const requestUrl = toCodexWebsocketUrl(input.requestUrl); + const existing = session.socket; + if ( + existing + && session.socketUrl === requestUrl + && existing.readyState === WebSocket.OPEN + ) { + return { + socket: existing, + reusedSession: true, + }; + } + + if (existing) { + await closeSocket(existing); + clearSessionSocket(session, existing); + } + + const nextSocket = new WebSocket(requestUrl, { + headers: buildCodexWebsocketHandshakeHeaders(input.headers), + }); + await waitForSocketOpen(nextSocket); + session.socket = nextSocket; + session.socketUrl = requestUrl; + + nextSocket.on('close', () => { + clearSessionSocket(session, nextSocket); + }); + nextSocket.on('error', () => { + clearSessionSocket(session, nextSocket); + }); + + return { + socket: nextSocket, + reusedSession: false, + }; +} + +async function sendSessionRequest( + session: CodexWebsocketSession, + input: CodexWebsocketRuntimeSendInput, +): Promise<CodexWebsocketRuntimeResult> { + const { socket, reusedSession } = await ensureSessionSocket(session, input); + const events: Array<Record<string, unknown>> = []; + + return new Promise<CodexWebsocketRuntimeResult>((resolve, reject) => { + let settled = false; + + const cleanup = () => { + socket.off('message', onMessage); + socket.off('error', onError); + socket.off('close', onClose); + }; + + const rejectWith = (message: string) => { + if (settled) return; + settled = true; + cleanup(); + reject(new CodexWebsocketRuntimeError(message, { events: [...events] })); + }; + + const onMessage = (payload: WebSocket.RawData) => { + try { + const parsed = JSON.parse(String(payload)); + if (!isRecord(parsed)) return; + events.push(parsed); + if (!isTerminalEvent(parsed)) return; + if (settled) return; + if (isRuntimeErrorEvent(parsed)) { + settled = true; + cleanup(); + clearSessionSocket(session, socket); + void closeSocket(socket); + reject(new CodexWebsocketRuntimeError(extractTerminalErrorMessage(parsed), { + events: [...events], + status: extractFailureTerminalStatus(parsed), + payload: parsed, + })); + return; + } + settled = true; + cleanup(); + resolve({ + events: [...events], + reusedSession, + }); + } catch { + // Ignore malformed frames and wait for a terminal event. + } + }; + + const onError = (error: Error) => { + clearSessionSocket(session, socket); + rejectWith(error.message || 'upstream websocket error'); + }; + + const onClose = () => { + clearSessionSocket(session, socket); + rejectWith('stream closed before response.completed'); + }; + + socket.on('message', onMessage); + socket.once('error', onError); + socket.once('close', onClose); + + socket.send(JSON.stringify(buildCodexWebsocketRequestBody(input.body)), (error) => { + if (!error) return; + clearSessionSocket(session, socket); + rejectWith(error.message || 'failed to send upstream websocket request'); + }); + }); +} + +export function createCodexWebsocketRuntime(input?: { + sessionStore?: CodexWebsocketSessionStore; +}) { + const sessionStore = input?.sessionStore || createCodexWebsocketSessionStore(); + + return { + async sendRequest(payload: CodexWebsocketRuntimeSendInput): Promise<CodexWebsocketRuntimeResult> { + const sessionId = payload.sessionId.trim(); + if (!sessionId) { + throw new CodexWebsocketRuntimeError('missing websocket session id'); + } + + const session = sessionStore.getOrCreate(sessionId); + const run = session.queue + .catch(() => undefined) + .then(() => sendSessionRequest(session, payload)); + session.queue = run.then(() => undefined, () => undefined); + return run; + }, + + async closeSession(sessionId: string): Promise<void> { + const session = sessionStore.take(sessionId); + if (!session) return; + await session.queue.catch(() => undefined); + await closeSocket(session.socket); + session.socket = null; + session.socketUrl = null; + }, + + async closeAllSessions(): Promise<void> { + const sessions = sessionStore.list(); + for (const session of sessions) { + await this.closeSession(session.sessionId); + } + }, + }; +} diff --git a/src/server/proxy-core/runtime/codexWebsocketSessionStore.ts b/src/server/proxy-core/runtime/codexWebsocketSessionStore.ts new file mode 100644 index 00000000..e8d6c507 --- /dev/null +++ b/src/server/proxy-core/runtime/codexWebsocketSessionStore.ts @@ -0,0 +1,34 @@ +import type { CodexWebsocketSession, CodexWebsocketSessionStore } from './types.js'; + +export function createCodexWebsocketSessionStore(): CodexWebsocketSessionStore { + const sessions = new Map<string, CodexWebsocketSession>(); + + return { + getOrCreate(sessionId) { + const normalized = sessionId.trim(); + const existing = sessions.get(normalized); + if (existing) return existing; + + const created: CodexWebsocketSession = { + sessionId: normalized, + socket: null, + socketUrl: null, + queue: Promise.resolve(), + }; + sessions.set(normalized, created); + return created; + }, + take(sessionId) { + const normalized = sessionId.trim(); + if (!normalized) return null; + const existing = sessions.get(normalized) || null; + if (existing) { + sessions.delete(normalized); + } + return existing; + }, + list() { + return [...sessions.values()]; + }, + }; +} diff --git a/src/server/proxy-core/runtime/types.ts b/src/server/proxy-core/runtime/types.ts new file mode 100644 index 00000000..ada31c51 --- /dev/null +++ b/src/server/proxy-core/runtime/types.ts @@ -0,0 +1,26 @@ +import type WebSocket from 'ws'; + +export type CodexWebsocketRuntimeSendInput = { + sessionId: string; + requestUrl: string; + headers: Record<string, string>; + body: Record<string, unknown>; +}; + +export type CodexWebsocketRuntimeResult = { + events: Array<Record<string, unknown>>; + reusedSession: boolean; +}; + +export type CodexWebsocketSession = { + sessionId: string; + socket: WebSocket | null; + socketUrl: string | null; + queue: Promise<unknown>; +}; + +export type CodexWebsocketSessionStore = { + getOrCreate(sessionId: string): CodexWebsocketSession; + take(sessionId: string): CodexWebsocketSession | null; + list(): CodexWebsocketSession[]; +}; diff --git a/src/server/proxy-core/surfaces/chatSurface.ts b/src/server/proxy-core/surfaces/chatSurface.ts new file mode 100644 index 00000000..12fa710e --- /dev/null +++ b/src/server/proxy-core/surfaces/chatSurface.ts @@ -0,0 +1,944 @@ +import { TextDecoder } from 'node:util'; +import type { FastifyRequest, FastifyReply } from 'fastify'; +import { tokenRouter } from '../../services/tokenRouter.js'; +import { reportProxyAllFailed } from '../../services/alertService.js'; +import { mergeProxyUsage, parseProxyUsage } from '../../services/proxyUsageParser.js'; +import { type DownstreamFormat } from '../../transformers/shared/normalized.js'; +import { + buildClaudeCountTokensUpstreamRequest, + buildUpstreamEndpointRequest, + recordUpstreamEndpointFailure, + recordUpstreamEndpointSuccess, + resolveUpstreamEndpointCandidates, +} from '../../routes/proxy/upstreamEndpoint.js'; +import { + ensureModelAllowedForDownstreamKey, + getDownstreamRoutingPolicy, + recordDownstreamCostUsage, +} from '../../routes/proxy/downstreamPolicy.js'; +import { executeEndpointFlow, type BuiltEndpointRequest } from '../../routes/proxy/endpointFlow.js'; +import { detectProxyFailure } from '../../routes/proxy/proxyFailureJudge.js'; +import { openAiChatTransformer } from '../../transformers/openai/chat/index.js'; +import { anthropicMessagesTransformer } from '../../transformers/anthropic/messages/index.js'; +import { getProxyAuthContext, getProxyResourceOwner } from '../../middleware/auth.js'; +import { + ProxyInputFileResolutionError, + resolveOpenAiBodyInputFiles, +} from '../../services/proxyInputFileResolver.js'; +import { + buildOauthProviderHeaders, +} from '../../services/oauth/service.js'; +import { getOauthInfoFromAccount } from '../../services/oauth/oauthAccount.js'; +import { + collectResponsesFinalPayloadFromSse, + collectResponsesFinalPayloadFromSseText, + createSingleChunkStreamReader, + looksLikeResponsesSseText, +} from '../../routes/proxy/responsesSseFinal.js'; +import { + createGeminiCliStreamReader, + unwrapGeminiCliPayload, +} from '../../routes/proxy/geminiCliCompat.js'; +import { summarizeConversationFileInputsInOpenAiBody } from '../capabilities/conversationFileCapabilities.js'; +import { readRuntimeResponseText } from '../executors/types.js'; +import { detectDownstreamClientContext } from '../../routes/proxy/downstreamClientContext.js'; +import { canRetryProxyChannel, getProxyMaxChannelRetries } from '../../services/proxyChannelRetry.js'; +import { + acquireSurfaceChannelLease, + bindSurfaceStickyChannel, + buildSurfaceChannelBusyMessage, + buildSurfaceStickySessionKey, + clearSurfaceStickyChannel, + createSurfaceFailureToolkit, + createSurfaceDispatchRequest, + recordSurfaceSuccess, + selectSurfaceChannelForAttempt, + trySurfaceOauthRefreshRecovery, +} from './sharedSurface.js'; + +function isRecord(value: unknown): value is Record<string, unknown> { + return !!value && typeof value === 'object' && !Array.isArray(value); +} + +function asTrimmedString(value: unknown): string { + return typeof value === 'string' ? value.trim() : ''; +} + +function prioritizeEndpointCandidate( + candidates: Array<'chat' | 'messages' | 'responses'>, + preferred: 'chat' | 'messages' | 'responses', +): Array<'chat' | 'messages' | 'responses'> { + if (!candidates.includes(preferred)) return candidates; + return [ + preferred, + ...candidates.filter((candidate) => candidate !== preferred), + ]; +} + +export async function handleChatSurfaceRequest( + request: FastifyRequest, + reply: FastifyReply, + downstreamFormat: DownstreamFormat, +) { + const downstreamTransformer = downstreamFormat === 'claude' + ? anthropicMessagesTransformer + : openAiChatTransformer; + const downstreamPath = downstreamFormat === 'claude' ? '/v1/messages' : '/v1/chat/completions'; + const clientContext = detectDownstreamClientContext({ + downstreamPath, + headers: request.headers as Record<string, unknown>, + body: request.body, + }); + const parsedRequestEnvelope = downstreamTransformer.transformRequest(request.body); + if (parsedRequestEnvelope.error) { + return reply.code(parsedRequestEnvelope.error.statusCode).send(parsedRequestEnvelope.error.payload); + } + + const requestEnvelope = parsedRequestEnvelope.value!; + const { + requestedModel, + isStream, + upstreamBody, + claudeOriginalBody, + } = requestEnvelope.parsed; + if (!await ensureModelAllowedForDownstreamKey(request, reply, requestedModel)) return; + const downstreamPolicy = getDownstreamRoutingPolicy(request); + const owner = getProxyResourceOwner(request); + let resolvedOpenAiBody = upstreamBody; + if (owner) { + try { + resolvedOpenAiBody = await resolveOpenAiBodyInputFiles(upstreamBody, owner); + } catch (error) { + if (error instanceof ProxyInputFileResolutionError) { + return reply.code(error.statusCode).send(error.payload); + } + throw error; + } + } + const conversationFileSummary = summarizeConversationFileInputsInOpenAiBody(resolvedOpenAiBody); + const hasNonImageFileInput = conversationFileSummary.hasDocument; + const codexSessionCacheKey = deriveCodexSessionCacheKey({ + downstreamFormat, + body: downstreamFormat === 'claude' ? claudeOriginalBody : request.body, + requestedModel, + proxyToken: getProxyAuthContext(request)?.token || null, + }); + const downstreamApiKeyId = getProxyAuthContext(request)?.keyId ?? null; + const maxRetries = getProxyMaxChannelRetries(); + const failureToolkit = createSurfaceFailureToolkit({ + warningScope: 'chat', + downstreamPath, + maxRetries, + clientContext, + downstreamApiKeyId, + }); + const stickySessionKey = buildSurfaceStickySessionKey({ + clientContext, + requestedModel, + downstreamPath, + downstreamApiKeyId, + }); + + const excludeChannelIds: number[] = []; + let retryCount = 0; + + while (retryCount <= maxRetries) { + const selected = await selectSurfaceChannelForAttempt({ + requestedModel, + downstreamPolicy, + excludeChannelIds, + retryCount, + stickySessionKey, + }); + + if (!selected) { + await reportProxyAllFailed({ + model: requestedModel, + reason: 'No available channels after retries', + }); + return reply.code(503).send({ + error: { message: 'No available channels for this model', type: 'server_error' }, + }); + } + + excludeChannelIds.push(selected.channel.id); + + const modelName = selected.actualModel || requestedModel; + const oauth = getOauthInfoFromAccount(selected.account); + const isCodexSite = String(selected.site.platform || '').trim().toLowerCase() === 'codex'; + let endpointCandidates = [ + ...await resolveUpstreamEndpointCandidates( + { + site: selected.site, + account: selected.account, + }, + modelName, + downstreamFormat, + requestedModel, + { + hasNonImageFileInput, + conversationFileSummary, + }, + ), + ]; + if (oauth?.provider === 'codex' && downstreamFormat === 'openai') { + endpointCandidates = prioritizeEndpointCandidate(endpointCandidates, 'responses'); + } + const endpointRuntimeContext = { + siteId: selected.site.id, + modelName, + downstreamFormat, + requestedModelHint: requestedModel, + requestCapabilities: { + hasNonImageFileInput, + conversationFileSummary, + }, + }; + const buildProviderHeaders = () => ( + buildOauthProviderHeaders({ + account: selected.account, + downstreamHeaders: request.headers as Record<string, unknown>, + }) + ); + const buildEndpointRequest = ( + endpoint: 'chat' | 'messages' | 'responses', + options: { forceNormalizeClaudeBody?: boolean } = {}, + ) => { + const upstreamStream = isStream || (isCodexSite && endpoint === 'responses'); + const endpointRequest = buildUpstreamEndpointRequest({ + endpoint, + modelName, + stream: upstreamStream, + tokenValue: selected.tokenValue, + oauthProvider: oauth?.provider, + oauthProjectId: oauth?.projectId, + sitePlatform: selected.site.platform, + siteUrl: selected.site.url, + openaiBody: resolvedOpenAiBody, + downstreamFormat, + claudeOriginalBody, + forceNormalizeClaudeBody: options.forceNormalizeClaudeBody, + downstreamHeaders: request.headers as Record<string, unknown>, + providerHeaders: buildProviderHeaders(), + codexSessionCacheKey, + }); + return { + endpoint, + path: endpointRequest.path, + headers: endpointRequest.headers, + body: endpointRequest.body as Record<string, unknown>, + runtime: endpointRequest.runtime, + }; + }; + const dispatchRequest = createSurfaceDispatchRequest({ + site: selected.site, + accountExtraConfig: selected.account.extraConfig, + }); + const endpointStrategy = downstreamTransformer.compatibility.createEndpointStrategy({ + downstreamFormat, + endpointCandidates, + modelName, + requestedModelHint: requestedModel, + sitePlatform: selected.site.platform, + isStream: isStream || isCodexSite, + buildRequest: ({ endpoint, forceNormalizeClaudeBody }) => buildEndpointRequest( + endpoint, + { forceNormalizeClaudeBody }, + ), + dispatchRequest, + }); + const tryRecover = async (ctx: Parameters<NonNullable<typeof endpointStrategy.tryRecover>>[0]) => { + if ((ctx.response.status === 401 || ctx.response.status === 403) && oauth) { + const recovered = await trySurfaceOauthRefreshRecovery({ + ctx, + selected, + siteUrl: selected.site.url, + buildRequest: (endpoint) => buildEndpointRequest(endpoint), + dispatchRequest, + }); + if (recovered?.upstream?.ok) { + return recovered; + } + } + return endpointStrategy.tryRecover(ctx); + }; + let startTime = Date.now(); + const leaseResult = await acquireSurfaceChannelLease({ + stickySessionKey, + selected, + }); + if (leaseResult.status === 'timeout') { + clearSurfaceStickyChannel({ + stickySessionKey, + selected, + }); + const busyMessage = buildSurfaceChannelBusyMessage(leaseResult.waitMs); + await failureToolkit.log({ + selected, + modelRequested: requestedModel, + status: 'failed', + httpStatus: 503, + latencyMs: leaseResult.waitMs, + errorMessage: busyMessage, + retryCount, + }); + if (canRetryProxyChannel(retryCount)) { + retryCount += 1; + continue; + } + return reply.code(503).send({ + error: { + message: busyMessage, + type: 'server_error', + }, + }); + } + const channelLease = leaseResult.lease; + + try { + const endpointResult = await executeEndpointFlow({ + siteUrl: selected.site.url, + endpointCandidates, + buildRequest: (endpoint) => buildEndpointRequest(endpoint), + dispatchRequest, + tryRecover, + onAttemptFailure: (ctx) => { + recordUpstreamEndpointFailure({ + ...endpointRuntimeContext, + endpoint: ctx.request.endpoint, + status: ctx.response.status, + errorText: ctx.rawErrText, + }); + }, + onAttemptSuccess: (ctx) => { + recordUpstreamEndpointSuccess({ + ...endpointRuntimeContext, + endpoint: ctx.request.endpoint, + }); + }, + shouldDowngrade: endpointStrategy.shouldDowngrade, + onDowngrade: (ctx) => { + return failureToolkit.log({ + selected, + modelRequested: requestedModel, + status: 'failed', + httpStatus: ctx.response.status, + latencyMs: Date.now() - startTime, + errorMessage: ctx.errText, + retryCount, + }); + }, + }); + + if (!endpointResult.ok) { + clearSurfaceStickyChannel({ + stickySessionKey, + selected, + }); + const failureOutcome = await failureToolkit.handleUpstreamFailure({ + selected, + requestedModel, + modelName, + status: endpointResult.status || 502, + errText: endpointResult.errText || 'unknown error', + rawErrText: endpointResult.rawErrText, + latencyMs: Date.now() - startTime, + retryCount, + }); + if (failureOutcome.action === 'retry') { + retryCount += 1; + continue; + } + return reply.code(failureOutcome.status).send(failureOutcome.payload); + } + + const upstream = endpointResult.upstream; + const successfulUpstreamPath = endpointResult.upstreamPath; + + if (isStream) { + const upstreamContentType = (upstream.headers.get('content-type') || '').toLowerCase(); + const startSseResponse = () => { + reply.hijack(); + reply.raw.statusCode = 200; + reply.raw.setHeader('Content-Type', 'text/event-stream; charset=utf-8'); + reply.raw.setHeader('Cache-Control', 'no-cache, no-transform'); + reply.raw.setHeader('Connection', 'keep-alive'); + reply.raw.setHeader('X-Accel-Buffering', 'no'); + }; + + let parsedUsage: ReturnType<typeof parseProxyUsage> = { + promptTokens: 0, + completionTokens: 0, + totalTokens: 0, + cacheReadTokens: 0, + cacheCreationTokens: 0, + promptTokensIncludeCache: null, + }; + + const writeLines = (lines: string[]) => { + for (const line of lines) { + reply.raw.write(line); + } + }; + const streamSession = openAiChatTransformer.proxyStream.createSession({ + downstreamFormat, + modelName, + successfulUpstreamPath, + onParsedPayload: (payload) => { + if (payload && typeof payload === 'object') { + parsedUsage = mergeProxyUsage(parsedUsage, parseProxyUsage(payload)); + } + }, + writeLines, + writeRaw: (chunk) => { + reply.raw.write(chunk); + }, + }); + let rawText = ''; + if (!upstreamContentType.includes('text/event-stream')) { + const fallbackText = await readRuntimeResponseText(upstream); + rawText = fallbackText; + if (looksLikeResponsesSseText(fallbackText)) { + startSseResponse(); + const streamResult = await streamSession.run( + createSingleChunkStreamReader(fallbackText), + reply.raw, + ); + const latency = Date.now() - startTime; + if (streamResult.status === 'failed') { + clearSurfaceStickyChannel({ + stickySessionKey, + selected, + }); + await failureToolkit.recordStreamFailure({ + selected, + requestedModel, + modelName, + errorMessage: streamResult.errorMessage, + latencyMs: latency, + retryCount, + promptTokens: parsedUsage.promptTokens, + completionTokens: parsedUsage.completionTokens, + totalTokens: parsedUsage.totalTokens, + upstreamPath: successfulUpstreamPath, + }); + return; + } + bindSurfaceStickyChannel({ + stickySessionKey, + selected, + }); + return; + } + let fallbackData: unknown = null; + try { + fallbackData = JSON.parse(fallbackText); + } catch { + fallbackData = fallbackText; + } + if (String(selected.site.platform || '').trim().toLowerCase() === 'gemini-cli') { + fallbackData = unwrapGeminiCliPayload(fallbackData); + } + parsedUsage = mergeProxyUsage(parsedUsage, parseProxyUsage(fallbackData)); + const latency = Date.now() - startTime; + const failure = detectProxyFailure({ rawText, usage: parsedUsage }); + if (failure) { + clearSurfaceStickyChannel({ + stickySessionKey, + selected, + }); + const failureOutcome = await failureToolkit.handleDetectedFailure({ + selected, + requestedModel, + modelName, + failure, + latencyMs: latency, + retryCount, + promptTokens: parsedUsage.promptTokens, + completionTokens: parsedUsage.completionTokens, + totalTokens: parsedUsage.totalTokens, + upstreamPath: successfulUpstreamPath, + }); + if (failureOutcome.action === 'retry') { + retryCount += 1; + continue; + } + return reply.code(failureOutcome.status).send(failureOutcome.payload); + } + + startSseResponse(); + const streamResult = streamSession.consumeUpstreamFinalPayload(fallbackData, fallbackText, reply.raw); + if (streamResult.status === 'failed') { + clearSurfaceStickyChannel({ + stickySessionKey, + selected, + }); + await failureToolkit.recordStreamFailure({ + selected, + requestedModel, + modelName, + errorMessage: streamResult.errorMessage, + latencyMs: latency, + retryCount, + promptTokens: parsedUsage.promptTokens, + completionTokens: parsedUsage.completionTokens, + totalTokens: parsedUsage.totalTokens, + upstreamPath: successfulUpstreamPath, + runtimeFailureStatus: 502, + }); + return; + } + bindSurfaceStickyChannel({ + stickySessionKey, + selected, + }); + } else { + startSseResponse(); + const upstreamReader = upstream.body?.getReader(); + const baseReader = String(selected.site.platform || '').trim().toLowerCase() === 'gemini-cli' && upstreamReader + ? createGeminiCliStreamReader(upstreamReader) + : upstreamReader; + const decoder = new TextDecoder(); + const reader = baseReader + ? { + async read() { + const result = await baseReader.read(); + if (result.value) { + rawText += decoder.decode(result.value, { stream: true }); + } + return result; + }, + async cancel(reason?: unknown) { + return baseReader.cancel(reason); + }, + releaseLock() { + return baseReader.releaseLock(); + }, + } + : baseReader; + const streamResult = await streamSession.run(reader, reply.raw); + rawText += decoder.decode(); + + const latency = Date.now() - startTime; + if (streamResult.status === 'failed') { + clearSurfaceStickyChannel({ + stickySessionKey, + selected, + }); + await failureToolkit.recordStreamFailure({ + selected, + requestedModel, + modelName, + errorMessage: streamResult.errorMessage, + latencyMs: latency, + retryCount, + promptTokens: parsedUsage.promptTokens, + completionTokens: parsedUsage.completionTokens, + totalTokens: parsedUsage.totalTokens, + upstreamPath: successfulUpstreamPath, + runtimeFailureStatus: 502, + }); + return; + } + + // Once SSE has been hijacked and streamed downstream, we can no longer + // safely fall back to an HTTP error response or retry by switching the + // channel mid-flight. Stream-level failures must be handled in-band by + // the proxy stream session itself. + } + + const latency = Date.now() - startTime; + await recordSurfaceSuccess({ + selected, + requestedModel, + modelName, + parsedUsage, + requestStartedAtMs: startTime, + latencyMs: latency, + retryCount, + upstreamPath: successfulUpstreamPath, + logSuccess: failureToolkit.log, + recordDownstreamCost: (estimatedCost) => { + recordDownstreamCostUsage(request, estimatedCost); + }, + bestEffortMetrics: { + errorLabel: '[proxy/chat] failed to record success metrics', + }, + }); + bindSurfaceStickyChannel({ + stickySessionKey, + selected, + }); + return; + } + + const upstreamContentType = (upstream.headers.get('content-type') || '').toLowerCase(); + let rawText = ''; + let upstreamData: unknown; + if (upstreamContentType.includes('text/event-stream') && successfulUpstreamPath.endsWith('/responses')) { + const collected = await collectResponsesFinalPayloadFromSse(upstream, modelName); + rawText = collected.rawText; + upstreamData = collected.payload; + } else { + rawText = await readRuntimeResponseText(upstream); + if (looksLikeResponsesSseText(rawText)) { + upstreamData = collectResponsesFinalPayloadFromSseText(rawText, modelName).payload; + } else { + upstreamData = rawText; + try { + upstreamData = JSON.parse(rawText); + } catch { + upstreamData = rawText; + } + } + } + if (String(selected.site.platform || '').trim().toLowerCase() === 'gemini-cli') { + upstreamData = unwrapGeminiCliPayload(upstreamData); + } + + const latency = Date.now() - startTime; + const parsedUsage = parseProxyUsage(upstreamData); + const failure = detectProxyFailure({ rawText, usage: parsedUsage }); + if (failure) { + clearSurfaceStickyChannel({ + stickySessionKey, + selected, + }); + const failureOutcome = await failureToolkit.handleDetectedFailure({ + selected, + requestedModel, + modelName, + failure, + latencyMs: latency, + retryCount, + promptTokens: parsedUsage.promptTokens, + completionTokens: parsedUsage.completionTokens, + totalTokens: parsedUsage.totalTokens, + upstreamPath: successfulUpstreamPath, + }); + if (failureOutcome.action === 'retry') { + retryCount += 1; + continue; + } + return reply.code(failureOutcome.status).send(failureOutcome.payload); + } + const normalizedFinal = downstreamTransformer.transformFinalResponse(upstreamData, modelName, rawText); + const downstreamResponse = downstreamTransformer.serializeFinalResponse(normalizedFinal, parsedUsage); + + await recordSurfaceSuccess({ + selected, + requestedModel, + modelName, + parsedUsage, + requestStartedAtMs: startTime, + latencyMs: latency, + retryCount, + upstreamPath: successfulUpstreamPath, + logSuccess: failureToolkit.log, + recordDownstreamCost: (estimatedCost) => { + recordDownstreamCostUsage(request, estimatedCost); + }, + bestEffortMetrics: { + errorLabel: '[proxy/chat] failed to record success metrics', + }, + }); + bindSurfaceStickyChannel({ + stickySessionKey, + selected, + }); + + return reply.send(downstreamResponse); + } catch (err: any) { + clearSurfaceStickyChannel({ + stickySessionKey, + selected, + }); + const failureOutcome = await failureToolkit.handleExecutionError({ + selected, + requestedModel, + modelName, + errorMessage: err?.message || 'network failure', + latencyMs: Date.now() - startTime, + retryCount, + }); + if (failureOutcome.action === 'retry') { + retryCount += 1; + continue; + } + return reply.code(failureOutcome.status).send(failureOutcome.payload); + } finally { + channelLease.release(); + } + } +} + +function deriveCodexSessionCacheKey(input: { + downstreamFormat: DownstreamFormat | 'responses'; + body: unknown; + requestedModel: string; + proxyToken: string | null; +}): string | null { + if (isRecord(input.body)) { + if (input.downstreamFormat === 'claude' && isRecord(input.body.metadata)) { + const userId = asTrimmedString(input.body.metadata.user_id); + if (userId) return `${input.requestedModel}:claude:${userId}`; + } + const promptCacheKey = asTrimmedString(input.body.prompt_cache_key); + if (promptCacheKey) return `${input.requestedModel}:responses:${promptCacheKey}`; + } + + const proxyToken = asTrimmedString(input.proxyToken); + if (proxyToken) { + return `${input.requestedModel}:proxy:${proxyToken}`; + } + + return null; +} + +export async function handleClaudeCountTokensSurfaceRequest( + request: FastifyRequest, + reply: FastifyReply, +) { + const rawBody = isRecord(request.body) ? { ...request.body } : null; + if (!rawBody) { + return reply.code(400).send({ + error: { + message: 'Request body must be a JSON object', + type: 'invalid_request_error', + }, + }); + } + + const requestedModel = asTrimmedString(rawBody.model); + if (!requestedModel) { + return reply.code(400).send({ + error: { + message: 'model is required', + type: 'invalid_request_error', + }, + }); + } + + if (!await ensureModelAllowedForDownstreamKey(request, reply, requestedModel)) return; + const downstreamPath = '/v1/messages/count_tokens'; + const clientContext = detectDownstreamClientContext({ + downstreamPath, + headers: request.headers as Record<string, unknown>, + body: rawBody, + }); + const downstreamPolicy = getDownstreamRoutingPolicy(request); + const downstreamApiKeyId = getProxyAuthContext(request)?.keyId ?? null; + const maxRetries = getProxyMaxChannelRetries(); + const failureToolkit = createSurfaceFailureToolkit({ + warningScope: 'chat', + downstreamPath, + maxRetries, + clientContext, + downstreamApiKeyId, + }); + const stickySessionKey = buildSurfaceStickySessionKey({ + clientContext, + requestedModel, + downstreamPath, + downstreamApiKeyId, + }); + const excludeChannelIds: number[] = []; + let retryCount = 0; + + while (retryCount <= maxRetries) { + const selected = await selectSurfaceChannelForAttempt({ + requestedModel, + downstreamPolicy, + excludeChannelIds, + retryCount, + stickySessionKey, + }); + + if (!selected) { + await reportProxyAllFailed({ + model: requestedModel, + reason: 'No available channels after retries', + }); + return reply.code(503).send({ + error: { message: 'No available channels for this model', type: 'server_error' }, + }); + } + + excludeChannelIds.push(selected.channel.id); + const modelName = selected.actualModel || requestedModel; + const endpointCandidates = await resolveUpstreamEndpointCandidates( + { + site: selected.site, + account: selected.account, + }, + modelName, + 'claude', + requestedModel, + ); + if (!endpointCandidates.includes('messages')) { + if (canRetryProxyChannel(retryCount)) { + retryCount += 1; + continue; + } + return reply.code(501).send({ + error: { + message: 'Claude count_tokens compatibility is not implemented for this upstream', + type: 'invalid_request_error', + }, + }); + } + const oauth = getOauthInfoFromAccount(selected.account); + const startTime = Date.now(); + const leaseResult = await acquireSurfaceChannelLease({ + stickySessionKey, + selected, + }); + if (leaseResult.status === 'timeout') { + clearSurfaceStickyChannel({ + stickySessionKey, + selected, + }); + const busyMessage = buildSurfaceChannelBusyMessage(leaseResult.waitMs); + await failureToolkit.log({ + selected, + modelRequested: requestedModel, + status: 'failed', + httpStatus: 503, + latencyMs: leaseResult.waitMs, + errorMessage: busyMessage, + retryCount, + }); + if (canRetryProxyChannel(retryCount)) { + retryCount += 1; + continue; + } + return reply.code(503).send({ + error: { + message: busyMessage, + type: 'server_error', + }, + }); + } + const channelLease = leaseResult.lease; + + const buildRequest = () => { + const upstreamRequest = buildClaudeCountTokensUpstreamRequest({ + modelName, + tokenValue: selected.tokenValue, + oauthProvider: oauth?.provider, + sitePlatform: selected.site.platform, + claudeBody: rawBody, + downstreamHeaders: request.headers as Record<string, unknown>, + }); + return { + endpoint: 'messages' as const, + path: upstreamRequest.path, + headers: upstreamRequest.headers, + body: upstreamRequest.body, + runtime: upstreamRequest.runtime, + }; + }; + + try { + let upstreamRequest = buildRequest(); + const dispatchRequest = createSurfaceDispatchRequest({ + site: selected.site, + accountExtraConfig: selected.account.extraConfig, + }); + let upstream = await dispatchRequest(upstreamRequest); + + if ((upstream.status === 401 || upstream.status === 403) && oauth) { + const recoverContext = { + request: upstreamRequest, + response: upstream, + rawErrText: '', + }; + const recovered = await trySurfaceOauthRefreshRecovery({ + ctx: recoverContext, + selected, + siteUrl: selected.site.url, + buildRequest: () => buildRequest(), + dispatchRequest, + captureFailureBody: false, + }); + if (recovered?.upstream?.ok) { + upstreamRequest = buildRequest(); + upstream = recovered.upstream; + } else { + upstreamRequest = recoverContext.request; + upstream = recoverContext.response; + } + } + + const latency = Date.now() - startTime; + const contentType = upstream.headers.get('content-type') || 'application/json'; + const text = await readRuntimeResponseText(upstream); + let payload: unknown = text; + try { + payload = JSON.parse(text); + } catch { + payload = text; + } + + if (!upstream.ok) { + clearSurfaceStickyChannel({ + stickySessionKey, + selected, + }); + const failureOutcome = await failureToolkit.handleUpstreamFailure({ + selected, + requestedModel, + modelName, + status: upstream.status, + errText: typeof payload === 'string' ? payload : JSON.stringify(payload), + rawErrText: typeof payload === 'string' ? payload : text, + latencyMs: latency, + retryCount, + }); + if (failureOutcome.action === 'retry') { + retryCount += 1; + continue; + } + return reply.code(failureOutcome.status).send(failureOutcome.payload); + } + + tokenRouter.recordSuccess(selected.channel.id, latency, 0, modelName); + recordDownstreamCostUsage(request, 0); + await failureToolkit.log({ + selected, + modelRequested: requestedModel, + status: 'success', + httpStatus: upstream.status, + latencyMs: latency, + errorMessage: null, + retryCount, + upstreamPath: upstreamRequest.path, + }); + bindSurfaceStickyChannel({ + stickySessionKey, + selected, + }); + return reply.code(upstream.status).type(contentType).send(payload); + } catch (error: any) { + clearSurfaceStickyChannel({ + stickySessionKey, + selected, + }); + const failureOutcome = await failureToolkit.handleExecutionError({ + selected, + requestedModel, + modelName, + errorMessage: error?.message || 'network failure', + latencyMs: Date.now() - startTime, + retryCount, + }); + if (failureOutcome.action === 'retry') { + retryCount += 1; + continue; + } + return reply.code(failureOutcome.status).send(failureOutcome.payload); + } finally { + channelLease.release(); + } + } +} diff --git a/src/server/proxy-core/surfaces/filesSurface.ts b/src/server/proxy-core/surfaces/filesSurface.ts new file mode 100644 index 00000000..456f0022 --- /dev/null +++ b/src/server/proxy-core/surfaces/filesSurface.ts @@ -0,0 +1,139 @@ +import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'; +import { getProxyResourceOwner } from '../../middleware/auth.js'; +import { + getProxyFileByPublicIdForOwner, + getProxyFileContentByPublicIdForOwner, + listProxyFilesByOwner, + saveProxyFile, + softDeleteProxyFileByPublicIdForOwner, +} from '../../services/proxyFileStore.js'; +import { ensureMultipartBufferParser, parseMultipartFormData } from '../../routes/proxy/multipart.js'; +import { + isSupportedConversationFileMimeType, + resolveConversationFileMimeType, +} from '../../../shared/conversationFileTypes.js'; + +function invalidRequest(reply: FastifyReply, message: string) { + return reply.code(400).send({ error: { message, type: 'invalid_request_error' } }); +} + +function notFound(reply: FastifyReply, message = 'file not found') { + return reply.code(404).send({ error: { message, type: 'not_found_error' } }); +} + +function toUnixSeconds(sqlDateTime: string | null | undefined): number { + if (!sqlDateTime) return Math.floor(Date.now() / 1000); + const parsed = Date.parse(sqlDateTime.replace(' ', 'T') + 'Z'); + if (!Number.isFinite(parsed)) return Math.floor(Date.now() / 1000); + return Math.floor(parsed / 1000); +} + +function toFileObject(record: { + publicId: string; + filename: string; + mimeType: string; + purpose: string | null; + byteSize: number; + createdAt: string | null; +}) { + return { + id: record.publicId, + object: 'file', + bytes: record.byteSize, + created_at: toUnixSeconds(record.createdAt), + filename: record.filename, + purpose: record.purpose || 'assistants', + mime_type: record.mimeType, + }; +} + +export async function filesProxyRoute(app: FastifyInstance) { + ensureMultipartBufferParser(app); + + app.post('/v1/files', async (request: FastifyRequest, reply: FastifyReply) => { + const owner = getProxyResourceOwner(request); + if (!owner) { + return reply.code(401).send({ error: { message: 'Missing proxy auth context', type: 'authentication_error' } }); + } + + const formData = await parseMultipartFormData(request); + if (!formData) { + return invalidRequest(reply, 'multipart/form-data with a file field is required'); + } + + const fileEntry = formData.get('file'); + if (!fileEntry || typeof fileEntry !== 'object' || typeof (fileEntry as File).arrayBuffer !== 'function') { + return invalidRequest(reply, 'file field is required'); + } + + const filename = fileEntry.name || 'upload.bin'; + const mimeType = resolveConversationFileMimeType(fileEntry.type, filename); + if (!isSupportedConversationFileMimeType(mimeType)) { + return invalidRequest(reply, `unsupported file mime type: ${mimeType || 'application/octet-stream'}`); + } + + const purposeValue = formData.get('purpose'); + const purpose = typeof purposeValue === 'string' && purposeValue.trim().length > 0 + ? purposeValue.trim() + : 'assistants'; + const buffer = Buffer.from(await fileEntry.arrayBuffer()); + + const saved = await saveProxyFile({ + ...owner, + filename, + mimeType, + purpose, + contentBase64: buffer.toString('base64'), + }); + return reply.send(toFileObject(saved)); + }); + + app.get('/v1/files', async (request: FastifyRequest, reply: FastifyReply) => { + const owner = getProxyResourceOwner(request); + if (!owner) { + return reply.code(401).send({ error: { message: 'Missing proxy auth context', type: 'authentication_error' } }); + } + const files = await listProxyFilesByOwner(owner); + return reply.send({ + object: 'list', + data: files.map((item) => toFileObject(item)), + has_more: false, + }); + }); + + app.get<{ Params: { fileId: string } }>('/v1/files/:fileId', async (request, reply) => { + const owner = getProxyResourceOwner(request); + if (!owner) { + return reply.code(401).send({ error: { message: 'Missing proxy auth context', type: 'authentication_error' } }); + } + const file = await getProxyFileByPublicIdForOwner(request.params.fileId, owner); + if (!file) return notFound(reply); + return reply.send(toFileObject(file)); + }); + + app.get<{ Params: { fileId: string } }>('/v1/files/:fileId/content', async (request, reply) => { + const owner = getProxyResourceOwner(request); + if (!owner) { + return reply.code(401).send({ error: { message: 'Missing proxy auth context', type: 'authentication_error' } }); + } + const file = await getProxyFileContentByPublicIdForOwner(request.params.fileId, owner); + if (!file) return notFound(reply); + reply.type(file.mimeType); + reply.header('Content-Disposition', `inline; filename="${encodeURIComponent(file.filename)}"`); + return reply.send(file.buffer); + }); + + app.delete<{ Params: { fileId: string } }>('/v1/files/:fileId', async (request, reply) => { + const owner = getProxyResourceOwner(request); + if (!owner) { + return reply.code(401).send({ error: { message: 'Missing proxy auth context', type: 'authentication_error' } }); + } + const deleted = await softDeleteProxyFileByPublicIdForOwner(request.params.fileId, owner); + if (!deleted) return notFound(reply); + return reply.send({ + id: request.params.fileId, + object: 'file', + deleted: true, + }); + }); +} diff --git a/src/server/proxy-core/surfaces/geminiSurface.ts b/src/server/proxy-core/surfaces/geminiSurface.ts new file mode 100644 index 00000000..f0b1b504 --- /dev/null +++ b/src/server/proxy-core/surfaces/geminiSurface.ts @@ -0,0 +1,966 @@ +import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'; +import { TextDecoder } from 'node:util'; +import { fetch } from 'undici'; +import { and, eq } from 'drizzle-orm'; +import { db, schema } from '../../db/index.js'; +import { formatUtcSqlDateTime } from '../../services/localTimeService.js'; +import { parseProxyUsage } from '../../services/proxyUsageParser.js'; +import { isModelAllowedByPolicyOrAllowedRoutes } from '../../services/downstreamApiKeyService.js'; +import { tokenRouter } from '../../services/tokenRouter.js'; +import { buildOauthProviderHeaders } from '../../services/oauth/service.js'; +import { getOauthInfoFromAccount } from '../../services/oauth/oauthAccount.js'; +import { refreshOauthAccessTokenSingleflight } from '../../services/oauth/refreshSingleflight.js'; +import { resolveChannelProxyUrl, withSiteRecordProxyRequestInit } from '../../services/siteProxy.js'; +import * as routeRefreshWorkflow from '../../services/routeRefreshWorkflow.js'; +import { getDownstreamRoutingPolicy } from '../../routes/proxy/downstreamPolicy.js'; +import { executeEndpointFlow, type BuiltEndpointRequest } from '../../routes/proxy/endpointFlow.js'; +import { composeProxyLogMessage } from '../../routes/proxy/logPathMeta.js'; +import { + buildUpstreamEndpointRequest, + recordUpstreamEndpointFailure, + recordUpstreamEndpointSuccess, + resolveUpstreamEndpointCandidates, +} from '../../routes/proxy/upstreamEndpoint.js'; +import { + geminiGenerateContentTransformer, +} from '../../transformers/gemini/generate-content/index.js'; +import { createChatEndpointStrategy } from '../../transformers/shared/chatEndpointStrategy.js'; +import { normalizeUpstreamFinalResponse } from '../../transformers/shared/normalized.js'; +import { + createGeminiCliStreamReader, + unwrapGeminiCliPayload, + wrapGeminiCliRequest, +} from '../../routes/proxy/geminiCliCompat.js'; +import { dispatchRuntimeRequest } from '../../routes/proxy/runtimeExecutor.js'; +import { detectDownstreamClientContext, type DownstreamClientContext } from '../../routes/proxy/downstreamClientContext.js'; +import { insertProxyLog } from '../../services/proxyLogStore.js'; +import { summarizeConversationFileInputsInOpenAiBody } from '../capabilities/conversationFileCapabilities.js'; +import { readRuntimeResponseText } from '../executors/types.js'; +import { canRetryProxyChannel, getProxyMaxChannelRetries } from '../../services/proxyChannelRetry.js'; +const GEMINI_MODEL_PROBES = [ + 'gemini-2.5-flash', + 'gemini-2.0-flash', + 'gemini-1.5-flash', + 'gemini-pro', +]; +const GEMINI_CLI_STATIC_MODELS = [ + { name: 'models/gemini-2.5-pro', displayName: 'Gemini 2.5 Pro' }, + { name: 'models/gemini-2.5-flash', displayName: 'Gemini 2.5 Flash' }, + { name: 'models/gemini-2.5-flash-lite', displayName: 'Gemini 2.5 Flash Lite' }, + { name: 'models/gemini-3-pro-preview', displayName: 'Gemini 3 Pro Preview' }, + { name: 'models/gemini-3.1-pro-preview', displayName: 'Gemini 3.1 Pro Preview' }, + { name: 'models/gemini-3-flash-preview', displayName: 'Gemini 3 Flash Preview' }, + { name: 'models/gemini-3.1-flash-lite-preview', displayName: 'Gemini 3.1 Flash Lite Preview' }, +]; +const EMPTY_PROXY_USAGE = { + promptTokens: 0, + completionTokens: 0, + totalTokens: 0, +}; + +function isGeminiCliPlatform(platform: unknown): boolean { + return String(platform || '').trim().toLowerCase() === 'gemini-cli'; +} + +function isAntigravityPlatform(platform: unknown): boolean { + return String(platform || '').trim().toLowerCase() === 'antigravity'; +} + +function isInternalGeminiPlatform(platform: unknown): boolean { + return isGeminiCliPlatform(platform) || isAntigravityPlatform(platform); +} + +function buildGeminiCliActionPath(input: { + isStreamAction: boolean; + isCountTokensAction: boolean; +}) { + if (input.isCountTokensAction) return '/v1internal:countTokens'; + if (input.isStreamAction) return '/v1internal:streamGenerateContent?alt=sse'; + return '/v1internal:generateContent'; +} + +function isDirectGeminiFamilyPlatform(platform: unknown): boolean { + const normalized = String(platform || '').trim().toLowerCase(); + return normalized === 'gemini' || normalized === 'gemini-cli' || normalized === 'antigravity'; +} + +function omitGeminiCliModelField(body: unknown): Record<string, unknown> { + if (!body || typeof body !== 'object' || Array.isArray(body)) return {}; + const { model: _model, ...rest } = body as Record<string, unknown>; + return rest; +} + +async function selectGeminiChannel(request: FastifyRequest) { + const policy = getDownstreamRoutingPolicy(request); + for (const candidate of GEMINI_MODEL_PROBES) { + const selected = await tokenRouter.selectChannel(candidate, policy); + if (selected) return selected; + } + return null; +} + +async function selectNextGeminiProbeChannel(request: FastifyRequest, excludeChannelIds: number[]) { + const policy = getDownstreamRoutingPolicy(request); + for (const candidate of GEMINI_MODEL_PROBES) { + const selected = await tokenRouter.selectNextChannel(candidate, excludeChannelIds, policy); + if (selected) return selected; + } + return null; +} + +function resolveDownstreamPath(request: FastifyRequest): string { + const rawUrl = request.raw.url || request.url || ''; + const withoutQuery = rawUrl.split('?')[0] || rawUrl; + return withoutQuery || '/v1beta/models'; +} + +function resolveUpstreamPath(apiVersion: string, modelActionPath: string): string { + const normalizedVersion = apiVersion.replace(/^\/+/, ''); + const normalizedAction = modelActionPath.replace(/^\/+/, ''); + return `/${normalizedVersion}/${normalizedAction}`; +} + +function hasDownstreamModelRestrictions(policy: { supportedModels?: unknown; allowedRouteIds?: unknown; denyAllWhenEmpty?: unknown }): boolean { + const supportedModels = Array.isArray(policy.supportedModels) ? policy.supportedModels : []; + const allowedRouteIds = Array.isArray(policy.allowedRouteIds) ? policy.allowedRouteIds : []; + return supportedModels.length > 0 || allowedRouteIds.length > 0 || policy.denyAllWhenEmpty === true; +} + +function extractGeminiListedModelName(item: unknown): string { + if (!item || typeof item !== 'object') return ''; + const rawName = typeof (item as { name?: unknown }).name === 'string' + ? (item as { name: string }).name.trim() + : ''; + if (!rawName) return ''; + return rawName.startsWith('models/') ? rawName.slice('models/'.length) : rawName; +} + +async function filterGeminiListedModelsForPolicy( + payload: unknown, + request: FastifyRequest, +): Promise<unknown> { + if (!payload || typeof payload !== 'object' || !Array.isArray((payload as { models?: unknown[] }).models)) { + return payload; + } + + const policy = getDownstreamRoutingPolicy(request); + if (!hasDownstreamModelRestrictions(policy)) { + return payload; + } + + const filteredModels: unknown[] = []; + for (const item of (payload as { models: unknown[] }).models) { + const modelName = extractGeminiListedModelName(item); + if (!modelName) continue; + if (!await isModelAllowedByPolicyOrAllowedRoutes(modelName, policy)) continue; + const decision = await tokenRouter.explainSelection?.(modelName, [], policy); + if (decision && typeof decision.selectedChannelId !== 'number') continue; + filteredModels.push(item); + } + + return { + ...(payload as Record<string, unknown>), + models: filteredModels, + }; +} + +async function readRouteAwareGeminiModels(request: FastifyRequest): Promise<Array<{ name: string; displayName: string }>> { + const policy = getDownstreamRoutingPolicy(request); + const rows = await db.select({ modelName: schema.modelAvailability.modelName }) + .from(schema.modelAvailability) + .innerJoin(schema.accounts, eq(schema.modelAvailability.accountId, schema.accounts.id)) + .innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id)) + .where(and( + eq(schema.modelAvailability.available, true), + eq(schema.accounts.status, 'active'), + eq(schema.sites.status, 'active'), + )) + .all(); + const routeAliases = await db.select({ displayName: schema.tokenRoutes.displayName }) + .from(schema.tokenRoutes) + .where(eq(schema.tokenRoutes.enabled, true)) + .all(); + const deduped = Array.from(new Set([ + ...rows.map((row) => String(row.modelName || '').trim()).filter(Boolean), + ...routeAliases.map((row) => String(row.displayName || '').trim()).filter(Boolean), + ])).sort(); + + const allowed: Array<{ name: string; displayName: string }> = []; + for (const modelName of deduped) { + if (!await isModelAllowedByPolicyOrAllowedRoutes(modelName, policy)) continue; + const decision = await tokenRouter.explainSelection?.(modelName, [], policy); + if (decision && typeof decision.selectedChannelId !== 'number') continue; + allowed.push({ + name: `models/${modelName}`, + displayName: modelName, + }); + } + + return allowed; +} + +async function logProxy( + selected: any, + modelRequested: string, + status: string, + httpStatus: number, + latencyMs: number, + errorMessage: string | null, + retryCount: number, + downstreamPath: string, + upstreamPath: string | null, + clientContext: DownstreamClientContext | null = null, + promptTokens = 0, + completionTokens = 0, + totalTokens = 0, +) { + try { + const createdAt = formatUtcSqlDateTime(new Date()); + const normalizedErrorMessage = composeProxyLogMessage({ + clientKind: clientContext?.clientKind && clientContext.clientKind !== 'generic' + ? clientContext.clientKind + : null, + sessionId: clientContext?.sessionId || null, + traceHint: clientContext?.traceHint || null, + downstreamPath, + upstreamPath, + errorMessage, + }); + await insertProxyLog({ + routeId: selected.channel.routeId, + channelId: selected.channel.id, + accountId: selected.account.id, + modelRequested, + modelActual: selected.actualModel || modelRequested, + status, + httpStatus, + latencyMs, + promptTokens, + completionTokens, + totalTokens, + estimatedCost: 0, + clientFamily: clientContext?.clientKind || null, + clientAppId: clientContext?.clientAppId || null, + clientAppName: clientContext?.clientAppName || null, + clientConfidence: clientContext?.clientConfidence || null, + errorMessage: normalizedErrorMessage, + retryCount, + createdAt, + }); + } catch (error) { + console.warn('[proxy/gemini] failed to write proxy log', error); + } +} + +async function recordGeminiChannelSuccessBestEffort( + channelId: number, + latencyMs: number, + modelName: string, +): Promise<void> { + try { + await tokenRouter.recordSuccess?.(channelId, latencyMs, 0, modelName); + } catch (error) { + console.warn('[proxy/gemini] failed to record channel success', error); + } +} + +export async function geminiProxyRoute(app: FastifyInstance) { + const listModels = async (request: FastifyRequest, reply: FastifyReply) => { + const apiVersion = geminiGenerateContentTransformer.resolveProxyApiVersion( + request.params as { geminiApiVersion?: string } | undefined, + ); + const excludeChannelIds: number[] = []; + let retryCount = 0; + let lastStatus = 503; + let lastText = 'No available channels for Gemini models'; + let lastContentType = 'application/json'; + + while (retryCount <= getProxyMaxChannelRetries()) { + const selected = retryCount === 0 + ? await selectGeminiChannel(request) + : await selectNextGeminiProbeChannel(request, excludeChannelIds); + if (!selected) { + return reply.code(lastStatus).type(lastContentType).send(lastText); + } + + excludeChannelIds.push(selected.channel.id); + + try { + if (!isDirectGeminiFamilyPlatform(selected.site.platform)) { + let models = await readRouteAwareGeminiModels(request); + if (models.length <= 0) { + await routeRefreshWorkflow.refreshModelsAndRebuildRoutes(); + models = await readRouteAwareGeminiModels(request); + } + return reply.code(200).send({ models }); + } + + if (isGeminiCliPlatform(selected.site.platform)) { + const filtered = await filterGeminiListedModelsForPolicy( + { models: GEMINI_CLI_STATIC_MODELS }, + request, + ); + return reply.code(200).send(filtered); + } + + const upstream = await fetch( + geminiGenerateContentTransformer.resolveModelsUrl(selected.site.url, apiVersion, selected.tokenValue), + { method: 'GET' }, + ); + const text = await readRuntimeResponseText(upstream); + if (!upstream.ok) { + lastStatus = upstream.status; + lastText = text; + lastContentType = upstream.headers.get('content-type') || 'application/json'; + await tokenRouter.recordFailure?.(selected.channel.id, { + status: upstream.status, + errorText: text, + }); + if (canRetryProxyChannel(retryCount)) { + retryCount += 1; + continue; + } + return reply.code(lastStatus).type(lastContentType).send(lastText); + } + + try { + const parsed = JSON.parse(text); + const filtered = await filterGeminiListedModelsForPolicy(parsed, request); + return reply.code(upstream.status).send(filtered); + } catch { + return reply.code(upstream.status).type(upstream.headers.get('content-type') || 'application/json').send(text); + } + } catch (error) { + await tokenRouter.recordFailure?.(selected.channel.id, { + errorText: error instanceof Error ? error.message : 'Gemini upstream request failed', + }); + lastStatus = 502; + lastContentType = 'application/json'; + lastText = JSON.stringify({ + error: { + message: error instanceof Error ? error.message : 'Gemini upstream request failed', + type: 'upstream_error', + }, + }); + if (canRetryProxyChannel(retryCount)) { + retryCount += 1; + continue; + } + return reply.code(lastStatus).type(lastContentType).send(lastText); + } + } + }; + + const handleGenerateContent = async ( + request: FastifyRequest, + reply: FastifyReply, + options?: { + downstreamProtocol?: 'gemini' | 'gemini-cli'; + action?: 'generateContent' | 'streamGenerateContent' | 'countTokens'; + }, + ) => { + const downstreamProtocol = options?.downstreamProtocol || 'gemini'; + const isGeminiCliDownstream = downstreamProtocol === 'gemini-cli'; + const cliRequestedModel = isGeminiCliDownstream + ? (typeof (request.body as Record<string, unknown> | null | undefined)?.model === 'string' + ? String((request.body as Record<string, unknown>).model).trim() + : '') + : ''; + const parsedPath = isGeminiCliDownstream + ? { + apiVersion: 'v1beta', + modelActionPath: `models/${cliRequestedModel}:${options?.action || 'generateContent'}`, + isStreamAction: options?.action === 'streamGenerateContent', + requestedModel: cliRequestedModel, + } + : geminiGenerateContentTransformer.parseProxyRequestPath({ + rawUrl: request.raw.url || request.url || '', + params: request.params as { geminiApiVersion?: string } | undefined, + }); + const { apiVersion, modelActionPath, isStreamAction, requestedModel } = parsedPath; + const isCountTokensAction = isGeminiCliDownstream + ? options?.action === 'countTokens' + : modelActionPath.endsWith(':countTokens'); + const rawUrl = request.raw.url || request.url || ''; + const wantsSseEnvelope = ( + isStreamAction + && (isGeminiCliDownstream || /(?:^|[?&])alt=sse(?:&|$)/i.test(rawUrl)) + ); + if (!requestedModel) { + return reply.code(400).send({ + error: { message: 'Gemini model path is required', type: 'invalid_request_error' }, + }); + } + + const policy = getDownstreamRoutingPolicy(request); + const downstreamPath = resolveDownstreamPath(request); + const clientContext = detectDownstreamClientContext({ + downstreamPath, + headers: request.headers as Record<string, unknown>, + body: request.body, + }); + const excludeChannelIds: number[] = []; + let retryCount = 0; + let lastStatus = 503; + let lastText = 'No available channels for this model'; + let lastContentType = 'application/json'; + + while (retryCount <= getProxyMaxChannelRetries()) { + const selected = retryCount === 0 + ? await tokenRouter.selectChannel(requestedModel, policy) + : await tokenRouter.selectNextChannel(requestedModel, excludeChannelIds, policy); + if (!selected) { + return reply.code(lastStatus).type(lastContentType).send(lastText); + } + + excludeChannelIds.push(selected.channel.id); + + const actualModel = selected.actualModel || requestedModel; + const normalizedBody = geminiGenerateContentTransformer.inbound.normalizeRequest( + isGeminiCliDownstream ? omitGeminiCliModelField(request.body) : (request.body || {}), + actualModel, + ); + let oauth = getOauthInfoFromAccount(selected.account); + const isGeminiCli = isGeminiCliPlatform(selected.site.platform); + const isInternalGemini = isInternalGeminiPlatform(selected.site.platform); + const isDirectGeminiFamily = isDirectGeminiFamilyPlatform(selected.site.platform); + const startTime = Date.now(); + let upstreamPath = ''; + + try { + if (isDirectGeminiFamily) { + if (isGeminiCli && !oauth?.projectId) { + lastStatus = 500; + lastContentType = 'application/json'; + lastText = JSON.stringify({ + error: { + message: 'Gemini CLI OAuth project is missing', + type: 'server_error', + }, + }); + await tokenRouter.recordFailure?.(selected.channel.id, { + status: 500, + errorText: 'Gemini CLI OAuth project is missing', + }); + if (canRetryProxyChannel(retryCount)) { + retryCount += 1; + continue; + } + return reply.code(lastStatus).type(lastContentType).send(lastText); + } + + const actualModelAction = modelActionPath.replace( + /^models\/[^:]+/, + `models/${actualModel}`, + ); + upstreamPath = isInternalGemini + ? buildGeminiCliActionPath({ isStreamAction, isCountTokensAction }) + : resolveUpstreamPath(apiVersion, actualModelAction); + const query = new URLSearchParams(request.query as Record<string, string>).toString(); + const channelProxyUrl = resolveChannelProxyUrl(selected.site, selected.account.extraConfig); + const dispatchSelectedRequest = async () => { + const requestBody = isInternalGemini + ? ( + isCountTokensAction + ? { request: normalizedBody } + : wrapGeminiCliRequest({ + modelName: actualModel, + projectId: oauth?.projectId || '', + request: normalizedBody as Record<string, unknown>, + }) + ) + : normalizedBody; + const requestHeaders = isInternalGemini + ? { + 'Content-Type': 'application/json', + ...(isStreamAction ? { Accept: 'text/event-stream' } : {}), + Authorization: `Bearer ${selected.tokenValue}`, + ...buildOauthProviderHeaders({ + account: selected.account, + downstreamHeaders: request.headers as Record<string, unknown>, + }), + } + : { + 'Content-Type': 'application/json', + }; + const targetUrl = isInternalGemini + ? `${selected.site.url}${upstreamPath}` + : geminiGenerateContentTransformer.resolveActionUrl( + selected.site.url, + apiVersion, + actualModelAction, + selected.tokenValue, + query, + ); + + return isInternalGemini + ? dispatchRuntimeRequest({ + siteUrl: selected.site.url, + targetUrl, + request: { + endpoint: 'chat', + path: upstreamPath, + headers: requestHeaders, + body: requestBody as Record<string, unknown>, + runtime: { + executor: isGeminiCli ? 'gemini-cli' : 'antigravity', + modelName: actualModel, + stream: isStreamAction, + oauthProjectId: oauth?.projectId || null, + action: isCountTokensAction + ? 'countTokens' + : (isStreamAction ? 'streamGenerateContent' : 'generateContent'), + }, + }, + buildInit: async (_requestUrl, requestForFetch) => withSiteRecordProxyRequestInit(selected.site, { + method: 'POST', + headers: requestForFetch.headers, + body: JSON.stringify(requestForFetch.body), + }, channelProxyUrl), + }) + : fetch(targetUrl, { + method: 'POST', + headers: requestHeaders, + body: JSON.stringify(requestBody), + }); + }; + + let upstream = await dispatchSelectedRequest(); + let contentType = upstream.headers.get('content-type') || 'application/json'; + if (upstream.status === 401 && oauth) { + try { + const refreshed = await refreshOauthAccessTokenSingleflight(selected.account.id); + selected.tokenValue = refreshed.accessToken; + selected.account = { + ...selected.account, + accessToken: refreshed.accessToken, + extraConfig: refreshed.extraConfig ?? selected.account.extraConfig, + }; + oauth = getOauthInfoFromAccount(selected.account); + upstream = await dispatchSelectedRequest(); + contentType = upstream.headers.get('content-type') || 'application/json'; + } catch { + // Preserve the original 401 response when refresh fails. + } + } + if (!upstream.ok) { + lastStatus = upstream.status; + lastContentType = contentType; + lastText = await readRuntimeResponseText(upstream); + await tokenRouter.recordFailure?.(selected.channel.id, { + status: upstream.status, + errorText: lastText, + }); + await logProxy( + selected, + requestedModel, + 'failed', + lastStatus, + Date.now() - startTime, + lastText, + retryCount, + downstreamPath, + upstreamPath, + clientContext, + ); + if (canRetryProxyChannel(retryCount)) { + retryCount += 1; + continue; + } + + try { + return reply.code(lastStatus).send(JSON.parse(lastText)); + } catch { + return reply.code(lastStatus).type(lastContentType).send(lastText); + } + } + + if (geminiGenerateContentTransformer.stream.isSseContentType(contentType)) { + reply.hijack(); + reply.raw.statusCode = upstream.status; + reply.raw.setHeader('Content-Type', contentType || 'text/event-stream'); + const upstreamReader = upstream.body?.getReader(); + const reader = isInternalGemini && !isGeminiCliDownstream && upstreamReader + ? createGeminiCliStreamReader(upstreamReader) + : upstreamReader; + if (!reader) { + const latency = Date.now() - startTime; + await recordGeminiChannelSuccessBestEffort(selected.channel.id, latency, actualModel); + await logProxy( + selected, + requestedModel, + 'success', + upstream.status, + latency, + null, + retryCount, + downstreamPath, + upstreamPath, + clientContext, + ); + reply.raw.end(); + return; + } + const aggregateState = geminiGenerateContentTransformer.stream.createAggregateState(); + const decoder = new TextDecoder(); + let rest = ''; + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + if (!value) continue; + const chunkText = decoder.decode(value, { stream: true }); + const consumed = geminiGenerateContentTransformer.stream.consumeUpstreamSseBuffer( + aggregateState, + rest + chunkText, + ); + rest = consumed.rest; + for (const line of consumed.lines) { + reply.raw.write(line); + } + } + const tail = decoder.decode(); + if (tail) { + const consumed = geminiGenerateContentTransformer.stream.consumeUpstreamSseBuffer( + aggregateState, + rest + tail, + ); + for (const line of consumed.lines) { + reply.raw.write(line); + } + } + } finally { + reader.releaseLock(); + reply.raw.end(); + } + const parsedUsage = parseProxyUsage(aggregateState); + const latency = Date.now() - startTime; + await recordGeminiChannelSuccessBestEffort(selected.channel.id, latency, actualModel); + await logProxy( + selected, + requestedModel, + 'success', + upstream.status, + latency, + null, + retryCount, + downstreamPath, + upstreamPath, + clientContext, + parsedUsage.promptTokens, + parsedUsage.completionTokens, + parsedUsage.totalTokens, + ); + return; + } + + const text = await readRuntimeResponseText(upstream); + const aggregateState = geminiGenerateContentTransformer.stream.createAggregateState(); + let parsedUsage = EMPTY_PROXY_USAGE; + try { + const parsed = JSON.parse(text); + const unwrappedPayload = isInternalGemini && !isGeminiCliDownstream + ? unwrapGeminiCliPayload(parsed) + : parsed; + const responsePayload = isCountTokensAction + ? unwrappedPayload + : geminiGenerateContentTransformer.stream.serializeUpstreamJsonPayload( + aggregateState, + unwrappedPayload, + isStreamAction, + ); + parsedUsage = parseProxyUsage(aggregateState); + const latency = Date.now() - startTime; + await recordGeminiChannelSuccessBestEffort(selected.channel.id, latency, actualModel); + await logProxy( + selected, + requestedModel, + 'success', + upstream.status, + latency, + null, + retryCount, + downstreamPath, + upstreamPath, + clientContext, + parsedUsage.promptTokens, + parsedUsage.completionTokens, + parsedUsage.totalTokens, + ); + return reply.code(upstream.status).send( + isGeminiCliDownstream && !isCountTokensAction + ? { response: responsePayload } + : responsePayload, + ); + } catch { + const latency = Date.now() - startTime; + await recordGeminiChannelSuccessBestEffort(selected.channel.id, latency, actualModel); + await logProxy( + selected, + requestedModel, + 'success', + upstream.status, + latency, + null, + retryCount, + downstreamPath, + upstreamPath, + clientContext, + ); + return reply.code(upstream.status).type(contentType || 'application/json').send(text); + } + } + + if (isCountTokensAction) { + lastStatus = 501; + lastContentType = 'application/json'; + lastText = JSON.stringify({ + error: { + message: 'Gemini countTokens compatibility is not implemented for this upstream', + type: 'invalid_request_error', + }, + }); + return reply.code(lastStatus).type(lastContentType).send(lastText); + } + + const openAiBody = geminiGenerateContentTransformer.compatibility.buildOpenAiBodyFromGeminiRequest({ + body: normalizedBody as Record<string, unknown>, + modelName: actualModel, + stream: isStreamAction, + }); + const conversationFileSummary = summarizeConversationFileInputsInOpenAiBody(openAiBody); + const hasNonImageFileInput = conversationFileSummary.hasDocument; + const endpointCandidates = await resolveUpstreamEndpointCandidates( + { + site: selected.site, + account: selected.account, + }, + actualModel, + 'openai', + requestedModel, + { + hasNonImageFileInput, + conversationFileSummary, + }, + ); + const endpointRuntimeContext = { + siteId: selected.site.id, + modelName: actualModel, + downstreamFormat: 'openai' as const, + requestedModelHint: requestedModel, + requestCapabilities: { + hasNonImageFileInput, + conversationFileSummary, + }, + }; + const buildEndpointRequest = ( + endpoint: 'chat' | 'messages' | 'responses', + requestOptions: { forceNormalizeClaudeBody?: boolean } = {}, + ) => { + const endpointRequest = buildUpstreamEndpointRequest({ + endpoint, + modelName: actualModel, + stream: isStreamAction, + tokenValue: selected.tokenValue, + oauthProvider: oauth?.provider, + oauthProjectId: oauth?.projectId, + sitePlatform: selected.site.platform, + siteUrl: selected.site.url, + openaiBody: openAiBody, + downstreamFormat: 'openai', + forceNormalizeClaudeBody: requestOptions.forceNormalizeClaudeBody, + downstreamHeaders: request.headers as Record<string, unknown>, + providerHeaders: buildOauthProviderHeaders({ + account: selected.account, + downstreamHeaders: request.headers as Record<string, unknown>, + }), + }); + return { + endpoint, + path: endpointRequest.path, + headers: endpointRequest.headers, + body: endpointRequest.body as Record<string, unknown>, + runtime: endpointRequest.runtime, + }; + }; + const channelProxyUrl = resolveChannelProxyUrl(selected.site, selected.account.extraConfig); + const dispatchRequest = (compatibilityRequest: BuiltEndpointRequest, targetUrl?: string) => ( + dispatchRuntimeRequest({ + siteUrl: selected.site.url, + targetUrl, + request: compatibilityRequest, + buildInit: async (_requestUrl, requestForFetch) => withSiteRecordProxyRequestInit(selected.site, { + method: 'POST', + headers: requestForFetch.headers, + body: JSON.stringify(requestForFetch.body), + }, channelProxyUrl), + }) + ); + const endpointStrategy = createChatEndpointStrategy({ + downstreamFormat: 'openai', + endpointCandidates, + modelName: actualModel, + requestedModelHint: requestedModel, + sitePlatform: selected.site.platform, + isStream: isStreamAction, + buildRequest: ({ endpoint, forceNormalizeClaudeBody }) => buildEndpointRequest( + endpoint, + { forceNormalizeClaudeBody }, + ), + dispatchRequest, + }); + const endpointResult = await executeEndpointFlow({ + siteUrl: selected.site.url, + endpointCandidates, + buildRequest: (endpoint) => buildEndpointRequest(endpoint), + dispatchRequest, + tryRecover: endpointStrategy.tryRecover, + onAttemptFailure: (ctx) => { + recordUpstreamEndpointFailure({ + ...endpointRuntimeContext, + endpoint: ctx.request.endpoint, + status: ctx.response.status, + errorText: ctx.rawErrText, + }); + }, + onAttemptSuccess: (ctx) => { + recordUpstreamEndpointSuccess({ + ...endpointRuntimeContext, + endpoint: ctx.request.endpoint, + }); + }, + shouldDowngrade: endpointStrategy.shouldDowngrade, + }); + if (!endpointResult.ok) { + lastStatus = endpointResult.status; + lastContentType = 'application/json'; + lastText = JSON.stringify({ + error: { + message: endpointResult.errText, + type: 'upstream_error', + }, + }); + await tokenRouter.recordFailure?.(selected.channel.id, { + status: endpointResult.status, + errorText: endpointResult.rawErrText || endpointResult.errText, + }); + await logProxy( + selected, + requestedModel, + 'failed', + lastStatus, + Date.now() - startTime, + endpointResult.errText, + retryCount, + downstreamPath, + null, + clientContext, + ); + if (canRetryProxyChannel(retryCount)) { + retryCount += 1; + continue; + } + return reply.code(lastStatus).type(lastContentType).send(lastText); + } + + upstreamPath = endpointResult.upstreamPath; + const upstream = endpointResult.upstream; + const rawText = await readRuntimeResponseText(upstream); + let upstreamData: unknown = rawText; + try { + upstreamData = JSON.parse(rawText); + } catch {} + const parsedUsage = parseProxyUsage(upstreamData); + const normalizedFinal = normalizeUpstreamFinalResponse(upstreamData, actualModel, rawText); + const geminiResponse = geminiGenerateContentTransformer.compatibility.serializeNormalizedFinalToGemini({ + normalized: normalizedFinal, + usage: { + promptTokens: parsedUsage.promptTokens, + completionTokens: parsedUsage.completionTokens, + totalTokens: parsedUsage.totalTokens, + }, + }); + const latency = Date.now() - startTime; + await recordGeminiChannelSuccessBestEffort(selected.channel.id, latency, actualModel); + await logProxy( + selected, + requestedModel, + 'success', + upstream.status, + latency, + null, + retryCount, + downstreamPath, + upstreamPath, + clientContext, + parsedUsage.promptTokens, + parsedUsage.completionTokens, + parsedUsage.totalTokens, + ); + const downstreamPayload = isGeminiCliDownstream + ? { response: geminiResponse } + : geminiResponse; + if (wantsSseEnvelope) { + // Some compatibility upstreams finish stream requests with a final JSON payload. + // Preserve Gemini streaming UX by wrapping that terminal payload back into one SSE event. + return reply + .code(upstream.status) + .type('text/event-stream; charset=utf-8') + .send(geminiGenerateContentTransformer.stream.serializeSsePayload(downstreamPayload)); + } + return reply.code(upstream.status).send(downstreamPayload); + } catch (error) { + lastStatus = 502; + lastContentType = 'application/json'; + lastText = JSON.stringify({ + error: { + message: error instanceof Error ? error.message : 'Gemini upstream request failed', + type: 'upstream_error', + }, + }); + await tokenRouter.recordFailure?.(selected.channel.id, { + errorText: error instanceof Error ? error.message : 'Gemini upstream request failed', + }); + await logProxy( + selected, + requestedModel, + 'failed', + 0, + Date.now() - startTime, + error instanceof Error ? error.message : 'Gemini upstream request failed', + retryCount, + downstreamPath, + upstreamPath || null, + clientContext, + ); + if (canRetryProxyChannel(retryCount)) { + retryCount += 1; + continue; + } + return reply.code(lastStatus).type(lastContentType).send(lastText); + } + } + }; + + const generateContent = async (request: FastifyRequest, reply: FastifyReply) => handleGenerateContent(request, reply); + const geminiCliGenerateContent = async (request: FastifyRequest, reply: FastifyReply) => handleGenerateContent(request, reply, { + downstreamProtocol: 'gemini-cli', + action: 'generateContent', + }); + const geminiCliStreamGenerateContent = async (request: FastifyRequest, reply: FastifyReply) => handleGenerateContent(request, reply, { + downstreamProtocol: 'gemini-cli', + action: 'streamGenerateContent', + }); + const geminiCliCountTokens = async (request: FastifyRequest, reply: FastifyReply) => handleGenerateContent(request, reply, { + downstreamProtocol: 'gemini-cli', + action: 'countTokens', + }); + + app.get('/v1beta/models', listModels); + app.get('/gemini/:geminiApiVersion/models', listModels); + app.post('/v1beta/models/*', generateContent); + app.post('/gemini/:geminiApiVersion/models/*', generateContent); + app.post('/v1internal::generateContent', geminiCliGenerateContent); + app.post('/v1internal::streamGenerateContent', geminiCliStreamGenerateContent); + app.post('/v1internal::countTokens', geminiCliCountTokens); +} diff --git a/src/server/proxy-core/surfaces/inputFilesSurface.ts b/src/server/proxy-core/surfaces/inputFilesSurface.ts new file mode 100644 index 00000000..df3c3c81 --- /dev/null +++ b/src/server/proxy-core/surfaces/inputFilesSurface.ts @@ -0,0 +1,4 @@ +export { + inlineLocalInputFileReferences, + ProxyInputFileResolutionError, +} from '../../services/proxyInputFileResolver.js'; diff --git a/src/server/proxy-core/surfaces/modelsSurface.test.ts b/src/server/proxy-core/surfaces/modelsSurface.test.ts new file mode 100644 index 00000000..2fe84271 --- /dev/null +++ b/src/server/proxy-core/surfaces/modelsSurface.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { listModelsSurface } from './modelsSurface.js'; + +describe('listModelsSurface', () => { + it('returns OpenAI list shape and hides models without a resolvable channel', async () => { + const result = await listModelsSurface({ + downstreamPolicy: { type: 'all' }, + responseFormat: 'openai', + tokenRouter: { + getAvailableModels: vi.fn().mockResolvedValue(['routable-model', 'orphan-model']), + explainSelection: vi.fn() + .mockResolvedValueOnce({ selectedChannelId: null }) + .mockResolvedValueOnce({ selectedChannelId: 11 }), + }, + refreshModelsAndRebuildRoutes: vi.fn(), + isModelAllowed: vi.fn().mockResolvedValue(true), + now: () => new Date('2026-03-19T00:00:00.000Z'), + }); + + expect(result).toEqual({ + object: 'list', + data: [ + { + id: 'routable-model', + object: 'model', + created: 1773878400, + owned_by: 'metapi', + }, + ], + }); + }); + + it('returns Claude list shape when requested', async () => { + const result = await listModelsSurface({ + downstreamPolicy: { type: 'all' }, + responseFormat: 'claude', + tokenRouter: { + getAvailableModels: vi.fn().mockResolvedValue(['claude-opus-4-6']), + explainSelection: vi.fn().mockResolvedValue({ selectedChannelId: 22 }), + }, + refreshModelsAndRebuildRoutes: vi.fn(), + isModelAllowed: vi.fn().mockResolvedValue(true), + now: () => new Date('2026-03-19T00:00:00.000Z'), + }); + + expect(result).toEqual({ + data: [ + { + id: 'claude-opus-4-6', + type: 'model', + display_name: 'claude-opus-4-6', + created_at: '2026-03-19T00:00:00.000Z', + }, + ], + first_id: 'claude-opus-4-6', + last_id: 'claude-opus-4-6', + has_more: false, + }); + }); + + it('applies downstream policy filtering before selection checks and refreshes once when the first read is empty', async () => { + const getAvailableModels = vi.fn() + .mockResolvedValueOnce(['blocked-model']) + .mockResolvedValueOnce(['allowed-model']); + const refreshModelsAndRebuildRoutes = vi.fn().mockResolvedValue(undefined); + const isModelAllowed = vi.fn() + .mockResolvedValueOnce(false) + .mockResolvedValueOnce(true); + const explainSelection = vi.fn().mockResolvedValue({ selectedChannelId: 33 }); + + const result = await listModelsSurface({ + downstreamPolicy: { type: 'whitelist' }, + responseFormat: 'openai', + tokenRouter: { + getAvailableModels, + explainSelection, + }, + refreshModelsAndRebuildRoutes, + isModelAllowed, + now: () => new Date('2026-03-19T00:00:00.000Z'), + }); + + expect(refreshModelsAndRebuildRoutes).toHaveBeenCalledTimes(1); + expect(result).toEqual({ + object: 'list', + data: [ + { + id: 'allowed-model', + object: 'model', + created: 1773878400, + owned_by: 'metapi', + }, + ], + }); + }); +}); diff --git a/src/server/proxy-core/surfaces/modelsSurface.ts b/src/server/proxy-core/surfaces/modelsSurface.ts new file mode 100644 index 00000000..c560da28 --- /dev/null +++ b/src/server/proxy-core/surfaces/modelsSurface.ts @@ -0,0 +1,70 @@ +function isSearchPseudoModel(modelName: string): boolean { + const normalized = (modelName || '').trim().toLowerCase(); + if (!normalized) return false; + return normalized === '__search' || /^__.+_search$/.test(normalized); +} + +type ModelsSurfaceInput = { + downstreamPolicy: unknown; + responseFormat: 'openai' | 'claude'; + tokenRouter: { + getAvailableModels(): Promise<string[]>; + explainSelection(modelName: string, excludeChannelIds: number[], downstreamPolicy: unknown): Promise<{ + selectedChannelId?: number | null; + }>; + }; + refreshModelsAndRebuildRoutes(): Promise<unknown>; + isModelAllowed(modelName: string, downstreamPolicy: unknown): Promise<boolean>; + now?: () => Date; +}; + +async function readVisibleModels(input: ModelsSurfaceInput): Promise<string[]> { + const deduped = Array.from(new Set(await input.tokenRouter.getAvailableModels())) + .filter((modelName) => !isSearchPseudoModel(modelName)) + .sort(); + const allowed: string[] = []; + for (const modelName of deduped) { + if (!await input.isModelAllowed(modelName, input.downstreamPolicy)) { + continue; + } + const decision = await input.tokenRouter.explainSelection(modelName, [], input.downstreamPolicy); + if (typeof decision.selectedChannelId === 'number') { + allowed.push(modelName); + } + } + return allowed; +} + +export async function listModelsSurface(input: ModelsSurfaceInput) { + let models = await readVisibleModels(input); + if (models.length === 0) { + await input.refreshModelsAndRebuildRoutes(); + models = await readVisibleModels(input); + } + + const now = input.now?.() ?? new Date(); + if (input.responseFormat === 'claude') { + const data = models.map((id) => ({ + id, + type: 'model' as const, + display_name: id, + created_at: now.toISOString(), + })); + return { + data, + first_id: data[0]?.id || null, + last_id: data[data.length - 1]?.id || null, + has_more: false, + }; + } + + return { + object: 'list' as const, + data: models.map((id) => ({ + id, + object: 'model' as const, + created: Math.floor(now.getTime() / 1000), + owned_by: 'metapi', + })), + }; +} diff --git a/src/server/proxy-core/surfaces/openAiResponsesSurface.ts b/src/server/proxy-core/surfaces/openAiResponsesSurface.ts new file mode 100644 index 00000000..a7ef6584 --- /dev/null +++ b/src/server/proxy-core/surfaces/openAiResponsesSurface.ts @@ -0,0 +1,873 @@ +import { TextDecoder } from 'node:util'; +import type { FastifyReply, FastifyRequest } from 'fastify'; +import { reportProxyAllFailed } from '../../services/alertService.js'; +import { mergeProxyUsage, parseProxyUsage } from '../../services/proxyUsageParser.js'; +import { openAiResponsesTransformer } from '../../transformers/openai/responses/index.js'; +import { + buildUpstreamEndpointRequest, + recordUpstreamEndpointFailure, + recordUpstreamEndpointSuccess, + resolveUpstreamEndpointCandidates, +} from '../../routes/proxy/upstreamEndpoint.js'; +import { ensureModelAllowedForDownstreamKey, getDownstreamRoutingPolicy, recordDownstreamCostUsage } from '../../routes/proxy/downstreamPolicy.js'; +import { executeEndpointFlow, type BuiltEndpointRequest } from '../../routes/proxy/endpointFlow.js'; +import { detectProxyFailure } from '../../routes/proxy/proxyFailureJudge.js'; +import { getProxyAuthContext, getProxyResourceOwner } from '../../middleware/auth.js'; +import { normalizeInputFileBlock } from '../../transformers/shared/inputFile.js'; +import { + ProxyInputFileResolutionError, + resolveResponsesBodyInputFiles, +} from '../../services/proxyInputFileResolver.js'; +import { + buildOauthProviderHeaders, +} from '../../services/oauth/service.js'; +import { getOauthInfoFromAccount } from '../../services/oauth/oauthAccount.js'; +import { + collectResponsesFinalPayloadFromSse, + collectResponsesFinalPayloadFromSseText, + createSingleChunkStreamReader, + looksLikeResponsesSseText, +} from '../../routes/proxy/responsesSseFinal.js'; +import { + createGeminiCliStreamReader, + unwrapGeminiCliPayload, +} from '../../routes/proxy/geminiCliCompat.js'; +import { isCodexResponsesSurface } from '../cliProfiles/codexProfile.js'; +import { readRuntimeResponseText } from '../executors/types.js'; +import { runCodexHttpSessionTask } from '../runtime/codexHttpSessionQueue.js'; +import { + summarizeConversationFileInputsInOpenAiBody, + summarizeConversationFileInputsInResponsesBody, +} from '../capabilities/conversationFileCapabilities.js'; +import { detectDownstreamClientContext } from '../../routes/proxy/downstreamClientContext.js'; +import { getProxyMaxChannelRetries } from '../../services/proxyChannelRetry.js'; +import { + acquireSurfaceChannelLease, + bindSurfaceStickyChannel, + buildSurfaceChannelBusyMessage, + buildSurfaceStickySessionKey, + clearSurfaceStickyChannel, + createSurfaceFailureToolkit, + createSurfaceDispatchRequest, + recordSurfaceSuccess, + selectSurfaceChannelForAttempt, + trySurfaceOauthRefreshRecovery, +} from './sharedSurface.js'; + +function isRecord(value: unknown): value is Record<string, unknown> { + return !!value && typeof value === 'object'; +} + +function getCodexSessionHeaderValue(headers: Record<string, string>): string { + for (const [rawKey, rawValue] of Object.entries(headers)) { + const normalizedKey = rawKey.trim().toLowerCase(); + if (normalizedKey === 'session_id' || normalizedKey === 'session-id') { + return String(rawValue || '').trim(); + } + } + return ''; +} +function isResponsesWebsocketTransportRequest(headers: Record<string, unknown>): boolean { + return Object.entries(headers) + .some(([rawKey, rawValue]) => rawKey.trim().toLowerCase() === 'x-metapi-responses-websocket-transport' + && String(rawValue).trim() === '1'); +} + +function normalizeIncludeList(value: unknown): string[] { + if (typeof value === 'string') { + const trimmed = value.trim(); + return trimmed ? [trimmed] : []; + } + if (!Array.isArray(value)) return []; + return value + .map((item) => (typeof item === 'string' ? item.trim() : '')) + .filter((item) => item.length > 0); +} + +function hasExplicitInclude(body: Record<string, unknown>): boolean { + return Object.prototype.hasOwnProperty.call(body, 'include'); +} + +function hasResponsesReasoningRequest(value: unknown): boolean { + if (!isRecord(value)) return false; + const relevantKeys = ['effort', 'budget_tokens', 'budgetTokens', 'max_tokens', 'maxTokens', 'summary']; + return relevantKeys.some((key) => { + const entry = value[key]; + if (typeof entry === 'string') return entry.trim().length > 0; + return entry !== undefined && entry !== null; + }); +} + +function carriesResponsesReasoningContinuity(value: unknown): boolean { + if (Array.isArray(value)) { + return value.some((item) => carriesResponsesReasoningContinuity(item)); + } + if (!isRecord(value)) return false; + + const type = typeof value.type === 'string' ? value.type.trim().toLowerCase() : ''; + if (type === 'reasoning') { + if (typeof value.encrypted_content === 'string' && value.encrypted_content.trim()) { + return true; + } + if (Array.isArray(value.summary) && value.summary.length > 0) { + return true; + } + } + + if (typeof value.reasoning_signature === 'string' && value.reasoning_signature.trim()) { + return true; + } + + return carriesResponsesReasoningContinuity(value.input) + || carriesResponsesReasoningContinuity(value.content); +} + +function wantsNativeResponsesReasoning(body: unknown): boolean { + if (!isRecord(body)) return false; + const include = normalizeIncludeList(body.include); + if (include.some((item) => item.toLowerCase() === 'reasoning.encrypted_content')) { + return true; + } + if (carriesResponsesReasoningContinuity(body.input)) { + return true; + } + if (hasExplicitInclude(body)) { + return false; + } + return hasResponsesReasoningRequest(body.reasoning); +} + +function carriesResponsesFileUrlInput(value: unknown): boolean { + if (Array.isArray(value)) { + return value.some((item) => carriesResponsesFileUrlInput(item)); + } + if (!isRecord(value)) return false; + + const normalizedFile = normalizeInputFileBlock(value); + if (normalizedFile?.fileUrl) return true; + + return Object.values(value).some((entry) => carriesResponsesFileUrlInput(entry)); +} + +function shouldRefreshOauthResponsesRequest(input: { + oauthProvider?: string; + status: number; + response: { headers: { get(name: string): string | null } }; + rawErrText: string; +}): boolean { + if (input.status === 401) return true; + if (input.status !== 403 || input.oauthProvider !== 'codex') return false; + const authenticate = input.response.headers.get('www-authenticate') || ''; + const combined = `${authenticate}\n${input.rawErrText || ''}`; + return /\b(invalid_token|expired_token|expired|invalid|unauthorized|account mismatch|authentication)\b/i.test(combined); +} + +type UsageSummary = ReturnType<typeof parseProxyUsage>; + +export async function handleOpenAiResponsesSurfaceRequest( + request: FastifyRequest, + reply: FastifyReply, + downstreamPath: '/v1/responses' | '/v1/responses/compact', +) { + const body = request.body as Record<string, unknown>; + const clientContext = detectDownstreamClientContext({ + downstreamPath, + headers: request.headers as Record<string, unknown>, + body, + }); + const defaultEncryptedReasoningInclude = isCodexResponsesSurface( + request.headers as Record<string, unknown>, + ); + const parsedRequestEnvelope = openAiResponsesTransformer.transformRequest(body, { + defaultEncryptedReasoningInclude, + }); + if (parsedRequestEnvelope.error) { + return reply.code(parsedRequestEnvelope.error.statusCode).send(parsedRequestEnvelope.error.payload); + } + const requestEnvelope = parsedRequestEnvelope.value!; + const requestedModel = requestEnvelope.model; + const isStream = requestEnvelope.stream; + const isCompactRequest = downstreamPath === '/v1/responses/compact'; + if (isCompactRequest && isStream) { + return reply.code(400).send({ + error: { + message: 'stream is not supported on /v1/responses/compact', + type: 'invalid_request_error', + }, + }); + } + if (!await ensureModelAllowedForDownstreamKey(request, reply, requestedModel)) return; + const downstreamPolicy = getDownstreamRoutingPolicy(request); + const downstreamApiKeyId = getProxyAuthContext(request)?.keyId ?? null; + const maxRetries = getProxyMaxChannelRetries(); + const failureToolkit = createSurfaceFailureToolkit({ + warningScope: 'responses', + downstreamPath, + maxRetries, + clientContext, + downstreamApiKeyId, + }); + const stickySessionKey = buildSurfaceStickySessionKey({ + clientContext, + requestedModel, + downstreamPath, + downstreamApiKeyId, + }); + const excludeChannelIds: number[] = []; + let retryCount = 0; + + while (retryCount <= maxRetries) { + const selected = await selectSurfaceChannelForAttempt({ + requestedModel, + downstreamPolicy, + excludeChannelIds, + retryCount, + stickySessionKey, + }); + + if (!selected) { + await reportProxyAllFailed({ + model: requestedModel, + reason: 'No available channels after retries', + }); + return reply.code(503).send({ + error: { message: 'No available channels for this model', type: 'server_error' }, + }); + } + + excludeChannelIds.push(selected.channel.id); + + const modelName = selected.actualModel || requestedModel; + const oauth = getOauthInfoFromAccount(selected.account); + const isCodexSite = String(selected.site.platform || '').trim().toLowerCase() === 'codex'; + const owner = getProxyResourceOwner(request); + let normalizedResponsesBody: Record<string, unknown> = { + ...requestEnvelope.parsed.normalizedBody, + model: modelName, + stream: isStream, + }; + if (body.generate === false) { + normalizedResponsesBody.generate = false; + } + if (owner) { + try { + normalizedResponsesBody = await resolveResponsesBodyInputFiles(normalizedResponsesBody, owner); + } catch (error) { + if (error instanceof ProxyInputFileResolutionError) { + return reply.code(error.statusCode).send(error.payload); + } + throw error; + } + } + const openAiBody = openAiResponsesTransformer.inbound.toOpenAiBody( + normalizedResponsesBody, + modelName, + isStream, + { defaultEncryptedReasoningInclude }, + ); + const conversationFileSummary = summarizeConversationFileInputsInOpenAiBody(openAiBody); + const hasNonImageFileInput = conversationFileSummary.hasDocument; + const prefersNativeResponsesReasoning = wantsNativeResponsesReasoning(normalizedResponsesBody); + const responsesConversationFileSummary = summarizeConversationFileInputsInResponsesBody(normalizedResponsesBody); + const requiresNativeResponsesFileUrl = responsesConversationFileSummary.hasRemoteDocumentUrl + || carriesResponsesFileUrlInput(normalizedResponsesBody.input); + const endpointCandidates = await resolveUpstreamEndpointCandidates( + { + site: selected.site, + account: selected.account, + }, + modelName, + 'responses', + requestedModel, + { + hasNonImageFileInput, + conversationFileSummary, + wantsNativeResponsesReasoning: prefersNativeResponsesReasoning, + }, + ); + if (endpointCandidates.length === 0) { + endpointCandidates.push('responses', 'chat', 'messages'); + } + const endpointRuntimeContext = { + siteId: selected.site.id, + modelName, + downstreamFormat: 'responses' as const, + requestedModelHint: requestedModel, + requestCapabilities: { + hasNonImageFileInput, + conversationFileSummary, + wantsNativeResponsesReasoning: prefersNativeResponsesReasoning, + }, + }; + const buildProviderHeaders = () => ( + buildOauthProviderHeaders({ + account: selected.account, + downstreamHeaders: request.headers as Record<string, unknown>, + }) + ); + const buildEndpointRequest = (endpoint: 'chat' | 'messages' | 'responses') => { + const upstreamStream = isStream || (isCodexSite && endpoint === 'responses'); + const endpointRequest = buildUpstreamEndpointRequest({ + endpoint, + modelName, + stream: upstreamStream, + tokenValue: selected.tokenValue, + oauthProvider: oauth?.provider, + oauthProjectId: oauth?.projectId, + sitePlatform: selected.site.platform, + siteUrl: selected.site.url, + openaiBody: openAiBody, + downstreamFormat: 'responses', + responsesOriginalBody: normalizedResponsesBody, + downstreamHeaders: request.headers as Record<string, unknown>, + providerHeaders: buildProviderHeaders(), + }); + const upstreamPath = ( + isCompactRequest && endpoint === 'responses' + ? `${endpointRequest.path}/compact` + : endpointRequest.path + ); + return { + endpoint, + path: upstreamPath, + headers: endpointRequest.headers, + body: endpointRequest.body as Record<string, unknown>, + runtime: endpointRequest.runtime, + }; + }; + const baseDispatchRequest = createSurfaceDispatchRequest({ + site: selected.site, + accountExtraConfig: selected.account.extraConfig, + }); + const dispatchRequest = ( + endpointRequest: BuiltEndpointRequest, + targetUrl?: string, + ) => { + if (!isCodexSite || endpointRequest.path !== '/responses') { + return baseDispatchRequest(endpointRequest, targetUrl); + } + const sessionId = getCodexSessionHeaderValue(endpointRequest.headers); + return runCodexHttpSessionTask( + sessionId, + () => baseDispatchRequest(endpointRequest, targetUrl), + ); + }; + const endpointStrategy = openAiResponsesTransformer.compatibility.createEndpointStrategy({ + isStream: isStream || isCodexSite, + requiresNativeResponsesFileUrl, + dispatchRequest, + }); + const tryRecover = async (ctx: Parameters<NonNullable<typeof endpointStrategy.tryRecover>>[0]) => { + if (oauth && shouldRefreshOauthResponsesRequest({ + oauthProvider: oauth.provider, + status: ctx.response.status, + response: ctx.response, + rawErrText: ctx.rawErrText || '', + })) { + const recovered = await trySurfaceOauthRefreshRecovery({ + ctx, + selected, + siteUrl: selected.site.url, + buildRequest: (endpoint) => buildEndpointRequest(endpoint), + dispatchRequest, + }); + if (recovered?.upstream?.ok) { + return recovered; + } + } + return endpointStrategy.tryRecover(ctx); + }; + + const startTime = Date.now(); + const leaseResult = await acquireSurfaceChannelLease({ + stickySessionKey, + selected, + }); + if (leaseResult.status === 'timeout') { + clearSurfaceStickyChannel({ + stickySessionKey, + selected, + }); + const busyMessage = buildSurfaceChannelBusyMessage(leaseResult.waitMs); + await failureToolkit.log({ + selected, + modelRequested: requestedModel, + status: 'failed', + httpStatus: 503, + latencyMs: leaseResult.waitMs, + errorMessage: busyMessage, + retryCount, + }); + retryCount += 1; + if (retryCount <= maxRetries) { + continue; + } + return reply.code(503).send({ + error: { + message: busyMessage, + type: 'server_error', + }, + }); + } + const channelLease = leaseResult.lease; + + try { + const endpointResult = await executeEndpointFlow({ + siteUrl: selected.site.url, + endpointCandidates, + buildRequest: (endpoint) => buildEndpointRequest(endpoint), + dispatchRequest, + tryRecover, + onAttemptFailure: (ctx) => { + recordUpstreamEndpointFailure({ + ...endpointRuntimeContext, + endpoint: ctx.request.endpoint, + status: ctx.response.status, + errorText: ctx.rawErrText, + }); + }, + onAttemptSuccess: (ctx) => { + recordUpstreamEndpointSuccess({ + ...endpointRuntimeContext, + endpoint: ctx.request.endpoint, + }); + }, + shouldDowngrade: endpointStrategy.shouldDowngrade, + onDowngrade: (ctx) => { + return failureToolkit.log({ + selected, + modelRequested: requestedModel, + status: 'failed', + httpStatus: ctx.response.status, + latencyMs: Date.now() - startTime, + errorMessage: ctx.errText, + retryCount, + }); + }, + }); + + if (!endpointResult.ok) { + clearSurfaceStickyChannel({ + stickySessionKey, + selected, + }); + const failureOutcome = await failureToolkit.handleUpstreamFailure({ + selected, + requestedModel, + modelName, + status: endpointResult.status || 502, + errText: endpointResult.errText || 'unknown error', + rawErrText: endpointResult.rawErrText, + latencyMs: Date.now() - startTime, + retryCount, + }); + if (failureOutcome.action === 'retry') { + retryCount += 1; + continue; + } + return reply.code(failureOutcome.status).send(failureOutcome.payload); + } + + const upstream = endpointResult.upstream; + const successfulUpstreamPath = endpointResult.upstreamPath; + const finalizeStreamSuccess = async (parsedUsage: UsageSummary, latency: number) => { + try { + await recordSurfaceSuccess({ + selected, + requestedModel, + modelName, + parsedUsage, + requestStartedAtMs: startTime, + latencyMs: latency, + retryCount, + upstreamPath: successfulUpstreamPath, + logSuccess: failureToolkit.log, + recordDownstreamCost: (estimatedCost) => { + recordDownstreamCostUsage(request, estimatedCost); + }, + bestEffortMetrics: { + errorLabel: '[responses] post-stream bookkeeping failed:', + }, + }); + } catch (error) { + console.error('[responses] post-stream success logging failed:', error); + } + }; + + if (isStream) { + const upstreamContentType = (upstream.headers.get('content-type') || '').toLowerCase(); + const startSseResponse = () => { + reply.hijack(); + reply.raw.statusCode = 200; + reply.raw.setHeader('Content-Type', 'text/event-stream; charset=utf-8'); + reply.raw.setHeader('Cache-Control', 'no-cache, no-transform'); + reply.raw.setHeader('Connection', 'keep-alive'); + reply.raw.setHeader('X-Accel-Buffering', 'no'); + }; + + let parsedUsage: UsageSummary = { + promptTokens: 0, + completionTokens: 0, + totalTokens: 0, + cacheReadTokens: 0, + cacheCreationTokens: 0, + promptTokensIncludeCache: null, + }; + const writeLines = (lines: string[]) => { + for (const line of lines) reply.raw.write(line); + }; + const websocketTransportRequest = isResponsesWebsocketTransportRequest(request.headers as Record<string, unknown>); + const streamSession = openAiResponsesTransformer.proxyStream.createSession({ + modelName, + successfulUpstreamPath, + getUsage: () => parsedUsage, + onParsedPayload: (payload) => { + if (payload && typeof payload === 'object') { + parsedUsage = mergeProxyUsage(parsedUsage, parseProxyUsage(payload)); + } + }, + writeLines, + writeRaw: (chunk) => { + reply.raw.write(chunk); + }, + }); + if (!upstreamContentType.includes('text/event-stream')) { + const rawText = await readRuntimeResponseText(upstream); + if (looksLikeResponsesSseText(rawText)) { + startSseResponse(); + const streamResult = await streamSession.run( + createSingleChunkStreamReader(rawText), + reply.raw, + ); + const latency = Date.now() - startTime; + if (streamResult.status === 'failed') { + clearSurfaceStickyChannel({ + stickySessionKey, + selected, + }); + await failureToolkit.recordStreamFailure({ + selected, + requestedModel, + modelName, + errorMessage: streamResult.errorMessage, + latencyMs: latency, + retryCount, + promptTokens: parsedUsage.promptTokens, + completionTokens: parsedUsage.completionTokens, + totalTokens: parsedUsage.totalTokens, + upstreamPath: successfulUpstreamPath, + }); + return; + } + + await finalizeStreamSuccess(parsedUsage, latency); + bindSurfaceStickyChannel({ + stickySessionKey, + selected, + }); + return; + } + let upstreamData: unknown = rawText; + try { + upstreamData = JSON.parse(rawText); + } catch { + upstreamData = rawText; + } + if (String(selected.site.platform || '').trim().toLowerCase() === 'gemini-cli') { + upstreamData = unwrapGeminiCliPayload(upstreamData); + } + + parsedUsage = parseProxyUsage(upstreamData); + const latency = Date.now() - startTime; + const failure = detectProxyFailure({ rawText, usage: parsedUsage }); + if (failure) { + clearSurfaceStickyChannel({ + stickySessionKey, + selected, + }); + const failureOutcome = await failureToolkit.handleDetectedFailure({ + selected, + requestedModel, + modelName, + failure, + latencyMs: latency, + retryCount, + promptTokens: parsedUsage.promptTokens, + completionTokens: parsedUsage.completionTokens, + totalTokens: parsedUsage.totalTokens, + upstreamPath: successfulUpstreamPath, + }); + if (failureOutcome.action === 'retry') { + retryCount += 1; + continue; + } + return reply.code(failureOutcome.status).send(failureOutcome.payload); + } + + startSseResponse(); + const streamResult = streamSession.consumeUpstreamFinalPayload(upstreamData, rawText, reply.raw); + if (streamResult.status === 'failed') { + clearSurfaceStickyChannel({ + stickySessionKey, + selected, + }); + await failureToolkit.recordStreamFailure({ + selected, + requestedModel, + modelName, + errorMessage: streamResult.errorMessage, + latencyMs: latency, + retryCount, + promptTokens: parsedUsage.promptTokens, + completionTokens: parsedUsage.completionTokens, + totalTokens: parsedUsage.totalTokens, + upstreamPath: successfulUpstreamPath, + runtimeFailureStatus: 502, + }); + return; + } + + await finalizeStreamSuccess(parsedUsage, latency); + bindSurfaceStickyChannel({ + stickySessionKey, + selected, + }); + return; + } + + startSseResponse(); + + let replayReader: ReturnType<typeof createSingleChunkStreamReader> | null = null; + if (websocketTransportRequest) { + const rawText = await readRuntimeResponseText(upstream); + if (looksLikeResponsesSseText(rawText)) { + try { + const collectedPayload = collectResponsesFinalPayloadFromSseText(rawText, modelName).payload; + parsedUsage = mergeProxyUsage(parsedUsage, parseProxyUsage(collectedPayload)); + const createdPayload = { + ...collectedPayload, + status: 'in_progress', + output: [], + output_text: '', + }; + const terminalEventType = String(collectedPayload.status || '').trim().toLowerCase() === 'incomplete' + ? 'response.incomplete' + : 'response.completed'; + writeLines([ + `event: response.created\ndata: ${JSON.stringify({ type: 'response.created', response: createdPayload })}\n\n`, + `event: ${terminalEventType}\ndata: ${JSON.stringify({ type: terminalEventType, response: collectedPayload })}\n\n`, + 'data: [DONE]\n\n', + ]); + reply.raw.end(); + const latency = Date.now() - startTime; + await finalizeStreamSuccess(parsedUsage, latency); + return; + } catch { + // Fall through to the generic stream session for response.failed/error terminals. + } + + const streamResult = await streamSession.run( + createSingleChunkStreamReader(rawText), + reply.raw, + ); + const latency = Date.now() - startTime; + if (streamResult.status === 'failed') { + await failureToolkit.recordStreamFailure({ + selected, + requestedModel, + modelName, + errorMessage: streamResult.errorMessage, + latencyMs: latency, + retryCount, + promptTokens: parsedUsage.promptTokens, + completionTokens: parsedUsage.completionTokens, + totalTokens: parsedUsage.totalTokens, + upstreamPath: successfulUpstreamPath, + runtimeFailureStatus: 502, + }); + return; + } + + await finalizeStreamSuccess(parsedUsage, latency); + return; + } + + replayReader = createSingleChunkStreamReader(rawText); + } + + const upstreamReader = replayReader ?? upstream.body?.getReader(); + const baseReader = String(selected.site.platform || '').trim().toLowerCase() === 'gemini-cli' && upstreamReader + ? createGeminiCliStreamReader(upstreamReader) + : upstreamReader; + let rawText = ''; + const decoder = new TextDecoder(); + const reader = baseReader + ? { + async read() { + const result = await baseReader.read(); + if (result.value) { + rawText += decoder.decode(result.value, { stream: true }); + } + return result; + }, + async cancel(reason?: unknown) { + return baseReader.cancel(reason); + }, + releaseLock() { + return baseReader.releaseLock(); + }, + } + : baseReader; + const streamResult = await streamSession.run(reader, reply.raw); + rawText += decoder.decode(); + + const latency = Date.now() - startTime; + if (streamResult.status === 'failed') { + clearSurfaceStickyChannel({ + stickySessionKey, + selected, + }); + await failureToolkit.recordStreamFailure({ + selected, + requestedModel, + modelName, + errorMessage: streamResult.errorMessage, + latencyMs: latency, + retryCount, + promptTokens: parsedUsage.promptTokens, + completionTokens: parsedUsage.completionTokens, + totalTokens: parsedUsage.totalTokens, + upstreamPath: successfulUpstreamPath, + runtimeFailureStatus: 502, + }); + return; + } + + // Once SSE has been hijacked and bytes may already be on the wire, we + // must not attempt to convert stream failures into a fresh HTTP error + // response or retry on another channel. Responses stream failures are + // handled in-band by the proxy stream session. + + await finalizeStreamSuccess(parsedUsage, latency); + bindSurfaceStickyChannel({ + stickySessionKey, + selected, + }); + return; + } + + const upstreamContentType = (upstream.headers.get('content-type') || '').toLowerCase(); + let rawText = ''; + let upstreamData: unknown; + if ( + upstreamContentType.includes('text/event-stream') + && ( + successfulUpstreamPath.endsWith('/responses') + || successfulUpstreamPath.endsWith('/responses/compact') + ) + ) { + const collected = await collectResponsesFinalPayloadFromSse(upstream, modelName); + rawText = collected.rawText; + upstreamData = collected.payload; + } else { + rawText = await readRuntimeResponseText(upstream); + if (looksLikeResponsesSseText(rawText)) { + upstreamData = collectResponsesFinalPayloadFromSseText(rawText, modelName).payload; + } else { + upstreamData = rawText; + try { + upstreamData = JSON.parse(rawText); + } catch { + upstreamData = rawText; + } + } + } + if (String(selected.site.platform || '').trim().toLowerCase() === 'gemini-cli') { + upstreamData = unwrapGeminiCliPayload(upstreamData); + } + const latency = Date.now() - startTime; + const parsedUsage = parseProxyUsage(upstreamData); + const failure = detectProxyFailure({ rawText, usage: parsedUsage }); + if (failure) { + clearSurfaceStickyChannel({ + stickySessionKey, + selected, + }); + const failureOutcome = await failureToolkit.handleDetectedFailure({ + selected, + requestedModel, + modelName, + failure, + latencyMs: latency, + retryCount, + promptTokens: parsedUsage.promptTokens, + completionTokens: parsedUsage.completionTokens, + totalTokens: parsedUsage.totalTokens, + upstreamPath: successfulUpstreamPath, + }); + if (failureOutcome.action === 'retry') { + retryCount += 1; + continue; + } + return reply.code(failureOutcome.status).send(failureOutcome.payload); + } + const normalized = openAiResponsesTransformer.transformFinalResponse( + upstreamData, + modelName, + rawText, + ); + const downstreamData = openAiResponsesTransformer.outbound.serializeFinal({ + upstreamPayload: upstreamData, + normalized, + usage: parsedUsage, + serializationMode: isCompactRequest ? 'compact' : 'response', + }); + try { + await recordSurfaceSuccess({ + selected, + requestedModel, + modelName, + parsedUsage, + requestStartedAtMs: startTime, + latencyMs: latency, + retryCount, + upstreamPath: successfulUpstreamPath, + logSuccess: failureToolkit.log, + recordDownstreamCost: (estimatedCost) => { + recordDownstreamCostUsage(request, estimatedCost); + }, + bestEffortMetrics: { + errorLabel: '[responses] post-response bookkeeping failed:', + }, + }); + } catch (error) { + console.error('[responses] post-response success logging failed:', error); + } + bindSurfaceStickyChannel({ + stickySessionKey, + selected, + }); + return reply.send(downstreamData); + } catch (err: any) { + clearSurfaceStickyChannel({ + stickySessionKey, + selected, + }); + const failureOutcome = await failureToolkit.handleExecutionError({ + selected, + requestedModel, + modelName, + errorMessage: err?.message || 'network failure', + latencyMs: Date.now() - startTime, + retryCount, + }); + if (failureOutcome.action === 'retry') { + retryCount += 1; + continue; + } + return reply.code(failureOutcome.status).send(failureOutcome.payload); + } finally { + channelLease.release(); + } + } +} diff --git a/src/server/proxy-core/surfaces/sharedSurface.test.ts b/src/server/proxy-core/surfaces/sharedSurface.test.ts new file mode 100644 index 00000000..4fca8f94 --- /dev/null +++ b/src/server/proxy-core/surfaces/sharedSurface.test.ts @@ -0,0 +1,988 @@ +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { EMPTY_DOWNSTREAM_ROUTING_POLICY } from '../../services/downstreamPolicyTypes.js'; + +const selectChannelMock = vi.fn(); +const selectNextChannelMock = vi.fn(); +const selectPreferredChannelMock = vi.fn(); +const recordFailureMock = vi.fn(); +const refreshModelsAndRebuildRoutesMock = vi.fn(); +const composeProxyLogMessageMock = vi.fn(); +const formatUtcSqlDateTimeMock = vi.fn(); +const insertProxyLogMock = vi.fn(); +const resolveChannelProxyUrlMock = vi.fn(); +const withSiteRecordProxyRequestInitMock = vi.fn(); +const dispatchRuntimeRequestMock = vi.fn(); +const reportProxyAllFailedMock = vi.fn(); +const reportTokenExpiredMock = vi.fn(); +const isTokenExpiredErrorMock = vi.fn(); +const shouldRetryProxyRequestMock = vi.fn(); +const recordOauthQuotaResetHintMock = vi.fn(); +const recordSuccessMock = vi.fn(); +const resolveProxyUsageWithSelfLogFallbackMock = vi.fn(); +const resolveProxyLogBillingMock = vi.fn(); +const refreshOauthAccessTokenSingleflightMock = vi.fn(); +const getStickyChannelIdMock = vi.fn(); +const bindStickyChannelMock = vi.fn(); +const clearStickyChannelMock = vi.fn(); +const acquireChannelLeaseMock = vi.fn(); +const buildStickySessionKeyMock = vi.fn(); +const consoleWarnMock = vi.spyOn(console, 'warn').mockImplementation(() => {}); +const consoleErrorMock = vi.spyOn(console, 'error').mockImplementation(() => {}); + +vi.mock('../../services/tokenRouter.js', () => ({ + tokenRouter: { + selectChannel: (...args: unknown[]) => selectChannelMock(...args), + selectNextChannel: (...args: unknown[]) => selectNextChannelMock(...args), + selectPreferredChannel: (...args: unknown[]) => selectPreferredChannelMock(...args), + recordFailure: (...args: unknown[]) => recordFailureMock(...args), + recordSuccess: (...args: unknown[]) => recordSuccessMock(...args), + }, +})); + +vi.mock('../../services/proxyChannelCoordinator.js', () => ({ + proxyChannelCoordinator: { + getStickyChannelId: (...args: unknown[]) => getStickyChannelIdMock(...args), + bindStickyChannel: (...args: unknown[]) => bindStickyChannelMock(...args), + clearStickyChannel: (...args: unknown[]) => clearStickyChannelMock(...args), + acquireChannelLease: (...args: unknown[]) => acquireChannelLeaseMock(...args), + buildStickySessionKey: (...args: unknown[]) => buildStickySessionKeyMock(...args), + }, +})); + +vi.mock('../../services/modelService.js', () => ({ + refreshModelsAndRebuildRoutes: (...args: unknown[]) => refreshModelsAndRebuildRoutesMock(...args), +})); + +vi.mock('../../routes/proxy/logPathMeta.js', () => ({ + composeProxyLogMessage: (...args: unknown[]) => composeProxyLogMessageMock(...args), +})); + +vi.mock('../../services/localTimeService.js', () => ({ + formatUtcSqlDateTime: (...args: unknown[]) => formatUtcSqlDateTimeMock(...args), +})); + +vi.mock('../../services/proxyLogStore.js', () => ({ + insertProxyLog: (...args: unknown[]) => insertProxyLogMock(...args), +})); + +vi.mock('../../services/siteProxy.js', () => ({ + resolveChannelProxyUrl: (...args: unknown[]) => resolveChannelProxyUrlMock(...args), + withSiteRecordProxyRequestInit: (...args: unknown[]) => withSiteRecordProxyRequestInitMock(...args), +})); + +vi.mock('../../routes/proxy/runtimeExecutor.js', () => ({ + dispatchRuntimeRequest: (...args: unknown[]) => dispatchRuntimeRequestMock(...args), +})); + +vi.mock('../../services/alertService.js', () => ({ + reportProxyAllFailed: (...args: unknown[]) => reportProxyAllFailedMock(...args), + reportTokenExpired: (...args: unknown[]) => reportTokenExpiredMock(...args), +})); + +vi.mock('../../services/alertRules.js', () => ({ + isTokenExpiredError: (...args: unknown[]) => isTokenExpiredErrorMock(...args), +})); + +vi.mock('../../services/proxyRetryPolicy.js', () => ({ + shouldRetryProxyRequest: (...args: unknown[]) => shouldRetryProxyRequestMock(...args), +})); + +vi.mock('../../services/oauth/quota.js', () => ({ + recordOauthQuotaResetHint: (...args: unknown[]) => recordOauthQuotaResetHintMock(...args), +})); + +vi.mock('../../services/proxyUsageFallbackService.js', () => ({ + resolveProxyUsageWithSelfLogFallback: (...args: unknown[]) => resolveProxyUsageWithSelfLogFallbackMock(...args), +})); + +vi.mock('../../routes/proxy/proxyBilling.js', () => ({ + resolveProxyLogBilling: (...args: unknown[]) => resolveProxyLogBillingMock(...args), +})); + +vi.mock('../../services/oauth/refreshSingleflight.js', () => ({ + refreshOauthAccessTokenSingleflight: (...args: unknown[]) => refreshOauthAccessTokenSingleflightMock(...args), +})); + +describe('selectSurfaceChannelForAttempt', () => { + afterAll(() => { + consoleWarnMock.mockRestore(); + consoleErrorMock.mockRestore(); + }); + + beforeEach(() => { + selectChannelMock.mockReset(); + selectNextChannelMock.mockReset(); + selectPreferredChannelMock.mockReset(); + recordFailureMock.mockReset(); + refreshModelsAndRebuildRoutesMock.mockReset(); + composeProxyLogMessageMock.mockReset(); + formatUtcSqlDateTimeMock.mockReset(); + insertProxyLogMock.mockReset(); + resolveChannelProxyUrlMock.mockReset(); + withSiteRecordProxyRequestInitMock.mockReset(); + dispatchRuntimeRequestMock.mockReset(); + reportProxyAllFailedMock.mockReset(); + reportTokenExpiredMock.mockReset(); + isTokenExpiredErrorMock.mockReset(); + shouldRetryProxyRequestMock.mockReset(); + recordOauthQuotaResetHintMock.mockReset(); + recordSuccessMock.mockReset(); + resolveProxyUsageWithSelfLogFallbackMock.mockReset(); + resolveProxyLogBillingMock.mockReset(); + refreshOauthAccessTokenSingleflightMock.mockReset(); + getStickyChannelIdMock.mockReset(); + bindStickyChannelMock.mockReset(); + clearStickyChannelMock.mockReset(); + acquireChannelLeaseMock.mockReset(); + buildStickySessionKeyMock.mockReset(); + consoleWarnMock.mockClear(); + consoleErrorMock.mockClear(); + }); + + it('refreshes models and retries selectChannel on the first attempt when no channel is available', async () => { + const selected = { channel: { id: 11 } }; + selectChannelMock + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(selected); + + const { selectSurfaceChannelForAttempt } = await import('./sharedSurface.js'); + const result = await selectSurfaceChannelForAttempt({ + requestedModel: 'gpt-5.2', + downstreamPolicy: EMPTY_DOWNSTREAM_ROUTING_POLICY, + excludeChannelIds: [], + retryCount: 0, + }); + + expect(result).toBe(selected); + expect(selectChannelMock).toHaveBeenCalledTimes(2); + expect(selectNextChannelMock).not.toHaveBeenCalled(); + expect(refreshModelsAndRebuildRoutesMock).toHaveBeenCalledTimes(1); + }); + + it('uses selectNextChannel on retry attempts without refreshing models', async () => { + const selected = { channel: { id: 22 } }; + selectNextChannelMock.mockResolvedValueOnce(selected); + + const { selectSurfaceChannelForAttempt } = await import('./sharedSurface.js'); + const result = await selectSurfaceChannelForAttempt({ + requestedModel: 'gpt-5.2', + downstreamPolicy: EMPTY_DOWNSTREAM_ROUTING_POLICY, + excludeChannelIds: [11], + retryCount: 1, + }); + + expect(result).toBe(selected); + expect(selectChannelMock).not.toHaveBeenCalled(); + expect(selectNextChannelMock).toHaveBeenCalledWith( + 'gpt-5.2', + [11], + EMPTY_DOWNSTREAM_ROUTING_POLICY, + ); + expect(refreshModelsAndRebuildRoutesMock).not.toHaveBeenCalled(); + }); + + it('prefers the sticky session channel on the first attempt when it is still eligible', async () => { + const selected = { channel: { id: 55 } }; + getStickyChannelIdMock.mockReturnValueOnce(55); + selectPreferredChannelMock.mockResolvedValueOnce(selected); + + const { selectSurfaceChannelForAttempt } = await import('./sharedSurface.js'); + const result = await selectSurfaceChannelForAttempt({ + requestedModel: 'gpt-5.2', + downstreamPolicy: EMPTY_DOWNSTREAM_ROUTING_POLICY, + excludeChannelIds: [], + retryCount: 0, + stickySessionKey: 'sticky-session', + }); + + expect(result).toBe(selected); + expect(selectPreferredChannelMock).toHaveBeenCalledWith( + 'gpt-5.2', + 55, + EMPTY_DOWNSTREAM_ROUTING_POLICY, + [], + ); + expect(selectChannelMock).not.toHaveBeenCalled(); + expect(clearStickyChannelMock).not.toHaveBeenCalled(); + }); + + it('clears stale sticky bindings and falls back to regular selection when the preferred channel is unavailable', async () => { + const selected = { channel: { id: 22 } }; + getStickyChannelIdMock.mockReturnValueOnce(55); + selectPreferredChannelMock.mockResolvedValueOnce(null); + selectChannelMock.mockResolvedValueOnce(selected); + + const { selectSurfaceChannelForAttempt } = await import('./sharedSurface.js'); + const result = await selectSurfaceChannelForAttempt({ + requestedModel: 'gpt-5.2', + downstreamPolicy: EMPTY_DOWNSTREAM_ROUTING_POLICY, + excludeChannelIds: [], + retryCount: 0, + stickySessionKey: 'sticky-session', + }); + + expect(result).toBe(selected); + expect(clearStickyChannelMock).toHaveBeenCalledWith('sticky-session', 55); + expect(selectChannelMock).toHaveBeenCalledWith('gpt-5.2', EMPTY_DOWNSTREAM_ROUTING_POLICY); + }); + + it('logs refresh failures and still retries selection once on the first attempt', async () => { + const selected = { channel: { id: 33 } }; + selectChannelMock + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(selected); + refreshModelsAndRebuildRoutesMock.mockRejectedValueOnce(new Error('refresh failed')); + + const { selectSurfaceChannelForAttempt } = await import('./sharedSurface.js'); + const result = await selectSurfaceChannelForAttempt({ + requestedModel: 'gpt-5.2', + downstreamPolicy: EMPTY_DOWNSTREAM_ROUTING_POLICY, + excludeChannelIds: [], + retryCount: 0, + }); + + expect(result).toBe(selected); + expect(selectChannelMock).toHaveBeenCalledTimes(2); + expect(consoleWarnMock).toHaveBeenCalledWith( + '[proxy/surface] failed to refresh routes after empty selection', + expect.any(Error), + ); + }); + + it('writes proxy logs through the shared log formatter and store', async () => { + composeProxyLogMessageMock.mockReturnValue('normalized error'); + formatUtcSqlDateTimeMock.mockReturnValue('2026-03-21 22:00:00'); + insertProxyLogMock.mockResolvedValue(undefined); + + const { writeSurfaceProxyLog } = await import('./sharedSurface.js'); + await writeSurfaceProxyLog({ + warningScope: 'chat', + selected: { + channel: { id: 11, routeId: 22 }, + account: { id: 33 }, + actualModel: 'upstream-model', + }, + modelRequested: 'gpt-5.2', + status: 'failed', + httpStatus: 502, + latencyMs: 1200, + errorMessage: 'upstream failed', + retryCount: 1, + downstreamPath: '/v1/chat/completions', + promptTokens: 10, + completionTokens: 5, + totalTokens: 15, + estimatedCost: 0.42, + billingDetails: { source: 'test' }, + upstreamPath: '/v1/responses', + clientContext: { + clientKind: 'codex', + clientAppId: 'app-id', + clientAppName: 'App', + clientConfidence: 'high', + sessionId: 'sess-1', + traceHint: 'trace-1', + }, + downstreamApiKeyId: 44, + }); + + expect(composeProxyLogMessageMock).toHaveBeenCalledWith({ + clientKind: 'codex', + sessionId: 'sess-1', + traceHint: 'trace-1', + downstreamPath: '/v1/chat/completions', + upstreamPath: '/v1/responses', + errorMessage: 'upstream failed', + }); + expect(insertProxyLogMock).toHaveBeenCalledWith({ + routeId: 22, + channelId: 11, + accountId: 33, + downstreamApiKeyId: 44, + modelRequested: 'gpt-5.2', + modelActual: 'upstream-model', + status: 'failed', + httpStatus: 502, + latencyMs: 1200, + promptTokens: 10, + completionTokens: 5, + totalTokens: 15, + estimatedCost: 0.42, + billingDetails: { source: 'test' }, + clientFamily: 'codex', + clientAppId: 'app-id', + clientAppName: 'App', + clientConfidence: 'high', + errorMessage: 'normalized error', + retryCount: 1, + createdAt: '2026-03-21 22:00:00', + }); + }); + + it('builds runtime dispatch requests with site proxy initialization', async () => { + const site = { url: 'https://upstream.example.com' }; + const request = { + endpoint: 'responses', + path: '/v1/responses', + headers: { authorization: 'Bearer test' }, + body: { model: 'gpt-5.2', input: 'hello' }, + runtime: { executor: 'default' }, + }; + resolveChannelProxyUrlMock.mockReturnValue('http://proxy.example.com'); + withSiteRecordProxyRequestInitMock.mockImplementation(async (_site, init, proxyUrl) => ({ + ...init, + proxyUrl, + })); + dispatchRuntimeRequestMock.mockResolvedValue('ok'); + + const { createSurfaceDispatchRequest } = await import('./sharedSurface.js'); + const dispatchRequest = createSurfaceDispatchRequest({ + site, + accountExtraConfig: '{"proxyUrl":"http://proxy.example.com"}', + }); + const result = await dispatchRequest(request, 'https://target.example.com/v1/responses'); + + expect(result).toBe('ok'); + expect(resolveChannelProxyUrlMock).toHaveBeenCalledWith( + site, + '{"proxyUrl":"http://proxy.example.com"}', + ); + expect(dispatchRuntimeRequestMock).toHaveBeenCalledTimes(1); + const dispatchArg = dispatchRuntimeRequestMock.mock.calls[0]?.[0]; + expect(dispatchArg.siteUrl).toBe('https://upstream.example.com'); + expect(dispatchArg.targetUrl).toBe('https://target.example.com/v1/responses'); + expect(dispatchArg.request).toBe(request); + return dispatchArg.buildInit('https://target.example.com/v1/responses', { + headers: { authorization: 'Bearer test' }, + body: { model: 'gpt-5.2', input: 'hello' }, + }).then((init: Record<string, unknown>) => { + expect(withSiteRecordProxyRequestInitMock).toHaveBeenCalledWith(site, { + method: 'POST', + headers: { authorization: 'Bearer test' }, + body: JSON.stringify({ model: 'gpt-5.2', input: 'hello' }), + }, 'http://proxy.example.com'); + expect(init).toEqual({ + method: 'POST', + headers: { authorization: 'Bearer test' }, + body: JSON.stringify({ model: 'gpt-5.2', input: 'hello' }), + proxyUrl: 'http://proxy.example.com', + }); + }); + }); + + it('retries retryable upstream HTTP failures through the shared failure toolkit', async () => { + composeProxyLogMessageMock.mockReturnValue('normalized error'); + formatUtcSqlDateTimeMock.mockReturnValue('2026-03-21 22:00:00'); + insertProxyLogMock.mockResolvedValue(undefined); + shouldRetryProxyRequestMock.mockReturnValue(true); + isTokenExpiredErrorMock.mockReturnValue(false); + recordOauthQuotaResetHintMock.mockResolvedValue(null); + + const { createSurfaceFailureToolkit } = await import('./sharedSurface.js'); + const toolkit = createSurfaceFailureToolkit({ + warningScope: 'chat', + downstreamPath: '/v1/chat/completions', + maxRetries: 2, + clientContext: null, + downstreamApiKeyId: 44, + }); + + const result = await toolkit.handleUpstreamFailure({ + selected: { + channel: { id: 11, routeId: 22 }, + account: { id: 33, username: 'oauth-user' }, + site: { name: 'Codex OAuth' }, + actualModel: 'upstream-model', + }, + requestedModel: 'gpt-5.2', + modelName: 'upstream-model', + status: 429, + errText: 'quota exceeded', + rawErrText: '{"error":"quota exceeded"}', + latencyMs: 1200, + retryCount: 0, + }); + + expect(result).toEqual({ action: 'retry' }); + expect(recordFailureMock).toHaveBeenCalledWith(11, { + status: 429, + errorText: '{"error":"quota exceeded"}', + modelName: 'upstream-model', + }); + expect(recordOauthQuotaResetHintMock).toHaveBeenCalledWith({ + accountId: 33, + statusCode: 429, + errorText: '{"error":"quota exceeded"}', + }); + expect(reportProxyAllFailedMock).not.toHaveBeenCalled(); + expect(insertProxyLogMock).toHaveBeenCalledWith(expect.objectContaining({ + channelId: 11, + accountId: 33, + downstreamApiKeyId: 44, + modelRequested: 'gpt-5.2', + modelActual: 'upstream-model', + status: 'failed', + httpStatus: 429, + latencyMs: 1200, + errorMessage: 'normalized error', + retryCount: 0, + })); + }); + + it('keeps retryable failures on the retry path even when quota hint recording fails', async () => { + composeProxyLogMessageMock.mockReturnValue('normalized error'); + formatUtcSqlDateTimeMock.mockReturnValue('2026-03-21 22:00:00'); + insertProxyLogMock.mockResolvedValue(undefined); + shouldRetryProxyRequestMock.mockReturnValue(true); + isTokenExpiredErrorMock.mockReturnValue(false); + recordOauthQuotaResetHintMock.mockRejectedValue(new Error('hint failed')); + + const { createSurfaceFailureToolkit } = await import('./sharedSurface.js'); + const toolkit = createSurfaceFailureToolkit({ + warningScope: 'chat', + downstreamPath: '/v1/chat/completions', + maxRetries: 2, + clientContext: null, + downstreamApiKeyId: null, + }); + + await expect(toolkit.handleUpstreamFailure({ + selected: { + channel: { id: 11, routeId: 22 }, + account: { id: 33, username: 'oauth-user' }, + site: { name: 'Codex OAuth' }, + actualModel: 'upstream-model', + }, + requestedModel: 'gpt-5.2', + modelName: 'upstream-model', + status: 429, + errText: 'quota exceeded', + rawErrText: '{"error":"quota exceeded"}', + latencyMs: 1200, + retryCount: 0, + })).resolves.toEqual({ action: 'retry' }); + }); + + it('returns a terminal upstream error response and reports token expiration when retries stop', async () => { + composeProxyLogMessageMock.mockReturnValue('normalized error'); + formatUtcSqlDateTimeMock.mockReturnValue('2026-03-21 22:00:00'); + insertProxyLogMock.mockResolvedValue(undefined); + shouldRetryProxyRequestMock.mockReturnValue(false); + isTokenExpiredErrorMock.mockReturnValue(true); + recordOauthQuotaResetHintMock.mockResolvedValue(null); + + const { createSurfaceFailureToolkit } = await import('./sharedSurface.js'); + const toolkit = createSurfaceFailureToolkit({ + warningScope: 'responses', + downstreamPath: '/v1/responses', + maxRetries: 2, + clientContext: null, + downstreamApiKeyId: null, + }); + + const result = await toolkit.handleUpstreamFailure({ + selected: { + channel: { id: 11, routeId: 22 }, + account: { id: 33, username: 'oauth-user' }, + site: { name: 'Codex OAuth' }, + actualModel: 'upstream-model', + }, + requestedModel: 'gpt-5.2', + modelName: 'upstream-model', + status: 401, + errText: 'expired token', + rawErrText: 'expired token', + latencyMs: 900, + retryCount: 2, + }); + + expect(result).toEqual({ + action: 'respond', + status: 401, + payload: { + error: { + message: 'expired token', + type: 'upstream_error', + }, + }, + }); + expect(reportTokenExpiredMock).toHaveBeenCalledWith({ + accountId: 33, + username: 'oauth-user', + siteName: 'Codex OAuth', + detail: 'HTTP 401', + }); + expect(reportProxyAllFailedMock).toHaveBeenCalledWith({ + model: 'gpt-5.2', + reason: 'upstream returned HTTP 401', + }); + }); + + it('returns terminal failures even when final alerting throws', async () => { + composeProxyLogMessageMock.mockReturnValue('normalized error'); + formatUtcSqlDateTimeMock.mockReturnValue('2026-03-21 22:00:00'); + insertProxyLogMock.mockResolvedValue(undefined); + shouldRetryProxyRequestMock.mockReturnValue(false); + isTokenExpiredErrorMock.mockReturnValue(true); + recordOauthQuotaResetHintMock.mockResolvedValue(null); + reportTokenExpiredMock.mockRejectedValue(new Error('token alert failed')); + + const { createSurfaceFailureToolkit } = await import('./sharedSurface.js'); + const toolkit = createSurfaceFailureToolkit({ + warningScope: 'responses', + downstreamPath: '/v1/responses', + maxRetries: 2, + clientContext: null, + downstreamApiKeyId: null, + }); + + await expect(toolkit.handleUpstreamFailure({ + selected: { + channel: { id: 11, routeId: 22 }, + account: { id: 33, username: 'oauth-user' }, + site: { name: 'Codex OAuth' }, + actualModel: 'upstream-model', + }, + requestedModel: 'gpt-5.2', + modelName: 'upstream-model', + status: 401, + errText: 'expired token', + rawErrText: 'expired token', + latencyMs: 900, + retryCount: 2, + })).resolves.toEqual({ + action: 'respond', + status: 401, + payload: { + error: { + message: 'expired token', + type: 'upstream_error', + }, + }, + }); + }); + + it('handles detected proxy failures through the shared failure toolkit', async () => { + composeProxyLogMessageMock.mockReturnValue('normalized error'); + formatUtcSqlDateTimeMock.mockReturnValue('2026-03-21 22:00:00'); + insertProxyLogMock.mockResolvedValue(undefined); + shouldRetryProxyRequestMock.mockReturnValue(false); + + const { createSurfaceFailureToolkit } = await import('./sharedSurface.js'); + const toolkit = createSurfaceFailureToolkit({ + warningScope: 'chat', + downstreamPath: '/v1/chat/completions', + maxRetries: 2, + clientContext: null, + downstreamApiKeyId: null, + }); + + const result = await toolkit.handleDetectedFailure({ + selected: { + channel: { id: 11, routeId: 22 }, + account: { id: 33, username: 'oauth-user' }, + site: { name: 'Codex OAuth' }, + actualModel: 'upstream-model', + }, + requestedModel: 'gpt-5.2', + modelName: 'upstream-model', + failure: { + status: 500, + reason: 'upstream failure', + }, + latencyMs: 700, + retryCount: 2, + promptTokens: 12, + completionTokens: 4, + totalTokens: 16, + upstreamPath: '/v1/responses', + }); + + expect(result).toEqual({ + action: 'respond', + status: 500, + payload: { + error: { + message: 'upstream failure', + type: 'upstream_error', + }, + }, + }); + expect(recordFailureMock).toHaveBeenCalledWith(11, { + status: 500, + errorText: 'upstream failure', + modelName: 'upstream-model', + }); + expect(reportProxyAllFailedMock).toHaveBeenCalledWith({ + model: 'gpt-5.2', + reason: 'upstream failure', + }); + expect(recordOauthQuotaResetHintMock).not.toHaveBeenCalled(); + }); + + it('returns a terminal 502 for exhausted network failures through the shared failure toolkit', async () => { + composeProxyLogMessageMock.mockReturnValue('normalized error'); + formatUtcSqlDateTimeMock.mockReturnValue('2026-03-21 22:00:00'); + insertProxyLogMock.mockResolvedValue(undefined); + + const { createSurfaceFailureToolkit } = await import('./sharedSurface.js'); + const toolkit = createSurfaceFailureToolkit({ + warningScope: 'responses', + downstreamPath: '/v1/responses', + maxRetries: 2, + clientContext: null, + downstreamApiKeyId: null, + }); + + const result = await toolkit.handleExecutionError({ + selected: { + channel: { id: 11, routeId: 22 }, + account: { id: 33, username: 'oauth-user' }, + site: { name: 'Codex OAuth' }, + actualModel: 'upstream-model', + }, + requestedModel: 'gpt-5.2', + modelName: 'upstream-model', + errorMessage: 'socket hang up', + latencyMs: 650, + retryCount: 2, + }); + + expect(result).toEqual({ + action: 'respond', + status: 502, + payload: { + error: { + message: 'Upstream error: socket hang up', + type: 'upstream_error', + }, + }, + }); + expect(recordFailureMock).toHaveBeenCalledWith(11, { + errorText: 'socket hang up', + modelName: 'upstream-model', + }); + expect(reportProxyAllFailedMock).toHaveBeenCalledWith({ + model: 'gpt-5.2', + reason: 'socket hang up', + }); + }); + + it('records stream failures with error text even without a runtime status code', async () => { + composeProxyLogMessageMock.mockReturnValue('normalized error'); + formatUtcSqlDateTimeMock.mockReturnValue('2026-03-21 22:00:00'); + insertProxyLogMock.mockResolvedValue(undefined); + + const { createSurfaceFailureToolkit } = await import('./sharedSurface.js'); + const toolkit = createSurfaceFailureToolkit({ + warningScope: 'responses', + downstreamPath: '/v1/responses', + maxRetries: 2, + clientContext: null, + downstreamApiKeyId: null, + }); + + await toolkit.recordStreamFailure({ + selected: { + channel: { id: 11, routeId: 22 }, + account: { id: 33, username: 'oauth-user' }, + site: { name: 'Codex OAuth' }, + actualModel: 'upstream-model', + }, + requestedModel: 'gpt-5.2', + modelName: 'upstream-model', + errorMessage: 'stream exploded', + latencyMs: 450, + retryCount: 1, + }); + + expect(recordFailureMock).toHaveBeenCalledWith(11, { + errorText: 'stream exploded', + modelName: 'upstream-model', + }); + }); + + it('refreshes oauth tokens through the shared recover helper and retries the rebuilt request', async () => { + const refreshedResponse = { + ok: true, + status: 200, + text: vi.fn(), + }; + const selected = { + account: { + id: 33, + accessToken: 'old-access-token', + extraConfig: '{"oauth":{"refreshToken":"refresh"}}', + }, + tokenValue: 'old-access-token', + }; + const ctx = { + request: { + endpoint: 'responses' as const, + path: '/v1/responses', + headers: { authorization: 'Bearer old-access-token' }, + body: { model: 'gpt-5.2' }, + }, + response: { + ok: false, + status: 401, + text: vi.fn(), + }, + rawErrText: 'expired token', + }; + refreshOauthAccessTokenSingleflightMock.mockResolvedValue({ + accessToken: 'new-access-token', + extraConfig: '{"oauth":{"refreshToken":"refresh-next"}}', + }); + const dispatchRequest = vi.fn().mockResolvedValue(refreshedResponse); + + const { trySurfaceOauthRefreshRecovery } = await import('./sharedSurface.js'); + const result = await trySurfaceOauthRefreshRecovery({ + ctx, + selected, + siteUrl: 'https://upstream.example.com', + buildRequest: () => ({ + endpoint: 'responses', + path: '/v1/responses', + headers: { authorization: `Bearer ${selected.tokenValue}` }, + body: { model: 'gpt-5.2' }, + }), + dispatchRequest, + }); + + expect(refreshOauthAccessTokenSingleflightMock).toHaveBeenCalledWith(33); + expect(selected.tokenValue).toBe('new-access-token'); + expect(selected.account.accessToken).toBe('new-access-token'); + expect(selected.account.extraConfig).toBe('{"oauth":{"refreshToken":"refresh-next"}}'); + expect(dispatchRequest).toHaveBeenCalledWith(expect.objectContaining({ + headers: { authorization: 'Bearer new-access-token' }, + }), 'https://upstream.example.com/v1/responses'); + expect(result).toEqual({ + request: { + endpoint: 'responses', + path: '/v1/responses', + headers: { authorization: 'Bearer new-access-token' }, + body: { model: 'gpt-5.2' }, + }, + targetUrl: 'https://upstream.example.com/v1/responses', + upstream: refreshedResponse, + upstreamPath: '/v1/responses', + }); + }); + + it('updates the recover context with the refreshed failure response when oauth refresh retry still fails', async () => { + const refreshedResponse = { + ok: false, + status: 403, + text: vi.fn().mockResolvedValue('account mismatch'), + }; + const ctx = { + request: { + endpoint: 'responses' as const, + path: '/v1/responses', + headers: { authorization: 'Bearer old-access-token' }, + body: { model: 'gpt-5.2' }, + }, + response: { + ok: false, + status: 401, + text: vi.fn(), + }, + rawErrText: 'expired token', + }; + const selected = { + account: { + id: 33, + accessToken: 'old-access-token', + extraConfig: '{"oauth":{"refreshToken":"refresh"}}', + }, + tokenValue: 'old-access-token', + }; + refreshOauthAccessTokenSingleflightMock.mockResolvedValue({ + accessToken: 'new-access-token', + extraConfig: '{"oauth":{"refreshToken":"refresh-next"}}', + }); + + const { trySurfaceOauthRefreshRecovery } = await import('./sharedSurface.js'); + const result = await trySurfaceOauthRefreshRecovery({ + ctx, + selected, + siteUrl: 'https://upstream.example.com', + buildRequest: () => ({ + endpoint: 'responses', + path: '/v1/responses', + headers: { authorization: `Bearer ${selected.tokenValue}` }, + body: { model: 'gpt-5.2' }, + }), + dispatchRequest: vi.fn().mockResolvedValue(refreshedResponse), + }); + + expect(result).toBeNull(); + expect(ctx.request.headers).toEqual({ authorization: 'Bearer new-access-token' }); + expect(ctx.response).toBe(refreshedResponse); + expect(ctx.rawErrText).toBe('account mismatch'); + }); + + it('records shared success bookkeeping with usage fallback, billing, and success logging', async () => { + resolveProxyUsageWithSelfLogFallbackMock.mockResolvedValue({ + promptTokens: 20, + completionTokens: 8, + totalTokens: 28, + }); + resolveProxyLogBillingMock.mockResolvedValue({ + estimatedCost: 0.42, + billingDetails: { source: 'pricing-test' }, + }); + const logSuccess = vi.fn().mockResolvedValue(undefined); + const recordDownstreamCost = vi.fn(); + + const { recordSurfaceSuccess } = await import('./sharedSurface.js'); + const result = await recordSurfaceSuccess({ + selected: { + channel: { id: 11, routeId: 22 }, + account: { id: 33, username: 'oauth-user' }, + site: { id: 44, url: 'https://upstream.example.com', name: 'Codex OAuth' }, + tokenValue: 'live-token', + tokenName: 'default', + actualModel: 'upstream-model', + }, + requestedModel: 'gpt-5.2', + modelName: 'upstream-model', + parsedUsage: { + promptTokens: 10, + completionTokens: 5, + totalTokens: 15, + }, + requestStartedAtMs: 1000, + latencyMs: 250, + retryCount: 1, + upstreamPath: '/v1/responses', + logSuccess, + recordDownstreamCost, + }); + + expect(resolveProxyUsageWithSelfLogFallbackMock).toHaveBeenCalledWith({ + site: { id: 44, url: 'https://upstream.example.com', name: 'Codex OAuth' }, + account: { id: 33, username: 'oauth-user' }, + tokenValue: 'live-token', + tokenName: 'default', + modelName: 'upstream-model', + requestStartedAtMs: 1000, + requestEndedAtMs: 1250, + localLatencyMs: 250, + usage: { + promptTokens: 10, + completionTokens: 5, + totalTokens: 15, + }, + }); + expect(resolveProxyLogBillingMock).toHaveBeenCalledWith({ + site: { id: 44, url: 'https://upstream.example.com', name: 'Codex OAuth' }, + account: { id: 33, username: 'oauth-user' }, + modelName: 'upstream-model', + parsedUsage: { + promptTokens: 10, + completionTokens: 5, + totalTokens: 15, + }, + resolvedUsage: { + promptTokens: 20, + completionTokens: 8, + totalTokens: 28, + }, + }); + expect(recordSuccessMock).toHaveBeenCalledWith(11, 250, 0.42, 'upstream-model'); + expect(recordDownstreamCost).toHaveBeenCalledWith(0.42); + expect(logSuccess).toHaveBeenCalledWith({ + selected: { + channel: { id: 11, routeId: 22 }, + account: { id: 33, username: 'oauth-user' }, + site: { id: 44, url: 'https://upstream.example.com', name: 'Codex OAuth' }, + tokenValue: 'live-token', + tokenName: 'default', + actualModel: 'upstream-model', + }, + modelRequested: 'gpt-5.2', + status: 'success', + httpStatus: 200, + latencyMs: 250, + errorMessage: null, + retryCount: 1, + promptTokens: 20, + completionTokens: 8, + totalTokens: 28, + estimatedCost: 0.42, + billingDetails: { source: 'pricing-test' }, + upstreamPath: '/v1/responses', + }); + expect(result).toEqual({ + resolvedUsage: { + promptTokens: 20, + completionTokens: 8, + totalTokens: 28, + }, + estimatedCost: 0.42, + billingDetails: { source: 'pricing-test' }, + }); + }); + + it('treats success metrics as best-effort when requested', async () => { + resolveProxyUsageWithSelfLogFallbackMock.mockRejectedValueOnce(new Error('billing failed')); + const logSuccess = vi.fn().mockResolvedValue(undefined); + const recordDownstreamCost = vi.fn(); + + const { recordSurfaceSuccess } = await import('./sharedSurface.js'); + const result = await recordSurfaceSuccess({ + selected: { + channel: { id: 11, routeId: 22 }, + account: { id: 33, username: 'oauth-user' }, + site: { id: 44, url: 'https://upstream.example.com', name: 'Codex OAuth' }, + tokenValue: 'live-token', + tokenName: 'default', + actualModel: 'upstream-model', + }, + requestedModel: 'gpt-5.2', + modelName: 'upstream-model', + parsedUsage: { + promptTokens: 10, + completionTokens: 5, + totalTokens: 15, + }, + requestStartedAtMs: 1000, + latencyMs: 250, + retryCount: 1, + upstreamPath: '/v1/responses', + logSuccess, + recordDownstreamCost, + bestEffortMetrics: { + errorLabel: '[proxy/chat] failed to record success metrics', + }, + }); + + expect(consoleErrorMock).toHaveBeenCalledWith( + '[proxy/chat] failed to record success metrics', + expect.any(Error), + ); + expect(recordSuccessMock).toHaveBeenCalledWith(11, 250, 0, 'upstream-model'); + expect(recordDownstreamCost).toHaveBeenCalledWith(0); + expect(logSuccess).toHaveBeenCalledWith(expect.objectContaining({ + promptTokens: 10, + completionTokens: 5, + totalTokens: 15, + estimatedCost: 0, + billingDetails: null, + })); + expect(result).toEqual({ + resolvedUsage: { + promptTokens: 10, + completionTokens: 5, + totalTokens: 15, + recoveredFromSelfLog: false, + estimatedCostFromQuota: 0, + selfLogBillingMeta: null, + }, + estimatedCost: 0, + billingDetails: null, + }); + }); +}); diff --git a/src/server/proxy-core/surfaces/sharedSurface.ts b/src/server/proxy-core/surfaces/sharedSurface.ts new file mode 100644 index 00000000..bfd72b39 --- /dev/null +++ b/src/server/proxy-core/surfaces/sharedSurface.ts @@ -0,0 +1,709 @@ +import * as routeRefreshWorkflow from '../../services/routeRefreshWorkflow.js'; +import { formatUtcSqlDateTime } from '../../services/localTimeService.js'; +import { resolveChannelProxyUrl, withSiteRecordProxyRequestInit } from '../../services/siteProxy.js'; +import type { SiteProxyConfigLike } from '../../services/siteProxy.js'; +import { tokenRouter } from '../../services/tokenRouter.js'; +import { resolveProxyUsageWithSelfLogFallback } from '../../services/proxyUsageFallbackService.js'; +import type { DownstreamRoutingPolicy } from '../../services/downstreamPolicyTypes.js'; +import { reportProxyAllFailed, reportTokenExpired } from '../../services/alertService.js'; +import { isTokenExpiredError } from '../../services/alertRules.js'; +import { shouldRetryProxyRequest } from '../../services/proxyRetryPolicy.js'; +import { composeProxyLogMessage } from '../../routes/proxy/logPathMeta.js'; +import { resolveProxyLogBilling } from '../../routes/proxy/proxyBilling.js'; +import type { DownstreamClientContext } from '../../routes/proxy/downstreamClientContext.js'; +import { insertProxyLog } from '../../services/proxyLogStore.js'; +import { dispatchRuntimeRequest } from '../../routes/proxy/runtimeExecutor.js'; +import type { BuiltEndpointRequest } from '../../routes/proxy/endpointFlow.js'; +import { buildUpstreamUrl } from '../../routes/proxy/upstreamUrl.js'; +import { recordOauthQuotaResetHint } from '../../services/oauth/quota.js'; +import { refreshOauthAccessTokenSingleflight } from '../../services/oauth/refreshSingleflight.js'; +import { proxyChannelCoordinator } from '../../services/proxyChannelCoordinator.js'; +import { readRuntimeResponseText } from '../executors/types.js'; + +type SelectedChannel = Awaited<ReturnType<typeof tokenRouter.selectChannel>>; +type SurfaceWarningScope = 'chat' | 'responses'; + +type SurfaceSelectedChannel = { + channel: { routeId: number | null; id: number }; + account: { id: number; username?: string | null }; + site: { name?: string | null }; + actualModel?: string | null; +}; + +type SurfaceFailureResponse = { + action: 'respond'; + status: number; + payload: { + error: { + message: string; + type: 'upstream_error'; + }; + }; +}; + +type SurfaceFailureOutcome = + | { action: 'retry' } + | SurfaceFailureResponse; + +type SurfaceOauthRefreshSelectedChannel = { + account: { + id: number; + accessToken?: string | null; + extraConfig?: string | null; + }; + tokenValue: string; +}; + +type SurfaceOauthRefreshContext<TRequest extends BuiltEndpointRequest> = { + request: TRequest; + response: Awaited<ReturnType<typeof dispatchRuntimeRequest>>; + rawErrText: string; +}; + +type SurfaceSuccessSelectedChannel = SurfaceSelectedChannel & { + account: Record<string, unknown> & { + id: number; + username?: string | null; + accessToken?: string | null; + apiToken?: string | null; + extraConfig?: string | null; + platformUserId?: number | null; + }; + site: Record<string, unknown> & { + id: number; + url: string; + platform: string; + apiKey?: string | null; + useSystemProxy?: boolean | null; + proxyUrl?: string | null; + name?: string | null; + }; + tokenValue: string; + tokenName?: string | null; +}; + +type SurfaceUsageSummary = { + promptTokens: number; + completionTokens: number; + totalTokens: number; + cacheReadTokens: number; + cacheCreationTokens: number; + promptTokensIncludeCache: boolean | null; +}; + +type SurfaceResolvedUsageSummary = { + promptTokens: number; + completionTokens: number; + totalTokens: number; + recoveredFromSelfLog: boolean; + estimatedCostFromQuota: number; + selfLogBillingMeta: import('../../services/proxyUsageFallbackService.js').SelfLogBillingMeta | null; +}; + +export async function selectSurfaceChannelForAttempt(input: { + requestedModel: string; + downstreamPolicy: DownstreamRoutingPolicy; + excludeChannelIds: number[]; + retryCount: number; + stickySessionKey?: string | null; +}): Promise<SelectedChannel> { + let selected: SelectedChannel = null; + + if (input.retryCount === 0 && input.stickySessionKey) { + const preferredChannelId = proxyChannelCoordinator.getStickyChannelId(input.stickySessionKey); + if (preferredChannelId && !input.excludeChannelIds.includes(preferredChannelId)) { + selected = await tokenRouter.selectPreferredChannel( + input.requestedModel, + preferredChannelId, + input.downstreamPolicy, + input.excludeChannelIds, + ); + if (!selected) { + proxyChannelCoordinator.clearStickyChannel(input.stickySessionKey, preferredChannelId); + } + } + } + + if (!selected) { + selected = input.retryCount === 0 + ? await tokenRouter.selectChannel(input.requestedModel, input.downstreamPolicy) + : await tokenRouter.selectNextChannel( + input.requestedModel, + input.excludeChannelIds, + input.downstreamPolicy, + ); + } + + if (!selected && input.retryCount === 0) { + try { + await routeRefreshWorkflow.refreshModelsAndRebuildRoutes(); + } catch (error) { + console.warn('[proxy/surface] failed to refresh routes after empty selection', error); + } + selected = await tokenRouter.selectChannel(input.requestedModel, input.downstreamPolicy); + } + + return selected; +} + +export function buildSurfaceStickySessionKey(input: { + clientContext?: DownstreamClientContext | null; + requestedModel: string; + downstreamPath: string; + downstreamApiKeyId?: number | null; +}): string | null { + return proxyChannelCoordinator.buildStickySessionKey({ + clientKind: input.clientContext?.clientKind || null, + sessionId: input.clientContext?.sessionId || null, + requestedModel: input.requestedModel, + downstreamPath: input.downstreamPath, + downstreamApiKeyId: input.downstreamApiKeyId, + }); +} + +export function bindSurfaceStickyChannel(input: { + stickySessionKey?: string | null; + selected: { + channel: { id: number }; + account?: { extraConfig?: string | null } | null; + }; +}): void { + proxyChannelCoordinator.bindStickyChannel( + input.stickySessionKey, + input.selected.channel.id, + input.selected.account?.extraConfig, + ); +} + +export function clearSurfaceStickyChannel(input: { + stickySessionKey?: string | null; + selected: { + channel: { id: number }; + }; +}): void { + proxyChannelCoordinator.clearStickyChannel( + input.stickySessionKey, + input.selected.channel.id, + ); +} + +export async function acquireSurfaceChannelLease(input: { + stickySessionKey?: string | null; + selected: { + channel: { id: number }; + account?: { extraConfig?: string | null } | null; + }; +}) { + return await proxyChannelCoordinator.acquireChannelLease({ + // Only session-addressable requests should consume the guarded per-channel + // lease pool. Requests without a stable downstream session key should keep + // the pre-sticky-session parallel behavior instead of contending globally. + channelId: input.stickySessionKey ? input.selected.channel.id : 0, + accountExtraConfig: input.selected.account?.extraConfig, + }); +} + +export function buildSurfaceChannelBusyMessage(waitMs: number): string { + return waitMs > 0 + ? `Channel busy: waited ${waitMs}ms for an available session slot` + : 'Channel busy: no session slot available'; +} + +export async function writeSurfaceProxyLog(input: { + warningScope: string; + selected: { + channel: { routeId: number | null; id: number | null }; + account: { id: number | null }; + actualModel?: string | null; + }; + modelRequested: string; + status: string; + httpStatus: number; + latencyMs: number; + errorMessage: string | null; + retryCount: number; + downstreamPath: string; + promptTokens?: number; + completionTokens?: number; + totalTokens?: number; + estimatedCost?: number; + billingDetails?: unknown; + upstreamPath?: string | null; + clientContext?: DownstreamClientContext | null; + downstreamApiKeyId?: number | null; +}): Promise<void> { + try { + const createdAt = formatUtcSqlDateTime(new Date()); + const normalizedErrorMessage = composeProxyLogMessage({ + clientKind: input.clientContext?.clientKind && input.clientContext.clientKind !== 'generic' + ? input.clientContext.clientKind + : null, + sessionId: input.clientContext?.sessionId || null, + traceHint: input.clientContext?.traceHint || null, + downstreamPath: input.downstreamPath, + upstreamPath: input.upstreamPath || null, + errorMessage: input.errorMessage, + }); + await insertProxyLog({ + routeId: input.selected.channel.routeId, + channelId: input.selected.channel.id, + accountId: input.selected.account.id, + downstreamApiKeyId: input.downstreamApiKeyId ?? null, + modelRequested: input.modelRequested, + modelActual: input.selected.actualModel ?? null, + status: input.status, + httpStatus: input.httpStatus, + latencyMs: input.latencyMs, + promptTokens: input.promptTokens ?? 0, + completionTokens: input.completionTokens ?? 0, + totalTokens: input.totalTokens ?? 0, + estimatedCost: input.estimatedCost ?? 0, + billingDetails: input.billingDetails ?? null, + clientFamily: input.clientContext?.clientKind || null, + clientAppId: input.clientContext?.clientAppId || null, + clientAppName: input.clientContext?.clientAppName || null, + clientConfidence: input.clientContext?.clientConfidence || null, + errorMessage: normalizedErrorMessage, + retryCount: input.retryCount, + createdAt, + }); + } catch (error) { + console.warn(`[proxy/${input.warningScope}] failed to write proxy log`, error); + } +} + +export function createSurfaceDispatchRequest(input: { + site: SiteProxyConfigLike & { url: string }; + accountExtraConfig?: string | null; +}) { + const channelProxyUrl = resolveChannelProxyUrl(input.site, input.accountExtraConfig); + return ( + request: BuiltEndpointRequest, + targetUrl?: string, + ) => ( + dispatchRuntimeRequest({ + siteUrl: input.site.url, + targetUrl, + request, + buildInit: (_requestUrl, requestForFetch) => withSiteRecordProxyRequestInit(input.site, { + method: 'POST', + headers: requestForFetch.headers, + body: JSON.stringify(requestForFetch.body), + }, channelProxyUrl), + }) + ); +} + +export async function trySurfaceOauthRefreshRecovery<TRequest extends BuiltEndpointRequest>(input: { + ctx: SurfaceOauthRefreshContext<TRequest>; + selected: SurfaceOauthRefreshSelectedChannel; + siteUrl: string; + buildRequest: (endpoint: TRequest['endpoint']) => TRequest; + dispatchRequest: ( + request: TRequest, + targetUrl: string, + ) => Promise<Awaited<ReturnType<typeof dispatchRuntimeRequest>>>; + captureFailureBody?: boolean; +}): Promise<{ + upstream: Awaited<ReturnType<typeof dispatchRuntimeRequest>>; + upstreamPath: string; + request?: TRequest; + targetUrl?: string; +} | null> { + try { + const refreshed = await refreshOauthAccessTokenSingleflight(input.selected.account.id); + input.selected.tokenValue = refreshed.accessToken; + input.selected.account = { + ...input.selected.account, + accessToken: refreshed.accessToken, + extraConfig: refreshed.extraConfig ?? input.selected.account.extraConfig, + }; + + const refreshedRequest = input.buildRequest(input.ctx.request.endpoint); + const refreshedTargetUrl = buildUpstreamUrl(input.siteUrl, refreshedRequest.path); + const refreshedResponse = await input.dispatchRequest(refreshedRequest, refreshedTargetUrl); + if (refreshedResponse.ok) { + return { + upstream: refreshedResponse, + upstreamPath: refreshedRequest.path, + request: refreshedRequest, + targetUrl: refreshedTargetUrl, + }; + } + + input.ctx.request = refreshedRequest; + input.ctx.response = refreshedResponse; + if (input.captureFailureBody !== false) { + const failureBody = await readRuntimeResponseText(refreshedResponse).catch(() => ''); + input.ctx.rawErrText = failureBody.trim() || 'unknown error'; + } + } catch { + return null; + } + + return null; +} + +export async function recordSurfaceSuccess(input: { + selected: SurfaceSuccessSelectedChannel; + requestedModel: string; + modelName: string; + parsedUsage: SurfaceUsageSummary; + requestStartedAtMs: number; + latencyMs: number; + retryCount: number; + upstreamPath?: string | null; + logSuccess: (args: { + selected: SurfaceSelectedChannel; + modelRequested: string; + status: string; + httpStatus: number; + latencyMs: number; + errorMessage: string | null; + retryCount: number; + promptTokens?: number; + completionTokens?: number; + totalTokens?: number; + estimatedCost?: number; + billingDetails?: unknown; + upstreamPath?: string | null; + }) => Promise<void>; + recordDownstreamCost?: (estimatedCost: number) => void; + bestEffortMetrics?: { + errorLabel: string; + }; +}): Promise<{ + resolvedUsage: SurfaceResolvedUsageSummary; + estimatedCost: number; + billingDetails: unknown; +}> { + let resolvedUsage: SurfaceResolvedUsageSummary = { + promptTokens: input.parsedUsage.promptTokens, + completionTokens: input.parsedUsage.completionTokens, + totalTokens: input.parsedUsage.totalTokens, + recoveredFromSelfLog: false, + estimatedCostFromQuota: 0, + selfLogBillingMeta: null, + }; + let estimatedCost = 0; + let billingDetails: unknown = null; + + try { + resolvedUsage = await resolveProxyUsageWithSelfLogFallback({ + site: input.selected.site, + account: input.selected.account, + tokenValue: input.selected.tokenValue, + tokenName: input.selected.tokenName, + modelName: input.modelName, + requestStartedAtMs: input.requestStartedAtMs, + requestEndedAtMs: input.requestStartedAtMs + input.latencyMs, + localLatencyMs: input.latencyMs, + usage: { + promptTokens: input.parsedUsage.promptTokens, + completionTokens: input.parsedUsage.completionTokens, + totalTokens: input.parsedUsage.totalTokens, + }, + }); + const billing = await resolveProxyLogBilling({ + site: input.selected.site, + account: input.selected.account, + modelName: input.modelName, + parsedUsage: input.parsedUsage, + resolvedUsage, + }); + estimatedCost = billing.estimatedCost; + billingDetails = billing.billingDetails; + } catch (error) { + if (!input.bestEffortMetrics) { + throw error; + } + console.error(input.bestEffortMetrics.errorLabel, error); + } + + tokenRouter.recordSuccess( + input.selected.channel.id, + input.latencyMs, + estimatedCost, + input.modelName, + ); + input.recordDownstreamCost?.(estimatedCost); + await input.logSuccess({ + selected: input.selected, + modelRequested: input.requestedModel, + status: 'success', + httpStatus: 200, + latencyMs: input.latencyMs, + errorMessage: null, + retryCount: input.retryCount, + promptTokens: resolvedUsage.promptTokens, + completionTokens: resolvedUsage.completionTokens, + totalTokens: resolvedUsage.totalTokens, + estimatedCost, + billingDetails, + upstreamPath: input.upstreamPath, + }); + + return { + resolvedUsage, + estimatedCost, + billingDetails, + }; +} + +export function createSurfaceFailureToolkit(input: { + warningScope: SurfaceWarningScope; + downstreamPath: string; + maxRetries: number; + clientContext?: DownstreamClientContext | null; + downstreamApiKeyId?: number | null; +}) { + const log = async (args: { + selected: SurfaceSelectedChannel; + modelRequested: string; + status: string; + httpStatus: number; + latencyMs: number; + errorMessage: string | null; + retryCount: number; + promptTokens?: number; + completionTokens?: number; + totalTokens?: number; + estimatedCost?: number; + billingDetails?: unknown; + upstreamPath?: string | null; + }) => { + await writeSurfaceProxyLog({ + warningScope: input.warningScope, + selected: args.selected, + modelRequested: args.modelRequested, + status: args.status, + httpStatus: args.httpStatus, + latencyMs: args.latencyMs, + errorMessage: args.errorMessage, + retryCount: args.retryCount, + downstreamPath: input.downstreamPath, + promptTokens: args.promptTokens, + completionTokens: args.completionTokens, + totalTokens: args.totalTokens, + estimatedCost: args.estimatedCost, + billingDetails: args.billingDetails, + upstreamPath: args.upstreamPath, + clientContext: input.clientContext, + downstreamApiKeyId: input.downstreamApiKeyId, + }); + }; + + const maybeRetry = (retryCount: number) => retryCount < input.maxRetries + ? { action: 'retry' as const } + : null; + + const runBestEffort = (label: string, fn: () => Promise<unknown>) => { + void Promise.resolve() + .then(fn) + .catch((error) => { + console.warn(`[proxy/${input.warningScope}] failed to ${label}`, error); + }); + }; + + return { + log, + async handleUpstreamFailure(args: { + selected: SurfaceSelectedChannel; + requestedModel: string; + modelName: string; + status: number; + errText: string; + rawErrText?: string | null; + latencyMs: number; + retryCount: number; + }): Promise<SurfaceFailureOutcome> { + const rawErrText = args.rawErrText || args.errText; + await tokenRouter.recordFailure(args.selected.channel.id, { + status: args.status, + errorText: rawErrText, + modelName: args.modelName, + }); + await log({ + selected: args.selected, + modelRequested: args.requestedModel, + status: 'failed', + httpStatus: args.status, + latencyMs: args.latencyMs, + errorMessage: args.errText, + retryCount: args.retryCount, + }); + runBestEffort('record oauth quota reset hint', () => recordOauthQuotaResetHint({ + accountId: args.selected.account.id, + statusCode: args.status, + errorText: rawErrText, + })); + + if (isTokenExpiredError({ status: args.status, message: args.errText })) { + runBestEffort('report token expired', () => reportTokenExpired({ + accountId: args.selected.account.id, + username: args.selected.account.username, + siteName: args.selected.site.name, + detail: `HTTP ${args.status}`, + })); + } + + if (shouldRetryProxyRequest(args.status, args.errText)) { + const retry = maybeRetry(args.retryCount); + if (retry) return retry; + } + + runBestEffort('report proxy all failed', () => reportProxyAllFailed({ + model: args.requestedModel, + reason: `upstream returned HTTP ${args.status}`, + })); + + return { + action: 'respond', + status: args.status, + payload: { + error: { + message: args.errText, + type: 'upstream_error', + }, + }, + }; + }, + + async handleDetectedFailure(args: { + selected: SurfaceSelectedChannel; + requestedModel: string; + modelName: string; + failure: { status: number; reason: string }; + latencyMs: number; + retryCount: number; + promptTokens?: number; + completionTokens?: number; + totalTokens?: number; + upstreamPath?: string | null; + }): Promise<SurfaceFailureOutcome> { + await tokenRouter.recordFailure(args.selected.channel.id, { + status: args.failure.status, + errorText: args.failure.reason, + modelName: args.modelName, + }); + await log({ + selected: args.selected, + modelRequested: args.requestedModel, + status: 'failed', + httpStatus: args.failure.status, + latencyMs: args.latencyMs, + errorMessage: args.failure.reason, + retryCount: args.retryCount, + promptTokens: args.promptTokens, + completionTokens: args.completionTokens, + totalTokens: args.totalTokens, + upstreamPath: args.upstreamPath, + }); + + if (shouldRetryProxyRequest(args.failure.status, args.failure.reason)) { + const retry = maybeRetry(args.retryCount); + if (retry) return retry; + } + + runBestEffort('report proxy all failed', () => reportProxyAllFailed({ + model: args.requestedModel, + reason: args.failure.reason, + })); + + return { + action: 'respond', + status: args.failure.status, + payload: { + error: { + message: args.failure.reason, + type: 'upstream_error', + }, + }, + }; + }, + + async handleExecutionError(args: { + selected: SurfaceSelectedChannel; + requestedModel: string; + modelName: string; + errorMessage: string; + latencyMs: number; + retryCount: number; + }): Promise<SurfaceFailureOutcome> { + await tokenRouter.recordFailure(args.selected.channel.id, { + errorText: args.errorMessage, + modelName: args.modelName, + }); + await log({ + selected: args.selected, + modelRequested: args.requestedModel, + status: 'failed', + httpStatus: 0, + latencyMs: args.latencyMs, + errorMessage: args.errorMessage, + retryCount: args.retryCount, + }); + + const retry = maybeRetry(args.retryCount); + if (retry) return retry; + + runBestEffort('report proxy all failed', () => reportProxyAllFailed({ + model: args.requestedModel, + reason: args.errorMessage || 'network failure', + })); + + return { + action: 'respond', + status: 502, + payload: { + error: { + message: `Upstream error: ${args.errorMessage || 'network failure'}`, + type: 'upstream_error', + }, + }, + }; + }, + + async recordStreamFailure(args: { + selected: SurfaceSelectedChannel; + requestedModel: string; + modelName: string; + errorMessage: string | null; + latencyMs: number; + retryCount: number; + promptTokens?: number; + completionTokens?: number; + totalTokens?: number; + upstreamPath?: string | null; + httpStatus?: number; + runtimeFailureStatus?: number | null; + }) { + const errorMessage = args.errorMessage || 'stream processing failed'; + if (typeof args.runtimeFailureStatus === 'number') { + await tokenRouter.recordFailure(args.selected.channel.id, { + status: args.runtimeFailureStatus, + errorText: errorMessage, + modelName: args.modelName, + }); + } else { + await tokenRouter.recordFailure(args.selected.channel.id, { + errorText: errorMessage, + modelName: args.modelName, + }); + } + await log({ + selected: args.selected, + modelRequested: args.requestedModel, + status: 'failed', + httpStatus: args.httpStatus ?? 200, + latencyMs: args.latencyMs, + errorMessage, + retryCount: args.retryCount, + promptTokens: args.promptTokens, + completionTokens: args.completionTokens, + totalTokens: args.totalTokens, + upstreamPath: args.upstreamPath, + }); + }, + }; +} diff --git a/src/server/routes/api/accountTokens.architecture.test.ts b/src/server/routes/api/accountTokens.architecture.test.ts new file mode 100644 index 00000000..9cdd6004 --- /dev/null +++ b/src/server/routes/api/accountTokens.architecture.test.ts @@ -0,0 +1,13 @@ +import { readFileSync } from 'node:fs'; +import { describe, expect, it } from 'vitest'; + +function readSource(relativePath: string): string { + return readFileSync(new URL(relativePath, import.meta.url), 'utf8'); +} + +describe('account token route architecture boundaries', () => { + it('keeps legacy token repair from forcing route rebuilds inline', () => { + const source = readSource('./accountTokens.ts'); + expect(source).not.toContain('rebuildRoutes: true'); + }); +}); diff --git a/src/server/routes/api/accountTokens.batch.test.ts b/src/server/routes/api/accountTokens.batch.test.ts index 619bcc8f..e38fe2e6 100644 --- a/src/server/routes/api/accountTokens.batch.test.ts +++ b/src/server/routes/api/accountTokens.batch.test.ts @@ -3,6 +3,7 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vites import { mkdtempSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; +import { eq } from 'drizzle-orm'; const { deleteApiTokenMock } = vi.hoisted(() => ({ deleteApiTokenMock: vi.fn(), @@ -105,6 +106,43 @@ describe('account token batch routes', () => { expect(rows.every((row) => row.enabled === true)).toBe(true); }); + it('rejects enabling masked_pending placeholders until they are completed', async () => { + await db.update(schema.accountTokens) + .set({ + enabled: false, + valueStatus: 'masked_pending' as any, + token: 'sk-mask***tail', + }) + .where(eq(schema.accountTokens.id, 1)) + .run(); + + const response = await app.inject({ + method: 'POST', + url: '/api/account-tokens/batch', + payload: { + ids: [1, 2], + action: 'enable', + }, + }); + + expect(response.statusCode).toBe(200); + const body = response.json() as { + successIds?: number[]; + failedItems?: Array<{ id: number; message: string }>; + }; + expect(body.successIds).toEqual([2]); + expect(body.failedItems).toEqual([ + expect.objectContaining({ + id: 1, + message: expect.stringContaining('待补全令牌'), + }), + ]); + + const rows = await db.select().from(schema.accountTokens).all(); + expect(rows.find((row) => row.id === 1)?.enabled).toBe(false); + expect(rows.find((row) => row.id === 2)?.enabled).toBe(true); + }); + it('deletes selected account tokens through the upstream adapter', async () => { const response = await app.inject({ method: 'POST', diff --git a/src/server/routes/api/accountTokens.coverage-refresh-failure.test.ts b/src/server/routes/api/accountTokens.coverage-refresh-failure.test.ts new file mode 100644 index 00000000..735adc18 --- /dev/null +++ b/src/server/routes/api/accountTokens.coverage-refresh-failure.test.ts @@ -0,0 +1,115 @@ +import Fastify, { type FastifyInstance } from 'fastify'; +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { mkdtempSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { eq } from 'drizzle-orm'; + +const refreshModelsForAccountMock = vi.fn(); +const rebuildTokenRoutesFromAvailabilityMock = vi.fn(); + +vi.mock('../../services/modelService.js', () => ({ + refreshModelsForAccount: (...args: unknown[]) => refreshModelsForAccountMock(...args), + rebuildTokenRoutesFromAvailability: (...args: unknown[]) => rebuildTokenRoutesFromAvailabilityMock(...args), +})); + +type DbModule = typeof import('../../db/index.js'); + +describe('account token coverage refresh failure handling', () => { + let app: FastifyInstance; + let db: DbModule['db']; + let schema: DbModule['schema']; + let dataDir = ''; + + beforeAll(async () => { + dataDir = mkdtempSync(join(tmpdir(), 'metapi-account-token-coverage-failure-')); + process.env.DATA_DIR = dataDir; + + await import('../../db/migrate.js'); + const dbModule = await import('../../db/index.js'); + const accountTokenRoutesModule = await import('./accountTokens.js'); + db = dbModule.db; + schema = dbModule.schema; + + app = Fastify(); + await app.register(accountTokenRoutesModule.accountTokensRoutes); + }); + + beforeEach(async () => { + refreshModelsForAccountMock.mockReset(); + rebuildTokenRoutesFromAvailabilityMock.mockReset(); + + await db.delete(schema.proxyLogs).run(); + await db.delete(schema.checkinLogs).run(); + await db.delete(schema.routeChannels).run(); + await db.delete(schema.tokenRoutes).run(); + await db.delete(schema.tokenModelAvailability).run(); + await db.delete(schema.modelAvailability).run(); + await db.delete(schema.accountTokens).run(); + await db.delete(schema.accounts).run(); + await db.delete(schema.sites).run(); + }); + + afterAll(async () => { + await app.close(); + delete process.env.DATA_DIR; + }); + + it('returns success after token persistence even when coverage rebuild fails', async () => { + const site = await db.insert(schema.sites).values({ + name: 'site-1', + url: 'https://site-1.example.com', + platform: 'new-api', + status: 'active', + }).returning().get(); + + const account = await db.insert(schema.accounts).values({ + siteId: site.id, + username: 'user-1', + accessToken: 'acc-token-1', + status: 'active', + }).returning().get(); + + refreshModelsForAccountMock.mockResolvedValue({ + accountId: account.id, + refreshed: true, + status: 'success', + errorCode: null, + errorMessage: '', + modelCount: 1, + modelsPreview: ['gpt-4o-mini'], + tokenScanned: 1, + discoveredByCredential: false, + discoveredApiToken: false, + }); + rebuildTokenRoutesFromAvailabilityMock.mockRejectedValue(new Error('rebuild failed')); + + const response = await app.inject({ + method: 'POST', + url: '/api/account-tokens', + payload: { + accountId: account.id, + name: 'manual-token', + token: 'sk-manual-token', + }, + }); + + expect(response.statusCode).toBe(200); + expect(response.json()).toMatchObject({ + success: true, + coverageRefresh: { + rebuild: { + success: false, + error: 'rebuild failed', + }, + }, + }); + + const tokens = await db.select() + .from(schema.accountTokens) + .where(eq(schema.accountTokens.accountId, account.id)) + .all(); + expect(tokens).toHaveLength(1); + expect(tokens[0]?.token).toBe('sk-manual-token'); + }); +}); diff --git a/src/server/routes/api/accountTokens.coverage-refresh.test.ts b/src/server/routes/api/accountTokens.coverage-refresh.test.ts new file mode 100644 index 00000000..781ee0bf --- /dev/null +++ b/src/server/routes/api/accountTokens.coverage-refresh.test.ts @@ -0,0 +1,203 @@ +import Fastify, { type FastifyInstance } from 'fastify'; +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { mkdtempSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { eq } from 'drizzle-orm'; + +const getApiTokensMock = vi.fn(); +const getApiTokenMock = vi.fn(); +const createApiTokenMock = vi.fn(); +const getUserGroupsMock = vi.fn(); +const deleteApiTokenMock = vi.fn(); +const getModelsMock = vi.fn(); + +vi.mock('../../services/platforms/index.js', () => ({ + getAdapter: () => ({ + getApiTokens: (...args: unknown[]) => getApiTokensMock(...args), + getApiToken: (...args: unknown[]) => getApiTokenMock(...args), + createApiToken: (...args: unknown[]) => createApiTokenMock(...args), + getUserGroups: (...args: unknown[]) => getUserGroupsMock(...args), + deleteApiToken: (...args: unknown[]) => deleteApiTokenMock(...args), + getModels: (...args: unknown[]) => getModelsMock(...args), + }), +})); + +type DbModule = typeof import('../../db/index.js'); + +describe('account token coverage refresh', { timeout: 15_000 }, () => { + let app: FastifyInstance; + let db: DbModule['db']; + let schema: DbModule['schema']; + let dataDir = ''; + let seedId = 0; + + const nextSeed = () => { + seedId += 1; + return seedId; + }; + + const seedAccount = async (modelName: string) => { + const id = nextSeed(); + const site = await db.insert(schema.sites).values({ + name: `site-${id}`, + url: `https://site-${id}.example.com`, + platform: 'new-api', + status: 'active', + }).returning().get(); + + const account = await db.insert(schema.accounts).values({ + siteId: site.id, + username: `user-${id}`, + accessToken: `acc-token-${id}`, + status: 'active', + }).returning().get(); + + await db.insert(schema.modelAvailability).values({ + accountId: account.id, + modelName, + available: true, + }).run(); + + return { site, account }; + }; + + const readTokenCandidates = async () => { + const response = await app.inject({ + method: 'GET', + url: '/api/models/token-candidates', + }); + expect(response.statusCode).toBe(200); + return response.json() as { + models: Record<string, Array<{ tokenId: number; accountId: number }>>; + modelsWithoutToken: Record<string, Array<{ accountId: number }>>; + }; + }; + + beforeAll(async () => { + dataDir = mkdtempSync(join(tmpdir(), 'metapi-account-token-coverage-')); + process.env.DATA_DIR = dataDir; + + await import('../../db/migrate.js'); + const dbModule = await import('../../db/index.js'); + const accountTokenRoutesModule = await import('./accountTokens.js'); + const statsRoutesModule = await import('./stats.js'); + db = dbModule.db; + schema = dbModule.schema; + + app = Fastify(); + await app.register(accountTokenRoutesModule.accountTokensRoutes); + await app.register(statsRoutesModule.statsRoutes); + }); + + beforeEach(async () => { + getApiTokensMock.mockReset(); + getApiTokenMock.mockReset(); + createApiTokenMock.mockReset(); + getUserGroupsMock.mockReset(); + deleteApiTokenMock.mockReset(); + getModelsMock.mockReset(); + seedId = 0; + + await db.delete(schema.proxyLogs).run(); + await db.delete(schema.checkinLogs).run(); + await db.delete(schema.routeChannels).run(); + await db.delete(schema.tokenRoutes).run(); + await db.delete(schema.tokenModelAvailability).run(); + await db.delete(schema.modelAvailability).run(); + await db.delete(schema.accountTokens).run(); + await db.delete(schema.accounts).run(); + await db.delete(schema.sites).run(); + }); + + afterAll(async () => { + await app.close(); + delete process.env.DATA_DIR; + }); + + it('refreshes token coverage after manually adding an account token', async () => { + const modelName = 'gpt-4o-mini'; + const { account } = await seedAccount(modelName); + getModelsMock.mockImplementation(async (_url: string, credential: string) => { + if (credential === account.accessToken || credential === 'sk-manual-token') { + return [modelName]; + } + return []; + }); + + const response = await app.inject({ + method: 'POST', + url: '/api/account-tokens', + payload: { + accountId: account.id, + name: 'manual-token', + token: 'sk-manual-token', + }, + }); + + expect(response.statusCode).toBe(200); + + const token = await db.select() + .from(schema.accountTokens) + .where(eq(schema.accountTokens.accountId, account.id)) + .get(); + expect(token).toBeDefined(); + + const tokenAvailability = await db.select() + .from(schema.tokenModelAvailability) + .where(eq(schema.tokenModelAvailability.tokenId, token!.id)) + .all(); + expect(tokenAvailability.map((row) => row.modelName)).toContain(modelName); + + const candidates = await readTokenCandidates(); + expect(candidates.modelsWithoutToken[modelName]).toBeUndefined(); + expect(candidates.models[modelName]).toEqual([ + expect.objectContaining({ + accountId: account.id, + tokenId: token!.id, + }), + ]); + }); + + it('refreshes token coverage after syncing account tokens from upstream', async () => { + const modelName = 'claude-sonnet-4-5-20250929'; + const { account } = await seedAccount(modelName); + getApiTokensMock.mockResolvedValue([ + { name: 'default', key: 'sk-synced-token', enabled: true }, + ]); + getModelsMock.mockImplementation(async (_url: string, credential: string) => { + if (credential === account.accessToken || credential === 'sk-synced-token') { + return [modelName]; + } + return []; + }); + + const response = await app.inject({ + method: 'POST', + url: `/api/account-tokens/sync/${account.id}`, + }); + + expect(response.statusCode).toBe(200); + + const token = await db.select() + .from(schema.accountTokens) + .where(eq(schema.accountTokens.accountId, account.id)) + .get(); + expect(token).toBeDefined(); + + const tokenAvailability = await db.select() + .from(schema.tokenModelAvailability) + .where(eq(schema.tokenModelAvailability.tokenId, token!.id)) + .all(); + expect(tokenAvailability.map((row) => row.modelName)).toContain(modelName); + + const candidates = await readTokenCandidates(); + expect(candidates.modelsWithoutToken[modelName]).toBeUndefined(); + expect(candidates.models[modelName]).toEqual([ + expect.objectContaining({ + accountId: account.id, + tokenId: token!.id, + }), + ]); + }); +}); diff --git a/src/server/routes/api/accountTokens.sync.test.ts b/src/server/routes/api/accountTokens.sync.test.ts index b4936555..5b96963c 100644 --- a/src/server/routes/api/accountTokens.sync.test.ts +++ b/src/server/routes/api/accountTokens.sync.test.ts @@ -12,6 +12,8 @@ const createApiTokenMock = vi.fn(); const getUserGroupsMock = vi.fn(); const deleteApiTokenMock = vi.fn(); +type AccountTokenServiceModule = typeof import('../../services/accountTokenService.js'); + vi.mock('../../services/platforms/index.js', () => ({ getAdapter: () => ({ getApiTokens: (...args: unknown[]) => getApiTokensMock(...args), @@ -28,6 +30,7 @@ describe('account tokens sync routes with site status', () => { let app: FastifyInstance; let db: DbModule['db']; let schema: DbModule['schema']; + let maskToken: AccountTokenServiceModule['maskToken']; let dataDir = ''; let seedId = 0; @@ -63,9 +66,11 @@ describe('account tokens sync routes with site status', () => { await import('../../db/migrate.js'); const dbModule = await import('../../db/index.js'); + const accountTokenServiceModule = await import('../../services/accountTokenService.js'); const routesModule = await import('./accountTokens.js'); db = dbModule.db; schema = dbModule.schema; + maskToken = accountTokenServiceModule.maskToken; app = Fastify(); await app.register(routesModule.accountTokensRoutes); @@ -138,6 +143,305 @@ describe('account tokens sync routes with site status', () => { expect(tokenRows.length).toBe(0); }); + it('stores masked upstream token values as masked_pending placeholders', async () => { + const { account } = await seedAccount({ siteStatus: 'active' }); + getApiTokensMock.mockResolvedValue([ + { name: 'masked-only', key: 'sk-abc***xyz', enabled: true }, + ]); + getApiTokenMock.mockResolvedValue(null); + + const response = await app.inject({ + method: 'POST', + url: `/api/account-tokens/sync/${account.id}`, + }); + + expect(response.statusCode).toBe(200); + expect(response.json()).toMatchObject({ + success: true, + synced: true, + status: 'synced', + reason: 'upstream_masked_tokens', + maskedPending: 1, + pendingTokenIds: [expect.any(Number)], + total: 1, + created: 1, + updated: 0, + }); + + const tokenRows = await db.select() + .from(schema.accountTokens) + .where(eq(schema.accountTokens.accountId, account.id)) + .all(); + expect(tokenRows).toHaveLength(1); + expect(tokenRows[0]).toMatchObject({ + name: 'masked-only', + token: 'sk-abc***xyz', + source: 'sync', + enabled: false, + isDefault: false, + }); + expect((tokenRows[0] as any).valueStatus).toBe('masked_pending'); + + const owner = await db.select() + .from(schema.accounts) + .where(eq(schema.accounts.id, account.id)) + .get(); + expect(owner?.apiToken ?? null).toBeNull(); + + const listResponse = await app.inject({ + method: 'GET', + url: '/api/account-tokens', + }); + expect(listResponse.statusCode).toBe(200); + expect(listResponse.json()).toMatchObject([ + expect.objectContaining({ + id: tokenRows[0].id, + valueStatus: 'masked_pending', + }), + ]); + }); + + it('reuses an existing ready token when upstream only returns the matching masked token', async () => { + const { account } = await seedAccount({ siteStatus: 'active' }); + const fullToken = 'sk-real-token-1234'; + await db.insert(schema.accountTokens).values({ + accountId: account.id, + name: 'masked-only', + token: fullToken, + source: 'manual', + enabled: true, + isDefault: true, + tokenGroup: 'default', + valueStatus: 'ready' as any, + }).run(); + + getApiTokensMock.mockResolvedValue([ + { name: 'masked-only', key: maskToken(fullToken), enabled: true, tokenGroup: 'default' }, + ]); + getApiTokenMock.mockResolvedValue(null); + + const response = await app.inject({ + method: 'POST', + url: `/api/account-tokens/sync/${account.id}`, + }); + + expect(response.statusCode).toBe(200); + expect(response.json()).toMatchObject({ + success: true, + synced: true, + status: 'synced', + created: 0, + updated: 1, + maskedPending: 0, + total: 1, + }); + + const tokenRows = await db.select() + .from(schema.accountTokens) + .where(eq(schema.accountTokens.accountId, account.id)) + .all(); + expect(tokenRows).toHaveLength(1); + expect(tokenRows[0]).toMatchObject({ + name: 'masked-only', + token: fullToken, + source: 'sync', + enabled: true, + isDefault: true, + tokenGroup: 'default', + }); + expect((tokenRows[0] as any).valueStatus).toBe('ready'); + }); + + it('removes matching masked_pending placeholders after reusing a ready token', async () => { + const { account } = await seedAccount({ siteStatus: 'active' }); + const fullToken = 'sk-real-token-1234'; + const maskedToken = maskToken(fullToken); + + await db.insert(schema.accountTokens).values({ + accountId: account.id, + name: 'masked-only', + token: fullToken, + source: 'manual', + enabled: true, + isDefault: true, + tokenGroup: 'default', + valueStatus: 'ready' as any, + }).run(); + await db.insert(schema.accountTokens).values({ + accountId: account.id, + name: 'masked-only', + token: maskedToken, + source: 'sync', + enabled: false, + isDefault: false, + tokenGroup: 'default', + valueStatus: 'masked_pending' as any, + }).run(); + + getApiTokensMock.mockResolvedValue([ + { name: 'masked-only', key: maskedToken, enabled: true, tokenGroup: 'default' }, + ]); + getApiTokenMock.mockResolvedValue(null); + + const response = await app.inject({ + method: 'POST', + url: `/api/account-tokens/sync/${account.id}`, + }); + + expect(response.statusCode).toBe(200); + expect(response.json()).toMatchObject({ + success: true, + synced: true, + status: 'synced', + created: 0, + updated: 1, + maskedPending: 0, + total: 1, + }); + + const tokenRows = await db.select() + .from(schema.accountTokens) + .where(eq(schema.accountTokens.accountId, account.id)) + .all(); + expect(tokenRows).toHaveLength(1); + expect(tokenRows[0]).toMatchObject({ + name: 'masked-only', + token: fullToken, + source: 'sync', + enabled: true, + isDefault: true, + tokenGroup: 'default', + }); + expect((tokenRows[0] as any).valueStatus).toBe('ready'); + }); + + it('does not reuse a different ready token when another logical token shares the same masked value', async () => { + const { account } = await seedAccount({ siteStatus: 'active' }); + const firstFullToken = 'sk-real-token-1234'; + const secondFullToken = 'sk-real-zzzzz-1234'; + const sharedMaskedToken = maskToken(firstFullToken); + + expect(maskToken(secondFullToken)).toBe(sharedMaskedToken); + + await db.insert(schema.accountTokens).values({ + accountId: account.id, + name: 'first-token', + token: firstFullToken, + source: 'manual', + enabled: true, + isDefault: true, + tokenGroup: 'default', + valueStatus: 'ready' as any, + }).run(); + await db.insert(schema.accountTokens).values({ + accountId: account.id, + name: 'second-token', + token: sharedMaskedToken, + source: 'sync', + enabled: false, + isDefault: false, + tokenGroup: 'default', + valueStatus: 'masked_pending' as any, + }).run(); + + getApiTokensMock.mockResolvedValue([ + { name: 'second-token', key: sharedMaskedToken, enabled: true, tokenGroup: 'default' }, + ]); + getApiTokenMock.mockResolvedValue(null); + + const response = await app.inject({ + method: 'POST', + url: `/api/account-tokens/sync/${account.id}`, + }); + + expect(response.statusCode).toBe(200); + expect(response.json()).toMatchObject({ + success: true, + synced: true, + status: 'synced', + created: 0, + updated: 1, + maskedPending: 1, + total: 2, + }); + + const tokenRows = await db.select() + .from(schema.accountTokens) + .where(eq(schema.accountTokens.accountId, account.id)) + .all(); + expect(tokenRows).toHaveLength(2); + expect(tokenRows.find((row) => row.token === firstFullToken)).toMatchObject({ + name: 'first-token', + token: firstFullToken, + source: 'manual', + enabled: true, + isDefault: true, + tokenGroup: 'default', + }); + const maskedRow = tokenRows.find((row) => row.name === 'second-token'); + expect(maskedRow).toMatchObject({ + token: sharedMaskedToken, + source: 'sync', + enabled: false, + isDefault: false, + tokenGroup: 'default', + }); + expect((maskedRow as any)?.valueStatus).toBe('masked_pending'); + }); + + it('keeps fully ambiguous short masks as masked_pending instead of reusing a ready token', async () => { + const { account } = await seedAccount({ siteStatus: 'active' }); + const fullToken = 'sk-abcd'; + await db.insert(schema.accountTokens).values({ + accountId: account.id, + name: 'short-token', + token: fullToken, + source: 'manual', + enabled: true, + isDefault: true, + tokenGroup: 'default', + valueStatus: 'ready' as any, + }).run(); + + getApiTokensMock.mockResolvedValue([ + { name: 'short-token', key: maskToken(fullToken), enabled: true, tokenGroup: 'default' }, + ]); + getApiTokenMock.mockResolvedValue(null); + + const response = await app.inject({ + method: 'POST', + url: `/api/account-tokens/sync/${account.id}`, + }); + + expect(response.statusCode).toBe(200); + expect(response.json()).toMatchObject({ + success: true, + synced: true, + status: 'synced', + reason: 'upstream_masked_tokens', + created: 1, + updated: 0, + maskedPending: 1, + total: 2, + }); + + const tokenRows = await db.select() + .from(schema.accountTokens) + .where(eq(schema.accountTokens.accountId, account.id)) + .all(); + expect(tokenRows).toHaveLength(2); + expect(tokenRows.find((row) => row.token === fullToken)).toBeDefined(); + const maskedRow = tokenRows.find((row) => row.token === 'sk-***'); + expect(maskedRow).toMatchObject({ + name: 'short-token', + source: 'sync', + enabled: false, + isDefault: false, + tokenGroup: 'default', + }); + expect((maskedRow as any)?.valueStatus).toBe('masked_pending'); + }); + it('rejects sync and token management for apikey connections', async () => { const { account } = await seedAccount({ siteStatus: 'active', accessToken: '' }); await db.update(schema.accounts) @@ -450,4 +754,162 @@ describe('account tokens sync routes with site status', () => { const existing = await db.select().from(schema.accountTokens).where(eq(schema.accountTokens.id, token.id)).get(); expect(existing).toBeDefined(); }); + + it('rejects retrieving token value when stored token is masked', async () => { + const { account } = await seedAccount({ siteStatus: 'active' }); + const token = await db.insert(schema.accountTokens).values({ + accountId: account.id, + name: 'masked-token', + token: 'sk-mask***tail', + source: 'sync', + enabled: true, + isDefault: false, + valueStatus: 'masked_pending' as any, + }).returning().get(); + + const response = await app.inject({ + method: 'GET', + url: `/api/account-tokens/${token.id}/value`, + }); + + expect(response.statusCode).toBe(409); + expect(response.json()).toMatchObject({ + success: false, + }); + }); + + it('upgrades an existing masked_pending placeholder when upstream later returns the full token', async () => { + const { account } = await seedAccount({ siteStatus: 'active' }); + await db.insert(schema.accountTokens).values({ + accountId: account.id, + name: 'masked-only', + token: 'sk-abc***xyz', + source: 'sync', + enabled: false, + isDefault: false, + tokenGroup: 'default', + valueStatus: 'masked_pending' as any, + }).run(); + + getApiTokensMock.mockResolvedValue([ + { name: 'masked-only', key: 'sk-real-token-1234', enabled: true, tokenGroup: 'default' }, + ]); + getApiTokenMock.mockResolvedValue(null); + + const response = await app.inject({ + method: 'POST', + url: `/api/account-tokens/sync/${account.id}`, + }); + + expect(response.statusCode).toBe(200); + expect(response.json()).toMatchObject({ + success: true, + synced: true, + status: 'synced', + total: 1, + created: 0, + updated: 1, + }); + + const tokenRows = await db.select() + .from(schema.accountTokens) + .where(eq(schema.accountTokens.accountId, account.id)) + .all(); + expect(tokenRows).toHaveLength(1); + expect(tokenRows[0]).toMatchObject({ + name: 'masked-only', + token: 'sk-real-token-1234', + enabled: true, + }); + expect((tokenRows[0] as any).valueStatus).toBe('ready'); + }); + + it('does not allow setting a masked_pending placeholder as default', async () => { + const { account } = await seedAccount({ siteStatus: 'active' }); + const token = await db.insert(schema.accountTokens).values({ + accountId: account.id, + name: 'masked-token', + token: 'sk-mask***tail', + source: 'sync', + enabled: false, + isDefault: false, + valueStatus: 'masked_pending' as any, + }).returning().get(); + + const response = await app.inject({ + method: 'POST', + url: `/api/account-tokens/${token.id}/default`, + }); + + expect(response.statusCode).toBe(400); + expect(response.json()).toMatchObject({ + success: false, + message: expect.stringContaining('待补全令牌'), + }); + }); + + it('promotes a masked_pending placeholder to ready when a full token is saved', async () => { + const { account } = await seedAccount({ siteStatus: 'active' }); + const token = await db.insert(schema.accountTokens).values({ + accountId: account.id, + name: 'masked-token', + token: 'sk-mask***tail', + source: 'sync', + enabled: false, + isDefault: false, + valueStatus: 'masked_pending' as any, + }).returning().get(); + + const response = await app.inject({ + method: 'PUT', + url: `/api/account-tokens/${token.id}`, + payload: { + token: 'sk-real-token-updated', + enabled: true, + }, + }); + + expect(response.statusCode).toBe(200); + expect(response.json()).toMatchObject({ + success: true, + token: expect.objectContaining({ + id: token.id, + enabled: true, + valueStatus: 'ready', + }), + }); + + const latest = await db.select() + .from(schema.accountTokens) + .where(eq(schema.accountTokens.id, token.id)) + .get(); + expect(latest).toMatchObject({ + token: 'sk-real-token-updated', + enabled: true, + }); + expect((latest as any)?.valueStatus).toBe('ready'); + }); + + it('deletes masked_pending placeholders locally without calling upstream delete', async () => { + const { account } = await seedAccount({ siteStatus: 'active' }); + const token = await db.insert(schema.accountTokens).values({ + accountId: account.id, + name: 'masked-token', + token: 'sk-mask***tail', + source: 'sync', + enabled: false, + isDefault: false, + valueStatus: 'masked_pending' as any, + }).returning().get(); + + const response = await app.inject({ + method: 'DELETE', + url: `/api/account-tokens/${token.id}`, + }); + + expect(response.statusCode).toBe(200); + expect(deleteApiTokenMock).not.toHaveBeenCalled(); + const removed = await db.select().from(schema.accountTokens).where(eq(schema.accountTokens.id, token.id)).get(); + expect(removed).toBeUndefined(); + }); }); diff --git a/src/server/routes/api/accountTokens.ts b/src/server/routes/api/accountTokens.ts index 7ed061d3..ebffde40 100644 --- a/src/server/routes/api/accountTokens.ts +++ b/src/server/routes/api/accountTokens.ts @@ -2,17 +2,28 @@ import { and, eq } from 'drizzle-orm'; import { db, schema } from '../../db/index.js'; import { - ensureDefaultTokenForAccount, + ACCOUNT_TOKEN_VALUE_STATUS_MASKED_PENDING, + ACCOUNT_TOKEN_VALUE_STATUS_READY, + isMaskedPendingAccountToken, + isMaskedTokenValue, + isUsableAccountToken, listTokensWithRelations, normalizeTokenForDisplay, maskToken, repairDefaultToken, + resolveAccountTokenValueStatus, setDefaultToken, - syncTokensFromUpstream, } from '../../services/accountTokenService.js'; import { getAdapter } from '../../services/platforms/index.js'; -import { getCredentialModeFromExtraConfig, resolvePlatformUserId } from '../../services/accountExtraConfig.js'; +import { getCredentialModeFromExtraConfig, getProxyUrlFromExtraConfig, resolvePlatformUserId } from '../../services/accountExtraConfig.js'; import { startBackgroundTask } from '../../services/backgroundTaskService.js'; +import { withAccountProxyOverride } from '../../services/siteProxy.js'; +import { type ModelRefreshResult } from '../../services/modelService.js'; +import { + type CoverageBatchRebuildResult, + convergeAccountMutation, + refreshAccountCoverageBatch, +} from '../../services/accountMutationWorkflow.js'; type AccountWithSiteRow = { accounts: typeof schema.accounts.$inferSelect; @@ -32,10 +43,29 @@ type SyncExecutionResult = { synced: boolean; created: number; updated: number; + maskedPending?: number; + pendingTokenIds?: number[]; total: number; defaultTokenId?: number | null; }; +type CoverageRefreshFailureItem = { + accountId: number; + refreshed: false; + status: 'failed'; + errorCode: 'coverage_refresh_failed'; + errorMessage: string; + modelCount: 0; + modelsPreview: string[]; + reason: 'coverage_refresh_failed'; + tokenScanned: 0; + discoveredByCredential: false; + discoveredApiToken: false; +}; + +type CoverageRefreshItem = ModelRefreshResult | CoverageRefreshFailureItem; +type CoverageRefreshRebuildResult = CoverageBatchRebuildResult; + const TOKEN_SYNC_TIMEOUT_MS = 15_000; const SYNC_ALL_BATCH_SIZE = 3; @@ -198,7 +228,38 @@ async function executeAccountTokenSync(row: AccountWithSiteRow): Promise<SyncExe if (!row.accounts.accessToken) { if (row.accounts.apiToken) { - ensureDefaultTokenForAccount(accountId, row.accounts.apiToken, { name: 'default', source: 'legacy' }); + try { + const convergence = await convergeAccountMutation({ + accountId, + preferredApiToken: row.accounts.apiToken, + defaultTokenSource: 'legacy', + }); + if (convergence.defaultTokenId != null) { + return { + ...base, + status: 'synced', + reason: 'legacy_default_token_restored', + message: 'restored local default token from legacy api token', + synced: true, + created: 0, + updated: 0, + total: 0, + defaultTokenId: convergence.defaultTokenId, + }; + } + } catch (error: any) { + return { + ...base, + status: 'failed', + reason: 'sync_error', + message: error?.message || 'sync failed', + synced: false, + created: 0, + updated: 0, + total: 0, + defaultTokenId: null, + }; + } } return { ...base, @@ -229,15 +290,18 @@ async function executeAccountTokenSync(row: AccountWithSiteRow): Promise<SyncExe try { const platformUserId = resolvePlatformUserId(row.accounts.extraConfig, row.accounts.username); + const accountProxyUrl = getProxyUrlFromExtraConfig(row.accounts.extraConfig); let tokens = await withTimeout( - () => adapter.getApiTokens(row.sites.url, row.accounts.accessToken, platformUserId), + () => withAccountProxyOverride(accountProxyUrl, + () => adapter.getApiTokens(row.sites.url, row.accounts.accessToken, platformUserId)), TOKEN_SYNC_TIMEOUT_MS, `token sync timeout (${Math.round(TOKEN_SYNC_TIMEOUT_MS / 1000)}s)`, ); if (tokens.length === 0) { const fallback = await withTimeout( - () => adapter.getApiToken(row.sites.url, row.accounts.accessToken, platformUserId), + () => withAccountProxyOverride(accountProxyUrl, + () => adapter.getApiToken(row.sites.url, row.accounts.accessToken, platformUserId)), TOKEN_SYNC_TIMEOUT_MS, `token sync timeout (${Math.round(TOKEN_SYNC_TIMEOUT_MS / 1000)}s)`, ); @@ -260,7 +324,21 @@ async function executeAccountTokenSync(row: AccountWithSiteRow): Promise<SyncExe }; } - const synced = await syncTokensFromUpstream(accountId, tokens); + const convergence = await convergeAccountMutation({ + accountId, + upstreamTokens: tokens, + }); + const synced = convergence.tokenSync!; + if ((synced.maskedPending || 0) > 0) { + return { + ...base, + status: 'synced', + reason: 'upstream_masked_tokens', + message: `上游返回 ${synced.maskedPending} 条脱敏令牌,已保存为待补全记录,请手动补全明文 token。`, + synced: true, + ...synced, + }; + } return { ...base, status: 'synced', @@ -290,7 +368,7 @@ async function appendTokenSyncEvent(result: SyncExecutionResult) { ? 'info' : (result.status === 'skipped' ? 'warning' : 'error'); const detail = result.status === 'synced' - ? `新增 ${result.created},更新 ${result.updated},总数 ${result.total}` + ? `新增 ${result.created},更新 ${result.updated},待补全 ${result.maskedPending || 0},总数 ${result.total}` : (result.message || result.reason || 'sync skipped'); try { @@ -325,6 +403,12 @@ async function executeSyncAllAccountTokens() { results.push(...batchResults); } + const coverageRefresh = await refreshCoverageForAccounts( + results + .filter((item) => item.status === 'synced') + .map((item) => item.accountId), + ); + const summary = { total: results.length, synced: results.filter((item) => item.status === 'synced').length, @@ -334,7 +418,49 @@ async function executeSyncAllAccountTokens() { updated: results.reduce((acc, item) => acc + item.updated, 0), }; - return { summary, results }; + return { summary, results, coverageRefresh }; +} + +async function refreshCoverageForAccounts(accountIds: number[]) { + const result = await refreshAccountCoverageBatch({ + accountIds, + batchSize: SYNC_ALL_BATCH_SIZE, + mapFailure: buildCoverageRefreshFailureItem, + }); + + result.refresh.forEach((item) => { + if ((item as CoverageRefreshFailureItem).reason === 'coverage_refresh_failed') { + const failed = item as CoverageRefreshFailureItem; + console.warn(`[account-tokens] coverage refresh failed for account ${failed.accountId}: ${failed.errorMessage}`); + } + }); + if (result.rebuild && !result.rebuild.success) { + console.warn(`[account-tokens] token route rebuild failed after coverage refresh: ${result.rebuild.error}`); + } + + return { + refresh: result.refresh as CoverageRefreshItem[], + rebuild: result.rebuild as CoverageRefreshRebuildResult | null, + }; +} + +function buildCoverageRefreshFailureItem( + accountId: number, + errorMessage: string, +): CoverageRefreshFailureItem { + return { + accountId, + refreshed: false, + status: 'failed', + errorCode: 'coverage_refresh_failed', + errorMessage, + modelCount: 0, + modelsPreview: [], + reason: 'coverage_refresh_failed', + tokenScanned: 0, + discoveredByCredential: false, + discoveredApiToken: false, + }; } export async function accountTokensRoutes(app: FastifyInstance) { @@ -378,15 +504,25 @@ export async function accountTokensRoutes(app: FastifyInstance) { const existing = await db.select().from(schema.accountTokens) .where(eq(schema.accountTokens.accountId, body.accountId)) .all(); + const valueStatus = isMaskedTokenValue(tokenValue) + ? ACCOUNT_TOKEN_VALUE_STATUS_MASKED_PENDING + : ACCOUNT_TOKEN_VALUE_STATUS_READY; + const enabled = valueStatus === ACCOUNT_TOKEN_VALUE_STATUS_READY + ? (body.enabled ?? true) + : false; + const isDefault = valueStatus === ACCOUNT_TOKEN_VALUE_STATUS_READY + ? (body.isDefault ?? false) + : false; const inserted = await db.insert(schema.accountTokens).values({ accountId: body.accountId, name: (body.name || '').trim() || (existing.length === 0 ? 'default' : `token-${existing.length + 1}`), token: tokenValue, tokenGroup: (body.group || '').trim() || null, + valueStatus, source: body.source || 'manual', - enabled: body.enabled ?? true, - isDefault: body.isDefault ?? false, + enabled, + isDefault, createdAt: now, updatedAt: now, }).run(); @@ -399,13 +535,13 @@ export async function accountTokensRoutes(app: FastifyInstance) { return reply.code(500).send({ success: false, message: '创建令牌失败' }); } - if (body.isDefault || (existing.length === 0 && (body.enabled ?? true))) { + if (valueStatus === ACCOUNT_TOKEN_VALUE_STATUS_READY && (body.isDefault || (existing.length === 0 && enabled))) { await setDefaultToken(created.id); - } else if (existing.every((token) => !token.isDefault) && (body.enabled ?? true)) { + } else if (valueStatus === ACCOUNT_TOKEN_VALUE_STATUS_READY && existing.every((token) => !token.isDefault) && enabled) { await setDefaultToken(created.id); } - - return { success: true, token: created }; + const coverageRefresh = await refreshCoverageForAccounts([body.accountId]); + return { success: true, token: created, coverageRefresh }; } const account = row.accounts; @@ -456,20 +592,23 @@ export async function accountTokensRoutes(app: FastifyInstance) { } const platformUserId = resolvePlatformUserId(account.extraConfig, account.username); - const createdViaUpstream = await adapter.createApiToken( - site.url, - account.accessToken, - platformUserId, - { - name: asTrimmedString(body.name), - group: asTrimmedString(body.group), - unlimitedQuota, - remainQuota, - expiredTime, - allowIps: asTrimmedString(body.allowIps), - modelLimitsEnabled, - modelLimits: asTrimmedString(body.modelLimits), - }, + const createdViaUpstream = await withAccountProxyOverride( + getProxyUrlFromExtraConfig(account.extraConfig), + () => adapter.createApiToken( + site.url, + account.accessToken, + platformUserId, + { + name: asTrimmedString(body.name), + group: asTrimmedString(body.group), + unlimitedQuota, + remainQuota, + expiredTime, + allowIps: asTrimmedString(body.allowIps), + modelLimitsEnabled, + modelLimits: asTrimmedString(body.modelLimits), + }, + ), ); if (!createdViaUpstream) { return reply.code(502).send({ success: false, message: '站点创建令牌失败' }); @@ -484,6 +623,7 @@ export async function accountTokensRoutes(app: FastifyInstance) { if (syncResult.status === 'skipped') { return reply.code(502).send({ success: false, message: syncResult.message || '站点未返回可用令牌' }); } + const coverageRefresh = await refreshCoverageForAccounts([account.id]); const preferred = await db.select().from(schema.accountTokens) .where(and(eq(schema.accountTokens.accountId, account.id), eq(schema.accountTokens.isDefault, true))) @@ -497,6 +637,7 @@ export async function accountTokensRoutes(app: FastifyInstance) { success: true, createdViaUpstream: true, ...syncResult, + coverageRefresh, token, }; }); @@ -520,15 +661,21 @@ export async function accountTokensRoutes(app: FastifyInstance) { const account = row.accounts; const site = row.sites; const adapter = getAdapter(site.platform); - const shouldDeleteUpstream = !isSiteDisabled(site.status) && !!account.accessToken?.trim() && !!adapter; + const shouldDeleteUpstream = !isMaskedPendingAccountToken(existing) + && !isSiteDisabled(site.status) + && !!account.accessToken?.trim() + && !!adapter; if (shouldDeleteUpstream) { const platformUserId = resolvePlatformUserId(account.extraConfig, account.username); - const upstreamDeleted = await adapter!.deleteApiToken( - site.url, - account.accessToken, - existing.token, - platformUserId, + const upstreamDeleted = await withAccountProxyOverride( + getProxyUrlFromExtraConfig(account.extraConfig), + () => adapter!.deleteApiToken( + site.url, + account.accessToken, + existing.token, + platformUserId, + ), ); if (!upstreamDeleted) { return { success: false, message: '站点删除令牌失败,本地未删除' }; @@ -581,6 +728,10 @@ export async function accountTokensRoutes(app: FastifyInstance) { continue; } } else { + if (isMaskedPendingAccountToken(existing)) { + failedItems.push({ id, message: '待补全令牌不能修改启用状态,请先补全明文 token' }); + continue; + } await db.update(schema.accountTokens) .set({ enabled: action === 'enable', @@ -606,7 +757,7 @@ export async function accountTokensRoutes(app: FastifyInstance) { }; }); - app.put<{ Params: { id: string }; Body: { name?: string; token?: string; enabled?: boolean; isDefault?: boolean; source?: string } }>('/api/account-tokens/:id', async (request, reply) => { + app.put<{ Params: { id: string }; Body: { name?: string; token?: string; group?: string; enabled?: boolean; isDefault?: boolean; source?: string } }>('/api/account-tokens/:id', async (request, reply) => { const tokenId = Number.parseInt(request.params.id, 10); if (Number.isNaN(tokenId)) { return reply.code(400).send({ success: false, message: '令牌 ID 无效' }); @@ -627,6 +778,7 @@ export async function accountTokensRoutes(app: FastifyInstance) { const body = request.body; const updates: Record<string, unknown> = { updatedAt: new Date().toISOString() }; + let nextValueStatus = resolveAccountTokenValueStatus(existing); if (body.name !== undefined) { updates.name = (body.name || '').trim() || existing.name; @@ -638,11 +790,24 @@ export async function accountTokensRoutes(app: FastifyInstance) { return reply.code(400).send({ success: false, message: '令牌不能为空' }); } updates.token = tokenValue; + nextValueStatus = isMaskedTokenValue(tokenValue) + ? ACCOUNT_TOKEN_VALUE_STATUS_MASKED_PENDING + : ACCOUNT_TOKEN_VALUE_STATUS_READY; + updates.valueStatus = nextValueStatus; } - if (body.enabled !== undefined) updates.enabled = body.enabled; + if (body.group !== undefined) { + updates.tokenGroup = (body.group || '').trim() || null; + } + + if (nextValueStatus === ACCOUNT_TOKEN_VALUE_STATUS_MASKED_PENDING) { + updates.enabled = false; + updates.isDefault = false; + } else { + if (body.enabled !== undefined) updates.enabled = body.enabled; + if (body.isDefault !== undefined) updates.isDefault = body.isDefault; + } if (body.source !== undefined) updates.source = body.source; - if (body.isDefault !== undefined) updates.isDefault = body.isDefault; await db.update(schema.accountTokens).set(updates).where(eq(schema.accountTokens.id, tokenId)).run(); @@ -651,11 +816,11 @@ export async function accountTokensRoutes(app: FastifyInstance) { return reply.code(500).send({ success: false, message: '更新失败' }); } - if (body.isDefault === true) { + if (body.isDefault === true && isUsableAccountToken(latest)) { setDefaultToken(tokenId); - } else if (latest.isDefault && latest.enabled) { + } else if (latest.isDefault && isUsableAccountToken(latest)) { setDefaultToken(tokenId); - } else if (existing.isDefault && !latest.enabled) { + } else if (existing.isDefault && !isUsableAccountToken(latest)) { repairDefaultToken(existing.accountId); } else if (body.isDefault === false && existing.isDefault) { repairDefaultToken(existing.accountId); @@ -680,6 +845,9 @@ export async function accountTokensRoutes(app: FastifyInstance) { if (isApiKeyConnection(owner)) { return reply.code(400).send({ success: false, message: 'API Key 连接不支持管理账号令牌' }); } + if (isMaskedPendingAccountToken(tokenRow)) { + return reply.code(400).send({ success: false, message: '待补全令牌不能设为默认,请先补全明文 token' }); + } const success = await setDefaultToken(tokenId); if (!success) { return reply.code(404).send({ success: false, message: '令牌不存在' }); @@ -707,6 +875,13 @@ export async function accountTokensRoutes(app: FastifyInstance) { return reply.code(400).send({ success: false, message: 'API Key 连接不支持管理账号令牌' }); } + if (isMaskedPendingAccountToken(row.account_tokens) || isMaskedTokenValue(row.account_tokens.token)) { + return reply.code(409).send({ + success: false, + message: '当前仅保存了脱敏令牌,无法展开/复制。请在站点重新生成并同步,或手动更新为完整令牌。', + }); + } + const tokenValue = normalizeTokenForDisplay(row.account_tokens.token, row.sites.platform); return { success: true, @@ -748,7 +923,10 @@ export async function accountTokensRoutes(app: FastifyInstance) { try { const platformUserId = resolvePlatformUserId(account.extraConfig, account.username); - const groups = await adapter.getUserGroups(site.url, account.accessToken, platformUserId); + const groups = await withAccountProxyOverride( + getProxyUrlFromExtraConfig(account.extraConfig), + () => adapter.getUserGroups(site.url, account.accessToken, platformUserId), + ); const normalized = Array.from(new Set((groups || []).map((item) => String(item || '').trim()).filter(Boolean))); return { success: true, groups: normalized.length > 0 ? normalized : ['default'] }; } catch (error: any) { @@ -800,7 +978,8 @@ export async function accountTokensRoutes(app: FastifyInstance) { if (result.status === 'failed') { return reply.code(502).send({ success: false, message: result.message || '同步失败' }); } - return { success: true, ...result }; + const coverageRefresh = await refreshCoverageForAccounts([accountId]); + return { success: true, ...result, coverageRefresh }; }); app.post<{ Body?: { wait?: boolean } }>('/api/account-tokens/sync-all', async (request, reply) => { diff --git a/src/server/routes/api/accounts.add-background-task.test.ts b/src/server/routes/api/accounts.add-background-task.test.ts new file mode 100644 index 00000000..af304e30 --- /dev/null +++ b/src/server/routes/api/accounts.add-background-task.test.ts @@ -0,0 +1,178 @@ +import Fastify, { type FastifyInstance } from 'fastify'; +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +const verifyTokenMock = vi.fn(); +const getApiTokensMock = vi.fn(); +const refreshBalanceMock = vi.fn(); +const refreshModelsForAccountMock = vi.fn(); +const rebuildTokenRoutesFromAvailabilityMock = vi.fn(); +const ensureDefaultTokenForAccountMock = vi.fn(); +const syncTokensFromUpstreamMock = vi.fn(); + +vi.mock('../../services/platforms/index.js', () => ({ + getAdapter: () => ({ + verifyToken: (...args: unknown[]) => verifyTokenMock(...args), + getApiTokens: (...args: unknown[]) => getApiTokensMock(...args), + }), +})); + +vi.mock('../../services/balanceService.js', () => ({ + refreshBalance: (...args: unknown[]) => refreshBalanceMock(...args), +})); + +vi.mock('../../services/modelService.js', () => ({ + refreshModelsForAccount: (...args: unknown[]) => refreshModelsForAccountMock(...args), + rebuildTokenRoutesFromAvailability: (...args: unknown[]) => rebuildTokenRoutesFromAvailabilityMock(...args), +})); + +vi.mock('../../services/accountTokenService.js', () => ({ + ensureDefaultTokenForAccount: (...args: unknown[]) => ensureDefaultTokenForAccountMock(...args), + syncTokensFromUpstream: (...args: unknown[]) => syncTokensFromUpstreamMock(...args), +})); + +type DbModule = typeof import('../../db/index.js'); + +describe('accounts background initialization', () => { + let app: FastifyInstance; + let db: DbModule['db']; + let schema: DbModule['schema']; + let dataDir = ''; + let resetBackgroundTasks: (() => void) | null = null; + let getBackgroundTask: ((taskId: string) => { status: string } | null) | null = null; + + beforeAll(async () => { + dataDir = mkdtempSync(join(tmpdir(), 'metapi-accounts-background-init-')); + process.env.DATA_DIR = dataDir; + + await import('../../db/migrate.js'); + const dbModule = await import('../../db/index.js'); + const routesModule = await import('./accounts.js'); + const backgroundTaskModule = await import('../../services/backgroundTaskService.js'); + db = dbModule.db; + schema = dbModule.schema; + resetBackgroundTasks = backgroundTaskModule.__resetBackgroundTasksForTests; + getBackgroundTask = backgroundTaskModule.getBackgroundTask; + + app = Fastify(); + await app.register(routesModule.accountsRoutes); + }); + + beforeEach(async () => { + verifyTokenMock.mockReset(); + getApiTokensMock.mockReset(); + refreshBalanceMock.mockReset(); + refreshModelsForAccountMock.mockReset(); + rebuildTokenRoutesFromAvailabilityMock.mockReset(); + ensureDefaultTokenForAccountMock.mockReset(); + syncTokensFromUpstreamMock.mockReset(); + resetBackgroundTasks?.(); + + await db.delete(schema.proxyLogs).run(); + await db.delete(schema.checkinLogs).run(); + await db.delete(schema.routeChannels).run(); + await db.delete(schema.tokenRoutes).run(); + await db.delete(schema.tokenModelAvailability).run(); + await db.delete(schema.modelAvailability).run(); + await db.delete(schema.accountTokens).run(); + await db.delete(schema.events).run(); + await db.delete(schema.accounts).run(); + await db.delete(schema.sites).run(); + }); + + afterAll(async () => { + await app.close(); + if (dataDir) { + try { + rmSync(dataDir, { recursive: true, force: true }); + } catch {} + } + delete process.env.DATA_DIR; + }); + + it('returns immediately and queues background initialization when token sync is slow', async () => { + const site = await db.insert(schema.sites).values({ + name: 'Session Site', + url: 'https://session.example.com', + platform: 'new-api', + }).returning().get(); + + verifyTokenMock.mockResolvedValue({ + tokenType: 'session', + userInfo: { username: 'demo-user' }, + apiToken: 'sk-demo', + }); + ensureDefaultTokenForAccountMock.mockResolvedValue(undefined); + refreshBalanceMock.mockResolvedValue({ balance: 1, used: 0, quota: 1 }); + refreshModelsForAccountMock.mockResolvedValue(undefined); + rebuildTokenRoutesFromAvailabilityMock.mockResolvedValue(undefined); + syncTokensFromUpstreamMock.mockResolvedValue(undefined); + + let releaseTokens: ((value: Array<{ name: string; value: string }>) => void) | null = null; + const pendingTokens = new Promise<Array<{ name: string; value: string }>>((resolve) => { + releaseTokens = resolve; + }); + getApiTokensMock.mockReturnValue(pendingTokens); + + const responsePromise = app.inject({ + method: 'POST', + url: '/api/accounts', + payload: { + siteId: site.id, + accessToken: 'session-token', + }, + }); + + try { + const raceResult = await Promise.race([ + responsePromise.then(() => 'response'), + new Promise<'timeout'>((resolve) => setTimeout(() => resolve('timeout'), 25)), + ]); + + expect(raceResult).toBe('response'); + + const response = await responsePromise; + expect(response.statusCode).toBe(200); + + const body = response.json() as { + id: number; + queued?: boolean; + jobId?: string; + usernameDetected?: boolean; + apiTokenFound?: boolean; + }; + + expect(body).toMatchObject({ + queued: true, + usernameDetected: true, + apiTokenFound: true, + }); + expect(body.jobId).toBeTruthy(); + + const insertedAccounts = await db.select().from(schema.accounts).all(); + expect(insertedAccounts).toHaveLength(1); + expect(getBackgroundTask?.(body.jobId!)).toMatchObject({ + status: expect.stringMatching(/pending|running/), + }); + + releaseTokens?.([{ name: 'default', value: 'sk-demo' }]); + + for (let attempt = 0; attempt < 20; attempt += 1) { + const task = getBackgroundTask?.(body.jobId!); + if (task?.status === 'succeeded') break; + await new Promise((resolve) => setTimeout(resolve, 0)); + } + + expect(syncTokensFromUpstreamMock).toHaveBeenCalledTimes(1); + expect(refreshBalanceMock).toHaveBeenCalledTimes(1); + expect(refreshModelsForAccountMock).toHaveBeenCalledTimes(1); + expect(rebuildTokenRoutesFromAvailabilityMock).toHaveBeenCalledTimes(1); + expect(getBackgroundTask?.(body.jobId!)).toMatchObject({ status: 'succeeded' }); + } finally { + releaseTokens?.([]); + await responsePromise.catch(() => undefined); + } + }); +}); diff --git a/src/server/routes/api/accounts.credentialMode.test.ts b/src/server/routes/api/accounts.credentialMode.test.ts index 46702eb0..40d531ef 100644 --- a/src/server/routes/api/accounts.credentialMode.test.ts +++ b/src/server/routes/api/accounts.credentialMode.test.ts @@ -19,7 +19,7 @@ vi.mock('../../services/platforms/index.js', () => ({ type DbModule = typeof import('../../db/index.js'); -describe('accounts credential mode', () => { +describe('accounts credential mode', { timeout: 15_000 }, () => { let app: FastifyInstance; let db: DbModule['db']; let schema: DbModule['schema']; @@ -172,6 +172,62 @@ describe('accounts credential mode', () => { }); }); + it('marks codex oauth connection as direct-routed proxy-only connection without checkin/balance capabilities', async () => { + const site = await db.insert(schema.sites).values({ + name: 'Codex Site', + url: 'https://chatgpt.com/backend-api/codex', + platform: 'codex', + }).returning().get(); + + const account = await db.insert(schema.accounts).values({ + siteId: site.id, + username: 'codex-user@example.com', + accessToken: 'oauth-access-token', + apiToken: null, + status: 'active', + checkinEnabled: false, + extraConfig: JSON.stringify({ + credentialMode: 'session', + oauth: { + provider: 'codex', + accountId: 'chatgpt-account-123', + email: 'codex-user@example.com', + planType: 'plus', + }, + }), + }).returning().get(); + + await db.insert(schema.modelAvailability).values({ + accountId: account.id, + modelName: 'gpt-5.2-codex', + available: true, + checkedAt: '2026-03-16T12:00:00.000Z', + }).run(); + + const listResponse = await app.inject({ + method: 'GET', + url: '/api/accounts', + }); + expect(listResponse.statusCode).toBe(200); + + const list = listResponse.json() as Array<{ + id: number; + credentialMode?: string; + capabilities?: { + canCheckin?: boolean; + canRefreshBalance?: boolean; + proxyOnly?: boolean; + }; + }>; + const item = list.find((entry) => entry.id === account.id); + expect(item?.credentialMode).toBe('session'); + expect(item?.capabilities).toMatchObject({ + canCheckin: false, + canRefreshBalance: false, + proxyOnly: true, + }); + }); + it('stores managed refresh token for sub2api session account', async () => { verifyTokenMock.mockResolvedValueOnce({ tokenType: 'session', @@ -260,4 +316,37 @@ describe('accounts credential mode', () => { }; expect(parsedCleared.sub2apiAuth).toBeUndefined(); }); + + it('does not refresh models for pin-only account edits', async () => { + const site = await db.insert(schema.sites).values({ + name: 'Pinned Site', + url: 'https://pinned.example.com', + platform: 'new-api', + }).returning().get(); + const account = await db.insert(schema.accounts).values({ + siteId: site.id, + username: 'pinned-user', + accessToken: 'access-token', + status: 'active', + isPinned: false, + sortOrder: 0, + }).returning().get(); + + const response = await app.inject({ + method: 'PUT', + url: `/api/accounts/${account.id}`, + payload: { + isPinned: true, + sortOrder: 5, + }, + }); + + expect(response.statusCode).toBe(200); + expect(getModelsMock).not.toHaveBeenCalled(); + expect(verifyTokenMock).not.toHaveBeenCalled(); + + const updated = await db.select().from(schema.accounts).where(eq(schema.accounts.id, account.id)).get(); + expect(updated?.isPinned).toBe(true); + expect(updated?.sortOrder).toBe(5); + }); }); diff --git a/src/server/routes/api/accounts.login-shield.test.ts b/src/server/routes/api/accounts.login-shield.test.ts index 428c79bf..3633efe8 100644 --- a/src/server/routes/api/accounts.login-shield.test.ts +++ b/src/server/routes/api/accounts.login-shield.test.ts @@ -3,6 +3,7 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vites import { mkdtempSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; +import { resetRequestRateLimitStore } from '../../middleware/requestRateLimit.js'; const loginMock = vi.fn(); @@ -36,6 +37,7 @@ describe('accounts login shield detection', () => { beforeEach(async () => { loginMock.mockReset(); + resetRequestRateLimitStore(); await db.delete(schema.proxyLogs).run(); await db.delete(schema.checkinLogs).run(); @@ -82,4 +84,48 @@ describe('accounts login shield detection', () => { expect((body.message || '').toLowerCase()).toContain('shield'); expect(body.message || '').not.toContain('Unexpected token'); }); + + it('rate limits repeated login attempts from the same client ip', async () => { + loginMock.mockResolvedValue({ + success: false, + message: 'invalid credentials', + }); + + const site = await db.insert(schema.sites).values({ + name: 'AnyRouter', + url: 'https://anyrouter.example.com', + platform: 'new-api', + }).returning().get(); + + for (let attempt = 0; attempt < 5; attempt += 1) { + const response = await app.inject({ + method: 'POST', + url: '/api/accounts/login', + remoteAddress: '198.51.100.10', + payload: { + siteId: site.id, + username: 'demo-user', + password: 'demo-password', + }, + }); + expect(response.statusCode).toBe(200); + } + + const limited = await app.inject({ + method: 'POST', + url: '/api/accounts/login', + remoteAddress: '198.51.100.10', + payload: { + siteId: site.id, + username: 'demo-user', + password: 'demo-password', + }, + }); + + expect(limited.statusCode).toBe(429); + expect(limited.json()).toMatchObject({ + success: false, + message: '请求过于频繁,请稍后再试', + }); + }); }); diff --git a/src/server/routes/api/accounts.manual-models.test.ts b/src/server/routes/api/accounts.manual-models.test.ts new file mode 100644 index 00000000..5b253b94 --- /dev/null +++ b/src/server/routes/api/accounts.manual-models.test.ts @@ -0,0 +1,156 @@ +import Fastify, { type FastifyInstance } from 'fastify'; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; +import { mkdtempSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { eq } from 'drizzle-orm'; + +type DbModule = typeof import('../../db/index.js'); + +describe('accounts manual models endpoint', () => { + let app: FastifyInstance; + let db: DbModule['db']; + let schema: DbModule['schema']; + let dataDir = ''; + + beforeAll(async () => { + dataDir = mkdtempSync(join(tmpdir(), 'metapi-accounts-manual-models-')); + process.env.DATA_DIR = dataDir; + + await import('../../db/migrate.js'); + const dbModule = await import('../../db/index.js'); + const routesModule = await import('./accounts.js'); + db = dbModule.db; + schema = dbModule.schema; + + app = Fastify(); + await app.register(routesModule.accountsRoutes); + }); + + beforeEach(async () => { + await db.delete(schema.proxyLogs).run(); + await db.delete(schema.checkinLogs).run(); + await db.delete(schema.routeChannels).run(); + await db.delete(schema.tokenRoutes).run(); + await db.delete(schema.tokenModelAvailability).run(); + await db.delete(schema.modelAvailability).run(); + await db.delete(schema.accountTokens).run(); + await db.delete(schema.accounts).run(); + await db.delete(schema.sites).run(); + }); + + afterAll(async () => { + await app.close(); + delete process.env.DATA_DIR; + }); + + it('adds manual models and sets isManual to true', async () => { + const site = await db.insert(schema.sites).values({ + name: 'Test Site', + url: 'https://test.example.com', + platform: 'new-api', + }).returning().get(); + + const account = await db.insert(schema.accounts).values({ + siteId: site.id, + accessToken: 'test-token', + }).returning().get(); + + const response = await app.inject({ + method: 'POST', + url: `/api/accounts/${account.id}/models/manual`, + payload: { + models: ['gpt-4-manual', 'claude-3-manual'], + }, + }); + + expect(response.statusCode).toBe(200); + const body = response.json(); + expect(body.success).toBe(true); + + const models = await db.select().from(schema.modelAvailability).where( + eq(schema.modelAvailability.accountId, account.id) + ).all(); + + expect(models).toHaveLength(2); + expect(models.map(m => m.modelName).sort()).toEqual(['claude-3-manual', 'gpt-4-manual']); + expect(models[0]?.isManual).toBe(true); + expect(models[1]?.isManual).toBe(true); + }); + + it('updates existing synced models to manual if provided', async () => { + const site = await db.insert(schema.sites).values({ + name: 'Test Site', + url: 'https://test.example.com', + platform: 'new-api', + }).returning().get(); + + const account = await db.insert(schema.accounts).values({ + siteId: site.id, + accessToken: 'test-token', + }).returning().get(); + + // Already-synced model that is NOT manual + await db.insert(schema.modelAvailability).values({ + accountId: account.id, + modelName: 'gpt-existing', + available: true, + }); + + const response = await app.inject({ + method: 'POST', + url: `/api/accounts/${account.id}/models/manual`, + payload: { + models: ['gpt-existing', 'gpt-new'], + }, + }); + + expect(response.statusCode).toBe(200); + + const models = await db.select().from(schema.modelAvailability) + .where(eq(schema.modelAvailability.accountId, account.id)) + .all(); + + expect(models).toHaveLength(2); + const existing = models.find(m => m.modelName === 'gpt-existing'); + const newModel = models.find(m => m.modelName === 'gpt-new'); + + expect(existing?.isManual).toBe(true); // Should be updated + expect(newModel?.isManual).toBe(true); + }); + + it('fails if account does not exist', async () => { + const response = await app.inject({ + method: 'POST', + url: '/api/accounts/999/models/manual', + payload: { + models: ['gpt-4-manual'], + }, + }); + + expect(response.statusCode).toBe(404); + }); + + it('returns validation error for empty models array', async () => { + const site = await db.insert(schema.sites).values({ + name: 'Test Site', + url: 'https://test.example.com', + platform: 'new-api', + }).returning().get(); + + const account = await db.insert(schema.accounts).values({ + siteId: site.id, + accessToken: 'test-token', + }).returning().get(); + + const response = await app.inject({ + method: 'POST', + url: `/api/accounts/${account.id}/models/manual`, + payload: { + models: [], + }, + }); + + expect(response.statusCode).toBe(400); + }); +}); diff --git a/src/server/routes/api/accounts.rebind-session.test.ts b/src/server/routes/api/accounts.rebind-session.test.ts index da558d46..f754eefc 100644 --- a/src/server/routes/api/accounts.rebind-session.test.ts +++ b/src/server/routes/api/accounts.rebind-session.test.ts @@ -15,7 +15,7 @@ vi.mock('../../services/platforms/index.js', () => ({ type DbModule = typeof import('../../db/index.js'); -describe('accounts rebind-session api', () => { +describe('accounts rebind-session api', { timeout: 15_000 }, () => { let app: FastifyInstance; let db: DbModule['db']; let schema: DbModule['schema']; diff --git a/src/server/routes/api/accounts.skip-model-fetch.test.ts b/src/server/routes/api/accounts.skip-model-fetch.test.ts new file mode 100644 index 00000000..6e29e2d9 --- /dev/null +++ b/src/server/routes/api/accounts.skip-model-fetch.test.ts @@ -0,0 +1,125 @@ +import Fastify, { type FastifyInstance } from 'fastify'; +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +const getModelsMock = vi.fn(); + +vi.mock('../../services/platforms/index.js', () => ({ + getAdapter: () => ({ + getModels: (...args: unknown[]) => getModelsMock(...args), + }), +})); + +type DbModule = typeof import('../../db/index.js'); + +describe('accounts skipModelFetch behavior', () => { + let app: FastifyInstance; + let db: DbModule['db']; + let schema: DbModule['schema']; + let dataDir = ''; + + beforeAll(async () => { + dataDir = mkdtempSync(join(tmpdir(), 'metapi-accounts-skip-model-')); + process.env.DATA_DIR = dataDir; + + await import('../../db/migrate.js'); + const dbModule = await import('../../db/index.js'); + const routesModule = await import('./accounts.js'); + db = dbModule.db; + schema = dbModule.schema; + + app = Fastify(); + await app.register(routesModule.accountsRoutes); + }); + + beforeEach(async () => { + getModelsMock.mockReset(); + + await db.delete(schema.proxyLogs).run(); + await db.delete(schema.checkinLogs).run(); + await db.delete(schema.routeChannels).run(); + await db.delete(schema.tokenRoutes).run(); + await db.delete(schema.tokenModelAvailability).run(); + await db.delete(schema.modelAvailability).run(); + await db.delete(schema.accountTokens).run(); + await db.delete(schema.accounts).run(); + await db.delete(schema.sites).run(); + }); + + afterAll(async () => { + await app.close(); + if (dataDir) { + try { + rmSync(dataDir, { recursive: true, force: true }); + } catch { } + } + delete process.env.DATA_DIR; + }); + + it('skips model fetching when skipModelFetch is true under apikey credentialMode', async () => { + getModelsMock.mockRejectedValueOnce(new Error('Should not be called')); + + const site = await db.insert(schema.sites).values({ + name: 'API Key Site', + url: 'https://apikey.example.com', + platform: 'new-api', + }).returning().get(); + + const response = await app.inject({ + method: 'POST', + url: '/api/accounts', + payload: { + siteId: site.id, + accessToken: 'sk-test-skip-fetch', + credentialMode: 'apikey', + skipModelFetch: true, + }, + }); + + expect(response.statusCode).toBe(200); + // getModels should NOT be called when skipModelFetch is true + expect(getModelsMock).toHaveBeenCalledTimes(0); + + const accounts = await db.select().from(schema.accounts).all(); + expect(accounts).toHaveLength(1); + expect(accounts[0]?.apiToken).toBe('sk-test-skip-fetch'); + + // Model availability should be empty initially (background task might not have populated it yet or failed) + const models = await db.select().from(schema.modelAvailability).all(); + expect(models).toHaveLength(0); + }); + + it('still calls getModels when skipModelFetch is false', async () => { + getModelsMock.mockResolvedValue(['gpt-4']); + + const site = await db.insert(schema.sites).values({ + name: 'API Key Site', + url: 'https://apikey.example.com', + platform: 'new-api', + }).returning().get(); + + const response = await app.inject({ + method: 'POST', + url: '/api/accounts', + payload: { + siteId: site.id, + accessToken: 'sk-test-normal-fetch', + credentialMode: 'apikey', + skipModelFetch: false, + }, + }); + + expect(response.statusCode).toBe(200); + // getModels is called TWICE: once for block validation, once asynchronously by refreshModelsForAccount + expect(getModelsMock).toHaveBeenCalledTimes(2); + + const accounts = await db.select().from(schema.accounts).all(); + expect(accounts).toHaveLength(1); + expect(accounts[0]?.apiToken).toBe('sk-test-normal-fetch'); + + // Model availability should be populated since getModels was called (which refreshModels uses later or handled directly) + // Actually our POST /api/accounts triggers rebuildTokenRoutesFromAvailability and refreshModelsForAccount asynchronously, so models might not be populated synchronously, but the mock should be called. + }); +}); diff --git a/src/server/routes/api/accounts.ts b/src/server/routes/api/accounts.ts index 11ac19e0..9286cdf8 100644 --- a/src/server/routes/api/accounts.ts +++ b/src/server/routes/api/accounts.ts @@ -1,13 +1,17 @@ import { FastifyInstance } from 'fastify'; -import { db, schema } from '../../db/index.js'; +import { db, schema, runtimeDbDialect } from '../../db/index.js'; import { and, eq, gte, lt, sql } from 'drizzle-orm'; import { refreshBalance } from '../../services/balanceService.js'; import { getAdapter } from '../../services/platforms/index.js'; -import { refreshModelsForAccount, rebuildTokenRoutesFromAvailability } from '../../services/modelService.js'; -import { ensureDefaultTokenForAccount, syncTokensFromUpstream } from '../../services/accountTokenService.js'; +import { + convergeAccountMutation, + rebuildRoutesBestEffort, +} from '../../services/accountMutationWorkflow.js'; import { getCredentialModeFromExtraConfig, + getProxyUrlFromExtraConfig, guessPlatformUserIdFromUsername, + hasOauthProvider, getSub2ApiAuthFromExtraConfig, mergeAccountExtraConfig, normalizeCredentialMode as normalizeCredentialModeInput, @@ -25,7 +29,8 @@ import { type RuntimeHealthState, } from '../../services/accountHealthService.js'; import { appendSessionTokenRebindHint } from '../../services/alertRules.js'; -import { withSiteRecordProxyRequestInit } from '../../services/siteProxy.js'; +import { parseSiteProxyUrlInput, withAccountProxyOverride, withSiteRecordProxyRequestInit } from '../../services/siteProxy.js'; +import { createRateLimitGuard } from '../../middleware/requestRateLimit.js'; type AccountWithSiteRow = { accounts: typeof schema.accounts.$inferSelect; @@ -47,8 +52,31 @@ type AccountCapabilities = { proxyOnly: boolean; }; +type AccountInitializationParams = { + accountId: number; + site: typeof schema.sites.$inferSelect; + adapter: NonNullable<ReturnType<typeof getAdapter>>; + tokenType: 'session' | 'apikey' | 'unknown'; + accessToken: string; + apiToken: string; + platformUserId?: number; + skipModelFetch?: boolean; +}; + type VerifyFailureReason = 'needs-user-id' | 'invalid-user-id' | 'shield-blocked' | null; +const limitAccountLogin = createRateLimitGuard({ + bucket: 'accounts-login', + max: 5, + windowMs: 60_000, +}); + +const limitAccountVerifyToken = createRateLimitGuard({ + bucket: 'accounts-verify-token', + max: 5, + windowMs: 60_000, +}); + function hasSessionTokenValue(value: string | null | undefined): boolean { return typeof value === 'string' && value.trim().length > 0; } @@ -66,7 +94,15 @@ function resolveStoredCredentialMode(account: typeof schema.accounts.$inferSelec function buildCapabilitiesFromCredentialMode( credentialMode: AccountCredentialMode, hasSessionToken: boolean, + extraConfig?: string | null, ): AccountCapabilities { + if (hasOauthProvider(extraConfig)) { + return { + canCheckin: false, + canRefreshBalance: false, + proxyOnly: true, + }; + } const sessionCapable = credentialMode === 'session' ? hasSessionToken : (credentialMode === 'apikey' ? false : hasSessionToken); @@ -79,7 +115,7 @@ function buildCapabilitiesFromCredentialMode( function buildCapabilitiesForAccount(account: typeof schema.accounts.$inferSelect): AccountCapabilities { const credentialMode = resolveStoredCredentialMode(account); - return buildCapabilitiesFromCredentialMode(credentialMode, hasSessionTokenValue(account.accessToken)); + return buildCapabilitiesFromCredentialMode(credentialMode, hasSessionTokenValue(account.accessToken), account.extraConfig); } function normalizeBatchIds(input: unknown): number[] { @@ -101,6 +137,67 @@ function normalizePinnedFlag(input: unknown): boolean | null { return null; } +async function initializeAccountInBackground({ + accountId, + site, + adapter, + tokenType, + accessToken, + apiToken, + platformUserId, + skipModelFetch, +}: AccountInitializationParams) { + const summary = { + accountId, + syncedTokenCount: 0, + refreshedBalance: false, + refreshedModels: false, + rebuiltRoutes: false, + }; + + let fetchedUpstreamTokens: Array<{ name?: string | null; key?: string | null; enabled?: boolean | null; tokenGroup?: string | null }> = []; + if (tokenType === 'session' && accessToken) { + try { + const syncedTokens = await adapter.getApiTokens(site.url, accessToken, platformUserId); + summary.syncedTokenCount = Array.isArray(syncedTokens) ? syncedTokens.length : 0; + fetchedUpstreamTokens = Array.isArray(syncedTokens) ? syncedTokens : []; + } catch {} + } + + const convergence = await convergeAccountMutation({ + accountId, + preferredApiToken: tokenType === 'session' ? apiToken : null, + defaultTokenSource: 'manual', + ensurePreferredTokenBeforeSync: tokenType === 'session', + upstreamTokens: fetchedUpstreamTokens, + refreshBalance: tokenType === 'session', + refreshModels: skipModelFetch !== true, + rebuildRoutes: skipModelFetch !== true, + continueOnError: true, + }); + summary.refreshedBalance = convergence.refreshedBalance; + summary.refreshedModels = convergence.refreshedModels; + summary.rebuiltRoutes = convergence.rebuiltRoutes; + + return summary; +} + +function buildQueuedAccountInitializationMessage( + tokenType: 'session' | 'apikey' | 'unknown', + skipModelFetch?: boolean, +) { + if (tokenType === 'session' && skipModelFetch === true) { + return '账号已添加,后台正在同步令牌和余额信息。'; + } + if (tokenType === 'session') { + return '账号已添加,后台正在同步令牌、余额和模型信息。'; + } + if (skipModelFetch === true) { + return '已添加为 API Key 账号(可用于代理转发)。'; + } + return '已添加为 API Key 账号,后台正在同步模型和路由信息。'; +} + function normalizeSortOrder(input: unknown): number | null { if (input === undefined || input === null || input === '') return null; const parsed = Number.parseInt(String(input), 10); @@ -397,6 +494,7 @@ export async function accountsRoutes(app: FastifyInstance) { capabilities: buildCapabilitiesFromCredentialMode( credentialMode, hasSessionTokenValue(r.accounts.accessToken), + r.accounts.extraConfig, ), todaySpend: Math.round((spendByAccount[r.accounts.id] || 0) * 1_000_000) / 1_000_000, todayReward: Math.round(estimateRewardWithTodayIncomeFallback({ @@ -413,6 +511,7 @@ export async function accountsRoutes(app: FastifyInstance) { sessionCapable: buildCapabilitiesFromCredentialMode( credentialMode, hasSessionTokenValue(r.accounts.accessToken), + r.accounts.extraConfig, ).canRefreshBalance, hasDiscoveredModels: (modelCountByAccount[r.accounts.id] || 0) > 0, }), @@ -421,7 +520,10 @@ export async function accountsRoutes(app: FastifyInstance) { }); // Login to a site and auto-create account - app.post<{ Body: { siteId: number; username: string; password: string } }>('/api/accounts/login', async (request) => { + app.post<{ Body: { siteId: number; username: string; password: string } }>( + '/api/accounts/login', + { preHandler: [limitAccountLogin] }, + async (request) => { const { siteId, username, password } = request.body; // Get site info @@ -504,22 +606,16 @@ export async function accountsRoutes(app: FastifyInstance) { return { success: false, message: 'account create failed' }; } - if (apiTokens.length > 0) { - try { - await syncTokensFromUpstream(result.id, apiTokens); - } catch { } - } else if (preferredApiToken) { - try { - await ensureDefaultTokenForAccount(result.id, preferredApiToken, { name: 'default', source: 'sync' }); - } catch { } - } - - // Auto-refresh balance - try { await refreshBalance(result.id); } catch { } - try { - await refreshModelsForAccount(result.id); - await rebuildTokenRoutesFromAvailability(); - } catch { } + await convergeAccountMutation({ + accountId: result.id, + preferredApiToken, + defaultTokenSource: 'sync', + upstreamTokens: apiTokens, + refreshBalance: true, + refreshModels: true, + rebuildRoutes: true, + continueOnError: true, + }); const account = await db.select().from(schema.accounts).where(eq(schema.accounts.id, result.id)).get(); return { @@ -529,10 +625,14 @@ export async function accountsRoutes(app: FastifyInstance) { tokenCount: apiTokens.length, reusedAccount: !!existing, }; - }); + }, + ); // Verify credentials against a site. - app.post<{ Body: { siteId: number; accessToken: string; platformUserId?: number; credentialMode?: AccountCredentialMode } }>('/api/accounts/verify-token', async (request) => { + app.post<{ Body: { siteId: number; accessToken: string; platformUserId?: number; credentialMode?: AccountCredentialMode } }>( + '/api/accounts/verify-token', + { preHandler: [limitAccountVerifyToken] }, + async (request) => { const { siteId, platformUserId } = request.body; const accessToken = (request.body.accessToken || '').trim(); const credentialMode = resolveRequestedCredentialMode(request.body.credentialMode); @@ -831,7 +931,8 @@ export async function accountsRoutes(app: FastifyInstance) { ? 'Session Token 验证失败' : 'Token invalid: cannot use it as session cookie or API key', }; - }); + }, + ); app.post<{ Params: { id: string }; Body: { accessToken: string; platformUserId?: number; refreshToken?: string; tokenExpiresAt?: number | string } }>( '/api/accounts/:id/rebind-session', @@ -869,7 +970,10 @@ export async function accountsRoutes(app: FastifyInstance) { let verifyResult: any; try { - verifyResult = await adapter.verifyToken(site.url, nextAccessToken, candidatePlatformUserId); + verifyResult = await withAccountProxyOverride( + getProxyUrlFromExtraConfig(account.extraConfig), + () => adapter.verifyToken(site.url, nextAccessToken, candidatePlatformUserId), + ); } catch (err: any) { return reply.code(400).send({ success: false, @@ -927,19 +1031,15 @@ export async function accountsRoutes(app: FastifyInstance) { await db.update(schema.accounts).set(updates).where(eq(schema.accounts.id, accountId)).run(); - if (nextApiToken) { - try { - await ensureDefaultTokenForAccount(accountId, nextApiToken, { name: 'default', source: 'sync' }); - } catch {} - } - - try { - await refreshBalance(accountId); - } catch {} - try { - await refreshModelsForAccount(accountId); - await rebuildTokenRoutesFromAvailability(); - } catch {} + await convergeAccountMutation({ + accountId, + preferredApiToken: nextApiToken, + defaultTokenSource: 'sync', + refreshBalance: true, + refreshModels: true, + rebuildRoutes: true, + continueOnError: true, + }); const latest = await db.select().from(schema.accounts).where(eq(schema.accounts.id, accountId)).get(); return { @@ -947,14 +1047,14 @@ export async function accountsRoutes(app: FastifyInstance) { account: latest, tokenType: 'session', credentialMode: 'session', - capabilities: latest ? buildCapabilitiesForAccount(latest) : buildCapabilitiesFromCredentialMode('session', true), + capabilities: latest ? buildCapabilitiesForAccount(latest) : buildCapabilitiesFromCredentialMode('session', true, null), apiTokenFound: !!nextApiToken, }; }, ); // Add an account (manual credential input) - app.post<{ Body: { siteId: number; username?: string; accessToken: string; apiToken?: string; platformUserId?: number; checkinEnabled?: boolean; credentialMode?: AccountCredentialMode; refreshToken?: string; tokenExpiresAt?: number | string } }>('/api/accounts', async (request, reply) => { + app.post<{ Body: { siteId: number; username?: string; accessToken: string; apiToken?: string; platformUserId?: number; checkinEnabled?: boolean; credentialMode?: AccountCredentialMode; refreshToken?: string; tokenExpiresAt?: number | string; skipModelFetch?: boolean } }>('/api/accounts', async (request, reply) => { const body = request.body; const site = await db.select().from(schema.sites).where(eq(schema.sites.id, body.siteId)).get(); if (!site) { @@ -979,29 +1079,35 @@ export async function accountsRoutes(app: FastifyInstance) { let verifiedModels: string[] = []; if (credentialMode === 'apikey') { - try { - const models = await adapter.getModels(site.url, rawAccessToken, body.platformUserId); - verifiedModels = Array.isArray(models) - ? models.filter((item) => typeof item === 'string' && item.trim().length > 0) - : []; - } catch (err: any) { - return reply.code(400).send({ - success: false, - message: err?.message || 'API Key 验证失败', - }); - } + if (body.skipModelFetch === true) { + tokenType = 'apikey'; + accessToken = ''; + if (!apiToken) apiToken = rawAccessToken; + } else { + try { + const models = await adapter.getModels(site.url, rawAccessToken, body.platformUserId); + verifiedModels = Array.isArray(models) + ? models.filter((item) => typeof item === 'string' && item.trim().length > 0) + : []; + } catch (err: any) { + return reply.code(400).send({ + success: false, + message: err?.message || 'API Key 验证失败', + }); + } - if (verifiedModels.length === 0) { - return reply.code(400).send({ - success: false, - requiresVerification: true, - message: 'API Key 验证失败:未获取到可用模型', - }); - } + if (verifiedModels.length === 0) { + return reply.code(400).send({ + success: false, + requiresVerification: true, + message: 'API Key 验证失败:未获取到可用模型', + }); + } - tokenType = 'apikey'; - accessToken = ''; - if (!apiToken) apiToken = rawAccessToken; + tokenType = 'apikey'; + accessToken = ''; + if (!apiToken) apiToken = rawAccessToken; + } } else { let verifyResult: any; try { @@ -1079,35 +1185,40 @@ export async function accountsRoutes(app: FastifyInstance) { return reply.code(500).send({ success: false, message: '创建账号失败' }); } - if (tokenType === 'session' && apiToken) { - try { - await ensureDefaultTokenForAccount(result.id, apiToken, { name: 'default', source: 'manual' }); - } catch { } - } - - if (tokenType === 'session' && accessToken) { - try { - const syncedTokens = await adapter.getApiTokens(site.url, accessToken, resolvedPlatformUserId); - if (syncedTokens.length > 0) { - await syncTokensFromUpstream(result.id, syncedTokens); - } - } catch { } - } - - // Try to refresh balance - if (tokenType === 'session') { - try { await refreshBalance(result.id); } catch { } + const shouldQueueInitialization = tokenType === 'session' || body.skipModelFetch !== true; + let queuedTaskId: string | undefined; + let queuedMessage: string | undefined; + if (shouldQueueInitialization) { + const taskTitle = `初始化连接 #${result.id}`; + const { task } = startBackgroundTask( + { + type: 'account-init', + title: taskTitle, + dedupeKey: `account-init-${result.id}`, + notifyOnFailure: true, + successMessage: () => `${taskTitle}已完成`, + failureMessage: (currentTask) => `${taskTitle}失败:${currentTask.error || 'unknown error'}`, + }, + async () => initializeAccountInBackground({ + accountId: result.id, + site, + adapter, + tokenType, + accessToken, + apiToken, + platformUserId: resolvedPlatformUserId, + skipModelFetch: body.skipModelFetch, + }), + ); + queuedTaskId = task.id; + queuedMessage = buildQueuedAccountInitializationMessage(tokenType, body.skipModelFetch); } - try { - await refreshModelsForAccount(result.id); - await rebuildTokenRoutesFromAvailability(); - } catch { } const account = await db.select().from(schema.accounts).where(eq(schema.accounts.id, result.id)).get(); const finalCredentialMode = account ? resolveStoredCredentialMode(account) : resolvedCredentialMode; const capabilities = account ? buildCapabilitiesForAccount(account) - : buildCapabilitiesFromCredentialMode(finalCredentialMode, tokenType === 'session'); + : buildCapabilitiesFromCredentialMode(finalCredentialMode, tokenType === 'session', null); return { ...account, tokenType, @@ -1116,6 +1227,9 @@ export async function accountsRoutes(app: FastifyInstance) { modelCount: verifiedModels.length, apiTokenFound: !!apiToken, usernameDetected: !!(!body.username && username), + queued: !!queuedTaskId, + jobId: queuedTaskId, + message: queuedMessage, }; }); @@ -1179,6 +1293,18 @@ export async function accountsRoutes(app: FastifyInstance) { updates.sortOrder = normalizedSortOrder; } + if (Object.prototype.hasOwnProperty.call(body, 'proxyUrl')) { + const baseExtraConfig = typeof updates.extraConfig === 'string' + ? updates.extraConfig : account.extraConfig; + const { present, valid, proxyUrl: normalizedProxy } = parseSiteProxyUrlInput(body.proxyUrl); + if (present && !valid) { + return reply.code(400).send({ message: 'Invalid proxy URL format' }); + } + updates.extraConfig = mergeAccountExtraConfig(baseExtraConfig, { + proxyUrl: normalizedProxy ?? undefined, + }); + } + updates.updatedAt = new Date().toISOString(); await db.update(schema.accounts).set(updates).where(eq(schema.accounts.id, id)).run(); @@ -1189,17 +1315,21 @@ export async function accountsRoutes(app: FastifyInstance) { explicitNextMode && explicitNextMode !== 'auto' ? explicitNextMode : (hasSessionTokenValue(nextAccessToken) ? 'session' : 'apikey'); - - if (nextCredentialMode !== 'apikey' && typeof updates.apiToken === 'string' && updates.apiToken.trim()) { - try { - await ensureDefaultTokenForAccount(id, updates.apiToken, { name: 'default', source: 'manual' }); - } catch { } - } - - try { - await refreshModelsForAccount(id); - await rebuildTokenRoutesFromAvailability(); - } catch { } + const needsModelRefresh = + Object.prototype.hasOwnProperty.call(body, 'accessToken') + || Object.prototype.hasOwnProperty.call(body, 'apiToken') + || Object.prototype.hasOwnProperty.call(body, 'extraConfig') + || Object.prototype.hasOwnProperty.call(body, 'proxyUrl') + || wantsManagedSub2ApiAuthPatch; + + await convergeAccountMutation({ + accountId: id, + preferredApiToken: nextCredentialMode !== 'apikey' ? updates.apiToken : null, + defaultTokenSource: 'manual', + refreshModels: needsModelRefresh, + rebuildRoutes: true, + continueOnError: true, + }); return await db.select().from(schema.accounts).where(eq(schema.accounts.id, id)).get(); }); @@ -1208,9 +1338,7 @@ export async function accountsRoutes(app: FastifyInstance) { app.delete<{ Params: { id: string } }>('/api/accounts/:id', async (request) => { const id = parseInt(request.params.id); await db.delete(schema.accounts).where(eq(schema.accounts.id, id)).run(); - try { - await rebuildTokenRoutesFromAvailability(); - } catch { } + await rebuildRoutesBestEffort(); return { success: true }; }); @@ -1264,9 +1392,7 @@ export async function accountsRoutes(app: FastifyInstance) { } if (shouldRebuildRoutes) { - try { - await rebuildTokenRoutesFromAvailability(); - } catch { } + await rebuildRoutesBestEffort(); } return { @@ -1343,4 +1469,143 @@ export async function accountsRoutes(app: FastifyInstance) { return { message: err?.message || 'failed to fetch balance' }; } }); + + // Get model list for an account (available models + disabled status at site level) + app.get<{ Params: { id: string } }>('/api/accounts/:id/models', async (request, reply) => { + const accountId = parseInt(request.params.id, 10); + if (!Number.isFinite(accountId) || accountId <= 0) { + return reply.code(400).send({ message: '账号 ID 无效' }); + } + + const account = await db.select().from(schema.accounts) + .innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id)) + .where(eq(schema.accounts.id, accountId)) + .get(); + + if (!account) { + return reply.code(404).send({ message: '账号不存在' }); + } + + const siteId = account.accounts.siteId; + + // Get available models for this account + const modelRows = await db.select({ + modelName: schema.modelAvailability.modelName, + available: schema.modelAvailability.available, + latencyMs: schema.modelAvailability.latencyMs, + isManual: schema.modelAvailability.isManual, + }).from(schema.modelAvailability) + .where(eq(schema.modelAvailability.accountId, accountId)) + .all(); + + // Get disabled models for this site + const disabledRows = await db.select({ + modelName: schema.siteDisabledModels.modelName, + }).from(schema.siteDisabledModels) + .where(eq(schema.siteDisabledModels.siteId, siteId)) + .all(); + + const disabledSet = new Set(disabledRows.map((r) => r.modelName)); + + const models = modelRows + .filter((r) => r.available) + .map((r) => ({ + name: r.modelName, + latencyMs: r.latencyMs, + disabled: disabledSet.has(r.modelName), + isManual: !!r.isManual, + })) + .sort((a, b) => a.name.localeCompare(b.name)); + + return { + siteId, + siteName: account.sites.name, + models, + totalCount: models.length, + disabledCount: models.filter((m) => m.disabled).length, + }; + }); + + // Add models manually to an account + app.post<{ Params: { id: string }; Body: { models: string[] } }>('/api/accounts/:id/models/manual', async (request, reply) => { + const accountId = parseInt(request.params.id, 10); + if (!Number.isFinite(accountId) || accountId <= 0) { + return reply.code(400).send({ message: '账号 ID 无效' }); + } + + const { models } = request.body; + if (!Array.isArray(models) || models.length === 0) { + return reply.code(400).send({ message: '模型列表不能为空' }); + } + + const normalizedModels = Array.from(new Set(models.map(m => String(m).trim()).filter(m => m.length > 0))); + if (normalizedModels.length === 0) { + return reply.code(400).send({ message: '模型列表不能为空' }); + } + + const account = await db.select().from(schema.accounts) + .where(eq(schema.accounts.id, accountId)) + .get(); + + if (!account) { + return reply.code(404).send({ message: '账号不存在' }); + } + + try { + await db.transaction(async (tx) => { + const checkedAt = new Date().toISOString(); + for (const modelName of normalizedModels) { + if (runtimeDbDialect === 'mysql') { + const existing = await tx.select() + .from(schema.modelAvailability) + .where(and(eq(schema.modelAvailability.accountId, accountId), eq(schema.modelAvailability.modelName, modelName))) + .get(); + + if (existing) { + await tx.update(schema.modelAvailability) + .set({ available: true, latencyMs: null, isManual: true, checkedAt }) + .where(eq(schema.modelAvailability.id, existing.id)) + .run(); + } else { + await tx.insert(schema.modelAvailability).values({ + accountId, + modelName, + available: true, + isManual: true, + latencyMs: null, + checkedAt, + }).run(); + } + } else { + // SQLite / PostgreSQL path + await (tx.insert(schema.modelAvailability) + .values({ + accountId, + modelName, + available: true, + isManual: true, + latencyMs: null, + checkedAt, + }) as any) + .onConflictDoUpdate({ + target: [schema.modelAvailability.accountId, schema.modelAvailability.modelName], + set: { + available: true, + isManual: true, + latencyMs: null, + checkedAt, + }, + }) + .run(); + } + } + }); + await rebuildRoutesBestEffort(); + + return { success: true }; + } catch (err: any) { + return reply.code(500).send({ success: false, message: err?.message || '保存失败' }); + } + }); } + diff --git a/src/server/routes/api/accounts.verifyTokenShield.test.ts b/src/server/routes/api/accounts.verifyTokenShield.test.ts index 68ddc0c6..ec9286bb 100644 --- a/src/server/routes/api/accounts.verifyTokenShield.test.ts +++ b/src/server/routes/api/accounts.verifyTokenShield.test.ts @@ -3,6 +3,7 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vites import { mkdtempSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; +import { resetRequestRateLimitStore } from '../../middleware/requestRateLimit.js'; const verifyTokenMock = vi.fn(); const undiciFetchMock = vi.fn(); @@ -45,6 +46,7 @@ describe('accounts verify-token shield detection', () => { verifyTokenMock.mockReset(); undiciFetchMock.mockReset(); adapterPlatformName = 'new-api'; + resetRequestRateLimitStore(); await db.delete(schema.proxyLogs).run(); await db.delete(schema.checkinLogs).run(); @@ -87,6 +89,45 @@ describe('accounts verify-token shield detection', () => { }); }); + it('rate limits repeated verify-token attempts from the same client ip', async () => { + verifyTokenMock.mockRejectedValue(new Error('invalid access token')); + + const site = await db.insert(schema.sites).values({ + name: 'AnyRouter', + url: 'https://anyrouter.example.com', + platform: 'new-api', + }).returning().get(); + + for (let attempt = 0; attempt < 5; attempt += 1) { + const response = await app.inject({ + method: 'POST', + url: '/api/accounts/verify-token', + remoteAddress: '198.51.100.11', + payload: { + siteId: site.id, + accessToken: 'session-or-cookie-token', + }, + }); + expect(response.statusCode).toBe(200); + } + + const limited = await app.inject({ + method: 'POST', + url: '/api/accounts/verify-token', + remoteAddress: '198.51.100.11', + payload: { + siteId: site.id, + accessToken: 'session-or-cookie-token', + }, + }); + + expect(limited.statusCode).toBe(429); + expect(limited.json()).toMatchObject({ + success: false, + message: '请求过于频繁,请稍后再试', + }); + }); + it('avoids raw shieldBlocked misclassification for new-api when verifyToken returned tokenType unknown', async () => { verifyTokenMock.mockResolvedValueOnce({ tokenType: 'unknown' }); undiciFetchMock.mockResolvedValue({ diff --git a/src/server/routes/api/auth.ts b/src/server/routes/api/auth.ts index 2652456d..a5528cd8 100644 --- a/src/server/routes/api/auth.ts +++ b/src/server/routes/api/auth.ts @@ -3,10 +3,20 @@ import { db, schema } from '../../db/index.js'; import { config } from '../../config.js'; import { eq } from 'drizzle-orm'; import { formatUtcSqlDateTime } from '../../services/localTimeService.js'; +import { createRateLimitGuard } from '../../middleware/requestRateLimit.js'; + +const limitAdminTokenChange = createRateLimitGuard({ + bucket: 'auth-change', + max: 3, + windowMs: 60_000, +}); export async function authRoutes(app: FastifyInstance) { // Change admin auth token (requires old token verification) - app.post<{ Body: { oldToken: string; newToken: string } }>('/api/settings/auth/change', async (request, reply) => { + app.post<{ Body: { oldToken: string; newToken: string } }>( + '/api/settings/auth/change', + { preHandler: [limitAdminTokenChange] }, + async (request, reply) => { const { oldToken, newToken } = request.body; if (!oldToken || !newToken) { @@ -45,7 +55,8 @@ export async function authRoutes(app: FastifyInstance) { } catch {} return { success: true, message: 'Token 已更新' }; - }); + }, + ); // Get masked current token (for display) app.get('/api/settings/auth/info', async () => { diff --git a/src/server/routes/api/checkin.lock.test.ts b/src/server/routes/api/checkin.lock.test.ts index ee335461..ca22ffb9 100644 --- a/src/server/routes/api/checkin.lock.test.ts +++ b/src/server/routes/api/checkin.lock.test.ts @@ -10,7 +10,7 @@ vi.mock('../../services/checkinService.js', () => ({ })); vi.mock('../../services/checkinScheduler.js', () => ({ - updateCheckinCron: vi.fn(), + updateCheckinSchedule: vi.fn(), })); vi.mock('../../db/index.js', () => { @@ -87,4 +87,25 @@ describe('POST /api/checkin/trigger background task dedupe', () => { await new Promise((resolve) => setTimeout(resolve, 20)); await app.close(); }); + + it('accepts the legacy cron-only schedule payload', async () => { + const { checkinRoutes } = await import('./checkin.js'); + const schedulerModule = await import('../../services/checkinScheduler.js'); + const app = Fastify(); + await app.register(checkinRoutes); + + const response = await app.inject({ + method: 'PUT', + url: '/api/checkin/schedule', + payload: { cron: '0 8 * * *' }, + }); + + expect(response.statusCode).toBe(200); + expect((schedulerModule as any).updateCheckinSchedule).toHaveBeenCalledWith({ + mode: 'cron', + cronExpr: '0 8 * * *', + intervalHours: undefined, + }); + await app.close(); + }); }); diff --git a/src/server/routes/api/checkin.ts b/src/server/routes/api/checkin.ts index 1f574b6c..e2478790 100644 --- a/src/server/routes/api/checkin.ts +++ b/src/server/routes/api/checkin.ts @@ -1,8 +1,10 @@ import { FastifyInstance } from 'fastify'; +import { config } from '../../config.js'; import { db, schema } from '../../db/index.js'; +import { upsertSetting } from '../../db/upsertSetting.js'; import { eq, desc } from 'drizzle-orm'; import { checkinAccount, checkinAll } from '../../services/checkinService.js'; -import { updateCheckinCron } from '../../services/checkinScheduler.js'; +import { updateCheckinSchedule } from '../../services/checkinScheduler.js'; import { startBackgroundTask, summarizeCheckinResults } from '../../services/backgroundTaskService.js'; import { classifyFailureReason } from '../../services/failureReasonService.js'; @@ -83,7 +85,7 @@ export async function checkinRoutes(app: FastifyInstance) { failureMessage: (currentTask) => `全部账号签到任务失败:${currentTask.error || 'unknown error'}`, }, async () => { - const results = await checkinAll(); + const results = await checkinAll({ scheduleMode: config.checkinScheduleMode }); return { summary: summarizeCheckinResults(results), total: results.length, @@ -107,7 +109,7 @@ export async function checkinRoutes(app: FastifyInstance) { // Trigger check-in for a specific account app.post<{ Params: { id: string } }>('/api/checkin/trigger/:id', async (request) => { const id = parseInt(request.params.id, 10); - const result = await checkinAccount(id); + const result = await checkinAccount(id, { scheduleMode: config.checkinScheduleMode }); return result; }); @@ -141,12 +143,33 @@ export async function checkinRoutes(app: FastifyInstance) { }); // Update check-in schedule - app.put<{ Body: { cron: string } }>('/api/checkin/schedule', async (request) => { + app.put<{ Body: { mode?: 'cron' | 'interval'; cron?: string; intervalHours?: number } }>('/api/checkin/schedule', async (request) => { try { - updateCheckinCron(request.body.cron); - await db.insert(schema.settings).values({ key: 'checkin_cron', value: JSON.stringify(request.body.cron) }) - .onConflictDoUpdate({ target: schema.settings.key, set: { value: JSON.stringify(request.body.cron) } }).run(); - return { success: true, cron: request.body.cron }; + const body = request.body || {}; + const nextMode: 'cron' | 'interval' = body.mode === 'interval' ? 'interval' : 'cron'; + const nextCron = typeof body.cron === 'string' ? body.cron : undefined; + const nextIntervalHours = body.intervalHours !== undefined ? Number(body.intervalHours) : undefined; + const normalizedIntervalHours = typeof nextIntervalHours === 'number' && Number.isFinite(nextIntervalHours) + ? Math.trunc(nextIntervalHours) + : undefined; + + updateCheckinSchedule({ + mode: nextMode, + cronExpr: nextCron, + intervalHours: normalizedIntervalHours, + }); + + await upsertSetting('checkin_schedule_mode', nextMode); + if (nextCron !== undefined) await upsertSetting('checkin_cron', nextCron); + if (normalizedIntervalHours !== undefined) { + await upsertSetting('checkin_interval_hours', normalizedIntervalHours); + } + return { + success: true, + mode: nextMode, + cron: nextCron, + intervalHours: normalizedIntervalHours, + }; } catch (err: any) { return { error: err.message }; } diff --git a/src/server/routes/api/downstreamApiKeys.test.ts b/src/server/routes/api/downstreamApiKeys.test.ts new file mode 100644 index 00000000..cd5610d4 --- /dev/null +++ b/src/server/routes/api/downstreamApiKeys.test.ts @@ -0,0 +1,548 @@ +import Fastify, { type FastifyInstance } from 'fastify'; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; +import { mkdtempSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { eq } from 'drizzle-orm'; +import { PgDialect } from 'drizzle-orm/pg-core'; + +type DbModule = typeof import('../../db/index.js'); + +describe('downstream api keys routes', () => { + let app: FastifyInstance; + let db: DbModule['db']; + let schema: DbModule['schema']; + let dataDir = ''; + + beforeAll(async () => { + dataDir = mkdtempSync(join(tmpdir(), 'metapi-downstream-routes-')); + process.env.DATA_DIR = dataDir; + + await import('../../db/migrate.js'); + const dbModule = await import('../../db/index.js'); + const routesModule = await import('./downstreamApiKeys.js'); + db = dbModule.db; + schema = dbModule.schema; + + app = Fastify(); + await app.register(routesModule.downstreamApiKeysRoutes); + }); + + beforeEach(async () => { + await db.delete(schema.proxyLogs).run(); + await db.delete(schema.downstreamApiKeys).run(); + await db.delete(schema.tokenRoutes).run(); + await db.delete(schema.sites).run(); + }); + + afterAll(async () => { + await app.close(); + delete process.env.DATA_DIR; + }); + + it('builds postgres trend buckets by casting text timestamps before date_trunc', async () => { + const routesModule = await import('./downstreamApiKeys.js') as Record<string, any>; + + expect(typeof routesModule.buildBucketTsExpressionForDialect).toBe('function'); + + const expression = routesModule.buildBucketTsExpressionForDialect('postgres', schema.proxyLogs.createdAt, 3600); + const rendered = new PgDialect().sqlToQuery(expression).sql; + + expect(rendered).toContain('date_trunc'); + expect(rendered).toContain('cast'); + expect(rendered).toContain('timestamp'); + }); + + it('creates, updates, resets and deletes downstream api keys', async () => { + const site = await db.insert(schema.sites).values({ + name: 'portal-site', + url: 'https://portal.example.com', + status: 'active', + platform: 'openai', + }).returning().get(); + const route = await db.insert(schema.tokenRoutes).values({ + modelPattern: 'gpt-5.2', + displayName: 'portal-route', + enabled: true, + }).returning().get(); + + const createRes = await app.inject({ + method: 'POST', + url: '/api/downstream-keys', + payload: { + name: 'portal-key', + key: 'sk-portal-key-001', + description: 'portal consumer', + groupName: '项目A', + tags: ['移动端', 'VIP'], + enabled: true, + maxCost: 12.5, + maxRequests: 500, + supportedModels: ['gpt-5.2', 'claude-sonnet-4-5'], + allowedRouteIds: [route.id], + siteWeightMultipliers: { [site.id]: 1.2 }, + }, + }); + + expect(createRes.statusCode).toBe(200); + const createdBody = createRes.json(); + expect(createdBody.success).toBe(true); + expect(createdBody.item).toMatchObject({ + name: 'portal-key', + keyMasked: expect.any(String), + groupName: '项目A', + tags: ['移动端', 'VIP'], + maxCost: 12.5, + maxRequests: 500, + supportedModels: ['gpt-5.2', 'claude-sonnet-4-5'], + allowedRouteIds: [route.id], + }); + + const keyId = createdBody.item.id as number; + + const updateRes = await app.inject({ + method: 'PUT', + url: `/api/downstream-keys/${keyId}`, + payload: { + name: 'portal-key-updated', + key: 'sk-portal-key-001', + groupName: '项目B', + tags: ['批量候选', 'VIP'], + enabled: false, + maxCost: 20, + maxRequests: 900, + }, + }); + + expect(updateRes.statusCode).toBe(200); + expect(updateRes.json()).toMatchObject({ + success: true, + item: { + id: keyId, + name: 'portal-key-updated', + groupName: '项目B', + tags: ['批量候选', 'VIP'], + enabled: false, + maxCost: 20, + maxRequests: 900, + }, + }); + + await db.update(schema.downstreamApiKeys).set({ + usedCost: 5.5, + usedRequests: 123, + }).where(eq(schema.downstreamApiKeys.id, keyId)).run(); + + const resetRes = await app.inject({ + method: 'POST', + url: `/api/downstream-keys/${keyId}/reset-usage`, + }); + + expect(resetRes.statusCode).toBe(200); + expect(resetRes.json()).toMatchObject({ success: true }); + + const resetRow = await db.select().from(schema.downstreamApiKeys) + .where(eq(schema.downstreamApiKeys.id, keyId)) + .get(); + expect(resetRow?.usedCost).toBe(0); + expect(resetRow?.usedRequests).toBe(0); + + const deleteRes = await app.inject({ + method: 'DELETE', + url: `/api/downstream-keys/${keyId}`, + }); + + expect(deleteRes.statusCode).toBe(200); + expect(deleteRes.json()).toMatchObject({ success: true }); + + const listRes = await app.inject({ + method: 'GET', + url: '/api/downstream-keys', + }); + expect(listRes.statusCode).toBe(200); + expect(listRes.json()).toMatchObject({ success: true, items: [] }); + }); + + it('supports batch enable/disable/reset/delete operations', async () => { + const inserted = await db.insert(schema.downstreamApiKeys).values([ + { + name: 'batch-a', + key: 'sk-batch-a-001', + enabled: true, + usedCost: 1.2, + usedRequests: 12, + }, + { + name: 'batch-b', + key: 'sk-batch-b-001', + enabled: false, + usedCost: 2.4, + usedRequests: 24, + }, + ]).returning().all(); + + const disableRes = await app.inject({ + method: 'POST', + url: '/api/downstream-keys/batch', + payload: { + ids: [inserted[0].id, inserted[1].id], + action: 'disable', + }, + }); + expect(disableRes.statusCode).toBe(200); + expect(disableRes.json()).toMatchObject({ + success: true, + successIds: [inserted[0].id, inserted[1].id], + failedItems: [], + }); + + const disabledRows = await db.select().from(schema.downstreamApiKeys).all(); + expect(disabledRows.every((row) => row.enabled === false)).toBe(true); + + const resetRes = await app.inject({ + method: 'POST', + url: '/api/downstream-keys/batch', + payload: { + ids: [inserted[0].id, inserted[1].id], + action: 'resetUsage', + }, + }); + expect(resetRes.statusCode).toBe(200); + const resetRows = await db.select().from(schema.downstreamApiKeys).all(); + expect(resetRows.every((row) => Number(row.usedCost) === 0 && Number(row.usedRequests) === 0)).toBe(true); + + const deleteRes = await app.inject({ + method: 'POST', + url: '/api/downstream-keys/batch', + payload: { + ids: [inserted[0].id, inserted[1].id], + action: 'delete', + }, + }); + expect(deleteRes.statusCode).toBe(200); + expect(await db.select().from(schema.downstreamApiKeys).all()).toHaveLength(0); + }); + + it('supports batch metadata update for group and tags', async () => { + const inserted = await db.insert(schema.downstreamApiKeys).values([ + { + name: 'meta-a', + key: 'sk-meta-a-001', + groupName: '旧分组', + tags: JSON.stringify(['旧标签']), + enabled: true, + }, + { + name: 'meta-b', + key: 'sk-meta-b-001', + tags: JSON.stringify(['旧标签', '公共']), + enabled: true, + }, + ]).returning().all(); + + const res = await app.inject({ + method: 'POST', + url: '/api/downstream-keys/batch', + payload: { + ids: inserted.map((item) => item.id), + action: 'updateMetadata', + groupOperation: 'set', + groupName: '新分组', + tagOperation: 'append', + tags: ['项目A', '公共'], + }, + }); + + expect(res.statusCode).toBe(200); + expect(res.json()).toMatchObject({ + success: true, + successIds: inserted.map((item) => item.id), + failedItems: [], + }); + + const rows = await db.select().from(schema.downstreamApiKeys).all(); + expect(rows).toHaveLength(2); + for (const row of rows) { + expect(row.groupName).toBe('新分组'); + } + const views = rows.map((row) => ({ + id: row.id, + groupName: row.groupName, + tags: JSON.parse(String(row.tags || '[]')), + })); + expect(views).toContainEqual(expect.objectContaining({ tags: ['旧标签', '项目A', '公共'] })); + expect(views).toContainEqual(expect.objectContaining({ tags: ['旧标签', '公共', '项目A'] })); + }); + + it('rejects duplicate key creation and invalid batch action', async () => { + const firstRes = await app.inject({ + method: 'POST', + url: '/api/downstream-keys', + payload: { + name: 'dup-a', + key: 'sk-dup-key-001', + }, + }); + expect(firstRes.statusCode).toBe(200); + + const duplicateRes = await app.inject({ + method: 'POST', + url: '/api/downstream-keys', + payload: { + name: 'dup-b', + key: 'sk-dup-key-001', + }, + }); + expect(duplicateRes.statusCode).toBe(409); + expect(duplicateRes.json()).toMatchObject({ + success: false, + message: 'API key 已存在', + }); + + const invalidBatchRes = await app.inject({ + method: 'POST', + url: '/api/downstream-keys/batch', + payload: { + ids: [999], + action: 'archive', + }, + }); + expect(invalidBatchRes.statusCode).toBe(400); + expect(invalidBatchRes.json()).toMatchObject({ + success: false, + message: 'Invalid action', + }); + }); + + it('rejects duplicate key update with conflict status', async () => { + const firstRes = await app.inject({ + method: 'POST', + url: '/api/downstream-keys', + payload: { + name: 'dup-a', + key: 'sk-dup-key-001', + }, + }); + expect(firstRes.statusCode).toBe(200); + + const secondRes = await app.inject({ + method: 'POST', + url: '/api/downstream-keys', + payload: { + name: 'dup-b', + key: 'sk-dup-key-002', + }, + }); + expect(secondRes.statusCode).toBe(200); + + const secondId = secondRes.json().item.id as number; + const duplicateUpdateRes = await app.inject({ + method: 'PUT', + url: `/api/downstream-keys/${secondId}`, + payload: { + name: 'dup-b-updated', + key: 'sk-dup-key-001', + }, + }); + + expect(duplicateUpdateRes.statusCode).toBe(409); + expect(duplicateUpdateRes.json()).toMatchObject({ + success: false, + message: 'API key 已存在', + }); + }); + + it('returns summary, overview and trend aggregated from proxy logs', async () => { + const inserted = await db.insert(schema.downstreamApiKeys).values({ + name: 'analytics-key', + key: 'sk-analytics-key-001', + enabled: true, + description: 'analytics', + usedCost: 0, + usedRequests: 0, + }).returning().get(); + + const now = Date.now(); + const within24h = new Date(now - 2 * 60 * 60 * 1000).toISOString(); + const within7d = new Date(now - 2 * 24 * 60 * 60 * 1000).toISOString(); + const outside7d = new Date(now - 10 * 24 * 60 * 60 * 1000).toISOString(); + + await db.insert(schema.proxyLogs).values([ + { + downstreamApiKeyId: inserted.id, + status: 'success', + totalTokens: 1200, + estimatedCost: 0.12, + createdAt: within24h, + }, + { + downstreamApiKeyId: inserted.id, + status: 'failed', + totalTokens: 300, + estimatedCost: 0.03, + createdAt: within24h, + }, + { + downstreamApiKeyId: inserted.id, + status: 'success', + totalTokens: 600, + estimatedCost: 0.06, + createdAt: within7d, + }, + { + downstreamApiKeyId: inserted.id, + status: 'success', + totalTokens: 900, + estimatedCost: 0.09, + createdAt: outside7d, + }, + ]).run(); + + const summaryRes = await app.inject({ + method: 'GET', + url: '/api/downstream-keys/summary?range=24h&status=enabled&search=analytics', + }); + + expect(summaryRes.statusCode).toBe(200); + expect(summaryRes.json()).toMatchObject({ + success: true, + range: '24h', + status: 'enabled', + search: 'analytics', + group: '', + tags: [], + tagMatch: 'any', + items: [ + { + id: inserted.id, + name: 'analytics-key', + rangeUsage: { + totalRequests: 2, + successRequests: 1, + failedRequests: 1, + successRate: 50, + totalTokens: 1500, + totalCost: 0.15, + }, + }, + ], + }); + + const overviewRes = await app.inject({ + method: 'GET', + url: `/api/downstream-keys/${inserted.id}/overview`, + }); + + expect(overviewRes.statusCode).toBe(200); + expect(overviewRes.json()).toMatchObject({ + success: true, + item: { id: inserted.id, name: 'analytics-key' }, + usage: { + last24h: { + totalRequests: 2, + successRequests: 1, + failedRequests: 1, + totalTokens: 1500, + totalCost: 0.15, + }, + last7d: { + totalRequests: 3, + successRequests: 2, + failedRequests: 1, + totalTokens: 2100, + totalCost: 0.21, + }, + all: { + totalRequests: 4, + successRequests: 3, + failedRequests: 1, + totalTokens: 3000, + totalCost: 0.3, + }, + }, + }); + + const trendRes = await app.inject({ + method: 'GET', + url: `/api/downstream-keys/${inserted.id}/trend?range=all`, + }); + + expect(trendRes.statusCode).toBe(200); + const trendBody = trendRes.json(); + expect(trendBody.success).toBe(true); + expect(trendBody.range).toBe('all'); + expect(trendBody.item).toMatchObject({ id: inserted.id, name: 'analytics-key' }); + expect(Array.isArray(trendBody.buckets)).toBe(true); + expect(trendBody.buckets.length).toBeGreaterThanOrEqual(3); + expect(trendBody.buckets.some((bucket: any) => bucket.totalTokens === 1500)).toBe(true); + expect(trendBody.buckets.some((bucket: any) => bucket.totalTokens === 600)).toBe(true); + expect(trendBody.buckets.some((bucket: any) => bucket.totalTokens === 900)).toBe(true); + }); + + it('rejects unknown route ids and site ids in downstream policy payloads', async () => { + const createRes = await app.inject({ + method: 'POST', + url: '/api/downstream-keys', + payload: { + name: 'invalid-policy', + key: 'sk-invalid-policy-001', + allowedRouteIds: [999], + }, + }); + expect(createRes.statusCode).toBe(400); + expect(createRes.json()).toMatchObject({ + success: false, + message: 'allowedRouteIds 包含不存在的路由: 999', + }); + + const site = await db.insert(schema.sites).values({ + name: 'site-a', + url: 'https://example.com', + status: 'active', + platform: 'openai', + }).returning().get(); + const route = await db.insert(schema.tokenRoutes).values({ + modelPattern: 'gpt-5.2', + enabled: true, + }).returning().get(); + const created = await app.inject({ + method: 'POST', + url: '/api/downstream-keys', + payload: { + name: 'valid-policy', + key: 'sk-valid-policy-001', + allowedRouteIds: [route.id], + siteWeightMultipliers: { [site.id]: 1.2 }, + }, + }); + expect(created.statusCode).toBe(200); + const keyId = created.json().item.id as number; + + const updateRes = await app.inject({ + method: 'PUT', + url: `/api/downstream-keys/${keyId}`, + payload: { + siteWeightMultipliers: { 999: 1.5 }, + }, + }); + expect(updateRes.statusCode).toBe(400); + expect(updateRes.json()).toMatchObject({ + success: false, + message: 'siteWeightMultipliers 包含不存在的站点: 999', + }); + + const filteredSummaryRes = await app.inject({ + method: 'GET', + url: '/api/downstream-keys/summary?range=all&group=__ungrouped__&tags=foo,bar&tagMatch=all', + }); + expect(filteredSummaryRes.statusCode).toBe(200); + expect(filteredSummaryRes.json()).toMatchObject({ + success: true, + range: 'all', + group: '__ungrouped__', + tags: ['foo', 'bar'], + tagMatch: 'all', + items: [], + }); + }); +}); diff --git a/src/server/routes/api/downstreamApiKeys.ts b/src/server/routes/api/downstreamApiKeys.ts index 987c4894..c48c273d 100644 --- a/src/server/routes/api/downstreamApiKeys.ts +++ b/src/server/routes/api/downstreamApiKeys.ts @@ -1,6 +1,6 @@ import { FastifyInstance } from 'fastify'; -import { eq } from 'drizzle-orm'; -import { db, schema } from '../../db/index.js'; +import { and, eq, inArray, sql, type SQL, type SQLWrapper } from 'drizzle-orm'; +import { db, hasProxyLogDownstreamApiKeyIdColumn, runtimeDbDialect, schema } from '../../db/index.js'; import { getDownstreamApiKeyById, listDownstreamApiKeys, @@ -8,6 +8,7 @@ import { toDownstreamApiKeyPolicyView, toPersistenceJson, } from '../../services/downstreamApiKeyService.js'; +import { formatUtcSqlDateTime } from '../../services/localTimeService.js'; function parseRouteId(raw: string): number | null { const id = Number.parseInt(raw, 10); @@ -19,12 +20,418 @@ function validateKeyShape(key: string): boolean { return key.startsWith('sk-') && key.length >= 6; } +type ErrorLike = { + message?: string; + code?: string | number; + cause?: unknown; +}; + +function getErrorChain(error: unknown): ErrorLike[] { + const chain: ErrorLike[] = []; + const seen = new Set<unknown>(); + let current: unknown = error; + while (current && typeof current === 'object' && !seen.has(current)) { + seen.add(current); + chain.push(current as ErrorLike); + current = (current as ErrorLike).cause; + } + return chain; +} + function looksLikeUniqueViolation(error: unknown): boolean { - const message = (error as Error | undefined)?.message || ''; - return message.includes('UNIQUE constraint failed') && message.includes('downstream_api_keys.key'); + const chain = getErrorChain(error); + if (runtimeDbDialect === 'postgres') { + return chain.some((entry) => { + const message = entry.message || ''; + const code = String(entry.code || ''); + return code === '23505' + || (message.includes('duplicate key value violates unique constraint') + && message.includes('downstream_api_keys_key_unique')); + }); + } + return chain.some((entry) => { + const message = entry.message || ''; + const code = String(entry.code || ''); + return code === 'SQLITE_CONSTRAINT' + || code === 'SQLITE_CONSTRAINT_UNIQUE' + || (message.includes('UNIQUE constraint failed') && message.includes('downstream_api_keys.key')); + }); +} + +function normalizeBatchIds(raw: unknown): number[] { + const values = Array.isArray(raw) ? raw : []; + const ids: number[] = []; + for (const item of values) { + const parsed = Number(item); + if (!Number.isFinite(parsed)) continue; + const id = Math.trunc(parsed); + if (id <= 0 || ids.includes(id)) continue; + ids.push(id); + if (ids.length >= 500) break; + } + return ids; +} + +type DownstreamKeyRange = '24h' | '7d' | 'all'; +type DownstreamKeyStatus = 'all' | 'enabled' | 'disabled'; + +function normalizeDownstreamKeyRange(raw: unknown): DownstreamKeyRange { + const value = typeof raw === 'string' ? raw.trim().toLowerCase() : ''; + if (value === '24h') return '24h'; + if (value === '7d') return '7d'; + if (value === 'all') return 'all'; + return '24h'; +} + +function normalizeDownstreamKeyStatus(raw: unknown): DownstreamKeyStatus { + const value = typeof raw === 'string' ? raw.trim().toLowerCase() : ''; + if (value === 'enabled') return 'enabled'; + if (value === 'disabled') return 'disabled'; + return 'all'; +} + +function normalizeSearchQuery(raw: unknown): string { + const value = typeof raw === 'string' ? raw.trim() : ''; + if (!value) return ''; + return value.slice(0, 80); +} + +function normalizeGroupQuery(raw: unknown): string { + const value = typeof raw === 'string' ? raw.trim() : ''; + return value.slice(0, 64); +} + +function normalizeTagQuery(raw: unknown): string[] { + const value = typeof raw === 'string' ? raw : Array.isArray(raw) ? raw.join(',') : ''; + return value + .split(/[\r\n,,]+/g) + .map((item) => item.trim()) + .filter(Boolean) + .map((item) => item.slice(0, 32)) + .filter((item, index, arr) => arr.findIndex((candidate) => candidate.toLowerCase() === item.toLowerCase()) === index) + .slice(0, 20); +} + +function normalizeTagMatchMode(raw: unknown): 'any' | 'all' { + const value = typeof raw === 'string' ? raw.trim().toLowerCase() : ''; + return value === 'all' ? 'all' : 'any'; +} + +function resolveRangeSinceUtc(range: DownstreamKeyRange): string | null { + const nowTs = Date.now(); + if (range === '24h') return formatUtcSqlDateTime(new Date(nowTs - 24 * 60 * 60 * 1000)); + if (range === '7d') return formatUtcSqlDateTime(new Date(nowTs - 7 * 24 * 60 * 60 * 1000)); + return null; +} + +function resolveBucketSeconds(range: DownstreamKeyRange): number { + return range === 'all' ? 86400 : 3600; +} + +export function buildBucketTsExpressionForDialect( + dialect: 'sqlite' | 'mysql' | 'postgres', + createdAtColumn: SQLWrapper, + bucketSeconds: number, +) { + if (dialect === 'mysql') { + return sql<number>`floor(unix_timestamp(${createdAtColumn}) / ${bucketSeconds}) * ${bucketSeconds}`; + } + if (dialect === 'postgres') { + const createdAtTimestamp = sql`cast(${createdAtColumn} as timestamp)`; + if (bucketSeconds === 86400) { + return sql<number>`extract(epoch from date_trunc('day', ${createdAtTimestamp}))::bigint`; + } + return sql<number>`extract(epoch from date_trunc('hour', ${createdAtTimestamp}))::bigint`; + } + // sqlite + return sql<number>`cast(cast(strftime('%s', ${createdAtColumn}) as integer) / ${bucketSeconds} as integer) * ${bucketSeconds}`; +} + +function resolveBucketTsExpression(bucketSeconds: number) { + return buildBucketTsExpressionForDialect(runtimeDbDialect, schema.proxyLogs.createdAt, bucketSeconds); +} + +async function validatePolicyReferences(input: { + allowedRouteIds: number[]; + siteWeightMultipliers: Record<number, number>; +}): Promise<string | null> { + const routeIds = input.allowedRouteIds || []; + if (routeIds.length > 0) { + const rows = await db.select({ id: schema.tokenRoutes.id }) + .from(schema.tokenRoutes) + .where(inArray(schema.tokenRoutes.id, routeIds)) + .all(); + const existingIds = new Set(rows.map((row) => Number(row.id))); + const missingIds = routeIds.filter((id) => !existingIds.has(id)); + if (missingIds.length > 0) { + return `allowedRouteIds 包含不存在的路由: ${missingIds.join(', ')}`; + } + } + + const siteIds = Object.keys(input.siteWeightMultipliers || {}) + .map((key) => Number(key)) + .filter((value) => Number.isFinite(value) && value > 0) + .map((value) => Math.trunc(value)); + if (siteIds.length > 0) { + const rows = await db.select({ id: schema.sites.id }) + .from(schema.sites) + .where(inArray(schema.sites.id, siteIds)) + .all(); + const existingIds = new Set(rows.map((row) => Number(row.id))); + const missingIds = siteIds.filter((id) => !existingIds.has(id)); + if (missingIds.length > 0) { + return `siteWeightMultipliers 包含不存在的站点: ${missingIds.join(', ')}`; + } + } + + return null; } export async function downstreamApiKeysRoutes(app: FastifyInstance) { + app.get<{ Querystring: { range?: string; status?: string; search?: string; group?: string; tags?: string | string[]; tagMatch?: string } }>('/api/downstream-keys/summary', async (request) => { + const range = normalizeDownstreamKeyRange(request.query?.range); + const status = normalizeDownstreamKeyStatus(request.query?.status); + const search = normalizeSearchQuery(request.query?.search); + const group = normalizeGroupQuery(request.query?.group); + const tags = normalizeTagQuery(request.query?.tags); + const tagMatch = normalizeTagMatchMode(request.query?.tagMatch); + + const whereClauses: SQL[] = []; + if (status === 'enabled') { + whereClauses.push(eq(schema.downstreamApiKeys.enabled, true)); + } else if (status === 'disabled') { + whereClauses.push(eq(schema.downstreamApiKeys.enabled, false)); + } + if (search) { + const pattern = `%${search.toLowerCase()}%`; + whereClauses.push(sql`(lower(${schema.downstreamApiKeys.name}) like ${pattern} or lower(coalesce(${schema.downstreamApiKeys.description}, '')) like ${pattern})`); + } + + let keysQuery = db.select().from(schema.downstreamApiKeys); + if (whereClauses.length > 0) { + keysQuery = keysQuery.where(and(...whereClauses)); + } + const keys = (await keysQuery.all()) + .map((row) => toDownstreamApiKeyPolicyView(row)) + .filter((item) => { + if (group === '__ungrouped__') { + if (item.groupName) return false; + } else if (group && item.groupName !== group) { + return false; + } + + if (!search && tags.length === 0) return true; + const haystack = [ + item.name, + item.description || '', + item.keyMasked, + item.groupName || '', + ...item.tags, + ...item.supportedModels, + ].join(' ').toLowerCase(); + if (search && !haystack.includes(search.toLowerCase())) return false; + if (tags.length === 0) return true; + const itemTags = new Set(item.tags.map((tag) => tag.toLowerCase())); + return tagMatch === 'all' + ? tags.every((tag) => itemTags.has(tag.toLowerCase())) + : tags.some((tag) => itemTags.has(tag.toLowerCase())); + }) + .sort((a, b) => b.id - a.id); + + if (keys.length === 0) { + return { success: true, range, status, search, group, tags, tagMatch, items: [] }; + } + + const columnReady = await hasProxyLogDownstreamApiKeyIdColumn(); + const sinceUtc = resolveRangeSinceUtc(range); + const ids = keys.map((k) => k.id); + + const usageRows = columnReady + ? await db.select({ + keyId: schema.proxyLogs.downstreamApiKeyId, + totalRequests: sql<number>`count(*)`, + successRequests: sql<number>`coalesce(sum(case when ${schema.proxyLogs.status} = 'success' then 1 else 0 end), 0)`, + failedRequests: sql<number>`coalesce(sum(case when ${schema.proxyLogs.status} = 'success' then 0 else 1 end), 0)`, + totalTokens: sql<number>`coalesce(sum(coalesce(${schema.proxyLogs.totalTokens}, 0)), 0)`, + totalCost: sql<number>`coalesce(sum(coalesce(${schema.proxyLogs.estimatedCost}, 0)), 0)`, + }) + .from(schema.proxyLogs) + .where(and( + inArray(schema.proxyLogs.downstreamApiKeyId, ids), + ...(sinceUtc ? [sql`${schema.proxyLogs.createdAt} >= ${sinceUtc}`] : []), + )) + .groupBy(schema.proxyLogs.downstreamApiKeyId) + .all() + : []; + + const usageByKey = new Map<number, { + totalRequests: number; + successRequests: number; + failedRequests: number; + totalTokens: number; + totalCost: number; + }>(); + + for (const row of usageRows) { + const keyId = Number((row as any).keyId ?? 0); + if (!Number.isFinite(keyId) || keyId <= 0) continue; + usageByKey.set(keyId, { + totalRequests: Number((row as any).totalRequests || 0), + successRequests: Number((row as any).successRequests || 0), + failedRequests: Number((row as any).failedRequests || 0), + totalTokens: Number((row as any).totalTokens || 0), + totalCost: Number((row as any).totalCost || 0), + }); + } + + return { + success: true, + range, + status, + search, + group, + tags, + tagMatch, + items: keys.map((key) => { + const usage = usageByKey.get(key.id) || { + totalRequests: 0, + successRequests: 0, + failedRequests: 0, + totalTokens: 0, + totalCost: 0, + }; + const successRate = usage.totalRequests > 0 + ? Math.round((usage.successRequests / usage.totalRequests) * 1000) / 10 + : null; + return { + ...key, + rangeUsage: { + totalRequests: usage.totalRequests, + successRequests: usage.successRequests, + failedRequests: usage.failedRequests, + successRate, + totalTokens: usage.totalTokens, + totalCost: Math.round(usage.totalCost * 1_000_000) / 1_000_000, + }, + }; + }), + }; + }); + + app.get<{ Params: { id: string } }>('/api/downstream-keys/:id/overview', async (request, reply) => { + const id = parseRouteId(request.params.id); + if (!id) { + return reply.code(400).send({ success: false, message: 'id 无效' }); + } + + const item = await getDownstreamApiKeyById(id); + if (!item) { + return reply.code(404).send({ success: false, message: 'API key 不存在' }); + } + + const columnReady = await hasProxyLogDownstreamApiKeyIdColumn(); + if (!columnReady) { + return { success: true, item, usage: { last24h: null, last7d: null, all: null } }; + } + + const readAggregate = async (range: DownstreamKeyRange) => { + const sinceUtc = resolveRangeSinceUtc(range); + const row = await db.select({ + totalRequests: sql<number>`count(*)`, + successRequests: sql<number>`coalesce(sum(case when ${schema.proxyLogs.status} = 'success' then 1 else 0 end), 0)`, + failedRequests: sql<number>`coalesce(sum(case when ${schema.proxyLogs.status} = 'success' then 0 else 1 end), 0)`, + totalTokens: sql<number>`coalesce(sum(coalesce(${schema.proxyLogs.totalTokens}, 0)), 0)`, + totalCost: sql<number>`coalesce(sum(coalesce(${schema.proxyLogs.estimatedCost}, 0)), 0)`, + }) + .from(schema.proxyLogs) + .where(and( + eq(schema.proxyLogs.downstreamApiKeyId, id), + ...(sinceUtc ? [sql`${schema.proxyLogs.createdAt} >= ${sinceUtc}`] : []), + )) + .get(); + + const totalRequests = Number((row as any)?.totalRequests || 0); + const successRequests = Number((row as any)?.successRequests || 0); + const totalCost = Number((row as any)?.totalCost || 0); + return { + totalRequests, + successRequests, + failedRequests: Number((row as any)?.failedRequests || 0), + successRate: totalRequests > 0 ? Math.round((successRequests / totalRequests) * 1000) / 10 : null, + totalTokens: Number((row as any)?.totalTokens || 0), + totalCost: Math.round(totalCost * 1_000_000) / 1_000_000, + }; + }; + + const [last24h, last7d, all] = await Promise.all([ + readAggregate('24h'), + readAggregate('7d'), + readAggregate('all'), + ]); + + return { success: true, item, usage: { last24h, last7d, all } }; + }); + + app.get<{ Params: { id: string }; Querystring: { range?: string } }>('/api/downstream-keys/:id/trend', async (request, reply) => { + const id = parseRouteId(request.params.id); + if (!id) { + return reply.code(400).send({ success: false, message: 'id 无效' }); + } + + const range = normalizeDownstreamKeyRange(request.query?.range); + const item = await getDownstreamApiKeyById(id); + if (!item) { + return reply.code(404).send({ success: false, message: 'API key 不存在' }); + } + + const columnReady = await hasProxyLogDownstreamApiKeyIdColumn(); + if (!columnReady) { + return { success: true, range, item: { id: item.id, name: item.name }, buckets: [] }; + } + + const bucketSeconds = resolveBucketSeconds(range); + const bucketTs = resolveBucketTsExpression(bucketSeconds); + const sinceUtc = resolveRangeSinceUtc(range); + + const rows = await db.select({ + bucketTs, + totalRequests: sql<number>`count(*)`, + successRequests: sql<number>`coalesce(sum(case when ${schema.proxyLogs.status} = 'success' then 1 else 0 end), 0)`, + failedRequests: sql<number>`coalesce(sum(case when ${schema.proxyLogs.status} = 'success' then 0 else 1 end), 0)`, + totalTokens: sql<number>`coalesce(sum(coalesce(${schema.proxyLogs.totalTokens}, 0)), 0)`, + totalCost: sql<number>`coalesce(sum(coalesce(${schema.proxyLogs.estimatedCost}, 0)), 0)`, + }) + .from(schema.proxyLogs) + .where(and( + eq(schema.proxyLogs.downstreamApiKeyId, id), + ...(sinceUtc ? [sql`${schema.proxyLogs.createdAt} >= ${sinceUtc}`] : []), + )) + .groupBy(bucketTs) + .orderBy(bucketTs) + .all(); + + return { + success: true, + range, + item: { id: item.id, name: item.name }, + bucketSeconds, + buckets: rows.map((row: any) => { + const tsSeconds = Number(row.bucketTs || 0); + const totalRequests = Number(row.totalRequests || 0); + const successRequests = Number(row.successRequests || 0); + return { + startUtc: tsSeconds > 0 ? new Date(tsSeconds * 1000).toISOString() : null, + totalRequests, + successRequests, + failedRequests: Number(row.failedRequests || 0), + successRate: totalRequests > 0 ? Math.round((successRequests / totalRequests) * 1000) / 10 : null, + totalTokens: Number(row.totalTokens || 0), + totalCost: Math.round(Number(row.totalCost || 0) * 1_000_000) / 1_000_000, + }; + }), + }; + }); + app.get('/api/downstream-keys', async () => { return { success: true, @@ -37,6 +444,8 @@ export async function downstreamApiKeysRoutes(app: FastifyInstance) { name?: unknown; key?: unknown; description?: unknown; + groupName?: unknown; + tags?: unknown; enabled?: unknown; expiresAt?: unknown; maxCost?: unknown; @@ -62,6 +471,13 @@ export async function downstreamApiKeysRoutes(app: FastifyInstance) { if (!validateKeyShape(normalized.key)) { return reply.code(400).send({ success: false, message: 'key 必须以 sk- 开头且长度至少 6' }); } + const policyRefError = await validatePolicyReferences({ + allowedRouteIds: normalized.allowedRouteIds, + siteWeightMultipliers: normalized.siteWeightMultipliers, + }); + if (policyRefError) { + return reply.code(400).send({ success: false, message: policyRefError }); + } const nowIso = new Date().toISOString(); @@ -70,6 +486,8 @@ export async function downstreamApiKeysRoutes(app: FastifyInstance) { name: normalized.name, key: normalized.key, description: normalized.description, + groupName: normalized.groupName, + tags: toPersistenceJson(normalized.tags), enabled: normalized.enabled, expiresAt: normalized.expiresAt, maxCost: normalized.maxCost, @@ -111,6 +529,8 @@ export async function downstreamApiKeysRoutes(app: FastifyInstance) { name?: unknown; key?: unknown; description?: unknown; + groupName?: unknown; + tags?: unknown; enabled?: unknown; expiresAt?: unknown; maxCost?: unknown; @@ -140,6 +560,8 @@ export async function downstreamApiKeysRoutes(app: FastifyInstance) { name: request.body?.name ?? existing.name, key: request.body?.key ?? existing.key, description: request.body?.description ?? existing.description, + groupName: request.body?.groupName ?? existing.groupName, + tags: request.body?.tags ?? existingView.tags, enabled: request.body?.enabled ?? existing.enabled, expiresAt: request.body?.expiresAt ?? existing.expiresAt, maxCost: request.body?.maxCost ?? existing.maxCost, @@ -161,6 +583,13 @@ export async function downstreamApiKeysRoutes(app: FastifyInstance) { if (!validateKeyShape(normalized.key)) { return reply.code(400).send({ success: false, message: 'key 必须以 sk- 开头且长度至少 6' }); } + const policyRefError = await validatePolicyReferences({ + allowedRouteIds: normalized.allowedRouteIds, + siteWeightMultipliers: normalized.siteWeightMultipliers, + }); + if (policyRefError) { + return reply.code(400).send({ success: false, message: policyRefError }); + } const nowIso = new Date().toISOString(); try { @@ -168,6 +597,8 @@ export async function downstreamApiKeysRoutes(app: FastifyInstance) { name: normalized.name, key: normalized.key, description: normalized.description, + groupName: normalized.groupName, + tags: toPersistenceJson(normalized.tags), enabled: normalized.enabled, expiresAt: normalized.expiresAt, maxCost: normalized.maxCost, @@ -178,7 +609,7 @@ export async function downstreamApiKeysRoutes(app: FastifyInstance) { updatedAt: nowIso, }).where(eq(schema.downstreamApiKeys.id, id)).run(); - const updated = getDownstreamApiKeyById(id); + const updated = await getDownstreamApiKeyById(id); return { success: true, item: updated, @@ -197,7 +628,7 @@ export async function downstreamApiKeysRoutes(app: FastifyInstance) { return reply.code(400).send({ success: false, message: 'id 无效' }); } - const existing = getDownstreamApiKeyById(id); + const existing = await getDownstreamApiKeyById(id); if (!existing) { return reply.code(404).send({ success: false, message: 'API key 不存在' }); } @@ -210,7 +641,7 @@ export async function downstreamApiKeysRoutes(app: FastifyInstance) { return { success: true, - item: getDownstreamApiKeyById(id), + item: await getDownstreamApiKeyById(id), }; }); @@ -220,7 +651,7 @@ export async function downstreamApiKeysRoutes(app: FastifyInstance) { return reply.code(400).send({ success: false, message: 'id 无效' }); } - const existing = getDownstreamApiKeyById(id); + const existing = await getDownstreamApiKeyById(id); if (!existing) { return reply.code(404).send({ success: false, message: 'API key 不存在' }); } @@ -231,4 +662,94 @@ export async function downstreamApiKeysRoutes(app: FastifyInstance) { return { success: true }; }); + + app.post<{ Body?: { + ids?: number[]; + action?: string; + groupOperation?: string; + groupName?: unknown; + tagOperation?: string; + tags?: unknown; + } }>('/api/downstream-keys/batch', async (request, reply) => { + const ids = normalizeBatchIds(request.body?.ids); + const action = String(request.body?.action || '').trim(); + if (ids.length === 0) { + return reply.code(400).send({ success: false, message: 'ids is required' }); + } + if (!['enable', 'disable', 'delete', 'resetUsage', 'updateMetadata'].includes(action)) { + return reply.code(400).send({ success: false, message: 'Invalid action' }); + } + + const groupOperation = String(request.body?.groupOperation || 'keep').trim(); + const tagOperation = String(request.body?.tagOperation || 'keep').trim(); + const normalizedGroupName = normalizeDownstreamApiKeyPayload({ groupName: request.body?.groupName }).groupName; + const normalizedTags = normalizeDownstreamApiKeyPayload({ tags: request.body?.tags }).tags; + + if (action === 'updateMetadata') { + if (!['keep', 'set', 'clear'].includes(groupOperation)) { + return reply.code(400).send({ success: false, message: 'Invalid groupOperation' }); + } + if (!['keep', 'append'].includes(tagOperation)) { + return reply.code(400).send({ success: false, message: 'Invalid tagOperation' }); + } + if (groupOperation === 'set' && !normalizedGroupName) { + return reply.code(400).send({ success: false, message: 'groupName is required when groupOperation=set' }); + } + if (tagOperation === 'append' && normalizedTags.length === 0) { + return reply.code(400).send({ success: false, message: 'tags is required when tagOperation=append' }); + } + } + + const successIds: number[] = []; + const failedItems: Array<{ id: number; message: string }> = []; + + for (const id of ids) { + try { + const existing = await getDownstreamApiKeyById(id); + if (!existing) { + failedItems.push({ id, message: 'API key 不存在' }); + continue; + } + + if (action === 'delete') { + await db.delete(schema.downstreamApiKeys) + .where(eq(schema.downstreamApiKeys.id, id)) + .run(); + } else if (action === 'resetUsage') { + await db.update(schema.downstreamApiKeys).set({ + usedCost: 0, + usedRequests: 0, + updatedAt: new Date().toISOString(), + }).where(eq(schema.downstreamApiKeys.id, id)).run(); + } else if (action === 'updateMetadata') { + const nextGroupName = groupOperation === 'keep' + ? existing.groupName + : (groupOperation === 'clear' ? null : normalizedGroupName); + const nextTags = tagOperation === 'append' + ? Array.from(new Map([...existing.tags, ...normalizedTags].map((tag) => [tag.toLowerCase(), tag])).values()) + : existing.tags; + await db.update(schema.downstreamApiKeys).set({ + groupName: nextGroupName, + tags: toPersistenceJson(nextTags), + updatedAt: new Date().toISOString(), + }).where(eq(schema.downstreamApiKeys.id, id)).run(); + } else { + await db.update(schema.downstreamApiKeys).set({ + enabled: action === 'enable', + updatedAt: new Date().toISOString(), + }).where(eq(schema.downstreamApiKeys.id, id)).run(); + } + + successIds.push(id); + } catch (error: any) { + failedItems.push({ id, message: error?.message || 'Batch operation failed' }); + } + } + + return { + success: true, + successIds, + failedItems, + }; + }); } diff --git a/src/server/routes/api/monitor.ts b/src/server/routes/api/monitor.ts index a818928f..7dd375b2 100644 --- a/src/server/routes/api/monitor.ts +++ b/src/server/routes/api/monitor.ts @@ -1,21 +1,39 @@ import { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'; import { db, schema } from '../../db/index.js'; +import { upsertSetting } from '../../db/upsertSetting.js'; import { config } from '../../config.js'; import { eq } from 'drizzle-orm'; +import { createRateLimitGuard } from '../../middleware/requestRateLimit.js'; const MONITOR_AUTH_COOKIE = 'meta_monitor_auth'; const LDOH_BASE_URL = 'https://ldoh.105117.xyz'; const LDOH_COOKIE_SETTING_KEY = 'monitor_ldoh_cookie'; -async function upsertSetting(key: string, value: unknown) { - await db.insert(schema.settings) - .values({ key, value: JSON.stringify(value) }) - .onConflictDoUpdate({ - target: schema.settings.key, - set: { value: JSON.stringify(value) }, - }) - .run(); -} +const limitMonitorConfigRead = createRateLimitGuard({ + bucket: 'monitor-config-read', + max: 30, + windowMs: 60_000, +}); + +const limitMonitorConfigWrite = createRateLimitGuard({ + bucket: 'monitor-config-write', + max: 10, + windowMs: 60_000, +}); + +const limitMonitorSession = createRateLimitGuard({ + bucket: 'monitor-session', + max: 10, + windowMs: 60_000, +}); + +const limitMonitorProxy = createRateLimitGuard({ + bucket: 'monitor-proxy', + max: 60, + windowMs: 60_000, +}); + + async function getSettingString(key: string): Promise<string> { const row = await db.select().from(schema.settings).where(eq(schema.settings.key, key)).get(); @@ -110,7 +128,7 @@ function resolveLdohProxyPath(request: FastifyRequest): string { } export async function monitorRoutes(app: FastifyInstance) { - app.get('/api/monitor/config', async () => { + app.get('/api/monitor/config', { preHandler: [limitMonitorConfigRead] }, async () => { const ldohCookie = await getSettingString(LDOH_COOKIE_SETTING_KEY); return { ldohCookieConfigured: !!ldohCookie, @@ -118,7 +136,10 @@ export async function monitorRoutes(app: FastifyInstance) { }; }); - app.put<{ Body: { ldohCookie?: string | null } }>('/api/monitor/config', async (request, reply) => { + app.put<{ Body: { ldohCookie?: string | null } }>( + '/api/monitor/config', + { preHandler: [limitMonitorConfigWrite] }, + async (request, reply) => { const raw = String(request.body?.ldohCookie || '').trim(); if (!raw) { await upsertSetting(LDOH_COOKIE_SETTING_KEY, ''); @@ -137,9 +158,10 @@ export async function monitorRoutes(app: FastifyInstance) { ldohCookieConfigured: true, ldohCookieMasked: maskCookieValue(normalized), }; - }); + }, + ); - app.post('/api/monitor/session', async (_, reply) => { + app.post('/api/monitor/session', { preHandler: [limitMonitorSession] }, async (_, reply) => { // HttpOnly cookie for iframe proxy auth within current origin. reply.header( 'Set-Cookie', @@ -209,7 +231,7 @@ export async function monitorRoutes(app: FastifyInstance) { return reply.send(buffer); }; - app.all('/monitor-proxy/ldoh', handleLdohProxy); - app.all('/monitor-proxy/ldoh/', handleLdohProxy); - app.all('/monitor-proxy/ldoh/*', handleLdohProxy); + app.all('/monitor-proxy/ldoh', { preHandler: [limitMonitorProxy] }, handleLdohProxy); + app.all('/monitor-proxy/ldoh/', { preHandler: [limitMonitorProxy] }, handleLdohProxy); + app.all('/monitor-proxy/ldoh/*', { preHandler: [limitMonitorProxy] }, handleLdohProxy); } diff --git a/src/server/routes/api/oauth.registration.test.ts b/src/server/routes/api/oauth.registration.test.ts new file mode 100644 index 00000000..291c44d9 --- /dev/null +++ b/src/server/routes/api/oauth.registration.test.ts @@ -0,0 +1,32 @@ +import Fastify from 'fastify'; +import { afterEach, describe, expect, it } from 'vitest'; +import { oauthRoutes } from './oauth.js'; +import { isPublicApiRoute } from '../../desktop.js'; + +describe('oauth route registration', () => { + const apps: Array<Awaited<ReturnType<typeof Fastify>>> = []; + + afterEach(async () => { + await Promise.all(apps.splice(0).map((app) => app.close())); + }); + + it('registers oauth routes on a Fastify instance at runtime', async () => { + const app = Fastify(); + apps.push(app); + await app.register(oauthRoutes); + + const routes = app.printRoutes(); + expect(routes).toContain('providers (GET, HEAD)'); + expect(routes).toContain(':provider'); + expect(routes).toContain('sessions/'); + expect(routes).toContain(':state (GET, HEAD)'); + expect(routes).toContain('onnections (GET, HEAD)'); + expect(routes).toContain('allback/'); + }); + + it('treats oauth callback route as a public desktop API route', () => { + expect(isPublicApiRoute('/api/oauth/callback/codex')).toBe(true); + expect(isPublicApiRoute('/api/oauth/callback/claude')).toBe(true); + expect(isPublicApiRoute('/api/oauth/providers')).toBe(false); + }); +}); diff --git a/src/server/routes/api/oauth.test.ts b/src/server/routes/api/oauth.test.ts new file mode 100644 index 00000000..b784ce10 --- /dev/null +++ b/src/server/routes/api/oauth.test.ts @@ -0,0 +1,1712 @@ +import Fastify, { type FastifyInstance } from 'fastify'; +import { eq } from 'drizzle-orm'; +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { mkdtempSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +const fetchMock = vi.fn(); +const undiciAgentCtorMock = vi.fn(); +const undiciProxyAgentCtorMock = vi.fn(); + +vi.mock('undici', () => ({ + fetch: (...args: unknown[]) => fetchMock(...args), + Agent: class MockUndiciAgent { + constructor(...args: unknown[]) { + undiciAgentCtorMock(...args); + } + }, + ProxyAgent: class MockUndiciProxyAgent { + constructor(...args: unknown[]) { + undiciProxyAgentCtorMock(...args); + } + }, +})); + +type DbModule = typeof import('../../db/index.js'); + +function buildJwt(payload: Record<string, unknown>) { + const encode = (value: unknown) => Buffer.from(JSON.stringify(value)) + .toString('base64url'); + return `${encode({ alg: 'none', typ: 'JWT' })}.${encode(payload)}.signature`; +} + +function createDeferred<T>() { + let resolve!: (value: T | PromiseLike<T>) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise<T>((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +describe('oauth routes', { timeout: 15_000 }, () => { + let app: FastifyInstance; + let db: DbModule['db']; + let schema: DbModule['schema']; + let dataDir = ''; + + beforeAll(async () => { + dataDir = mkdtempSync(join(tmpdir(), 'metapi-oauth-routes-')); + process.env.DATA_DIR = dataDir; + + await import('../../db/migrate.js'); + const dbModule = await import('../../db/index.js'); + const routesModule = await import('./oauth.js'); + db = dbModule.db; + schema = dbModule.schema; + + app = Fastify(); + await app.register(routesModule.oauthRoutes); + }); + + beforeEach(async () => { + fetchMock.mockReset(); + undiciAgentCtorMock.mockReset(); + undiciProxyAgentCtorMock.mockReset(); + await db.delete(schema.routeChannels).run(); + await db.delete(schema.tokenRoutes).run(); + await db.delete(schema.tokenModelAvailability).run(); + await db.delete(schema.modelAvailability).run(); + await db.delete(schema.accountTokens).run(); + await db.delete(schema.accounts).run(); + await db.delete(schema.settings).run(); + await db.delete(schema.sites).run(); + const { invalidateSiteProxyCache } = await import('../../services/siteProxy.js'); + invalidateSiteProxyCache(); + }); + + afterAll(async () => { + await app.close(); + delete process.env.DATA_DIR; + }); + + it('lists multi-provider oauth metadata', async () => { + const response = await app.inject({ + method: 'GET', + url: '/api/oauth/providers', + }); + + expect(response.statusCode).toBe(200); + expect(response.json()).toMatchObject({ + providers: expect.arrayContaining([ + expect.objectContaining({ + provider: 'codex', + platform: 'codex', + enabled: true, + loginType: 'oauth', + }), + expect.objectContaining({ + provider: 'claude', + platform: 'claude', + enabled: true, + loginType: 'oauth', + }), + expect.objectContaining({ + provider: 'gemini-cli', + platform: 'gemini-cli', + enabled: true, + loginType: 'oauth', + requiresProjectId: true, + }), + expect.objectContaining({ + provider: 'antigravity', + platform: 'antigravity', + enabled: true, + loginType: 'oauth', + requiresProjectId: false, + }), + ]), + }); + }); + + it('starts an antigravity oauth session and returns provider metadata', async () => { + const startResponse = await app.inject({ + method: 'POST', + url: '/api/oauth/providers/antigravity/start', + headers: { + host: 'metapi.example', + 'x-forwarded-proto': 'https', + }, + }); + + expect(startResponse.statusCode).toBe(200); + const startBody = startResponse.json() as { + provider: string; + state: string; + authorizationUrl: string; + instructions?: { + redirectUri: string; + callbackPort: number; + callbackPath: string; + manualCallbackDelayMs: number; + }; + }; + expect(startBody.provider).toBe('antigravity'); + expect(startBody.state).toMatch(/^[a-zA-Z0-9_-]{20,}$/); + expect(startBody.authorizationUrl).toContain('https://accounts.google.com/o/oauth2/v2/auth?'); + expect(startBody.authorizationUrl).toContain(encodeURIComponent('http://localhost:51121/oauth-callback')); + expect(startBody.authorizationUrl).toContain(`state=${encodeURIComponent(startBody.state)}`); + expect(startBody.instructions).toMatchObject({ + redirectUri: 'http://localhost:51121/oauth-callback', + callbackPort: 51121, + callbackPath: '/oauth-callback', + manualCallbackDelayMs: 15000, + }); + }); + + it('discovers the Antigravity project via onboardUser polling when loadCodeAssist does not return one', async () => { + fetchMock + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + access_token: 'antigravity-access-token', + refresh_token: 'antigravity-refresh-token', + expires_in: 3600, + token_type: 'Bearer', + scope: 'cloud-platform', + }), + text: async () => JSON.stringify({ ok: true }), + }) + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ email: 'antigravity-user@example.com' }), + text: async () => JSON.stringify({ email: 'antigravity-user@example.com' }), + }) + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + allowedTiers: [ + { id: 'legacy-tier', isDefault: true }, + ], + }), + text: async () => JSON.stringify({ ok: true }), + }) + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + done: false, + }), + text: async () => JSON.stringify({ done: false }), + }) + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + done: true, + response: { + cloudaicompanionProject: { + id: 'antigravity-auto-project', + }, + }, + }), + text: async () => JSON.stringify({ done: true }), + }) + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + models: { + 'gemini-3-pro-preview': { displayName: 'Gemini 3 Pro Preview' }, + }, + }), + text: async () => JSON.stringify({ ok: true }), + }); + + const startResponse = await app.inject({ + method: 'POST', + url: '/api/oauth/providers/antigravity/start', + headers: { + host: 'metapi.example', + 'x-forwarded-proto': 'https', + }, + }); + expect(startResponse.statusCode).toBe(200); + const startBody = startResponse.json() as { state: string }; + + const callbackResponse = await app.inject({ + method: 'POST', + url: `/api/oauth/sessions/${encodeURIComponent(startBody.state)}/manual-callback`, + payload: { + callbackUrl: `http://localhost:51121/oauth-callback?state=${encodeURIComponent(startBody.state)}&code=antigravity-oauth-code-123`, + }, + }); + expect(callbackResponse.statusCode).toBe(200); + expect(callbackResponse.json()).toEqual({ success: true }); + + const sessionResponse = await app.inject({ + method: 'GET', + url: `/api/oauth/sessions/${startBody.state}`, + }); + expect(sessionResponse.statusCode).toBe(200); + expect(sessionResponse.json()).toMatchObject({ + provider: 'antigravity', + status: 'success', + }); + + const accounts = await db.select().from(schema.accounts).all(); + expect(accounts).toHaveLength(1); + expect(accounts[0]).toMatchObject({ + oauthProvider: 'antigravity', + oauthProjectId: 'antigravity-auto-project', + username: 'antigravity-user@example.com', + accessToken: 'antigravity-access-token', + }); + + const parsed = JSON.parse(accounts[0]?.extraConfig || '{}'); + expect(parsed.oauth).toMatchObject({ + email: 'antigravity-user@example.com', + refreshToken: 'antigravity-refresh-token', + }); + expect(parsed.oauth).not.toHaveProperty('provider'); + expect(parsed.oauth).not.toHaveProperty('projectId'); + + expect(String(fetchMock.mock.calls[2]?.[0] || '')).toContain('/v1internal:loadCodeAssist'); + expect(String(fetchMock.mock.calls[3]?.[0] || '')).toContain('/v1internal:onboardUser'); + expect(String(fetchMock.mock.calls[4]?.[0] || '')).toContain('/v1internal:onboardUser'); + }); + + it('starts a codex oauth session and exposes pending status', async () => { + const startResponse = await app.inject({ + method: 'POST', + url: '/api/oauth/providers/codex/start', + headers: { + host: 'metapi.example', + 'x-forwarded-proto': 'https', + }, + }); + + expect(startResponse.statusCode).toBe(200); + const startBody = startResponse.json() as { + provider: string; + state: string; + authorizationUrl: string; + instructions?: { + redirectUri: string; + callbackPort: number; + callbackPath: string; + manualCallbackDelayMs: number; + sshTunnelCommand?: string; + }; + }; + expect(startBody.provider).toBe('codex'); + expect(startBody.state).toMatch(/^[a-zA-Z0-9_-]{20,}$/); + expect(startBody.authorizationUrl).toContain('https://auth.openai.com/oauth/authorize?'); + expect(startBody.authorizationUrl).toContain('client_id='); + expect(startBody.authorizationUrl).toContain(encodeURIComponent('http://localhost:1455/auth/callback')); + expect(startBody.authorizationUrl).toContain(`state=${encodeURIComponent(startBody.state)}`); + expect(startBody.authorizationUrl).toContain('code_challenge='); + expect(startBody.instructions).toMatchObject({ + redirectUri: 'http://localhost:1455/auth/callback', + callbackPort: 1455, + callbackPath: '/auth/callback', + manualCallbackDelayMs: 15000, + sshTunnelCommand: 'ssh -L 1455:127.0.0.1:1455 root@metapi.example -p 22', + }); + + const sessionResponse = await app.inject({ + method: 'GET', + url: `/api/oauth/sessions/${startBody.state}`, + }); + expect(sessionResponse.statusCode).toBe(200); + expect(sessionResponse.json()).toMatchObject({ + provider: 'codex', + state: startBody.state, + status: 'pending', + }); + }); + + it('keeps the codex loopback callback for local origins', async () => { + const startResponse = await app.inject({ + method: 'POST', + url: '/api/oauth/providers/codex/start', + headers: { + host: 'localhost:4000', + }, + }); + + expect(startResponse.statusCode).toBe(200); + const startBody = startResponse.json() as { + provider: string; + state: string; + authorizationUrl: string; + }; + expect(startBody.provider).toBe('codex'); + expect(startBody.authorizationUrl).toContain(encodeURIComponent('http://localhost:1455/auth/callback')); + expect(startBody.authorizationUrl).not.toContain(encodeURIComponent('http://localhost:4000/api/oauth/callback/codex')); + }); + + it('handles manual codex callback submission, creates oauth-backed account, and discovers plan models', async () => { + const jwt = buildJwt({ + email: 'codex-user@example.com', + 'https://api.openai.com/auth': { + chatgpt_account_id: 'chatgpt-account-123', + chatgpt_plan_type: 'plus', + }, + }); + fetchMock + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + access_token: 'oauth-access-token', + refresh_token: 'oauth-refresh-token', + id_token: jwt, + expires_in: 3600, + token_type: 'Bearer', + }), + text: async () => JSON.stringify({ ok: true }), + }) + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + models: [ + { id: 'gpt-5.2-codex' }, + { id: 'gpt-5' }, + { id: 'gpt-5.4' }, + ], + }), + text: async () => JSON.stringify({ ok: true }), + }); + + const startResponse = await app.inject({ + method: 'POST', + url: '/api/oauth/providers/codex/start', + headers: { + host: 'metapi.example', + 'x-forwarded-proto': 'https', + }, + }); + const startBody = startResponse.json() as { state: string }; + + const callbackResponse = await app.inject({ + method: 'POST', + url: `/api/oauth/sessions/${encodeURIComponent(startBody.state)}/manual-callback`, + payload: { + callbackUrl: `http://localhost:1455/auth/callback?state=${encodeURIComponent(startBody.state)}&code=oauth-code-123`, + }, + }); + expect(callbackResponse.statusCode).toBe(200); + expect(callbackResponse.json()).toEqual({ success: true }); + expect(String(fetchMock.mock.calls[0]?.[1]?.body || '')).toContain( + 'redirect_uri=http%3A%2F%2Flocalhost%3A1455%2Fauth%2Fcallback', + ); + + const sessionResponse = await app.inject({ + method: 'GET', + url: `/api/oauth/sessions/${startBody.state}`, + }); + expect(sessionResponse.statusCode).toBe(200); + const sessionBody = sessionResponse.json() as { + provider: string; + status: string; + accountId?: number; + siteId?: number; + }; + expect(sessionBody).toMatchObject({ + provider: 'codex', + status: 'success', + }); + expect(sessionBody.accountId).toBeTypeOf('number'); + expect(sessionBody.siteId).toBeTypeOf('number'); + + const sites = await db.select().from(schema.sites).all(); + expect(sites).toHaveLength(1); + expect(sites[0]).toMatchObject({ + platform: 'codex', + url: 'https://chatgpt.com/backend-api/codex', + status: 'active', + }); + + const accounts = await db.select().from(schema.accounts).all(); + expect(accounts).toHaveLength(1); + expect(accounts[0]).toMatchObject({ + siteId: sites[0]?.id, + username: 'codex-user@example.com', + accessToken: 'oauth-access-token', + checkinEnabled: false, + status: 'active', + }); + expect(JSON.parse(accounts[0]?.extraConfig || '{}')).toMatchObject({ + credentialMode: 'session', + oauth: { + email: 'codex-user@example.com', + planType: 'plus', + refreshToken: 'oauth-refresh-token', + idToken: jwt, + }, + }); + const codexStoredOauth = JSON.parse(accounts[0]?.extraConfig || '{}').oauth; + expect(codexStoredOauth).not.toHaveProperty('provider'); + expect(codexStoredOauth).not.toHaveProperty('accountId'); + + const models = await db.select().from(schema.modelAvailability).all(); + const modelNames = models.map((row) => row.modelName); + expect(modelNames.sort()).toEqual(['gpt-5', 'gpt-5.2-codex', 'gpt-5.4']); + }); + + it('includes dispatcher for codex token exchange when oauth site enables the system proxy', async () => { + const jwt = buildJwt({ + email: 'codex-proxy@example.com', + 'https://api.openai.com/auth': { + chatgpt_account_id: 'chatgpt-account-proxy', + chatgpt_plan_type: 'plus', + }, + }); + await db.insert(schema.settings).values({ + key: 'system_proxy_url', + value: JSON.stringify('http://127.0.0.1:7890'), + }).run(); + await db.insert(schema.sites).values({ + name: 'ChatGPT Codex OAuth', + url: 'https://chatgpt.com/backend-api/codex', + platform: 'codex', + status: 'active', + useSystemProxy: true, + }).run(); + + fetchMock + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + access_token: 'oauth-access-token', + refresh_token: 'oauth-refresh-token', + id_token: jwt, + expires_in: 3600, + token_type: 'Bearer', + }), + text: async () => JSON.stringify({ ok: true }), + }) + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + models: [{ id: 'gpt-5.4' }], + }), + text: async () => JSON.stringify({ ok: true }), + }); + + const startResponse = await app.inject({ + method: 'POST', + url: '/api/oauth/providers/codex/start', + headers: { + host: 'metapi.example', + 'x-forwarded-proto': 'https', + }, + }); + expect(startResponse.statusCode).toBe(200); + const startBody = startResponse.json() as { state: string }; + + const callbackResponse = await app.inject({ + method: 'POST', + url: `/api/oauth/sessions/${encodeURIComponent(startBody.state)}/manual-callback`, + payload: { + callbackUrl: `http://localhost:1455/auth/callback?state=${encodeURIComponent(startBody.state)}&code=oauth-code-proxy`, + }, + }); + expect(callbackResponse.statusCode).toBe(200); + const codexTokenCall = fetchMock.mock.calls.find((call) => String(call[0] || '') === 'https://auth.openai.com/oauth/token'); + const codexTokenFetchInit = codexTokenCall?.[1] as Record<string, unknown> | undefined; + expect(codexTokenFetchInit).toEqual(expect.objectContaining({ + dispatcher: expect.anything(), + })); + }); + + it('marks oauth session as error and avoids creating a connection when manual codex callback model discovery fails', async () => { + const jwt = buildJwt({ + email: 'codex-fail@example.com', + 'https://api.openai.com/auth': { + chatgpt_account_id: 'chatgpt-account-fail', + chatgpt_plan_type: 'team', + }, + }); + fetchMock + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + access_token: 'oauth-access-token', + refresh_token: 'oauth-refresh-token', + id_token: jwt, + expires_in: 3600, + token_type: 'Bearer', + }), + text: async () => JSON.stringify({ ok: true }), + }) + .mockResolvedValueOnce({ + ok: false, + status: 403, + json: async () => ({ error: 'forbidden' }), + text: async () => 'forbidden', + }); + + const startResponse = await app.inject({ + method: 'POST', + url: '/api/oauth/providers/codex/start', + headers: { + host: 'metapi.example', + 'x-forwarded-proto': 'https', + }, + }); + const startBody = startResponse.json() as { state: string }; + + const callbackResponse = await app.inject({ + method: 'POST', + url: `/api/oauth/sessions/${encodeURIComponent(startBody.state)}/manual-callback`, + payload: { + callbackUrl: `http://localhost:1455/auth/callback?state=${encodeURIComponent(startBody.state)}&code=oauth-code-456`, + }, + }); + expect(callbackResponse.statusCode).toBe(500); + expect(callbackResponse.json()).toMatchObject({ + message: expect.stringContaining('HTTP 403'), + }); + + const sessionResponse = await app.inject({ + method: 'GET', + url: `/api/oauth/sessions/${startBody.state}`, + }); + expect(sessionResponse.statusCode).toBe(200); + expect(sessionResponse.json()).toMatchObject({ + provider: 'codex', + status: 'error', + error: expect.stringContaining('HTTP 403'), + }); + + const accounts = await db.select().from(schema.accounts).all(); + expect(accounts).toEqual([]); + + const connectionsResponse = await app.inject({ + method: 'GET', + url: '/api/oauth/connections', + }); + expect(connectionsResponse.statusCode).toBe(200); + expect(connectionsResponse.json()).toMatchObject({ + items: [], + total: 0, + limit: 50, + offset: 0, + }); + }); + + it('keeps the existing codex connection intact when a rebind callback fails model discovery', async () => { + const originalJwt = buildJwt({ + email: 'codex-existing@example.com', + 'https://api.openai.com/auth': { + chatgpt_account_id: 'chatgpt-account-existing', + chatgpt_plan_type: 'team', + }, + }); + const site = await db.insert(schema.sites).values({ + name: 'ChatGPT Codex OAuth', + url: 'https://chatgpt.com/backend-api/codex', + platform: 'codex', + status: 'active', + }).returning().get(); + const existing = await db.insert(schema.accounts).values({ + siteId: site.id, + username: 'codex-existing@example.com', + accessToken: 'stable-access-token', + apiToken: null, + status: 'active', + oauthProvider: 'codex', + oauthAccountKey: 'chatgpt-account-existing', + extraConfig: JSON.stringify({ + credentialMode: 'session', + oauth: { + provider: 'codex', + accountId: 'chatgpt-account-existing', + accountKey: 'chatgpt-account-existing', + email: 'codex-existing@example.com', + planType: 'team', + refreshToken: 'stable-refresh-token', + idToken: originalJwt, + }, + }), + }).returning().get(); + + const reboundJwt = buildJwt({ + email: 'codex-existing@example.com', + 'https://api.openai.com/auth': { + chatgpt_account_id: 'chatgpt-account-rebound', + chatgpt_plan_type: 'plus', + }, + }); + fetchMock + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + access_token: 'rebound-access-token', + refresh_token: 'rebound-refresh-token', + id_token: reboundJwt, + expires_in: 3600, + token_type: 'Bearer', + }), + text: async () => JSON.stringify({ ok: true }), + }) + .mockResolvedValueOnce({ + ok: false, + status: 403, + json: async () => ({ error: 'forbidden' }), + text: async () => 'forbidden', + }); + + const startResponse = await app.inject({ + method: 'POST', + url: '/api/oauth/providers/codex/start', + headers: { + host: 'metapi.example', + 'x-forwarded-proto': 'https', + }, + payload: { + accountId: existing.id, + }, + }); + const startBody = startResponse.json() as { state: string }; + + const callbackResponse = await app.inject({ + method: 'POST', + url: `/api/oauth/sessions/${encodeURIComponent(startBody.state)}/manual-callback`, + payload: { + callbackUrl: `http://localhost:1455/auth/callback?state=${encodeURIComponent(startBody.state)}&code=oauth-code-rebind-fail`, + }, + }); + expect(callbackResponse.statusCode).toBe(500); + + const stored = await db.select().from(schema.accounts).where(eq(schema.accounts.id, existing.id)).get(); + expect(stored).toMatchObject({ + id: existing.id, + username: 'codex-existing@example.com', + accessToken: 'stable-access-token', + oauthAccountKey: 'chatgpt-account-existing', + }); + expect(JSON.parse(stored?.extraConfig || '{}')).toMatchObject({ + oauth: { + accountId: 'chatgpt-account-existing', + refreshToken: 'stable-refresh-token', + idToken: originalJwt, + }, + }); + + const accounts = await db.select().from(schema.accounts).all(); + expect(accounts).toHaveLength(1); + expect(accounts[0]?.oauthAccountKey).toBe('chatgpt-account-existing'); + }); + + it('keeps the existing codex account non-active while rebind model discovery is still pending', async () => { + const originalJwt = buildJwt({ + email: 'codex-existing@example.com', + 'https://api.openai.com/auth': { + chatgpt_account_id: 'chatgpt-account-existing', + chatgpt_plan_type: 'team', + }, + }); + const site = await db.insert(schema.sites).values({ + name: 'ChatGPT Codex OAuth', + url: 'https://chatgpt.com/backend-api/codex', + platform: 'codex', + status: 'active', + }).returning().get(); + const existing = await db.insert(schema.accounts).values({ + siteId: site.id, + username: 'codex-existing@example.com', + accessToken: 'stable-access-token', + apiToken: null, + status: 'active', + oauthProvider: 'codex', + oauthAccountKey: 'chatgpt-account-existing', + extraConfig: JSON.stringify({ + credentialMode: 'session', + oauth: { + provider: 'codex', + accountId: 'chatgpt-account-existing', + accountKey: 'chatgpt-account-existing', + email: 'codex-existing@example.com', + planType: 'team', + refreshToken: 'stable-refresh-token', + idToken: originalJwt, + }, + }), + }).returning().get(); + + const reboundJwt = buildJwt({ + email: 'codex-existing@example.com', + 'https://api.openai.com/auth': { + chatgpt_account_id: 'chatgpt-account-rebound', + chatgpt_plan_type: 'plus', + }, + }); + const discoveryGate = createDeferred<ResponseLike>(); + fetchMock + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + access_token: 'rebound-access-token', + refresh_token: 'rebound-refresh-token', + id_token: reboundJwt, + expires_in: 3600, + token_type: 'Bearer', + }), + text: async () => JSON.stringify({ ok: true }), + }) + .mockImplementationOnce(() => discoveryGate.promise); + + const startResponse = await app.inject({ + method: 'POST', + url: '/api/oauth/providers/codex/start', + headers: { + host: 'metapi.example', + 'x-forwarded-proto': 'https', + }, + payload: { + accountId: existing.id, + }, + }); + const startBody = startResponse.json() as { state: string }; + + const callbackPromise = app.inject({ + method: 'POST', + url: `/api/oauth/sessions/${encodeURIComponent(startBody.state)}/manual-callback`, + payload: { + callbackUrl: `http://localhost:1455/auth/callback?state=${encodeURIComponent(startBody.state)}&code=oauth-code-rebind-pending`, + }, + }); + + while (fetchMock.mock.calls.length < 2) { + await new Promise((resolve) => setTimeout(resolve, 0)); + } + + const pendingRow = await db.select().from(schema.accounts).where(eq(schema.accounts.id, existing.id)).get(); + expect(pendingRow?.status).toBe('disabled'); + + discoveryGate.resolve({ + ok: true, + status: 200, + json: async () => ({ + models: [{ id: 'gpt-5.4' }], + }), + text: async () => JSON.stringify({ ok: true }), + } as ResponseLike); + + const callbackResponse = await callbackPromise; + expect(callbackResponse.statusCode).toBe(200); + + const stored = await db.select().from(schema.accounts).where(eq(schema.accounts.id, existing.id)).get(); + expect(stored).toMatchObject({ + id: existing.id, + status: 'active', + accessToken: 'rebound-access-token', + oauthAccountKey: 'chatgpt-account-rebound', + }); + }); + + it('fails codex oauth onboarding when token exchange does not expose chatgpt_account_id', async () => { + const jwt = buildJwt({ + email: 'codex-no-account@example.com', + 'https://api.openai.com/auth': { + chatgpt_plan_type: 'plus', + }, + }); + fetchMock.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + access_token: 'oauth-access-token', + refresh_token: 'oauth-refresh-token', + id_token: jwt, + expires_in: 3600, + token_type: 'Bearer', + }), + text: async () => JSON.stringify({ ok: true }), + }); + + const startResponse = await app.inject({ + method: 'POST', + url: '/api/oauth/providers/codex/start', + headers: { + host: 'metapi.example', + 'x-forwarded-proto': 'https', + }, + }); + const startBody = startResponse.json() as { state: string }; + + const callbackResponse = await app.inject({ + method: 'POST', + url: `/api/oauth/sessions/${encodeURIComponent(startBody.state)}/manual-callback`, + payload: { + callbackUrl: `http://localhost:1455/auth/callback?state=${encodeURIComponent(startBody.state)}&code=oauth-code-no-account-id`, + }, + }); + expect(callbackResponse.statusCode).toBe(500); + expect(callbackResponse.json()).toMatchObject({ + message: expect.stringContaining('chatgpt_account_id'), + }); + + const accounts = await db.select().from(schema.accounts).all(); + expect(accounts).toEqual([]); + }); + + it('marks gemini oauth session as error when token exchange fails before account persistence', async () => { + fetchMock.mockResolvedValueOnce({ + ok: false, + status: 400, + json: async () => ({ + error: 'invalid_grant', + error_description: 'Bad Request', + }), + text: async () => '{"error":"invalid_grant","error_description":"Bad Request"}', + }); + + const startResponse = await app.inject({ + method: 'POST', + url: '/api/oauth/providers/gemini-cli/start', + payload: { + projectId: 'demo-project', + }, + headers: { + host: 'metapi.example', + 'x-forwarded-proto': 'https', + }, + }); + const startBody = startResponse.json() as { state: string }; + + const callbackResponse = await app.inject({ + method: 'POST', + url: `/api/oauth/sessions/${encodeURIComponent(startBody.state)}/manual-callback`, + payload: { + callbackUrl: `http://localhost:8085/oauth2callback?state=${encodeURIComponent(startBody.state)}&code=oauth-code-gemini-123`, + }, + }); + expect(callbackResponse.statusCode).toBe(500); + expect(callbackResponse.json()).toMatchObject({ + message: expect.stringContaining('invalid_grant'), + }); + + const sessionResponse = await app.inject({ + method: 'GET', + url: `/api/oauth/sessions/${startBody.state}`, + }); + expect(sessionResponse.statusCode).toBe(200); + expect(sessionResponse.json()).toMatchObject({ + provider: 'gemini-cli', + status: 'error', + error: expect.stringContaining('invalid_grant'), + }); + + const accounts = await db.select().from(schema.accounts).all(); + expect(accounts).toEqual([]); + }); + + it('defaults Gemini CLI oauth to the first available Google Cloud project when projectId is omitted', async () => { + fetchMock + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + access_token: 'gemini-access-token', + refresh_token: 'gemini-refresh-token', + expires_in: 3600, + token_type: 'Bearer', + scope: 'cloud-platform', + }), + text: async () => JSON.stringify({ ok: true }), + }) + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + projects: [ + { projectId: 'first-project-id' }, + { projectId: 'second-project-id' }, + ], + }), + text: async () => JSON.stringify({ ok: true }), + }) + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + cloudaicompanionProject: { + id: 'first-project-id', + }, + allowedTiers: [ + { id: 'legacy-tier', isDefault: true }, + ], + }), + text: async () => JSON.stringify({ ok: true }), + }) + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + done: true, + response: { + cloudaicompanionProject: { + id: 'first-project-id', + }, + }, + }), + text: async () => JSON.stringify({ done: true }), + }) + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ state: 'ENABLED' }), + text: async () => JSON.stringify({ state: 'ENABLED' }), + }) + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ email: 'gemini-user@example.com' }), + text: async () => JSON.stringify({ email: 'gemini-user@example.com' }), + }) + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ state: 'ENABLED' }), + text: async () => JSON.stringify({ state: 'ENABLED' }), + }); + + const startResponse = await app.inject({ + method: 'POST', + url: '/api/oauth/providers/gemini-cli/start', + headers: { + host: 'metapi.example', + 'x-forwarded-proto': 'https', + }, + }); + expect(startResponse.statusCode).toBe(200); + const startBody = startResponse.json() as { state: string }; + + const callbackResponse = await app.inject({ + method: 'POST', + url: `/api/oauth/sessions/${encodeURIComponent(startBody.state)}/manual-callback`, + payload: { + callbackUrl: `http://localhost:8085/oauth2callback?state=${encodeURIComponent(startBody.state)}&code=gemini-oauth-code-123`, + }, + }); + expect(callbackResponse.statusCode).toBe(200); + expect(callbackResponse.json()).toEqual({ success: true }); + + const sessionResponse = await app.inject({ + method: 'GET', + url: `/api/oauth/sessions/${startBody.state}`, + }); + expect(sessionResponse.statusCode).toBe(200); + expect(sessionResponse.json()).toMatchObject({ + provider: 'gemini-cli', + status: 'success', + }); + + const accounts = await db.select().from(schema.accounts).all(); + expect(accounts).toHaveLength(1); + expect(accounts[0]).toMatchObject({ + oauthProvider: 'gemini-cli', + oauthProjectId: 'first-project-id', + username: 'gemini-user@example.com', + accessToken: 'gemini-access-token', + }); + + const parsed = JSON.parse(accounts[0]?.extraConfig || '{}'); + expect(parsed.oauth).toMatchObject({ + email: 'gemini-user@example.com', + refreshToken: 'gemini-refresh-token', + }); + expect(parsed.oauth).not.toHaveProperty('provider'); + expect(parsed.oauth).not.toHaveProperty('projectId'); + + expect(String(fetchMock.mock.calls[1]?.[0] || '')).toContain('cloudresourcemanager.googleapis.com/v1/projects'); + expect(String(fetchMock.mock.calls[2]?.[0] || '')).toContain('/v1internal:loadCodeAssist'); + expect(String(fetchMock.mock.calls[3]?.[0] || '')).toContain('/v1internal:onboardUser'); + expect(String(fetchMock.mock.calls[4]?.[0] || '')).toContain('/projects/first-project-id/services/cloudaicompanion.googleapis.com'); + expect(String(fetchMock.mock.calls[6]?.[0] || '')).toContain('/projects/first-project-id/services/cloudaicompanion.googleapis.com'); + }); + + it('onboards Gemini CLI into the backend project and auto-enables Cloud AI API when Google returns a free-tier project remap', async () => { + fetchMock + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + access_token: 'gemini-access-token', + refresh_token: 'gemini-refresh-token', + expires_in: 3600, + token_type: 'Bearer', + scope: 'cloud-platform', + }), + text: async () => JSON.stringify({ ok: true }), + }) + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + projects: [ + { projectId: 'gen-lang-client-source-project' }, + ], + }), + text: async () => JSON.stringify({ ok: true }), + }) + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + allowedTiers: [ + { id: 'legacy-tier', isDefault: true }, + ], + }), + text: async () => JSON.stringify({ ok: true }), + }) + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + done: true, + response: { + cloudaicompanionProject: { + id: 'gen-lang-client-0123456789', + }, + }, + }), + text: async () => JSON.stringify({ done: true }), + }) + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ state: 'DISABLED' }), + text: async () => JSON.stringify({ state: 'DISABLED' }), + }) + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ ok: true }), + text: async () => JSON.stringify({ ok: true }), + }) + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ email: 'gemini-free-user@example.com' }), + text: async () => JSON.stringify({ email: 'gemini-free-user@example.com' }), + }) + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ state: 'ENABLED' }), + text: async () => JSON.stringify({ state: 'ENABLED' }), + }); + + const startResponse = await app.inject({ + method: 'POST', + url: '/api/oauth/providers/gemini-cli/start', + headers: { + host: 'metapi.example', + 'x-forwarded-proto': 'https', + }, + }); + expect(startResponse.statusCode).toBe(200); + const startBody = startResponse.json() as { state: string }; + + const callbackResponse = await app.inject({ + method: 'POST', + url: `/api/oauth/sessions/${encodeURIComponent(startBody.state)}/manual-callback`, + payload: { + callbackUrl: `http://localhost:8085/oauth2callback?state=${encodeURIComponent(startBody.state)}&code=gemini-oauth-code-free-tier`, + }, + }); + expect(callbackResponse.statusCode).toBe(200); + expect(callbackResponse.json()).toEqual({ success: true }); + + const accounts = await db.select().from(schema.accounts).all(); + expect(accounts).toHaveLength(1); + expect(accounts[0]).toMatchObject({ + oauthProvider: 'gemini-cli', + oauthProjectId: 'gen-lang-client-0123456789', + username: 'gemini-free-user@example.com', + }); + + const parsed = JSON.parse(accounts[0]?.extraConfig || '{}'); + expect(parsed.oauth).toMatchObject({ + refreshToken: 'gemini-refresh-token', + }); + expect(parsed.oauth).not.toHaveProperty('projectId'); + + expect(String(fetchMock.mock.calls[2]?.[0] || '')).toContain('/v1internal:loadCodeAssist'); + expect(String(fetchMock.mock.calls[3]?.[0] || '')).toContain('/v1internal:onboardUser'); + expect(String(fetchMock.mock.calls[4]?.[0] || '')).toContain('/projects/gen-lang-client-0123456789/services/cloudaicompanion.googleapis.com'); + expect(String(fetchMock.mock.calls[5]?.[0] || '')).toContain('/projects/gen-lang-client-0123456789/services/cloudaicompanion.googleapis.com:enable'); + expect(String(fetchMock.mock.calls[7]?.[0] || '')).toContain('/projects/gen-lang-client-0123456789/services/cloudaicompanion.googleapis.com'); + }); + + it('surfaces Cloud AI API enable failures during Gemini CLI oauth setup and keeps the account rolled back', async () => { + fetchMock + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + access_token: 'gemini-access-token', + refresh_token: 'gemini-refresh-token', + expires_in: 3600, + token_type: 'Bearer', + scope: 'cloud-platform', + }), + text: async () => JSON.stringify({ ok: true }), + }) + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + projects: [ + { projectId: 'project-enable-failure' }, + ], + }), + text: async () => JSON.stringify({ ok: true }), + }) + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + cloudaicompanionProject: { + id: 'project-enable-failure', + }, + allowedTiers: [ + { id: 'legacy-tier', isDefault: true }, + ], + }), + text: async () => JSON.stringify({ ok: true }), + }) + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + done: true, + response: { + cloudaicompanionProject: { + id: 'project-enable-failure', + }, + }, + }), + text: async () => JSON.stringify({ done: true }), + }) + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ state: 'DISABLED' }), + text: async () => JSON.stringify({ state: 'DISABLED' }), + }) + .mockResolvedValueOnce({ + ok: false, + status: 403, + json: async () => ({ error: { message: 'Permission denied for project-enable-failure' } }), + text: async () => JSON.stringify({ error: { message: 'Permission denied for project-enable-failure' } }), + }); + + const startResponse = await app.inject({ + method: 'POST', + url: '/api/oauth/providers/gemini-cli/start', + headers: { + host: 'metapi.example', + 'x-forwarded-proto': 'https', + }, + }); + expect(startResponse.statusCode).toBe(200); + const startBody = startResponse.json() as { state: string }; + + const callbackResponse = await app.inject({ + method: 'POST', + url: `/api/oauth/sessions/${encodeURIComponent(startBody.state)}/manual-callback`, + payload: { + callbackUrl: `http://localhost:8085/oauth2callback?state=${encodeURIComponent(startBody.state)}&code=gemini-oauth-code-enable-failure`, + }, + }); + expect(callbackResponse.statusCode).toBe(500); + expect(callbackResponse.json()).toMatchObject({ + message: expect.stringContaining('project activation required'), + }); + + const sessionResponse = await app.inject({ + method: 'GET', + url: `/api/oauth/sessions/${startBody.state}`, + }); + expect(sessionResponse.statusCode).toBe(200); + expect(sessionResponse.json()).toMatchObject({ + provider: 'gemini-cli', + status: 'error', + error: expect.stringContaining('Permission denied for project-enable-failure'), + }); + + const accounts = await db.select().from(schema.accounts).all(); + expect(accounts).toEqual([]); + }); + + it('fails Gemini CLI oauth setup when the Google account has no available Cloud projects', async () => { + fetchMock + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + access_token: 'gemini-access-token', + refresh_token: 'gemini-refresh-token', + expires_in: 3600, + token_type: 'Bearer', + scope: 'cloud-platform', + }), + text: async () => JSON.stringify({ ok: true }), + }) + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + projects: [], + }), + text: async () => JSON.stringify({ ok: true }), + }); + + const startResponse = await app.inject({ + method: 'POST', + url: '/api/oauth/providers/gemini-cli/start', + headers: { + host: 'metapi.example', + 'x-forwarded-proto': 'https', + }, + }); + expect(startResponse.statusCode).toBe(200); + const startBody = startResponse.json() as { state: string }; + + const callbackResponse = await app.inject({ + method: 'POST', + url: `/api/oauth/sessions/${encodeURIComponent(startBody.state)}/manual-callback`, + payload: { + callbackUrl: `http://localhost:8085/oauth2callback?state=${encodeURIComponent(startBody.state)}&code=gemini-oauth-code-no-projects`, + }, + }); + expect(callbackResponse.statusCode).toBe(500); + expect(callbackResponse.json()).toMatchObject({ + message: 'no Google Cloud projects available for this account', + }); + + const sessionResponse = await app.inject({ + method: 'GET', + url: `/api/oauth/sessions/${startBody.state}`, + }); + expect(sessionResponse.statusCode).toBe(200); + expect(sessionResponse.json()).toMatchObject({ + provider: 'gemini-cli', + status: 'error', + error: 'no Google Cloud projects available for this account', + }); + + const accounts = await db.select().from(schema.accounts).all(); + expect(accounts).toEqual([]); + }); + + it('lists oauth connection health metadata and supports deleting the connection', async () => { + const site = await db.insert(schema.sites).values({ + name: 'ChatGPT Codex OAuth', + url: 'https://chatgpt.com/backend-api/codex', + platform: 'codex', + status: 'active', + }).returning().get(); + + const account = await db.insert(schema.accounts).values({ + siteId: site.id, + username: 'codex-user@example.com', + accessToken: 'oauth-access-token', + apiToken: null, + status: 'active', + oauthProvider: 'codex', + oauthAccountKey: 'chatgpt-account-123', + extraConfig: JSON.stringify({ + credentialMode: 'session', + oauth: { + provider: 'codex', + accountId: 'chatgpt-account-123', + email: 'codex-user@example.com', + planType: 'team', + idToken: buildJwt({ + email: 'codex-user@example.com', + 'https://api.openai.com/auth': { + chatgpt_account_id: 'chatgpt-account-123', + chatgpt_plan_type: 'team', + chatgpt_subscription_active_start: '2026-03-01T00:00:00.000Z', + chatgpt_subscription_active_until: '2026-04-01T00:00:00.000Z', + }, + }), + quota: { + status: 'supported', + source: 'reverse_engineered', + lastSyncAt: '2026-03-17T08:00:00.000Z', + lastLimitResetAt: '2026-03-17T13:00:00.000Z', + windows: { + fiveHour: { + supported: false, + message: 'official 5h quota window is not exposed by current codex oauth artifacts', + }, + sevenDay: { + supported: false, + message: 'official 7d quota window is not exposed by current codex oauth artifacts', + }, + }, + }, + modelDiscoveryStatus: 'abnormal', + lastModelSyncAt: '2026-03-17T08:00:00.000Z', + lastModelSyncError: 'Codex 模型获取失败(HTTP 403: forbidden)', + }, + }), + }).returning().get(); + + await db.insert(schema.modelAvailability).values({ + accountId: account.id, + modelName: 'gpt-5.2-codex', + available: true, + checkedAt: '2026-03-17T08:00:00.000Z', + }).run(); + + const route = await db.insert(schema.tokenRoutes).values({ + modelPattern: 'gpt-5.2-codex', + enabled: true, + }).returning().get(); + await db.insert(schema.routeChannels).values({ + routeId: route.id, + accountId: account.id, + tokenId: null, + priority: 0, + weight: 10, + enabled: true, + manualOverride: false, + }).run(); + + const listResponse = await app.inject({ + method: 'GET', + url: '/api/oauth/connections', + }); + + expect(listResponse.statusCode).toBe(200); + expect(listResponse.json()).toMatchObject({ + items: expect.arrayContaining([ + expect.objectContaining({ + accountId: account.id, + provider: 'codex', + email: 'codex-user@example.com', + status: 'abnormal', + quota: expect.objectContaining({ + status: 'supported', + source: 'reverse_engineered', + lastLimitResetAt: '2026-03-17T13:00:00.000Z', + subscription: expect.objectContaining({ + planType: 'team', + activeStart: '2026-03-01T00:00:00.000Z', + activeUntil: '2026-04-01T00:00:00.000Z', + }), + }), + routeChannelCount: 1, + lastModelSyncAt: '2026-03-17T08:00:00.000Z', + lastModelSyncError: 'Codex 模型获取失败(HTTP 403: forbidden)', + }), + ]), + total: 1, + limit: 50, + offset: 0, + }); + + const deleteResponse = await app.inject({ + method: 'DELETE', + url: `/api/oauth/connections/${account.id}`, + }); + expect(deleteResponse.statusCode).toBe(200); + expect(deleteResponse.json()).toEqual({ success: true }); + + const accounts = await db.select().from(schema.accounts).all(); + expect(accounts).toEqual([]); + }); + + it('refreshes oauth quota snapshots and marks unsupported providers explicitly', async () => { + const codexSite = await db.insert(schema.sites).values({ + name: 'ChatGPT Codex OAuth', + url: 'https://chatgpt.com/backend-api/codex', + platform: 'codex', + status: 'active', + }).returning().get(); + const antigravitySite = await db.insert(schema.sites).values({ + name: 'Antigravity OAuth', + url: 'https://example.com/antigravity', + platform: 'antigravity', + status: 'active', + }).returning().get(); + + const codexAccount = await db.insert(schema.accounts).values({ + siteId: codexSite.id, + username: 'codex-user@example.com', + accessToken: 'oauth-access-token', + status: 'active', + oauthProvider: 'codex', + oauthAccountKey: 'chatgpt-account-123', + extraConfig: JSON.stringify({ + credentialMode: 'session', + oauth: { + provider: 'codex', + accountId: 'chatgpt-account-123', + email: 'codex-user@example.com', + planType: 'plus', + idToken: buildJwt({ + email: 'codex-user@example.com', + 'https://api.openai.com/auth': { + chatgpt_account_id: 'chatgpt-account-123', + chatgpt_plan_type: 'plus', + chatgpt_subscription_active_start: '2026-03-01T00:00:00.000Z', + chatgpt_subscription_active_until: '2026-04-01T00:00:00.000Z', + }, + }), + }, + }), + }).returning().get(); + + const antigravityAccount = await db.insert(schema.accounts).values({ + siteId: antigravitySite.id, + username: 'ag-user@example.com', + accessToken: 'oauth-access-token', + status: 'active', + oauthProvider: 'antigravity', + oauthAccountKey: 'ag-account-123', + extraConfig: JSON.stringify({ + credentialMode: 'session', + oauth: { + provider: 'antigravity', + accountId: 'ag-account-123', + email: 'ag-user@example.com', + planType: 'pro', + }, + }), + }).returning().get(); + + const codexRefresh = await app.inject({ + method: 'POST', + url: `/api/oauth/connections/${codexAccount.id}/quota/refresh`, + }); + expect(codexRefresh.statusCode).toBe(200); + expect(codexRefresh.json()).toMatchObject({ + success: true, + quota: expect.objectContaining({ + status: 'supported', + subscription: expect.objectContaining({ + planType: 'plus', + activeStart: '2026-03-01T00:00:00.000Z', + activeUntil: '2026-04-01T00:00:00.000Z', + }), + }), + }); + + const antigravityRefresh = await app.inject({ + method: 'POST', + url: `/api/oauth/connections/${antigravityAccount.id}/quota/refresh`, + }); + expect(antigravityRefresh.statusCode).toBe(200); + expect(antigravityRefresh.json()).toMatchObject({ + success: true, + quota: expect.objectContaining({ + status: 'unsupported', + providerMessage: 'official quota windows are not exposed for antigravity oauth', + }), + }); + }); + + it('keeps multiple codex team workspaces with the same email as separate oauth connections', async () => { + const buildTokenExchange = (accountId: string) => ({ + ok: true, + status: 200, + json: async () => ({ + access_token: `oauth-access-token-${accountId}`, + refresh_token: `oauth-refresh-token-${accountId}`, + id_token: buildJwt({ + email: 'team-user@example.com', + 'https://api.openai.com/auth': { + chatgpt_account_id: accountId, + chatgpt_plan_type: 'team', + }, + }), + expires_in: 3600, + token_type: 'Bearer', + }), + text: async () => JSON.stringify({ ok: true }), + }); + + const buildModelDiscovery = (modelId: string) => ({ + ok: true, + status: 200, + json: async () => ({ + models: [{ id: modelId }], + }), + text: async () => JSON.stringify({ ok: true }), + }); + + fetchMock + .mockResolvedValueOnce(buildTokenExchange('chatgpt-team-account-a')) + .mockResolvedValueOnce(buildModelDiscovery('gpt-5.4')) + .mockResolvedValueOnce(buildTokenExchange('chatgpt-team-account-b')) + .mockResolvedValueOnce(buildModelDiscovery('gpt-5.4')); + + const startFirstResponse = await app.inject({ + method: 'POST', + url: '/api/oauth/providers/codex/start', + headers: { + host: 'metapi.example', + 'x-forwarded-proto': 'https', + }, + }); + const firstSession = startFirstResponse.json() as { state: string }; + + const submitFirstCallback = await app.inject({ + method: 'POST', + url: `/api/oauth/sessions/${encodeURIComponent(firstSession.state)}/manual-callback`, + payload: { + callbackUrl: `http://localhost:1455/auth/callback?state=${encodeURIComponent(firstSession.state)}&code=oauth-code-team-a`, + }, + }); + expect(submitFirstCallback.statusCode).toBe(200); + + const startSecondResponse = await app.inject({ + method: 'POST', + url: '/api/oauth/providers/codex/start', + headers: { + host: 'metapi.example', + 'x-forwarded-proto': 'https', + }, + }); + const secondSession = startSecondResponse.json() as { state: string }; + + const submitSecondCallback = await app.inject({ + method: 'POST', + url: `/api/oauth/sessions/${encodeURIComponent(secondSession.state)}/manual-callback`, + payload: { + callbackUrl: `http://localhost:1455/auth/callback?state=${encodeURIComponent(secondSession.state)}&code=oauth-code-team-b`, + }, + }); + expect(submitSecondCallback.statusCode).toBe(200); + + const accounts = await db.select().from(schema.accounts) + .where(eq(schema.accounts.oauthProvider, 'codex')) + .orderBy(schema.accounts.id) + .all(); + + expect(accounts).toHaveLength(2); + expect(accounts.map((account) => account.oauthAccountKey)).toEqual([ + 'chatgpt-team-account-a', + 'chatgpt-team-account-b', + ]); + + const connectionsResponse = await app.inject({ + method: 'GET', + url: '/api/oauth/connections', + }); + + expect(connectionsResponse.statusCode).toBe(200); + expect(connectionsResponse.json()).toMatchObject({ + total: 2, + items: expect.arrayContaining([ + expect.objectContaining({ + provider: 'codex', + accountKey: 'chatgpt-team-account-a', + }), + expect.objectContaining({ + provider: 'codex', + accountKey: 'chatgpt-team-account-b', + }), + ]), + }); + }); + + it('backfills structured oauth identity columns for legacy oauth rows before listing connections', async () => { + const site = await db.insert(schema.sites).values({ + name: 'Legacy Codex', + url: 'https://codex.example.com', + platform: 'codex', + status: 'active', + useSystemProxy: false, + isPinned: false, + globalWeight: 1, + sortOrder: 0, + }).returning().get(); + + const account = await db.insert(schema.accounts).values({ + siteId: site.id, + username: 'legacy-user@example.com', + accessToken: 'legacy-oauth-access-token', + status: 'active', + extraConfig: JSON.stringify({ + oauth: { + provider: 'codex', + accountKey: 'legacy-chatgpt-account', + projectId: 'legacy-project', + refreshToken: 'legacy-refresh-token', + modelDiscoveryStatus: 'healthy', + }, + }), + isPinned: false, + sortOrder: 0, + }).returning().get(); + + expect(account.oauthProvider).toBeNull(); + expect(account.oauthAccountKey).toBeNull(); + expect(account.oauthProjectId).toBeNull(); + + const response = await app.inject({ + method: 'GET', + url: '/api/oauth/connections', + }); + + expect(response.statusCode).toBe(200); + expect(response.json()).toMatchObject({ + total: 1, + items: [ + expect.objectContaining({ + provider: 'codex', + accountKey: 'legacy-chatgpt-account', + projectId: 'legacy-project', + username: 'legacy-user@example.com', + }), + ], + }); + + const backfilled = await db.select().from(schema.accounts) + .where(eq(schema.accounts.id, account.id)) + .get(); + + expect(backfilled).toEqual(expect.objectContaining({ + oauthProvider: 'codex', + oauthAccountKey: 'legacy-chatgpt-account', + oauthProjectId: 'legacy-project', + })); + }); + + it('rejects malformed manual callback submissions', async () => { + const startResponse = await app.inject({ + method: 'POST', + url: '/api/oauth/providers/claude/start', + headers: { + host: 'metapi.example', + 'x-forwarded-proto': 'https', + }, + }); + const startBody = startResponse.json() as { state: string }; + + const callbackResponse = await app.inject({ + method: 'POST', + url: `/api/oauth/sessions/${encodeURIComponent(startBody.state)}/manual-callback`, + payload: { + callbackUrl: 'not-a-valid-url', + }, + }); + + expect(callbackResponse.statusCode).toBe(400); + expect(callbackResponse.json()).toMatchObject({ + message: expect.stringContaining('invalid oauth callback url'), + }); + }); +}); diff --git a/src/server/routes/api/oauth.ts b/src/server/routes/api/oauth.ts new file mode 100644 index 00000000..32976eb4 --- /dev/null +++ b/src/server/routes/api/oauth.ts @@ -0,0 +1,275 @@ +import type { FastifyInstance, FastifyRequest } from 'fastify'; +import { createRateLimitGuard } from '../../middleware/requestRateLimit.js'; +import { + deleteOauthConnection, + getOauthSessionStatus, + handleOauthCallback, + listOauthConnections, + listOauthProviders, + refreshOauthConnectionQuota, + startOauthProviderFlow, + startOauthRebindFlow, + submitOauthManualCallback, +} from '../../services/oauth/service.js'; + +const limitOauthProviderRead = createRateLimitGuard({ + bucket: 'oauth-provider-read', + max: 60, + windowMs: 60_000, +}); + +const limitOauthStart = createRateLimitGuard({ + bucket: 'oauth-start', + max: 20, + windowMs: 60_000, +}); + +const limitOauthSessionRead = createRateLimitGuard({ + bucket: 'oauth-session-read', + max: 120, + windowMs: 60_000, +}); + +const limitOauthSessionMutate = createRateLimitGuard({ + bucket: 'oauth-session-mutate', + max: 30, + windowMs: 60_000, +}); + +const limitOauthConnectionRead = createRateLimitGuard({ + bucket: 'oauth-connection-read', + max: 60, + windowMs: 60_000, +}); + +const limitOauthConnectionMutate = createRateLimitGuard({ + bucket: 'oauth-connection-mutate', + max: 20, + windowMs: 60_000, +}); + +const limitOauthCallback = createRateLimitGuard({ + bucket: 'oauth-callback', + max: 30, + windowMs: 60_000, +}); + +function escapeHtml(value: string): string { + return value + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", '''); +} + +function renderCallbackPage(message: string): string { + return `<!doctype html> +<html lang="zh-CN"> + <head> + <meta charset="utf-8" /> + <title>OAuth Callback + + + + ${escapeHtml(message)} + +`; +} + +function parsePositiveInteger(value: unknown): number | null { + if (typeof value === 'number' && Number.isInteger(value) && value > 0) return value; + if (typeof value !== 'string') return null; + const parsed = Number.parseInt(value.trim(), 10); + return Number.isInteger(parsed) && parsed > 0 ? parsed : null; +} + +function parseOptionalProjectId(value: unknown): string | undefined | null { + if (value === undefined || value === null) return undefined; + if (typeof value !== 'string') return null; + const trimmed = value.trim(); + return trimmed || undefined; +} + +function resolveRequestOrigin(request: FastifyRequest): string | undefined { + const forwardedProto = typeof request.headers['x-forwarded-proto'] === 'string' + ? request.headers['x-forwarded-proto'].split(',')[0]?.trim() + : ''; + const protocol = forwardedProto || request.protocol || 'http'; + const forwardedHost = typeof request.headers['x-forwarded-host'] === 'string' + ? request.headers['x-forwarded-host'].split(',')[0]?.trim() + : ''; + const host = forwardedHost + || (typeof request.headers.host === 'string' ? request.headers.host.trim() : ''); + if (!host) return undefined; + return `${protocol}://${host}`; +} + +export async function oauthRoutes(app: FastifyInstance) { + app.get('/api/oauth/providers', { preHandler: [limitOauthProviderRead] }, async () => ({ + providers: listOauthProviders(), + })); + + app.post<{ Params: { provider: string }; Body: { accountId?: number; projectId?: string } }>( + '/api/oauth/providers/:provider/start', + { preHandler: [limitOauthStart] }, + async (request, reply) => { + const rebindAccountId = request.body?.accountId === undefined + ? undefined + : parsePositiveInteger(request.body.accountId); + if (request.body?.accountId !== undefined && rebindAccountId === null) { + return reply.code(400).send({ message: 'invalid account id' }); + } + const projectId = parseOptionalProjectId(request.body?.projectId); + if (request.body?.projectId !== undefined && projectId === null) { + return reply.code(400).send({ message: 'invalid project id' }); + } + + try { + return await startOauthProviderFlow({ + provider: request.params.provider, + rebindAccountId: rebindAccountId ?? undefined, + projectId: projectId ?? undefined, + requestOrigin: resolveRequestOrigin(request), + }); + } catch (error: any) { + return reply.code(404).send({ message: error?.message || 'oauth provider not found' }); + } + }, + ); + + app.get<{ Params: { state: string } }>( + '/api/oauth/sessions/:state', + { preHandler: [limitOauthSessionRead] }, + async (request, reply) => { + const session = getOauthSessionStatus(request.params.state); + if (!session) { + return reply.code(404).send({ message: 'oauth session not found' }); + } + return session; + }, + ); + + app.post<{ Params: { state: string }; Body: { callbackUrl?: string } }>( + '/api/oauth/sessions/:state/manual-callback', + { preHandler: [limitOauthSessionMutate] }, + async (request, reply) => { + const callbackUrl = typeof request.body?.callbackUrl === 'string' + ? request.body.callbackUrl.trim() + : ''; + if (!callbackUrl) { + return reply.code(400).send({ message: 'invalid oauth callback url' }); + } + try { + return await submitOauthManualCallback({ + state: request.params.state, + callbackUrl, + }); + } catch (error: any) { + const message = error?.message || 'oauth callback submission failed'; + if (message === 'invalid oauth callback url' || message === 'oauth callback state mismatch') { + return reply.code(400).send({ message }); + } + if (message === 'oauth session not found') { + return reply.code(404).send({ message }); + } + return reply.code(500).send({ message }); + } + }, + ); + + app.get<{ Querystring: { limit?: string; offset?: string } }>( + '/api/oauth/connections', + { preHandler: [limitOauthConnectionRead] }, + async (request, reply) => { + const limit = request.query.limit === undefined ? undefined : parsePositiveInteger(request.query.limit); + const offset = request.query.offset === undefined + ? undefined + : (() => { + if (typeof request.query.offset !== 'string') return null; + const parsed = Number.parseInt(request.query.offset.trim(), 10); + return Number.isInteger(parsed) && parsed >= 0 ? parsed : null; + })(); + if (request.query.limit !== undefined && limit === null) { + return reply.code(400).send({ message: 'invalid limit' }); + } + if (request.query.offset !== undefined && offset === null) { + return reply.code(400).send({ message: 'invalid offset' }); + } + return listOauthConnections({ + limit: limit ?? undefined, + offset: offset ?? undefined, + }); + }, + ); + + app.post<{ Params: { accountId: string } }>( + '/api/oauth/connections/:accountId/rebind', + { preHandler: [limitOauthConnectionMutate] }, + async (request, reply) => { + const accountId = parsePositiveInteger(request.params.accountId); + if (accountId === null) { + return reply.code(400).send({ message: 'invalid account id' }); + } + try { + return await startOauthRebindFlow(accountId, resolveRequestOrigin(request)); + } catch (error: any) { + return reply.code(404).send({ message: error?.message || 'oauth account not found' }); + } + }, + ); + + app.delete<{ Params: { accountId: string } }>( + '/api/oauth/connections/:accountId', + { preHandler: [limitOauthConnectionMutate] }, + async (request, reply) => { + const accountId = parsePositiveInteger(request.params.accountId); + if (accountId === null) { + return reply.code(400).send({ message: 'invalid account id' }); + } + try { + return await deleteOauthConnection(accountId); + } catch (error: any) { + return reply.code(404).send({ message: error?.message || 'oauth account not found' }); + } + }, + ); + + app.post<{ Params: { accountId: string } }>( + '/api/oauth/connections/:accountId/quota/refresh', + { preHandler: [limitOauthConnectionMutate] }, + async (request, reply) => { + const accountId = parsePositiveInteger(request.params.accountId); + if (accountId === null) { + return reply.code(400).send({ message: 'invalid account id' }); + } + try { + return await refreshOauthConnectionQuota(accountId); + } catch (error: any) { + return reply.code(404).send({ message: error?.message || 'oauth account not found' }); + } + }, + ); + + app.get<{ Params: { provider: string }; Querystring: { state?: string; code?: string; error?: string } }>( + '/api/oauth/callback/:provider', + { preHandler: [limitOauthCallback] }, + async (request, reply) => { + let message = 'OAuth callback received.'; + try { + await handleOauthCallback({ + provider: request.params.provider, + state: String(request.query.state || ''), + code: request.query.code, + error: request.query.error, + }); + message = 'OAuth authorization succeeded. You can close this window.'; + } catch { + message = 'OAuth authorization failed. Return to metapi and review the server logs.'; + } + + reply.type('text/html; charset=utf-8'); + return renderCallbackPage(message); + }, + ); +} diff --git a/src/server/routes/api/routeRefreshWorkflow.architecture.test.ts b/src/server/routes/api/routeRefreshWorkflow.architecture.test.ts new file mode 100644 index 00000000..3a99ed8d --- /dev/null +++ b/src/server/routes/api/routeRefreshWorkflow.architecture.test.ts @@ -0,0 +1,59 @@ +import { readFileSync } from 'node:fs'; +import { describe, expect, it } from 'vitest'; + +function readSource(relativePath: string): string { + return readFileSync(new URL(relativePath, import.meta.url), 'utf8'); +} + +function expectNoDirectModelServiceRouteRefresh(source: string): void { + expect(source).not.toMatch(/import\s*\{[^}]*\brefreshModelsAndRebuildRoutes\b[^}]*\}\s*from\s*['"][^'"]*modelService\.js['"]/m); + expect(source).not.toMatch(/import\s*\{[^}]*\brebuildTokenRoutesFromAvailability\b[^}]*\}\s*from\s*['"][^'"]*modelService\.js['"]/m); +} + +describe('route refresh workflow architecture boundaries', () => { + it('keeps api controllers on the shared route refresh workflow instead of modelService', () => { + const tokensSource = readSource('./tokens.ts'); + const settingsSource = readSource('./settings.ts'); + const statsSource = readSource('./stats.ts'); + + expect(tokensSource).toContain("from '../../services/routeRefreshWorkflow.js'"); + expect(tokensSource).not.toContain("from '../../services/modelService.js'"); + + expect(settingsSource).toContain("from '../../services/routeRefreshWorkflow.js'"); + expect(settingsSource).not.toContain("from '../../services/modelService.js'"); + + expect(statsSource).toContain("from '../../services/routeRefreshWorkflow.js'"); + expectNoDirectModelServiceRouteRefresh(statsSource); + expect(tokensSource).toContain('const rebuild = await routeRefreshWorkflow.rebuildRoutesOnly();'); + expect(statsSource).toContain('const rebuild = await routeRefreshWorkflow.rebuildRoutesOnly();'); + }); + + it('keeps proxy fallback refreshes and scheduler hooks on the route refresh workflow', () => { + const completionsSource = readSource('../proxy/completions.ts'); + const embeddingsSource = readSource('../proxy/embeddings.ts'); + const imagesSource = readSource('../proxy/images.ts'); + const modelsRouteSource = readSource('../proxy/models.ts'); + const searchSource = readSource('../proxy/search.ts'); + const videosSource = readSource('../proxy/videos.ts'); + const schedulerSource = readSource('../../services/checkinScheduler.ts'); + const oauthServiceSource = readSource('../../services/oauth/service.ts'); + const sharedSurfaceSource = readSource('../../proxy-core/surfaces/sharedSurface.ts'); + const geminiSurfaceSource = readSource('../../proxy-core/surfaces/geminiSurface.ts'); + + for (const source of [ + completionsSource, + embeddingsSource, + imagesSource, + modelsRouteSource, + searchSource, + videosSource, + schedulerSource, + oauthServiceSource, + sharedSurfaceSource, + geminiSurfaceSource, + ]) { + expect(source).toContain('routeRefreshWorkflow'); + expectNoDirectModelServiceRouteRefresh(source); + } + }); +}); diff --git a/src/server/routes/api/search.route.test.ts b/src/server/routes/api/search.route.test.ts index f467b0cf..99afc2e2 100644 --- a/src/server/routes/api/search.route.test.ts +++ b/src/server/routes/api/search.route.test.ts @@ -137,4 +137,57 @@ describe('search routes', () => { ], }); }); + + it('includes oauth direct-account model availability in model search results', async () => { + const site = await db.insert(schema.sites).values({ + name: 'codex site', + url: 'https://chatgpt.com/backend-api/codex', + platform: 'codex', + status: 'active', + }).returning().get(); + + const account = await db.insert(schema.accounts).values({ + siteId: site.id, + username: 'codex-user@example.com', + accessToken: 'oauth-access-token', + apiToken: null, + status: 'active', + extraConfig: mergeAccountExtraConfig(null, { + credentialMode: 'session', + oauth: { + provider: 'codex', + accountId: 'chatgpt-account-123', + email: 'codex-user@example.com', + planType: 'team', + }, + }), + }).returning().get(); + + await db.insert(schema.modelAvailability).values({ + accountId: account.id, + modelName: 'gpt-5.2-codex', + available: true, + }).run(); + + const response = await app.inject({ + method: 'POST', + url: '/api/search', + payload: { + query: 'gpt-5.2', + limit: 20, + }, + }); + + expect(response.statusCode).toBe(200); + expect(response.json()).toMatchObject({ + models: [ + expect.objectContaining({ + name: 'gpt-5.2-codex', + accountCount: 1, + tokenCount: 0, + siteCount: 1, + }), + ], + }); + }); }); diff --git a/src/server/routes/api/search.ts b/src/server/routes/api/search.ts index 6725ae44..674bc9cf 100644 --- a/src/server/routes/api/search.ts +++ b/src/server/routes/api/search.ts @@ -2,7 +2,8 @@ import { FastifyInstance } from 'fastify'; import { db, schema } from '../../db/index.js'; import { and, like, desc, eq, or } from 'drizzle-orm'; import { getProxyLogBaseSelectFields } from '../../services/proxyLogStore.js'; -import { getCredentialModeFromExtraConfig } from '../../services/accountExtraConfig.js'; +import { getCredentialModeFromExtraConfig, supportsDirectAccountRoutingConnection } from '../../services/accountExtraConfig.js'; +import { ACCOUNT_TOKEN_VALUE_STATUS_READY } from '../../services/accountTokenService.js'; function hasSessionTokenValue(value: string | null | undefined): boolean { return typeof value === 'string' && value.trim().length > 0; @@ -134,11 +135,31 @@ export async function searchRoutes(app: FastifyInstance) { like(schema.tokenModelAvailability.modelName, q), eq(schema.tokenModelAvailability.available, true), eq(schema.accountTokens.enabled, true), + eq(schema.accountTokens.valueStatus, ACCOUNT_TOKEN_VALUE_STATUS_READY), eq(schema.accounts.status, 'active'), ), ) .limit(perCategory * 20) .all(); + const directAccountModelRows = await db.select({ + modelName: schema.modelAvailability.modelName, + accountId: schema.accounts.id, + siteId: schema.sites.id, + accounts: schema.accounts, + }) + .from(schema.modelAvailability) + .innerJoin(schema.accounts, eq(schema.modelAvailability.accountId, schema.accounts.id)) + .innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id)) + .where( + and( + like(schema.modelAvailability.modelName, q), + eq(schema.modelAvailability.available, true), + eq(schema.accounts.status, 'active'), + eq(schema.sites.status, 'active'), + ), + ) + .limit(perCategory * 20) + .all(); const modelAgg = new Map; accountIds: Set; siteIds: Set }>(); for (const row of modelRows) { @@ -151,6 +172,16 @@ export async function searchRoutes(app: FastifyInstance) { agg.accountIds.add(row.accountId); agg.siteIds.add(row.siteId); } + for (const row of directAccountModelRows) { + if (!supportsDirectAccountRoutingConnection(row.accounts)) continue; + const key = row.modelName; + if (!modelAgg.has(key)) { + modelAgg.set(key, { tokenIds: new Set(), accountIds: new Set(), siteIds: new Set() }); + } + const agg = modelAgg.get(key)!; + agg.accountIds.add(row.accountId); + agg.siteIds.add(row.siteId); + } const models = Array.from(modelAgg.entries()) .map(([name, agg]) => ({ diff --git a/src/server/routes/api/settings.backup-webdav.test.ts b/src/server/routes/api/settings.backup-webdav.test.ts new file mode 100644 index 00000000..93437317 --- /dev/null +++ b/src/server/routes/api/settings.backup-webdav.test.ts @@ -0,0 +1,114 @@ +import Fastify, { type FastifyInstance } from 'fastify'; +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { mkdtempSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { eq } from 'drizzle-orm'; + +type DbModule = typeof import('../../db/index.js'); + +describe('settings backup webdav api', () => { + let app: FastifyInstance; + let db: DbModule['db']; + let schema: DbModule['schema']; + let dataDir = ''; + + beforeAll(async () => { + dataDir = mkdtempSync(join(tmpdir(), 'metapi-settings-backup-webdav-')); + process.env.DATA_DIR = dataDir; + await import('../../db/migrate.js'); + const dbModule = await import('../../db/index.js'); + const settingsRoutesModule = await import('./settings.js'); + + db = dbModule.db; + schema = dbModule.schema; + + app = Fastify(); + await app.register(settingsRoutesModule.settingsRoutes); + }); + + beforeEach(async () => { + await db.delete(schema.settings).run(); + await db.delete(schema.events).run(); + }); + + afterAll(async () => { + await app.close(); + delete process.env.DATA_DIR; + }); + + it('saves and returns masked webdav config', async () => { + const response = await app.inject({ + method: 'PUT', + url: '/api/settings/backup/webdav', + payload: { + enabled: true, + fileUrl: 'https://dav.example.com/backups/metapi.json', + username: 'alice', + password: 'secret-pass', + exportType: 'accounts', + autoSyncEnabled: true, + autoSyncCron: '0 */6 * * *', + }, + }); + + expect(response.statusCode).toBe(200); + const body = response.json() as { + success?: boolean; + config?: { + fileUrl?: string; + username?: string; + exportType?: string; + autoSyncEnabled?: boolean; + autoSyncCron?: string; + hasPassword?: boolean; + passwordMasked?: string; + }; + }; + expect(body.success).toBe(true); + expect(body.config).toMatchObject({ + fileUrl: 'https://dav.example.com/backups/metapi.json', + username: 'alice', + exportType: 'accounts', + autoSyncEnabled: true, + autoSyncCron: '0 */6 * * *', + hasPassword: true, + }); + expect(body.config?.passwordMasked).toBeTruthy(); + + const saved = await db.select().from(schema.settings).where(eq(schema.settings.key, 'backup_webdav_config_v1')).get(); + expect(saved?.value).toContain('"fileUrl":"https://dav.example.com/backups/metapi.json"'); + expect(saved?.value).toContain('"password":"secret-pass"'); + }); + + it('exports current backup to webdav through settings route', async () => { + const fetchSpy = vi.spyOn(globalThis, 'fetch'); + fetchSpy.mockResolvedValue(new Response(null, { status: 201 })); + + await db.insert(schema.settings).values({ + key: 'backup_webdav_config_v1', + value: JSON.stringify({ + enabled: true, + fileUrl: 'https://dav.example.com/backups/metapi.json', + username: 'alice', + password: 'secret-pass', + exportType: 'all', + autoSyncEnabled: false, + autoSyncCron: '0 * * * *', + }), + }).run(); + + const response = await app.inject({ + method: 'POST', + url: '/api/settings/backup/webdav/export', + }); + + expect(response.statusCode).toBe(200); + const body = response.json() as { success?: boolean; fileUrl?: string }; + expect(body.success).toBe(true); + expect(body.fileUrl).toBe('https://dav.example.com/backups/metapi.json'); + expect(fetchSpy).toHaveBeenCalledTimes(1); + + fetchSpy.mockRestore(); + }); +}); diff --git a/src/server/routes/api/settings.events.test.ts b/src/server/routes/api/settings.events.test.ts index 2f21334f..a65c2da9 100644 --- a/src/server/routes/api/settings.events.test.ts +++ b/src/server/routes/api/settings.events.test.ts @@ -4,6 +4,7 @@ import { mkdtempSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { eq } from 'drizzle-orm'; +import { resetRequestRateLimitStore } from '../../middleware/requestRateLimit.js'; type DbModule = typeof import('../../db/index.js'); type ConfigModule = typeof import('../../config.js'); @@ -35,6 +36,7 @@ describe('settings and auth events', () => { }); beforeEach(async () => { + resetRequestRateLimitStore(); await db.delete(schema.events).run(); await db.delete(schema.settings).run(); @@ -42,8 +44,24 @@ describe('settings and auth events', () => { config.proxyToken = 'sk-old-proxy-token-123'; config.systemProxyUrl = ''; config.checkinCron = '0 8 * * *'; + (config as any).checkinScheduleMode = 'cron'; + (config as any).checkinIntervalHours = 6; config.balanceRefreshCron = '0 * * * *'; + config.logCleanupConfigured = false; + config.logCleanupCron = '0 6 * * *'; + config.logCleanupUsageLogsEnabled = false; + config.logCleanupProgramLogsEnabled = false; + config.logCleanupRetentionDays = 30; + config.codexUpstreamWebsocketEnabled = false; + config.proxySessionChannelConcurrencyLimit = 2; + config.proxySessionChannelQueueWaitMs = 1500; config.routingFallbackUnitCost = 1; + (config as any).telegramEnabled = false; + (config as any).telegramApiBaseUrl = 'https://api.telegram.org'; + (config as any).telegramBotToken = ''; + (config as any).telegramChatId = ''; + (config as any).telegramUseSystemProxy = false; + (config as any).telegramMessageThreadId = ''; }); afterAll(async () => { @@ -74,6 +92,60 @@ describe('settings and auth events', () => { expect(events[0].message || '').toContain('签到 Cron'); }); + it('persists and returns checkin interval mode from runtime settings', async () => { + const updateResponse = await app.inject({ + method: 'PUT', + url: '/api/settings/runtime', + payload: { + checkinScheduleMode: 'interval', + checkinIntervalHours: 8, + checkinCron: '0 8 * * *', + }, + }); + + expect(updateResponse.statusCode).toBe(200); + const updated = updateResponse.json() as { checkinScheduleMode?: string; checkinIntervalHours?: number }; + expect(updated.checkinScheduleMode).toBe('interval'); + expect(updated.checkinIntervalHours).toBe(8); + + const savedMode = await db.select().from(schema.settings).where(eq(schema.settings.key, 'checkin_schedule_mode')).get(); + const savedInterval = await db.select().from(schema.settings).where(eq(schema.settings.key, 'checkin_interval_hours')).get(); + expect(savedMode?.value).toBe(JSON.stringify('interval')); + expect(savedInterval?.value).toBe(JSON.stringify(8)); + }); + + it('persists codex upstream websocket and session lease settings from runtime settings', async () => { + const updateResponse = await app.inject({ + method: 'PUT', + url: '/api/settings/runtime', + payload: { + codexUpstreamWebsocketEnabled: true, + proxySessionChannelConcurrencyLimit: 6, + proxySessionChannelQueueWaitMs: 4200, + }, + }); + + expect(updateResponse.statusCode).toBe(200); + const updated = updateResponse.json() as { + codexUpstreamWebsocketEnabled?: boolean; + proxySessionChannelConcurrencyLimit?: number; + proxySessionChannelQueueWaitMs?: number; + }; + expect(updated.codexUpstreamWebsocketEnabled).toBe(true); + expect(updated.proxySessionChannelConcurrencyLimit).toBe(6); + expect(updated.proxySessionChannelQueueWaitMs).toBe(4200); + expect(config.codexUpstreamWebsocketEnabled).toBe(true); + expect(config.proxySessionChannelConcurrencyLimit).toBe(6); + expect(config.proxySessionChannelQueueWaitMs).toBe(4200); + + const savedWebsocket = await db.select().from(schema.settings).where(eq(schema.settings.key, 'codex_upstream_websocket_enabled')).get(); + const savedConcurrency = await db.select().from(schema.settings).where(eq(schema.settings.key, 'proxy_session_channel_concurrency_limit')).get(); + const savedQueueWait = await db.select().from(schema.settings).where(eq(schema.settings.key, 'proxy_session_channel_queue_wait_ms')).get(); + expect(savedWebsocket?.value).toBe(JSON.stringify(true)); + expect(savedConcurrency?.value).toBe(JSON.stringify(6)); + expect(savedQueueWait?.value).toBe(JSON.stringify(4200)); + }); + it('returns current recognized admin IP in runtime settings response', async () => { const response = await app.inject({ method: 'GET', @@ -85,8 +157,10 @@ describe('settings and auth events', () => { }); expect(response.statusCode).toBe(200); - const body = response.json() as { currentAdminIp?: string }; + const body = response.json() as { currentAdminIp?: string; serverTimeZone?: string }; expect(body.currentAdminIp).toBe('203.0.113.5'); + expect(typeof body.serverTimeZone).toBe('string'); + expect((body.serverTimeZone || '').length).toBeGreaterThan(0); }); it('rejects proxy token that does not start with sk-', async () => { @@ -165,6 +239,101 @@ describe('settings and auth events', () => { expect(body.message).toContain('Telegram Chat ID'); }); + it('persists and returns telegram api base url from runtime settings', async () => { + const updateResponse = await app.inject({ + method: 'PUT', + url: '/api/settings/runtime', + payload: { + telegramApiBaseUrl: 'https://tg-proxy.example.com/custom/', + }, + }); + + expect(updateResponse.statusCode).toBe(200); + const updated = updateResponse.json() as { telegramApiBaseUrl?: string }; + expect(updated.telegramApiBaseUrl).toBe('https://tg-proxy.example.com/custom'); + expect((config as any).telegramApiBaseUrl).toBe('https://tg-proxy.example.com/custom'); + + const saved = await db.select().from(schema.settings).where(eq(schema.settings.key, 'telegram_api_base_url')).get(); + expect(saved?.value).toBe(JSON.stringify('https://tg-proxy.example.com/custom')); + + const getResponse = await app.inject({ + method: 'GET', + url: '/api/settings/runtime', + }); + expect(getResponse.statusCode).toBe(200); + const runtime = getResponse.json() as { telegramApiBaseUrl?: string }; + expect(runtime.telegramApiBaseUrl).toBe('https://tg-proxy.example.com/custom'); + }); + + it('persists and returns telegram message thread id from runtime settings', async () => { + const updateResponse = await app.inject({ + method: 'PUT', + url: '/api/settings/runtime', + payload: { + telegramMessageThreadId: '77', + }, + }); + + expect(updateResponse.statusCode).toBe(200); + const updated = updateResponse.json() as { telegramMessageThreadId?: string }; + expect(updated.telegramMessageThreadId).toBe('77'); + expect((config as any).telegramMessageThreadId).toBe('77'); + + const saved = await db.select().from(schema.settings).where(eq(schema.settings.key, 'telegram_message_thread_id')).get(); + expect(saved?.value).toBe(JSON.stringify('77')); + + const getResponse = await app.inject({ + method: 'GET', + url: '/api/settings/runtime', + }); + expect(getResponse.statusCode).toBe(200); + const runtime = getResponse.json() as { telegramMessageThreadId?: string }; + expect(runtime.telegramMessageThreadId).toBe('77'); + }); + + it('rejects invalid telegram api base url when telegram is enabled', async () => { + const response = await app.inject({ + method: 'PUT', + url: '/api/settings/runtime', + payload: { + telegramEnabled: true, + telegramBotToken: '123456:telegram-token', + telegramChatId: '-1001234567890', + telegramApiBaseUrl: 'not-a-url', + }, + }); + + expect(response.statusCode).toBe(400); + const body = response.json() as { message?: string }; + expect(body.message).toContain('Telegram API Base URL'); + }); + + it('persists and returns telegram use system proxy from runtime settings', async () => { + const updateResponse = await app.inject({ + method: 'PUT', + url: '/api/settings/runtime', + payload: { + telegramUseSystemProxy: true, + }, + }); + + expect(updateResponse.statusCode).toBe(200); + const updated = updateResponse.json() as { telegramUseSystemProxy?: boolean }; + expect(updated.telegramUseSystemProxy).toBe(true); + expect((config as any).telegramUseSystemProxy).toBe(true); + + const saved = await db.select().from(schema.settings).where(eq(schema.settings.key, 'telegram_use_system_proxy')).get(); + expect(saved?.value).toBe(JSON.stringify(true)); + + const getResponse = await app.inject({ + method: 'GET', + url: '/api/settings/runtime', + }); + expect(getResponse.statusCode).toBe(200); + const runtime = getResponse.json() as { telegramUseSystemProxy?: boolean }; + expect(runtime.telegramUseSystemProxy).toBe(true); + }); + it('persists and returns routing fallback unit cost from runtime settings', async () => { const updateResponse = await app.inject({ method: 'PUT', @@ -219,6 +388,134 @@ describe('settings and auth events', () => { expect(runtime.systemProxyUrl).toBe('http://127.0.0.1:7890'); }); + it('splits proxy error keywords on newlines and commas when saving runtime settings', async () => { + const updateResponse = await app.inject({ + method: 'PUT', + url: '/api/settings/runtime', + payload: { + proxyErrorKeywords: 'quota exceeded\nbad gateway,too many requests', + proxyEmptyContentFailEnabled: true, + }, + }); + + expect(updateResponse.statusCode).toBe(200); + const updated = updateResponse.json() as { + proxyErrorKeywords?: string[]; + proxyEmptyContentFailEnabled?: boolean; + }; + expect(updated.proxyErrorKeywords).toEqual([ + 'quota exceeded', + 'bad gateway', + 'too many requests', + ]); + expect(updated.proxyEmptyContentFailEnabled).toBe(true); + expect(config.proxyErrorKeywords).toEqual([ + 'quota exceeded', + 'bad gateway', + 'too many requests', + ]); + expect(config.proxyEmptyContentFailEnabled).toBe(true); + + const rows = await db.select().from(schema.settings).all(); + const settingsMap = new Map(rows.map((row) => [row.key, row.value])); + expect(settingsMap.get('proxy_error_keywords')).toBe(JSON.stringify([ + 'quota exceeded', + 'bad gateway', + 'too many requests', + ])); + expect(settingsMap.get('proxy_empty_content_fail_enabled')).toBe(JSON.stringify(true)); + + const getResponse = await app.inject({ + method: 'GET', + url: '/api/settings/runtime', + }); + expect(getResponse.statusCode).toBe(200); + const runtime = getResponse.json() as { + proxyErrorKeywords?: string[]; + proxyEmptyContentFailEnabled?: boolean; + }; + expect(runtime.proxyErrorKeywords).toEqual([ + 'quota exceeded', + 'bad gateway', + 'too many requests', + ]); + expect(runtime.proxyEmptyContentFailEnabled).toBe(true); + }); + + it('persists and returns log cleanup settings from runtime settings', async () => { + const updateResponse = await app.inject({ + method: 'PUT', + url: '/api/settings/runtime', + payload: { + logCleanupCron: '15 4 * * *', + logCleanupUsageLogsEnabled: true, + logCleanupProgramLogsEnabled: true, + logCleanupRetentionDays: 14, + }, + }); + + expect(updateResponse.statusCode).toBe(200); + const updated = updateResponse.json() as { + logCleanupCron?: string; + logCleanupUsageLogsEnabled?: boolean; + logCleanupProgramLogsEnabled?: boolean; + logCleanupRetentionDays?: number; + }; + expect(updated.logCleanupCron).toBe('15 4 * * *'); + expect(updated.logCleanupUsageLogsEnabled).toBe(true); + expect(updated.logCleanupProgramLogsEnabled).toBe(true); + expect(updated.logCleanupRetentionDays).toBe(14); + expect(config.logCleanupCron).toBe('15 4 * * *'); + expect(config.logCleanupUsageLogsEnabled).toBe(true); + expect(config.logCleanupProgramLogsEnabled).toBe(true); + expect(config.logCleanupRetentionDays).toBe(14); + + const rows = await db.select().from(schema.settings).all(); + const settingsMap = new Map(rows.map((row) => [row.key, row.value])); + expect(settingsMap.get('log_cleanup_cron')).toBe(JSON.stringify('15 4 * * *')); + expect(settingsMap.get('log_cleanup_usage_logs_enabled')).toBe(JSON.stringify(true)); + expect(settingsMap.get('log_cleanup_program_logs_enabled')).toBe(JSON.stringify(true)); + expect(settingsMap.get('log_cleanup_retention_days')).toBe(JSON.stringify(14)); + + const getResponse = await app.inject({ + method: 'GET', + url: '/api/settings/runtime', + }); + expect(getResponse.statusCode).toBe(200); + const runtime = getResponse.json() as { + logCleanupCron?: string; + logCleanupUsageLogsEnabled?: boolean; + logCleanupProgramLogsEnabled?: boolean; + logCleanupRetentionDays?: number; + }; + expect(runtime.logCleanupCron).toBe('15 4 * * *'); + expect(runtime.logCleanupUsageLogsEnabled).toBe(true); + expect(runtime.logCleanupProgramLogsEnabled).toBe(true); + expect(runtime.logCleanupRetentionDays).toBe(14); + }); + + it('rejects invalid log cleanup cron and retention days', async () => { + const invalidCronResponse = await app.inject({ + method: 'PUT', + url: '/api/settings/runtime', + payload: { + logCleanupCron: 'invalid cron', + }, + }); + expect(invalidCronResponse.statusCode).toBe(400); + expect((invalidCronResponse.json() as { message?: string }).message).toContain('日志清理 Cron'); + + const invalidRetentionResponse = await app.inject({ + method: 'PUT', + url: '/api/settings/runtime', + payload: { + logCleanupRetentionDays: 0, + }, + }); + expect(invalidRetentionResponse.statusCode).toBe(400); + expect((invalidRetentionResponse.json() as { message?: string }).message).toContain('保留天数'); + }); + it('invalidates cached site proxy resolution when system proxy url changes', async () => { await db.insert(schema.sites).values({ name: 'proxy-site', @@ -307,4 +604,36 @@ describe('settings and auth events', () => { relatedType: 'settings', }); }); + + it('rate limits repeated admin auth token changes from the same client ip', async () => { + for (let attempt = 0; attempt < 3; attempt += 1) { + const response = await app.inject({ + method: 'POST', + url: '/api/settings/auth/change', + remoteAddress: '198.51.100.12', + payload: { + oldToken: config.authToken, + newToken: `new-admin-token-${attempt}-456`, + }, + }); + + expect(response.statusCode).toBe(200); + } + + const limited = await app.inject({ + method: 'POST', + url: '/api/settings/auth/change', + remoteAddress: '198.51.100.12', + payload: { + oldToken: config.authToken, + newToken: 'new-admin-token-rate-limit', + }, + }); + + expect(limited.statusCode).toBe(429); + expect(limited.json()).toMatchObject({ + success: false, + message: '请求过于频繁,请稍后再试', + }); + }); }); diff --git a/src/server/routes/api/settings.factory-reset.test.ts b/src/server/routes/api/settings.factory-reset.test.ts index 27ce2ed1..b3bbffd4 100644 --- a/src/server/routes/api/settings.factory-reset.test.ts +++ b/src/server/routes/api/settings.factory-reset.test.ts @@ -63,7 +63,7 @@ describe('settings factory reset api', () => { delete process.env.DATA_DIR; }); - it('clears data, resets runtime config, and returns to initial sqlite state', async () => { + it('clears business data while preserving infrastructure settings', async () => { const siteInsert = await db.insert(schema.sites).values({ name: 'Reset Me', url: 'https://reset.example.com', @@ -149,10 +149,11 @@ describe('settings factory reset api', () => { expect(response.statusCode).toBe(200); expect(response.json()).toEqual({ success: true }); - expect(config.authToken).toBe(FACTORY_RESET_ADMIN_TOKEN); + expect(config.authToken).toBe('before-reset-token'); expect(config.dbType).toBe('sqlite'); expect(config.dbUrl).toBe(''); expect(config.dbSsl).toBe(false); + expect(config.systemProxyUrl).toBe('http://127.0.0.1:7890'); const sites = await db.select().from(schema.sites).all(); expect(sites.map((site) => site.name)).toEqual([ @@ -166,10 +167,12 @@ describe('settings factory reset api', () => { const dbTypeSetting = await db.select().from(schema.settings).where(eq(schema.settings.key, 'db_type')).get(); const dbUrlSetting = await db.select().from(schema.settings).where(eq(schema.settings.key, 'db_url')).get(); const dbSslSetting = await db.select().from(schema.settings).where(eq(schema.settings.key, 'db_ssl')).get(); - expect(authTokenSetting).toBeUndefined(); - expect(dbTypeSetting).toBeUndefined(); - expect(dbUrlSetting).toBeUndefined(); - expect(dbSslSetting).toBeUndefined(); + const systemProxySetting = await db.select().from(schema.settings).where(eq(schema.settings.key, 'system_proxy_url')).get(); + expect(authTokenSetting?.value).toBe(JSON.stringify('before-reset-token')); + expect(dbTypeSetting?.value).toBe(JSON.stringify('sqlite')); + expect(dbUrlSetting?.value).toBe(JSON.stringify('')); + expect(dbSslSetting?.value).toBe(JSON.stringify(false)); + expect(systemProxySetting?.value).toBe(JSON.stringify('http://127.0.0.1:7890')); expect(await db.select().from(schema.accounts).all()).toHaveLength(0); expect(await db.select().from(schema.accountTokens).all()).toHaveLength(0); diff --git a/src/server/routes/api/settings.system-proxy-test.test.ts b/src/server/routes/api/settings.system-proxy-test.test.ts new file mode 100644 index 00000000..0eaaa8ca --- /dev/null +++ b/src/server/routes/api/settings.system-proxy-test.test.ts @@ -0,0 +1,123 @@ +import Fastify, { type FastifyInstance } from 'fastify'; +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { mkdtempSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; + +const fetchMock = vi.fn(); + +vi.mock('undici', async () => { + const actual = await vi.importActual('undici'); + return { + ...actual, + fetch: (...args: unknown[]) => fetchMock(...args), + }; +}); + +type ConfigModule = typeof import('../../config.js'); + +describe('settings system proxy test route', () => { + let app: FastifyInstance; + let config: ConfigModule['config']; + let dataDir = ''; + + beforeAll(async () => { + dataDir = mkdtempSync(join(tmpdir(), 'metapi-settings-system-proxy-test-')); + process.env.DATA_DIR = dataDir; + + await import('../../db/migrate.js'); + const configModule = await import('../../config.js'); + const settingsRoutesModule = await import('./settings.js'); + + config = configModule.config; + + app = Fastify(); + await app.register(settingsRoutesModule.settingsRoutes); + }); + + beforeEach(() => { + fetchMock.mockReset(); + config.systemProxyUrl = ''; + }); + + afterAll(async () => { + await app.close(); + delete process.env.DATA_DIR; + }); + + it('tests the provided system proxy url and returns latency', async () => { + fetchMock.mockResolvedValue(new Response(null, { + status: 204, + headers: { 'content-type': 'text/plain' }, + })); + + const response = await app.inject({ + method: 'POST', + url: '/api/settings/system-proxy/test', + payload: { + proxyUrl: 'http://127.0.0.1:7890', + }, + }); + + expect(response.statusCode).toBe(200); + expect(response.json()).toMatchObject({ + success: true, + proxyUrl: 'http://127.0.0.1:7890', + probeUrl: 'https://www.gstatic.com/generate_204', + finalUrl: 'https://www.gstatic.com/generate_204', + reachable: true, + ok: true, + statusCode: 204, + }); + expect((response.json() as { latencyMs?: number }).latencyMs).toBeGreaterThanOrEqual(1); + + const [url, requestInit] = fetchMock.mock.calls[0] as [string, Record]; + expect(url).toBe('https://www.gstatic.com/generate_204'); + expect(requestInit.method).toBe('GET'); + expect(requestInit.dispatcher).toBeTruthy(); + }); + + it('uses the saved system proxy url when request body is empty', async () => { + config.systemProxyUrl = 'socks5://127.0.0.1:1080'; + fetchMock.mockResolvedValue(new Response(null, { status: 204 })); + + const response = await app.inject({ + method: 'POST', + url: '/api/settings/system-proxy/test', + payload: {}, + }); + + expect(response.statusCode).toBe(200); + expect((response.json() as { proxyUrl?: string }).proxyUrl).toBe('socks5://127.0.0.1:1080'); + }); + + it('rejects missing system proxy url', async () => { + const response = await app.inject({ + method: 'POST', + url: '/api/settings/system-proxy/test', + payload: {}, + }); + + expect(response.statusCode).toBe(400); + expect((response.json() as { message?: string }).message).toContain('请先填写系统代理地址'); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('returns 502 when the proxy probe request fails', async () => { + fetchMock.mockRejectedValue(new TypeError('fetch failed', { + cause: new Error('connect ECONNREFUSED 127.0.0.1:7890'), + })); + + const response = await app.inject({ + method: 'POST', + url: '/api/settings/system-proxy/test', + payload: { + proxyUrl: 'http://127.0.0.1:7890', + }, + }); + + expect(response.statusCode).toBe(502); + expect((response.json() as { message?: string }).message).toContain('连接被拒绝'); + expect((response.json() as { message?: string }).message).not.toContain('fetch failed'); + }); +}); diff --git a/src/server/routes/api/settings.ts b/src/server/routes/api/settings.ts index cba6159c..26893c72 100644 --- a/src/server/routes/api/settings.ts +++ b/src/server/routes/api/settings.ts @@ -1,11 +1,23 @@ -import { FastifyInstance } from 'fastify'; +import { FastifyInstance } from 'fastify'; import cron from 'node-cron'; +import { fetch } from 'undici'; import { config } from '../../config.js'; import { db, runtimeDbDialect, schema } from '../../db/index.js'; -import { refreshModelsAndRebuildRoutes } from '../../services/modelService.js'; -import { updateBalanceRefreshCron, updateCheckinCron } from '../../services/checkinScheduler.js'; +import { upsertSetting } from '../../db/upsertSetting.js'; +import * as routeRefreshWorkflow from '../../services/routeRefreshWorkflow.js'; +import { getAllBrandNames } from '../../services/brandMatcher.js'; +import { updateBalanceRefreshCron, updateCheckinSchedule, updateLogCleanupSettings } from '../../services/checkinScheduler.js'; import { sendNotification } from '../../services/notifyService.js'; -import { exportBackup, importBackup, type BackupExportType } from '../../services/backupService.js'; +import { + exportBackup, + exportBackupToWebdav, + getBackupWebdavConfig, + importBackup, + importBackupFromWebdav, + reloadBackupWebdavScheduler, + saveBackupWebdavConfig, + type BackupExportType, +} from '../../services/backupService.js'; import { startBackgroundTask } from '../../services/backgroundTaskService.js'; import { maskConnectionString, @@ -14,18 +26,29 @@ import { testDatabaseConnection, type MigrationDialect, } from '../../services/databaseMigrationService.js'; -import { formatUtcSqlDateTime } from '../../services/localTimeService.js'; +import { formatUtcSqlDateTime, getResolvedTimeZone } from '../../services/localTimeService.js'; import { extractClientIp, isIpAllowed } from '../../middleware/auth.js'; -import { invalidateSiteProxyCache, normalizeSiteProxyUrl } from '../../services/siteProxy.js'; +import { invalidateSiteProxyCache, normalizeSiteProxyUrl, withExplicitProxyRequestInit } from '../../services/siteProxy.js'; import { performFactoryReset } from '../../services/factoryResetService.js'; +import { normalizeLogCleanupRetentionDays } from '../../services/logCleanupService.js'; +import { stopProxyLogRetentionService } from '../../services/proxyLogRetentionService.js'; type RoutingWeights = typeof config.routingWeights; interface RuntimeSettingsBody { proxyToken?: string; systemProxyUrl?: string; + codexUpstreamWebsocketEnabled?: boolean; + proxySessionChannelConcurrencyLimit?: number; + proxySessionChannelQueueWaitMs?: number; checkinCron?: string; + checkinScheduleMode?: 'cron' | 'interval'; + checkinIntervalHours?: number; balanceRefreshCron?: string; + logCleanupCron?: string; + logCleanupUsageLogsEnabled?: boolean; + logCleanupProgramLogsEnabled?: boolean; + logCleanupRetentionDays?: number; webhookUrl?: string; barkUrl?: string; webhookEnabled?: boolean; @@ -33,8 +56,11 @@ interface RuntimeSettingsBody { serverChanEnabled?: boolean; serverChanKey?: string; telegramEnabled?: boolean; + telegramApiBaseUrl?: string; telegramBotToken?: string; telegramChatId?: string; + telegramUseSystemProxy?: boolean; + telegramMessageThreadId?: string; smtpEnabled?: boolean; smtpHost?: string; smtpPort?: number; @@ -47,6 +73,10 @@ interface RuntimeSettingsBody { adminIpAllowlist?: string[] | string; routingFallbackUnitCost?: number; routingWeights?: Partial; + proxyErrorKeywords?: string[] | string; + proxyEmptyContentFailEnabled?: boolean; + globalBlockedBrands?: string[]; + globalAllowedModels?: string[]; } interface DatabaseMigrationBody { @@ -56,6 +86,21 @@ interface DatabaseMigrationBody { ssl?: unknown; } +interface SystemProxyTestBody { + proxyUrl?: unknown; +} + +interface BackupWebdavConfigBody { + enabled?: unknown; + fileUrl?: unknown; + username?: unknown; + password?: unknown; + clearPassword?: unknown; + exportType?: unknown; + autoSyncEnabled?: unknown; + autoSyncCron?: unknown; +} + type RuntimeDatabaseConfig = { dialect: MigrationDialect; connectionString: string; @@ -66,6 +111,8 @@ const PROXY_TOKEN_PREFIX = 'sk-'; const DB_TYPE_SETTING_KEY = 'db_type'; const DB_URL_SETTING_KEY = 'db_url'; const DB_SSL_SETTING_KEY = 'db_ssl'; +const SYSTEM_PROXY_TEST_PROBE_URL = 'https://www.gstatic.com/generate_204'; +const SYSTEM_PROXY_TEST_TIMEOUT_MS = 15_000; function isValidProxyToken(value: string): boolean { return value.startsWith(PROXY_TOKEN_PREFIX) && value.length >= 6; @@ -77,15 +124,7 @@ function maskSecret(value: string): string { return `${value.slice(0, 4)}****${value.slice(-4)}`; } -async function upsertSetting(key: string, value: unknown) { - await db.insert(schema.settings) - .values({ key, value: JSON.stringify(value) }) - .onConflictDoUpdate({ - target: schema.settings.key, - set: { value: JSON.stringify(value) }, - }) - .run(); -} + async function appendSettingsEvent(input: { type: 'checkin' | 'balance' | 'proxy' | 'status' | 'token'; @@ -103,7 +142,7 @@ async function appendSettingsEvent(input: { relatedType: 'settings', createdAt, }).run(); - } catch {} + } catch { } } function toPositiveNumberOrFallback(value: unknown, fallback: number) { @@ -112,6 +151,94 @@ function toPositiveNumberOrFallback(value: unknown, fallback: number) { return n; } +function extractNestedErrorMessages(error: unknown): string[] { + const messages: string[] = []; + const visited = new Set(); + let current: any = error; + + while (current && !visited.has(current)) { + visited.add(current); + const message = typeof current?.message === 'string' ? current.message.trim() : ''; + if (message) { + messages.push(message); + } + current = current?.cause; + } + + return messages; +} + +function describeSystemProxyTestFailure(error: unknown): string { + const messages = extractNestedErrorMessages(error); + const detail = messages.find((message) => message && message !== 'fetch failed') + || messages[0] + || '未知错误'; + + if (/ECONNREFUSED/i.test(detail)) { + return '系统代理测试失败:连接被拒绝,请检查代理地址、端口和本地代理程序是否已启动'; + } + + if (/ETIMEDOUT|timed out|timeout/i.test(detail)) { + return '系统代理测试失败:连接超时,请检查代理服务或当前网络是否可用'; + } + + if (/ENOTFOUND|EAI_AGAIN/i.test(detail)) { + return '系统代理测试失败:域名解析失败,请检查网络或代理的 DNS 配置'; + } + + if (/ECONNRESET/i.test(detail)) { + return '系统代理测试失败:连接被对端重置,请检查代理链路是否稳定'; + } + + if (/407/.test(detail) || /proxy authentication/i.test(detail)) { + return '系统代理测试失败:代理要求认证,请检查用户名、密码或代理配置'; + } + + return `系统代理测试失败:${detail}`; +} + +async function testSystemProxyConnectivity(proxyUrl: string) { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), SYSTEM_PROXY_TEST_TIMEOUT_MS); + const startedAt = Date.now(); + + try { + const response = await fetch( + SYSTEM_PROXY_TEST_PROBE_URL, + withExplicitProxyRequestInit(proxyUrl, { + method: 'GET', + signal: controller.signal, + headers: { + 'cache-control': 'no-cache', + 'user-agent': 'metapi-system-proxy-tester/1.0', + }, + }), + ); + + try { + await response.arrayBuffer(); + } catch { + // Ignore body drain failures; reachability is determined by receiving a response. + } + + return { + reachable: true, + ok: response.ok, + statusCode: response.status, + latencyMs: Math.max(1, Date.now() - startedAt), + probeUrl: SYSTEM_PROXY_TEST_PROBE_URL, + finalUrl: response.url || SYSTEM_PROXY_TEST_PROBE_URL, + }; + } catch (error: any) { + if (error?.name === 'AbortError') { + throw new Error(`系统代理测试超时(${Math.round(SYSTEM_PROXY_TEST_TIMEOUT_MS / 1000)}s)`); + } + throw new Error(describeSystemProxyTestFailure(error)); + } finally { + clearTimeout(timeout); + } +} + function toStringList(value: unknown): string[] { if (Array.isArray(value)) { return value @@ -127,6 +254,33 @@ function toStringList(value: unknown): string[] { return []; } +function parseProxyErrorKeywords(value: unknown): string[] { + const splitKeywords = (input: string): string[] => input + .split(/\r?\n|,/) + .map((item) => item.trim()) + .filter((item) => item.length > 0); + + if (Array.isArray(value)) { + const keywords = value.flatMap((item) => { + if (typeof item !== 'string') return []; + return splitKeywords(item); + }); + return keywords; + } + + if (typeof value === 'string') { + const keywords = splitKeywords(value); + return keywords; + } + + throw new Error('上游错误关键词格式无效:需要 string 或 string[]'); +} + +function parseBooleanFlag(value: unknown, label: string): boolean { + if (typeof value === 'boolean') return value; + throw new Error(`${label}格式无效:需要 boolean`); +} + function isValidHttpUrl(raw: string): boolean { const value = String(raw || '').trim(); if (!value) return false; @@ -138,12 +292,50 @@ function isValidHttpUrl(raw: string): boolean { } } +function normalizeTelegramApiBaseUrl(raw: string): string { + return String(raw || '').trim().replace(/\/+$/, ''); +} + +function normalizeTelegramMessageThreadId(raw: unknown): string { + return String(raw || '').trim(); +} + +function isValidTelegramMessageThreadId(raw: string): boolean { + return /^[1-9]\d*$/.test(raw); +} + function applyImportedSettingToRuntime(key: string, value: unknown) { switch (key) { case 'checkin_cron': { if (typeof value !== 'string' || !value || !cron.validate(value)) return; config.checkinCron = value; - updateCheckinCron(value); + updateCheckinSchedule({ + mode: config.checkinScheduleMode, + cronExpr: config.checkinCron, + intervalHours: config.checkinIntervalHours, + }); + return; + } + case 'checkin_schedule_mode': { + if (value !== 'cron' && value !== 'interval') return; + const nextMode: 'cron' | 'interval' = value; + config.checkinScheduleMode = nextMode; + updateCheckinSchedule({ + mode: config.checkinScheduleMode, + cronExpr: config.checkinCron, + intervalHours: config.checkinIntervalHours, + }); + return; + } + case 'checkin_interval_hours': { + const intervalHours = Number(value); + if (!Number.isFinite(intervalHours) || intervalHours < 1 || intervalHours > 24) return; + config.checkinIntervalHours = Math.trunc(intervalHours); + updateCheckinSchedule({ + mode: config.checkinScheduleMode, + cronExpr: config.checkinCron, + intervalHours: config.checkinIntervalHours, + }); return; } case 'balance_refresh_cron': { @@ -152,6 +344,35 @@ function applyImportedSettingToRuntime(key: string, value: unknown) { updateBalanceRefreshCron(value); return; } + case 'log_cleanup_cron': { + if (typeof value !== 'string' || !value || !cron.validate(value)) return; + config.logCleanupConfigured = true; + updateLogCleanupSettings({ cronExpr: value }); + stopProxyLogRetentionService(); + return; + } + case 'log_cleanup_usage_logs_enabled': { + if (typeof value !== 'boolean') return; + config.logCleanupConfigured = true; + updateLogCleanupSettings({ usageLogsEnabled: value }); + stopProxyLogRetentionService(); + return; + } + case 'log_cleanup_program_logs_enabled': { + if (typeof value !== 'boolean') return; + config.logCleanupConfigured = true; + updateLogCleanupSettings({ programLogsEnabled: value }); + stopProxyLogRetentionService(); + return; + } + case 'log_cleanup_retention_days': { + const retentionDays = Number(value); + if (!Number.isFinite(retentionDays) || retentionDays < 1) return; + config.logCleanupConfigured = true; + updateLogCleanupSettings({ retentionDays: Math.trunc(retentionDays) }); + stopProxyLogRetentionService(); + return; + } case 'proxy_token': { if (typeof value !== 'string') return; const nextToken = value.trim(); @@ -164,6 +385,85 @@ function applyImportedSettingToRuntime(key: string, value: unknown) { config.systemProxyUrl = normalizeSiteProxyUrl(value) || ''; return; } + case 'codex_upstream_websocket_enabled': { + if (typeof value !== 'boolean') return; + config.codexUpstreamWebsocketEnabled = value; + return; + } + case 'proxy_error_keywords': { + try { + config.proxyErrorKeywords = parseProxyErrorKeywords(value); + } catch { + return; + } + return; + } + case 'proxy_session_channel_concurrency_limit': { + const limit = Number(value); + if (!Number.isFinite(limit) || limit < 0) return; + config.proxySessionChannelConcurrencyLimit = Math.trunc(limit); + return; + } + case 'proxy_session_channel_queue_wait_ms': { + const queueWaitMs = Number(value); + if (!Number.isFinite(queueWaitMs) || queueWaitMs < 0) return; + config.proxySessionChannelQueueWaitMs = Math.trunc(queueWaitMs); + return; + } + case 'proxy_empty_content_fail_enabled': { + try { + config.proxyEmptyContentFailEnabled = parseBooleanFlag(value, '空内容判定失败开关'); + } catch { + return; + } + return; + } + case 'global_blocked_brands': { + try { + const parsed = typeof value === 'string' ? JSON.parse(value) : value; + if (Array.isArray(parsed)) { + const nextBrands = parsed.filter((b): b is string => typeof b === 'string').map((b) => b.trim()).filter(Boolean); + const prev = JSON.stringify(config.globalBlockedBrands); + config.globalBlockedBrands = nextBrands; + if (prev !== JSON.stringify(nextBrands)) { + startBackgroundTask( + { + type: 'maintenance', + title: '品牌屏蔽变更后重建路由', + dedupeKey: 'refresh-models-and-rebuild-routes', + }, + async () => routeRefreshWorkflow.refreshModelsAndRebuildRoutes(), + ); + } + } + } catch { + return; + } + return; + } + case 'global_allowed_models': { + try { + const parsed = typeof value === 'string' ? JSON.parse(value) : value; + if (Array.isArray(parsed)) { + const nextModels = parsed.filter((m): m is string => typeof m === 'string').map((m) => m.trim()).filter(Boolean); + const prev = JSON.stringify(config.globalAllowedModels); + config.globalAllowedModels = nextModels; + if (prev !== JSON.stringify(nextModels)) { + startBackgroundTask( + { + type: 'maintenance', + title: '模型白名单变更后重建路由', + dedupeKey: 'refresh-models-and-rebuild-routes', + }, + async () => routeRefreshWorkflow.refreshModelsAndRebuildRoutes(), + ); + } + } + } catch { + return; + } + return; + } case 'webhook_url': { if (typeof value !== 'string') return; config.webhookUrl = value.trim(); @@ -195,6 +495,11 @@ function applyImportedSettingToRuntime(key: string, value: unknown) { config.telegramEnabled = !!value; return; } + case 'telegram_api_base_url': { + if (typeof value !== 'string') return; + config.telegramApiBaseUrl = normalizeTelegramApiBaseUrl(value) || 'https://api.telegram.org'; + return; + } case 'telegram_bot_token': { if (typeof value !== 'string') return; config.telegramBotToken = value.trim(); @@ -205,6 +510,15 @@ function applyImportedSettingToRuntime(key: string, value: unknown) { config.telegramChatId = value.trim(); return; } + case 'telegram_use_system_proxy': { + config.telegramUseSystemProxy = !!value; + return; + } + case 'telegram_message_thread_id': { + if (typeof value !== 'string') return; + config.telegramMessageThreadId = value.trim(); + return; + } case 'smtp_enabled': { config.smtpEnabled = !!value; return; @@ -280,7 +594,16 @@ function applyImportedSettingToRuntime(key: string, value: unknown) { function getRuntimeSettingsResponse(currentAdminIp = '') { return { checkinCron: config.checkinCron, + checkinScheduleMode: config.checkinScheduleMode, + checkinIntervalHours: config.checkinIntervalHours, balanceRefreshCron: config.balanceRefreshCron, + logCleanupCron: config.logCleanupCron, + logCleanupUsageLogsEnabled: config.logCleanupUsageLogsEnabled, + logCleanupProgramLogsEnabled: config.logCleanupProgramLogsEnabled, + logCleanupRetentionDays: config.logCleanupRetentionDays, + codexUpstreamWebsocketEnabled: config.codexUpstreamWebsocketEnabled, + proxySessionChannelConcurrencyLimit: config.proxySessionChannelConcurrencyLimit, + proxySessionChannelQueueWaitMs: config.proxySessionChannelQueueWaitMs, routingFallbackUnitCost: config.routingFallbackUnitCost, routingWeights: config.routingWeights, webhookUrl: config.webhookUrl, @@ -290,8 +613,11 @@ function getRuntimeSettingsResponse(currentAdminIp = '') { serverChanEnabled: config.serverChanEnabled, serverChanKeyMasked: maskSecret(config.serverChanKey), telegramEnabled: config.telegramEnabled, + telegramApiBaseUrl: config.telegramApiBaseUrl, telegramBotTokenMasked: maskSecret(config.telegramBotToken), telegramChatId: config.telegramChatId, + telegramUseSystemProxy: config.telegramUseSystemProxy, + telegramMessageThreadId: config.telegramMessageThreadId, smtpEnabled: config.smtpEnabled, smtpHost: config.smtpHost, smtpPort: config.smtpPort, @@ -303,8 +629,13 @@ function getRuntimeSettingsResponse(currentAdminIp = '') { notifyCooldownSec: config.notifyCooldownSec, adminIpAllowlist: config.adminIpAllowlist, currentAdminIp, + serverTimeZone: getResolvedTimeZone(), systemProxyUrl: config.systemProxyUrl, + proxyErrorKeywords: config.proxyErrorKeywords, + proxyEmptyContentFailEnabled: config.proxyEmptyContentFailEnabled, proxyTokenMasked: maskSecret(config.proxyToken), + globalBlockedBrands: config.globalBlockedBrands, + globalAllowedModels: config.globalAllowedModels, }; } @@ -382,6 +713,47 @@ export async function settingsRoutes(app: FastifyInstance) { return getRuntimeSettingsResponse(currentAdminIp); }); + app.get('/api/settings/brand-list', async () => { + return { brands: getAllBrandNames() }; + }); + + app.post<{ Body: SystemProxyTestBody }>('/api/settings/system-proxy/test', async (request, reply) => { + const rawProxyUrl = request.body?.proxyUrl === undefined + ? config.systemProxyUrl + : String(request.body.proxyUrl || '').trim(); + const normalizedProxyUrl = rawProxyUrl + ? normalizeSiteProxyUrl(rawProxyUrl) + : ''; + + if (!rawProxyUrl) { + return reply.code(400).send({ + success: false, + message: '请先填写系统代理地址', + }); + } + + if (!normalizedProxyUrl) { + return reply.code(400).send({ + success: false, + message: '系统代理地址无效,请填写合法的 http(s)/socks 代理 URL', + }); + } + + try { + const result = await testSystemProxyConnectivity(normalizedProxyUrl); + return { + success: true, + proxyUrl: normalizedProxyUrl, + ...result, + }; + } catch (error: any) { + return reply.code(502).send({ + success: false, + message: error?.message || '系统代理测试失败', + }); + } + }); + app.put<{ Body: RuntimeSettingsBody }>('/api/settings/runtime', async (request, reply) => { const body = request.body || {}; const changedLabels: string[] = []; @@ -420,17 +792,26 @@ export async function settingsRoutes(app: FastifyInstance) { } const telegramTouched = body.telegramEnabled !== undefined + || body.telegramApiBaseUrl !== undefined || body.telegramBotToken !== undefined - || body.telegramChatId !== undefined; + || body.telegramChatId !== undefined + || body.telegramUseSystemProxy !== undefined + || body.telegramMessageThreadId !== undefined; const nextTelegramEnabled = body.telegramEnabled !== undefined ? !!body.telegramEnabled : config.telegramEnabled; + const nextTelegramApiBaseUrl = body.telegramApiBaseUrl !== undefined + ? normalizeTelegramApiBaseUrl(body.telegramApiBaseUrl) + : config.telegramApiBaseUrl; const nextTelegramBotToken = body.telegramBotToken !== undefined ? String(body.telegramBotToken || '').trim() : config.telegramBotToken; const nextTelegramChatId = body.telegramChatId !== undefined ? String(body.telegramChatId || '').trim() : config.telegramChatId; + const nextTelegramMessageThreadId = body.telegramMessageThreadId !== undefined + ? normalizeTelegramMessageThreadId(body.telegramMessageThreadId) + : config.telegramMessageThreadId; if (telegramTouched && nextTelegramEnabled) { if (!nextTelegramBotToken) { return reply.code(400).send({ success: false, message: 'Telegram Bot Token 不能为空(启用 Telegram 时)' }); @@ -441,8 +822,22 @@ export async function settingsRoutes(app: FastifyInstance) { if (!nextTelegramChatId) { return reply.code(400).send({ success: false, message: 'Telegram Chat ID 不能为空(启用 Telegram 时)' }); } + if (nextTelegramMessageThreadId && !isValidTelegramMessageThreadId(nextTelegramMessageThreadId)) { + return reply.code(400).send({ success: false, message: 'Telegram Topic ID 格式无效,需要正整数' }); + } + if (nextTelegramApiBaseUrl && !isValidHttpUrl(nextTelegramApiBaseUrl)) { + return reply.code(400).send({ success: false, message: 'Telegram API Base URL 无效,请填写 http/https 地址' }); + } + } else if (body.telegramApiBaseUrl !== undefined && nextTelegramApiBaseUrl && !isValidHttpUrl(nextTelegramApiBaseUrl)) { + return reply.code(400).send({ success: false, message: 'Telegram API Base URL 无效,请填写 http/https 地址' }); + } else if (body.telegramMessageThreadId !== undefined && nextTelegramMessageThreadId && !isValidTelegramMessageThreadId(nextTelegramMessageThreadId)) { + return reply.code(400).send({ success: false, message: 'Telegram Topic ID 格式无效,需要正整数' }); } + const checkinScheduleTouched = body.checkinCron !== undefined + || body.checkinScheduleMode !== undefined + || body.checkinIntervalHours !== undefined; + if (body.checkinCron !== undefined) { if (!cron.validate(body.checkinCron)) { return reply.code(400).send({ success: false, message: '签到 Cron 表达式无效' }); @@ -450,8 +845,50 @@ export async function settingsRoutes(app: FastifyInstance) { if (body.checkinCron !== config.checkinCron) { changedLabels.push(`签到 Cron(${config.checkinCron} -> ${body.checkinCron})`); } - updateCheckinCron(body.checkinCron); - upsertSetting('checkin_cron', body.checkinCron); + } + + if (body.checkinScheduleMode !== undefined) { + if (body.checkinScheduleMode !== 'cron' && body.checkinScheduleMode !== 'interval') { + return reply.code(400).send({ success: false, message: '签到方式无效:仅支持 cron 或 interval' }); + } + if (body.checkinScheduleMode !== config.checkinScheduleMode) { + changedLabels.push('签到方式'); + } + config.checkinScheduleMode = body.checkinScheduleMode; + } + + if (body.checkinIntervalHours !== undefined) { + const intervalHours = Number(body.checkinIntervalHours); + if (!Number.isFinite(intervalHours) || intervalHours < 1 || intervalHours > 24) { + return reply.code(400).send({ success: false, message: '签到间隔必须是 1 到 24 的整数小时' }); + } + const nextIntervalHours = Math.trunc(intervalHours); + if (nextIntervalHours !== config.checkinIntervalHours) { + changedLabels.push(`签到间隔(${config.checkinIntervalHours}h -> ${nextIntervalHours}h)`); + } + config.checkinIntervalHours = nextIntervalHours; + } + + if (checkinScheduleTouched) { + const nextCheckinCron = body.checkinCron !== undefined ? body.checkinCron : config.checkinCron; + const nextCheckinScheduleMode: 'cron' | 'interval' = body.checkinScheduleMode !== undefined + ? body.checkinScheduleMode + : config.checkinScheduleMode; + const nextCheckinIntervalHours = body.checkinIntervalHours !== undefined + ? Math.trunc(Number(body.checkinIntervalHours)) + : config.checkinIntervalHours; + + updateCheckinSchedule({ + mode: nextCheckinScheduleMode, + cronExpr: nextCheckinCron, + intervalHours: nextCheckinIntervalHours, + }); + config.checkinCron = nextCheckinCron; + config.checkinScheduleMode = nextCheckinScheduleMode; + config.checkinIntervalHours = nextCheckinIntervalHours; + upsertSetting('checkin_cron', config.checkinCron); + upsertSetting('checkin_schedule_mode', config.checkinScheduleMode); + upsertSetting('checkin_interval_hours', config.checkinIntervalHours); } if (body.balanceRefreshCron !== undefined) { @@ -465,6 +902,61 @@ export async function settingsRoutes(app: FastifyInstance) { upsertSetting('balance_refresh_cron', body.balanceRefreshCron); } + const logCleanupTouched = + body.logCleanupCron !== undefined + || body.logCleanupUsageLogsEnabled !== undefined + || body.logCleanupProgramLogsEnabled !== undefined + || body.logCleanupRetentionDays !== undefined; + + if (logCleanupTouched) { + const nextLogCleanupCron = body.logCleanupCron !== undefined + ? String(body.logCleanupCron || '').trim() + : config.logCleanupCron; + if (!cron.validate(nextLogCleanupCron)) { + return reply.code(400).send({ success: false, message: '日志清理 Cron 表达式无效' }); + } + + const rawRetentionDays = body.logCleanupRetentionDays !== undefined + ? Number(body.logCleanupRetentionDays) + : config.logCleanupRetentionDays; + if (!Number.isFinite(rawRetentionDays) || rawRetentionDays < 1) { + return reply.code(400).send({ success: false, message: '日志清理保留天数必须是大于等于 1 的整数' }); + } + const nextLogCleanupRetentionDays = normalizeLogCleanupRetentionDays(rawRetentionDays); + const nextUsageLogsEnabled = body.logCleanupUsageLogsEnabled !== undefined + ? !!body.logCleanupUsageLogsEnabled + : config.logCleanupUsageLogsEnabled; + const nextProgramLogsEnabled = body.logCleanupProgramLogsEnabled !== undefined + ? !!body.logCleanupProgramLogsEnabled + : config.logCleanupProgramLogsEnabled; + + if (nextLogCleanupCron !== config.logCleanupCron) { + changedLabels.push(`日志清理 Cron(${config.logCleanupCron} -> ${nextLogCleanupCron})`); + } + if (nextUsageLogsEnabled !== config.logCleanupUsageLogsEnabled) { + changedLabels.push(`自动清理使用日志(${config.logCleanupUsageLogsEnabled ? '开启' : '关闭'} -> ${nextUsageLogsEnabled ? '开启' : '关闭'})`); + } + if (nextProgramLogsEnabled !== config.logCleanupProgramLogsEnabled) { + changedLabels.push(`自动清理程序日志(${config.logCleanupProgramLogsEnabled ? '开启' : '关闭'} -> ${nextProgramLogsEnabled ? '开启' : '关闭'})`); + } + if (nextLogCleanupRetentionDays !== config.logCleanupRetentionDays) { + changedLabels.push(`日志清理保留天数(${config.logCleanupRetentionDays} -> ${nextLogCleanupRetentionDays})`); + } + + config.logCleanupConfigured = true; + updateLogCleanupSettings({ + cronExpr: nextLogCleanupCron, + usageLogsEnabled: nextUsageLogsEnabled, + programLogsEnabled: nextProgramLogsEnabled, + retentionDays: nextLogCleanupRetentionDays, + }); + stopProxyLogRetentionService(); + upsertSetting('log_cleanup_cron', nextLogCleanupCron); + upsertSetting('log_cleanup_usage_logs_enabled', nextUsageLogsEnabled); + upsertSetting('log_cleanup_program_logs_enabled', nextProgramLogsEnabled); + upsertSetting('log_cleanup_retention_days', nextLogCleanupRetentionDays); + } + if (body.proxyToken !== undefined) { const proxyToken = String(body.proxyToken).trim(); if (!proxyToken.startsWith(PROXY_TOKEN_PREFIX)) { @@ -496,6 +988,136 @@ export async function settingsRoutes(app: FastifyInstance) { invalidateSiteProxyCache(); } + if (body.codexUpstreamWebsocketEnabled !== undefined) { + let nextValue = false; + try { + nextValue = parseBooleanFlag(body.codexUpstreamWebsocketEnabled, 'Codex 上游 WebSocket 开关'); + } catch (err: any) { + return reply.code(400).send({ + success: false, + message: err?.message || 'Codex 上游 WebSocket 开关格式无效', + }); + } + + if (nextValue !== config.codexUpstreamWebsocketEnabled) { + changedLabels.push('Codex 上游 WebSocket 默认策略'); + } + config.codexUpstreamWebsocketEnabled = nextValue; + upsertSetting('codex_upstream_websocket_enabled', config.codexUpstreamWebsocketEnabled); + } + + if (body.proxySessionChannelConcurrencyLimit !== undefined) { + const limit = Number(body.proxySessionChannelConcurrencyLimit); + if (!Number.isFinite(limit) || limit < 0) { + return reply.code(400).send({ success: false, message: '会话通道并发上限必须是大于等于 0 的整数' }); + } + const nextLimit = Math.trunc(limit); + if (nextLimit !== config.proxySessionChannelConcurrencyLimit) { + changedLabels.push(`会话通道并发上限(${config.proxySessionChannelConcurrencyLimit} -> ${nextLimit})`); + } + config.proxySessionChannelConcurrencyLimit = nextLimit; + upsertSetting('proxy_session_channel_concurrency_limit', config.proxySessionChannelConcurrencyLimit); + } + + if (body.proxySessionChannelQueueWaitMs !== undefined) { + const rawQueueWaitMs = Number(body.proxySessionChannelQueueWaitMs); + if (!Number.isFinite(rawQueueWaitMs) || rawQueueWaitMs < 0) { + return reply.code(400).send({ success: false, message: '会话通道排队等待时间必须是大于等于 0 的整数毫秒' }); + } + const nextQueueWaitMs = Math.trunc(rawQueueWaitMs); + if (nextQueueWaitMs !== config.proxySessionChannelQueueWaitMs) { + changedLabels.push(`会话通道排队等待(${config.proxySessionChannelQueueWaitMs}ms -> ${nextQueueWaitMs}ms)`); + } + config.proxySessionChannelQueueWaitMs = nextQueueWaitMs; + upsertSetting('proxy_session_channel_queue_wait_ms', config.proxySessionChannelQueueWaitMs); + } + + if (body.proxyErrorKeywords !== undefined) { + let nextKeywords: string[] = []; + try { + nextKeywords = parseProxyErrorKeywords(body.proxyErrorKeywords); + } catch (err: any) { + return reply.code(400).send({ + success: false, + message: err?.message || '上游错误关键词格式无效', + }); + } + + if (JSON.stringify(nextKeywords) !== JSON.stringify(config.proxyErrorKeywords || [])) { + changedLabels.push('上游错误关键词'); + } + config.proxyErrorKeywords = nextKeywords; + upsertSetting('proxy_error_keywords', config.proxyErrorKeywords); + } + + if (body.proxyEmptyContentFailEnabled !== undefined) { + let nextValue = false; + try { + nextValue = parseBooleanFlag(body.proxyEmptyContentFailEnabled, '空内容判定失败开关'); + } catch (err: any) { + return reply.code(400).send({ + success: false, + message: err?.message || '空内容判定失败开关格式无效', + }); + } + + if (nextValue !== config.proxyEmptyContentFailEnabled) { + changedLabels.push('空内容判定失败'); + } + config.proxyEmptyContentFailEnabled = nextValue; + upsertSetting('proxy_empty_content_fail_enabled', config.proxyEmptyContentFailEnabled); + } + + if (body.globalBlockedBrands !== undefined) { + if (!Array.isArray(body.globalBlockedBrands)) { + return reply.code(400).send({ error: 'globalBlockedBrands must be an array of strings' }); + } + const nextBrands = body.globalBlockedBrands.filter((b): b is string => typeof b === 'string').map((b) => b.trim()).filter(Boolean); + const uniqueBrands = Array.from(new Set(nextBrands)); + const prev = JSON.stringify(config.globalBlockedBrands); + const next = JSON.stringify(uniqueBrands); + if (prev !== next) { + changedLabels.push('全局品牌屏蔽'); + } + config.globalBlockedBrands = uniqueBrands; + upsertSetting('global_blocked_brands', JSON.stringify(uniqueBrands)); + if (prev !== next) { + startBackgroundTask( + { + type: 'maintenance', + title: '品牌屏蔽变更后重建路由', + dedupeKey: 'refresh-models-and-rebuild-routes', + }, + async () => routeRefreshWorkflow.refreshModelsAndRebuildRoutes(), + ); + } + } + + if (body.globalAllowedModels !== undefined) { + if (!Array.isArray(body.globalAllowedModels)) { + return reply.code(400).send({ error: 'globalAllowedModels must be an array of strings' }); + } + const nextModels = body.globalAllowedModels.filter((m): m is string => typeof m === 'string').map((m) => m.trim()).filter(Boolean); + const uniqueModels = Array.from(new Set(nextModels)); + const prev = JSON.stringify(config.globalAllowedModels); + const next = JSON.stringify(uniqueModels); + if (prev !== next) { + changedLabels.push('全局模型白名单'); + } + config.globalAllowedModels = uniqueModels; + upsertSetting('global_allowed_models', JSON.stringify(uniqueModels)); + if (prev !== next) { + startBackgroundTask( + { + type: 'maintenance', + title: '模型白名单变更后重建路由', + dedupeKey: 'refresh-models-and-rebuild-routes', + }, + async () => routeRefreshWorkflow.refreshModelsAndRebuildRoutes(), + ); + } + } + if (body.webhookUrl !== undefined) { if (String(body.webhookUrl || '').trim() !== config.webhookUrl) { changedLabels.push('Webhook 地址'); @@ -552,6 +1174,16 @@ export async function settingsRoutes(app: FastifyInstance) { upsertSetting('telegram_enabled', config.telegramEnabled); } + if (body.telegramApiBaseUrl !== undefined) { + const normalizedTelegramApiBaseUrl = normalizeTelegramApiBaseUrl(body.telegramApiBaseUrl); + const nextTelegramApiBaseUrl = normalizedTelegramApiBaseUrl || 'https://api.telegram.org'; + if (nextTelegramApiBaseUrl !== config.telegramApiBaseUrl) { + changedLabels.push('Telegram API Base URL'); + } + config.telegramApiBaseUrl = nextTelegramApiBaseUrl; + upsertSetting('telegram_api_base_url', config.telegramApiBaseUrl); + } + if (body.telegramBotToken !== undefined) { if (String(body.telegramBotToken || '').trim() !== config.telegramBotToken) { changedLabels.push('Telegram Bot Token'); @@ -568,6 +1200,23 @@ export async function settingsRoutes(app: FastifyInstance) { upsertSetting('telegram_chat_id', config.telegramChatId); } + if (body.telegramUseSystemProxy !== undefined) { + if (!!body.telegramUseSystemProxy !== config.telegramUseSystemProxy) { + changedLabels.push('Telegram 使用系统代理'); + } + config.telegramUseSystemProxy = !!body.telegramUseSystemProxy; + upsertSetting('telegram_use_system_proxy', config.telegramUseSystemProxy); + } + + if (body.telegramMessageThreadId !== undefined) { + const nextTelegramMessageThreadId = normalizeTelegramMessageThreadId(body.telegramMessageThreadId); + if (nextTelegramMessageThreadId !== config.telegramMessageThreadId) { + changedLabels.push('Telegram Topic ID'); + } + config.telegramMessageThreadId = nextTelegramMessageThreadId; + upsertSetting('telegram_message_thread_id', config.telegramMessageThreadId); + } + if (body.smtpEnabled !== undefined) { if (!!body.smtpEnabled !== config.smtpEnabled) { changedLabels.push('SMTP 开关'); @@ -810,6 +1459,9 @@ export async function settingsRoutes(app: FastifyInstance) { for (const item of result.appliedSettings) { applyImportedSettingToRuntime(item.key, item.value); } + if (result.appliedSettings.some((item) => item.key === 'backup_webdav_config_v1')) { + await reloadBackupWebdavScheduler(); + } return { success: true, message: '导入完成', @@ -823,6 +1475,65 @@ export async function settingsRoutes(app: FastifyInstance) { } }); + app.get('/api/settings/backup/webdav', async () => { + return getBackupWebdavConfig(); + }); + + app.put<{ Body: BackupWebdavConfigBody }>('/api/settings/backup/webdav', async (request, reply) => { + try { + const body = request.body || {}; + const result = await saveBackupWebdavConfig({ + enabled: body.enabled === undefined ? undefined : body.enabled === true, + fileUrl: body.fileUrl === undefined ? undefined : String(body.fileUrl || ''), + username: body.username === undefined ? undefined : String(body.username || ''), + password: body.password === undefined ? undefined : String(body.password), + clearPassword: body.clearPassword === true, + exportType: body.exportType === undefined ? undefined : String(body.exportType || '') as BackupExportType, + autoSyncEnabled: body.autoSyncEnabled === undefined ? undefined : body.autoSyncEnabled === true, + autoSyncCron: body.autoSyncCron === undefined ? undefined : String(body.autoSyncCron || ''), + }); + return result; + } catch (err: any) { + return reply.code(400).send({ + success: false, + message: err?.message || 'WebDAV 配置保存失败', + }); + } + }); + + app.post<{ Body: { type?: string } }>('/api/settings/backup/webdav/export', async (request, reply) => { + try { + const rawType = typeof request.body?.type === 'string' ? request.body.type.trim().toLowerCase() : ''; + const type: BackupExportType | undefined = rawType === 'all' || rawType === 'accounts' || rawType === 'preferences' + ? rawType + : undefined; + return await exportBackupToWebdav(type); + } catch (err: any) { + return reply.code(400).send({ + success: false, + message: err?.message || 'WebDAV 导出失败', + }); + } + }); + + app.post('/api/settings/backup/webdav/import', async (_, reply) => { + try { + const result = await importBackupFromWebdav(); + for (const item of result.appliedSettings) { + applyImportedSettingToRuntime(item.key, item.value); + } + if (result.appliedSettings.some((item) => item.key === 'backup_webdav_config_v1')) { + await reloadBackupWebdavScheduler(); + } + return result; + } catch (err: any) { + return reply.code(400).send({ + success: false, + message: err?.message || 'WebDAV 导入失败', + }); + } + }); + app.post('/api/settings/notify/test', async (_, reply) => { try { const result = await sendNotification( @@ -865,7 +1576,7 @@ export async function settingsRoutes(app: FastifyInstance) { }, failureMessage: (currentTask) => `缓存清理后重建失败:${currentTask.error || 'unknown error'}`, }, - async () => refreshModelsAndRebuildRoutes(), + async () => routeRefreshWorkflow.refreshModelsAndRebuildRoutes(), ); return reply.code(202).send({ @@ -889,7 +1600,10 @@ export async function settingsRoutes(app: FastifyInstance) { totalLatencyMs: 0, totalCost: 0, lastUsedAt: null, + lastSelectedAt: null, lastFailAt: null, + consecutiveFailCount: 0, + cooldownLevel: 0, cooldownUntil: null, }).run(); diff --git a/src/server/routes/api/siteAnnouncements.test.ts b/src/server/routes/api/siteAnnouncements.test.ts new file mode 100644 index 00000000..ee22f9c7 --- /dev/null +++ b/src/server/routes/api/siteAnnouncements.test.ts @@ -0,0 +1,301 @@ +import Fastify, { type FastifyInstance } from 'fastify'; +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { eq } from 'drizzle-orm'; + +const syncSiteAnnouncementsMock = vi.fn(); + +vi.mock('../../services/siteAnnouncementService.js', () => ({ + syncSiteAnnouncements: (...args: unknown[]) => syncSiteAnnouncementsMock(...args), +})); + +type DbModule = typeof import('../../db/index.js'); + +describe('site announcements routes', () => { + let app: FastifyInstance; + let db: DbModule['db']; + let schema: DbModule['schema']; + let closeDbConnections: DbModule['closeDbConnections'] | undefined; + let getBackgroundTask: ((taskId: string) => { id: string; status: string } | null) | null = null; + let resetBackgroundTasks: (() => void) | null = null; + let dataDir = ''; + + beforeAll(async () => { + dataDir = mkdtempSync(join(tmpdir(), 'metapi-site-announcements-routes-')); + process.env.DATA_DIR = dataDir; + + await import('../../db/migrate.js'); + const dbModule = await import('../../db/index.js'); + const routesModule = await import('./siteAnnouncements.js'); + const eventsModule = await import('./events.js'); + const backgroundTaskModule = await import('../../services/backgroundTaskService.js'); + db = dbModule.db; + schema = dbModule.schema; + closeDbConnections = dbModule.closeDbConnections; + getBackgroundTask = backgroundTaskModule.getBackgroundTask; + resetBackgroundTasks = backgroundTaskModule.__resetBackgroundTasksForTests; + + app = Fastify(); + await app.register(routesModule.siteAnnouncementsRoutes); + await app.register(eventsModule.eventsRoutes); + }); + + beforeEach(async () => { + syncSiteAnnouncementsMock.mockReset(); + resetBackgroundTasks?.(); + await db.delete(schema.siteAnnouncements).run(); + await db.delete(schema.events).run(); + await db.delete(schema.accounts).run(); + await db.delete(schema.sites).run(); + }); + + afterAll(async () => { + if (app) { + await app.close(); + } + if (typeof closeDbConnections === 'function') { + await closeDbConnections(); + } + if (dataDir) { + try { + rmSync(dataDir, { recursive: true, force: true }); + } catch {} + } + delete process.env.DATA_DIR; + }); + + it('lists announcements with site and unread filters', async () => { + const site = await db.insert(schema.sites).values({ + name: 'Sub Site', + url: 'https://sub.example.com', + platform: 'sub2api', + }).returning().get(); + const otherSite = await db.insert(schema.sites).values({ + name: 'Other Site', + url: 'https://other.example.com', + platform: 'new-api', + }).returning().get(); + + await db.insert(schema.siteAnnouncements).values([ + { + siteId: site.id, + platform: 'sub2api', + sourceKey: 'announcement:11', + title: 'Maintenance', + content: 'Window starts at 10:00', + level: 'info', + firstSeenAt: '2026-03-20 10:00:00', + lastSeenAt: '2026-03-20 10:00:00', + }, + { + siteId: otherSite.id, + platform: 'new-api', + sourceKey: 'announcement:12', + title: 'Other', + content: 'Other notice', + level: 'info', + firstSeenAt: '2026-03-20 09:00:00', + lastSeenAt: '2026-03-20 09:00:00', + readAt: '2026-03-20 09:05:00', + }, + ]).run(); + + const response = await app.inject({ + method: 'GET', + url: `/api/site-announcements?siteId=${site.id}&read=false`, + }); + + expect(response.statusCode).toBe(200); + const body = response.json() as Array<{ siteId: number; sourceKey: string }>; + expect(body).toEqual([ + expect.objectContaining({ + siteId: site.id, + sourceKey: 'announcement:11', + }), + ]); + }); + + it('normalizes postgres Date values for managed timestamps before filtering unread rows', async () => { + const routesModule = await import('./siteAnnouncements.js'); + + const rows = routesModule.buildSiteAnnouncementsResponseRows([ + { + id: 1, + siteId: 7, + platform: 'sub2api', + sourceKey: 'announcement:read', + title: 'Read row', + content: 'Already read', + level: 'info', + sourceUrl: null, + startsAt: null, + endsAt: null, + upstreamCreatedAt: '2026-03-10T04:30:08.200Z', + upstreamUpdatedAt: '2026-03-10T04:30:08.200Z', + firstSeenAt: new Date('2026-03-19T22:41:26.000Z') as never, + lastSeenAt: new Date('2026-03-19T22:41:26.000Z') as never, + readAt: new Date('2026-03-19T22:45:00.000Z') as never, + dismissedAt: null, + rawPayload: null, + }, + { + id: 2, + siteId: 8, + platform: 'new-api', + sourceKey: 'announcement:unread', + title: 'Unread row', + content: 'Still unread', + level: 'info', + sourceUrl: null, + startsAt: null, + endsAt: null, + upstreamCreatedAt: null, + upstreamUpdatedAt: null, + firstSeenAt: new Date('2026-03-19T22:55:27.000Z') as never, + lastSeenAt: new Date('2026-03-19T22:55:27.000Z') as never, + readAt: null, + dismissedAt: null, + rawPayload: null, + }, + ] as Array, { + read: 'false', + timeZone: 'Asia/Shanghai', + }); + + expect(rows).toEqual([ + expect.objectContaining({ + id: 2, + firstSeenAt: '2026-03-20 06:55:27', + lastSeenAt: '2026-03-20 06:55:27', + readAt: null, + }), + ]); + }); + + it('marks one announcement as read and then marks all as read', async () => { + const site = await db.insert(schema.sites).values({ + name: 'Sub Site', + url: 'https://sub.example.com', + platform: 'sub2api', + }).returning().get(); + const first = await db.insert(schema.siteAnnouncements).values({ + siteId: site.id, + platform: 'sub2api', + sourceKey: 'announcement:11', + title: 'Maintenance', + content: 'Window starts at 10:00', + level: 'info', + firstSeenAt: '2026-03-20 10:00:00', + lastSeenAt: '2026-03-20 10:00:00', + }).returning().get(); + await db.insert(schema.siteAnnouncements).values({ + siteId: site.id, + platform: 'sub2api', + sourceKey: 'announcement:12', + title: 'Model online', + content: 'gpt-4.1 is available', + level: 'info', + firstSeenAt: '2026-03-20 11:00:00', + lastSeenAt: '2026-03-20 11:00:00', + }).run(); + + const singleRead = await app.inject({ + method: 'POST', + url: `/api/site-announcements/${first.id}/read`, + }); + expect(singleRead.statusCode).toBe(200); + + const afterSingle = await db.select().from(schema.siteAnnouncements).where(eq(schema.siteAnnouncements.id, first.id)).get(); + expect(afterSingle?.readAt).toBeTruthy(); + + const bulkRead = await app.inject({ + method: 'POST', + url: '/api/site-announcements/read-all', + }); + expect(bulkRead.statusCode).toBe(200); + + const rows = await db.select().from(schema.siteAnnouncements).all(); + expect(rows.every((row) => typeof row.readAt === 'string' && row.readAt.length > 0)).toBe(true); + }); + + it('clears local announcement rows without touching program events', async () => { + const site = await db.insert(schema.sites).values({ + name: 'Sub Site', + url: 'https://sub.example.com', + platform: 'sub2api', + }).returning().get(); + await db.insert(schema.siteAnnouncements).values({ + siteId: site.id, + platform: 'sub2api', + sourceKey: 'announcement:11', + title: 'Maintenance', + content: 'Window starts at 10:00', + level: 'info', + firstSeenAt: '2026-03-20 10:00:00', + lastSeenAt: '2026-03-20 10:00:00', + }).run(); + await db.insert(schema.events).values({ + type: 'site_notice', + title: '站点公告:Sub Site', + message: 'Window starts at 10:00', + level: 'info', + relatedId: 123, + relatedType: 'site_announcement', + createdAt: '2026-03-20 10:00:00', + }).run(); + + const response = await app.inject({ + method: 'DELETE', + url: '/api/site-announcements', + }); + + expect(response.statusCode).toBe(200); + expect(await db.select().from(schema.siteAnnouncements).all()).toEqual([]); + expect(await db.select().from(schema.events).all()).toHaveLength(1); + }); + + it('queues a background sync task and returns its task id', async () => { + const site = await db.insert(schema.sites).values({ + name: 'Sub Site', + url: 'https://sub.example.com', + platform: 'sub2api', + }).returning().get(); + + syncSiteAnnouncementsMock.mockImplementation(() => new Promise(() => {})); + + const response = await app.inject({ + method: 'POST', + url: '/api/site-announcements/sync', + payload: { siteId: site.id }, + }); + + expect(response.statusCode).toBe(200); + const body = response.json() as { taskId: string; queued: boolean }; + expect(body.queued).toBe(true); + expect(typeof body.taskId).toBe('string'); + expect(getBackgroundTask?.(body.taskId)).toBeTruthy(); + }); + + it('returns site_notice rows through the generic events api filter', async () => { + await db.insert(schema.events).values({ + type: 'site_notice', + title: '站点公告:Sub Site', + message: 'Window starts at 10:00', + level: 'info', + relatedId: 1, + relatedType: 'site', + createdAt: '2026-03-20 10:00:00', + }).run(); + + const response = await app.inject({ + method: 'GET', + url: '/api/events?type=site_notice', + }); + + expect(response.statusCode).toBe(200); + const body = response.json() as Array<{ type: string }>; + expect(body).toEqual([expect.objectContaining({ type: 'site_notice' })]); + }); +}); diff --git a/src/server/routes/api/siteAnnouncements.ts b/src/server/routes/api/siteAnnouncements.ts new file mode 100644 index 00000000..ef8dd71a --- /dev/null +++ b/src/server/routes/api/siteAnnouncements.ts @@ -0,0 +1,196 @@ +import { FastifyInstance } from 'fastify'; +import { and, desc, eq } from 'drizzle-orm'; +import { db, schema } from '../../db/index.js'; +import { startBackgroundTask } from '../../services/backgroundTaskService.js'; +import { formatUtcSqlDateTime, getResolvedTimeZone } from '../../services/localTimeService.js'; +import { syncSiteAnnouncements } from '../../services/siteAnnouncementService.js'; + +type SiteAnnouncementRow = typeof schema.siteAnnouncements.$inferSelect; +type SiteAnnouncementsResponseFilters = { + read?: string; + status?: string; + timeZone?: string; +}; + +function formatDateTimePartsInTimeZone(value: Date, timeZone: string): string | null { + if (Number.isNaN(value.getTime())) return null; + const formatter = new Intl.DateTimeFormat('en-CA', { + timeZone, + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: false, + }); + const parts = formatter.formatToParts(value); + const lookup = new Map(parts.map((part) => [part.type, part.value])); + const year = lookup.get('year'); + const month = lookup.get('month'); + const day = lookup.get('day'); + const hour = lookup.get('hour'); + const minute = lookup.get('minute'); + const second = lookup.get('second'); + if (!year || !month || !day || !hour || !minute || !second) return null; + return `${year}-${month}-${day} ${hour}:${minute}:${second}`; +} + +function normalizeManagedDateTimeValue(input: unknown, timeZone = getResolvedTimeZone()): string | null { + if (input == null) return null; + if (input instanceof Date) { + return formatDateTimePartsInTimeZone(input, timeZone); + } + const raw = String(input).trim(); + return raw || null; +} + +function parseTimeValue(input?: unknown): number | null { + if (input instanceof Date) { + return Number.isNaN(input.getTime()) ? null : input.getTime(); + } + const raw = String(input || '').trim(); + if (!raw) return null; + const parsed = Date.parse(raw.includes('T') ? raw : raw.replace(' ', 'T') + 'Z'); + return Number.isFinite(parsed) ? parsed : null; +} + +function hasDateTimeValue(input: unknown): boolean { + if (input instanceof Date) { + return !Number.isNaN(input.getTime()); + } + return typeof input === 'string' && input.trim().length > 0; +} + +export function normalizeSiteAnnouncementRowForResponse(row: SiteAnnouncementRow, timeZone = getResolvedTimeZone()) { + return { + ...row, + firstSeenAt: normalizeManagedDateTimeValue((row as { firstSeenAt?: unknown }).firstSeenAt, timeZone), + lastSeenAt: normalizeManagedDateTimeValue((row as { lastSeenAt?: unknown }).lastSeenAt, timeZone), + readAt: normalizeManagedDateTimeValue((row as { readAt?: unknown }).readAt, timeZone), + dismissedAt: normalizeManagedDateTimeValue((row as { dismissedAt?: unknown }).dismissedAt, timeZone), + }; +} + +function applyReadFilter(rows: SiteAnnouncementRow[], read?: string): SiteAnnouncementRow[] { + if (read === 'true') { + return rows.filter((row) => hasDateTimeValue((row as { readAt?: unknown }).readAt)); + } + if (read === 'false') { + return rows.filter((row) => !hasDateTimeValue((row as { readAt?: unknown }).readAt)); + } + return rows; +} + +function applyStatusFilter(rows: SiteAnnouncementRow[], status?: string): SiteAnnouncementRow[] { + const normalized = String(status || '').trim().toLowerCase(); + if (!normalized) return rows; + const now = Date.now(); + if (normalized === 'dismissed') { + return rows.filter((row) => hasDateTimeValue((row as { dismissedAt?: unknown }).dismissedAt)); + } + if (normalized === 'expired') { + return rows.filter((row) => !row.dismissedAt && (() => { + const endsAt = parseTimeValue((row as { endsAt?: unknown }).endsAt); + return endsAt !== null && endsAt < now; + })()); + } + if (normalized === 'active') { + return rows.filter((row) => !row.dismissedAt && (() => { + const endsAt = parseTimeValue((row as { endsAt?: unknown }).endsAt); + return endsAt === null || endsAt >= now; + })()); + } + return rows; +} + +export function buildSiteAnnouncementsResponseRows( + rows: SiteAnnouncementRow[], + options?: SiteAnnouncementsResponseFilters, +) { + const normalizedRows = rows.map((row) => normalizeSiteAnnouncementRowForResponse(row, options?.timeZone)); + return applyStatusFilter(applyReadFilter(normalizedRows, options?.read), options?.status); +} + +export async function siteAnnouncementsRoutes(app: FastifyInstance) { + app.get<{ + Querystring: { + limit?: string; + offset?: string; + siteId?: string; + platform?: string; + read?: string; + status?: string; + }; + }>('/api/site-announcements', async (request) => { + const limit = Math.max(1, Math.min(500, Number.parseInt(request.query.limit || '50', 10))); + const offset = Math.max(0, Number.parseInt(request.query.offset || '0', 10)); + const filters: any[] = []; + + const siteId = Number.parseInt(String(request.query.siteId || ''), 10); + if (Number.isFinite(siteId) && siteId > 0) { + filters.push(eq(schema.siteAnnouncements.siteId, siteId)); + } + + const platform = String(request.query.platform || '').trim(); + if (platform) { + filters.push(eq(schema.siteAnnouncements.platform, platform)); + } + + const base = db.select().from(schema.siteAnnouncements); + const rows = filters.length > 0 + ? await base.where(and(...filters)).orderBy(desc(schema.siteAnnouncements.firstSeenAt)).all() + : await base.orderBy(desc(schema.siteAnnouncements.firstSeenAt)).all(); + + const filtered = buildSiteAnnouncementsResponseRows(rows, { + read: request.query.read, + status: request.query.status, + }); + return filtered.slice(offset, offset + limit); + }); + + app.post<{ Params: { id: string } }>('/api/site-announcements/:id/read', async (request) => { + const id = Number.parseInt(request.params.id, 10); + const readAt = formatUtcSqlDateTime(new Date()); + await db.update(schema.siteAnnouncements) + .set({ readAt }) + .where(eq(schema.siteAnnouncements.id, id)) + .run(); + return { success: true }; + }); + + app.post('/api/site-announcements/read-all', async () => { + const readAt = formatUtcSqlDateTime(new Date()); + await db.update(schema.siteAnnouncements) + .set({ readAt }) + .run(); + return { success: true }; + }); + + app.delete('/api/site-announcements', async () => { + await db.delete(schema.siteAnnouncements).run(); + return { success: true }; + }); + + app.post<{ Body: { siteId?: number | string | null } }>('/api/site-announcements/sync', async (request) => { + const parsedSiteId = Number.parseInt(String(request.body?.siteId ?? ''), 10); + const siteId = Number.isFinite(parsedSiteId) && parsedSiteId > 0 ? parsedSiteId : null; + const { task, reused } = startBackgroundTask( + { + type: 'site-announcements-sync', + title: siteId ? `同步站点公告 #${siteId}` : '同步站点公告', + dedupeKey: siteId ? `site-announcements:${siteId}` : 'site-announcements:all', + notifyOnSuccess: false, + notifyOnFailure: false, + }, + () => syncSiteAnnouncements(siteId ? { siteId } : undefined), + ); + + return { + success: true, + queued: true, + reused, + taskId: task.id, + }; + }); +} diff --git a/src/server/routes/api/sites.disabledModels.test.ts b/src/server/routes/api/sites.disabledModels.test.ts new file mode 100644 index 00000000..29ee482d --- /dev/null +++ b/src/server/routes/api/sites.disabledModels.test.ts @@ -0,0 +1,218 @@ +import Fastify, { type FastifyInstance } from 'fastify'; +import { describe, expect, it, beforeAll, beforeEach, afterAll } from 'vitest'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { mkdtempSync } from 'node:fs'; +import { eq } from 'drizzle-orm'; + +type DbModule = typeof import('../../db/index.js'); + +describe('sites disabled models API', () => { + let app: FastifyInstance; + let db: DbModule['db']; + let schema: DbModule['schema']; + let dataDir = ''; + + beforeAll(async () => { + dataDir = mkdtempSync(join(tmpdir(), 'metapi-sites-disabled-models-')); + process.env.DATA_DIR = dataDir; + + await import('../../db/migrate.js'); + const dbModule = await import('../../db/index.js'); + const routesModule = await import('./sites.js'); + db = dbModule.db; + schema = dbModule.schema; + + app = Fastify(); + await app.register(routesModule.sitesRoutes); + }); + + beforeEach(async () => { + await db.delete(schema.siteDisabledModels).run(); + await db.delete(schema.accounts).run(); + await db.delete(schema.sites).run(); + }); + + afterAll(async () => { + await app.close(); + delete process.env.DATA_DIR; + }); + + it('returns empty list for a new site', async () => { + const site = await db.insert(schema.sites).values({ + name: 'test-site', + url: 'https://test-site.example.com', + platform: 'new-api', + }).returning().get(); + + const resp = await app.inject({ + method: 'GET', + url: `/api/sites/${site.id}/disabled-models`, + }); + + expect(resp.statusCode).toBe(200); + const body = resp.json(); + expect(body.siteId).toBe(site.id); + expect(body.models).toEqual([]); + }); + + it('sets and retrieves disabled models', async () => { + const site = await db.insert(schema.sites).values({ + name: 'test-site', + url: 'https://test-site.example.com', + platform: 'new-api', + }).returning().get(); + + const putResp = await app.inject({ + method: 'PUT', + url: `/api/sites/${site.id}/disabled-models`, + payload: { models: ['gpt-4o', 'claude-sonnet-4-5-20250929'] }, + }); + + expect(putResp.statusCode).toBe(200); + const putBody = putResp.json(); + expect(putBody.models).toHaveLength(2); + expect(putBody.models).toContain('gpt-4o'); + expect(putBody.models).toContain('claude-sonnet-4-5-20250929'); + + const getResp = await app.inject({ + method: 'GET', + url: `/api/sites/${site.id}/disabled-models`, + }); + + expect(getResp.statusCode).toBe(200); + const getBody = getResp.json(); + expect(getBody.models).toHaveLength(2); + expect(getBody.models).toContain('gpt-4o'); + expect(getBody.models).toContain('claude-sonnet-4-5-20250929'); + }); + + it('replaces disabled models on subsequent PUT', async () => { + const site = await db.insert(schema.sites).values({ + name: 'test-site', + url: 'https://test-site.example.com', + platform: 'new-api', + }).returning().get(); + + await app.inject({ + method: 'PUT', + url: `/api/sites/${site.id}/disabled-models`, + payload: { models: ['gpt-4o', 'gpt-3.5-turbo'] }, + }); + + const putResp = await app.inject({ + method: 'PUT', + url: `/api/sites/${site.id}/disabled-models`, + payload: { models: ['claude-sonnet-4-5-20250929'] }, + }); + + expect(putResp.statusCode).toBe(200); + const body = putResp.json(); + expect(body.models).toEqual(['claude-sonnet-4-5-20250929']); + + // Verify old models are removed + const getResp = await app.inject({ + method: 'GET', + url: `/api/sites/${site.id}/disabled-models`, + }); + const getBody = getResp.json(); + expect(getBody.models).toEqual(['claude-sonnet-4-5-20250929']); + }); + + it('clears disabled models with empty array', async () => { + const site = await db.insert(schema.sites).values({ + name: 'test-site', + url: 'https://test-site.example.com', + platform: 'new-api', + }).returning().get(); + + await app.inject({ + method: 'PUT', + url: `/api/sites/${site.id}/disabled-models`, + payload: { models: ['gpt-4o'] }, + }); + + const putResp = await app.inject({ + method: 'PUT', + url: `/api/sites/${site.id}/disabled-models`, + payload: { models: [] }, + }); + + expect(putResp.statusCode).toBe(200); + expect(putResp.json().models).toEqual([]); + + const getResp = await app.inject({ + method: 'GET', + url: `/api/sites/${site.id}/disabled-models`, + }); + expect(getResp.json().models).toEqual([]); + }); + + it('returns 404 for non-existent site', async () => { + const getResp = await app.inject({ + method: 'GET', + url: '/api/sites/99999/disabled-models', + }); + expect(getResp.statusCode).toBe(404); + + const putResp = await app.inject({ + method: 'PUT', + url: '/api/sites/99999/disabled-models', + payload: { models: ['gpt-4o'] }, + }); + expect(putResp.statusCode).toBe(404); + }); + + it('returns 400 when models is not an array', async () => { + const site = await db.insert(schema.sites).values({ + name: 'test-site', + url: 'https://test-site.example.com', + platform: 'new-api', + }).returning().get(); + + const resp = await app.inject({ + method: 'PUT', + url: `/api/sites/${site.id}/disabled-models`, + payload: { models: 'not-an-array' }, + }); + expect(resp.statusCode).toBe(400); + }); + + it('deduplicates model names', async () => { + const site = await db.insert(schema.sites).values({ + name: 'test-site', + url: 'https://test-site.example.com', + platform: 'new-api', + }).returning().get(); + + const putResp = await app.inject({ + method: 'PUT', + url: `/api/sites/${site.id}/disabled-models`, + payload: { models: ['gpt-4o', 'gpt-4o', 'gpt-4o'] }, + }); + + expect(putResp.statusCode).toBe(200); + expect(putResp.json().models).toEqual(['gpt-4o']); + }); + + it('deletes disabled models when site is deleted (cascade)', async () => { + const site = await db.insert(schema.sites).values({ + name: 'delete-test', + url: 'https://delete-test.example.com', + platform: 'new-api', + }).returning().get(); + + await app.inject({ + method: 'PUT', + url: `/api/sites/${site.id}/disabled-models`, + payload: { models: ['gpt-4o', 'claude-sonnet-4-5-20250929'] }, + }); + + await db.delete(schema.sites).where(eq(schema.sites.id, site.id)).run(); + + const rows = await db.select().from(schema.siteDisabledModels) + .where(eq(schema.siteDisabledModels.siteId, site.id)) + .all(); + expect(rows).toHaveLength(0); + }); +}); diff --git a/src/server/routes/api/sites.proxyUrl.test.ts b/src/server/routes/api/sites.proxyUrl.test.ts index e0e2c544..3fc54e4d 100644 --- a/src/server/routes/api/sites.proxyUrl.test.ts +++ b/src/server/routes/api/sites.proxyUrl.test.ts @@ -6,7 +6,7 @@ import { mkdtempSync } from 'node:fs'; type DbModule = typeof import('../../db/index.js'); -describe('sites system proxy settings', () => { +describe('sites proxy settings', () => { let app: FastifyInstance; let db: DbModule['db']; let schema: DbModule['schema']; @@ -36,7 +36,7 @@ describe('sites system proxy settings', () => { delete process.env.DATA_DIR; }); - it('stores useSystemProxy and external checkin url when creating a site', async () => { + it('stores proxy settings, external checkin url, and custom headers when creating a site', async () => { const response = await app.inject({ method: 'POST', url: '/api/sites', @@ -44,7 +44,12 @@ describe('sites system proxy settings', () => { name: 'proxy-site', url: 'https://proxy-site.example.com', platform: 'new-api', + proxyUrl: 'socks5://127.0.0.1:1080', useSystemProxy: true, + customHeaders: JSON.stringify({ + 'cf-access-client-id': 'site-client-id', + 'x-site-scope': 'internal', + }), externalCheckinUrl: 'https://checkin.example.com/welfare', globalWeight: 1.5, }, @@ -52,15 +57,45 @@ describe('sites system proxy settings', () => { expect(response.statusCode).toBe(200); const payload = response.json() as { + proxyUrl?: string | null; useSystemProxy?: boolean; + customHeaders?: string | null; externalCheckinUrl?: string | null; globalWeight?: number; }; + expect(payload.proxyUrl).toBe('socks5://127.0.0.1:1080'); expect(payload.useSystemProxy).toBe(true); + expect(payload.customHeaders).toBe('{"cf-access-client-id":"site-client-id","x-site-scope":"internal"}'); expect(payload.externalCheckinUrl).toBe('https://checkin.example.com/welfare'); expect(payload.globalWeight).toBe(1.5); }); + it('returns a conflict response when the same platform and url already exist', async () => { + const first = await app.inject({ + method: 'POST', + url: '/api/sites', + payload: { + name: 'existing-site', + url: 'https://duplicate-site.example.com/', + platform: 'new-api', + }, + }); + expect(first.statusCode).toBe(200); + + const duplicate = await app.inject({ + method: 'POST', + url: '/api/sites', + payload: { + name: 'duplicate-site', + url: 'https://duplicate-site.example.com', + platform: 'new-api', + }, + }); + + expect(duplicate.statusCode).toBe(409); + expect((duplicate.json() as { error?: string }).error).toContain('already exists'); + }); + it('rejects invalid useSystemProxy flag', async () => { const response = await app.inject({ method: 'POST', @@ -77,6 +112,22 @@ describe('sites system proxy settings', () => { expect((response.json() as { error?: string }).error).toContain('Invalid useSystemProxy'); }); + it('rejects invalid proxy url', async () => { + const response = await app.inject({ + method: 'POST', + url: '/api/sites', + payload: { + name: 'proxy-site', + url: 'https://proxy-site.example.com', + platform: 'new-api', + proxyUrl: 'not-a-proxy', + }, + }); + + expect(response.statusCode).toBe(400); + expect((response.json() as { error?: string }).error).toContain('Invalid proxyUrl'); + }); + it('rejects invalid site global weight', async () => { const response = await app.inject({ method: 'POST', @@ -109,7 +160,7 @@ describe('sites system proxy settings', () => { expect((response.json() as { error?: string }).error).toContain('Invalid externalCheckinUrl'); }); - it('updates useSystemProxy for an existing site', async () => { + it('updates per-site proxy settings for an existing site', async () => { const created = await app.inject({ method: 'POST', url: '/api/sites', @@ -127,11 +178,85 @@ describe('sites system proxy settings', () => { method: 'PUT', url: `/api/sites/${site.id}`, payload: { + proxyUrl: 'http://127.0.0.1:8080', useSystemProxy: true, }, }); expect(response.statusCode).toBe(200); - expect((response.json() as { useSystemProxy?: boolean }).useSystemProxy).toBe(true); + const payload = response.json() as { proxyUrl?: string | null; useSystemProxy?: boolean }; + expect(payload.proxyUrl).toBe('http://127.0.0.1:8080'); + expect(payload.useSystemProxy).toBe(true); + }); + + it('returns a conflict response when updating a site to an existing platform and url', async () => { + const first = await app.inject({ + method: 'POST', + url: '/api/sites', + payload: { + name: 'first-site', + url: 'https://first-site.example.com', + platform: 'new-api', + }, + }); + expect(first.statusCode).toBe(200); + + const second = await app.inject({ + method: 'POST', + url: '/api/sites', + payload: { + name: 'second-site', + url: 'https://second-site.example.com', + platform: 'new-api', + }, + }); + expect(second.statusCode).toBe(200); + + const { id } = second.json() as { id: number }; + const response = await app.inject({ + method: 'PUT', + url: `/api/sites/${id}`, + payload: { + url: 'https://first-site.example.com/', + platform: 'new-api', + }, + }); + + expect(response.statusCode).toBe(409); + expect((response.json() as { error?: string }).error).toContain('already exists'); + }); + + it('rejects invalid custom headers json', async () => { + const response = await app.inject({ + method: 'POST', + url: '/api/sites', + payload: { + name: 'headers-site', + url: 'https://headers-site.example.com', + platform: 'new-api', + customHeaders: '{invalid-json}', + }, + }); + + expect(response.statusCode).toBe(400); + expect((response.json() as { error?: string }).error).toContain('Invalid customHeaders'); + }); + + it('rejects custom headers with non-string values', async () => { + const response = await app.inject({ + method: 'POST', + url: '/api/sites', + payload: { + name: 'headers-site', + url: 'https://headers-site.example.com', + platform: 'new-api', + customHeaders: JSON.stringify({ + 'x-site-scope': true, + }), + }, + }); + + expect(response.statusCode).toBe(400); + expect((response.json() as { error?: string }).error).toContain('must use a string value'); }); }); diff --git a/src/server/routes/api/sites.subscription-summary.test.ts b/src/server/routes/api/sites.subscription-summary.test.ts new file mode 100644 index 00000000..6dc6c12f --- /dev/null +++ b/src/server/routes/api/sites.subscription-summary.test.ts @@ -0,0 +1,128 @@ +import Fastify, { type FastifyInstance } from 'fastify'; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { mkdtempSync } from 'node:fs'; +import { + buildStoredSub2ApiSubscriptionSummary, + mergeAccountExtraConfig, +} from '../../services/accountExtraConfig.js'; + +type DbModule = typeof import('../../db/index.js'); + +describe('sites route subscription summary aggregation', () => { + let app: FastifyInstance; + let db: DbModule['db']; + let schema: DbModule['schema']; + let dataDir = ''; + + beforeAll(async () => { + dataDir = mkdtempSync(join(tmpdir(), 'metapi-sites-subscription-')); + process.env.DATA_DIR = dataDir; + + await import('../../db/migrate.js'); + const dbModule = await import('../../db/index.js'); + const routesModule = await import('./sites.js'); + db = dbModule.db; + schema = dbModule.schema; + + app = Fastify(); + await app.register(routesModule.sitesRoutes); + }); + + beforeEach(async () => { + await db.delete(schema.accounts).run(); + await db.delete(schema.sites).run(); + }); + + afterAll(async () => { + await app.close(); + delete process.env.DATA_DIR; + }); + + it('aggregates stored sub2api subscription summaries into /api/sites', async () => { + const site = await db.insert(schema.sites).values({ + name: 'sub2api-site', + url: 'https://sub2api.example.com', + platform: 'sub2api', + }).returning().get(); + + await db.insert(schema.accounts).values([ + { + siteId: site.id, + username: 'user-a', + accessToken: 'token-a', + balance: 10, + status: 'active', + extraConfig: mergeAccountExtraConfig(null, { + sub2apiSubscription: buildStoredSub2ApiSubscriptionSummary({ + activeCount: 1, + totalUsedUsd: 3, + subscriptions: [ + { + id: 11, + groupName: 'Pro', + expiresAt: '2026-04-10T00:00:00.000Z', + monthlyUsedUsd: 3, + monthlyLimitUsd: 20, + }, + ], + }, 1760000000000), + }), + }, + { + siteId: site.id, + username: 'user-b', + accessToken: 'token-b', + balance: 5, + status: 'active', + extraConfig: mergeAccountExtraConfig(null, { + sub2apiSubscription: buildStoredSub2ApiSubscriptionSummary({ + activeCount: 1, + totalUsedUsd: 2, + subscriptions: [ + { + id: 12, + groupName: 'Lite', + expiresAt: '2026-03-25T00:00:00.000Z', + monthlyUsedUsd: 2, + monthlyLimitUsd: 10, + }, + ], + }, 1760000002000), + }), + }, + ]).run(); + + const response = await app.inject({ + method: 'GET', + url: '/api/sites', + }); + + expect(response.statusCode).toBe(200); + const body = response.json() as Array<{ + totalBalance: number; + subscriptionSummary: { + activeCount: number; + totalUsedUsd: number; + totalMonthlyLimitUsd: number | null; + totalRemainingUsd: number | null; + nextExpiresAt: string | null; + planNames: string[]; + updatedAt: number | null; + } | null; + }>; + + expect(body).toHaveLength(1); + expect(body[0]?.totalBalance).toBe(15); + expect(body[0]?.subscriptionSummary).toEqual({ + activeCount: 2, + totalUsedUsd: 5, + totalMonthlyLimitUsd: 30, + totalRemainingUsd: 25, + nextExpiresAt: '2026-03-25T00:00:00.000Z', + planNames: ['Pro', 'Lite'], + updatedAt: 1760000002000, + }); + }); +}); diff --git a/src/server/routes/api/sites.ts b/src/server/routes/api/sites.ts index bf752763..4e04fed0 100644 --- a/src/server/routes/api/sites.ts +++ b/src/server/routes/api/sites.ts @@ -1,10 +1,12 @@ -import { FastifyInstance } from 'fastify'; +import { FastifyInstance, FastifyReply } from 'fastify'; import { db, schema } from '../../db/index.js'; -import { and, eq, sql } from 'drizzle-orm'; +import { and, eq } from 'drizzle-orm'; import { detectSite } from '../../services/siteDetector.js'; -import { invalidateSiteProxyCache } from '../../services/siteProxy.js'; +import { invalidateSiteProxyCache, parseSiteProxyUrlInput } from '../../services/siteProxy.js'; import { formatUtcSqlDateTime } from '../../services/localTimeService.js'; import { invalidateTokenRouterCache } from '../../services/tokenRouter.js'; +import { parseSiteCustomHeadersInput } from '../../services/siteCustomHeaders.js'; +import { getSub2ApiSubscriptionFromExtraConfig } from '../../services/accountExtraConfig.js'; function normalizeSiteStatus(input: unknown): 'active' | 'disabled' | null { if (input === undefined || input === null) return null; @@ -74,6 +76,123 @@ function normalizeOptionalExternalCheckinUrl(input: unknown): { return { valid: true, present: true, url: parsed.toString().replace(/\/+$/, '') }; } +type ErrorLike = { + message?: string; + code?: string | number; + cause?: unknown; +}; + +function normalizeSiteUrl(url: string): string { + return url.replace(/\/+$/, ''); +} + +function getErrorChain(error: unknown): ErrorLike[] { + const chain: ErrorLike[] = []; + const seen = new Set(); + let current: unknown = error; + while (current && typeof current === 'object' && !seen.has(current)) { + seen.add(current); + chain.push(current as ErrorLike); + current = (current as ErrorLike).cause; + } + return chain; +} + +function isSitesPlatformUrlConflict(error: unknown): boolean { + return getErrorChain(error).some((entry) => { + const message = String(entry.message || ''); + const lowered = message.toLowerCase(); + const code = String(entry.code || ''); + const isSqliteSitesUnique = ( + (code === 'SQLITE_CONSTRAINT' || code === 'SQLITE_CONSTRAINT_UNIQUE') + && lowered.includes('unique constraint failed: sites.platform, sites.url') + ); + return (code === '23505' && lowered.includes('sites_platform_url_unique')) + || (code === 'ER_DUP_ENTRY' && lowered.includes('sites_platform_url_unique')) + || isSqliteSitesUnique + || (lowered.includes('duplicate key value violates unique constraint') && lowered.includes('sites_platform_url_unique')) + || (lowered.includes('duplicate entry') && lowered.includes('sites_platform_url_unique')); + }); +} + +function findExistingSiteBinding( + siteRows: Array<{ id: number; url: string; platform: string }>, + platform: string, + normalizedUrl: string, + excludedSiteId?: number, +) { + return siteRows.find((site) => ( + site.platform === platform + && site.url === normalizedUrl + && site.id !== excludedSiteId + )); +} + +function sendSiteBindingConflict(reply: FastifyReply, platform: string, normalizedUrl: string) { + return reply.code(409).send({ + error: `A ${platform} site with URL ${normalizedUrl} already exists.`, + }); +} + +type SiteSubscriptionAggregate = { + activeCount: number; + totalUsedUsd: number; + totalMonthlyLimitUsd: number | null; + totalRemainingUsd: number | null; + nextExpiresAt: string | null; + planNames: string[]; + updatedAt: number | null; +}; + +function roundMetric(value: number): number { + return Math.round(value * 1_000_000) / 1_000_000; +} + +function pickEarlierIsoDate(current?: string | null, next?: string | null): string | null { + if (!current) return next || null; + if (!next) return current; + const currentMs = Date.parse(current); + const nextMs = Date.parse(next); + if (!Number.isFinite(currentMs)) return next; + if (!Number.isFinite(nextMs)) return current; + return nextMs < currentMs ? next : current; +} + +function aggregateSiteSubscription( + current: SiteSubscriptionAggregate | undefined, + extraConfig?: string | null, +): SiteSubscriptionAggregate | undefined { + const stored = getSub2ApiSubscriptionFromExtraConfig(extraConfig); + if (!stored) return current; + + const planNames = new Set(current?.planNames || []); + let totalMonthlyLimitUsd = current?.totalMonthlyLimitUsd ?? null; + let nextExpiresAt = current?.nextExpiresAt ?? null; + + for (const item of stored.subscriptions) { + if (item.groupName) planNames.add(item.groupName); + if (typeof item.monthlyLimitUsd === 'number' && Number.isFinite(item.monthlyLimitUsd)) { + totalMonthlyLimitUsd = roundMetric((totalMonthlyLimitUsd ?? 0) + item.monthlyLimitUsd); + } + nextExpiresAt = pickEarlierIsoDate(nextExpiresAt, item.expiresAt); + } + + const totalUsedUsd = roundMetric((current?.totalUsedUsd || 0) + stored.totalUsedUsd); + const totalRemainingUsd = totalMonthlyLimitUsd == null + ? null + : roundMetric(Math.max(0, totalMonthlyLimitUsd - totalUsedUsd)); + + return { + activeCount: (current?.activeCount || 0) + stored.activeCount, + totalUsedUsd, + totalMonthlyLimitUsd, + totalRemainingUsd, + nextExpiresAt, + planNames: Array.from(planNames), + updatedAt: Math.max(current?.updatedAt || 0, stored.updatedAt || 0) || null, + }; +} + export async function sitesRoutes(app: FastifyInstance) { function invalidateSiteCaches() { invalidateSiteProxyCache(); @@ -103,7 +222,7 @@ export async function sitesRoutes(app: FastifyInstance) { relatedType: 'site', createdAt, }).run(); - } catch {} + } catch { } return; } @@ -123,7 +242,7 @@ export async function sitesRoutes(app: FastifyInstance) { relatedType: 'site', createdAt, }).run(); - } catch {} + } catch { } } function normalizeBatchIds(input: unknown): number[] { @@ -136,21 +255,23 @@ export async function sitesRoutes(app: FastifyInstance) { // List all sites app.get('/api/sites', async () => { const siteRows = await db.select().from(schema.sites).all(); - const accountBalanceRows = await db.select({ + const accountRows = await db.select({ siteId: schema.accounts.siteId, - totalBalance: sql`coalesce(sum(${schema.accounts.balance}), 0)`, - }).from(schema.accounts) - .groupBy(schema.accounts.siteId) - .all(); + balance: schema.accounts.balance, + extraConfig: schema.accounts.extraConfig, + }).from(schema.accounts).all(); const totalBalanceBySiteId: Record = {}; - for (const row of accountBalanceRows) { - totalBalanceBySiteId[row.siteId] = Number(row.totalBalance || 0); + const subscriptionBySiteId: Record = {}; + for (const row of accountRows) { + totalBalanceBySiteId[row.siteId] = roundMetric((totalBalanceBySiteId[row.siteId] || 0) + Number(row.balance || 0)); + subscriptionBySiteId[row.siteId] = aggregateSiteSubscription(subscriptionBySiteId[row.siteId], row.extraConfig); } return siteRows.map((site) => ({ ...site, totalBalance: Math.round((totalBalanceBySiteId[site.id] || 0) * 1_000_000) / 1_000_000, + subscriptionSummary: subscriptionBySiteId[site.id] || null, })); }); @@ -159,14 +280,16 @@ export async function sitesRoutes(app: FastifyInstance) { name: string; url: string; platform?: string; + proxyUrl?: string | null; useSystemProxy?: boolean; + customHeaders?: string | null; externalCheckinUrl?: string | null; status?: string; isPinned?: boolean; sortOrder?: number; globalWeight?: number; } }>('/api/sites', async (request, reply) => { - const { name, url, platform, useSystemProxy, externalCheckinUrl, status, isPinned, sortOrder, globalWeight } = request.body; + const { name, url, platform, proxyUrl, useSystemProxy, customHeaders, externalCheckinUrl, status, isPinned, sortOrder, globalWeight } = request.body; const normalizedStatus = normalizeSiteStatus(status); if (status !== undefined && !normalizedStatus) { return reply.code(400).send({ error: 'Invalid site status. Expected active or disabled.' }); @@ -175,6 +298,10 @@ export async function sitesRoutes(app: FastifyInstance) { if (useSystemProxy !== undefined && normalizedUseSystemProxy === null) { return reply.code(400).send({ error: 'Invalid useSystemProxy value. Expected boolean.' }); } + const normalizedProxyUrl = parseSiteProxyUrlInput(proxyUrl); + if (!normalizedProxyUrl.valid) { + return reply.code(400).send({ error: 'Invalid proxyUrl. Expected a valid http(s)/socks proxy URL.' }); + } const normalizedExternalCheckinUrl = normalizeOptionalExternalCheckinUrl(externalCheckinUrl); if (!normalizedExternalCheckinUrl.valid) { return reply.code(400).send({ error: 'Invalid externalCheckinUrl. Expected a valid http(s) URL.' }); @@ -191,9 +318,14 @@ export async function sitesRoutes(app: FastifyInstance) { if (globalWeight !== undefined && normalizedGlobalWeight === null) { return reply.code(400).send({ error: 'Invalid globalWeight value. Expected a positive number.' }); } + const normalizedCustomHeaders = parseSiteCustomHeadersInput(customHeaders); + if (!normalizedCustomHeaders.valid) { + return reply.code(400).send({ error: normalizedCustomHeaders.error || 'Invalid customHeaders.' }); + } const existingSites = await db.select().from(schema.sites).all(); const maxSortOrder = existingSites.reduce((max, site) => Math.max(max, site.sortOrder || 0), -1); + const normalizedUrl = normalizeSiteUrl(url); let detectedPlatform = platform; if (!detectedPlatform) { @@ -203,17 +335,32 @@ export async function sitesRoutes(app: FastifyInstance) { if (!detectedPlatform) { return { error: 'Could not detect platform. Please specify manually.' }; } - const inserted = await db.insert(schema.sites).values({ - name, - url: url.replace(/\/+$/, ''), - platform: detectedPlatform, - useSystemProxy: normalizedUseSystemProxy ?? false, - externalCheckinUrl: normalizedExternalCheckinUrl.url, - status: normalizedStatus ?? 'active', - isPinned: normalizedPinned ?? false, - sortOrder: normalizedSortOrder ?? (maxSortOrder + 1), - globalWeight: normalizedGlobalWeight ?? 1, - }).run(); + const conflictingSite = findExistingSiteBinding(existingSites, detectedPlatform, normalizedUrl); + if (conflictingSite) { + return sendSiteBindingConflict(reply, detectedPlatform, normalizedUrl); + } + + let inserted; + try { + inserted = await db.insert(schema.sites).values({ + name, + url: normalizedUrl, + platform: detectedPlatform, + proxyUrl: normalizedProxyUrl.proxyUrl, + useSystemProxy: normalizedUseSystemProxy ?? false, + customHeaders: normalizedCustomHeaders.customHeaders, + externalCheckinUrl: normalizedExternalCheckinUrl.url, + status: normalizedStatus ?? 'active', + isPinned: normalizedPinned ?? false, + sortOrder: normalizedSortOrder ?? (maxSortOrder + 1), + globalWeight: normalizedGlobalWeight ?? 1, + }).run(); + } catch (error) { + if (isSitesPlatformUrlConflict(error)) { + return sendSiteBindingConflict(reply, detectedPlatform, normalizedUrl); + } + throw error; + } const siteId = Number(inserted.lastInsertRowid || 0); if (siteId <= 0) { return reply.code(500).send({ error: 'Create site failed' }); @@ -231,7 +378,9 @@ export async function sitesRoutes(app: FastifyInstance) { name?: string; url?: string; platform?: string; + proxyUrl?: string | null; useSystemProxy?: boolean; + customHeaders?: string | null; externalCheckinUrl?: string | null; status?: string; isPinned?: boolean; @@ -258,6 +407,10 @@ export async function sitesRoutes(app: FastifyInstance) { if (body.useSystemProxy !== undefined && normalizedUseSystemProxy === null) { return reply.code(400).send({ error: 'Invalid useSystemProxy value. Expected boolean.' }); } + const normalizedProxyUrl = parseSiteProxyUrlInput(body.proxyUrl); + if (!normalizedProxyUrl.valid) { + return reply.code(400).send({ error: 'Invalid proxyUrl. Expected a valid http(s)/socks proxy URL.' }); + } const normalizedExternalCheckinUrl = normalizeOptionalExternalCheckinUrl(body.externalCheckinUrl); if (!normalizedExternalCheckinUrl.valid) { return reply.code(400).send({ error: 'Invalid externalCheckinUrl. Expected a valid http(s) URL.' }); @@ -274,18 +427,46 @@ export async function sitesRoutes(app: FastifyInstance) { if (body.globalWeight !== undefined && normalizedGlobalWeight === null) { return reply.code(400).send({ error: 'Invalid globalWeight value. Expected a positive number.' }); } + const normalizedCustomHeaders = parseSiteCustomHeadersInput(body.customHeaders); + if (!normalizedCustomHeaders.valid) { + return reply.code(400).send({ error: normalizedCustomHeaders.error || 'Invalid customHeaders.' }); + } + + const nextUrl = body.url !== undefined ? normalizeSiteUrl(body.url) : existingSite.url; + const nextPlatform = body.platform !== undefined ? body.platform : existingSite.platform; + const siteIdentityChanged = nextUrl !== existingSite.url || nextPlatform !== existingSite.platform; + if (siteIdentityChanged) { + const siteRows = await db.select({ + id: schema.sites.id, + url: schema.sites.url, + platform: schema.sites.platform, + }).from(schema.sites).all(); + const conflictingSite = findExistingSiteBinding(siteRows, nextPlatform, nextUrl, id); + if (conflictingSite) { + return sendSiteBindingConflict(reply, nextPlatform, nextUrl); + } + } if (body.name !== undefined) updates.name = body.name; - if (body.url !== undefined) updates.url = body.url.replace(/\/+$/, ''); + if (body.url !== undefined) updates.url = nextUrl; if (body.platform !== undefined) updates.platform = body.platform; + if (normalizedProxyUrl.present) updates.proxyUrl = normalizedProxyUrl.proxyUrl; if (body.useSystemProxy !== undefined) updates.useSystemProxy = normalizedUseSystemProxy; + if (normalizedCustomHeaders.present) updates.customHeaders = normalizedCustomHeaders.customHeaders; if (normalizedExternalCheckinUrl.present) updates.externalCheckinUrl = normalizedExternalCheckinUrl.url; if (body.status !== undefined) updates.status = normalizedStatus; if (body.isPinned !== undefined) updates.isPinned = normalizedPinned; if (body.sortOrder !== undefined) updates.sortOrder = normalizedSortOrder; if (body.globalWeight !== undefined) updates.globalWeight = normalizedGlobalWeight; updates.updatedAt = new Date().toISOString(); - await db.update(schema.sites).set(updates).where(eq(schema.sites.id, id)).run(); + try { + await db.update(schema.sites).set(updates).where(eq(schema.sites.id, id)).run(); + } catch (error) { + if (isSitesPlatformUrlConflict(error)) { + return sendSiteBindingConflict(reply, nextPlatform, nextUrl); + } + throw error; + } if (body.status !== undefined && normalizedStatus) { await applySiteStatusSideEffects(id, existingSite.name, normalizedStatus); @@ -359,6 +540,101 @@ export async function sitesRoutes(app: FastifyInstance) { }; }); + // Get disabled models for a site + app.get<{ Params: { id: string } }>('/api/sites/:id/disabled-models', async (request, reply) => { + const id = parseInt(request.params.id); + if (Number.isNaN(id)) { + return reply.code(400).send({ error: 'Invalid site id' }); + } + const existingSite = await db.select().from(schema.sites).where(eq(schema.sites.id, id)).get(); + if (!existingSite) { + return reply.code(404).send({ error: 'Site not found' }); + } + const rows = await db.select({ modelName: schema.siteDisabledModels.modelName }) + .from(schema.siteDisabledModels) + .where(eq(schema.siteDisabledModels.siteId, id)) + .all(); + return { siteId: id, models: rows.map((r) => r.modelName) }; + }); + + // Update disabled models for a site (full replace) + app.put<{ Params: { id: string }; Body: { models?: string[] } }>('/api/sites/:id/disabled-models', async (request, reply) => { + const id = parseInt(request.params.id); + if (Number.isNaN(id)) { + return reply.code(400).send({ error: 'Invalid site id' }); + } + const existingSite = await db.select().from(schema.sites).where(eq(schema.sites.id, id)).get(); + if (!existingSite) { + return reply.code(404).send({ error: 'Site not found' }); + } + const rawModels = request.body?.models; + if (!Array.isArray(rawModels)) { + return reply.code(400).send({ error: 'models must be an array of strings' }); + } + const models = rawModels + .filter((m): m is string => typeof m === 'string') + .map((m) => m.trim()) + .filter((m) => m.length > 0); + const uniqueModels = Array.from(new Set(models)); + + await db.delete(schema.siteDisabledModels) + .where(eq(schema.siteDisabledModels.siteId, id)) + .run(); + + if (uniqueModels.length > 0) { + await db.insert(schema.siteDisabledModels).values( + uniqueModels.map((modelName) => ({ siteId: id, modelName })), + ).run(); + } + + invalidateSiteCaches(); + return { siteId: id, models: uniqueModels }; + }); + + // Get all discovered models for a site (from model_availability and token_model_availability) + app.get<{ Params: { id: string } }>('/api/sites/:id/available-models', async (request, reply) => { + const id = parseInt(request.params.id); + if (Number.isNaN(id)) { + return reply.code(400).send({ error: 'Invalid site id' }); + } + const existingSite = await db.select().from(schema.sites).where(eq(schema.sites.id, id)).get(); + if (!existingSite) { + return reply.code(404).send({ error: 'Site not found' }); + } + + // Get models from model_availability (account-level) + const accountModels = await db.select({ modelName: schema.modelAvailability.modelName }) + .from(schema.modelAvailability) + .innerJoin(schema.accounts, eq(schema.modelAvailability.accountId, schema.accounts.id)) + .where( + and( + eq(schema.accounts.siteId, id), + eq(schema.modelAvailability.available, true), + ), + ) + .all(); + + // Get models from token_model_availability (token-level) + const tokenModels = await db.select({ modelName: schema.tokenModelAvailability.modelName }) + .from(schema.tokenModelAvailability) + .innerJoin(schema.accountTokens, eq(schema.tokenModelAvailability.tokenId, schema.accountTokens.id)) + .innerJoin(schema.accounts, eq(schema.accountTokens.accountId, schema.accounts.id)) + .where( + and( + eq(schema.accounts.siteId, id), + eq(schema.tokenModelAvailability.available, true), + ), + ) + .all(); + + const models = Array.from(new Set([ + ...accountModels.map((r) => r.modelName.trim()), + ...tokenModels.map((r) => r.modelName.trim()), + ])).filter((m) => m.length > 0).sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' })); + + return { siteId: id, models }; + }); + // Detect platform for a URL app.post<{ Body: { url: string } }>('/api/sites/detect', async (request) => { const result = await detectSite(request.body.url); diff --git a/src/server/routes/api/stats.proxy-logs.test.ts b/src/server/routes/api/stats.proxy-logs.test.ts index 39a41794..fcfe2be4 100644 --- a/src/server/routes/api/stats.proxy-logs.test.ts +++ b/src/server/routes/api/stats.proxy-logs.test.ts @@ -29,6 +29,7 @@ describe('stats proxy logs routes', () => { beforeEach(async () => { await db.delete(schema.proxyLogs).run(); + await db.delete(schema.downstreamApiKeys).run(); await db.delete(schema.accounts).run(); await db.delete(schema.sites).run(); }); @@ -52,6 +53,14 @@ describe('stats proxy logs routes', () => { status: 'active', }).returning().get(); + const downstreamKey = await db.insert(schema.downstreamApiKeys).values({ + name: '项目A-Key', + key: 'sk-project-a-001', + groupName: '项目A', + tags: JSON.stringify(['VIP', '灰度']), + enabled: true, + }).returning().get(); + const timestamps = [ formatUtcSqlDateTime(new Date('2026-03-09T08:00:00.000Z')), formatUtcSqlDateTime(new Date('2026-03-09T08:01:00.000Z')), @@ -62,9 +71,14 @@ describe('stats proxy logs routes', () => { await db.insert(schema.proxyLogs).values([ { accountId: account.id, + downstreamApiKeyId: downstreamKey.id, modelRequested: 'gpt-4o', modelActual: 'gpt-4o', status: 'success', + clientFamily: 'generic', + clientAppId: 'cherry_studio', + clientAppName: 'Cherry Studio', + clientConfidence: 'exact', promptTokens: 10, completionTokens: 5, totalTokens: 15, @@ -74,9 +88,11 @@ describe('stats proxy logs routes', () => { }, { accountId: account.id, + downstreamApiKeyId: downstreamKey.id, modelRequested: 'gpt-4o-mini', modelActual: 'gpt-4o-mini', status: 'failed', + clientFamily: 'codex', promptTokens: 8, completionTokens: 2, totalTokens: 10, @@ -128,6 +144,10 @@ describe('stats proxy logs routes', () => { totalCost: number; totalTokensAll: number; }; + clientOptions: Array<{ + value: string; + label: string; + }>; }; expect(body.page).toBe(2); @@ -136,7 +156,17 @@ describe('stats proxy logs routes', () => { expect(body.items).toHaveLength(1); expect(body.items[0]?.modelRequested).toBe('gpt-4o-mini'); expect(body.items[0]?.status).toBe('failed'); + expect(body.items[0]?.downstreamKeyName).toBe('项目A-Key'); + expect(body.items[0]?.downstreamKeyGroupName).toBe('项目A'); + expect(body.items[0]?.downstreamKeyTags).toEqual(['VIP', '灰度']); + expect(body.items[0]?.clientFamily).toBe('codex'); + expect(body.items[0]?.clientAppId).toBe(null); + expect(body.items[0]?.clientAppName).toBe(null); + expect(body.items[0]?.clientConfidence).toBe(null); expect(body.items[0]).not.toHaveProperty('billingDetails'); + expect(body.clientOptions).toEqual([ + { value: 'family:codex', label: '协议 · Codex' }, + ]); expect(body.summary).toEqual({ totalCount: 3, successCount: 1, @@ -160,11 +190,24 @@ describe('stats proxy logs routes', () => { status: 'active', }).returning().get(); + const downstreamKey = await db.insert(schema.downstreamApiKeys).values({ + name: 'detail-key', + key: 'sk-detail-key-001', + groupName: '测试项目', + tags: JSON.stringify(['回归', '日志']), + enabled: true, + }).returning().get(); + const inserted = await db.insert(schema.proxyLogs).values({ accountId: account.id, + downstreamApiKeyId: downstreamKey.id, modelRequested: 'gpt-5', modelActual: 'gpt-5', status: 'success', + clientFamily: 'codex', + clientAppId: 'cherry_studio', + clientAppName: 'Cherry Studio', + clientConfidence: 'exact', promptTokens: 100, completionTokens: 20, totalTokens: 120, @@ -188,15 +231,307 @@ describe('stats proxy logs routes', () => { id: number; siteName: string | null; username: string | null; + downstreamKeyName: string | null; + downstreamKeyGroupName: string | null; + downstreamKeyTags: string[]; + clientFamily: string | null; + clientAppId: string | null; + clientAppName: string | null; + clientConfidence: string | null; billingDetails: Record | null; }; expect(body.id).toBe(logId); expect(body.siteName).toBe('detail-site'); expect(body.username).toBe('detail-user'); + expect(body.downstreamKeyName).toBe('detail-key'); + expect(body.downstreamKeyGroupName).toBe('测试项目'); + expect(body.downstreamKeyTags).toEqual(['回归', '日志']); + expect(body.clientFamily).toBe('codex'); + expect(body.clientAppId).toBe('cherry_studio'); + expect(body.clientAppName).toBe('Cherry Studio'); + expect(body.clientConfidence).toBe('exact'); expect(body.billingDetails).toMatchObject({ breakdown: { totalCost: 0.12 }, usage: { promptTokens: 100, completionTokens: 20 }, }); }); + + it('supports searching proxy logs by downstream key metadata', async () => { + const site = await db.insert(schema.sites).values({ + name: 'meta-site', + url: 'https://meta.example.com', + platform: 'new-api', + }).returning().get(); + + const account = await db.insert(schema.accounts).values({ + siteId: site.id, + username: 'meta-user', + accessToken: 'meta-token', + status: 'active', + }).returning().get(); + + const alphaKey = await db.insert(schema.downstreamApiKeys).values({ + name: '渠道-A', + key: 'sk-channel-a', + groupName: '项目甲', + tags: JSON.stringify(['商务', 'VIP']), + enabled: true, + }).returning().get(); + + const betaKey = await db.insert(schema.downstreamApiKeys).values({ + name: '渠道-B', + key: 'sk-channel-b', + groupName: '项目乙', + tags: JSON.stringify(['灰度']), + enabled: true, + }).returning().get(); + + await db.insert(schema.proxyLogs).values([ + { + accountId: account.id, + downstreamApiKeyId: alphaKey.id, + modelRequested: 'gpt-4o', + modelActual: 'gpt-4o', + status: 'success', + totalTokens: 12, + estimatedCost: 0.12, + createdAt: formatUtcSqlDateTime(new Date('2026-03-09T10:00:00.000Z')), + }, + { + accountId: account.id, + downstreamApiKeyId: betaKey.id, + modelRequested: 'gpt-4.1-mini', + modelActual: 'gpt-4.1-mini', + status: 'success', + totalTokens: 22, + estimatedCost: 0.22, + createdAt: formatUtcSqlDateTime(new Date('2026-03-09T10:05:00.000Z')), + }, + ]).run(); + + const response = await app.inject({ + method: 'GET', + url: '/api/stats/proxy-logs?search=%E9%A1%B9%E7%9B%AE%E7%94%B2', + }); + + expect(response.statusCode).toBe(200); + const body = response.json() as { + total: number; + items: Array>; + }; + + expect(body.total).toBe(1); + expect(body.items[0]?.downstreamKeyName).toBe('渠道-A'); + expect(body.items[0]?.downstreamKeyGroupName).toBe('项目甲'); + }); + + it('filters proxy logs by site and time range', async () => { + const alphaSite = await db.insert(schema.sites).values({ + name: 'alpha-site', + url: 'https://alpha.example.com', + platform: 'new-api', + }).returning().get(); + const betaSite = await db.insert(schema.sites).values({ + name: 'beta-site', + url: 'https://beta.example.com', + platform: 'new-api', + }).returning().get(); + + const alphaAccount = await db.insert(schema.accounts).values({ + siteId: alphaSite.id, + username: 'alpha-user', + accessToken: 'alpha-token', + status: 'active', + }).returning().get(); + const betaAccount = await db.insert(schema.accounts).values({ + siteId: betaSite.id, + username: 'beta-user', + accessToken: 'beta-token', + status: 'active', + }).returning().get(); + + await db.insert(schema.proxyLogs).values([ + { + accountId: alphaAccount.id, + modelRequested: 'gpt-4o', + modelActual: 'gpt-4o', + status: 'success', + totalTokens: 10, + estimatedCost: 0.11, + createdAt: formatUtcSqlDateTime(new Date('2026-03-09T08:15:00.000Z')), + }, + { + accountId: alphaAccount.id, + modelRequested: 'gpt-4.1-mini', + modelActual: 'gpt-4.1-mini', + status: 'failed', + totalTokens: 20, + estimatedCost: 0.22, + createdAt: formatUtcSqlDateTime(new Date('2026-03-09T08:45:00.000Z')), + }, + { + accountId: alphaAccount.id, + modelRequested: 'gpt-4.1', + modelActual: 'gpt-4.1', + status: 'success', + totalTokens: 30, + estimatedCost: 0.33, + createdAt: formatUtcSqlDateTime(new Date('2026-03-09T09:15:00.000Z')), + }, + { + accountId: betaAccount.id, + modelRequested: 'claude-3-7-sonnet', + modelActual: 'claude-3-7-sonnet', + status: 'success', + totalTokens: 40, + estimatedCost: 0.44, + createdAt: formatUtcSqlDateTime(new Date('2026-03-09T08:30:00.000Z')), + }, + ]).run(); + + const response = await app.inject({ + method: 'GET', + url: `/api/stats/proxy-logs?siteId=${alphaSite.id}&from=${encodeURIComponent('2026-03-09T08:00:00.000Z')}&to=${encodeURIComponent('2026-03-09T09:00:00.000Z')}`, + }); + + expect(response.statusCode).toBe(200); + const body = response.json() as { + items: Array>; + total: number; + summary: { + totalCount: number; + successCount: number; + failedCount: number; + totalCost: number; + totalTokensAll: number; + }; + }; + + expect(body.total).toBe(2); + expect(body.items).toHaveLength(2); + expect(body.items.map((item) => item.siteId)).toEqual([alphaSite.id, alphaSite.id]); + expect(body.items.map((item) => item.siteName)).toEqual(['alpha-site', 'alpha-site']); + expect(body.summary).toEqual({ + totalCount: 2, + successCount: 1, + failedCount: 1, + totalCost: 0.33, + totalTokensAll: 30, + }); + }); + + it('filters proxy logs by app id while keeping client options scoped only by the other filters', async () => { + const site = await db.insert(schema.sites).values({ + name: 'client-filter-site', + url: 'https://client-filter.example.com', + platform: 'new-api', + }).returning().get(); + + const account = await db.insert(schema.accounts).values({ + siteId: site.id, + username: 'client-filter-user', + accessToken: 'client-filter-token', + status: 'active', + }).returning().get(); + + await db.insert(schema.proxyLogs).values([ + { + accountId: account.id, + modelRequested: 'gpt-4o', + modelActual: 'gpt-4o', + status: 'success', + clientFamily: 'generic', + clientAppId: 'cherry_studio', + clientAppName: 'Cherry Studio', + clientConfidence: 'exact', + totalTokens: 12, + estimatedCost: 0.12, + createdAt: formatUtcSqlDateTime(new Date('2026-03-09T11:00:00.000Z')), + }, + { + accountId: account.id, + modelRequested: 'gpt-4.1', + modelActual: 'gpt-4.1', + status: 'failed', + clientFamily: 'codex', + totalTokens: 22, + estimatedCost: 0.22, + createdAt: formatUtcSqlDateTime(new Date('2026-03-09T11:05:00.000Z')), + }, + ]).run(); + + const response = await app.inject({ + method: 'GET', + url: '/api/stats/proxy-logs?client=app%3Acherry_studio', + }); + + expect(response.statusCode).toBe(200); + const body = response.json() as { + total: number; + items: Array>; + clientOptions: Array<{ + value: string; + label: string; + }>; + }; + + expect(body.total).toBe(1); + expect(body.items[0]?.clientAppId).toBe('cherry_studio'); + expect(body.clientOptions).toEqual([ + { value: 'app:cherry_studio', label: '应用 · Cherry Studio' }, + { value: 'family:codex', label: '协议 · Codex' }, + ]); + }); + + it('falls back to legacy client prefixes for old logs without inferring an app fingerprint', async () => { + const site = await db.insert(schema.sites).values({ + name: 'legacy-site', + url: 'https://legacy.example.com', + platform: 'new-api', + }).returning().get(); + + const account = await db.insert(schema.accounts).values({ + siteId: site.id, + username: 'legacy-user', + accessToken: 'legacy-token', + status: 'active', + }).returning().get(); + + const inserted = await db.insert(schema.proxyLogs).values({ + accountId: account.id, + modelRequested: 'gpt-4o', + modelActual: 'gpt-4o', + status: 'failed', + errorMessage: '[client:codex] [session:turn-123] [downstream:/v1/responses] upstream error', + totalTokens: 9, + estimatedCost: 0.09, + createdAt: formatUtcSqlDateTime(new Date('2026-03-09T12:00:00.000Z')), + }).run(); + + const logId = Number(inserted.lastInsertRowid || 0); + const listResponse = await app.inject({ + method: 'GET', + url: '/api/stats/proxy-logs', + }); + const detailResponse = await app.inject({ + method: 'GET', + url: `/api/stats/proxy-logs/${logId}`, + }); + + expect(listResponse.statusCode).toBe(200); + expect(detailResponse.statusCode).toBe(200); + + const listBody = listResponse.json() as { + items: Array>; + }; + const detailBody = detailResponse.json() as Record; + + expect(listBody.items[0]?.clientFamily).toBe('codex'); + expect(listBody.items[0]?.clientAppId).toBe(null); + expect(listBody.items[0]?.clientAppName).toBe(null); + expect(detailBody.clientFamily).toBe('codex'); + expect(detailBody.clientAppId).toBe(null); + expect(detailBody.clientAppName).toBe(null); + }); }); diff --git a/src/server/routes/api/stats.siteStatus.test.ts b/src/server/routes/api/stats.siteStatus.test.ts index 75ca6c5e..acb39ee1 100644 --- a/src/server/routes/api/stats.siteStatus.test.ts +++ b/src/server/routes/api/stats.siteStatus.test.ts @@ -242,4 +242,108 @@ describe('stats dashboard filters disabled sites', () => { tokensPerMinute: 1800, }); }); + + it('returns site availability buckets and average latency from per-request proxy logs', async () => { + const activeSite = await db.insert(schema.sites).values({ + name: 'availability-site', + url: 'https://availability.example.com', + platform: 'new-api', + }).returning().get(); + + const disabledSite = await db.insert(schema.sites).values({ + name: 'disabled-availability-site', + url: 'https://disabled-availability.example.com', + platform: 'new-api', + }).returning().get(); + + await db.run(sql`update sites set status = 'disabled' where id = ${disabledSite.id}`); + + const activeAccount = await db.insert(schema.accounts).values({ + siteId: activeSite.id, + username: 'availability-user', + accessToken: 'availability-token', + balance: 20, + status: 'active', + }).returning().get(); + + const disabledAccount = await db.insert(schema.accounts).values({ + siteId: disabledSite.id, + username: 'disabled-availability-user', + accessToken: 'disabled-availability-token', + balance: 20, + status: 'active', + }).returning().get(); + + const now = Date.now(); + const recentSuccess = formatUtcSqlDateTime(new Date(now - 5 * 60_000)); + const recentFailure = formatUtcSqlDateTime(new Date(now - 65 * 60_000)); + + await db.insert(schema.proxyLogs).values([ + { + accountId: activeAccount.id, + status: 'success', + latencyMs: 120, + createdAt: recentSuccess, + }, + { + accountId: activeAccount.id, + status: 'failed', + latencyMs: 280, + createdAt: recentFailure, + }, + { + accountId: disabledAccount.id, + status: 'success', + latencyMs: 999, + createdAt: recentSuccess, + }, + ]).run(); + + const response = await app.inject({ + method: 'GET', + url: '/api/stats/dashboard', + }); + + expect(response.statusCode).toBe(200); + const body = response.json() as { + siteAvailability?: Array<{ + siteId: number; + siteName: string; + totalRequests: number; + successCount: number; + failedCount: number; + availabilityPercent: number | null; + averageLatencyMs: number | null; + buckets: Array<{ + startUtc?: string; + totalRequests: number; + successCount: number; + failedCount: number; + }>; + }>; + }; + + expect(Array.isArray(body.siteAvailability)).toBe(true); + expect(body.siteAvailability).toHaveLength(1); + expect(body.siteAvailability?.[0]).toMatchObject({ + siteId: activeSite.id, + siteName: 'availability-site', + totalRequests: 2, + successCount: 1, + failedCount: 1, + availabilityPercent: 50, + averageLatencyMs: 200, + }); + expect(body.siteAvailability?.[0]?.buckets).toHaveLength(24); + expect( + body.siteAvailability?.[0]?.buckets.reduce((sum, bucket) => sum + bucket.totalRequests, 0), + ).toBe(2); + expect( + body.siteAvailability?.[0]?.buckets.reduce((sum, bucket) => sum + bucket.successCount, 0), + ).toBe(1); + expect( + body.siteAvailability?.[0]?.buckets.reduce((sum, bucket) => sum + bucket.failedCount, 0), + ).toBe(1); + expect(typeof body.siteAvailability?.[0]?.buckets[0]?.startUtc).toBe('string'); + }); }); diff --git a/src/server/routes/api/stats.token-candidates.test.ts b/src/server/routes/api/stats.token-candidates.test.ts index 2fb67d82..7b21763d 100644 --- a/src/server/routes/api/stats.token-candidates.test.ts +++ b/src/server/routes/api/stats.token-candidates.test.ts @@ -119,6 +119,90 @@ describe('/api/models/token-candidates', () => { expect(body.modelsWithoutToken['claude-haiku-4-5-20251001']).toBeUndefined(); }); + it('does not report apikey connections as missing account tokens', async () => { + const site = await db.insert(schema.sites).values({ + name: 'site-apikey', + url: 'https://site-apikey.example.com', + platform: 'new-api', + status: 'active', + }).returning().get(); + + const account = await db.insert(schema.accounts).values({ + siteId: site.id, + username: 'shenmo-direct', + accessToken: '', + apiToken: 'sk-shenmo-direct', + status: 'active', + extraConfig: JSON.stringify({ credentialMode: 'apikey' }), + }).returning().get(); + + await db.insert(schema.modelAvailability).values({ + accountId: account.id, + modelName: 'gpt-5.2-codex', + available: true, + }).run(); + + const response = await app.inject({ + method: 'GET', + url: '/api/models/token-candidates', + }); + + expect(response.statusCode).toBe(200); + const body = response.json() as { + modelsWithoutToken: Record>; + modelsMissingTokenGroups: Record>; + }; + + expect(body.modelsWithoutToken['gpt-5.2-codex']).toBeUndefined(); + expect(body.modelsMissingTokenGroups['gpt-5.2-codex']).toBeUndefined(); + }); + + it('does not report oauth direct connections as missing account tokens', async () => { + const site = await db.insert(schema.sites).values({ + name: 'codex-site', + url: 'https://chatgpt.com/backend-api/codex', + platform: 'codex', + status: 'active', + }).returning().get(); + + const account = await db.insert(schema.accounts).values({ + siteId: site.id, + username: 'codex-user@example.com', + accessToken: 'oauth-access-token', + apiToken: null, + status: 'active', + extraConfig: JSON.stringify({ + credentialMode: 'session', + oauth: { + provider: 'codex', + accountId: 'chatgpt-account-123', + email: 'codex-user@example.com', + planType: 'team', + }, + }), + }).returning().get(); + + await db.insert(schema.modelAvailability).values({ + accountId: account.id, + modelName: 'gpt-5.2-codex', + available: true, + }).run(); + + const response = await app.inject({ + method: 'GET', + url: '/api/models/token-candidates', + }); + + expect(response.statusCode).toBe(200); + const body = response.json() as { + modelsWithoutToken: Record>; + modelsMissingTokenGroups: Record>; + }; + + expect(body.modelsWithoutToken['gpt-5.2-codex']).toBeUndefined(); + expect(body.modelsMissingTokenGroups['gpt-5.2-codex']).toBeUndefined(); + }); + it('returns modelsMissingTokenGroups when account has partial group token coverage', async () => { const site = await db.insert(schema.sites).values({ name: 'site-b', @@ -364,7 +448,7 @@ describe('/api/models/token-candidates', () => { ]); }); - it('does not pass deprecated site apiKey into pricing catalog lookup', async () => { + it('does not request token-group hints for apikey connections', async () => { const site = await db.insert(schema.sites).values({ name: 'site-e', url: 'https://site-e.example.com', @@ -425,19 +509,11 @@ describe('/api/models/token-candidates', () => { }); expect(response.statusCode).toBe(200); - expect(fetchModelPricingCatalogMock).toHaveBeenCalled(); - expect(fetchModelPricingCatalogMock.mock.calls[0]?.[0]).toMatchObject({ - site: { - id: site.id, - url: site.url, - platform: site.platform, - }, - account: { - id: account.id, - accessToken: '', - apiToken: null, - }, - }); - expect(fetchModelPricingCatalogMock.mock.calls[0]?.[0]?.site?.apiKey).toBeUndefined(); + const body = response.json() as { + modelsMissingTokenGroups: Record>; + }; + + expect(fetchModelPricingCatalogMock).not.toHaveBeenCalled(); + expect(body.modelsMissingTokenGroups['claude-opus-4-6']).toBeUndefined(); }); }); diff --git a/src/server/routes/api/stats.ts b/src/server/routes/api/stats.ts index 03a76fcc..4a8626b0 100644 --- a/src/server/routes/api/stats.ts +++ b/src/server/routes/api/stats.ts @@ -1,11 +1,9 @@ import { FastifyInstance } from 'fastify'; import { db, schema } from '../../db/index.js'; import { and, desc, eq, gte, lt, sql } from 'drizzle-orm'; -import { - refreshModelsForAccount, - refreshModelsAndRebuildRoutes, - rebuildTokenRoutesFromAvailability, -} from '../../services/modelService.js'; +import { config } from '../../config.js'; +import { refreshModelsForAccount } from '../../services/modelService.js'; +import * as routeRefreshWorkflow from '../../services/routeRefreshWorkflow.js'; import { buildModelAnalysis } from '../../services/modelAnalysisService.js'; import { fallbackTokenCost, fetchModelPricingCatalog } from '../../services/modelPricingService.js'; import { getUpstreamModelDescriptionsCached } from '../../services/upstreamModelDescriptionService.js'; @@ -17,12 +15,19 @@ import { parseProxyLogBillingDetails, withProxyLogSelectFields, } from '../../services/proxyLogStore.js'; +import { parseProxyLogMessageMeta } from '../proxy/logPathMeta.js'; +import { requiresManagedAccountTokens } from '../../services/accountExtraConfig.js'; +import { ACCOUNT_TOKEN_VALUE_STATUS_READY } from '../../services/accountTokenService.js'; import { + formatLocalDateTime, formatUtcSqlDateTime, getLocalDayRangeUtc, getLocalRangeStartUtc, + parseStoredUtcDateTime, + type StoredUtcDateTimeInput, toLocalDayKeyFromStoredUtc, } from '../../services/localTimeService.js'; +import { createRateLimitGuard } from '../../middleware/requestRateLimit.js'; function parseBooleanFlag(raw?: string): boolean { if (!raw) return false; @@ -32,6 +37,11 @@ function parseBooleanFlag(raw?: string): boolean { const MODELS_MARKETPLACE_BASE_TTL_MS = 15_000; const MODELS_MARKETPLACE_PRICING_TTL_MS = 90_000; +const limitModelTokenCandidatesRead = createRateLimitGuard({ + bucket: 'models-token-candidates-read', + max: 30, + windowMs: 60_000, +}); type ModelsMarketplaceCacheEntry = { expiresAt: number; @@ -74,6 +84,22 @@ function proxyCostSqlExpression() { } type ProxyLogStatusFilter = 'all' | 'success' | 'failed'; +type ProxyLogClientFilter = { + kind: 'app' | 'family'; + value: string; +} | null; + +type ProxyLogClientOption = { + value: string; + label: string; +}; + +const PROXY_LOG_CLIENT_FAMILY_LABELS: Record = { + codex: 'Codex', + claude_code: 'Claude Code', + gemini_cli: 'Gemini CLI', + generic: '通用', +}; function normalizeProxyLogPageSize(raw?: string): number { const parsed = Number.parseInt(raw || '50', 10); @@ -98,12 +124,64 @@ function normalizeProxyLogSearch(raw?: string): string { return (raw || '').trim().toLowerCase(); } +function normalizeProxyLogSiteId(raw?: string): number | null { + const parsed = Number.parseInt(raw || '', 10); + if (!Number.isFinite(parsed) || parsed <= 0) return null; + return parsed; +} + +function normalizeProxyLogClientFilter(raw?: string): ProxyLogClientFilter { + const text = (raw || '').trim(); + if (!text) return null; + const separatorIndex = text.indexOf(':'); + if (separatorIndex <= 0) return null; + const kind = text.slice(0, separatorIndex).trim().toLowerCase(); + const value = text.slice(separatorIndex + 1).trim().toLowerCase(); + if (!value) return null; + if (kind === 'app' || kind === 'family') { + return { kind, value }; + } + return null; +} + +function normalizeProxyLogTimeBoundary(raw?: string): string | null { + const text = (raw || '').trim(); + if (!text) return null; + const parsed = new Date(text); + if (Number.isNaN(parsed.getTime())) return null; + return formatUtcSqlDateTime(parsed); +} + +function parseDownstreamKeyTags(raw: unknown): string[] { + if (typeof raw !== 'string' || !raw.trim()) return []; + try { + const parsed = JSON.parse(raw); + if (!Array.isArray(parsed)) return []; + const seen = new Set(); + const result: string[] = []; + for (const value of parsed) { + const text = String(value || '').trim(); + if (!text) continue; + const dedupeKey = text.toLowerCase(); + if (seen.has(dedupeKey)) continue; + seen.add(dedupeKey); + result.push(text); + } + return result; + } catch { + return []; + } +} + function buildProxyLogSearchCondition(search: string) { if (!search) return null; const likeTerm = `%${search}%`; return sql`( lower(coalesce(${schema.proxyLogs.modelRequested}, '')) like ${likeTerm} or lower(coalesce(${schema.proxyLogs.modelActual}, '')) like ${likeTerm} + or lower(coalesce(${schema.downstreamApiKeys.name}, '')) like ${likeTerm} + or lower(coalesce(${schema.downstreamApiKeys.groupName}, '')) like ${likeTerm} + or lower(coalesce(${schema.downstreamApiKeys.tags}, '')) like ${likeTerm} )`; } @@ -117,13 +195,29 @@ function buildProxyLogStatusCondition(status: ProxyLogStatusFilter) { return null; } +function buildProxyLogClientCondition(client: ProxyLogClientFilter) { + if (!client) return null; + if (client.kind === 'app') { + return eq(schema.proxyLogs.clientAppId, client.value); + } + return eq(schema.proxyLogs.clientFamily, client.value); +} + function buildProxyLogWhereClause(params: { status?: ProxyLogStatusFilter; search?: string; + client?: ProxyLogClientFilter; + siteId?: number | null; + fromUtc?: string | null; + toUtc?: string | null; }) { const conditions = [ params.status ? buildProxyLogStatusCondition(params.status) : null, params.search ? buildProxyLogSearchCondition(params.search) : null, + params.client ? buildProxyLogClientCondition(params.client) : null, + params.siteId ? eq(schema.sites.id, params.siteId) : null, + params.fromUtc ? gte(schema.proxyLogs.createdAt, params.fromUtc) : null, + params.toUtc ? lt(schema.proxyLogs.createdAt, params.toUtc) : null, ].filter((condition): condition is NonNullable => condition !== null); if (conditions.length === 0) return undefined; @@ -134,22 +228,263 @@ function toRoundedMicroNumber(value: number | null | undefined): number { return Math.round(Number(value || 0) * 1_000_000) / 1_000_000; } +function normalizeNullableText(value: unknown): string | null { + if (typeof value !== 'string') return null; + const trimmed = value.trim(); + return trimmed || null; +} + +function normalizeClientConfidence(value: unknown): string | null { + const normalized = normalizeNullableText(value)?.toLowerCase() || null; + if (normalized === 'exact' || normalized === 'heuristic' || normalized === 'unknown') { + return normalized; + } + return null; +} + +function displayProxyLogClientFamily(value: string | null): string | null { + if (!value) return null; + return PROXY_LOG_CLIENT_FAMILY_LABELS[value] || value; +} + +function resolveProxyLogClientMeta(proxyLog: Record) { + const clientFamily = normalizeNullableText(proxyLog.clientFamily)?.toLowerCase() || null; + const clientAppId = normalizeNullableText(proxyLog.clientAppId)?.toLowerCase() || null; + const clientAppName = normalizeNullableText(proxyLog.clientAppName) || null; + const clientConfidence = normalizeClientConfidence(proxyLog.clientConfidence); + + if (clientFamily || clientAppId || clientAppName || clientConfidence) { + return { + clientFamily, + clientAppId, + clientAppName, + clientConfidence, + }; + } + + const legacyMeta = parseProxyLogMessageMeta(typeof proxyLog.errorMessage === 'string' ? proxyLog.errorMessage : ''); + return { + clientFamily: normalizeNullableText(legacyMeta.clientKind)?.toLowerCase() || null, + clientAppId: null, + clientAppName: null, + clientConfidence: null, + }; +} + +function buildProxyLogClientOptions(rows: Array<{ + clientFamily?: string | null; + clientAppId?: string | null; + clientAppName?: string | null; +}>): ProxyLogClientOption[] { + const appOptions = new Map(); + const familyOptions = new Map(); + + for (const row of rows) { + const clientAppId = normalizeNullableText(row.clientAppId)?.toLowerCase() || null; + const clientAppName = normalizeNullableText(row.clientAppName) || null; + const clientFamily = normalizeNullableText(row.clientFamily)?.toLowerCase() || null; + + if (clientAppId && clientAppName && !appOptions.has(clientAppId)) { + appOptions.set(clientAppId, { + value: `app:${clientAppId}`, + label: `应用 · ${clientAppName}`, + }); + } + + if (clientFamily && clientFamily !== 'generic' && !familyOptions.has(clientFamily)) { + familyOptions.set(clientFamily, { + value: `family:${clientFamily}`, + label: `协议 · ${displayProxyLogClientFamily(clientFamily) || clientFamily}`, + }); + } + } + + return [ + ...Array.from(appOptions.values()).sort((left, right) => left.label.localeCompare(right.label, 'zh-CN')), + ...Array.from(familyOptions.values()).sort((left, right) => left.label.localeCompare(right.label, 'zh-CN')), + ]; +} + +const SITE_AVAILABILITY_BUCKET_COUNT = 24; +const SITE_AVAILABILITY_BUCKET_MS = 60 * 60 * 1000; + +type SiteAvailabilitySiteRow = { + id: number; + name: string; + url: string | null; + platform: string | null; + sortOrder: number | null; + isPinned: boolean | null; +}; + +type SiteAvailabilityLogRow = { + siteId: number | null; + createdAt: StoredUtcDateTimeInput; + status: string | null; + latencyMs: number | null; +}; + +type SiteAvailabilityBucketAccumulator = { + startUtc: string; + label: string; + totalRequests: number; + successCount: number; + failedCount: number; + latencyTotalMs: number; + latencyCount: number; +}; + +function roundPercent(value: number | null): number | null { + if (value == null || !Number.isFinite(value)) return null; + return Math.round(value * 10) / 10; +} + +function buildSiteAvailabilitySummaries( + sites: SiteAvailabilitySiteRow[], + logs: SiteAvailabilityLogRow[], + now = new Date(), +) { + const endLocal = new Date(now.getFullYear(), now.getMonth(), now.getDate(), now.getHours(), 0, 0, 0); + const startLocal = new Date(endLocal.getTime() - (SITE_AVAILABILITY_BUCKET_COUNT - 1) * SITE_AVAILABILITY_BUCKET_MS); + const startMs = startLocal.getTime(); + const rangeMs = SITE_AVAILABILITY_BUCKET_COUNT * SITE_AVAILABILITY_BUCKET_MS; + + const createBucketTemplate = (): SiteAvailabilityBucketAccumulator[] => ( + Array.from({ length: SITE_AVAILABILITY_BUCKET_COUNT }, (_, index) => { + const bucketStart = new Date(startMs + index * SITE_AVAILABILITY_BUCKET_MS); + return { + startUtc: bucketStart.toISOString(), + label: formatLocalDateTime(bucketStart), + totalRequests: 0, + successCount: 0, + failedCount: 0, + latencyTotalMs: 0, + latencyCount: 0, + }; + }) + ); + + const siteMap = new Map(); + + for (const site of sites) { + siteMap.set(site.id, { + site, + totalRequests: 0, + successCount: 0, + failedCount: 0, + latencyTotalMs: 0, + latencyCount: 0, + buckets: createBucketTemplate(), + }); + } + + for (const log of logs) { + if (log.siteId == null) continue; + const target = siteMap.get(log.siteId); + if (!target) continue; + + const parsed = parseStoredUtcDateTime(log.createdAt); + if (!parsed) continue; + const timestampMs = parsed.getTime(); + const diffMs = timestampMs - startMs; + if (diffMs < 0 || diffMs >= rangeMs) continue; + + const bucketIndex = Math.floor(diffMs / SITE_AVAILABILITY_BUCKET_MS); + const bucket = target.buckets[bucketIndex]; + const isSuccess = (log.status || '').trim().toLowerCase() === 'success'; + + target.totalRequests += 1; + bucket.totalRequests += 1; + if (isSuccess) { + target.successCount += 1; + bucket.successCount += 1; + } else { + target.failedCount += 1; + bucket.failedCount += 1; + } + + const latencyMs = Number(log.latencyMs); + if (Number.isFinite(latencyMs) && latencyMs >= 0) { + target.latencyTotalMs += latencyMs; + target.latencyCount += 1; + bucket.latencyTotalMs += latencyMs; + bucket.latencyCount += 1; + } + } + + return sites.map((site) => { + const aggregate = siteMap.get(site.id)!; + return { + siteId: site.id, + siteName: site.name, + siteUrl: site.url, + platform: site.platform, + totalRequests: aggregate.totalRequests, + successCount: aggregate.successCount, + failedCount: aggregate.failedCount, + availabilityPercent: aggregate.totalRequests > 0 + ? roundPercent((aggregate.successCount / aggregate.totalRequests) * 100) + : null, + averageLatencyMs: aggregate.latencyCount > 0 + ? Math.round(aggregate.latencyTotalMs / aggregate.latencyCount) + : null, + buckets: aggregate.buckets.map((bucket) => ({ + startUtc: bucket.startUtc, + label: bucket.label, + totalRequests: bucket.totalRequests, + successCount: bucket.successCount, + failedCount: bucket.failedCount, + availabilityPercent: bucket.totalRequests > 0 + ? roundPercent((bucket.successCount / bucket.totalRequests) * 100) + : null, + averageLatencyMs: bucket.latencyCount > 0 + ? Math.round(bucket.latencyTotalMs / bucket.latencyCount) + : null, + })), + }; + }); +} + function mapProxyLogRow( row: { proxy_logs: Record & { billingDetails?: string | null }; accounts: { username?: string | null } | null; - sites: { name?: string | null; url?: string | null } | null; + sites: { id?: number | null; name?: string | null; url?: string | null } | null; + downstream_api_keys: { + id?: number | null; + name?: string | null; + groupName?: string | null; + tags?: string | null; + } | null; }, options?: { includeBillingDetails?: boolean }, ) { + const clientMeta = resolveProxyLogClientMeta(row.proxy_logs); return { ...row.proxy_logs, ...(options?.includeBillingDetails ? { billingDetails: parseProxyLogBillingDetails(row.proxy_logs.billingDetails) } : {}), + clientFamily: clientMeta.clientFamily, + clientAppId: clientMeta.clientAppId, + clientAppName: clientMeta.clientAppName, + clientConfidence: clientMeta.clientConfidence, username: row.accounts?.username || null, + siteId: row.sites?.id || null, siteName: row.sites?.name || null, siteUrl: row.sites?.url || null, + downstreamKeyId: row.downstream_api_keys?.id || null, + downstreamKeyName: row.downstream_api_keys?.name || null, + downstreamKeyGroupName: row.downstream_api_keys?.groupName || null, + downstreamKeyTags: parseDownstreamKeyTags(row.downstream_api_keys?.tags), }; } @@ -264,6 +599,49 @@ export async function statsRoutes(app: FastifyInstance) { extraConfig: account.extraConfig, }), 0); const modelAnalysis = buildModelAnalysis(recentProxyLogs, { days: 7 }); + const activeSites = (await db.select({ + id: schema.sites.id, + name: schema.sites.name, + url: schema.sites.url, + platform: schema.sites.platform, + sortOrder: schema.sites.sortOrder, + isPinned: schema.sites.isPinned, + }).from(schema.sites) + .where(eq(schema.sites.status, 'active')) + .all()) + .sort((left: SiteAvailabilitySiteRow, right: SiteAvailabilitySiteRow) => { + const leftPinned = left.isPinned ? 1 : 0; + const rightPinned = right.isPinned ? 1 : 0; + if (leftPinned !== rightPinned) return rightPinned - leftPinned; + const leftOrder = Number(left.sortOrder || 0); + const rightOrder = Number(right.sortOrder || 0); + if (leftOrder !== rightOrder) return leftOrder - rightOrder; + return String(left.name || '').localeCompare(String(right.name || '')); + }); + const siteAvailabilityNow = new Date(); + const siteAvailabilitySinceUtc = formatUtcSqlDateTime(new Date( + siteAvailabilityNow.getFullYear(), + siteAvailabilityNow.getMonth(), + siteAvailabilityNow.getDate(), + siteAvailabilityNow.getHours() - (SITE_AVAILABILITY_BUCKET_COUNT - 1), + 0, + 0, + 0, + )); + const siteAvailabilityLogs = await db.select({ + siteId: schema.sites.id, + createdAt: schema.proxyLogs.createdAt, + status: schema.proxyLogs.status, + latencyMs: schema.proxyLogs.latencyMs, + }).from(schema.proxyLogs) + .innerJoin(schema.accounts, eq(schema.proxyLogs.accountId, schema.accounts.id)) + .innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id)) + .where(and( + gte(schema.proxyLogs.createdAt, siteAvailabilitySinceUtc), + eq(schema.sites.status, 'active'), + )) + .all(); + const siteAvailability = buildSiteAvailabilitySummaries(activeSites, siteAvailabilityLogs, siteAvailabilityNow); return { totalBalance, @@ -279,27 +657,49 @@ export async function statsRoutes(app: FastifyInstance) { requestsPerMinute, tokensPerMinute, }, + siteAvailability, modelAnalysis, }; }); // Proxy logs - app.get<{ Querystring: { limit?: string; offset?: string; status?: string; search?: string } }>('/api/stats/proxy-logs', async (request) => { + app.get<{ Querystring: { + limit?: string; + offset?: string; + status?: string; + search?: string; + client?: string; + siteId?: string; + from?: string; + to?: string; + } }>('/api/stats/proxy-logs', async (request) => { const limit = normalizeProxyLogPageSize(request.query.limit); const offset = normalizeProxyLogOffset(request.query.offset); const status = normalizeProxyLogStatusFilter(request.query.status); const search = normalizeProxyLogSearch(request.query.search); - const listWhere = buildProxyLogWhereClause({ status, search }); - const summaryWhere = buildProxyLogWhereClause({ search }); + const client = normalizeProxyLogClientFilter(request.query.client); + const siteId = normalizeProxyLogSiteId(request.query.siteId); + const fromUtc = normalizeProxyLogTimeBoundary(request.query.from); + const toUtc = normalizeProxyLogTimeBoundary(request.query.to); + const listWhere = buildProxyLogWhereClause({ status, search, client, siteId, fromUtc, toUtc }); + const summaryWhere = buildProxyLogWhereClause({ search, client, siteId, fromUtc, toUtc }); + const clientOptionsWhere = buildProxyLogWhereClause({ status, search, siteId, fromUtc, toUtc }); const listRows = await withProxyLogSelectFields(({ fields }) => { let query = db.select({ proxy_logs: fields, accounts: schema.accounts, sites: schema.sites, + downstream_api_keys: { + id: schema.downstreamApiKeys.id, + name: schema.downstreamApiKeys.name, + groupName: schema.downstreamApiKeys.groupName, + tags: schema.downstreamApiKeys.tags, + }, }).from(schema.proxyLogs) .leftJoin(schema.accounts, eq(schema.proxyLogs.accountId, schema.accounts.id)) - .leftJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id)); + .leftJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id)) + .leftJoin(schema.downstreamApiKeys, eq(schema.proxyLogs.downstreamApiKeyId, schema.downstreamApiKeys.id)); if (listWhere) { query = query.where(listWhere) as typeof query; @@ -313,24 +713,56 @@ export async function statsRoutes(app: FastifyInstance) { }, { includeBillingDetails: false }) as Array<{ proxy_logs: Record & { billingDetails?: string | null }; accounts: { username?: string | null } | null; - sites: { name?: string | null; url?: string | null } | null; + sites: { id?: number | null; name?: string | null; url?: string | null } | null; + downstream_api_keys: { id?: number | null; name?: string | null; groupName?: string | null; tags?: string | null } | null; }>; let totalQuery = db.select({ total: sql`count(*)`, - }).from(schema.proxyLogs); + }).from(schema.proxyLogs) + .leftJoin(schema.accounts, eq(schema.proxyLogs.accountId, schema.accounts.id)) + .leftJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id)) + .leftJoin(schema.downstreamApiKeys, eq(schema.proxyLogs.downstreamApiKeyId, schema.downstreamApiKeys.id)); if (listWhere) { totalQuery = totalQuery.where(listWhere) as typeof totalQuery; } const totalRow = await totalQuery.get(); + const clientOptionRows = await withProxyLogSelectFields(({ fields, includeClientFields }) => { + if (!includeClientFields) { + return Promise.resolve([]); + } + + let query = db.select({ + clientFamily: fields.clientFamily!, + clientAppId: fields.clientAppId!, + clientAppName: fields.clientAppName!, + }).from(schema.proxyLogs) + .leftJoin(schema.accounts, eq(schema.proxyLogs.accountId, schema.accounts.id)) + .leftJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id)) + .leftJoin(schema.downstreamApiKeys, eq(schema.proxyLogs.downstreamApiKeyId, schema.downstreamApiKeys.id)); + + if (clientOptionsWhere) { + query = query.where(clientOptionsWhere) as typeof query; + } + + return query.all(); + }, { includeBillingDetails: false, includeClientFields: true }) as Array<{ + clientFamily?: string | null; + clientAppId?: string | null; + clientAppName?: string | null; + }>; + let summaryQuery = db.select({ totalCount: sql`count(*)`, successCount: sql`coalesce(sum(case when ${schema.proxyLogs.status} = 'success' then 1 else 0 end), 0)`, failedCount: sql`coalesce(sum(case when coalesce(${schema.proxyLogs.status}, '') <> 'success' then 1 else 0 end), 0)`, totalCost: sql`coalesce(sum(coalesce(${schema.proxyLogs.estimatedCost}, 0)), 0)`, totalTokensAll: sql`coalesce(sum(coalesce(${schema.proxyLogs.totalTokens}, 0)), 0)`, - }).from(schema.proxyLogs); + }).from(schema.proxyLogs) + .leftJoin(schema.accounts, eq(schema.proxyLogs.accountId, schema.accounts.id)) + .leftJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id)) + .leftJoin(schema.downstreamApiKeys, eq(schema.proxyLogs.downstreamApiKeyId, schema.downstreamApiKeys.id)); if (summaryWhere) { summaryQuery = summaryQuery.where(summaryWhere) as typeof summaryQuery; } @@ -341,6 +773,7 @@ export async function statsRoutes(app: FastifyInstance) { total: Number(totalRow?.total || 0), page: Math.floor(offset / limit) + 1, pageSize: limit, + clientOptions: buildProxyLogClientOptions(clientOptionRows), summary: { totalCount: Number(summaryRow?.totalCount || 0), successCount: Number(summaryRow?.successCount || 0), @@ -362,15 +795,23 @@ export async function statsRoutes(app: FastifyInstance) { proxy_logs: fields, accounts: schema.accounts, sites: schema.sites, + downstream_api_keys: { + id: schema.downstreamApiKeys.id, + name: schema.downstreamApiKeys.name, + groupName: schema.downstreamApiKeys.groupName, + tags: schema.downstreamApiKeys.tags, + }, }).from(schema.proxyLogs) .leftJoin(schema.accounts, eq(schema.proxyLogs.accountId, schema.accounts.id)) .leftJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id)) + .leftJoin(schema.downstreamApiKeys, eq(schema.proxyLogs.downstreamApiKeyId, schema.downstreamApiKeys.id)) .where(eq(schema.proxyLogs.id, id)) .get() ), { includeBillingDetails: true }) as { proxy_logs: Record & { billingDetails?: string | null }; accounts: { username?: string | null } | null; - sites: { name?: string | null; url?: string | null } | null; + sites: { id?: number | null; name?: string | null; url?: string | null } | null; + downstream_api_keys: { id?: number | null; name?: string | null; groupName?: string | null; tags?: string | null } | null; } | undefined; if (!row) { @@ -404,7 +845,7 @@ export async function statsRoutes(app: FastifyInstance) { }, failureMessage: (currentTask) => `模型广场刷新失败:${currentTask.error || 'unknown error'}`, }, - async () => refreshModelsAndRebuildRoutes(), + async () => routeRefreshWorkflow.refreshModelsAndRebuildRoutes(), ); refreshQueued = !reused; refreshReused = reused; @@ -435,6 +876,15 @@ export async function statsRoutes(app: FastifyInstance) { .innerJoin(schema.accountTokens, eq(schema.tokenModelAvailability.tokenId, schema.accountTokens.id)) .innerJoin(schema.accounts, eq(schema.accountTokens.accountId, schema.accounts.id)) .innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id)) + .where( + and( + eq(schema.tokenModelAvailability.available, true), + eq(schema.accountTokens.enabled, true), + eq(schema.accountTokens.valueStatus, ACCOUNT_TOKEN_VALUE_STATUS_READY), + eq(schema.accounts.status, 'active'), + eq(schema.sites.status, 'active'), + ), + ) .all(); const accountAvailability = await db.select().from(schema.modelAvailability) .innerJoin(schema.accounts, eq(schema.modelAvailability.accountId, schema.accounts.id)) @@ -677,7 +1127,7 @@ export async function statsRoutes(app: FastifyInstance) { }; }); - app.get('/api/models/token-candidates', async () => { + app.get('/api/models/token-candidates', { preHandler: [limitModelTokenCandidatesRead] }, async () => { const resolveTokenGroupLabel = (tokenGroup: string | null, tokenName: string | null): string | null => { const explicit = (tokenGroup || '').trim(); if (explicit) return explicit; @@ -692,6 +1142,11 @@ export async function statsRoutes(app: FastifyInstance) { return name; }; + // Load global allowed models whitelist + const globalAllowedModels = new Set( + config.globalAllowedModels.map((m) => m.toLowerCase().trim()).filter(Boolean), + ); + const rows = await db.select().from(schema.tokenModelAvailability) .innerJoin(schema.accountTokens, eq(schema.tokenModelAvailability.tokenId, schema.accountTokens.id)) .innerJoin(schema.accounts, eq(schema.accountTokens.accountId, schema.accounts.id)) @@ -700,6 +1155,7 @@ export async function statsRoutes(app: FastifyInstance) { and( eq(schema.tokenModelAvailability.available, true), eq(schema.accountTokens.enabled, true), + eq(schema.accountTokens.valueStatus, ACCOUNT_TOKEN_VALUE_STATUS_READY), eq(schema.accounts.status, 'active'), eq(schema.sites.status, 'active'), ), @@ -711,6 +1167,9 @@ export async function statsRoutes(app: FastifyInstance) { username: schema.accounts.username, siteId: schema.sites.id, siteName: schema.sites.name, + accessToken: schema.accounts.accessToken, + apiToken: schema.accounts.apiToken, + extraConfig: schema.accounts.extraConfig, }) .from(schema.modelAvailability) .innerJoin(schema.accounts, eq(schema.modelAvailability.accountId, schema.accounts.id)) @@ -788,6 +1247,7 @@ export async function statsRoutes(app: FastifyInstance) { } for (const row of availableModelRows) { + if (!requiresManagedAccountTokens(row)) continue; const modelName = (row.modelName || '').trim(); if (!modelName) continue; const coverageKey = `${row.accountId}::${modelName.toLowerCase()}`; @@ -802,7 +1262,11 @@ export async function statsRoutes(app: FastifyInstance) { }); } - const accountIdsForGroupHints = new Set(availableModelRows.map((row) => row.accountId)); + const accountIdsForGroupHints = new Set( + availableModelRows + .filter((row) => requiresManagedAccountTokens(row)) + .map((row) => row.accountId), + ); const requiredGroupsByAccountModel = new Map>(); const hasPotentialGroupHints = hasAnyTokenGroupSignals || unknownGroupCoverageByAccountModel.size > 0; @@ -862,6 +1326,7 @@ export async function statsRoutes(app: FastifyInstance) { } for (const row of availableModelRows) { + if (!requiresManagedAccountTokens(row)) continue; const modelName = (row.modelName || '').trim(); if (!modelName) continue; const accountModelKey = `${row.accountId}::${modelName.toLowerCase()}`; @@ -912,10 +1377,41 @@ export async function statsRoutes(app: FastifyInstance) { } } + // Apply model whitelist filter if configured + const filteredResult: typeof result = {}; + const filteredModelsWithoutToken: typeof modelsWithoutToken = {}; + const filteredModelsMissingTokenGroups: typeof modelsMissingTokenGroups = {}; + + if (globalAllowedModels.size > 0) { + // Filter result + for (const [modelName, candidates] of Object.entries(result)) { + if (globalAllowedModels.has(modelName.toLowerCase().trim())) { + filteredResult[modelName] = candidates; + } + } + // Filter modelsWithoutToken + for (const [modelName, accounts] of Object.entries(modelsWithoutToken)) { + if (globalAllowedModels.has(modelName.toLowerCase().trim())) { + filteredModelsWithoutToken[modelName] = accounts; + } + } + // Filter modelsMissingTokenGroups + for (const [modelName, accounts] of Object.entries(modelsMissingTokenGroups)) { + if (globalAllowedModels.has(modelName.toLowerCase().trim())) { + filteredModelsMissingTokenGroups[modelName] = accounts; + } + } + } else { + // No whitelist configured, return all models (backward compatible) + Object.assign(filteredResult, result); + Object.assign(filteredModelsWithoutToken, modelsWithoutToken); + Object.assign(filteredModelsMissingTokenGroups, modelsMissingTokenGroups); + } + return { - models: result, - modelsWithoutToken, - modelsMissingTokenGroups, + models: filteredResult, + modelsWithoutToken: filteredModelsWithoutToken, + modelsMissingTokenGroups: filteredModelsMissingTokenGroups, endpointTypesByModel, }; }); @@ -928,7 +1424,7 @@ export async function statsRoutes(app: FastifyInstance) { } const refresh = await refreshModelsForAccount(accountId); - const rebuild = rebuildTokenRoutesFromAvailability(); + const rebuild = await routeRefreshWorkflow.rebuildRoutesOnly(); return { success: true, refresh, rebuild }; }); diff --git a/src/server/routes/api/test.proxy.test.ts b/src/server/routes/api/test.proxy.test.ts index 0647c15d..15284d98 100644 --- a/src/server/routes/api/test.proxy.test.ts +++ b/src/server/routes/api/test.proxy.test.ts @@ -1,3 +1,4 @@ +import { zstdCompressSync } from 'node:zlib'; import Fastify, { type FastifyInstance } from 'fastify'; import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; @@ -191,4 +192,166 @@ describe('testRoutes proxy tester transport', () => { expect(url).toMatch(/\/v1\/files$/); expect(requestInit.body?.constructor?.name).toBe('FormData'); }); + + it('decodes zstd-compressed buffered proxy test responses', async () => { + const payload = JSON.stringify({ + choices: [ + { + message: { + content: '你好,来自压缩响应', + }, + }, + ], + }); + fetchMock.mockResolvedValue(new Response(zstdCompressSync(Buffer.from(payload)), { + status: 200, + headers: { + 'content-encoding': 'zstd', + 'content-type': 'application/json; charset=utf-8', + }, + })); + + const response = await app.inject({ + method: 'POST', + url: '/api/test/proxy', + payload: { + method: 'POST', + path: '/v1/chat/completions', + requestKind: 'json', + stream: false, + jobMode: false, + rawMode: false, + jsonBody: { + model: 'gpt-4o-mini', + messages: [{ role: 'user', content: 'ping' }], + }, + }, + }); + + expect(response.statusCode).toBe(200); + expect(response.json()).toEqual({ + choices: [ + { + message: { + content: '你好,来自压缩响应', + }, + }, + ], + }); + }); + + it('decodes zstd-compressed non-SSE stream fallback responses', async () => { + const payload = JSON.stringify({ + choices: [ + { + message: { + content: '你好,来自流式回退', + }, + }, + ], + }); + fetchMock.mockResolvedValue(new Response(zstdCompressSync(Buffer.from(payload)), { + status: 200, + headers: { + 'content-encoding': 'zstd', + 'content-type': 'application/json; charset=utf-8', + }, + })); + + const response = await app.inject({ + method: 'POST', + url: '/api/test/proxy/stream', + payload: { + method: 'POST', + path: '/v1/chat/completions', + requestKind: 'json', + stream: true, + jobMode: false, + rawMode: false, + jsonBody: { + model: 'gpt-4o-mini', + messages: [{ role: 'user', content: 'ping' }], + }, + }, + }); + + expect(response.statusCode).toBe(200); + expect(response.headers['content-type']).toContain('text/event-stream'); + expect(response.payload).toBe(`data: ${payload}\n\ndata: [DONE]\n\n`); + }); + + it('encodes multiline non-SSE stream fallback responses as valid SSE', async () => { + const payload = 'line one\nline two\n'; + fetchMock.mockResolvedValue(new Response(payload, { + status: 200, + headers: { + 'content-type': 'text/plain; charset=utf-8', + }, + })); + + const response = await app.inject({ + method: 'POST', + url: '/api/test/proxy/stream', + payload: { + method: 'POST', + path: '/v1/chat/completions', + requestKind: 'json', + stream: true, + jobMode: false, + rawMode: false, + jsonBody: { + model: 'gpt-4o-mini', + messages: [{ role: 'user', content: 'ping' }], + }, + }, + }); + + expect(response.statusCode).toBe(200); + expect(response.headers['content-type']).toContain('text/event-stream'); + expect(response.payload).toBe('data: line one\ndata: line two\ndata: \n\ndata: [DONE]\n\n'); + }); + + it('cancels and releases SSE readers when stream forwarding fails', async () => { + const cancel = vi.fn().mockResolvedValue(undefined); + const releaseLock = vi.fn(); + const read = vi.fn().mockRejectedValue(new Error('boom')); + fetchMock.mockResolvedValue({ + ok: true, + status: 200, + headers: new Headers({ + 'content-type': 'text/event-stream; charset=utf-8', + }), + body: { + getReader: () => ({ + read, + cancel, + releaseLock, + }), + }, + }); + + const response = await app.inject({ + method: 'POST', + url: '/api/test/proxy/stream', + payload: { + method: 'POST', + path: '/v1/chat/completions', + requestKind: 'json', + stream: true, + jobMode: false, + rawMode: false, + jsonBody: { + model: 'gpt-4o-mini', + messages: [{ role: 'user', content: 'ping' }], + }, + }, + }); + + expect(response.statusCode).toBe(200); + expect(response.headers['content-type']).toContain('text/event-stream'); + expect(response.payload).toContain('event: error'); + expect(response.payload).toContain('"message":"boom"'); + expect(cancel).toHaveBeenCalledTimes(1); + expect(releaseLock).toHaveBeenCalledTimes(1); + }); }); diff --git a/src/server/routes/api/test.ts b/src/server/routes/api/test.ts index 2756dde2..c4921b79 100644 --- a/src/server/routes/api/test.ts +++ b/src/server/routes/api/test.ts @@ -2,6 +2,7 @@ import { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'; import { randomUUID } from 'node:crypto'; import { fetch, File as UndiciFile, FormData as UndiciFormData } from 'undici'; import { config } from '../../config.js'; +import { readRuntimeResponseText } from '../../proxy-core/executors/types.js'; type UndiciRequestInit = Parameters[1]; @@ -521,7 +522,7 @@ async function fetchProxyBuffered( }); const contentType = upstream.headers.get('content-type') || ''; - const text = await upstream.text(); + const text = await readRuntimeResponseText(upstream); if (!upstream.ok) { throw new UpstreamProxyError(upstream.status, normalizeErrorPayload(text)); @@ -656,14 +657,14 @@ async function sendStreamingEnvelope( } if (!upstream.ok) { - const text = await upstream.text(); + const text = await readRuntimeResponseText(upstream); cleanupClientListeners(); return reply.code(upstream.status).send(normalizeErrorPayload(text)); } const contentType = upstream.headers.get('content-type') || ''; - const reader = upstream.body?.getReader(); - if (!reader) { + const reader = contentType.includes('text/event-stream') ? upstream.body?.getReader() : null; + if (contentType.includes('text/event-stream') && !reader) { cleanupClientListeners(); return reply.code(502).send({ error: { @@ -682,25 +683,30 @@ async function sendStreamingEnvelope( try { if (contentType.includes('text/event-stream')) { - while (true) { - const { done, value } = await reader.read(); - if (done) break; - if (value) { - reply.raw.write(Buffer.from(value)); + const streamReader = reader!; + try { + while (true) { + const { done, value } = await streamReader.read(); + if (done) break; + if (value) { + reply.raw.write(Buffer.from(value)); + } + } + } finally { + try { + await streamReader.cancel(); + } catch { + // no-op + } finally { + streamReader.releaseLock(); } } } else { - let text = ''; - const decoder = new TextDecoder('utf-8'); - while (true) { - const { done, value } = await reader.read(); - if (done) break; - if (value) { - text += decoder.decode(value, { stream: true }); - } + const text = await readRuntimeResponseText(upstream); + for (const line of text.split(/\r?\n/)) { + reply.raw.write(`data: ${line}\n`); } - text += decoder.decode(); - reply.raw.write(`data: ${text}\n\n`); + reply.raw.write('\n'); reply.raw.write('data: [DONE]\n\n'); } } catch (error) { @@ -711,11 +717,6 @@ async function sendStreamingEnvelope( reply.raw.write(`event: error\ndata: ${message}\n\n`); } } finally { - try { - await reader.cancel(); - } catch { - // no-op - } cleanupClientListeners(); if (!reply.raw.writableEnded) { reply.raw.end(); diff --git a/src/server/routes/api/tokens.batch.test.ts b/src/server/routes/api/tokens.batch.test.ts index cf9f0702..60a8647a 100644 --- a/src/server/routes/api/tokens.batch.test.ts +++ b/src/server/routes/api/tokens.batch.test.ts @@ -154,4 +154,29 @@ describe('PUT /api/channels/batch', () => { expect(dbA?.manualOverride).toBe(true); expect(dbB?.manualOverride).toBe(true); }); + + it('reports the number of routes actually updated in route batch operations', async () => { + const route = await db.insert(schema.tokenRoutes).values({ + modelPattern: 'gpt-4o-mini', + enabled: true, + }).returning().get(); + + const res = await app.inject({ + method: 'POST', + url: '/api/routes/batch', + payload: { + ids: [route.id, route.id + 999], + action: 'disable', + }, + }); + + expect(res.statusCode).toBe(200); + expect(res.json()).toMatchObject({ + success: true, + updatedCount: 1, + }); + + const updatedRoute = await db.select().from(schema.tokenRoutes).where(eq(schema.tokenRoutes.id, route.id)).get(); + expect(updatedRoute?.enabled).toBe(false); + }); }); diff --git a/src/server/routes/api/tokens.route-update-rebuild.test.ts b/src/server/routes/api/tokens.route-update-rebuild.test.ts index ea255011..ecf55aca 100644 --- a/src/server/routes/api/tokens.route-update-rebuild.test.ts +++ b/src/server/routes/api/tokens.route-update-rebuild.test.ts @@ -149,4 +149,353 @@ describe('PUT /api/routes/:id route rebuild', () => { expect(rebuiltAuto?.priority).toBe(0); expect(rebuiltAuto?.weight).toBe(10); }); + + it('creates explicit-group routes with sourceRouteIds and aggregates source channels', async () => { + const sourceA = await seedAccountWithToken('claude-opus-4-5'); + const sourceB = await seedAccountWithToken('claude-sonnet-4-5'); + + const exactRouteA = await db.insert(schema.tokenRoutes).values({ + modelPattern: 'claude-opus-4-5', + enabled: true, + }).returning().get(); + const exactRouteB = await db.insert(schema.tokenRoutes).values({ + modelPattern: 'claude-sonnet-4-5', + enabled: true, + }).returning().get(); + + await db.insert(schema.routeChannels).values([ + { + routeId: exactRouteA.id, + accountId: sourceA.account.id, + tokenId: sourceA.token.id, + sourceModel: 'claude-opus-4-5', + priority: 0, + weight: 10, + enabled: true, + manualOverride: false, + }, + { + routeId: exactRouteB.id, + accountId: sourceB.account.id, + tokenId: sourceB.token.id, + sourceModel: 'claude-sonnet-4-5', + priority: 1, + weight: 8, + enabled: true, + manualOverride: false, + }, + ]).run(); + + const createResponse = await app.inject({ + method: 'POST', + url: '/api/routes', + payload: { + routeMode: 'explicit_group', + displayName: 'claude-opus-4-6', + sourceRouteIds: [exactRouteA.id, exactRouteB.id], + routingStrategy: 'weighted', + }, + }); + + expect(createResponse.statusCode).toBe(200); + expect(createResponse.json()).toMatchObject({ + displayName: 'claude-opus-4-6', + routeMode: 'explicit_group', + sourceRouteIds: [exactRouteA.id, exactRouteB.id], + }); + + const createdRouteId = (createResponse.json() as { id: number }).id; + + const storedChannels = await db.select().from(schema.routeChannels) + .where(eq(schema.routeChannels.routeId, createdRouteId)) + .all(); + expect(storedChannels).toHaveLength(0); + + const summaryResponse = await app.inject({ + method: 'GET', + url: '/api/routes/summary', + }); + expect(summaryResponse.statusCode).toBe(200); + expect(summaryResponse.json()).toContainEqual(expect.objectContaining({ + id: createdRouteId, + routeMode: 'explicit_group', + sourceRouteIds: [exactRouteA.id, exactRouteB.id], + channelCount: 2, + enabledChannelCount: 2, + siteNames: expect.arrayContaining([sourceA.site.name, sourceB.site.name]), + })); + + const channelsResponse = await app.inject({ + method: 'GET', + url: `/api/routes/${createdRouteId}/channels`, + }); + expect(channelsResponse.statusCode).toBe(200); + expect(channelsResponse.json()).toEqual(expect.arrayContaining([ + expect.objectContaining({ + routeId: exactRouteA.id, + accountId: sourceA.account.id, + sourceModel: 'claude-opus-4-5', + }), + expect.objectContaining({ + routeId: exactRouteB.id, + accountId: sourceB.account.id, + sourceModel: 'claude-sonnet-4-5', + }), + ])); + }); + + it('syncs explicit-group routing strategy to unique source routes', async () => { + await seedAccountWithToken('claude-opus-4-5'); + await seedAccountWithToken('claude-sonnet-4-5'); + + const exactRouteA = await db.insert(schema.tokenRoutes).values({ + modelPattern: 'claude-opus-4-5', + enabled: true, + routingStrategy: 'weighted', + }).returning().get(); + const exactRouteB = await db.insert(schema.tokenRoutes).values({ + modelPattern: 'claude-sonnet-4-5', + enabled: true, + routingStrategy: 'weighted', + }).returning().get(); + + const createResponse = await app.inject({ + method: 'POST', + url: '/api/routes', + payload: { + routeMode: 'explicit_group', + displayName: 'claude-opus-4-6', + sourceRouteIds: [exactRouteA.id, exactRouteB.id], + routingStrategy: 'stable_first', + }, + }); + + expect(createResponse.statusCode).toBe(200); + + const refreshedRouteA = await db.select().from(schema.tokenRoutes) + .where(eq(schema.tokenRoutes.id, exactRouteA.id)) + .get(); + const refreshedRouteB = await db.select().from(schema.tokenRoutes) + .where(eq(schema.tokenRoutes.id, exactRouteB.id)) + .get(); + + expect(refreshedRouteA?.routingStrategy).toBe('stable_first'); + expect(refreshedRouteB?.routingStrategy).toBe('stable_first'); + + const groupRouteId = (createResponse.json() as { id: number }).id; + const updateResponse = await app.inject({ + method: 'PUT', + url: `/api/routes/${groupRouteId}`, + payload: { + routingStrategy: 'round_robin', + }, + }); + + expect(updateResponse.statusCode).toBe(200); + + const updatedRouteA = await db.select().from(schema.tokenRoutes) + .where(eq(schema.tokenRoutes.id, exactRouteA.id)) + .get(); + const updatedRouteB = await db.select().from(schema.tokenRoutes) + .where(eq(schema.tokenRoutes.id, exactRouteB.id)) + .get(); + + expect(updatedRouteA?.routingStrategy).toBe('round_robin'); + expect(updatedRouteB?.routingStrategy).toBe('round_robin'); + }); + + it('does not overwrite source routes shared by another explicit-group', async () => { + await seedAccountWithToken('claude-opus-4-5'); + + const sharedSourceRoute = await db.insert(schema.tokenRoutes).values({ + modelPattern: 'claude-opus-4-5', + enabled: true, + routingStrategy: 'weighted', + }).returning().get(); + + const firstGroupResponse = await app.inject({ + method: 'POST', + url: '/api/routes', + payload: { + routeMode: 'explicit_group', + displayName: 'claude-opus-4-6', + sourceRouteIds: [sharedSourceRoute.id], + routingStrategy: 'stable_first', + }, + }); + expect(firstGroupResponse.statusCode).toBe(200); + + await db.update(schema.tokenRoutes).set({ + routingStrategy: 'weighted', + }).where(eq(schema.tokenRoutes.id, sharedSourceRoute.id)).run(); + + const secondGroupResponse = await app.inject({ + method: 'POST', + url: '/api/routes', + payload: { + routeMode: 'explicit_group', + displayName: 'claude-opus-4-6-alt', + sourceRouteIds: [sharedSourceRoute.id], + routingStrategy: 'round_robin', + }, + }); + expect(secondGroupResponse.statusCode).toBe(200); + + const refreshedSharedRoute = await db.select().from(schema.tokenRoutes) + .where(eq(schema.tokenRoutes.id, sharedSourceRoute.id)) + .get(); + + expect(refreshedSharedRoute?.routingStrategy).toBe('weighted'); + }); + + it('fills missing sourceModel from source exact routes when loading explicit-group channels', async () => { + const sourceA = await seedAccountWithToken('deepseek-chat'); + const sourceB = await seedAccountWithToken('deepseek-reasoner'); + + const exactRouteA = await db.insert(schema.tokenRoutes).values({ + modelPattern: 'deepseek-chat', + enabled: true, + }).returning().get(); + const exactRouteB = await db.insert(schema.tokenRoutes).values({ + modelPattern: 'deepseek-reasoner', + enabled: true, + }).returning().get(); + + await db.insert(schema.routeChannels).values([ + { + routeId: exactRouteA.id, + accountId: sourceA.account.id, + tokenId: sourceA.token.id, + sourceModel: null, + priority: 0, + weight: 10, + enabled: true, + manualOverride: false, + }, + { + routeId: exactRouteB.id, + accountId: sourceB.account.id, + tokenId: sourceB.token.id, + sourceModel: null, + priority: 1, + weight: 8, + enabled: true, + manualOverride: false, + }, + ]).run(); + + const createResponse = await app.inject({ + method: 'POST', + url: '/api/routes', + payload: { + routeMode: 'explicit_group', + displayName: 'deepseekv1', + sourceRouteIds: [exactRouteA.id, exactRouteB.id], + }, + }); + + expect(createResponse.statusCode).toBe(200); + const createdRouteId = (createResponse.json() as { id: number }).id; + + const channelsResponse = await app.inject({ + method: 'GET', + url: `/api/routes/${createdRouteId}/channels`, + }); + + expect(channelsResponse.statusCode).toBe(200); + expect(channelsResponse.json()).toEqual(expect.arrayContaining([ + expect.objectContaining({ + routeId: exactRouteA.id, + accountId: sourceA.account.id, + sourceModel: 'deepseek-chat', + }), + expect.objectContaining({ + routeId: exactRouteB.id, + accountId: sourceB.account.id, + sourceModel: 'deepseek-reasoner', + }), + ])); + }); + + it('rejects invalid explicit-group payloads', async () => { + const response = await app.inject({ + method: 'POST', + url: '/api/routes', + payload: { + routeMode: 'explicit_group', + displayName: '', + sourceRouteIds: [], + }, + }); + + expect(response.statusCode).toBe(400); + expect(response.json()).toMatchObject({ + success: false, + }); + }); + + it('prefers an exact route over a colliding explicit-group display name', async () => { + const exactCandidate = await seedAccountWithToken('claude-opus-4-6'); + const groupedCandidate = await seedAccountWithToken('claude-opus-4-5'); + + const exactRoute = await db.insert(schema.tokenRoutes).values({ + modelPattern: 'claude-opus-4-6', + enabled: true, + }).returning().get(); + const sourceRoute = await db.insert(schema.tokenRoutes).values({ + modelPattern: 'claude-opus-4-5', + enabled: true, + }).returning().get(); + + await db.insert(schema.routeChannels).values([ + { + routeId: exactRoute.id, + accountId: exactCandidate.account.id, + tokenId: exactCandidate.token.id, + sourceModel: 'claude-opus-4-6', + priority: 0, + weight: 10, + enabled: true, + manualOverride: false, + }, + { + routeId: sourceRoute.id, + accountId: groupedCandidate.account.id, + tokenId: groupedCandidate.token.id, + sourceModel: 'claude-opus-4-5', + priority: 0, + weight: 10, + enabled: true, + manualOverride: false, + }, + ]).run(); + + const groupResponse = await app.inject({ + method: 'POST', + url: '/api/routes', + payload: { + routeMode: 'explicit_group', + displayName: 'claude-opus-4-6', + sourceRouteIds: [sourceRoute.id], + }, + }); + + expect(groupResponse.statusCode).toBe(200); + + const decisionResponse = await app.inject({ + method: 'GET', + url: '/api/routes/decision?model=claude-opus-4-6', + }); + + expect(decisionResponse.statusCode).toBe(200); + expect(decisionResponse.json()).toMatchObject({ + success: true, + decision: { + matched: true, + routeId: exactRoute.id, + modelPattern: 'claude-opus-4-6', + actualModel: 'claude-opus-4-6', + }, + }); + }); }); diff --git a/src/server/routes/api/tokens.ts b/src/server/routes/api/tokens.ts index 18f6ba0c..5ab39a30 100644 --- a/src/server/routes/api/tokens.ts +++ b/src/server/routes/api/tokens.ts @@ -1,7 +1,16 @@ import { FastifyInstance } from 'fastify'; import { and, eq, inArray } from 'drizzle-orm'; import { db, schema } from '../../db/index.js'; -import { rebuildTokenRoutesFromAvailability, refreshModelsAndRebuildRoutes } from '../../services/modelService.js'; +import * as routeRefreshWorkflow from '../../services/routeRefreshWorkflow.js'; +import { + ACCOUNT_TOKEN_VALUE_STATUS_READY, + isUsableAccountToken, +} from '../../services/accountTokenService.js'; +import { + DEFAULT_ROUTE_ROUTING_STRATEGY, + normalizeRouteRoutingStrategy, + type RouteRoutingStrategy, +} from '../../services/routeRoutingStrategy.js'; import { invalidateTokenRouterCache, matchesModelPattern, tokenRouter } from '../../services/tokenRouter.js'; import { startBackgroundTask } from '../../services/backgroundTaskService.js'; import { @@ -10,20 +19,219 @@ import { parseRouteDecisionSnapshot, saveRouteDecisionSnapshots, } from '../../services/routeDecisionSnapshotStore.js'; +import { normalizeTokenRouteMode, type RouteMode } from '../../../shared/tokenRouteContract.js'; -function isExactModelPattern(modelPattern: string): boolean { - const normalized = modelPattern.trim(); - if (!normalized) return false; - if (normalized.toLowerCase().startsWith('re:')) return false; - return !/[\*\?\[]/.test(normalized); -} +function isExactModelPattern(modelPattern: string): boolean { + const normalized = modelPattern.trim(); + if (!normalized) return false; + if (normalized.toLowerCase().startsWith('re:')) return false; + return !/[\*\?]/.test(normalized); +} + +type RouteRow = typeof schema.tokenRoutes.$inferSelect & { + routeMode: RouteMode; + sourceRouteIds: number[]; +}; + +function normalizeRouteMode(routeMode: unknown): RouteMode { + return normalizeTokenRouteMode(routeMode); +} + +function isExplicitGroupRoute(route: Pick | Pick): boolean { + return normalizeRouteMode(route.routeMode) === 'explicit_group'; +} + +function normalizeSourceRouteIdsInput(input: unknown): number[] { + const rawValues = Array.isArray(input) ? input : []; + const normalized: number[] = []; + for (const raw of rawValues) { + const value = Number(raw); + if (!Number.isFinite(value)) continue; + const routeId = Math.trunc(value); + if (routeId <= 0 || normalized.includes(routeId)) continue; + normalized.push(routeId); + if (normalized.length >= 500) break; + } + return normalized; +} + +async function loadRouteSourceIdsMap(routeIds: number[]): Promise> { + const normalizedRouteIds = Array.from(new Set(routeIds.filter((routeId) => Number.isFinite(routeId) && routeId > 0))); + if (normalizedRouteIds.length === 0) return new Map(); + + const rows = await db.select().from(schema.routeGroupSources) + .where(inArray(schema.routeGroupSources.groupRouteId, normalizedRouteIds)) + .all(); + const sourceRouteIdsByRouteId = new Map(); + for (const row of rows) { + if (!sourceRouteIdsByRouteId.has(row.groupRouteId)) { + sourceRouteIdsByRouteId.set(row.groupRouteId, []); + } + sourceRouteIdsByRouteId.get(row.groupRouteId)!.push(row.sourceRouteId); + } + for (const [routeId, sourceRouteIds] of sourceRouteIdsByRouteId.entries()) { + sourceRouteIdsByRouteId.set(routeId, Array.from(new Set(sourceRouteIds))); + } + return sourceRouteIdsByRouteId; +} + +function decorateRoutesWithSources( + routes: Array, + sourceRouteIdsByRouteId: Map, +): RouteRow[] { + return routes.map((route) => ({ + ...route, + routeMode: normalizeRouteMode(route.routeMode), + sourceRouteIds: sourceRouteIdsByRouteId.get(route.id) ?? [], + })); +} + +async function listRoutesWithSources(): Promise { + const routes = await db.select().from(schema.tokenRoutes).all(); + const sourceRouteIdsByRouteId = await loadRouteSourceIdsMap(routes.map((route) => route.id)); + return decorateRoutesWithSources(routes, sourceRouteIdsByRouteId); +} + +async function getRouteWithSources(routeId: number): Promise { + const route = await db.select().from(schema.tokenRoutes).where(eq(schema.tokenRoutes.id, routeId)).get(); + if (!route) return null; + const sourceRouteIdsByRouteId = await loadRouteSourceIdsMap([routeId]); + return decorateRoutesWithSources([route], sourceRouteIdsByRouteId)[0] ?? null; +} + +async function validateExplicitGroupSourceRoutes(sourceRouteIds: number[], currentRouteId?: number): Promise<{ ok: true } | { ok: false; message: string }> { + if (sourceRouteIds.length === 0) { + return { ok: false, message: '显式群组至少需要选择一个来源模型' }; + } + + const routes = await db.select().from(schema.tokenRoutes) + .where(inArray(schema.tokenRoutes.id, sourceRouteIds)) + .all(); + if (routes.length !== sourceRouteIds.length) { + return { ok: false, message: '来源模型中存在不存在的路由' }; + } + + for (const route of routes) { + if (currentRouteId && route.id === currentRouteId) { + return { ok: false, message: '显式群组不能引用自身作为来源模型' }; + } + if (normalizeRouteMode(route.routeMode) === 'explicit_group') { + return { ok: false, message: '显式群组只能选择精确模型路由作为来源模型' }; + } + if (!isExactModelPattern(route.modelPattern)) { + return { ok: false, message: '显式群组只能选择精确模型路由作为来源模型' }; + } + } + + return { ok: true }; +} + +async function replaceRouteSourceRouteIds(routeId: number, sourceRouteIds: number[]): Promise { + await db.delete(schema.routeGroupSources).where(eq(schema.routeGroupSources.groupRouteId, routeId)).run(); + if (sourceRouteIds.length === 0) return; + await db.insert(schema.routeGroupSources).values( + sourceRouteIds.map((sourceRouteId) => ({ + groupRouteId: routeId, + sourceRouteId, + })), + ).run(); +} + +async function syncExplicitGroupSourceRouteStrategies(input: { + groupRouteId: number; + sourceRouteIds: number[]; + targetStrategy: RouteRoutingStrategy; + previousStrategy?: RouteRoutingStrategy | null; +}): Promise { + const normalizedSourceRouteIds = Array.from(new Set( + input.sourceRouteIds.filter((routeId): routeId is number => Number.isFinite(routeId) && routeId > 0), + )); + if (normalizedSourceRouteIds.length === 0) return []; + + const [sourceRoutes, sourceGroupRows] = await Promise.all([ + db.select().from(schema.tokenRoutes) + .where(inArray(schema.tokenRoutes.id, normalizedSourceRouteIds)) + .all(), + db.select({ + groupRouteId: schema.routeGroupSources.groupRouteId, + sourceRouteId: schema.routeGroupSources.sourceRouteId, + }).from(schema.routeGroupSources) + .where(inArray(schema.routeGroupSources.sourceRouteId, normalizedSourceRouteIds)) + .all(), + ]); + + const otherGroupRefsBySourceRouteId = new Map>(); + for (const row of sourceGroupRows) { + if (row.groupRouteId === input.groupRouteId) continue; + if (!otherGroupRefsBySourceRouteId.has(row.sourceRouteId)) { + otherGroupRefsBySourceRouteId.set(row.sourceRouteId, new Set()); + } + otherGroupRefsBySourceRouteId.get(row.sourceRouteId)!.add(row.groupRouteId); + } + + const previousStrategy = input.previousStrategy + ? normalizeRouteRoutingStrategy(input.previousStrategy) + : null; + const updatableRouteIds: number[] = []; + for (const route of sourceRoutes) { + if (normalizeRouteMode(route.routeMode) === 'explicit_group') continue; + if (!isExactModelPattern(route.modelPattern)) continue; + if ((otherGroupRefsBySourceRouteId.get(route.id)?.size || 0) > 0) continue; + + const currentStrategy = normalizeRouteRoutingStrategy(route.routingStrategy); + const shouldSync = ( + currentStrategy === DEFAULT_ROUTE_ROUTING_STRATEGY + || currentStrategy === input.targetStrategy + || (previousStrategy !== null && currentStrategy === previousStrategy) + ); + if (!shouldSync) continue; + if (currentStrategy === input.targetStrategy) continue; + updatableRouteIds.push(route.id); + } + + if (updatableRouteIds.length === 0) return []; + + await db.update(schema.tokenRoutes).set({ + routingStrategy: input.targetStrategy, + updatedAt: new Date().toISOString(), + }).where(inArray(schema.tokenRoutes.id, updatableRouteIds)).run(); + + return updatableRouteIds; +} + +async function clearDependentExplicitGroupSnapshotsBySourceRouteIds(sourceRouteIds: number[]): Promise { + const normalizedSourceRouteIds = Array.from(new Set( + sourceRouteIds.filter((routeId): routeId is number => Number.isFinite(routeId) && routeId > 0), + )); + if (normalizedSourceRouteIds.length === 0) return; + + const rows = await db.select({ groupRouteId: schema.routeGroupSources.groupRouteId }) + .from(schema.routeGroupSources) + .where(inArray(schema.routeGroupSources.sourceRouteId, normalizedSourceRouteIds)) + .all(); + const dependentRouteIdSet = new Set(); + for (const row of rows) { + const routeId = Number(row.groupRouteId); + if (Number.isFinite(routeId) && routeId > 0) { + dependentRouteIdSet.add(routeId); + } + } + const dependentRouteIds = Array.from(dependentRouteIdSet); + if (dependentRouteIds.length === 0) return; + await clearRouteDecisionSnapshots(dependentRouteIds); +} -async function getDefaultTokenId(accountId: number): Promise { - const token = await db.select().from(schema.accountTokens) - .where(and(eq(schema.accountTokens.accountId, accountId), eq(schema.accountTokens.enabled, true), eq(schema.accountTokens.isDefault, true))) - .get(); - return token?.id ?? null; -} +async function getDefaultTokenId(accountId: number): Promise { + const token = await db.select().from(schema.accountTokens) + .where(and( + eq(schema.accountTokens.accountId, accountId), + eq(schema.accountTokens.enabled, true), + eq(schema.accountTokens.isDefault, true), + eq(schema.accountTokens.valueStatus, ACCOUNT_TOKEN_VALUE_STATUS_READY), + )) + .get(); + return isUsableAccountToken(token ?? null) ? token!.id : null; +} function canonicalModelAlias(modelName: string): string { const normalized = modelName.trim().toLowerCase(); @@ -57,12 +265,12 @@ async function tokenSupportsModel(tokenId: number, modelName: string): Promise { - const row = await db.select().from(schema.accountTokens) - .where(and(eq(schema.accountTokens.id, tokenId), eq(schema.accountTokens.accountId, accountId))) - .get(); - return !!row; -} +async function checkTokenBelongsToAccount(tokenId: number, accountId: number): Promise { + const row = await db.select().from(schema.accountTokens) + .where(and(eq(schema.accountTokens.id, tokenId), eq(schema.accountTokens.accountId, accountId))) + .get(); + return isUsableAccountToken(row ?? null); +} async function getPatternTokenCandidates(modelPattern: string): Promise> { const rows = await db.select().from(schema.tokenModelAvailability) @@ -71,18 +279,20 @@ async function getPatternTokenCandidates(modelPattern: string): Promise = []; - for (const row of rows) { - const modelName = row.token_model_availability.modelName?.trim(); - if (!modelName) continue; + eq(schema.tokenModelAvailability.available, true), + eq(schema.accountTokens.enabled, true), + eq(schema.accountTokens.valueStatus, ACCOUNT_TOKEN_VALUE_STATUS_READY), + eq(schema.accounts.status, 'active'), + eq(schema.sites.status, 'active'), + ), + ) + .all(); + + const result: Array<{ tokenId: number; accountId: number; sourceModel: string }> = []; + for (const row of rows) { + if (!isUsableAccountToken(row.account_tokens)) continue; + const modelName = row.token_model_availability.modelName?.trim(); + if (!modelName) continue; if (!matchesModelPattern(modelName, modelPattern)) continue; result.push({ tokenId: row.account_tokens.id, @@ -127,9 +337,9 @@ async function getMatchedExactRouteChannelCandidates(modelPattern: string): Prom })).filter((candidate) => candidate.sourceModel.length > 0); } -async function populateRouteChannelsByModelPattern(routeId: number, modelPattern: string): Promise { - const routeCandidates = await getMatchedExactRouteChannelCandidates(modelPattern); - const availabilityCandidates = (await getPatternTokenCandidates(modelPattern)).map((candidate) => ({ +async function populateRouteChannelsByModelPattern(routeId: number, modelPattern: string): Promise { + const routeCandidates = await getMatchedExactRouteChannelCandidates(modelPattern); + const availabilityCandidates = (await getPatternTokenCandidates(modelPattern)).map((candidate) => ({ tokenId: candidate.tokenId, accountId: candidate.accountId, sourceModel: candidate.sourceModel, @@ -171,59 +381,59 @@ async function populateRouteChannelsByModelPattern(routeId: number, modelPattern existingPairs.add(pairKey); created += 1; } - - return created; -} - -async function rebuildAutomaticRouteChannelsByModelPattern(routeId: number, modelPattern: string): Promise<{ - removedChannels: number; - createdChannels: number; -}> { - const removableChannels = await db.select().from(schema.routeChannels) - .where( - and( - eq(schema.routeChannels.routeId, routeId), - eq(schema.routeChannels.manualOverride, false), - ), - ) - .all(); - - for (const channel of removableChannels) { - await db.delete(schema.routeChannels).where(eq(schema.routeChannels.id, channel.id)).run(); - } - - const createdChannels = await populateRouteChannelsByModelPattern(routeId, modelPattern); - return { - removedChannels: removableChannels.length, - createdChannels, - }; -} + + return created; +} + +async function rebuildAutomaticRouteChannelsByModelPattern(routeId: number, modelPattern: string): Promise<{ + removedChannels: number; + createdChannels: number; +}> { + const removableChannels = await db.select().from(schema.routeChannels) + .where( + and( + eq(schema.routeChannels.routeId, routeId), + eq(schema.routeChannels.manualOverride, false), + ), + ) + .all(); + + for (const channel of removableChannels) { + await db.delete(schema.routeChannels).where(eq(schema.routeChannels.id, channel.id)).run(); + } + + const createdChannels = await populateRouteChannelsByModelPattern(routeId, modelPattern); + return { + removedChannels: removableChannels.length, + createdChannels, + }; +} type BatchChannelPriorityUpdate = { id: number; priority: number; }; -type BatchRouteDecisionModels = { - models: string[]; - refreshPricingCatalog?: boolean; - persistSnapshots?: boolean; -}; - -type BatchRouteDecisionRouteModels = { - items: Array<{ - routeId: number; - model: string; - }>; - refreshPricingCatalog?: boolean; - persistSnapshots?: boolean; -}; - -type BatchRouteWideDecisionRouteIds = { - routeIds: number[]; - refreshPricingCatalog?: boolean; - persistSnapshots?: boolean; -}; +type BatchRouteDecisionModels = { + models: string[]; + refreshPricingCatalog?: boolean; + persistSnapshots?: boolean; +}; + +type BatchRouteDecisionRouteModels = { + items: Array<{ + routeId: number; + model: string; + }>; + refreshPricingCatalog?: boolean; + persistSnapshots?: boolean; +}; + +type BatchRouteWideDecisionRouteIds = { + routeIds: number[]; + refreshPricingCatalog?: boolean; + persistSnapshots?: boolean; +}; function parseBatchChannelUpdates(input: unknown): { ok: true; updates: BatchChannelPriorityUpdate[] } | { ok: false; message: string } { if (!input || typeof input !== 'object') { @@ -264,9 +474,9 @@ function parseBatchChannelUpdates(input: unknown): { ok: true; updates: BatchCha return { ok: true, updates: normalized }; } -function parseBatchRouteDecisionModels( - input: unknown, -): { ok: true; models: string[]; refreshPricingCatalog: boolean; persistSnapshots: boolean } | { ok: false; message: string } { +function parseBatchRouteDecisionModels( + input: unknown, +): { ok: true; models: string[]; refreshPricingCatalog: boolean; persistSnapshots: boolean } | { ok: false; message: string } { if (!input || typeof input !== 'object') { return { ok: false, message: '请求体必须是对象' }; } @@ -291,17 +501,17 @@ function parseBatchRouteDecisionModels( return { ok: false, message: 'models 中没有有效模型名称' }; } - return { - ok: true, - models: normalized, - refreshPricingCatalog: (input as { refreshPricingCatalog?: unknown }).refreshPricingCatalog === true, - persistSnapshots: (input as { persistSnapshots?: unknown }).persistSnapshots === true, - }; -} - -function parseBatchRouteDecisionRouteModels( - input: unknown, -): { ok: true; items: Array<{ routeId: number; model: string }>; refreshPricingCatalog: boolean; persistSnapshots: boolean } | { ok: false; message: string } { + return { + ok: true, + models: normalized, + refreshPricingCatalog: (input as { refreshPricingCatalog?: unknown }).refreshPricingCatalog === true, + persistSnapshots: (input as { persistSnapshots?: unknown }).persistSnapshots === true, + }; +} + +function parseBatchRouteDecisionRouteModels( + input: unknown, +): { ok: true; items: Array<{ routeId: number; model: string }>; refreshPricingCatalog: boolean; persistSnapshots: boolean } | { ok: false; message: string } { if (!input || typeof input !== 'object') { return { ok: false, message: '请求体必须是对象' }; } @@ -335,17 +545,17 @@ function parseBatchRouteDecisionRouteModels( return { ok: false, message: 'items 中没有有效 routeId/model' }; } - return { - ok: true, - items: normalized, - refreshPricingCatalog: (input as { refreshPricingCatalog?: unknown }).refreshPricingCatalog === true, - persistSnapshots: (input as { persistSnapshots?: unknown }).persistSnapshots === true, - }; -} - -function parseBatchRouteWideDecisionRouteIds( - input: unknown, -): { ok: true; routeIds: number[]; refreshPricingCatalog: boolean; persistSnapshots: boolean } | { ok: false; message: string } { + return { + ok: true, + items: normalized, + refreshPricingCatalog: (input as { refreshPricingCatalog?: unknown }).refreshPricingCatalog === true, + persistSnapshots: (input as { persistSnapshots?: unknown }).persistSnapshots === true, + }; +} + +function parseBatchRouteWideDecisionRouteIds( + input: unknown, +): { ok: true; routeIds: number[]; refreshPricingCatalog: boolean; persistSnapshots: boolean } | { ok: false; message: string } { if (!input || typeof input !== 'object') { return { ok: false, message: '请求体必须是对象' }; } @@ -370,56 +580,280 @@ function parseBatchRouteWideDecisionRouteIds( return { ok: false, message: 'routeIds 中没有有效 routeId' }; } - return { - ok: true, - routeIds: normalized, - refreshPricingCatalog: (input as { refreshPricingCatalog?: unknown }).refreshPricingCatalog === true, - persistSnapshots: (input as { persistSnapshots?: unknown }).persistSnapshots === true, - }; + return { + ok: true, + routeIds: normalized, + refreshPricingCatalog: (input as { refreshPricingCatalog?: unknown }).refreshPricingCatalog === true, + persistSnapshots: (input as { persistSnapshots?: unknown }).persistSnapshots === true, + }; +} + +type RouteChannelSummary = { + channelCount: number; + enabledChannelCount: number; + siteNames: Set; +}; + +async function fetchChannelsForRouteRows(routes: RouteRow[]): Promise> { + if (routes.length === 0) return new Map(); + + const explicitSourceRouteIds = Array.from(new Set(routes + .filter((route) => isExplicitGroupRoute(route)) + .flatMap((route) => route.sourceRouteIds))); + const explicitSourceRoutes = explicitSourceRouteIds.length > 0 + ? (await db.select({ + id: schema.tokenRoutes.id, + modelPattern: schema.tokenRoutes.modelPattern, + routeMode: schema.tokenRoutes.routeMode, + enabled: schema.tokenRoutes.enabled, + }).from(schema.tokenRoutes) + .where(inArray(schema.tokenRoutes.id, explicitSourceRouteIds)) + .all()) + : []; + const enabledExplicitSourceRouteIds = explicitSourceRoutes + .filter((route) => route.enabled && !isExplicitGroupRoute(route) && isExactModelPattern(route.modelPattern)) + .map((route) => route.id); + const actualRouteIds = Array.from(new Set([ + ...routes.filter((route) => !isExplicitGroupRoute(route)).map((route) => route.id), + ...enabledExplicitSourceRouteIds, + ])); + if (actualRouteIds.length === 0) { + return new Map(routes.map((route) => [route.id, []])); + } + + const actualRouteById = new Map(); + for (const route of routes.filter((item) => !isExplicitGroupRoute(item))) { + actualRouteById.set(route.id, { modelPattern: route.modelPattern, routeMode: route.routeMode ?? null }); + } + for (const route of explicitSourceRoutes) { + actualRouteById.set(route.id, { modelPattern: route.modelPattern, routeMode: route.routeMode ?? null }); + } + + const channelRows = await db.select().from(schema.routeChannels) + .innerJoin(schema.accounts, eq(schema.routeChannels.accountId, schema.accounts.id)) + .innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id)) + .leftJoin(schema.accountTokens, eq(schema.routeChannels.tokenId, schema.accountTokens.id)) + .where(inArray(schema.routeChannels.routeId, actualRouteIds)) + .all(); + + const channelsByActualRouteId = new Map(); + + for (const row of channelRows) { + const routeId = row.route_channels.routeId; + const actualRoute = actualRouteById.get(routeId); + const fallbackSourceModel = actualRoute && !isExplicitGroupRoute(actualRoute) && isExactModelPattern(actualRoute.modelPattern) + ? actualRoute.modelPattern + : null; + const resolvedSourceModel = (row.route_channels.sourceModel || fallbackSourceModel || '').trim(); + if (!channelsByActualRouteId.has(routeId)) channelsByActualRouteId.set(routeId, []); + channelsByActualRouteId.get(routeId)!.push({ + ...row.route_channels, + sourceModel: resolvedSourceModel || null, + account: row.accounts, + site: row.sites, + token: row.account_tokens + ? { + id: row.account_tokens.id, + name: row.account_tokens.name, + accountId: row.account_tokens.accountId, + enabled: row.account_tokens.enabled, + isDefault: row.account_tokens.isDefault, + } + : null, + }); + } + + const channelsByRoute = new Map(); + for (const route of routes) { + if (isExplicitGroupRoute(route)) { + channelsByRoute.set(route.id, route.sourceRouteIds.flatMap((sourceRouteId) => channelsByActualRouteId.get(sourceRouteId) || [])); + continue; + } + channelsByRoute.set(route.id, channelsByActualRouteId.get(route.id) || []); + } + + return channelsByRoute; +} + +async function fetchChannelsForRoutes(routeIds: number[]): Promise> { + if (routeIds.length === 0) return new Map(); + return await fetchChannelsForRouteRows(await listRoutesWithSources()).then((channelsByRoute) => { + const filtered = new Map(); + for (const routeId of routeIds) { + filtered.set(routeId, channelsByRoute.get(routeId) || []); + } + return filtered; + }); +} + +async function buildRouteChannelSummaryMap(routes: RouteRow[]): Promise> { + const channelsByRoute = await fetchChannelsForRouteRows(routes); + const summaryByRoute = new Map(); + for (const route of routes) { + const channels = channelsByRoute.get(route.id) || []; + const siteNames = new Set(); + let enabledChannelCount = 0; + for (const channel of channels) { + if (channel.enabled) enabledChannelCount += 1; + if (channel.site?.name) siteNames.add(channel.site.name); + } + summaryByRoute.set(route.id, { + channelCount: channels.length, + enabledChannelCount, + siteNames, + }); + } + return summaryByRoute; } -export async function tokensRoutes(app: FastifyInstance) { - // List all routes - app.get('/api/routes', async () => { - const routes = await db.select().from(schema.tokenRoutes).all(); +export async function tokensRoutes(app: FastifyInstance) { + // List routes with basic info only (lightweight for selectors) + app.get('/api/routes/lite', async () => { + return (await listRoutesWithSources()).map((route) => ({ + id: route.id, + modelPattern: route.modelPattern, + displayName: route.displayName, + displayIcon: route.displayIcon, + routeMode: route.routeMode, + sourceRouteIds: route.sourceRouteIds, + routingStrategy: route.routingStrategy, + enabled: route.enabled, + })); + }); + + // Route summary (no channel details) for first-screen rendering + app.get('/api/routes/summary', async () => { + const routes = await listRoutesWithSources(); if (routes.length === 0) return []; + const aggByRoute = await buildRouteChannelSummaryMap(routes); + + return routes.map((route) => { + const agg = aggByRoute.get(route.id); + return { + id: route.id, + modelPattern: route.modelPattern, + displayName: route.displayName ?? null, + displayIcon: route.displayIcon ?? null, + routeMode: route.routeMode, + sourceRouteIds: route.sourceRouteIds, + modelMapping: route.modelMapping ?? null, + routingStrategy: route.routingStrategy ?? 'weighted', + enabled: route.enabled, + channelCount: agg?.channelCount ?? 0, + enabledChannelCount: agg?.enabledChannelCount ?? 0, + siteNames: agg ? Array.from(agg.siteNames) : [], + decisionSnapshot: parseRouteDecisionSnapshot(route.decisionSnapshot), + decisionRefreshedAt: route.decisionRefreshedAt ?? null, + }; + }); + }); - const routeIds = routes.map((route) => route.id); - const channelRows = await db.select().from(schema.routeChannels) - .innerJoin(schema.accounts, eq(schema.routeChannels.accountId, schema.accounts.id)) - .innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id)) - .leftJoin(schema.accountTokens, eq(schema.routeChannels.tokenId, schema.accountTokens.id)) - .where(inArray(schema.routeChannels.routeId, routeIds)) + // Get channels for a single route (on-demand loading) + app.get<{ Params: { id: string } }>('/api/routes/:id/channels', async (request, reply) => { + const routeId = parseInt(request.params.id, 10); + const route = await getRouteWithSources(routeId); + if (!route) { + return reply.code(404).send({ success: false, message: '路由不存在' }); + } + const channelsByRoute = await fetchChannelsForRouteRows([route]); + return channelsByRoute.get(routeId) || []; + }); + + // Batch add channels to a route + app.post<{ Params: { id: string }; Body: { channels: Array<{ accountId: number; tokenId?: number; sourceModel?: string }> } }>('/api/routes/:id/channels/batch', async (request, reply) => { + const routeId = parseInt(request.params.id, 10); + const body = request.body; + + const route = await getRouteWithSources(routeId); + if (!route) { + return reply.code(404).send({ success: false, message: '路由不存在' }); + } + if (isExplicitGroupRoute(route)) { + return reply.code(400).send({ success: false, message: '显式群组不支持直接维护通道' }); + } + + if (!body?.channels || !Array.isArray(body.channels) || body.channels.length === 0) { + return reply.code(400).send({ success: false, message: 'channels 必须是非空数组' }); + } + + const existingChannels = await db.select().from(schema.routeChannels) + .where(eq(schema.routeChannels.routeId, routeId)) .all(); + const existingPairs = new Set( + existingChannels.map((channel) => { + const tokenId = typeof channel.tokenId === 'number' && Number.isFinite(channel.tokenId) ? channel.tokenId : 0; + const sourceModel = (channel.sourceModel || '').trim().toLowerCase(); + return `${channel.accountId}::${tokenId}::${sourceModel}`; + }), + ); - const channelsByRoute = new Map(); - - for (const row of channelRows) { - const routeId = row.route_channels.routeId; - if (!channelsByRoute.has(routeId)) channelsByRoute.set(routeId, []); - channelsByRoute.get(routeId)!.push({ - ...row.route_channels, - account: row.accounts, - site: row.sites, - token: row.account_tokens - ? { - id: row.account_tokens.id, - name: row.account_tokens.name, - accountId: row.account_tokens.accountId, - enabled: row.account_tokens.enabled, - isDefault: row.account_tokens.isDefault, - } - : null, - }); + let created = 0; + let skipped = 0; + const errors: string[] = []; + + for (const item of body.channels) { + if (!item?.accountId || typeof item.accountId !== 'number') { + errors.push('无效的 accountId'); + continue; + } + + const sourceModel = typeof item.sourceModel === 'string' + ? item.sourceModel.trim() + : (isExactModelPattern(route.modelPattern) ? route.modelPattern.trim() : ''); + const effectiveTokenId = item.tokenId ?? await getDefaultTokenId(item.accountId); + + if (item.tokenId && !await checkTokenBelongsToAccount(item.tokenId, item.accountId)) { + errors.push(`令牌 ${item.tokenId} 不属于账号 ${item.accountId}`); + continue; + } + + const tokenIdForKey = typeof effectiveTokenId === 'number' && Number.isFinite(effectiveTokenId) ? effectiveTokenId : 0; + const pairKey = `${item.accountId}::${tokenIdForKey}::${sourceModel.toLowerCase()}`; + if (existingPairs.has(pairKey)) { + skipped += 1; + continue; + } + + try { + await db.insert(schema.routeChannels).values({ + routeId, + accountId: item.accountId, + tokenId: effectiveTokenId, + sourceModel: sourceModel || null, + priority: 0, + weight: 10, + manualOverride: true, + }).run(); + existingPairs.add(pairKey); + created += 1; + } catch (e: any) { + errors.push(e.message || `添加通道失败: accountId=${item.accountId}`); + } } + if (created > 0) { + await clearRouteDecisionSnapshot(routeId); + await clearDependentExplicitGroupSnapshotsBySourceRouteIds([routeId]); + invalidateTokenRouterCache(); + } + + return { success: true, created, skipped, errors }; + }); + + // List all routes + app.get('/api/routes', async () => { + const routes = await listRoutesWithSources(); + if (routes.length === 0) return []; + + const channelsByRoute = await fetchChannelsForRouteRows(routes); + return routes.map((route) => ({ ...route, - decisionSnapshot: parseRouteDecisionSnapshot(route.decisionSnapshot), - decisionRefreshedAt: route.decisionRefreshedAt ?? null, - channels: channelsByRoute.get(route.id) || [], - })); - }); + decisionSnapshot: parseRouteDecisionSnapshot(route.decisionSnapshot), + decisionRefreshedAt: route.decisionRefreshedAt ?? null, + channels: channelsByRoute.get(route.id) || [], + })); + }); app.get<{ Querystring: { model?: string } }>('/api/routes/decision', async (request, reply) => { const model = (request.query.model || '').trim(); @@ -427,186 +861,321 @@ export async function tokensRoutes(app: FastifyInstance) { return reply.code(400).send({ success: false, message: 'model 不能为空' }); } - const decision = await tokenRouter.explainSelection(model); + const decision = await tokenRouter.explainSelection(model); return { success: true, decision }; }); - app.post<{ Body: BatchRouteDecisionModels }>('/api/routes/decision/batch', async (request, reply) => { - const parsed = parseBatchRouteDecisionModels(request.body); - if (!parsed.ok) { - return reply.code(400).send({ success: false, message: parsed.message }); - } - - const decisions: Record>> = {}; - const routes = parsed.persistSnapshots - ? await db.select({ - id: schema.tokenRoutes.id, - modelPattern: schema.tokenRoutes.modelPattern, - }).from(schema.tokenRoutes).all() - : []; - const refreshedKeys = parsed.refreshPricingCatalog ? new Set() : undefined; - for (const model of parsed.models) { - if (parsed.refreshPricingCatalog) { - await tokenRouter.refreshPricingReferenceCosts(model, { refreshedKeys }); - } - decisions[model] = await tokenRouter.explainSelection(model); - } - - if (parsed.persistSnapshots) { - const snapshotWrites: Array<{ routeId: number; snapshot: unknown }> = []; - for (const model of parsed.models) { - const decision = decisions[model]; - for (const route of routes) { - if (!isExactModelPattern(route.modelPattern)) continue; - if (!matchesModelPattern(model, route.modelPattern)) continue; - snapshotWrites.push({ routeId: route.id, snapshot: decision }); - } - } - await saveRouteDecisionSnapshots(snapshotWrites); - } - - return { success: true, decisions }; - }); + app.post<{ Body: BatchRouteDecisionModels }>('/api/routes/decision/batch', async (request, reply) => { + const parsed = parseBatchRouteDecisionModels(request.body); + if (!parsed.ok) { + return reply.code(400).send({ success: false, message: parsed.message }); + } - app.post<{ Body: BatchRouteDecisionRouteModels }>('/api/routes/decision/by-route/batch', async (request, reply) => { - const parsed = parseBatchRouteDecisionRouteModels(request.body); - if (!parsed.ok) { - return reply.code(400).send({ success: false, message: parsed.message }); - } - - const decisions: Record>>> = {}; - const refreshedKeys = parsed.refreshPricingCatalog ? new Set() : undefined; - for (const item of parsed.items) { - const routeKey = String(item.routeId); - if (!decisions[routeKey]) decisions[routeKey] = {}; - if (parsed.refreshPricingCatalog) { - await tokenRouter.refreshPricingReferenceCostsForRoute(item.routeId, item.model, { refreshedKeys }); - } - decisions[routeKey][item.model] = await tokenRouter.explainSelectionForRoute(item.routeId, item.model); - } - - if (parsed.persistSnapshots) { - await saveRouteDecisionSnapshots(parsed.items.map((item) => ({ - routeId: item.routeId, - snapshot: decisions[String(item.routeId)]?.[item.model] ?? null, - }))); - } - - return { success: true, decisions }; - }); + const decisions: Record>> = {}; + const routes = parsed.persistSnapshots + ? await db.select({ + id: schema.tokenRoutes.id, + modelPattern: schema.tokenRoutes.modelPattern, + }).from(schema.tokenRoutes).all() + : []; + const refreshedKeys = parsed.refreshPricingCatalog ? new Set() : undefined; + for (const model of parsed.models) { + if (parsed.refreshPricingCatalog) { + await tokenRouter.refreshPricingReferenceCosts(model, { refreshedKeys }); + } + decisions[model] = await tokenRouter.explainSelection(model); + } - app.post<{ Body: BatchRouteWideDecisionRouteIds }>('/api/routes/decision/route-wide/batch', async (request, reply) => { - const parsed = parseBatchRouteWideDecisionRouteIds(request.body); - if (!parsed.ok) { - return reply.code(400).send({ success: false, message: parsed.message }); - } + if (parsed.persistSnapshots) { + const snapshotWrites: Array<{ routeId: number; snapshot: unknown }> = []; + for (const model of parsed.models) { + const decision = decisions[model]; + for (const route of routes) { + if (!isExactModelPattern(route.modelPattern)) continue; + if (!matchesModelPattern(model, route.modelPattern)) continue; + snapshotWrites.push({ routeId: route.id, snapshot: decision }); + } + } + await saveRouteDecisionSnapshots(snapshotWrites); + } + + return { success: true, decisions }; + }); + + app.post<{ Body: BatchRouteDecisionRouteModels }>('/api/routes/decision/by-route/batch', async (request, reply) => { + const parsed = parseBatchRouteDecisionRouteModels(request.body); + if (!parsed.ok) { + return reply.code(400).send({ success: false, message: parsed.message }); + } + + const decisions: Record>>> = {}; + const refreshedKeys = parsed.refreshPricingCatalog ? new Set() : undefined; + for (const item of parsed.items) { + const routeKey = String(item.routeId); + if (!decisions[routeKey]) decisions[routeKey] = {}; + if (parsed.refreshPricingCatalog) { + await tokenRouter.refreshPricingReferenceCostsForRoute(item.routeId, item.model, { refreshedKeys }); + } + decisions[routeKey][item.model] = await tokenRouter.explainSelectionForRoute(item.routeId, item.model); + } + + if (parsed.persistSnapshots) { + await saveRouteDecisionSnapshots(parsed.items.map((item) => ({ + routeId: item.routeId, + snapshot: decisions[String(item.routeId)]?.[item.model] ?? null, + }))); + } + + return { success: true, decisions }; + }); + + app.post<{ Body: BatchRouteWideDecisionRouteIds }>('/api/routes/decision/route-wide/batch', async (request, reply) => { + const parsed = parseBatchRouteWideDecisionRouteIds(request.body); + if (!parsed.ok) { + return reply.code(400).send({ success: false, message: parsed.message }); + } + + const decisions: Record>> = {}; + const refreshedKeys = parsed.refreshPricingCatalog ? new Set() : undefined; + for (const routeId of parsed.routeIds) { + if (parsed.refreshPricingCatalog) { + await tokenRouter.refreshRouteWidePricingReferenceCosts(routeId, { refreshedKeys }); + } + decisions[String(routeId)] = await tokenRouter.explainSelectionRouteWide(routeId); + } + + if (parsed.persistSnapshots) { + await saveRouteDecisionSnapshots(parsed.routeIds.map((routeId) => ({ + routeId, + snapshot: decisions[String(routeId)] ?? null, + }))); + } + + return { success: true, decisions }; + }); + + // Create a route + app.post<{ Body: { routeMode?: string; modelPattern?: string; displayName?: string; displayIcon?: string; modelMapping?: string; routingStrategy?: string; enabled?: boolean; sourceRouteIds?: number[] } }>('/api/routes', async (request, reply) => { + const body = request.body; + const routeMode = normalizeRouteMode(body.routeMode); + const displayName = typeof body.displayName === 'string' ? body.displayName.trim() : ''; + const sourceRouteIds = normalizeSourceRouteIdsInput(body.sourceRouteIds); + const normalizedRoutingStrategy = normalizeRouteRoutingStrategy(body.routingStrategy); + const modelPattern = routeMode === 'explicit_group' + ? displayName + : (typeof body.modelPattern === 'string' ? body.modelPattern.trim() : ''); - const decisions: Record>> = {}; - const refreshedKeys = parsed.refreshPricingCatalog ? new Set() : undefined; - for (const routeId of parsed.routeIds) { - if (parsed.refreshPricingCatalog) { - await tokenRouter.refreshRouteWidePricingReferenceCosts(routeId, { refreshedKeys }); + if (routeMode === 'explicit_group') { + if (!displayName) { + return reply.code(400).send({ success: false, message: '显式群组必须填写对外模型名' }); } - decisions[String(routeId)] = await tokenRouter.explainSelectionRouteWide(routeId); - } - - if (parsed.persistSnapshots) { - await saveRouteDecisionSnapshots(parsed.routeIds.map((routeId) => ({ - routeId, - snapshot: decisions[String(routeId)] ?? null, - }))); + const validation = await validateExplicitGroupSourceRoutes(sourceRouteIds); + if (!validation.ok) { + return reply.code(400).send({ success: false, message: validation.message }); + } + } else if (!modelPattern) { + return reply.code(400).send({ success: false, message: '模型匹配不能为空' }); } - return { success: true, decisions }; - }); - - // Create a route - app.post<{ Body: { modelPattern: string; displayName?: string; displayIcon?: string; modelMapping?: string; enabled?: boolean } }>('/api/routes', async (request) => { - const body = request.body; const insertedRoute = await db.insert(schema.tokenRoutes).values({ - modelPattern: body.modelPattern, - displayName: body.displayName, + modelPattern, + displayName: displayName || body.displayName, displayIcon: body.displayIcon, + routeMode, modelMapping: body.modelMapping, + routingStrategy: normalizedRoutingStrategy, enabled: body.enabled ?? true, }).run(); const routeId = Number(insertedRoute.lastInsertRowid || 0); if (routeId <= 0) { return { success: false, message: '创建路由失败' }; } - const route = await db.select().from(schema.tokenRoutes).where(eq(schema.tokenRoutes.id, routeId)).get(); + const route = await getRouteWithSources(routeId); if (!route) { return { success: false, message: '创建路由失败' }; } - - await populateRouteChannelsByModelPattern(route.id, body.modelPattern); - invalidateTokenRouterCache(); - return route; - }); - + + if (routeMode === 'explicit_group') { + await replaceRouteSourceRouteIds(route.id, sourceRouteIds); + const syncedRouteIds = await syncExplicitGroupSourceRouteStrategies({ + groupRouteId: route.id, + sourceRouteIds, + targetStrategy: normalizedRoutingStrategy, + }); + if (syncedRouteIds.length > 0) { + await clearRouteDecisionSnapshots(syncedRouteIds); + await clearDependentExplicitGroupSnapshotsBySourceRouteIds(syncedRouteIds); + } + } else { + await populateRouteChannelsByModelPattern(route.id, modelPattern); + } + invalidateTokenRouterCache(); + return await getRouteWithSources(routeId); + }); + // Update a route app.put<{ Params: { id: string }; Body: any }>('/api/routes/:id', async (request, reply) => { const id = parseInt(request.params.id, 10); const body = request.body as Record; - const existingRoute = await db.select().from(schema.tokenRoutes).where(eq(schema.tokenRoutes.id, id)).get(); + const existingRoute = await getRouteWithSources(id); if (!existingRoute) { return reply.code(404).send({ success: false, message: '路由不存在' }); } + const routeMode = normalizeRouteMode(body.routeMode ?? existingRoute.routeMode); + if (routeMode !== existingRoute.routeMode) { + return reply.code(400).send({ success: false, message: '暂不支持在不同群组模式之间直接切换' }); + } const updates: Record = {}; let nextModelPattern = existingRoute.modelPattern; + let nextDisplayName = existingRoute.displayName ?? ''; + let nextSourceRouteIds = existingRoute.sourceRouteIds; + const previousRoutingStrategy = normalizeRouteRoutingStrategy(existingRoute.routingStrategy); + let nextRoutingStrategy = previousRoutingStrategy; - if (body.displayName !== undefined) updates.displayName = body.displayName; + if (body.displayName !== undefined) { + nextDisplayName = String(body.displayName || '').trim(); + updates.displayName = nextDisplayName || null; + } if (body.displayIcon !== undefined) updates.displayIcon = body.displayIcon; - if (body.modelPattern !== undefined) { + if (routeMode === 'explicit_group') { + nextModelPattern = nextDisplayName; + updates.modelPattern = nextModelPattern; + if (body.sourceRouteIds !== undefined) { + nextSourceRouteIds = normalizeSourceRouteIdsInput(body.sourceRouteIds); + } + if (!nextDisplayName) { + return reply.code(400).send({ success: false, message: '显式群组必须填写对外模型名' }); + } + const validation = await validateExplicitGroupSourceRoutes(nextSourceRouteIds, id); + if (!validation.ok) { + return reply.code(400).send({ success: false, message: validation.message }); + } + } else if (body.modelPattern !== undefined) { nextModelPattern = String(body.modelPattern); updates.modelPattern = nextModelPattern; } if (body.modelMapping !== undefined) updates.modelMapping = body.modelMapping; + if (body.routingStrategy !== undefined) { + nextRoutingStrategy = normalizeRouteRoutingStrategy(body.routingStrategy); + updates.routingStrategy = nextRoutingStrategy; + } if (body.enabled !== undefined) updates.enabled = body.enabled; + if (body.routeMode !== undefined) updates.routeMode = routeMode; updates.updatedAt = new Date().toISOString(); await db.update(schema.tokenRoutes).set(updates).where(eq(schema.tokenRoutes.id, id)).run(); - const modelPatternChanged = body.modelPattern !== undefined && nextModelPattern !== existingRoute.modelPattern; - const routeBehaviorChanged = modelPatternChanged || body.modelMapping !== undefined || body.enabled !== undefined; - if (modelPatternChanged) { + if (routeMode === 'explicit_group' && body.sourceRouteIds !== undefined) { + await replaceRouteSourceRouteIds(id, nextSourceRouteIds); + } + const shouldSyncExplicitGroupSources = ( + routeMode === 'explicit_group' + && (body.routingStrategy !== undefined || body.sourceRouteIds !== undefined) + ); + let syncedSourceRouteIds: number[] = []; + if (shouldSyncExplicitGroupSources) { + syncedSourceRouteIds = await syncExplicitGroupSourceRouteStrategies({ + groupRouteId: id, + sourceRouteIds: nextSourceRouteIds, + targetStrategy: nextRoutingStrategy, + previousStrategy: previousRoutingStrategy, + }); + } + const modelPatternChanged = nextModelPattern !== existingRoute.modelPattern; + const routeBehaviorChanged = modelPatternChanged + || (routeMode === 'explicit_group' && body.sourceRouteIds !== undefined) + || body.modelMapping !== undefined + || body.routingStrategy !== undefined + || body.enabled !== undefined; + if (routeMode === 'pattern' && modelPatternChanged) { await rebuildAutomaticRouteChannelsByModelPattern(id, nextModelPattern); } if (routeBehaviorChanged) { await clearRouteDecisionSnapshot(id); + await clearDependentExplicitGroupSnapshotsBySourceRouteIds([id]); + } + if (syncedSourceRouteIds.length > 0) { + await clearRouteDecisionSnapshots(syncedSourceRouteIds); + await clearDependentExplicitGroupSnapshotsBySourceRouteIds(syncedSourceRouteIds); } invalidateTokenRouterCache(); - return await db.select().from(schema.tokenRoutes).where(eq(schema.tokenRoutes.id, id)).get(); + return await getRouteWithSources(id); }); // Delete a route - app.delete<{ Params: { id: string } }>('/api/routes/:id', async (request) => { - const id = parseInt(request.params.id, 10); - await db.delete(schema.tokenRoutes).where(eq(schema.tokenRoutes.id, id)).run(); - invalidateTokenRouterCache(); - return { success: true }; - }); + app.delete<{ Params: { id: string } }>('/api/routes/:id', async (request) => { + const id = parseInt(request.params.id, 10); + await clearDependentExplicitGroupSnapshotsBySourceRouteIds([id]); + await db.delete(schema.tokenRoutes).where(eq(schema.tokenRoutes.id, id)).run(); + invalidateTokenRouterCache(); + return { success: true }; + }); + + // Batch update routes (enable/disable) + app.post<{ Body: { ids: number[]; action: 'enable' | 'disable' } }>('/api/routes/batch', async (request, reply) => { + const body = request.body; + if (!body || typeof body !== 'object') { + return reply.code(400).send({ success: false, message: '请求体必须是对象' }); + } + const action = body.action; + if (action !== 'enable' && action !== 'disable') { + return reply.code(400).send({ success: false, message: 'action 必须是 enable 或 disable' }); + } + const rawIds = body.ids; + if (!Array.isArray(rawIds) || rawIds.length === 0) { + return reply.code(400).send({ success: false, message: 'ids 必须是非空数组' }); + } + const dedupe = new Set(); + const ids: number[] = []; + for (const raw of rawIds) { + if (typeof raw !== 'number' || !Number.isFinite(raw)) continue; + const id = Math.trunc(raw); + if (id <= 0 || dedupe.has(id)) continue; + dedupe.add(id); + ids.push(id); + if (ids.length >= 500) break; + } + if (ids.length === 0) { + return reply.code(400).send({ success: false, message: 'ids 中没有有效的路由 ID' }); + } + + const enabled = action === 'enable'; + const now = new Date().toISOString(); + const updateResult = await db.update(schema.tokenRoutes) + .set({ enabled, updatedAt: now }) + .where(inArray(schema.tokenRoutes.id, ids)) + .run(); + + await clearRouteDecisionSnapshots(ids); + await clearDependentExplicitGroupSnapshotsBySourceRouteIds(ids); + invalidateTokenRouterCache(); + + return { success: true, updatedCount: Number(updateResult?.changes || 0) }; + }); // Add a channel to a route - app.post<{ Params: { id: string }; Body: { accountId: number; tokenId?: number; sourceModel?: string; priority?: number; weight?: number } }>('/api/routes/:id/channels', async (request, reply) => { - const routeId = parseInt(request.params.id, 10); - const body = request.body; - - const route = await db.select().from(schema.tokenRoutes).where(eq(schema.tokenRoutes.id, routeId)).get(); - if (!route) { - return reply.code(404).send({ success: false, message: '路由不存在' }); - } + app.post<{ Params: { id: string }; Body: { accountId: number; tokenId?: number; sourceModel?: string; priority?: number; weight?: number } }>('/api/routes/:id/channels', async (request, reply) => { + const routeId = parseInt(request.params.id, 10); + const body = request.body; + + const route = await getRouteWithSources(routeId); + if (!route) { + return reply.code(404).send({ success: false, message: '路由不存在' }); + } + if (isExplicitGroupRoute(route)) { + return reply.code(400).send({ success: false, message: '显式群组不支持直接维护通道' }); + } const sourceModel = typeof body.sourceModel === 'string' ? body.sourceModel.trim() : (isExactModelPattern(route.modelPattern) ? route.modelPattern.trim() : ''); - const effectiveTokenId = body.tokenId ?? await getDefaultTokenId(body.accountId); + const effectiveTokenId = body.tokenId ?? await getDefaultTokenId(body.accountId); - if (body.tokenId && !await checkTokenBelongsToAccount(body.tokenId, body.accountId)) { + if (body.tokenId && !await checkTokenBelongsToAccount(body.tokenId, body.accountId)) { return reply.code(400).send({ success: false, message: '令牌不存在或不属于当前账号' }); } - if (isExactModelPattern(route.modelPattern) && effectiveTokenId && !await tokenSupportsModel(effectiveTokenId, route.modelPattern)) { + if (isExactModelPattern(route.modelPattern) && effectiveTokenId && !await tokenSupportsModel(effectiveTokenId, route.modelPattern)) { return reply.code(400).send({ success: false, message: '该令牌不支持当前模型' }); } @@ -622,23 +1191,24 @@ export async function tokensRoutes(app: FastifyInstance) { return reply.code(400).send({ success: false, message: '该来源模型的通道已存在' }); } - const insertedChannel = await db.insert(schema.routeChannels).values({ - routeId, - accountId: body.accountId, - tokenId: body.tokenId, - sourceModel: sourceModel || null, - priority: body.priority ?? 0, - weight: body.weight ?? 10, - }).run(); - const channelId = Number(insertedChannel.lastInsertRowid || 0); - if (channelId <= 0) { - return reply.code(500).send({ success: false, message: '创建通道失败' }); - } - const created = await db.select().from(schema.routeChannels).where(eq(schema.routeChannels.id, channelId)).get(); - if (!created) { - return reply.code(500).send({ success: false, message: '创建通道失败' }); + const insertedChannel = await db.insert(schema.routeChannels).values({ + routeId, + accountId: body.accountId, + tokenId: body.tokenId, + sourceModel: sourceModel || null, + priority: body.priority ?? 0, + weight: body.weight ?? 10, + }).run(); + const channelId = Number(insertedChannel.lastInsertRowid || 0); + if (channelId <= 0) { + return reply.code(500).send({ success: false, message: '创建通道失败' }); + } + const created = await db.select().from(schema.routeChannels).where(eq(schema.routeChannels.id, channelId)).get(); + if (!created) { + return reply.code(500).send({ success: false, message: '创建通道失败' }); } await clearRouteDecisionSnapshot(routeId); + await clearDependentExplicitGroupSnapshotsBySourceRouteIds([routeId]); invalidateTokenRouterCache(); return created; }); @@ -671,6 +1241,7 @@ export async function tokensRoutes(app: FastifyInstance) { .where(inArray(schema.routeChannels.id, channelIds)) .all(); await clearRouteDecisionSnapshots(existingChannels.map((channel) => channel.routeId)); + await clearDependentExplicitGroupSnapshotsBySourceRouteIds(existingChannels.map((channel) => channel.routeId)); invalidateTokenRouterCache(); return { success: true, channels: updatedChannels }; }); @@ -692,16 +1263,16 @@ export async function tokensRoutes(app: FastifyInstance) { if (body.tokenId !== undefined && body.tokenId !== null) { const tokenId = Number(body.tokenId); - if (!Number.isFinite(tokenId) || !await checkTokenBelongsToAccount(tokenId, channel.accountId)) { + if (!Number.isFinite(tokenId) || !await checkTokenBelongsToAccount(tokenId, channel.accountId)) { return reply.code(400).send({ success: false, message: '令牌不存在或不属于通道账号' }); } } - const nextTokenId = body.tokenId === undefined - ? (channel.tokenId ?? await getDefaultTokenId(channel.accountId)) - : (body.tokenId === null ? await getDefaultTokenId(channel.accountId) : Number(body.tokenId)); + const nextTokenId = body.tokenId === undefined + ? (channel.tokenId ?? await getDefaultTokenId(channel.accountId)) + : (body.tokenId === null ? await getDefaultTokenId(channel.accountId) : Number(body.tokenId)); - if (isExactModelPattern(route.modelPattern) && nextTokenId && !await tokenSupportsModel(nextTokenId, route.modelPattern)) { + if (isExactModelPattern(route.modelPattern) && nextTokenId && !await tokenSupportsModel(nextTokenId, route.modelPattern)) { return reply.code(400).send({ success: false, message: '该令牌不支持当前模型' }); } @@ -714,20 +1285,22 @@ export async function tokensRoutes(app: FastifyInstance) { for (const key of ['priority', 'weight', 'enabled', 'tokenId']) { if (body[key] !== undefined) updates[key] = body[key]; } - + await db.update(schema.routeChannels).set(updates).where(eq(schema.routeChannels.id, channelId)).run(); await clearRouteDecisionSnapshot(channel.routeId); + await clearDependentExplicitGroupSnapshotsBySourceRouteIds([channel.routeId]); invalidateTokenRouterCache(); return await db.select().from(schema.routeChannels).where(eq(schema.routeChannels.id, channelId)).get(); }); - - // Delete a channel - app.delete<{ Params: { channelId: string } }>('/api/channels/:channelId', async (request) => { - const channelId = parseInt(request.params.channelId, 10); - const channel = await db.select().from(schema.routeChannels).where(eq(schema.routeChannels.id, channelId)).get(); + + // Delete a channel + app.delete<{ Params: { channelId: string } }>('/api/channels/:channelId', async (request) => { + const channelId = parseInt(request.params.channelId, 10); + const channel = await db.select().from(schema.routeChannels).where(eq(schema.routeChannels.id, channelId)).get(); await db.delete(schema.routeChannels).where(eq(schema.routeChannels.id, channelId)).run(); if (channel) { await clearRouteDecisionSnapshot(channel.routeId); + await clearDependentExplicitGroupSnapshotsBySourceRouteIds([channel.routeId]); } invalidateTokenRouterCache(); return { success: true }; @@ -735,14 +1308,14 @@ export async function tokensRoutes(app: FastifyInstance) { // Rebuild routes/channels from model availability. app.post<{ Body?: { refreshModels?: boolean; wait?: boolean } }>('/api/routes/rebuild', async (request, reply) => { - const body = (request.body || {}) as { refreshModels?: boolean }; - if (body.refreshModels === false) { - const rebuild = rebuildTokenRoutesFromAvailability(); - return { success: true, rebuild }; - } + const body = (request.body || {}) as { refreshModels?: boolean }; + if (body.refreshModels === false) { + const rebuild = await routeRefreshWorkflow.rebuildRoutesOnly(); + return { success: true, rebuild }; + } if ((request.body as { wait?: boolean } | undefined)?.wait) { - const result = await refreshModelsAndRebuildRoutes(); + const result = await routeRefreshWorkflow.refreshModelsAndRebuildRoutes(); return { success: true, ...result }; } @@ -759,8 +1332,8 @@ export async function tokensRoutes(app: FastifyInstance) { }, failureMessage: (currentTask) => `刷新模型并重建路由失败:${currentTask.error || 'unknown error'}`, }, - async () => refreshModelsAndRebuildRoutes(), - ); + async () => routeRefreshWorkflow.refreshModelsAndRebuildRoutes(), + ); return reply.code(202).send({ success: true, diff --git a/src/server/routes/proxy/architecture-boundaries.test.ts b/src/server/routes/proxy/architecture-boundaries.test.ts index 903109c5..3d0be227 100644 --- a/src/server/routes/proxy/architecture-boundaries.test.ts +++ b/src/server/routes/proxy/architecture-boundaries.test.ts @@ -8,12 +8,15 @@ function readSource(relativePath: string): string { describe('proxy route architecture boundaries', () => { it('keeps shared protocol helpers out of chat route', () => { const source = readSource('./chat.ts'); + const surfaceSource = readSource('../../proxy-core/surfaces/chatSurface.ts'); + expect(source).toContain("from '../../proxy-core/surfaces/chatSurface.js'"); expect(source).not.toContain("from './chatFormats.js'"); - expect(source).toContain("from '../../transformers/openai/chat/index.js'"); - expect(source).toContain("from '../../transformers/anthropic/messages/index.js'"); + expect(surfaceSource).toContain("from '../../transformers/openai/chat/index.js'"); + expect(surfaceSource).toContain("from '../../transformers/anthropic/messages/index.js'"); }); it('keeps anthropic-specific stream orchestration out of chat route', () => { const source = readSource('./chat.ts'); + const surfaceSource = readSource('../../proxy-core/surfaces/chatSurface.ts'); expect(source).not.toContain('serializeAnthropicRawSseEvent'); expect(source).not.toContain('syncAnthropicRawStreamStateFromEvent'); expect(source).not.toContain('isAnthropicRawSseEventName'); @@ -25,13 +28,30 @@ describe('proxy route architecture boundaries', () => { expect(source).not.toContain('const promoteResponsesCandidate ='); expect(source).not.toContain('shouldRetryClaudeMessagesWithNormalizedBody('); expect(source).not.toContain('buildOpenAiSyntheticFinalStream('); - expect(source).toContain('anthropicMessagesTransformer.consumeSseEventBlock('); - expect(source).toContain('anthropicMessagesTransformer.serializeUpstreamFinalAsStream('); - expect(source).toContain('openAiChatTransformer.serializeUpstreamFinalAsStream('); + expect(source).not.toContain('anthropicMessagesTransformer.consumeSseEventBlock('); + expect(source).not.toContain('anthropicMessagesTransformer.serializeUpstreamFinalAsStream('); + expect(source).not.toContain('openAiChatTransformer.serializeUpstreamFinalAsStream('); + expect(surfaceSource).toContain('openAiChatTransformer.proxyStream.createSession('); + expect(surfaceSource).toContain('streamSession.consumeUpstreamFinalPayload('); + expect(surfaceSource).toContain('streamSession.run('); + }); + + it('keeps chat endpoint retry and downgrade strategy out of the route', () => { + const source = readSource('./chat.ts'); + const surfaceSource = readSource('../../proxy-core/surfaces/chatSurface.ts'); + expect(surfaceSource).toContain('downstreamTransformer.compatibility.createEndpointStrategy('); + expect(source).not.toContain('anthropicMessagesTransformer.compatibility.shouldRetryNormalizedBody('); + expect(source).not.toContain('buildMinimalJsonHeadersForCompatibility('); + expect(source).not.toContain('promoteResponsesCandidateAfterLegacyChatError('); + expect(source).not.toContain('isEndpointDowngradeError('); + expect(source).not.toContain('isEndpointDispatchDeniedError('); + expect(source).not.toContain('isUnsupportedMediaTypeError('); }); it('keeps responses protocol assembly out of responses route', () => { const source = readSource('./responses.ts'); + const surfaceSource = readSource('../../proxy-core/surfaces/openAiResponsesSurface.ts'); + expect(source).toContain("from '../../proxy-core/surfaces/openAiResponsesSurface.js'"); expect(source).not.toContain('function toResponsesPayload('); expect(source).not.toContain('function createResponsesStreamState('); expect(source).not.toContain("from '../../transformers/openai/responses/conversion.js'"); @@ -43,15 +63,27 @@ describe('proxy route architecture boundaries', () => { expect(source).not.toContain("from './protocolCompat.js'"); expect(source).not.toContain('function shouldDowngradeFromChatToMessagesForResponses('); expect(source).not.toContain('function normalizeText('); - expect(source).toContain('openAiResponsesTransformer.inbound.toOpenAiBody('); - expect(source).toContain('openAiResponsesTransformer.compatibility.buildRetryBodies('); - expect(source).toContain('openAiResponsesTransformer.compatibility.buildRetryHeaders('); - expect(source).toContain('openAiResponsesTransformer.compatibility.shouldRetry('); - expect(source).toContain('openAiResponsesTransformer.compatibility.shouldDowngradeChatToMessages('); - expect(source).toContain('openAiResponsesTransformer.aggregator.createState('); - expect(source).toContain('openAiResponsesTransformer.aggregator.serialize('); - expect(source).toContain('openAiResponsesTransformer.aggregator.complete('); - expect(source).toContain('openAiResponsesTransformer.outbound.serializeFinal('); + expect(surfaceSource).toContain('openAiResponsesTransformer.inbound.toOpenAiBody('); + expect(surfaceSource).toContain('openAiResponsesTransformer.compatibility.createEndpointStrategy('); + expect(surfaceSource).not.toContain('openAiResponsesTransformer.aggregator.createState('); + expect(surfaceSource).not.toContain('openAiResponsesTransformer.aggregator.serialize('); + expect(surfaceSource).not.toContain('openAiResponsesTransformer.aggregator.complete('); + expect(surfaceSource).toContain('openAiResponsesTransformer.proxyStream.createSession('); + expect(surfaceSource).toContain('streamSession.run('); + expect(surfaceSource).toContain('openAiResponsesTransformer.outbound.serializeFinal('); + }); + + it('keeps responses endpoint retry and downgrade strategy out of the route', () => { + const source = readSource('./responses.ts'); + const surfaceSource = readSource('../../proxy-core/surfaces/openAiResponsesSurface.ts'); + expect(surfaceSource).toContain('openAiResponsesTransformer.compatibility.createEndpointStrategy('); + expect(source).not.toContain('openAiResponsesTransformer.compatibility.shouldRetry('); + expect(source).not.toContain('openAiResponsesTransformer.compatibility.buildRetryBodies('); + expect(source).not.toContain('openAiResponsesTransformer.compatibility.buildRetryHeaders('); + expect(source).not.toContain('openAiResponsesTransformer.compatibility.shouldDowngradeChatToMessages('); + expect(source).not.toContain('buildMinimalJsonHeadersForCompatibility('); + expect(source).not.toContain('isEndpointDowngradeError('); + expect(source).not.toContain('isUnsupportedMediaTypeError('); }); it('removes normalizeContentText from upstream endpoint routing', () => { @@ -60,16 +92,91 @@ describe('proxy route architecture boundaries', () => { expect(source).not.toContain('normalizeContentText('); }); + it('keeps codex runtime header and prompt-cache derivation inside provider profiles', () => { + const source = readSource('./upstreamEndpoint.ts'); + expect(source).not.toContain('buildCodexRuntimeHeaders('); + expect(source).not.toContain('shouldInjectDerivedPromptCacheKey'); + }); + it('keeps gemini runtime closure in transformer-owned helpers', () => { const source = readSource('./gemini.ts'); + const surfaceSource = readSource('../../proxy-core/surfaces/geminiSurface.ts'); + expect(source).toContain("from '../../proxy-core/surfaces/geminiSurface.js'"); expect(source).not.toContain('outbound.serializeAggregateResponse('); expect(source).not.toContain('aggregator.apply('); expect(source).not.toContain('stream.serializeAggregateSsePayload('); expect(source).not.toContain('stream.serializeAggregateJsonPayload('); expect(source).not.toContain('stream.applyJsonPayloadToAggregate('); expect(source).not.toContain('stream.parseSsePayloads('); - expect(source).toContain('stream.consumeUpstreamSseBuffer('); - expect(source).toContain('stream.serializeUpstreamJsonPayload('); + expect(surfaceSource).toContain('stream.consumeUpstreamSseBuffer('); + expect(surfaceSource).toContain('stream.serializeUpstreamJsonPayload('); + }); + + it('keeps proxy file persistence out of files route', () => { + const source = readSource('./files.ts'); + expect(source).toContain("from '../../proxy-core/surfaces/filesSurface.js'"); + expect(source).not.toContain('saveProxyFile('); + expect(source).not.toContain('listProxyFilesByOwner('); + expect(source).not.toContain('getProxyFileByPublicIdForOwner('); + expect(source).not.toContain('getProxyFileContentByPublicIdForOwner('); + expect(source).not.toContain('softDeleteProxyFileByPublicIdForOwner('); + }); + + it('keeps chat stream lifecycle behind transformer-owned facade', () => { + const source = readSource('./chat.ts'); + const surfaceSource = readSource('../../proxy-core/surfaces/chatSurface.ts'); + expect(source).not.toContain("from '../../transformers/shared/protocolLifecycle.js'"); + expect(source).not.toContain('createProxyStreamLifecycle'); + expect(source).not.toContain('let shouldTerminateEarly = false;'); + expect(source).not.toContain('const consumeSseBuffer = (incoming: string): string => {'); + expect(source).not.toContain('writeDone();'); + expect(surfaceSource).toContain('openAiChatTransformer.proxyStream.createSession('); + }); + + it('keeps responses stream lifecycle behind transformer-owned facade', () => { + const source = readSource('./responses.ts'); + const surfaceSource = readSource('../../proxy-core/surfaces/openAiResponsesSurface.ts'); + expect(source).not.toContain("from '../../transformers/shared/protocolLifecycle.js'"); + expect(source).not.toContain('createProxyStreamLifecycle'); + expect(source).not.toContain('const consumeSseBuffer = (incoming: string): string => {'); + expect(source).not.toContain('reply.raw.end();'); + expect(surfaceSource).not.toContain('openAiResponsesTransformer.aggregator.complete('); + expect(surfaceSource).toContain('reply.hijack();'); + expect(surfaceSource).toContain('openAiResponsesTransformer.proxyStream.createSession('); + }); + + it('keeps oauth refresh recovery and success bookkeeping behind shared surface helpers', () => { + const chatSurfaceSource = readSource('../../proxy-core/surfaces/chatSurface.ts'); + const responsesSurfaceSource = readSource('../../proxy-core/surfaces/openAiResponsesSurface.ts'); + + expect(chatSurfaceSource).toContain('trySurfaceOauthRefreshRecovery('); + expect(chatSurfaceSource).toContain('recordSurfaceSuccess('); + expect(chatSurfaceSource).not.toContain('refreshOauthAccessTokenSingleflight('); + expect(chatSurfaceSource).not.toContain('resolveProxyUsageWithSelfLogFallback('); + expect(chatSurfaceSource).not.toContain('resolveProxyLogBilling('); + expect((chatSurfaceSource.match(/bestEffortMetrics:/g) || []).length).toBeGreaterThanOrEqual(2); + + expect(responsesSurfaceSource).toContain('trySurfaceOauthRefreshRecovery('); + expect(responsesSurfaceSource).toContain('recordSurfaceSuccess('); + expect(responsesSurfaceSource).not.toContain('refreshOauthAccessTokenSingleflight('); + expect(responsesSurfaceSource).not.toContain('resolveProxyUsageWithSelfLogFallback('); + expect(responsesSurfaceSource).not.toContain('resolveProxyLogBilling('); + }); + + it('keeps canonical transformer contracts imported from the transformer boundary only', () => { + const chatSource = readSource('./chat.ts'); + const responsesSource = readSource('./responses.ts'); + const geminiSource = readSource('./gemini.ts'); + + expect(chatSource).not.toContain("from '../proxy-core/"); + expect(responsesSource).not.toContain("from '../proxy-core/"); + expect(geminiSource).not.toContain("from '../proxy-core/"); + expect(chatSource).not.toContain("from '../../transformers/contracts.js'"); + expect(responsesSource).not.toContain("from '../../transformers/contracts.js'"); + expect(geminiSource).not.toContain("from '../../transformers/contracts.js'"); + expect(chatSource).not.toContain("from '../../transformers/canonical/"); + expect(responsesSource).not.toContain("from '../../transformers/canonical/"); + expect(geminiSource).not.toContain("from '../../transformers/canonical/"); }); }); diff --git a/src/server/routes/proxy/architecture-semantic-boundaries.test.ts b/src/server/routes/proxy/architecture-semantic-boundaries.test.ts new file mode 100644 index 00000000..01ee46fc --- /dev/null +++ b/src/server/routes/proxy/architecture-semantic-boundaries.test.ts @@ -0,0 +1,24 @@ +import { readFileSync } from 'node:fs'; +import { describe, expect, it } from 'vitest'; + +function readSource(relativePath: string): string { + return readFileSync(new URL(relativePath, import.meta.url), 'utf8'); +} + +describe('proxy route semantic ownership boundaries', () => { + it('keeps chat stream closeout semantics out of the route', () => { + const source = readSource('./chat.ts'); + + expect(source).not.toContain('const finalizeChatStream ='); + expect(source).not.toContain('openAiChatTransformer.buildSyntheticChunks('); + expect(source).not.toContain('openAiChatTransformer.aggregator.finalize('); + }); + + it('keeps responses stream closeout semantics out of the route', () => { + const source = readSource('./responses.ts'); + + expect(source).not.toContain('const finalizeResponsesSse ='); + expect(source).not.toContain("reply.raw.write('data: [DONE]"); + expect(source).not.toContain('successfulUpstreamPath === \'/v1/responses\''); + }); +}); diff --git a/src/server/routes/proxy/chat.codex-oauth.test.ts b/src/server/routes/proxy/chat.codex-oauth.test.ts new file mode 100644 index 00000000..5fe3262c --- /dev/null +++ b/src/server/routes/proxy/chat.codex-oauth.test.ts @@ -0,0 +1,407 @@ +import Fastify, { type FastifyInstance } from 'fastify'; +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; + +const fetchMock = vi.fn(); +const selectChannelMock = vi.fn(); +const selectNextChannelMock = vi.fn(); +const recordSuccessMock = vi.fn(); +const recordFailureMock = vi.fn(); +const refreshModelsAndRebuildRoutesMock = vi.fn(); +const reportProxyAllFailedMock = vi.fn(); +const reportTokenExpiredMock = vi.fn(); +const resolveProxyUsageWithSelfLogFallbackMock = vi.fn(async ({ usage }: any) => ({ + ...usage, + estimatedCostFromQuota: 0, + recoveredFromSelfLog: false, +})); +const refreshOauthAccessTokenSingleflightMock = vi.fn(); +const dbInsertMock = vi.fn((_arg?: any) => ({ + values: () => ({ + run: () => undefined, + }), +})); + +vi.mock('undici', () => ({ + fetch: (...args: unknown[]) => fetchMock(...args), +})); + +vi.mock('../../services/tokenRouter.js', () => ({ + tokenRouter: { + selectChannel: (...args: unknown[]) => selectChannelMock(...args), + selectNextChannel: (...args: unknown[]) => selectNextChannelMock(...args), + recordSuccess: (...args: unknown[]) => recordSuccessMock(...args), + recordFailure: (...args: unknown[]) => recordFailureMock(...args), + }, +})); + +vi.mock('../../services/modelService.js', () => ({ + refreshModelsAndRebuildRoutes: (...args: unknown[]) => refreshModelsAndRebuildRoutesMock(...args), +})); + +vi.mock('../../services/alertService.js', () => ({ + reportProxyAllFailed: (...args: unknown[]) => reportProxyAllFailedMock(...args), + reportTokenExpired: (...args: unknown[]) => reportTokenExpiredMock(...args), +})); + +vi.mock('../../services/alertRules.js', () => ({ + isTokenExpiredError: ({ status }: { status?: number }) => status === 401, +})); + +vi.mock('../../services/modelPricingService.js', () => ({ + estimateProxyCost: async () => 0, + buildProxyBillingDetails: async () => null, + fetchModelPricingCatalog: async () => null, +})); + +vi.mock('../../services/proxyRetryPolicy.js', () => ({ + shouldRetryProxyRequest: () => false, +})); + +vi.mock('../../services/proxyUsageFallbackService.js', () => ({ + resolveProxyUsageWithSelfLogFallback: (arg: any) => resolveProxyUsageWithSelfLogFallbackMock(arg), +})); + +vi.mock('../../services/oauth/refreshSingleflight.js', () => ({ + refreshOauthAccessTokenSingleflight: (...args: unknown[]) => refreshOauthAccessTokenSingleflightMock(...args), +})); + +vi.mock('../../db/index.js', () => ({ + db: { + insert: (arg: any) => dbInsertMock(arg), + }, + hasProxyLogBillingDetailsColumn: async () => false, + hasProxyLogClientColumns: async () => false, + hasProxyLogDownstreamApiKeyIdColumn: async () => false, + schema: { + proxyLogs: {}, + }, +})); + +describe('chat proxy codex oauth compatibility', () => { + let app: FastifyInstance; + + const createSseResponse = (chunks: string[], status = 200) => { + const encoder = new TextEncoder(); + return new Response(new ReadableStream({ + start(controller) { + for (const chunk of chunks) { + controller.enqueue(encoder.encode(chunk)); + } + controller.close(); + }, + }), { + status, + headers: { 'content-type': 'text/event-stream; charset=utf-8' }, + }); + }; + + beforeAll(async () => { + const { chatProxyRoute, claudeMessagesProxyRoute } = await import('./chat.js'); + app = Fastify(); + await app.register(chatProxyRoute); + await app.register(claudeMessagesProxyRoute); + }); + + beforeEach(() => { + fetchMock.mockReset(); + selectChannelMock.mockReset(); + selectNextChannelMock.mockReset(); + recordSuccessMock.mockReset(); + recordFailureMock.mockReset(); + refreshModelsAndRebuildRoutesMock.mockReset(); + reportProxyAllFailedMock.mockReset(); + reportTokenExpiredMock.mockReset(); + resolveProxyUsageWithSelfLogFallbackMock.mockClear(); + refreshOauthAccessTokenSingleflightMock.mockReset(); + dbInsertMock.mockClear(); + + selectChannelMock.mockReturnValue({ + channel: { id: 11, routeId: 22 }, + site: { name: 'codex-site', url: 'https://chatgpt.com/backend-api/codex', platform: 'codex' }, + account: { + id: 33, + username: 'codex-user@example.com', + extraConfig: JSON.stringify({ + credentialMode: 'session', + oauth: { + provider: 'codex', + accountId: 'chatgpt-account-123', + email: 'codex-user@example.com', + planType: 'team', + }, + }), + }, + tokenName: 'default', + tokenValue: 'oauth-access-token', + actualModel: 'gpt-5.4', + }); + selectNextChannelMock.mockReturnValue(null); + refreshOauthAccessTokenSingleflightMock.mockResolvedValue({ + accessToken: 'fresh-access-token', + accountId: 33, + accountKey: 'chatgpt-account-123', + }); + }); + + afterAll(async () => { + await app.close(); + }); + + it('strips codex-unsupported responses fields and aggregates the SSE result for /v1/messages callers', async () => { + fetchMock.mockResolvedValue(createSseResponse([ + 'event: response.created\n', + 'data: {"type":"response.created","response":{"id":"resp_codex_claude","model":"gpt-5.4","created_at":1706000000,"status":"in_progress","output":[]}}\n\n', + 'event: response.output_item.added\n', + 'data: {"type":"response.output_item.added","output_index":0,"item":{"id":"msg_codex_claude","type":"message","role":"assistant","status":"in_progress","content":[]}}\n\n', + 'event: response.output_text.delta\n', + 'data: {"type":"response.output_text.delta","output_index":0,"item_id":"msg_codex_claude","delta":"pong from codex"}\n\n', + 'event: response.completed\n', + 'data: {"type":"response.completed","response":{"id":"resp_codex_claude","model":"gpt-5.4","status":"completed","usage":{"input_tokens":9,"output_tokens":3,"total_tokens":12}}}\n\n', + 'data: [DONE]\n\n', + ])); + + const response = await app.inject({ + method: 'POST', + url: '/v1/messages', + payload: { + model: 'gpt-5.4', + max_tokens: 256, + messages: [{ role: 'user', content: 'hello codex' }], + }, + }); + + expect(response.statusCode).toBe(200); + expect(fetchMock).toHaveBeenCalledTimes(1); + + const [, options] = fetchMock.mock.calls[0] as [string, any]; + const forwardedBody = JSON.parse(options.body); + expect(forwardedBody.stream).toBe(true); + expect(forwardedBody.instructions).toBe(''); + expect(forwardedBody.store).toBe(false); + expect(forwardedBody.parallel_tool_calls).toBeUndefined(); + expect(forwardedBody.include).toBeUndefined(); + expect(forwardedBody.max_output_tokens).toBe(256); + expect(forwardedBody.max_completion_tokens).toBeUndefined(); + + const body = response.json(); + expect(body.type).toBe('message'); + expect(body.role).toBe('assistant'); + expect(body.model).toBe('gpt-5.4'); + expect(body.content?.[0]?.type).toBe('text'); + expect(body.content?.[0]?.text).toContain('pong from codex'); + }); + + it('translates codex SSE into Claude messages stream events instead of leaking raw response events', async () => { + fetchMock.mockResolvedValue(createSseResponse([ + 'event: response.created\n', + 'data: {"type":"response.created","response":{"id":"resp_codex_claude_stream","model":"gpt-5.4","created_at":1706000000,"status":"in_progress","output":[]}}\n\n', + 'event: response.output_item.added\n', + 'data: {"type":"response.output_item.added","output_index":0,"item":{"id":"msg_codex_claude_stream","type":"message","role":"assistant","status":"in_progress","content":[]}}\n\n', + 'event: response.content_part.added\n', + 'data: {"type":"response.content_part.added","output_index":0,"content_index":0,"item_id":"msg_codex_claude_stream","part":{"type":"output_text","text":""}}\n\n', + 'event: response.output_text.delta\n', + 'data: {"type":"response.output_text.delta","output_index":0,"item_id":"msg_codex_claude_stream","content_index":0,"delta":"pong from codex"}\n\n', + 'event: response.content_part.done\n', + 'data: {"type":"response.content_part.done","output_index":0,"content_index":0,"item_id":"msg_codex_claude_stream","part":{"type":"output_text","text":"pong from codex"}}\n\n', + 'event: response.completed\n', + 'data: {"type":"response.completed","response":{"id":"resp_codex_claude_stream","model":"gpt-5.4","status":"completed","usage":{"input_tokens":9,"output_tokens":3,"total_tokens":12}}}\n\n', + 'data: [DONE]\n\n', + ])); + + const response = await app.inject({ + method: 'POST', + url: '/v1/messages', + payload: { + model: 'gpt-5.4', + stream: true, + max_tokens: 256, + messages: [{ role: 'user', content: 'hello codex' }], + }, + }); + + expect(response.statusCode).toBe(200); + expect(response.headers['content-type']).toContain('text/event-stream'); + expect(response.body).toContain('event: message_start'); + expect(response.body).toContain('event: content_block_delta'); + expect(response.body).toContain('pong from codex'); + expect(response.body).not.toContain('event: response.created'); + expect(response.body).not.toContain('"type":"response.output_text.delta"'); + }); + + it('translates codex SSE into OpenAI chat completion chunks instead of leaking raw response events', async () => { + fetchMock.mockResolvedValue(createSseResponse([ + 'event: response.created\n', + 'data: {"type":"response.created","response":{"id":"resp_codex_openai_stream","model":"gpt-5.4","created_at":1706000000,"status":"in_progress","output":[]}}\n\n', + 'event: response.output_item.added\n', + 'data: {"type":"response.output_item.added","output_index":0,"item":{"id":"msg_codex_openai_stream","type":"message","role":"assistant","status":"in_progress","content":[]}}\n\n', + 'event: response.output_text.delta\n', + 'data: {"type":"response.output_text.delta","output_index":0,"item_id":"msg_codex_openai_stream","delta":"pong from codex"}\n\n', + 'event: response.completed\n', + 'data: {"type":"response.completed","response":{"id":"resp_codex_openai_stream","model":"gpt-5.4","status":"completed","usage":{"input_tokens":9,"output_tokens":3,"total_tokens":12}}}\n\n', + 'data: [DONE]\n\n', + ])); + + const response = await app.inject({ + method: 'POST', + url: '/v1/chat/completions', + payload: { + model: 'gpt-5.4', + stream: true, + messages: [{ role: 'user', content: 'hello codex' }], + }, + }); + + expect(response.statusCode).toBe(200); + expect(response.headers['content-type']).toContain('text/event-stream'); + expect(response.body).toContain('"object":"chat.completion.chunk"'); + expect(response.body).toContain('pong from codex'); + expect(response.body).not.toContain('event: response.created'); + expect(response.body).not.toContain('"type":"response.output_text.delta"'); + }); + + it('still translates raw codex SSE into OpenAI chat completion chunks when upstream mislabels the content-type', async () => { + fetchMock.mockResolvedValue(new Response([ + 'event: response.created\n', + 'data: {"type":"response.created","response":{"id":"resp_codex_openai_stream_header_miss","model":"gpt-5.4","created_at":1706000000,"status":"in_progress","output":[]}}\n\n', + 'event: response.output_item.added\n', + 'data: {"type":"response.output_item.added","output_index":0,"item":{"id":"msg_codex_openai_stream_header_miss","type":"message","role":"assistant","status":"in_progress","content":[]}}\n\n', + 'event: response.output_text.delta\n', + 'data: {"type":"response.output_text.delta","output_index":0,"item_id":"msg_codex_openai_stream_header_miss","delta":"pong from codex"}\n\n', + 'event: response.completed\n', + 'data: {"type":"response.completed","response":{"id":"resp_codex_openai_stream_header_miss","model":"gpt-5.4","status":"completed","usage":{"input_tokens":9,"output_tokens":3,"total_tokens":12}}}\n\n', + 'data: [DONE]\n\n', + ].join(''), { + status: 200, + headers: { 'content-type': 'application/json' }, + })); + + const response = await app.inject({ + method: 'POST', + url: '/v1/chat/completions', + payload: { + model: 'gpt-5.4', + stream: true, + messages: [{ role: 'user', content: 'hello codex' }], + }, + }); + + expect(response.statusCode).toBe(200); + expect(response.headers['content-type']).toContain('text/event-stream'); + expect(response.body).toContain('"object":"chat.completion.chunk"'); + expect(response.body).toContain('pong from codex'); + expect(response.body).not.toContain('event: response.created'); + expect(response.body).not.toContain('"type":"response.output_text.delta"'); + }); + + it('retries oauth chat requests with a normalized upstream URL after refresh', async () => { + selectChannelMock.mockReturnValue({ + channel: { id: 11, routeId: 22 }, + site: { name: 'openai-site', url: 'https://gateway.example.com/v1/', platform: 'openai' }, + account: { + id: 33, + username: 'oauth-user@example.com', + extraConfig: JSON.stringify({ + credentialMode: 'session', + oauth: { + provider: 'codex', + accountId: 'chatgpt-account-123', + email: 'oauth-user@example.com', + planType: 'plus', + }, + }), + }, + tokenName: 'default', + tokenValue: 'expired-access-token', + actualModel: 'gpt-4o-mini', + }); + + fetchMock + .mockResolvedValueOnce(new Response(JSON.stringify({ + error: { message: 'expired token', type: 'invalid_request_error' }, + }), { + status: 401, + headers: { 'content-type': 'application/json' }, + })) + .mockResolvedValueOnce(new Response(JSON.stringify({ + id: 'chatcmpl_refreshed', + object: 'chat.completion', + created: 1706000000, + model: 'gpt-4o-mini', + choices: [ + { + index: 0, + message: { role: 'assistant', content: 'ok after refresh' }, + finish_reason: 'stop', + }, + ], + usage: { prompt_tokens: 4, completion_tokens: 2, total_tokens: 6 }, + }), { + status: 200, + headers: { 'content-type': 'application/json' }, + })); + + const response = await app.inject({ + method: 'POST', + url: '/v1/chat/completions', + payload: { + model: 'gpt-4o-mini', + messages: [{ role: 'user', content: 'hello oauth' }], + }, + }); + + expect(response.statusCode).toBe(200); + expect(refreshOauthAccessTokenSingleflightMock).toHaveBeenCalledWith(33); + expect(fetchMock).toHaveBeenCalledTimes(2); + + const [firstUrl, firstOptions] = fetchMock.mock.calls[0] as [string, any]; + const [secondUrl, secondOptions] = fetchMock.mock.calls[1] as [string, any]; + expect(firstUrl).toBe('https://gateway.example.com/v1/responses'); + expect(secondUrl).toBe('https://gateway.example.com/v1/responses'); + expect(firstOptions.headers.Authorization).toBe('Bearer expired-access-token'); + expect(secondOptions.headers.Authorization).toBe('Bearer fresh-access-token'); + expect(response.json()?.choices?.[0]?.message?.content).toBe('ok after refresh'); + }); + + it('retries oauth chat requests after a 403 auth failure', async () => { + fetchMock + .mockResolvedValueOnce(new Response(JSON.stringify({ + error: { message: 'forbidden account mismatch', type: 'invalid_request_error' }, + }), { + status: 403, + headers: { 'content-type': 'application/json' }, + })) + .mockResolvedValueOnce(new Response(JSON.stringify({ + id: 'chatcmpl_refreshed_403', + object: 'chat.completion', + created: 1706000000, + model: 'gpt-4o-mini', + choices: [ + { + index: 0, + message: { role: 'assistant', content: 'ok after forbidden refresh' }, + finish_reason: 'stop', + }, + ], + usage: { prompt_tokens: 4, completion_tokens: 2, total_tokens: 6 }, + }), { + status: 200, + headers: { 'content-type': 'application/json' }, + })); + + const response = await app.inject({ + method: 'POST', + url: '/v1/chat/completions', + payload: { + model: 'gpt-4o-mini', + messages: [{ role: 'user', content: 'hello oauth' }], + }, + }); + + expect(response.statusCode).toBe(200); + expect(refreshOauthAccessTokenSingleflightMock).toHaveBeenCalledWith(33); + expect(fetchMock).toHaveBeenCalledTimes(2); + const [, secondOptions] = fetchMock.mock.calls[1] as [string, any]; + expect(secondOptions.headers.Authorization).toBe('Bearer fresh-access-token'); + expect(response.json()?.choices?.[0]?.message?.content).toBe('ok after forbidden refresh'); + }); +}); diff --git a/src/server/routes/proxy/chat.count-tokens.test.ts b/src/server/routes/proxy/chat.count-tokens.test.ts new file mode 100644 index 00000000..9a0754ae --- /dev/null +++ b/src/server/routes/proxy/chat.count-tokens.test.ts @@ -0,0 +1,199 @@ +import Fastify, { type FastifyInstance } from 'fastify'; +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; + +const fetchMock = vi.fn(); +const selectChannelMock = vi.fn(); +const selectNextChannelMock = vi.fn(); +const recordSuccessMock = vi.fn(); +const recordFailureMock = vi.fn(); +const refreshModelsAndRebuildRoutesMock = vi.fn(); +const reportProxyAllFailedMock = vi.fn(); +const reportTokenExpiredMock = vi.fn(); +const resolveProxyUsageWithSelfLogFallbackMock = vi.fn(async ({ usage }: any) => ({ + ...usage, + estimatedCostFromQuota: 0, + recoveredFromSelfLog: false, +})); +const dbInsertMock = vi.fn((_arg?: any) => ({ + values: () => ({ + run: () => undefined, + }), +})); + +vi.mock('undici', () => ({ + fetch: (...args: unknown[]) => fetchMock(...args), +})); + +vi.mock('../../services/tokenRouter.js', () => ({ + tokenRouter: { + selectChannel: (...args: unknown[]) => selectChannelMock(...args), + selectNextChannel: (...args: unknown[]) => selectNextChannelMock(...args), + recordSuccess: (...args: unknown[]) => recordSuccessMock(...args), + recordFailure: (...args: unknown[]) => recordFailureMock(...args), + }, +})); + +vi.mock('../../services/modelService.js', () => ({ + refreshModelsAndRebuildRoutes: (...args: unknown[]) => refreshModelsAndRebuildRoutesMock(...args), +})); + +vi.mock('../../services/alertService.js', () => ({ + reportProxyAllFailed: (...args: unknown[]) => reportProxyAllFailedMock(...args), + reportTokenExpired: (...args: unknown[]) => reportTokenExpiredMock(...args), +})); + +vi.mock('../../services/alertRules.js', () => ({ + isTokenExpiredError: () => false, +})); + +vi.mock('../../services/modelPricingService.js', () => ({ + estimateProxyCost: async () => 0, + buildProxyBillingDetails: async () => null, + fetchModelPricingCatalog: async () => null, +})); + +vi.mock('../../services/proxyRetryPolicy.js', () => ({ + shouldRetryProxyRequest: () => false, +})); + +vi.mock('../../services/proxyUsageFallbackService.js', () => ({ + resolveProxyUsageWithSelfLogFallback: (arg: any) => resolveProxyUsageWithSelfLogFallbackMock(arg), +})); + +vi.mock('../../services/oauth/quota.js', () => ({ + recordOauthQuotaResetHint: async () => undefined, +})); + +vi.mock('../../db/index.js', () => ({ + db: { + insert: (arg: any) => dbInsertMock(arg), + }, + hasProxyLogBillingDetailsColumn: async () => false, + hasProxyLogClientColumns: async () => false, + hasProxyLogDownstreamApiKeyIdColumn: async () => false, + schema: { + proxyLogs: {}, + }, +})); + +describe('claude count_tokens proxy route', () => { + let app: FastifyInstance; + + beforeAll(async () => { + const { claudeMessagesProxyRoute } = await import('./chat.js'); + app = Fastify(); + await app.register(claudeMessagesProxyRoute); + }); + + beforeEach(() => { + fetchMock.mockReset(); + selectChannelMock.mockReset(); + selectNextChannelMock.mockReset(); + recordSuccessMock.mockReset(); + recordFailureMock.mockReset(); + refreshModelsAndRebuildRoutesMock.mockReset(); + reportProxyAllFailedMock.mockReset(); + reportTokenExpiredMock.mockReset(); + resolveProxyUsageWithSelfLogFallbackMock.mockClear(); + dbInsertMock.mockClear(); + + selectChannelMock.mockReturnValue({ + channel: { id: 11, routeId: 22 }, + site: { name: 'claude-site', url: 'https://api.anthropic.com', platform: 'claude' }, + account: { id: 33, username: 'claude-user@example.com' }, + tokenName: 'default', + tokenValue: 'sk-claude', + actualModel: 'claude-opus-4-6', + }); + selectNextChannelMock.mockReturnValue(null); + }); + + afterAll(async () => { + await app.close(); + }); + + it('forwards /v1/messages/count_tokens to the claude count_tokens upstream path', async () => { + fetchMock.mockResolvedValue(new Response(JSON.stringify({ + input_tokens: 42, + }), { + status: 200, + headers: { 'content-type': 'application/json' }, + })); + + const response = await app.inject({ + method: 'POST', + url: '/v1/messages/count_tokens', + payload: { + model: 'claude-opus-4-6', + tools: [ + { name: 'lookup', input_schema: { type: 'object' } }, + ], + messages: [ + { + role: 'user', + content: [{ type: 'text', text: 'count these tokens' }], + }, + ], + }, + }); + + expect(response.statusCode).toBe(200); + expect(response.json()).toEqual({ input_tokens: 42 }); + expect(fetchMock).toHaveBeenCalledTimes(1); + + const [targetUrl, options] = fetchMock.mock.calls[0] as [string, any]; + expect(targetUrl).toBe('https://api.anthropic.com/v1/messages/count_tokens?beta=true'); + expect(options.headers['anthropic-version']).toBe('2023-06-01'); + expect(options.headers['anthropic-beta']).toContain('claude-code-20250219'); + expect(options.headers['Accept-Encoding']).toBe('gzip, deflate, br, zstd'); + + const forwardedBody = JSON.parse(String(options.body)); + expect(forwardedBody.model).toBe('claude-opus-4-6'); + expect(forwardedBody.messages).toEqual([ + { + role: 'user', + content: [{ type: 'text', text: 'count these tokens', cache_control: { type: 'ephemeral' } }], + }, + ]); + }); + + it('supports /v1/messages/count_tokens for openai-platform gateways that expose Claude messages endpoints', async () => { + selectChannelMock.mockReturnValue({ + channel: { id: 12, routeId: 23 }, + site: { name: 'gateway-site', url: 'https://gateway.example.com', platform: 'openai' }, + account: { id: 34, username: 'gateway-user@example.com' }, + tokenName: 'default', + tokenValue: 'sk-gateway', + actualModel: 'claude-sonnet-4-5-20250929', + }); + fetchMock.mockResolvedValue(new Response(JSON.stringify({ + input_tokens: 9, + }), { + status: 200, + headers: { 'content-type': 'application/json' }, + })); + + const response = await app.inject({ + method: 'POST', + url: '/v1/messages/count_tokens', + payload: { + model: 'claude-sonnet-4-5-20250929', + messages: [ + { + role: 'user', + content: [{ type: 'text', text: 'count through a compatible gateway' }], + }, + ], + }, + }); + + expect(response.statusCode).toBe(200); + expect(response.json()).toEqual({ input_tokens: 9 }); + expect(fetchMock).toHaveBeenCalledTimes(1); + + const [targetUrl, options] = fetchMock.mock.calls[0] as [string, any]; + expect(targetUrl).toBe('https://gateway.example.com/v1/messages/count_tokens?beta=true'); + expect(options.headers['x-api-key']).toBe('sk-gateway'); + expect(options.headers['anthropic-version']).toBe('2023-06-01'); + }); +}); diff --git a/src/server/routes/proxy/chat.stream.test.ts b/src/server/routes/proxy/chat.stream.test.ts index 930cf7b1..244316ac 100644 --- a/src/server/routes/proxy/chat.stream.test.ts +++ b/src/server/routes/proxy/chat.stream.test.ts @@ -1,5 +1,8 @@ +import { zstdCompressSync } from 'node:zlib'; import Fastify, { type FastifyInstance } from 'fastify'; import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { config } from '../../config.js'; +import { resetUpstreamEndpointRuntimeState } from './upstreamEndpoint.js'; const fetchMock = vi.fn(); const selectChannelMock = vi.fn(); @@ -67,6 +70,9 @@ vi.mock('../../db/index.js', () => ({ db: { insert: (arg: any) => dbInsertMock(arg), }, + hasProxyLogBillingDetailsColumn: async () => false, + hasProxyLogClientColumns: async () => false, + hasProxyLogDownstreamApiKeyIdColumn: async () => false, schema: { proxyLogs: {}, }, @@ -98,6 +104,7 @@ describe('chat proxy stream behavior', () => { fetchModelPricingCatalogMock.mockReset(); resolveProxyUsageWithSelfLogFallbackMock.mockClear(); dbInsertMock.mockClear(); + resetUpstreamEndpointRuntimeState(); selectChannelMock.mockReturnValue({ channel: { id: 11, routeId: 22 }, @@ -109,6 +116,19 @@ describe('chat proxy stream behavior', () => { }); selectNextChannelMock.mockReturnValue(null); fetchModelPricingCatalogMock.mockResolvedValue(null); + (config as any).codexHeaderDefaults = { + userAgent: '', + betaFeatures: '', + }; + (config as any).payloadRules = { + default: [], + defaultRaw: [], + override: [], + overrideRaw: [], + filter: [], + }; + config.proxyEmptyContentFailEnabled = false; + config.proxyErrorKeywords = []; }); afterAll(async () => { @@ -150,6 +170,150 @@ describe('chat proxy stream behavior', () => { expect(response.body).toContain('data: [DONE]'); }); + it('decodes zstd-compressed non-stream chat responses before serializing downstream JSON', async () => { + const payload = JSON.stringify({ + id: 'chatcmpl-zstd', + object: 'chat.completion', + created: 1_706_000_000, + model: 'upstream-gpt', + choices: [{ + index: 0, + message: { role: 'assistant', content: '你好,来自 zstd 非流式响应' }, + finish_reason: 'stop', + }], + usage: { prompt_tokens: 11, completion_tokens: 7, total_tokens: 18 }, + }); + fetchMock.mockResolvedValue(new Response(zstdCompressSync(Buffer.from(payload)), { + status: 200, + headers: { + 'content-encoding': 'zstd', + 'content-type': 'application/json; charset=utf-8', + }, + })); + + const response = await app.inject({ + method: 'POST', + url: '/v1/chat/completions', + payload: { + model: 'gpt-4o-mini', + messages: [{ role: 'user', content: 'hi' }], + }, + }); + + expect(response.statusCode).toBe(200); + expect(response.json()?.choices?.[0]?.message?.content).toBe('你好,来自 zstd 非流式响应'); + }); + + it('decodes zstd-compressed non-SSE streaming chat responses before SSE conversion', async () => { + const payload = JSON.stringify({ + id: 'chatcmpl-zstd-stream', + object: 'chat.completion', + created: 1_706_000_000, + model: 'upstream-gpt', + choices: [{ + index: 0, + message: { role: 'assistant', content: '你好,来自 zstd 流式回退' }, + finish_reason: 'stop', + }], + usage: { prompt_tokens: 11, completion_tokens: 7, total_tokens: 18 }, + }); + fetchMock.mockResolvedValue(new Response(zstdCompressSync(Buffer.from(payload)), { + status: 200, + headers: { + 'content-encoding': 'zstd', + 'content-type': 'application/json; charset=utf-8', + }, + })); + + const response = await app.inject({ + method: 'POST', + url: '/v1/chat/completions', + payload: { + model: 'gpt-4o-mini', + stream: true, + messages: [{ role: 'user', content: 'hi' }], + }, + }); + + expect(response.statusCode).toBe(200); + expect(response.headers['content-type']).toContain('text/event-stream'); + expect(response.body).toContain('"chat.completion.chunk"'); + expect(response.body).toContain('你好,来自 zstd 流式回退'); + expect(response.body).not.toContain('(�/�'); + expect(response.body).toContain('data: [DONE]'); + }); + + it('returns upstream_error for empty non-stream chat responses when empty-content failure is enabled', async () => { + config.proxyEmptyContentFailEnabled = true; + + fetchMock.mockResolvedValue(new Response(JSON.stringify({ + id: 'chatcmpl-empty', + object: 'chat.completion', + created: 1_706_000_000, + model: 'upstream-gpt', + choices: [{ + index: 0, + message: { role: 'assistant', content: '' }, + finish_reason: 'stop', + }], + usage: { prompt_tokens: 6, completion_tokens: 0, total_tokens: 6 }, + }), { + status: 200, + headers: { 'content-type': 'application/json' }, + })); + + const response = await app.inject({ + method: 'POST', + url: '/v1/chat/completions', + payload: { + model: 'gpt-4o-mini', + messages: [{ role: 'user', content: 'hi' }], + }, + }); + + expect(response.statusCode).toBe(502); + expect(response.json()?.error?.type).toBe('upstream_error'); + expect(response.json()?.error?.message).toContain('empty content'); + expect(recordSuccessMock).not.toHaveBeenCalled(); + expect(recordFailureMock).toHaveBeenCalledTimes(1); + }); + + it('returns HTTP upstream_error instead of hijacking when streamed chat requests receive empty non-SSE payloads', async () => { + config.proxyEmptyContentFailEnabled = true; + + fetchMock.mockResolvedValue(new Response(JSON.stringify({ + id: 'chatcmpl-empty-stream', + object: 'chat.completion', + created: 1_706_000_000, + model: 'upstream-gpt', + choices: [{ + index: 0, + message: { role: 'assistant', content: '' }, + finish_reason: 'stop', + }], + usage: { prompt_tokens: 4, completion_tokens: 0, total_tokens: 4 }, + }), { + status: 200, + headers: { 'content-type': 'application/json' }, + })); + + const response = await app.inject({ + method: 'POST', + url: '/v1/chat/completions', + payload: { + model: 'gpt-4o-mini', + stream: true, + messages: [{ role: 'user', content: 'hi' }], + }, + }); + + expect(response.statusCode).toBe(502); + expect(response.headers['content-type']).not.toContain('text/event-stream'); + expect(response.json()?.error?.type).toBe('upstream_error'); + expect(recordSuccessMock).not.toHaveBeenCalled(); + expect(recordFailureMock).toHaveBeenCalledTimes(1); + }); + it('returns clear 400 when /v1/chat/completions receives responses-style input without messages', async () => { const response = await app.inject({ method: 'POST', @@ -201,6 +365,117 @@ describe('chat proxy stream behavior', () => { expect(response.body).toContain('data: [DONE]'); }); + it('normalizes inline think tags into reasoning_content for /v1/chat/completions streams', async () => { + const encoder = new TextEncoder(); + const upstreamBody = new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode('data: {"id":"chatcmpl-think","model":"upstream-gpt","choices":[{"delta":{"role":"assistant"},"finish_reason":null}]}\n\n')); + controller.enqueue(encoder.encode('data: {"id":"chatcmpl-think","model":"upstream-gpt","choices":[{"delta":{"content":"plan quietly"},"finish_reason":null}]}\n\n')); + controller.enqueue(encoder.encode('data: {"id":"chatcmpl-think","model":"upstream-gpt","choices":[{"delta":{"content":"visible answer"},"finish_reason":null}]}\n\n')); + controller.enqueue(encoder.encode('data: {"id":"chatcmpl-think","model":"upstream-gpt","choices":[{"delta":{},"finish_reason":"stop"}]}\n\n')); + controller.enqueue(encoder.encode('data: [DONE]\n\n')); + controller.close(); + }, + }); + + fetchMock.mockResolvedValue(new Response(upstreamBody, { + status: 200, + headers: { 'content-type': 'text/event-stream; charset=utf-8' }, + })); + + const response = await app.inject({ + method: 'POST', + url: '/v1/chat/completions', + payload: { + model: 'gpt-4o-mini', + stream: true, + messages: [{ role: 'user', content: 'show your work and answer' }], + }, + }); + + expect(response.statusCode).toBe(200); + expect(response.body).toContain('"reasoning_content":"plan quietly"'); + expect(response.body).toContain('"content":"visible answer"'); + expect(response.body).not.toContain(''); + expect(response.body).not.toContain(''); + expect(response.body).toContain('data: [DONE]'); + }); + + it('tracks split inline think tags across SSE chunks for /v1/chat/completions streams', async () => { + const encoder = new TextEncoder(); + const upstreamBody = new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode('data: {"id":"chatcmpl-think-split","model":"upstream-gpt","choices":[{"delta":{"role":"assistant"},"finish_reason":null}]}\n\n')); + controller.enqueue(encoder.encode('data: {"id":"chatcmpl-think-split","model":"upstream-gpt","choices":[{"delta":{"content":"plan "},"finish_reason":null}]}\n\n')); + controller.enqueue(encoder.encode('data: {"id":"chatcmpl-think-split","model":"upstream-gpt","choices":[{"delta":{"content":"quietlyvisible "},"finish_reason":null}]}\n\n')); + controller.enqueue(encoder.encode('data: {"id":"chatcmpl-think-split","model":"upstream-gpt","choices":[{"delta":{"content":"answer"},"finish_reason":null}]}\n\n')); + controller.enqueue(encoder.encode('data: {"id":"chatcmpl-think-split","model":"upstream-gpt","choices":[{"delta":{},"finish_reason":"stop"}]}\n\n')); + controller.enqueue(encoder.encode('data: [DONE]\n\n')); + controller.close(); + }, + }); + + fetchMock.mockResolvedValue(new Response(upstreamBody, { + status: 200, + headers: { 'content-type': 'text/event-stream; charset=utf-8' }, + })); + + const response = await app.inject({ + method: 'POST', + url: '/v1/chat/completions', + payload: { + model: 'gpt-4o-mini', + stream: true, + messages: [{ role: 'user', content: 'show your work and answer' }], + }, + }); + + expect(response.statusCode).toBe(200); + expect(response.body).toContain('"reasoning_content":"plan "'); + expect(response.body).toContain('"reasoning_content":"quietly"'); + expect(response.body).toContain('"content":"visible "'); + expect(response.body).toContain('"content":"answer"'); + expect(response.body).not.toContain(''); + expect(response.body).not.toContain(''); + expect(response.body).not.toContain('visible'); + expect(response.body).toContain('data: [DONE]'); + }); + + it('synthesizes a terminal finish chunk when /v1/chat/completions upstream EOFs after visible content', async () => { + const encoder = new TextEncoder(); + const upstreamBody = new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode('data: {"id":"chatcmpl-eof","model":"upstream-gpt","choices":[{"delta":{"role":"assistant"},"finish_reason":null}]}\n\n')); + controller.enqueue(encoder.encode('data: {"id":"chatcmpl-eof","model":"upstream-gpt","choices":[{"delta":{"content":"tail before eof"},"finish_reason":null}]}\n\n')); + controller.close(); + }, + }); + + fetchMock.mockResolvedValue(new Response(upstreamBody, { + status: 200, + headers: { 'content-type': 'text/event-stream; charset=utf-8' }, + })); + + const response = await app.inject({ + method: 'POST', + url: '/v1/chat/completions', + payload: { + model: 'gpt-4o-mini', + stream: true, + messages: [{ role: 'user', content: 'finish cleanly' }], + }, + }); + + expect(response.statusCode).toBe(200); + expect(response.body).toContain('tail before eof'); + expect(response.body).toContain('"finish_reason":"stop"'); + expect(response.body).toContain('data: [DONE]'); + }); + it('normalizes anthropic-style SSE events into OpenAI chunks for clients like OpenWebUI', async () => { const encoder = new TextEncoder(); const upstreamBody = new ReadableStream({ @@ -443,7 +718,8 @@ describe('chat proxy stream behavior', () => { expect(response.statusCode).toBe(200); const [_targetUrl, options] = fetchMock.mock.calls[0] as [string, any]; - expect(options.headers['anthropic-beta']).toBe('code-2025-09-30'); + expect(options.headers['anthropic-beta']).toContain('claude-code-20250219'); + expect(options.headers['anthropic-beta']).toContain('code-2025-09-30'); expect(options.headers['x-claude-client']).toBe('claude-code'); const forwardedBody = JSON.parse(options.body); @@ -486,6 +762,40 @@ describe('chat proxy stream behavior', () => { expect(response.body).not.toContain('event: ping'); }); + it('does not synthesize message_stop when anthropic upstream EOFs before terminal event on /v1/messages', async () => { + const encoder = new TextEncoder(); + const upstreamBody = new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode('event: message_start\ndata: {"type":"message_start","message":{"id":"msg_eof_early","model":"claude-opus-4-6"}}\n\n')); + controller.enqueue(encoder.encode('event: content_block_start\ndata: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}\n\n')); + controller.enqueue(encoder.encode('event: content_block_delta\ndata: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"hello"}}\n\n')); + controller.close(); + }, + }); + + fetchMock.mockResolvedValue(new Response(upstreamBody, { + status: 200, + headers: { 'content-type': 'text/event-stream; charset=utf-8' }, + })); + + const response = await app.inject({ + method: 'POST', + url: '/v1/messages', + payload: { + model: 'claude-opus-4-6', + stream: true, + max_tokens: 256, + messages: [{ role: 'user', content: 'hello' }], + }, + }); + + expect(response.statusCode).toBe(200); + expect(response.body).toContain('event: message_start'); + expect(response.body).toContain('event: content_block_delta'); + expect(response.body).not.toContain('event: message_stop'); + expect(response.body).not.toContain('"stop_reason":"end_turn"'); + }); + it('normalizes Claude thinking adaptive type for legacy upstreams on /v1/messages', async () => { fetchMock.mockResolvedValue(new Response(JSON.stringify({ id: 'msg_headers_adaptive', @@ -839,7 +1149,7 @@ describe('chat proxy stream behavior', () => { expect(body.output_text).toContain('ok from messages fallback'); }); - it('passes through /v1/responses SSE payloads', async () => { + it('canonicalizes native /v1/responses SSE payloads instead of passing them through raw', async () => { const encoder = new TextEncoder(); const upstreamBody = new ReadableStream({ start(controller) { @@ -867,6 +1177,9 @@ describe('chat proxy stream behavior', () => { expect(response.statusCode).toBe(200); expect(response.headers['content-type']).toContain('text/event-stream'); expect(response.body).toContain('response.output_text.delta'); + expect(response.body).toContain('response.output_text.done'); + expect(response.body).toContain('response.output_item.done'); + expect(response.body).toContain('response.completed'); expect(response.body).toContain('[DONE]'); }); @@ -915,7 +1228,7 @@ describe('chat proxy stream behavior', () => { expect(response.body).toContain('[DONE]'); }); - it('converts chat tool_calls SSE to Responses function_call events on /v1/responses', async () => { + it('replays downgraded chat-completions SSE for websocket transport without requiring native responses terminals', async () => { fetchModelPricingCatalogMock.mockResolvedValue({ models: [ { @@ -929,10 +1242,9 @@ describe('chat proxy stream behavior', () => { const encoder = new TextEncoder(); const upstreamBody = new ReadableStream({ start(controller) { - controller.enqueue(encoder.encode('data: {"id":"chatcmpl-r2","model":"upstream-gpt","choices":[{"delta":{"role":"assistant"},"finish_reason":null}]}\n\n')); - controller.enqueue(encoder.encode('data: {"id":"chatcmpl-r2","model":"upstream-gpt","choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_abc","type":"function","function":{"name":"Glob","arguments":""}}]},"finish_reason":null}]}\n\n')); - controller.enqueue(encoder.encode('data: {"id":"chatcmpl-r2","model":"upstream-gpt","choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\\"pattern\\":\\"README*\\"}"}}]},"finish_reason":"tool_calls"}]}\n\n')); - controller.enqueue(encoder.encode('data: [DONE]\n\n')); + controller.enqueue(encoder.encode('data: {"id":"chatcmpl-r1-ws","model":"upstream-gpt","choices":[{"delta":{"role":"assistant"},"finish_reason":null}]}\n\n')); + controller.enqueue(encoder.encode('data: {"id":"chatcmpl-r1-ws","model":"upstream-gpt","choices":[{"delta":{"content":"hello from fallback"},"finish_reason":null}]}\n\n')); + controller.enqueue(encoder.encode('data: {"id":"chatcmpl-r1-ws","model":"upstream-gpt","choices":[{"delta":{},"finish_reason":"stop"}]}\n\n')); controller.close(); }, }); @@ -945,22 +1257,26 @@ describe('chat proxy stream behavior', () => { const response = await app.inject({ method: 'POST', url: '/v1/responses', + headers: { + 'x-metapi-responses-websocket-transport': '1', + }, payload: { model: 'gpt-5.2', - input: 'find readme', + input: 'hello', stream: true, }, }); expect(response.statusCode).toBe(200); - expect(response.body).toContain('"type":"function_call"'); - expect(response.body).toContain('response.function_call_arguments.delta'); - expect(response.body).toContain('"name":"Glob"'); - expect(response.body).toContain('"delta":"{\\"pattern\\":\\"README*\\"}"'); + expect(response.headers['content-type']).toContain('text/event-stream'); + expect(response.body).toContain('response.output_item.added'); + expect(response.body).toContain('response.output_text.delta'); expect(response.body).toContain('response.completed'); + expect(response.body).not.toContain('response.failed'); + expect(response.body).toContain('[DONE]'); }); - it('emits response.failed without synthetic response.completed when upstream stream fails on /v1/responses', async () => { + it('initializes reasoning items before emitting reasoning summary deltas on /v1/responses streams', async () => { fetchModelPricingCatalogMock.mockResolvedValue({ models: [ { @@ -974,8 +1290,9 @@ describe('chat proxy stream behavior', () => { const encoder = new TextEncoder(); const upstreamBody = new ReadableStream({ start(controller) { - controller.enqueue(encoder.encode('data: {"id":"chatcmpl-r3","model":"upstream-gpt","choices":[{"delta":{"role":"assistant"},"finish_reason":null}]}\n\n')); - controller.enqueue(encoder.encode('event: error\ndata: {"type":"error","error":{"message":"upstream stream failed","type":"upstream_error"}}\n\n')); + controller.enqueue(encoder.encode('data: {"id":"chatcmpl-r1-reasoning","model":"upstream-gpt","choices":[{"delta":{"role":"assistant"},"finish_reason":null}]}\n\n')); + controller.enqueue(encoder.encode('data: {"id":"chatcmpl-r1-reasoning","model":"upstream-gpt","choices":[{"delta":{"reasoning_content":"plan first"},"finish_reason":null}]}\n\n')); + controller.enqueue(encoder.encode('data: {"id":"chatcmpl-r1-reasoning","model":"upstream-gpt","choices":[{"delta":{},"finish_reason":"stop"}]}\n\n')); controller.enqueue(encoder.encode('data: [DONE]\n\n')); controller.close(); }, @@ -997,23 +1314,125 @@ describe('chat proxy stream behavior', () => { }); expect(response.statusCode).toBe(200); - expect(response.body).toContain('response.failed'); - expect(response.body).toContain('"status":"failed"'); - expect(response.body).not.toContain('response.completed'); - expect(response.body).toContain('[DONE]'); + expect(response.headers['content-type']).toContain('text/event-stream'); + expect(response.body).toContain('event: response.output_item.added'); + expect(response.body).toContain('event: response.reasoning_summary_part.added'); + expect(response.body).toContain('event: response.reasoning_summary_text.delta'); + const eventBlocks = response.body.split('\n\n').filter((block) => block.trim().length > 0); + const reasoningItemAddedIndex = eventBlocks.findIndex( + (block) => block.includes('event: response.output_item.added') && block.includes('"type":"reasoning"'), + ); + const reasoningSummaryPartAddedIndex = eventBlocks.findIndex( + (block) => block.includes('event: response.reasoning_summary_part.added'), + ); + const reasoningSummaryTextDeltaIndex = eventBlocks.findIndex( + (block) => block.includes('event: response.reasoning_summary_text.delta'), + ); + + expect(reasoningItemAddedIndex).toBeGreaterThanOrEqual(0); + expect(reasoningItemAddedIndex).toBeLessThan(reasoningSummaryPartAddedIndex); + expect(reasoningSummaryPartAddedIndex).toBeLessThan(reasoningSummaryTextDeltaIndex); }); - it('preserves Responses-specific payload fields and forwards openai headers on /v1/responses', async () => { - fetchMock.mockResolvedValue(new Response(JSON.stringify({ - id: 'resp_passthrough', - object: 'response', - status: 'completed', - model: 'upstream-gpt', - output_text: 'ok', - output: [], - }), { - status: 200, - headers: { 'content-type': 'application/json' }, + it('converts chat tool_calls SSE to Responses function_call events on /v1/responses', async () => { + fetchModelPricingCatalogMock.mockResolvedValue({ + models: [ + { + modelName: 'upstream-gpt', + supportedEndpointTypes: ['/v1/chat/completions'], + }, + ], + groupRatio: {}, + }); + + const encoder = new TextEncoder(); + const upstreamBody = new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode('data: {"id":"chatcmpl-r2","model":"upstream-gpt","choices":[{"delta":{"role":"assistant"},"finish_reason":null}]}\n\n')); + controller.enqueue(encoder.encode('data: {"id":"chatcmpl-r2","model":"upstream-gpt","choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_abc","type":"function","function":{"name":"Glob","arguments":""}}]},"finish_reason":null}]}\n\n')); + controller.enqueue(encoder.encode('data: {"id":"chatcmpl-r2","model":"upstream-gpt","choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\\"pattern\\":\\"README*\\"}"}}]},"finish_reason":"tool_calls"}]}\n\n')); + controller.enqueue(encoder.encode('data: [DONE]\n\n')); + controller.close(); + }, + }); + + fetchMock.mockResolvedValue(new Response(upstreamBody, { + status: 200, + headers: { 'content-type': 'text/event-stream; charset=utf-8' }, + })); + + const response = await app.inject({ + method: 'POST', + url: '/v1/responses', + payload: { + model: 'gpt-5.2', + input: 'find readme', + stream: true, + }, + }); + + expect(response.statusCode).toBe(200); + expect(response.body).toContain('"type":"function_call"'); + expect(response.body).toContain('response.function_call_arguments.delta'); + expect(response.body).toContain('"name":"Glob"'); + expect(response.body).toContain('"delta":"{\\"pattern\\":\\"README*\\"}"'); + expect(response.body).toContain('response.completed'); + }); + + it('emits response.failed without synthetic response.completed when upstream stream fails on /v1/responses', async () => { + fetchModelPricingCatalogMock.mockResolvedValue({ + models: [ + { + modelName: 'upstream-gpt', + supportedEndpointTypes: ['/v1/chat/completions'], + }, + ], + groupRatio: {}, + }); + + const encoder = new TextEncoder(); + const upstreamBody = new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode('data: {"id":"chatcmpl-r3","model":"upstream-gpt","choices":[{"delta":{"role":"assistant"},"finish_reason":null}]}\n\n')); + controller.enqueue(encoder.encode('event: error\ndata: {"type":"error","error":{"message":"upstream stream failed","type":"upstream_error"}}\n\n')); + controller.enqueue(encoder.encode('data: [DONE]\n\n')); + controller.close(); + }, + }); + + fetchMock.mockResolvedValue(new Response(upstreamBody, { + status: 200, + headers: { 'content-type': 'text/event-stream; charset=utf-8' }, + })); + + const response = await app.inject({ + method: 'POST', + url: '/v1/responses', + payload: { + model: 'gpt-5.2', + input: 'hello', + stream: true, + }, + }); + + expect(response.statusCode).toBe(200); + expect(response.body).toContain('response.failed'); + expect(response.body).toContain('"status":"failed"'); + expect(response.body).not.toContain('response.completed'); + expect(response.body).toContain('[DONE]'); + }); + + it('preserves Responses-specific payload fields and forwards openai headers on /v1/responses', async () => { + fetchMock.mockResolvedValue(new Response(JSON.stringify({ + id: 'resp_passthrough', + object: 'response', + status: 'completed', + model: 'upstream-gpt', + output_text: 'ok', + output: [], + }), { + status: 200, + headers: { 'content-type': 'application/json' }, })); const response = await app.inject({ @@ -1236,6 +1655,15 @@ describe('chat proxy stream behavior', () => { status: 400, headers: { 'content-type': 'application/json' }, })) + .mockResolvedValueOnce(new Response(JSON.stringify({ + error: { + message: 'request validation failed', + type: 'invalid_request_error', + }, + }), { + status: 400, + headers: { 'content-type': 'application/json' }, + })) .mockResolvedValueOnce(new Response(JSON.stringify({ id: 'resp_retry_minimal_headers', object: 'response', @@ -1265,21 +1693,27 @@ describe('chat proxy stream behavior', () => { const body = response.json(); expect(body.output_text).toContain('ok after minimal headers retry'); - expect(fetchMock).toHaveBeenCalledTimes(3); + expect(fetchMock).toHaveBeenCalledTimes(4); const [, firstOptions] = fetchMock.mock.calls[0] as [string, any]; const [, secondOptions] = fetchMock.mock.calls[1] as [string, any]; const [, thirdOptions] = fetchMock.mock.calls[2] as [string, any]; + const [, fourthOptions] = fetchMock.mock.calls[3] as [string, any]; expect(firstOptions.headers['openai-beta']).toBe('responses-2025-03-11'); expect(secondOptions.headers['openai-beta']).toBe('responses-2025-03-11'); - expect(thirdOptions.headers['openai-beta']).toBeUndefined(); + expect(thirdOptions.headers['openai-beta']).toBe('responses-2025-03-11'); + expect(fourthOptions.headers['openai-beta']).toBeUndefined(); const firstBody = JSON.parse(firstOptions.body); const secondBody = JSON.parse(secondOptions.body); const thirdBody = JSON.parse(thirdOptions.body); + const fourthBody = JSON.parse(fourthOptions.body); expect(firstBody.user).toBe('user-123'); expect(secondBody.user).toBeUndefined(); expect(thirdBody.user).toBeUndefined(); + expect(secondBody.include).toEqual(['reasoning.encrypted_content']); + expect(thirdBody.include).toBeUndefined(); + expect(fourthBody.user).toBeUndefined(); }); it('returns concise Cloudflare host error on /v1/responses 502 html failures', async () => { @@ -1524,7 +1958,7 @@ describe('chat proxy stream behavior', () => { expect(deltaMatches.length).toBe(1); const textMatches = response.body.match(/I'm Claude, an AI assistant made by Anthropic\./g) || []; expect(textMatches.length).toBeGreaterThan(0); - expect(textMatches.length).toBeLessThanOrEqual(4); + expect(textMatches.length).toBeLessThanOrEqual(6); }); it('deduplicates overlapping text windows when /v1/responses is converted from /v1/messages stream', async () => { @@ -1667,6 +2101,7 @@ describe('chat proxy stream behavior', () => { expect(forwarded.tool_choice?.function?.name).toBe('Glob'); }); + it('preserves function_call/function_call_output when /v1/responses falls back to /v1/messages', async () => { fetchModelPricingCatalogMock.mockResolvedValue({ models: [ @@ -1779,6 +2214,87 @@ describe('chat proxy stream behavior', () => { expect(targetUrl).toContain('/v1/messages'); }); + it('does not stick generic /v1/responses traffic to /v1/messages after a fallback success', async () => { + selectChannelMock.mockReturnValue({ + channel: { id: 11, routeId: 22 }, + site: { name: 'generic-site', url: 'https://upstream.example.com', platform: 'new-api' }, + account: { id: 33, username: 'demo-user' }, + tokenName: 'default', + tokenValue: 'sk-demo', + actualModel: 'upstream-gpt', + }); + + fetchMock + .mockResolvedValueOnce(new Response(JSON.stringify({ + error: { message: 'Gateway time-out', type: 'upstream_error' }, + }), { + status: 504, + headers: { 'content-type': 'application/json' }, + })) + .mockResolvedValueOnce(new Response(JSON.stringify({ + error: { message: 'Bad gateway', type: 'upstream_error' }, + }), { + status: 502, + headers: { 'content-type': 'application/json' }, + })) + .mockResolvedValueOnce(new Response(JSON.stringify({ + id: 'msg_fallback_1', + type: 'message', + model: 'upstream-gpt', + content: [{ type: 'text', text: 'ok via messages fallback' }], + stop_reason: 'end_turn', + usage: { input_tokens: 7, output_tokens: 3, total_tokens: 10 }, + }), { + status: 200, + headers: { 'content-type': 'application/json' }, + })) + .mockResolvedValueOnce(new Response(JSON.stringify({ + id: 'resp_recovered_1', + object: 'response', + model: 'upstream-gpt', + status: 'completed', + output_text: 'ok via recovered responses', + usage: { input_tokens: 6, output_tokens: 2, total_tokens: 8 }, + }), { + status: 200, + headers: { 'content-type': 'application/json' }, + })); + + const firstResponse = await app.inject({ + method: 'POST', + url: '/v1/responses', + payload: { + model: 'gpt-5.4', + input: 'hello', + }, + }); + + expect(firstResponse.statusCode).toBe(200); + expect(firstResponse.json().output_text).toContain('ok via messages fallback'); + + const secondResponse = await app.inject({ + method: 'POST', + url: '/v1/responses', + payload: { + model: 'gpt-5.4', + input: 'hello again', + }, + }); + + expect(secondResponse.statusCode).toBe(200); + expect(secondResponse.json().output_text).toContain('ok via recovered responses'); + expect(fetchMock).toHaveBeenCalledTimes(4); + + const [firstUrl] = fetchMock.mock.calls[0] as [string, any]; + const [secondUrl] = fetchMock.mock.calls[1] as [string, any]; + const [thirdUrl] = fetchMock.mock.calls[2] as [string, any]; + const [fourthUrl] = fetchMock.mock.calls[3] as [string, any]; + expect(firstUrl).toContain('/v1/responses'); + expect(secondUrl).toContain('/v1/chat/completions'); + expect(thirdUrl).toContain('/v1/messages'); + expect(fourthUrl).toContain('/v1/responses'); + }); + it('prefers native /v1/responses for claude-family /v1/responses requests that explicitly ask for encrypted reasoning', async () => { selectChannelMock.mockReturnValue({ channel: { id: 11, routeId: 22 }, @@ -1826,6 +2342,295 @@ describe('chat proxy stream behavior', () => { expect(targetUrl).toContain('/v1/responses'); }); + it('returns upstream_error for empty non-stream /v1/responses payloads when empty-content failure is enabled', async () => { + config.proxyEmptyContentFailEnabled = true; + + fetchMock.mockResolvedValue(new Response(JSON.stringify({ + id: 'resp-empty', + object: 'response', + model: 'gpt-5.4', + status: 'completed', + output: [], + output_text: '', + usage: { input_tokens: 3, output_tokens: 0, total_tokens: 3 }, + }), { + status: 200, + headers: { 'content-type': 'application/json' }, + })); + + const response = await app.inject({ + method: 'POST', + url: '/v1/responses', + payload: { + model: 'gpt-5.4', + input: 'hello', + }, + }); + + expect(response.statusCode).toBe(502); + expect(response.json()?.error?.type).toBe('upstream_error'); + expect(response.json()?.error?.message).toContain('empty content'); + expect(recordSuccessMock).not.toHaveBeenCalled(); + expect(recordFailureMock).toHaveBeenCalledTimes(1); + }); + + it('returns HTTP upstream_error instead of hijacking when streamed /v1/responses receives empty non-SSE payloads', async () => { + config.proxyEmptyContentFailEnabled = true; + + fetchMock.mockResolvedValue(new Response(JSON.stringify({ + id: 'resp-empty-stream', + object: 'response', + model: 'gpt-5.4', + status: 'completed', + output: [], + output_text: '', + usage: { input_tokens: 2, output_tokens: 0, total_tokens: 2 }, + }), { + status: 200, + headers: { 'content-type': 'application/json' }, + })); + + const response = await app.inject({ + method: 'POST', + url: '/v1/responses', + payload: { + model: 'gpt-5.4', + input: 'hello', + stream: true, + }, + }); + + expect(response.statusCode).toBe(502); + expect(response.headers['content-type']).not.toContain('text/event-stream'); + expect(response.json()?.error?.type).toBe('upstream_error'); + expect(recordSuccessMock).not.toHaveBeenCalled(); + expect(recordFailureMock).toHaveBeenCalledTimes(1); + }); + + it('prefers native /v1/responses for claude-family /v1/responses requests that include input_file file_url', async () => { + selectChannelMock.mockReturnValue({ + channel: { id: 11, routeId: 22 }, + site: { name: 'generic-site', url: 'https://upstream.example.com', platform: 'new-api' }, + account: { id: 33, username: 'demo-user' }, + tokenName: 'default', + tokenValue: 'sk-demo', + actualModel: 'upstream-gpt', + }); + + fetchMock.mockResolvedValue(new Response(JSON.stringify({ + id: 'resp_file_url_1', + object: 'response', + model: 'upstream-gpt', + output_text: 'hello from responses upstream', + output: [ + { + id: 'msg_file_url_1', + type: 'message', + role: 'assistant', + status: 'completed', + content: [{ type: 'output_text', text: 'hello from responses upstream' }], + }, + ], + status: 'completed', + usage: { input_tokens: 7, output_tokens: 3, total_tokens: 10 }, + }), { + status: 200, + headers: { 'content-type': 'application/json' }, + })); + + const response = await app.inject({ + method: 'POST', + url: '/v1/responses', + payload: { + model: 'claude-haiku-4-5-20251001', + input: [ + { + type: 'message', + role: 'user', + content: [ + { type: 'input_text', text: 'read this remote file' }, + { + type: 'input_file', + filename: 'remote.pdf', + file_url: 'https://example.com/remote.pdf', + }, + ], + }, + ], + }, + }); + + expect(response.statusCode).toBe(200); + expect(fetchMock).toHaveBeenCalledTimes(1); + const [targetUrl, options] = fetchMock.mock.calls[0] as [string, any]; + expect(targetUrl).toContain('/v1/responses'); + const forwardedBody = JSON.parse(options.body); + expect(forwardedBody.input[0].content[1]).toEqual({ + type: 'input_file', + filename: 'remote.pdf', + file_url: 'https://example.com/remote.pdf', + }); + }); + + it('converts input_file file_url into Claude document url blocks for claude-only upstreams', async () => { + selectChannelMock.mockReturnValue({ + channel: { id: 11, routeId: 22 }, + site: { name: 'claude-site', url: 'https://upstream.example.com', platform: 'claude' }, + account: { id: 33, username: 'demo-user' }, + tokenName: 'default', + tokenValue: 'sk-demo', + actualModel: 'upstream-claude', + }); + fetchMock.mockResolvedValue(new Response(JSON.stringify({ + id: 'msg_file_url_1', + type: 'message', + role: 'assistant', + model: 'upstream-claude', + content: [{ type: 'text', text: 'hello from claude messages' }], + stop_reason: 'end_turn', + usage: { input_tokens: 7, output_tokens: 3 }, + }), { + status: 200, + headers: { 'content-type': 'application/json' }, + })); + + const response = await app.inject({ + method: 'POST', + url: '/v1/responses', + payload: { + model: 'claude-haiku-4-5-20251001', + input: [ + { + type: 'message', + role: 'user', + content: [ + { type: 'input_text', text: 'read this remote file' }, + { + type: 'input_file', + filename: 'remote.pdf', + file_url: 'https://example.com/remote.pdf', + }, + ], + }, + ], + }, + }); + + expect(response.statusCode).toBe(200); + expect(fetchMock).toHaveBeenCalledTimes(1); + const [targetUrl, options] = fetchMock.mock.calls[0] as [string, any]; + expect(targetUrl).toContain('/v1/messages'); + const forwardedBody = JSON.parse(options.body); + expect(forwardedBody.messages[0].content[1]).toMatchObject({ + type: 'document', + title: 'remote.pdf', + source: { + type: 'url', + url: 'https://example.com/remote.pdf', + }, + }); + }); + + it('does not let remote document url success poison later inline document endpoint preference', async () => { + selectChannelMock.mockReturnValue({ + channel: { id: 11, routeId: 22 }, + site: { name: 'generic-site', url: 'https://upstream.example.com', platform: 'new-api' }, + account: { id: 33, username: 'demo-user' }, + tokenName: 'default', + tokenValue: 'sk-demo', + actualModel: 'upstream-gpt', + }); + fetchMock.mockImplementation(async (target: unknown) => { + const url = String(target); + if (url.includes('/v1/responses')) { + return new Response(JSON.stringify({ + id: 'resp_file_url_runtime_1', + object: 'response', + model: 'upstream-gpt', + output_text: 'hello from responses upstream', + output: [ + { + id: 'msg_file_url_runtime_1', + type: 'message', + role: 'assistant', + content: [{ type: 'output_text', text: 'hello from responses upstream' }], + }, + ], + status: 'completed', + usage: { input_tokens: 7, output_tokens: 3, total_tokens: 10 }, + }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + } + if (url.includes('/v1/messages')) { + return new Response(JSON.stringify({ + id: 'msg_inline_runtime_1', + type: 'message', + role: 'assistant', + model: 'upstream-gpt', + content: [{ type: 'text', text: 'hello from messages upstream' }], + stop_reason: 'end_turn', + usage: { input_tokens: 7, output_tokens: 3 }, + }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + } + throw new Error(`unexpected target url: ${url}`); + }); + + const remoteResponse = await app.inject({ + method: 'POST', + url: '/v1/responses', + payload: { + model: 'claude-haiku-4-5-20251001', + input: [ + { + type: 'message', + role: 'user', + content: [ + { type: 'input_text', text: 'read this remote file' }, + { + type: 'input_file', + filename: 'remote.pdf', + file_url: 'https://example.com/remote.pdf', + }, + ], + }, + ], + }, + }); + + expect(remoteResponse.statusCode).toBe(200); + expect(String(fetchMock.mock.calls[0]?.[0])).toContain('/v1/responses'); + + const inlineResponse = await app.inject({ + method: 'POST', + url: '/v1/responses', + payload: { + model: 'claude-haiku-4-5-20251001', + input: [ + { + type: 'message', + role: 'user', + content: [ + { type: 'input_text', text: 'read this inline file' }, + { + type: 'input_file', + filename: 'brief.pdf', + file_data: 'data:application/pdf;base64,JVBERi0xLjQK', + }, + ], + }, + ], + }, + }); + + expect(inlineResponse.statusCode).toBe(200); + expect(String(fetchMock.mock.calls[1]?.[0])).toContain('/v1/messages'); + }); + it('prefers native /v1/responses for claude-family /v1/responses requests that opt into reasoning without injecting a generic default include', async () => { selectChannelMock.mockReturnValue({ channel: { id: 11, routeId: 22 }, @@ -2566,6 +3371,87 @@ describe('chat proxy stream behavior', () => { expect(toolMessage.content).toContain('matches'); }); + it('maps claude tool config and thinking budget before routing claude downstream requests to openai chat endpoint', async () => { + selectChannelMock.mockReturnValue({ + channel: { id: 11, routeId: 22 }, + site: { name: 'openai-site', url: 'https://api.openai.com', platform: 'openai' }, + account: { id: 33, username: 'demo-user' }, + tokenName: 'default', + tokenValue: 'sk-openai', + actualModel: 'gpt-4o-mini', + }); + + fetchMock.mockResolvedValue(new Response(JSON.stringify({ + id: 'chatcmpl-openai-config-mapped', + object: 'chat.completion', + created: 1_706_000_007, + model: 'gpt-4o-mini', + choices: [{ + index: 0, + message: { role: 'assistant', content: 'tool config mapped' }, + finish_reason: 'stop', + }], + usage: { prompt_tokens: 9, completion_tokens: 3, total_tokens: 12 }, + }), { + status: 200, + headers: { 'content-type': 'application/json' }, + })); + + const response = await app.inject({ + method: 'POST', + url: '/v1/messages', + payload: { + model: 'gpt-4o-mini', + max_tokens: 256, + metadata: { user_id: 'user-1' }, + thinking: { type: 'enabled', budget_tokens: 1024 }, + tools: [{ + name: 'Glob', + description: 'Search files', + input_schema: { + type: 'object', + properties: { + pattern: { type: 'string' }, + }, + required: ['pattern'], + }, + }], + tool_choice: { + type: 'tool', + name: 'Glob', + }, + messages: [{ role: 'user', content: 'hello' }], + }, + }); + + expect(response.statusCode).toBe(200); + + const [_targetUrl, options] = fetchMock.mock.calls[0] as [string, any]; + const forwardedBody = JSON.parse(options.body); + expect(forwardedBody.metadata).toEqual({ user_id: 'user-1' }); + expect(forwardedBody.reasoning_budget).toBe(1024); + expect(forwardedBody.tools).toEqual([{ + type: 'function', + function: { + name: 'Glob', + description: 'Search files', + parameters: { + type: 'object', + properties: { + pattern: { type: 'string' }, + }, + required: ['pattern'], + }, + }, + }]); + expect(forwardedBody.tool_choice).toEqual({ + type: 'function', + function: { + name: 'Glob', + }, + }); + }); + it('forces claude platform to use /v1/messages with x-api-key auth for openai downstream requests', async () => { selectChannelMock.mockReturnValue({ channel: { id: 11, routeId: 22 }, @@ -2940,6 +3826,8 @@ describe('chat proxy stream behavior', () => { expect(response.statusCode).toBe(200); expect(response.body).toContain('"finish_reason":"error"'); expect(response.body).toContain('[DONE]'); + expect(recordSuccessMock).not.toHaveBeenCalled(); + expect(recordFailureMock).toHaveBeenCalledTimes(1); }); it('preserves non-stream function_call output when /v1/chat/completions falls back to /v1/responses', async () => { diff --git a/src/server/routes/proxy/chat.ts b/src/server/routes/proxy/chat.ts index 5b2ccbbb..2e0da688 100644 --- a/src/server/routes/proxy/chat.ts +++ b/src/server/routes/proxy/chat.ts @@ -1,687 +1,17 @@ -import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; -import { tokenRouter } from '../../services/tokenRouter.js'; -import { db, schema } from '../../db/index.js'; -import { fetch } from 'undici'; -import { refreshModelsAndRebuildRoutes } from '../../services/modelService.js'; -import { reportProxyAllFailed, reportTokenExpired } from '../../services/alertService.js'; -import { isTokenExpiredError } from '../../services/alertRules.js'; -import { shouldRetryProxyRequest } from '../../services/proxyRetryPolicy.js'; -import { resolveProxyUsageWithSelfLogFallback } from '../../services/proxyUsageFallbackService.js'; -import { mergeProxyUsage, parseProxyUsage } from '../../services/proxyUsageParser.js'; -import { resolveProxyUrlForSite, withSiteRecordProxyRequestInit } from '../../services/siteProxy.js'; -import { type DownstreamFormat } from '../../transformers/shared/normalized.js'; +import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'; import { - buildMinimalJsonHeadersForCompatibility, - buildUpstreamEndpointRequest, - isEndpointDispatchDeniedError, - isEndpointDowngradeError, - isUnsupportedMediaTypeError, - promoteResponsesCandidateAfterLegacyChatError, - resolveUpstreamEndpointCandidates, -} from './upstreamEndpoint.js'; -import { - ensureModelAllowedForDownstreamKey, - getDownstreamRoutingPolicy, - recordDownstreamCostUsage, -} from './downstreamPolicy.js'; -import { composeProxyLogMessage } from './logPathMeta.js'; -import { executeEndpointFlow, withUpstreamPath } from './endpointFlow.js'; -import { formatUtcSqlDateTime } from '../../services/localTimeService.js'; -import { resolveProxyLogBilling } from './proxyBilling.js'; -import { openAiChatTransformer } from '../../transformers/openai/chat/index.js'; -import { anthropicMessagesTransformer } from '../../transformers/anthropic/messages/index.js'; -import { getProxyResourceOwner } from '../../middleware/auth.js'; -import { - ProxyInputFileResolutionError, - hasNonImageFileInputInOpenAiBody, - resolveOpenAiBodyInputFiles, -} from '../../services/proxyInputFileResolver.js'; - -const MAX_RETRIES = 2; - -function isRecord(value: unknown): value is Record { - return !!value && typeof value === 'object'; -} + handleChatSurfaceRequest, + handleClaudeCountTokensSurfaceRequest, +} from '../../proxy-core/surfaces/chatSurface.js'; export async function chatProxyRoute(app: FastifyInstance) { app.post('/v1/chat/completions', async (request: FastifyRequest, reply: FastifyReply) => - handleChatProxyRequest(request, reply, 'openai')); + handleChatSurfaceRequest(request, reply, 'openai')); } export async function claudeMessagesProxyRoute(app: FastifyInstance) { app.post('/v1/messages', async (request: FastifyRequest, reply: FastifyReply) => - handleChatProxyRequest(request, reply, 'claude')); -} - -async function handleChatProxyRequest( - request: FastifyRequest, - reply: FastifyReply, - downstreamFormat: DownstreamFormat, -) { - const downstreamTransformer = downstreamFormat === 'claude' - ? anthropicMessagesTransformer - : openAiChatTransformer; - const parsedRequest = downstreamTransformer.transformRequest(request.body); - if (parsedRequest.error) { - return reply.code(parsedRequest.error.statusCode).send(parsedRequest.error.payload); - } - - const { requestedModel, isStream, upstreamBody, claudeOriginalBody } = parsedRequest.value!; - const downstreamPath = downstreamFormat === 'claude' ? '/v1/messages' : '/v1/chat/completions'; - if (!await ensureModelAllowedForDownstreamKey(request, reply, requestedModel)) return; - const downstreamPolicy = getDownstreamRoutingPolicy(request); - const owner = getProxyResourceOwner(request); - let resolvedOpenAiBody = upstreamBody; - if (owner) { - try { - resolvedOpenAiBody = await resolveOpenAiBodyInputFiles(upstreamBody, owner); - } catch (error) { - if (error instanceof ProxyInputFileResolutionError) { - return reply.code(error.statusCode).send(error.payload); - } - throw error; - } - } - const hasNonImageFileInput = hasNonImageFileInputInOpenAiBody(resolvedOpenAiBody); - - const excludeChannelIds: number[] = []; - let retryCount = 0; - - while (retryCount <= MAX_RETRIES) { - let selected = retryCount === 0 - ? await tokenRouter.selectChannel(requestedModel, downstreamPolicy) - : await tokenRouter.selectNextChannel(requestedModel, excludeChannelIds, downstreamPolicy); - - if (!selected && retryCount === 0) { - await refreshModelsAndRebuildRoutes(); - selected = await tokenRouter.selectChannel(requestedModel, downstreamPolicy); - } - - if (!selected) { - await reportProxyAllFailed({ - model: requestedModel, - reason: 'No available channels after retries', - }); - return reply.code(503).send({ - error: { message: 'No available channels for this model', type: 'server_error' }, - }); - } - - excludeChannelIds.push(selected.channel.id); - - const modelName = selected.actualModel || requestedModel; - const endpointCandidates = [ - ...await resolveUpstreamEndpointCandidates( - { - site: selected.site, - account: selected.account, - }, - modelName, - downstreamFormat, - requestedModel, - { - hasNonImageFileInput, - }, - ), - ]; - let startTime = Date.now(); - - try { - const endpointResult = await executeEndpointFlow({ - siteUrl: selected.site.url, - proxyUrl: resolveProxyUrlForSite(selected.site), - endpointCandidates, - buildRequest: (endpoint) => { - const endpointRequest = buildUpstreamEndpointRequest({ - endpoint, - modelName, - stream: isStream, - tokenValue: selected.tokenValue, - sitePlatform: selected.site.platform, - siteUrl: selected.site.url, - openaiBody: resolvedOpenAiBody, - downstreamFormat, - claudeOriginalBody, - downstreamHeaders: request.headers as Record, - }); - return { - endpoint, - path: endpointRequest.path, - headers: endpointRequest.headers, - body: endpointRequest.body as Record, - }; - }, - tryRecover: async (ctx) => { - if (anthropicMessagesTransformer.compatibility.shouldRetryNormalizedBody({ - downstreamFormat, - endpointPath: ctx.request.path, - status: ctx.response.status, - upstreamErrorText: ctx.rawErrText, - })) { - const normalizedClaudeRequest = buildUpstreamEndpointRequest({ - endpoint: ctx.request.endpoint, - modelName, - stream: isStream, - tokenValue: selected.tokenValue, - sitePlatform: selected.site.platform, - siteUrl: selected.site.url, - openaiBody: resolvedOpenAiBody, - downstreamFormat, - claudeOriginalBody, - forceNormalizeClaudeBody: true, - downstreamHeaders: request.headers as Record, - }); - const normalizedTargetUrl = `${selected.site.url}${normalizedClaudeRequest.path}`; - const normalizedResponse = await fetch(normalizedTargetUrl, withSiteRecordProxyRequestInit(selected.site, { - method: 'POST', - headers: normalizedClaudeRequest.headers, - body: JSON.stringify(normalizedClaudeRequest.body), - })); - - if (normalizedResponse.ok) { - return { - upstream: normalizedResponse, - upstreamPath: normalizedClaudeRequest.path, - }; - } - - ctx.request = { - ...ctx.request, - path: normalizedClaudeRequest.path, - headers: normalizedClaudeRequest.headers, - body: normalizedClaudeRequest.body as Record, - }; - ctx.response = normalizedResponse; - ctx.rawErrText = await normalizedResponse.text().catch(() => 'unknown error'); - } - - if (!isUnsupportedMediaTypeError(ctx.response.status, ctx.rawErrText)) { - return null; - } - - const minimalHeaders = buildMinimalJsonHeadersForCompatibility({ - headers: ctx.request.headers, - endpoint: ctx.request.endpoint, - stream: isStream, - }); - const normalizedCurrentHeaders = Object.fromEntries( - Object.entries(ctx.request.headers).map(([key, value]) => [key.toLowerCase(), value]), - ); - if (JSON.stringify(minimalHeaders) === JSON.stringify(normalizedCurrentHeaders)) { - return null; - } - - const minimalResponse = await fetch(ctx.targetUrl, withSiteRecordProxyRequestInit(selected.site, { - method: 'POST', - headers: minimalHeaders, - body: JSON.stringify(ctx.request.body), - })); - - if (minimalResponse.ok) { - return { - upstream: minimalResponse, - upstreamPath: ctx.request.path, - }; - } - - ctx.request = { - ...ctx.request, - headers: minimalHeaders, - }; - ctx.response = minimalResponse; - ctx.rawErrText = await minimalResponse.text().catch(() => 'unknown error'); - return null; - }, - shouldDowngrade: (ctx) => ( - (() => { - promoteResponsesCandidateAfterLegacyChatError(endpointCandidates, { - status: ctx.response.status, - upstreamErrorText: ctx.rawErrText, - downstreamFormat, - sitePlatform: selected.site.platform, - modelName, - requestedModelHint: requestedModel, - currentEndpoint: ctx.request.endpoint, - }); - return ( - ctx.response.status >= 500 - || isEndpointDowngradeError(ctx.response.status, ctx.rawErrText) - || anthropicMessagesTransformer.compatibility.isMessagesRequiredError(ctx.rawErrText) - || isEndpointDispatchDeniedError(ctx.response.status, ctx.rawErrText) - ); - })() - ), - onDowngrade: (ctx) => { - logProxy( - selected, - requestedModel, - 'failed', - ctx.response.status, - Date.now() - startTime, - ctx.errText, - retryCount, - downstreamPath, - ); - }, - }); - - if (!endpointResult.ok) { - const status = endpointResult.status || 502; - const errText = endpointResult.errText || 'unknown error'; - tokenRouter.recordFailure(selected.channel.id); - logProxy(selected, requestedModel, 'failed', status, Date.now() - startTime, errText, retryCount, downstreamPath); - - if (isTokenExpiredError({ status, message: errText })) { - await reportTokenExpired({ - accountId: selected.account.id, - username: selected.account.username, - siteName: selected.site.name, - detail: `HTTP ${status}`, - }); - } - - if (shouldRetryProxyRequest(status, errText) && retryCount < MAX_RETRIES) { - retryCount += 1; - continue; - } - - await reportProxyAllFailed({ - model: requestedModel, - reason: `upstream returned HTTP ${status}`, - }); - - return reply.code(status).send({ - error: { message: errText, type: 'upstream_error' }, - }); - } - - const upstream = endpointResult.upstream; - const successfulUpstreamPath = endpointResult.upstreamPath; - - if (isStream) { - reply.hijack(); - reply.raw.statusCode = 200; - reply.raw.setHeader('Content-Type', 'text/event-stream; charset=utf-8'); - reply.raw.setHeader('Cache-Control', 'no-cache, no-transform'); - reply.raw.setHeader('Connection', 'keep-alive'); - reply.raw.setHeader('X-Accel-Buffering', 'no'); - - const streamContext = downstreamTransformer.createStreamContext(modelName); - const claudeContext = anthropicMessagesTransformer.createDownstreamContext(); - let parsedUsage: ReturnType = { - promptTokens: 0, - completionTokens: 0, - totalTokens: 0, - cacheReadTokens: 0, - cacheCreationTokens: 0, - promptTokensIncludeCache: null, - }; - - const writeLines = (lines: string[]) => { - for (const line of lines) { - reply.raw.write(line); - } - }; - - const writeDone = () => { - writeLines(downstreamTransformer.serializeDone(streamContext, claudeContext)); - }; - - const upstreamContentType = (upstream.headers.get('content-type') || '').toLowerCase(); - if (!upstreamContentType.includes('text/event-stream')) { - const fallbackText = await upstream.text(); - let fallbackData: unknown = null; - try { - fallbackData = JSON.parse(fallbackText); - } catch { - fallbackData = fallbackText; - } - - parsedUsage = mergeProxyUsage(parsedUsage, parseProxyUsage(fallbackData)); - if (downstreamFormat === 'openai') { - const syntheticLines = openAiChatTransformer.serializeUpstreamFinalAsStream( - fallbackData, - modelName, - fallbackText, - streamContext, - ); - writeLines(syntheticLines); - } else { - writeLines( - anthropicMessagesTransformer.serializeUpstreamFinalAsStream( - fallbackData, - modelName, - fallbackText, - streamContext, - claudeContext, - ), - ); - } - writeDone(); - reply.raw.end(); - - const latency = Date.now() - startTime; - const resolvedUsage = await resolveProxyUsageWithSelfLogFallback({ - site: selected.site, - account: selected.account, - tokenValue: selected.tokenValue, - tokenName: selected.tokenName, - modelName, - requestStartedAtMs: startTime, - requestEndedAtMs: startTime + latency, - localLatencyMs: latency, - usage: { - promptTokens: parsedUsage.promptTokens, - completionTokens: parsedUsage.completionTokens, - totalTokens: parsedUsage.totalTokens, - }, - }); - - const { estimatedCost, billingDetails } = await resolveProxyLogBilling({ - site: selected.site, - account: selected.account, - modelName, - parsedUsage, - resolvedUsage, - }); - - tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost); - recordDownstreamCostUsage(request, estimatedCost); - logProxy( - selected, - requestedModel, - 'success', - 200, - latency, - null, - retryCount, - downstreamPath, - resolvedUsage.promptTokens, - resolvedUsage.completionTokens, - resolvedUsage.totalTokens, - estimatedCost, - billingDetails, - successfulUpstreamPath, - ); - return; - } - - const reader = upstream.body?.getReader(); - if (!reader) { - writeDone(); - reply.raw.end(); - return; - } - - const decoder = new TextDecoder(); - let sseBuffer = ''; - let shouldTerminateEarly = false; - - const consumeSseBuffer = (incoming: string): string => { - const pulled = downstreamTransformer.pullSseEvents(incoming); - for (const eventBlock of pulled.events) { - if (eventBlock.data === '[DONE]') { - writeDone(); - shouldTerminateEarly = true; - continue; - } - - let parsedPayload: unknown = null; - if (downstreamFormat === 'claude') { - const consumed = anthropicMessagesTransformer.consumeSseEventBlock( - eventBlock, - streamContext, - claudeContext, - modelName, - ); - parsedPayload = consumed.parsedPayload; - if (parsedPayload && typeof parsedPayload === 'object') { - parsedUsage = mergeProxyUsage(parsedUsage, parseProxyUsage(parsedPayload)); - } - if (consumed.handled) { - writeLines(consumed.lines); - if (consumed.done) { - shouldTerminateEarly = true; - break; - } - continue; - } - } else { - try { - parsedPayload = JSON.parse(eventBlock.data); - } catch { - parsedPayload = null; - } - if (parsedPayload && typeof parsedPayload === 'object') { - parsedUsage = mergeProxyUsage(parsedUsage, parseProxyUsage(parsedPayload)); - } - } - - if (parsedPayload && typeof parsedPayload === 'object') { - const normalizedEvent = downstreamTransformer.transformStreamEvent(parsedPayload, streamContext, modelName); - writeLines(downstreamTransformer.serializeStreamEvent(normalizedEvent, streamContext, claudeContext)); - if (downstreamFormat === 'claude' && claudeContext.doneSent) { - shouldTerminateEarly = true; - break; - } - if (streamContext.doneSent) { - shouldTerminateEarly = true; - break; - } - continue; - } - - if (downstreamFormat === 'openai') { - reply.raw.write(`data: ${eventBlock.data}\n\n`); - } else { - writeLines(anthropicMessagesTransformer.serializeStreamEvent({ - contentDelta: eventBlock.data, - }, streamContext, claudeContext)); - if (claudeContext.doneSent) { - shouldTerminateEarly = true; - break; - } - } - } - - return pulled.rest; - }; - - try { - while (true) { - const { done, value } = await reader.read(); - if (done) break; - if (!value) continue; - - sseBuffer += decoder.decode(value, { stream: true }); - sseBuffer = consumeSseBuffer(sseBuffer); - if (shouldTerminateEarly) { - await reader.cancel().catch(() => {}); - break; - } - } - - if (!shouldTerminateEarly) { - sseBuffer += decoder.decode(); - } - if (!shouldTerminateEarly && sseBuffer.trim().length > 0) { - sseBuffer = consumeSseBuffer(`${sseBuffer}\n\n`); - } - } finally { - reader.releaseLock(); - writeDone(); - reply.raw.end(); - } - - const latency = Date.now() - startTime; - const resolvedUsage = await resolveProxyUsageWithSelfLogFallback({ - site: selected.site, - account: selected.account, - tokenValue: selected.tokenValue, - tokenName: selected.tokenName, - modelName, - requestStartedAtMs: startTime, - requestEndedAtMs: startTime + latency, - localLatencyMs: latency, - usage: { - promptTokens: parsedUsage.promptTokens, - completionTokens: parsedUsage.completionTokens, - totalTokens: parsedUsage.totalTokens, - }, - }); - - const { estimatedCost, billingDetails } = await resolveProxyLogBilling({ - site: selected.site, - account: selected.account, - modelName, - parsedUsage, - resolvedUsage, - }); - - tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost); - recordDownstreamCostUsage(request, estimatedCost); - logProxy( - selected, - requestedModel, - 'success', - 200, - latency, - null, - retryCount, - downstreamPath, - resolvedUsage.promptTokens, - resolvedUsage.completionTokens, - resolvedUsage.totalTokens, - estimatedCost, - billingDetails, - successfulUpstreamPath, - ); - return; - } - - const rawText = await upstream.text(); - let upstreamData: unknown = rawText; - try { - upstreamData = JSON.parse(rawText); - } catch { - upstreamData = rawText; - } - - const latency = Date.now() - startTime; - const parsedUsage = parseProxyUsage(upstreamData); - const normalizedFinal = downstreamTransformer.transformFinalResponse(upstreamData, modelName, rawText); - const downstreamResponse = downstreamTransformer.serializeFinalResponse(normalizedFinal, parsedUsage); - - const resolvedUsage = await resolveProxyUsageWithSelfLogFallback({ - site: selected.site, - account: selected.account, - tokenValue: selected.tokenValue, - tokenName: selected.tokenName, - modelName, - requestStartedAtMs: startTime, - requestEndedAtMs: startTime + latency, - localLatencyMs: latency, - usage: { - promptTokens: parsedUsage.promptTokens, - completionTokens: parsedUsage.completionTokens, - totalTokens: parsedUsage.totalTokens, - }, - }); - - const { estimatedCost, billingDetails } = await resolveProxyLogBilling({ - site: selected.site, - account: selected.account, - modelName, - parsedUsage, - resolvedUsage, - }); - - tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost); - recordDownstreamCostUsage(request, estimatedCost); - logProxy( - selected, - requestedModel, - 'success', - 200, - latency, - null, - retryCount, - downstreamPath, - resolvedUsage.promptTokens, - resolvedUsage.completionTokens, - resolvedUsage.totalTokens, - estimatedCost, - billingDetails, - successfulUpstreamPath, - ); - - return reply.send(downstreamResponse); - } catch (err: any) { - tokenRouter.recordFailure(selected.channel.id); - logProxy(selected, requestedModel, 'failed', 0, Date.now() - startTime, err?.message || 'network error', retryCount, downstreamPath); - - if (retryCount < MAX_RETRIES) { - retryCount += 1; - continue; - } - - await reportProxyAllFailed({ - model: requestedModel, - reason: err?.message || 'network failure', - }); - - return reply.code(502).send({ - error: { - message: `Upstream error: ${err?.message || 'network failure'}`, - type: 'upstream_error', - }, - }); - } - } -} - -async function logProxy( - selected: any, - modelRequested: string, - status: string, - httpStatus: number, - latencyMs: number, - errorMessage: string | null, - retryCount: number, - downstreamPath: string, - promptTokens = 0, - completionTokens = 0, - totalTokens = 0, - estimatedCost = 0, - billingDetails: unknown = null, - upstreamPath: string | null = null, -) { - try { - const createdAt = formatUtcSqlDateTime(new Date()); - const normalizedErrorMessage = composeProxyLogMessage({ - downstreamPath, - upstreamPath, - errorMessage, - }); - await db.insert(schema.proxyLogs).values({ - routeId: selected.channel.routeId, - channelId: selected.channel.id, - accountId: selected.account.id, - modelRequested, - modelActual: selected.actualModel, - status, - httpStatus, - latencyMs, - promptTokens, - completionTokens, - totalTokens, - estimatedCost, - billingDetails: billingDetails ? JSON.stringify(billingDetails) : null, - errorMessage: normalizedErrorMessage, - retryCount, - createdAt, - }).run(); - } catch (error) { - console.warn('[proxy/chat] failed to write proxy log', error); - } + handleChatSurfaceRequest(request, reply, 'claude')); + app.post('/v1/messages/count_tokens', async (request: FastifyRequest, reply: FastifyReply) => + handleClaudeCountTokensSurfaceRequest(request, reply)); } diff --git a/src/server/routes/proxy/completions.ts b/src/server/routes/proxy/completions.ts index 01d6930d..7c685d20 100644 --- a/src/server/routes/proxy/completions.ts +++ b/src/server/routes/proxy/completions.ts @@ -1,44 +1,55 @@ import { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'; import { fetch } from 'undici'; -import { db, schema } from '../../db/index.js'; -import { tokenRouter } from '../../services/tokenRouter.js'; -import { refreshModelsAndRebuildRoutes } from '../../services/modelService.js'; -import { reportProxyAllFailed, reportTokenExpired } from '../../services/alertService.js'; -import { isTokenExpiredError } from '../../services/alertRules.js'; -import { shouldRetryProxyRequest } from '../../services/proxyRetryPolicy.js'; -import { resolveProxyUsageWithSelfLogFallback } from '../../services/proxyUsageFallbackService.js'; -import { mergeProxyUsage, parseProxyUsage, pullSseDataEvents } from '../../services/proxyUsageParser.js'; -import { ensureModelAllowedForDownstreamKey, getDownstreamRoutingPolicy, recordDownstreamCostUsage } from './downstreamPolicy.js'; -import { withSiteRecordProxyRequestInit } from '../../services/siteProxy.js'; -import { composeProxyLogMessage } from './logPathMeta.js'; -import { formatUtcSqlDateTime } from '../../services/localTimeService.js'; -import { resolveProxyLogBilling } from './proxyBilling.js'; - -const MAX_RETRIES = 2; +import { tokenRouter } from '../../services/tokenRouter.js'; +import * as routeRefreshWorkflow from '../../services/routeRefreshWorkflow.js'; +import { reportProxyAllFailed, reportTokenExpired } from '../../services/alertService.js'; +import { isTokenExpiredError } from '../../services/alertRules.js'; +import { shouldRetryProxyRequest } from '../../services/proxyRetryPolicy.js'; +import { resolveProxyUsageWithSelfLogFallback } from '../../services/proxyUsageFallbackService.js'; +import { mergeProxyUsage, parseProxyUsage, pullSseDataEvents } from '../../services/proxyUsageParser.js'; +import { ensureModelAllowedForDownstreamKey, getDownstreamRoutingPolicy, recordDownstreamCostUsage } from './downstreamPolicy.js'; +import { withSiteRecordProxyRequestInit } from '../../services/siteProxy.js'; +import { getProxyUrlFromExtraConfig } from '../../services/accountExtraConfig.js'; +import { composeProxyLogMessage } from './logPathMeta.js'; +import { formatUtcSqlDateTime } from '../../services/localTimeService.js'; +import { detectProxyFailure } from './proxyFailureJudge.js'; +import { resolveProxyLogBilling } from './proxyBilling.js'; +import { getProxyAuthContext } from '../../middleware/auth.js'; +import { buildUpstreamUrl } from './upstreamUrl.js'; +import { detectDownstreamClientContext, type DownstreamClientContext } from './downstreamClientContext.js'; +import { insertProxyLog } from '../../services/proxyLogStore.js'; +import { canRetryProxyChannel, getProxyMaxChannelRetries } from '../../services/proxyChannelRetry.js'; export async function completionsProxyRoute(app: FastifyInstance) { app.post('/v1/completions', async (request: FastifyRequest, reply: FastifyReply) => { const body = request.body as any; - const requestedModel = body?.model; - if (!requestedModel) { - return reply.code(400).send({ error: { message: 'model is required', type: 'invalid_request_error' } }); + const requestedModel = body?.model; + if (!requestedModel) { + return reply.code(400).send({ error: { message: 'model is required', type: 'invalid_request_error' } }); } if (!await ensureModelAllowedForDownstreamKey(request, reply, requestedModel)) return; const downstreamPolicy = getDownstreamRoutingPolicy(request); - - const isStream = body.stream === true; + const downstreamApiKeyId = getProxyAuthContext(request)?.keyId ?? null; + const downstreamPath = '/v1/completions'; + const clientContext = detectDownstreamClientContext({ + downstreamPath, + headers: request.headers as Record, + body, + }); + + const isStream = body.stream === true; const excludeChannelIds: number[] = []; let retryCount = 0; - while (retryCount <= MAX_RETRIES) { - let selected = retryCount === 0 - ? await tokenRouter.selectChannel(requestedModel, downstreamPolicy) - : await tokenRouter.selectNextChannel(requestedModel, excludeChannelIds, downstreamPolicy); - - if (!selected && retryCount === 0) { - await refreshModelsAndRebuildRoutes(); - selected = await tokenRouter.selectChannel(requestedModel, downstreamPolicy); - } + while (retryCount <= getProxyMaxChannelRetries()) { + let selected = retryCount === 0 + ? await tokenRouter.selectChannel(requestedModel, downstreamPolicy) + : await tokenRouter.selectNextChannel(requestedModel, excludeChannelIds, downstreamPolicy); + + if (!selected && retryCount === 0) { + await routeRefreshWorkflow.refreshModelsAndRebuildRoutes(); + selected = await tokenRouter.selectChannel(requestedModel, downstreamPolicy); + } if (!selected) { await reportProxyAllFailed({ @@ -49,27 +60,48 @@ export async function completionsProxyRoute(app: FastifyInstance) { error: { message: 'No available channels for this model', type: 'server_error' }, }); } - - excludeChannelIds.push(selected.channel.id); - - const targetUrl = `${selected.site.url}/v1/completions`; - const forwardBody = { ...body, model: selected.actualModel }; - const startTime = Date.now(); + + excludeChannelIds.push(selected.channel.id); + + const targetUrl = buildUpstreamUrl(selected.site.url, '/v1/completions'); + const upstreamModel = selected.actualModel || requestedModel; + const forwardBody = { ...body, model: upstreamModel }; + const startTime = Date.now(); try { - const upstream = await fetch(targetUrl, withSiteRecordProxyRequestInit(selected.site, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${selected.tokenValue}`, - }, - body: JSON.stringify(forwardBody), - })); + const upstream = await fetch(targetUrl, withSiteRecordProxyRequestInit(selected.site, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${selected.tokenValue}`, + }, + body: JSON.stringify(forwardBody), + }, getProxyUrlFromExtraConfig(selected.account.extraConfig))); - if (!upstream.ok) { - const errText = await upstream.text().catch(() => 'unknown error'); - tokenRouter.recordFailure(selected.channel.id); - logProxy(selected, requestedModel, 'failed', upstream.status, Date.now() - startTime, errText, retryCount); + if (!upstream.ok) { + const errText = await upstream.text().catch(() => 'unknown error'); + await recordTokenRouterEventBestEffort('record channel failure', () => tokenRouter.recordFailure(selected.channel.id, { + status: upstream.status, + errorText: errText, + modelName: upstreamModel, + })); + logProxy( + selected, + requestedModel, + 'failed', + upstream.status, + Date.now() - startTime, + errText, + retryCount, + downstreamApiKeyId, + 0, + 0, + 0, + 0, + null, + clientContext, + downstreamPath, + ); if (isTokenExpiredError({ status: upstream.status, message: errText })) { await reportTokenExpired({ @@ -80,7 +112,7 @@ export async function completionsProxyRoute(app: FastifyInstance) { }); } - if (shouldRetryProxyRequest(upstream.status, errText) && retryCount < MAX_RETRIES) { + if (shouldRetryProxyRequest(upstream.status, errText) && canRetryProxyChannel(retryCount)) { retryCount++; continue; } @@ -105,117 +137,214 @@ export async function completionsProxyRoute(app: FastifyInstance) { return; } - const decoder = new TextDecoder(); - let parsedUsage: ReturnType = { - promptTokens: 0, - completionTokens: 0, - totalTokens: 0, - cacheReadTokens: 0, - cacheCreationTokens: 0, - promptTokensIncludeCache: null, - }; - let sseBuffer = ''; - try { - while (true) { - const { done, value } = await reader.read(); - if (done) break; - const chunk = decoder.decode(value, { stream: true }); - reply.raw.write(chunk); - - sseBuffer += chunk; - const pulled = pullSseDataEvents(sseBuffer); - sseBuffer = pulled.rest; - for (const eventPayload of pulled.events) { - try { - parsedUsage = mergeProxyUsage(parsedUsage, parseProxyUsage(JSON.parse(eventPayload))); - } catch {} - } - } - if (sseBuffer.trim().length > 0) { - const pulled = pullSseDataEvents(`${sseBuffer}\n\n`); - for (const eventPayload of pulled.events) { - try { - parsedUsage = mergeProxyUsage(parsedUsage, parseProxyUsage(JSON.parse(eventPayload))); - } catch {} - } - } - } finally { - reader.releaseLock(); - reply.raw.end(); - } + const decoder = new TextDecoder(); + let parsedUsage: ReturnType = { + promptTokens: 0, + completionTokens: 0, + totalTokens: 0, + cacheReadTokens: 0, + cacheCreationTokens: 0, + promptTokensIncludeCache: null, + }; + let sseBuffer = ''; + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + const chunk = decoder.decode(value, { stream: true }); + reply.raw.write(chunk); - const latency = Date.now() - startTime; - const resolvedUsage = await resolveProxyUsageWithSelfLogFallback({ - site: selected.site, - account: selected.account, - tokenValue: selected.tokenValue, - tokenName: selected.tokenName, - modelName: selected.actualModel || requestedModel, - requestStartedAtMs: startTime, - requestEndedAtMs: startTime + latency, - localLatencyMs: latency, - usage: { - promptTokens: parsedUsage.promptTokens, - completionTokens: parsedUsage.completionTokens, - totalTokens: parsedUsage.totalTokens, - }, - }); - const { estimatedCost, billingDetails } = await resolveProxyLogBilling({ - site: selected.site, - account: selected.account, - modelName: selected.actualModel || requestedModel, - parsedUsage, - resolvedUsage, - }); - tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost); + sseBuffer += chunk; + const pulled = pullSseDataEvents(sseBuffer); + sseBuffer = pulled.rest; + for (const eventPayload of pulled.events) { + try { + parsedUsage = mergeProxyUsage(parsedUsage, parseProxyUsage(JSON.parse(eventPayload))); + } catch {} + } + } + if (sseBuffer.trim().length > 0) { + const pulled = pullSseDataEvents(`${sseBuffer}\n\n`); + for (const eventPayload of pulled.events) { + try { + parsedUsage = mergeProxyUsage(parsedUsage, parseProxyUsage(JSON.parse(eventPayload))); + } catch {} + } + } + } finally { + reader.releaseLock(); + reply.raw.end(); + } + + const latency = Date.now() - startTime; + const resolvedUsage = await resolveProxyUsageWithSelfLogFallback({ + site: selected.site, + account: selected.account, + tokenValue: selected.tokenValue, + tokenName: selected.tokenName, + modelName: selected.actualModel || requestedModel, + requestStartedAtMs: startTime, + requestEndedAtMs: startTime + latency, + localLatencyMs: latency, + usage: { + promptTokens: parsedUsage.promptTokens, + completionTokens: parsedUsage.completionTokens, + totalTokens: parsedUsage.totalTokens, + }, + }); + const { estimatedCost, billingDetails } = await resolveProxyLogBilling({ + site: selected.site, + account: selected.account, + modelName: selected.actualModel || requestedModel, + parsedUsage, + resolvedUsage, + }); + await recordTokenRouterEventBestEffort('record channel success', () => ( + tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost, upstreamModel) + )); recordDownstreamCostUsage(request, estimatedCost); logProxy( - selected, requestedModel, 'success', 200, latency, null, retryCount, - resolvedUsage.promptTokens, resolvedUsage.completionTokens, resolvedUsage.totalTokens, estimatedCost, billingDetails, + selected, + requestedModel, + 'success', + 200, + latency, + null, + retryCount, + downstreamApiKeyId, + resolvedUsage.promptTokens, + resolvedUsage.completionTokens, + resolvedUsage.totalTokens, + estimatedCost, + billingDetails, + clientContext, + downstreamPath, ); - return; - } - - const data = await upstream.json() as any; - const latency = Date.now() - startTime; - const parsedUsage = parseProxyUsage(data); - const resolvedUsage = await resolveProxyUsageWithSelfLogFallback({ - site: selected.site, - account: selected.account, - tokenValue: selected.tokenValue, - tokenName: selected.tokenName, - modelName: selected.actualModel || requestedModel, - requestStartedAtMs: startTime, - requestEndedAtMs: startTime + latency, - localLatencyMs: latency, - usage: { - promptTokens: parsedUsage.promptTokens, - completionTokens: parsedUsage.completionTokens, - totalTokens: parsedUsage.totalTokens, - }, - }); - const { estimatedCost, billingDetails } = await resolveProxyLogBilling({ - site: selected.site, - account: selected.account, - modelName: selected.actualModel || requestedModel, - parsedUsage, - resolvedUsage, - }); - - tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost); + return; + } + + const rawText = await upstream.text(); + let data: any = rawText; + try { + data = JSON.parse(rawText); + } catch { + data = rawText; + } + const latency = Date.now() - startTime; + const parsedUsage = parseProxyUsage(data); + const failure = detectProxyFailure({ rawText, usage: parsedUsage }); + if (failure) { + const errText = failure.reason; + await recordTokenRouterEventBestEffort('record channel failure', () => tokenRouter.recordFailure(selected.channel.id, { + status: failure.status, + errorText: errText, + modelName: upstreamModel, + })); + logProxy( + selected, + requestedModel, + 'failed', + failure.status, + latency, + errText, + retryCount, + downstreamApiKeyId, + 0, + 0, + 0, + 0, + null, + clientContext, + downstreamPath, + ); + + if (shouldRetryProxyRequest(failure.status, errText) && canRetryProxyChannel(retryCount)) { + retryCount += 1; + continue; + } + + await reportProxyAllFailed({ + model: requestedModel, + reason: failure.reason, + }); + + return reply.code(failure.status).send({ + error: { message: errText, type: 'upstream_error' }, + }); + } + + const resolvedUsage = await resolveProxyUsageWithSelfLogFallback({ + site: selected.site, + account: selected.account, + tokenValue: selected.tokenValue, + tokenName: selected.tokenName, + modelName: selected.actualModel || requestedModel, + requestStartedAtMs: startTime, + requestEndedAtMs: startTime + latency, + localLatencyMs: latency, + usage: { + promptTokens: parsedUsage.promptTokens, + completionTokens: parsedUsage.completionTokens, + totalTokens: parsedUsage.totalTokens, + }, + }); + const { estimatedCost, billingDetails } = await resolveProxyLogBilling({ + site: selected.site, + account: selected.account, + modelName: selected.actualModel || requestedModel, + parsedUsage, + resolvedUsage, + }); + + await recordTokenRouterEventBestEffort('record channel success', () => ( + tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost, upstreamModel) + )); recordDownstreamCostUsage(request, estimatedCost); logProxy( - selected, requestedModel, 'success', 200, latency, null, retryCount, - resolvedUsage.promptTokens, resolvedUsage.completionTokens, resolvedUsage.totalTokens, estimatedCost, billingDetails, + selected, + requestedModel, + 'success', + 200, + latency, + null, + retryCount, + downstreamApiKeyId, + resolvedUsage.promptTokens, + resolvedUsage.completionTokens, + resolvedUsage.totalTokens, + estimatedCost, + billingDetails, + clientContext, + downstreamPath, ); return reply.send(data); - } catch (err: any) { - tokenRouter.recordFailure(selected.channel.id); - logProxy(selected, requestedModel, 'failed', 0, Date.now() - startTime, err.message, retryCount); - if (retryCount < MAX_RETRIES) { - retryCount++; - continue; - } + } catch (err: any) { + await recordTokenRouterEventBestEffort('record channel failure', () => tokenRouter.recordFailure(selected.channel.id, { + status: 0, + errorText: err.message, + modelName: upstreamModel, + })); + logProxy( + selected, + requestedModel, + 'failed', + 0, + Date.now() - startTime, + err.message, + retryCount, + downstreamApiKeyId, + 0, + 0, + 0, + 0, + null, + clientContext, + downstreamPath, + ); + if (canRetryProxyChannel(retryCount)) { + retryCount++; + continue; + } await reportProxyAllFailed({ model: requestedModel, reason: err.message || 'network failure', @@ -228,46 +357,70 @@ export async function completionsProxyRoute(app: FastifyInstance) { }); } -async function logProxy( - selected: any, - modelRequested: string, - status: string, +async function logProxy( + selected: any, + modelRequested: string, + status: string, httpStatus: number, latencyMs: number, errorMessage: string | null, retryCount: number, - promptTokens = 0, + downstreamApiKeyId: number | null = null, + promptTokens = 0, completionTokens = 0, totalTokens = 0, estimatedCost = 0, billingDetails: unknown = null, + clientContext: DownstreamClientContext | null = null, + downstreamPath = '/v1/completions', ) { try { const createdAt = formatUtcSqlDateTime(new Date()); const normalizedErrorMessage = composeProxyLogMessage({ - downstreamPath: '/v1/completions', + clientKind: clientContext?.clientKind && clientContext.clientKind !== 'generic' + ? clientContext.clientKind + : null, + sessionId: clientContext?.sessionId || null, + traceHint: clientContext?.traceHint || null, + downstreamPath, errorMessage, }); - await db.insert(schema.proxyLogs).values({ + await insertProxyLog({ routeId: selected.channel.routeId, channelId: selected.channel.id, accountId: selected.account.id, + downstreamApiKeyId, modelRequested, - modelActual: selected.actualModel, + modelActual: selected.actualModel || modelRequested, status, - httpStatus, - latencyMs, - promptTokens, + httpStatus, + latencyMs, + promptTokens, completionTokens, totalTokens, estimatedCost, - billingDetails: billingDetails ? JSON.stringify(billingDetails) : null, + billingDetails, + clientFamily: clientContext?.clientKind || null, + clientAppId: clientContext?.clientAppId || null, + clientAppName: clientContext?.clientAppName || null, + clientConfidence: clientContext?.clientConfidence || null, errorMessage: normalizedErrorMessage, retryCount, createdAt, - }).run(); + }); } catch (error) { console.warn('[proxy/completions] failed to write proxy log', error); } } + +async function recordTokenRouterEventBestEffort( + label: string, + operation: () => Promise, +): Promise { + try { + await operation(); + } catch (error) { + console.warn(`[proxy/completions] failed to ${label}`, error); + } +} diff --git a/src/server/routes/proxy/downstreamClientContext.architecture.test.ts b/src/server/routes/proxy/downstreamClientContext.architecture.test.ts new file mode 100644 index 00000000..d562f675 --- /dev/null +++ b/src/server/routes/proxy/downstreamClientContext.architecture.test.ts @@ -0,0 +1,17 @@ +import { readFileSync } from 'node:fs'; +import { describe, expect, it } from 'vitest'; + +function readSource(relativePath: string): string { + return readFileSync(new URL(relativePath, import.meta.url), 'utf8'); +} + +describe('downstreamClientContext architecture boundaries', () => { + it('keeps protocol-level client metadata on cli profiles instead of importing individual profile helpers', () => { + const source = readSource('./downstreamClientContext.ts'); + + expect(source).toContain("from '../../proxy-core/cliProfiles/registry.js'"); + expect(source).toContain("from '../../proxy-core/cliProfiles/types.js'"); + expect(source).not.toContain("from '../../proxy-core/cliProfiles/codexProfile.js'"); + expect(source).not.toContain("from '../../proxy-core/cliProfiles/claudeCodeProfile.js'"); + }); +}); diff --git a/src/server/routes/proxy/downstreamClientContext.routes.test.ts b/src/server/routes/proxy/downstreamClientContext.routes.test.ts new file mode 100644 index 00000000..147b94d8 --- /dev/null +++ b/src/server/routes/proxy/downstreamClientContext.routes.test.ts @@ -0,0 +1,249 @@ +import Fastify, { type FastifyInstance } from 'fastify'; +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; + +const fetchMock = vi.fn(); +const selectChannelMock = vi.fn(); +const selectNextChannelMock = vi.fn(); +const recordSuccessMock = vi.fn(); +const recordFailureMock = vi.fn(); +const refreshModelsAndRebuildRoutesMock = vi.fn(); +const reportProxyAllFailedMock = vi.fn(); +const reportTokenExpiredMock = vi.fn(); +const estimateProxyCostMock = vi.fn(async (_arg?: any) => 0); +const buildProxyBillingDetailsMock = vi.fn(async (_arg?: any) => null); +const fetchModelPricingCatalogMock = vi.fn(async (_arg?: any): Promise => null); +const resolveProxyUsageWithSelfLogFallbackMock = vi.fn(async ({ usage }: any) => ({ + ...usage, + estimatedCostFromQuota: 0, + recoveredFromSelfLog: false, +})); +const dbValuesMock = vi.fn((_arg?: any) => ({ + run: () => undefined, +})); +const dbInsertMock = vi.fn((_arg?: any) => ({ + values: (arg: any) => dbValuesMock(arg), +})); + +vi.mock('undici', () => ({ + fetch: (...args: unknown[]) => fetchMock(...args), +})); + +vi.mock('../../services/tokenRouter.js', () => ({ + tokenRouter: { + selectChannel: (...args: unknown[]) => selectChannelMock(...args), + selectNextChannel: (...args: unknown[]) => selectNextChannelMock(...args), + recordSuccess: (...args: unknown[]) => recordSuccessMock(...args), + recordFailure: (...args: unknown[]) => recordFailureMock(...args), + }, +})); + +vi.mock('../../services/modelService.js', () => ({ + refreshModelsAndRebuildRoutes: (...args: unknown[]) => refreshModelsAndRebuildRoutesMock(...args), +})); + +vi.mock('../../services/alertService.js', () => ({ + reportProxyAllFailed: (...args: unknown[]) => reportProxyAllFailedMock(...args), + reportTokenExpired: (...args: unknown[]) => reportTokenExpiredMock(...args), +})); + +vi.mock('../../services/alertRules.js', () => ({ + isTokenExpiredError: () => false, +})); + +vi.mock('../../services/modelPricingService.js', () => ({ + estimateProxyCost: (arg: any) => estimateProxyCostMock(arg), + buildProxyBillingDetails: (arg: any) => buildProxyBillingDetailsMock(arg), + fetchModelPricingCatalog: (arg: any) => fetchModelPricingCatalogMock(arg), +})); + +vi.mock('../../services/proxyRetryPolicy.js', () => ({ + shouldRetryProxyRequest: () => false, +})); + +vi.mock('../../services/proxyUsageFallbackService.js', () => ({ + resolveProxyUsageWithSelfLogFallback: (arg: any) => resolveProxyUsageWithSelfLogFallbackMock(arg), +})); + +vi.mock('../../db/index.js', () => ({ + db: { + insert: (arg: any) => dbInsertMock(arg), + }, + schema: { + proxyLogs: {}, + }, + hasProxyLogBillingDetailsColumn: async () => false, + hasProxyLogClientColumns: async () => false, + hasProxyLogDownstreamApiKeyIdColumn: async () => false, +})); + +describe('downstream client context route logging', () => { + let app: FastifyInstance; + + beforeAll(async () => { + const { claudeMessagesProxyRoute } = await import('./chat.js'); + const { responsesProxyRoute } = await import('./responses.js'); + app = Fastify(); + await app.register(claudeMessagesProxyRoute); + await app.register(responsesProxyRoute); + }); + + beforeEach(() => { + fetchMock.mockReset(); + selectChannelMock.mockReset(); + selectNextChannelMock.mockReset(); + recordSuccessMock.mockReset(); + recordFailureMock.mockReset(); + refreshModelsAndRebuildRoutesMock.mockReset(); + reportProxyAllFailedMock.mockReset(); + reportTokenExpiredMock.mockReset(); + estimateProxyCostMock.mockClear(); + buildProxyBillingDetailsMock.mockClear(); + fetchModelPricingCatalogMock.mockReset(); + resolveProxyUsageWithSelfLogFallbackMock.mockClear(); + dbInsertMock.mockClear(); + dbValuesMock.mockClear(); + + selectChannelMock.mockReturnValue({ + channel: { id: 11, routeId: 22 }, + site: { id: 44, name: 'demo-site', url: 'https://upstream.example.com', platform: 'openai' }, + account: { id: 33, username: 'demo-user' }, + tokenName: 'default', + tokenValue: 'sk-demo', + actualModel: 'upstream-gpt', + }); + selectNextChannelMock.mockReturnValue(null); + fetchModelPricingCatalogMock.mockResolvedValue(null); + }); + + afterAll(async () => { + await app.close(); + }); + + it('includes Codex client and session metadata in /v1/responses failure logs', async () => { + fetchMock.mockResolvedValue(new Response(JSON.stringify({ + error: { + message: 'bad request', + type: 'upstream_error', + }, + }), { + status: 400, + headers: { 'content-type': 'application/json' }, + })); + + const response = await app.inject({ + method: 'POST', + url: '/v1/responses', + headers: { + originator: 'codex_cli_rs', + Session_id: 'codex-session-123', + }, + payload: { + model: 'gpt-5.2', + input: 'hello', + }, + }); + + expect(response.statusCode).toBe(400); + expect(dbValuesMock).toHaveBeenCalled(); + const insertedLog = dbValuesMock.mock.calls.at(-1)?.[0]; + expect(insertedLog.errorMessage).toContain('[client:codex]'); + expect(insertedLog.errorMessage).toContain('[session:codex-session-123]'); + expect(insertedLog.errorMessage).toContain('[downstream:/v1/responses]'); + }); + + it('reuses the same Codex detection on /v1/responses/compact failure logs', async () => { + fetchMock.mockResolvedValue(new Response(JSON.stringify({ + error: { + message: 'compact failed', + type: 'upstream_error', + }, + }), { + status: 400, + headers: { 'content-type': 'application/json' }, + })); + + const response = await app.inject({ + method: 'POST', + url: '/v1/responses/compact', + headers: { + 'x-stainless-lang': 'typescript', + Session_id: 'codex-session-compact', + }, + payload: { + model: 'gpt-5.2', + input: 'hello', + }, + }); + + expect(response.statusCode).toBe(400); + expect(dbValuesMock).toHaveBeenCalled(); + const insertedLog = dbValuesMock.mock.calls.at(-1)?.[0]; + expect(insertedLog.errorMessage).toContain('[client:codex]'); + expect(insertedLog.errorMessage).toContain('[session:codex-session-compact]'); + expect(insertedLog.errorMessage).toContain('[downstream:/v1/responses/compact]'); + }); + + it('includes Claude Code client and session metadata in /v1/messages failure logs', async () => { + fetchMock.mockResolvedValue(new Response(JSON.stringify({ + error: { + message: 'messages failed', + type: 'upstream_error', + }, + }), { + status: 400, + headers: { 'content-type': 'application/json' }, + })); + + const response = await app.inject({ + method: 'POST', + url: '/v1/messages', + payload: { + model: 'claude-opus-4-6', + max_tokens: 256, + messages: [{ role: 'user', content: 'hello' }], + metadata: { + user_id: 'user_20836b5653ed68aa981604f502c0a491397f6053826a93c953423632578d38ad_account__session_f25958b8-e75c-455d-8b40-f006d87cc2a4', + }, + }, + }); + + expect(response.statusCode).toBe(400); + expect(dbValuesMock).toHaveBeenCalled(); + const insertedLog = dbValuesMock.mock.calls.at(-1)?.[0]; + expect(insertedLog.errorMessage).toContain('[client:claude_code]'); + expect(insertedLog.errorMessage).toContain('[session:f25958b8-e75c-455d-8b40-f006d87cc2a4]'); + expect(insertedLog.errorMessage).toContain('[downstream:/v1/messages]'); + }); + + it('keeps invalid Claude metadata.user_id requests generic in failure logs', async () => { + fetchMock.mockResolvedValue(new Response(JSON.stringify({ + error: { + message: 'messages failed', + type: 'upstream_error', + }, + }), { + status: 400, + headers: { 'content-type': 'application/json' }, + })); + + const response = await app.inject({ + method: 'POST', + url: '/v1/messages', + payload: { + model: 'claude-opus-4-6', + max_tokens: 256, + messages: [{ role: 'user', content: 'hello' }], + metadata: { + user_id: 'user_123', + }, + }, + }); + + expect(response.statusCode).toBe(400); + expect(dbValuesMock).toHaveBeenCalled(); + const insertedLog = dbValuesMock.mock.calls.at(-1)?.[0]; + expect(insertedLog.errorMessage).toContain('[downstream:/v1/messages]'); + expect(insertedLog.errorMessage).not.toContain('[client:'); + expect(insertedLog.errorMessage).not.toContain('[session:'); + }); +}); diff --git a/src/server/routes/proxy/downstreamClientContext.test.ts b/src/server/routes/proxy/downstreamClientContext.test.ts new file mode 100644 index 00000000..9b2b26e4 --- /dev/null +++ b/src/server/routes/proxy/downstreamClientContext.test.ts @@ -0,0 +1,256 @@ +import { describe, expect, it } from 'vitest'; +import { extractClaudeCodeSessionId } from '../../proxy-core/cliProfiles/claudeCodeProfile.js'; +import { isCodexResponsesSurface } from '../../proxy-core/cliProfiles/codexProfile.js'; +import { detectDownstreamClientContext } from './downstreamClientContext.js'; + +describe('extractClaudeCodeSessionId', () => { + it('extracts session uuid from axonhub-compatible Claude Code user ids', () => { + expect(extractClaudeCodeSessionId( + 'user_20836b5653ed68aa981604f502c0a491397f6053826a93c953423632578d38ad_account__session_f25958b8-e75c-455d-8b40-f006d87cc2a4', + )).toBe('f25958b8-e75c-455d-8b40-f006d87cc2a4'); + }); + + it('returns null for non-Claude-Code user ids', () => { + expect(extractClaudeCodeSessionId('user_123')).toBe(null); + expect(extractClaudeCodeSessionId('session_f25958b8-e75c-455d-8b40-f006d87cc2a4')).toBe(null); + }); +}); + +describe('isCodexResponsesSurface', () => { + it('detects Codex responses surface from originator, stainless, and turn-state headers', () => { + expect(isCodexResponsesSurface({ + originator: 'codex_cli_rs', + })).toBe(true); + + expect(isCodexResponsesSurface({ + 'x-stainless-lang': 'typescript', + })).toBe(true); + + expect(isCodexResponsesSurface({ + 'x-codex-turn-state': 'turn-state-123', + })).toBe(true); + }); + + it('detects broader Codex official-client family headers from user-agent and originator prefixes', () => { + expect(isCodexResponsesSurface({ + 'user-agent': 'Mozilla/5.0 codex_chatgpt_desktop/1.2.3', + })).toBe(true); + + expect(isCodexResponsesSurface({ + originator: 'codex_vscode', + })).toBe(true); + }); + + it('returns false for generic responses clients', () => { + expect(isCodexResponsesSurface({ + 'content-type': 'application/json', + })).toBe(false); + }); +}); + +describe('detectDownstreamClientContext', () => { + it('recognizes Codex requests and attaches Session_id as session and trace hint', () => { + expect(detectDownstreamClientContext({ + downstreamPath: '/v1/responses', + headers: { + originator: 'codex_cli_rs', + Session_id: 'codex-session-123', + }, + })).toEqual({ + clientKind: 'codex', + clientAppId: 'codex_cli_rs', + clientAppName: 'Codex CLI', + clientConfidence: 'exact', + sessionId: 'codex-session-123', + traceHint: 'codex-session-123', + }); + }); + + it('keeps Codex requests without Session_id as client-only context', () => { + expect(detectDownstreamClientContext({ + downstreamPath: '/v1/responses/compact', + headers: { + 'x-stainless-lang': 'typescript', + }, + })).toEqual({ + clientKind: 'codex', + clientAppId: 'codex', + clientAppName: 'Codex', + clientConfidence: 'heuristic', + }); + }); + + it('recognizes broader Codex official-client user-agent families without requiring stainless headers', () => { + expect(detectDownstreamClientContext({ + downstreamPath: '/v1/responses', + headers: { + 'user-agent': 'Mozilla/5.0 codex_chatgpt_desktop/1.2.3', + }, + })).toEqual({ + clientKind: 'codex', + clientAppId: 'codex_chatgpt_desktop', + clientAppName: 'Codex Desktop', + clientConfidence: 'exact', + }); + }); + + it('recognizes broader Codex official-client originator prefixes', () => { + expect(detectDownstreamClientContext({ + downstreamPath: '/v1/responses', + headers: { + originator: 'codex_exec', + }, + })).toEqual({ + clientKind: 'codex', + clientAppId: 'codex_exec', + clientAppName: 'Codex Exec', + clientConfidence: 'exact', + }); + }); + + it('prefers explicit self-reported client names before protocol-family fallbacks', () => { + expect(detectDownstreamClientContext({ + downstreamPath: '/v1/responses', + headers: { + 'openai-beta': 'responses-2025-03-11', + 'x-openai-client-user-agent': '{"client":"openclaw"}', + }, + })).toEqual({ + clientKind: 'codex', + clientAppId: 'openclaw', + clientAppName: 'openclaw', + clientConfidence: 'exact', + }); + }); + + it('treats explicit OpenClaw user-agent headers as self-reported app names', () => { + expect(detectDownstreamClientContext({ + downstreamPath: '/v1/responses', + headers: { + 'openai-beta': 'responses-2025-03-11', + 'user-agent': 'OpenClaw/1.0', + }, + })).toEqual({ + clientKind: 'codex', + clientAppId: 'openclaw', + clientAppName: 'OpenClaw', + clientConfidence: 'exact', + }); + }); + + it('recognizes Claude Code requests from metadata.user_id without mutating the body', () => { + const body = { + model: 'claude-opus-4-6', + metadata: { + user_id: 'user_20836b5653ed68aa981604f502c0a491397f6053826a93c953423632578d38ad_account__session_f25958b8-e75c-455d-8b40-f006d87cc2a4', + }, + }; + + expect(detectDownstreamClientContext({ + downstreamPath: '/v1/messages', + body, + })).toEqual({ + clientKind: 'claude_code', + clientAppId: 'claude_code', + clientAppName: 'Claude Code', + clientConfidence: 'exact', + sessionId: 'f25958b8-e75c-455d-8b40-f006d87cc2a4', + traceHint: 'f25958b8-e75c-455d-8b40-f006d87cc2a4', + }); + expect(body).toEqual({ + model: 'claude-opus-4-6', + metadata: { + user_id: 'user_20836b5653ed68aa981604f502c0a491397f6053826a93c953423632578d38ad_account__session_f25958b8-e75c-455d-8b40-f006d87cc2a4', + }, + }); + }); + + it('falls back to generic when Claude metadata.user_id is missing or invalid', () => { + expect(detectDownstreamClientContext({ + downstreamPath: '/v1/messages', + body: { + metadata: { + user_id: 'user_123', + }, + }, + })).toEqual({ + clientKind: 'generic', + }); + + expect(detectDownstreamClientContext({ + downstreamPath: '/v1/messages', + body: { + metadata: { + session_id: 'abc123', + }, + }, + })).toEqual({ + clientKind: 'generic', + }); + }); + + it('recognizes Gemini CLI internal routes as a first-class downstream client kind', () => { + expect(detectDownstreamClientContext({ + downstreamPath: '/v1internal:generateContent', + body: { + model: 'gpt-4.1', + contents: [ + { + role: 'user', + parts: [{ text: 'hello' }], + }, + ], + }, + })).toEqual({ + clientKind: 'gemini_cli', + clientAppId: 'gemini_cli', + clientAppName: 'Gemini CLI', + clientConfidence: 'exact', + }); + }); + + it('recognizes app fingerprints alongside a generic protocol family', () => { + expect(detectDownstreamClientContext({ + downstreamPath: '/v1/chat/completions', + headers: { + 'x-title': 'Cherry Studio', + 'http-referer': 'https://cherry-ai.com', + }, + })).toEqual({ + clientKind: 'generic', + clientAppId: 'cherry_studio', + clientAppName: 'Cherry Studio', + clientConfidence: 'exact', + }); + }); + + it('keeps protocol family detection when an app fingerprint also matches', () => { + expect(detectDownstreamClientContext({ + downstreamPath: '/v1/responses', + headers: { + originator: 'codex_cli_rs', + 'x-title': 'Cherry Studio', + 'http-referer': 'https://cherry-ai.com', + }, + })).toEqual({ + clientKind: 'codex', + clientAppId: 'cherry_studio', + clientAppName: 'Cherry Studio', + clientConfidence: 'exact', + }); + }); + + it('marks weak app-only matches as heuristic instead of upgrading protocol behavior', () => { + expect(detectDownstreamClientContext({ + downstreamPath: '/v1/chat/completions', + headers: { + 'user-agent': 'CherryStudio/1.2.3', + }, + })).toEqual({ + clientKind: 'generic', + clientAppId: 'cherry_studio', + clientAppName: 'Cherry Studio', + clientConfidence: 'heuristic', + }); + }); +}); diff --git a/src/server/routes/proxy/downstreamClientContext.ts b/src/server/routes/proxy/downstreamClientContext.ts new file mode 100644 index 00000000..56fa75e0 --- /dev/null +++ b/src/server/routes/proxy/downstreamClientContext.ts @@ -0,0 +1,248 @@ +import { detectCliProfile } from '../../proxy-core/cliProfiles/registry.js'; +import type { + CliProfileClientConfidence, + CliProfileId, +} from '../../proxy-core/cliProfiles/types.js'; + +export type DownstreamClientKind = CliProfileId; +export type DownstreamClientConfidence = CliProfileClientConfidence; + +export type DownstreamClientContext = { + clientKind: DownstreamClientKind; + sessionId?: string; + traceHint?: string; + clientAppId?: string; + clientAppName?: string; + clientConfidence?: DownstreamClientConfidence; +}; + +type NormalizedClientHeaders = Record; + +type DownstreamClientBodySummary = { + topLevelKeys: string[]; + metadataUserId: string | null; +}; + +type DownstreamClientFingerprintInput = { + downstreamPath: string; + headers: NormalizedClientHeaders; + bodySummary: DownstreamClientBodySummary; +}; + +type DownstreamClientFingerprintRule = { + id: string; + name: string; + priority: number; + match(input: DownstreamClientFingerprintInput): DownstreamClientConfidence | null; +}; + +type DownstreamResolvedClientApp = { + clientAppId: string; + clientAppName: string; + clientConfidence: DownstreamClientConfidence; +}; + +function isRecord(value: unknown): value is Record { + return !!value && typeof value === 'object' && !Array.isArray(value); +} + +function normalizeHeaderValues(value: unknown): string[] { + if (typeof value === 'string') { + const trimmed = value.trim(); + return trimmed ? [trimmed] : []; + } + + if (!Array.isArray(value)) return []; + return value + .filter((item): item is string => typeof item === 'string') + .map((item) => item.trim()) + .filter((item) => item.length > 0); +} + +function normalizeHeaders(headers?: Record): NormalizedClientHeaders { + if (!headers) return {}; + + const normalized: NormalizedClientHeaders = {}; + for (const [rawKey, rawValue] of Object.entries(headers)) { + const key = rawKey.trim().toLowerCase(); + if (!key) continue; + const values = normalizeHeaderValues(rawValue); + if (values.length === 0) continue; + normalized[key] = normalized[key] + ? [...normalized[key], ...values] + : values; + } + return normalized; +} + +function headerEquals(headers: NormalizedClientHeaders, key: string, expected: string): boolean { + const normalizedExpected = expected.trim().toLowerCase(); + return (headers[key.trim().toLowerCase()] || []).some((value) => value.trim().toLowerCase() === normalizedExpected); +} + +function headerIncludes(headers: NormalizedClientHeaders, key: string, expectedFragment: string): boolean { + const normalizedExpected = expectedFragment.trim().toLowerCase(); + return (headers[key.trim().toLowerCase()] || []).some((value) => value.trim().toLowerCase().includes(normalizedExpected)); +} + +function normalizeClientDisplayName(value: string): string | null { + const trimmed = value.trim(); + if (!trimmed) return null; + return trimmed.length <= 120 ? trimmed : trimmed.slice(0, 120).trim() || null; +} + +function normalizeClientAppId(value: string): string | null { + const normalized = value.trim().toLowerCase(); + return normalized || null; +} + +function parseExplicitClientSelfReportValue(value: string): string | null { + const trimmed = value.trim(); + if (!trimmed) return null; + + try { + const parsed = JSON.parse(trimmed); + if (isRecord(parsed)) { + for (const key of ['client', 'name', 'app']) { + const raw = parsed[key]; + if (typeof raw !== 'string') continue; + const normalized = normalizeClientDisplayName(raw); + if (normalized) return normalized; + } + } + } catch { + return normalizeClientDisplayName(trimmed); + } + + return null; +} + +function buildBodySummary(body: unknown): DownstreamClientBodySummary { + if (!isRecord(body)) { + return { + topLevelKeys: [], + metadataUserId: null, + }; + } + + const metadataUserId = isRecord(body.metadata) && typeof body.metadata.user_id === 'string' + ? body.metadata.user_id.trim() || null + : null; + + return { + topLevelKeys: Object.keys(body).sort((left, right) => left.localeCompare(right)), + metadataUserId, + }; +} + +const appFingerprintRegistry: DownstreamClientFingerprintRule[] = [ + { + id: 'cherry_studio', + name: 'Cherry Studio', + priority: 100, + match(input) { + const hasTitle = headerEquals(input.headers, 'x-title', 'Cherry Studio'); + const hasReferer = headerEquals(input.headers, 'http-referer', 'https://cherry-ai.com') + || headerEquals(input.headers, 'referer', 'https://cherry-ai.com'); + + if (hasTitle && hasReferer) { + return 'exact'; + } + + const weakSignals = [ + headerIncludes(input.headers, 'user-agent', 'cherrystudio'), + headerIncludes(input.headers, 'x-title', 'cherry studio'), + headerIncludes(input.headers, 'http-referer', 'cherry-ai.com'), + headerIncludes(input.headers, 'referer', 'cherry-ai.com'), + ]; + + return weakSignals.some(Boolean) ? 'heuristic' : null; + }, + }, +]; + +function detectDownstreamClientFingerprint(input: { + downstreamPath: string; + headers?: Record; + body?: unknown; +}) { + const fingerprintInput: DownstreamClientFingerprintInput = { + downstreamPath: input.downstreamPath, + headers: normalizeHeaders(input.headers), + bodySummary: buildBodySummary(input.body), + }; + + let matchedRule: DownstreamClientFingerprintRule | null = null; + let matchedConfidence: DownstreamClientConfidence | null = null; + + for (const rule of appFingerprintRegistry) { + const confidence = rule.match(fingerprintInput); + if (!confidence) continue; + if (!matchedRule || rule.priority > matchedRule.priority) { + matchedRule = rule; + matchedConfidence = confidence; + } + } + + if (!matchedRule || !matchedConfidence) { + return null; + } + + return { + clientAppId: matchedRule.id, + clientAppName: matchedRule.name, + clientConfidence: matchedConfidence, + }; +} + +function detectExplicitClientSelfReport(headers: NormalizedClientHeaders): DownstreamResolvedClientApp | null { + for (const value of headers['x-openai-client-user-agent'] || []) { + const clientAppName = parseExplicitClientSelfReportValue(value); + if (!clientAppName) continue; + return { + clientAppId: normalizeClientAppId(clientAppName) || 'self_reported_client', + clientAppName, + clientConfidence: 'exact', + }; + } + + for (const value of headers['user-agent'] || []) { + const normalized = value.trim().toLowerCase(); + if (!normalized.startsWith('openclaw/')) continue; + return { + clientAppId: 'openclaw', + clientAppName: 'OpenClaw', + clientConfidence: 'exact', + }; + } + + return null; +} + +export function detectDownstreamClientContext(input: { + downstreamPath: string; + headers?: Record; + body?: unknown; +}): DownstreamClientContext { + const detected = detectCliProfile(input); + const normalizedHeaders = normalizeHeaders(input.headers); + const explicitSelfReport = detectExplicitClientSelfReport(normalizedHeaders); + const fingerprint = detectDownstreamClientFingerprint(input); + const profileClientApp = fingerprint || explicitSelfReport + ? null + : ( + detected.clientAppId && detected.clientAppName + ? { + clientAppId: detected.clientAppId, + clientAppName: detected.clientAppName, + ...(detected.clientConfidence ? { clientConfidence: detected.clientConfidence } : {}), + } + : null + ); + return { + clientKind: detected.id, + ...(detected.sessionId ? { sessionId: detected.sessionId } : {}), + ...(detected.traceHint ? { traceHint: detected.traceHint } : {}), + ...(explicitSelfReport || fingerprint || profileClientApp || {}), + }; +} diff --git a/src/server/routes/proxy/downstreamPolicy.ts b/src/server/routes/proxy/downstreamPolicy.ts index 8d763fdc..4863810a 100644 --- a/src/server/routes/proxy/downstreamPolicy.ts +++ b/src/server/routes/proxy/downstreamPolicy.ts @@ -4,7 +4,9 @@ import { isModelAllowedByPolicyOrAllowedRoutes, recordManagedKeyCostUsage } from import { EMPTY_DOWNSTREAM_ROUTING_POLICY, type DownstreamRoutingPolicy } from '../../services/downstreamPolicyTypes.js'; export function getDownstreamRoutingPolicy(request: FastifyRequest): DownstreamRoutingPolicy { - return getProxyAuthContext(request)?.policy || EMPTY_DOWNSTREAM_ROUTING_POLICY; + const authContext = getProxyAuthContext(request); + if (!authContext) return EMPTY_DOWNSTREAM_ROUTING_POLICY; + return authContext.policy; } export async function ensureModelAllowedForDownstreamKey( diff --git a/src/server/routes/proxy/embeddings.ts b/src/server/routes/proxy/embeddings.ts index 6f29e01b..be776702 100644 --- a/src/server/routes/proxy/embeddings.ts +++ b/src/server/routes/proxy/embeddings.ts @@ -1,43 +1,53 @@ import { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'; import { fetch } from 'undici'; -import { db, schema } from '../../db/index.js'; -import { tokenRouter } from '../../services/tokenRouter.js'; -import { refreshModelsAndRebuildRoutes } from '../../services/modelService.js'; -import { reportProxyAllFailed, reportTokenExpired } from '../../services/alertService.js'; -import { isTokenExpiredError } from '../../services/alertRules.js'; -import { shouldRetryProxyRequest } from '../../services/proxyRetryPolicy.js'; -import { resolveProxyUsageWithSelfLogFallback } from '../../services/proxyUsageFallbackService.js'; -import { parseProxyUsage } from '../../services/proxyUsageParser.js'; -import { ensureModelAllowedForDownstreamKey, getDownstreamRoutingPolicy, recordDownstreamCostUsage } from './downstreamPolicy.js'; -import { withSiteRecordProxyRequestInit } from '../../services/siteProxy.js'; -import { composeProxyLogMessage } from './logPathMeta.js'; -import { formatUtcSqlDateTime } from '../../services/localTimeService.js'; -import { resolveProxyLogBilling } from './proxyBilling.js'; - -const MAX_RETRIES = 2; +import { tokenRouter } from '../../services/tokenRouter.js'; +import * as routeRefreshWorkflow from '../../services/routeRefreshWorkflow.js'; +import { reportProxyAllFailed, reportTokenExpired } from '../../services/alertService.js'; +import { isTokenExpiredError } from '../../services/alertRules.js'; +import { shouldRetryProxyRequest } from '../../services/proxyRetryPolicy.js'; +import { resolveProxyUsageWithSelfLogFallback } from '../../services/proxyUsageFallbackService.js'; +import { parseProxyUsage } from '../../services/proxyUsageParser.js'; +import { ensureModelAllowedForDownstreamKey, getDownstreamRoutingPolicy, recordDownstreamCostUsage } from './downstreamPolicy.js'; +import { withSiteRecordProxyRequestInit } from '../../services/siteProxy.js'; +import { getProxyUrlFromExtraConfig } from '../../services/accountExtraConfig.js'; +import { composeProxyLogMessage } from './logPathMeta.js'; +import { formatUtcSqlDateTime } from '../../services/localTimeService.js'; +import { resolveProxyLogBilling } from './proxyBilling.js'; +import { getProxyAuthContext } from '../../middleware/auth.js'; +import { buildUpstreamUrl } from './upstreamUrl.js'; +import { detectDownstreamClientContext, type DownstreamClientContext } from './downstreamClientContext.js'; +import { insertProxyLog } from '../../services/proxyLogStore.js'; +import { canRetryProxyChannel, getProxyMaxChannelRetries } from '../../services/proxyChannelRetry.js'; export async function embeddingsProxyRoute(app: FastifyInstance) { app.post('/v1/embeddings', async (request: FastifyRequest, reply: FastifyReply) => { const body = request.body as any; - const requestedModel = body?.model; - if (!requestedModel) { - return reply.code(400).send({ error: { message: 'model is required', type: 'invalid_request_error' } }); - } - if (!await ensureModelAllowedForDownstreamKey(request, reply, requestedModel)) return; - const downstreamPolicy = getDownstreamRoutingPolicy(request); - - const excludeChannelIds: number[] = []; + const requestedModel = body?.model; + if (!requestedModel) { + return reply.code(400).send({ error: { message: 'model is required', type: 'invalid_request_error' } }); + } + if (!await ensureModelAllowedForDownstreamKey(request, reply, requestedModel)) return; + const downstreamPolicy = getDownstreamRoutingPolicy(request); + const downstreamApiKeyId = getProxyAuthContext(request)?.keyId ?? null; + const downstreamPath = '/v1/embeddings'; + const clientContext = detectDownstreamClientContext({ + downstreamPath, + headers: request.headers as Record, + body, + }); + + const excludeChannelIds: number[] = []; let retryCount = 0; - while (retryCount <= MAX_RETRIES) { - let selected = retryCount === 0 - ? await tokenRouter.selectChannel(requestedModel, downstreamPolicy) - : await tokenRouter.selectNextChannel(requestedModel, excludeChannelIds, downstreamPolicy); - - if (!selected && retryCount === 0) { - await refreshModelsAndRebuildRoutes(); - selected = await tokenRouter.selectChannel(requestedModel, downstreamPolicy); - } + while (retryCount <= getProxyMaxChannelRetries()) { + let selected = retryCount === 0 + ? await tokenRouter.selectChannel(requestedModel, downstreamPolicy) + : await tokenRouter.selectNextChannel(requestedModel, excludeChannelIds, downstreamPolicy); + + if (!selected && retryCount === 0) { + await routeRefreshWorkflow.refreshModelsAndRebuildRoutes(); + selected = await tokenRouter.selectChannel(requestedModel, downstreamPolicy); + } if (!selected) { await reportProxyAllFailed({ @@ -46,27 +56,47 @@ export async function embeddingsProxyRoute(app: FastifyInstance) { }); return reply.code(503).send({ error: { message: 'No available channels', type: 'server_error' } }); } - - excludeChannelIds.push(selected.channel.id); - - const targetUrl = `${selected.site.url}/v1/embeddings`; - const forwardBody = { ...body, model: selected.actualModel }; - const startTime = Date.now(); + excludeChannelIds.push(selected.channel.id); + + const targetUrl = buildUpstreamUrl(selected.site.url, '/v1/embeddings'); + const upstreamModel = selected.actualModel || requestedModel; + const forwardBody = { ...body, model: upstreamModel }; + const startTime = Date.now(); try { - const upstream = await fetch(targetUrl, withSiteRecordProxyRequestInit(selected.site, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${selected.tokenValue}`, - }, - body: JSON.stringify(forwardBody), - })); + const upstream = await fetch(targetUrl, withSiteRecordProxyRequestInit(selected.site, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${selected.tokenValue}`, + }, + body: JSON.stringify(forwardBody), + }, getProxyUrlFromExtraConfig(selected.account.extraConfig))); - const text = await upstream.text(); - if (!upstream.ok) { - tokenRouter.recordFailure(selected.channel.id); - logProxy(selected, requestedModel, 'failed', upstream.status, Date.now() - startTime, text, retryCount); + const text = await upstream.text(); + if (!upstream.ok) { + await recordTokenRouterEventBestEffort('record channel failure', () => tokenRouter.recordFailure(selected.channel.id, { + status: upstream.status, + errorText: text, + modelName: upstreamModel, + })); + logProxy( + selected, + requestedModel, + 'failed', + upstream.status, + Date.now() - startTime, + text, + retryCount, + downstreamApiKeyId, + 0, + 0, + 0, + 0, + null, + clientContext, + downstreamPath, + ); if (isTokenExpiredError({ status: upstream.status, message: text })) { await reportTokenExpired({ @@ -77,7 +107,7 @@ export async function embeddingsProxyRoute(app: FastifyInstance) { }); } - if (shouldRetryProxyRequest(upstream.status, text) && retryCount < MAX_RETRIES) { + if (shouldRetryProxyRequest(upstream.status, text) && canRetryProxyChannel(retryCount)) { retryCount++; continue; } @@ -116,20 +146,42 @@ export async function embeddingsProxyRoute(app: FastifyInstance) { resolvedUsage, }); - tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost); + await recordTokenRouterEventBestEffort('record channel success', () => ( + tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost, upstreamModel) + )); recordDownstreamCostUsage(request, estimatedCost); logProxy( - selected, requestedModel, 'success', upstream.status, latency, null, retryCount, - resolvedUsage.promptTokens, resolvedUsage.completionTokens, resolvedUsage.totalTokens, estimatedCost, billingDetails, + selected, requestedModel, 'success', upstream.status, latency, null, retryCount, downstreamApiKeyId, + resolvedUsage.promptTokens, resolvedUsage.completionTokens, resolvedUsage.totalTokens, estimatedCost, billingDetails, clientContext, downstreamPath, ); return reply.code(upstream.status).send(data); - } catch (err: any) { - tokenRouter.recordFailure(selected.channel.id); - logProxy(selected, requestedModel, 'failed', 0, Date.now() - startTime, err.message, retryCount); - if (retryCount < MAX_RETRIES) { - retryCount++; - continue; - } + } catch (err: any) { + await recordTokenRouterEventBestEffort('record channel failure', () => tokenRouter.recordFailure(selected.channel.id, { + status: 0, + errorText: err.message, + modelName: upstreamModel, + })); + logProxy( + selected, + requestedModel, + 'failed', + 0, + Date.now() - startTime, + err.message, + retryCount, + downstreamApiKeyId, + 0, + 0, + 0, + 0, + null, + clientContext, + downstreamPath, + ); + if (canRetryProxyChannel(retryCount)) { + retryCount++; + continue; + } await reportProxyAllFailed({ model: requestedModel, reason: err.message || 'network failure', @@ -148,38 +200,62 @@ async function logProxy( latencyMs: number, errorMessage: string | null, retryCount: number, - promptTokens = 0, - completionTokens = 0, - totalTokens = 0, - estimatedCost = 0, - billingDetails: unknown = null, -) { - try { - const createdAt = formatUtcSqlDateTime(new Date()); - const normalizedErrorMessage = composeProxyLogMessage({ - downstreamPath: '/v1/embeddings', - errorMessage, - }); - await db.insert(schema.proxyLogs).values({ - routeId: selected.channel.routeId, + downstreamApiKeyId: number | null = null, + promptTokens = 0, + completionTokens = 0, + totalTokens = 0, + estimatedCost = 0, + billingDetails: unknown = null, + clientContext: DownstreamClientContext | null = null, + downstreamPath = '/v1/embeddings', +) { + try { + const createdAt = formatUtcSqlDateTime(new Date()); + const normalizedErrorMessage = composeProxyLogMessage({ + clientKind: clientContext?.clientKind && clientContext.clientKind !== 'generic' + ? clientContext.clientKind + : null, + sessionId: clientContext?.sessionId || null, + traceHint: clientContext?.traceHint || null, + downstreamPath, + errorMessage, + }); + await insertProxyLog({ + routeId: selected.channel.routeId, channelId: selected.channel.id, accountId: selected.account.id, + downstreamApiKeyId, modelRequested, - modelActual: selected.actualModel, - status, - httpStatus, - latencyMs, - promptTokens, - completionTokens, - totalTokens, - estimatedCost, - billingDetails: billingDetails ? JSON.stringify(billingDetails) : null, - errorMessage: normalizedErrorMessage, - retryCount, - createdAt, - }).run(); + modelActual: selected.actualModel || modelRequested, + status, + httpStatus, + latencyMs, + promptTokens, + completionTokens, + totalTokens, + estimatedCost, + billingDetails, + clientFamily: clientContext?.clientKind || null, + clientAppId: clientContext?.clientAppId || null, + clientAppName: clientContext?.clientAppName || null, + clientConfidence: clientContext?.clientConfidence || null, + errorMessage: normalizedErrorMessage, + retryCount, + createdAt, + }); } catch (error) { console.warn('[proxy/embeddings] failed to write proxy log', error); } } + +async function recordTokenRouterEventBestEffort( + label: string, + operation: () => Promise, +): Promise { + try { + await operation(); + } catch (error) { + console.warn(`[proxy/embeddings] failed to ${label}`, error); + } +} diff --git a/src/server/routes/proxy/endpointFlow.test.ts b/src/server/routes/proxy/endpointFlow.test.ts index b0295664..89b02329 100644 --- a/src/server/routes/proxy/endpointFlow.test.ts +++ b/src/server/routes/proxy/endpointFlow.test.ts @@ -1,11 +1,15 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { fetch } from 'undici'; -import { executeEndpointFlow, type BuiltEndpointRequest } from './endpointFlow.js'; +import type { BuiltEndpointRequest } from './endpointFlow.js'; vi.mock('undici', () => ({ fetch: vi.fn(), })); +vi.mock('../../services/siteProxy.js', () => ({ + withSiteProxyRequestInit: async (_targetUrl: string, init: RequestInit) => init, +})); + const fetchMock = vi.mocked(fetch); function requestFor(path: string): BuiltEndpointRequest { @@ -22,6 +26,14 @@ function toUndiciResponse(response: Response): Awaited> } describe('executeEndpointFlow', () => { + let executeEndpointFlow: (input: any) => Promise; + + beforeEach(async () => { + if (!executeEndpointFlow) { + ({ executeEndpointFlow } = await import('./endpointFlow.js')); + } + }); + beforeEach(() => { fetchMock.mockReset(); }); @@ -42,9 +54,74 @@ describe('executeEndpointFlow', () => { if (result.ok) { expect(result.upstreamPath).toBe('/v1/responses'); } + expect(fetchMock.mock.calls[0]?.[0]).toBe('https://example.com/v1/responses'); expect(fetchMock).toHaveBeenCalledTimes(1); }); + it('uses the injected dispatchRequest hook instead of the default fetch path', async () => { + const dispatchRequest = vi.fn(async () => toUndiciResponse(new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }))); + + const result = await executeEndpointFlow({ + siteUrl: 'https://example.com', + endpointCandidates: ['responses'], + buildRequest: () => requestFor('/v1/responses'), + dispatchRequest, + }); + + expect(result.ok).toBe(true); + expect(dispatchRequest).toHaveBeenCalledTimes(1); + expect(dispatchRequest.mock.calls[0]?.[1]).toBe('https://example.com/v1/responses'); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('avoids duplicated /v1 when base url already ends with /v1', async () => { + fetchMock.mockResolvedValueOnce(toUndiciResponse(new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }))); + + await executeEndpointFlow({ + siteUrl: 'https://api.example.com/v1', + endpointCandidates: ['chat'], + buildRequest: () => ({ ...requestFor('/v1/chat/completions'), endpoint: 'chat' }), + }); + + expect(fetchMock.mock.calls[0]?.[0]).toBe('https://api.example.com/v1/chat/completions'); + }); + + it('avoids duplicated /v1 when base url already ends with /api/v1', async () => { + fetchMock.mockResolvedValueOnce(toUndiciResponse(new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }))); + + await executeEndpointFlow({ + siteUrl: 'https://openrouter.ai/api/v1', + endpointCandidates: ['chat'], + buildRequest: () => ({ ...requestFor('/v1/chat/completions'), endpoint: 'chat' }), + }); + + expect(fetchMock.mock.calls[0]?.[0]).toBe('https://openrouter.ai/api/v1/chat/completions'); + }); + + it('keeps url well-formed when base url includes query/hash', async () => { + fetchMock.mockResolvedValueOnce(toUndiciResponse(new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }))); + + await executeEndpointFlow({ + siteUrl: 'https://api.example.com/v1?foo=1#keep', + endpointCandidates: ['chat'], + buildRequest: () => ({ ...requestFor('/v1/chat/completions'), endpoint: 'chat' }), + }); + + expect(fetchMock.mock.calls[0]?.[0]).toBe('https://api.example.com/v1/chat/completions?foo=1#keep'); + }); + it('downgrades to next endpoint when policy allows', async () => { fetchMock .mockResolvedValueOnce(toUndiciResponse(new Response(JSON.stringify({ @@ -79,6 +156,40 @@ describe('executeEndpointFlow', () => { expect(fetchMock).toHaveBeenCalledTimes(2); }); + it('emits attempt callbacks for failed and successful endpoint probes', async () => { + fetchMock + .mockResolvedValueOnce(toUndiciResponse(new Response(JSON.stringify({ + error: { message: 'unsupported endpoint', type: 'invalid_request_error' }, + }), { + status: 404, + headers: { 'content-type': 'application/json' }, + }))) + .mockResolvedValueOnce(toUndiciResponse(new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }))); + + const onAttemptFailure = vi.fn(); + const onAttemptSuccess = vi.fn(); + + const result = await executeEndpointFlow({ + siteUrl: 'https://example.com', + endpointCandidates: ['responses', 'chat'], + buildRequest: (endpoint) => endpoint === 'responses' + ? requestFor('/v1/responses') + : { ...requestFor('/v1/chat/completions'), endpoint }, + shouldDowngrade: () => true, + onAttemptFailure, + onAttemptSuccess, + }); + + expect(result.ok).toBe(true); + expect(onAttemptFailure).toHaveBeenCalledTimes(1); + expect(onAttemptFailure.mock.calls[0]?.[0]?.request?.path).toBe('/v1/responses'); + expect(onAttemptSuccess).toHaveBeenCalledTimes(1); + expect(onAttemptSuccess.mock.calls[0]?.[0]?.request?.path).toBe('/v1/chat/completions'); + }); + it('accepts recovered response from tryRecover hook', async () => { fetchMock.mockResolvedValueOnce(toUndiciResponse(new Response(JSON.stringify({ error: { message: 'upstream_error', type: 'upstream_error' }, @@ -109,6 +220,99 @@ describe('executeEndpointFlow', () => { expect(fetchMock).toHaveBeenCalledTimes(1); }); + it('uses recovered request metadata for success callbacks', async () => { + fetchMock.mockResolvedValueOnce(toUndiciResponse(new Response(JSON.stringify({ + error: { message: 'upstream_error', type: 'upstream_error' }, + }), { + status: 400, + headers: { 'content-type': 'application/json' }, + }))); + + const recovered = toUndiciResponse(new Response(JSON.stringify({ ok: 'recovered' }), { + status: 200, + headers: { 'content-type': 'application/json' }, + })); + const onAttemptSuccess = vi.fn(); + + await executeEndpointFlow({ + siteUrl: 'https://example.com', + endpointCandidates: ['responses'], + buildRequest: () => requestFor('/v1/responses'), + tryRecover: async () => ({ + upstream: recovered, + upstreamPath: '/v1/messages', + request: { ...requestFor('/v1/messages'), endpoint: 'messages' }, + }), + onAttemptSuccess, + }); + + expect(onAttemptSuccess).toHaveBeenCalledTimes(1); + expect(onAttemptSuccess.mock.calls[0]?.[0]?.request?.path).toBe('/v1/messages'); + expect(onAttemptSuccess.mock.calls[0]?.[0]?.targetUrl).toBe('https://example.com/v1/messages'); + }); + + it('does not let attempt hook failures change routing', async () => { + fetchMock + .mockResolvedValueOnce(toUndiciResponse(new Response(JSON.stringify({ + error: { message: 'unsupported endpoint', type: 'invalid_request_error' }, + }), { + status: 404, + headers: { 'content-type': 'application/json' }, + }))) + .mockResolvedValueOnce(toUndiciResponse(new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }))); + + const result = await executeEndpointFlow({ + siteUrl: 'https://example.com', + endpointCandidates: ['responses', 'chat'], + buildRequest: (endpoint) => endpoint === 'responses' + ? requestFor('/v1/responses') + : { ...requestFor('/v1/chat/completions'), endpoint }, + shouldDowngrade: () => true, + onAttemptFailure: async () => { + throw new Error('failure hook should be ignored'); + }, + onAttemptSuccess: async () => { + throw new Error('success hook should be ignored'); + }, + }); + + expect(result.ok).toBe(true); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it('uses proxyUrl for the default fetch path when no dispatch hook is provided', async () => { + fetchMock.mockResolvedValueOnce(toUndiciResponse(new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }))); + + await executeEndpointFlow({ + siteUrl: 'https://example.com', + proxyUrl: 'https://proxy.internal/base', + endpointCandidates: ['responses'], + buildRequest: () => requestFor('/v1/responses'), + }); + + expect(fetchMock.mock.calls[0]?.[0]).toBe('https://proxy.internal/base/v1/responses'); + }); + it('normalizes proxyUrl with versioned base paths instead of duplicating path segments', async () => { + fetchMock.mockResolvedValueOnce(toUndiciResponse(new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }))); + + await executeEndpointFlow({ + siteUrl: 'https://example.com', + proxyUrl: 'https://proxy.internal/api/v1?mode=relay#frag', + endpointCandidates: ['responses'], + buildRequest: () => requestFor('/v1/responses'), + }); + + expect(fetchMock.mock.calls[0]?.[0]).toBe('https://proxy.internal/api/v1/responses?mode=relay#frag'); + }); it('returns normalized final error when all endpoints fail', async () => { fetchMock.mockResolvedValueOnce(toUndiciResponse(new Response(JSON.stringify({ error: { message: 'upstream_error', type: 'upstream_error' }, diff --git a/src/server/routes/proxy/endpointFlow.ts b/src/server/routes/proxy/endpointFlow.ts index ed0f2500..75dc5ba5 100644 --- a/src/server/routes/proxy/endpointFlow.ts +++ b/src/server/routes/proxy/endpointFlow.ts @@ -1,13 +1,22 @@ import { fetch } from 'undici'; -import { withExplicitProxyRequestInit } from '../../services/siteProxy.js'; +import { readRuntimeResponseText } from '../../proxy-core/executors/types.js'; +import { withSiteProxyRequestInit } from '../../services/siteProxy.js'; import { summarizeUpstreamError } from './upstreamError.js'; import type { UpstreamEndpoint } from './upstreamEndpoint.js'; +import { buildUpstreamUrl } from './upstreamUrl.js'; export type BuiltEndpointRequest = { endpoint: UpstreamEndpoint; path: string; headers: Record; body: Record; + runtime?: { + executor: 'default' | 'codex' | 'gemini-cli' | 'antigravity' | 'claude'; + modelName?: string; + stream?: boolean; + oauthProjectId?: string | null; + action?: 'generateContent' | 'streamGenerateContent' | 'countTokens'; + }; }; export type EndpointAttemptContext = { @@ -19,9 +28,19 @@ export type EndpointAttemptContext = { rawErrText: string; }; +export type EndpointAttemptSuccessContext = { + endpointIndex: number; + endpointCount: number; + request: BuiltEndpointRequest; + targetUrl: string; + response: Awaited>; +}; + export type EndpointRecoverResult = { upstream: Awaited>; upstreamPath: string; + request?: BuiltEndpointRequest; + targetUrl?: string; } | null; export type EndpointFlowResult = @@ -34,6 +53,7 @@ export type EndpointFlowResult = ok: false; status: number; errText: string; + rawErrText?: string; }; export function withUpstreamPath(path: string, message: string): string { @@ -45,11 +65,29 @@ type ExecuteEndpointFlowInput = { proxyUrl?: string | null; endpointCandidates: UpstreamEndpoint[]; buildRequest: (endpoint: UpstreamEndpoint, endpointIndex: number) => BuiltEndpointRequest; + dispatchRequest?: ( + request: BuiltEndpointRequest, + targetUrl: string, + ) => Promise>>; tryRecover?: (ctx: EndpointAttemptContext) => Promise; shouldDowngrade?: (ctx: EndpointAttemptContext) => boolean; onDowngrade?: (ctx: EndpointAttemptContext & { errText: string }) => void | Promise; + onAttemptFailure?: (ctx: EndpointAttemptContext & { errText: string }) => void | Promise; + onAttemptSuccess?: (ctx: EndpointAttemptSuccessContext) => void | Promise; }; +async function runEndpointFlowHook( + hook: ((ctx: T) => void | Promise) | undefined, + ctx: T, + hookName: string, +): Promise { + if (!hook) return; + try { + await hook(ctx); + } catch (error) { + console.error(`endpointFlow ${hookName} hook failed`, error); + } +} export async function executeEndpointFlow(input: ExecuteEndpointFlowInput): Promise { const endpointCount = input.endpointCandidates.length; if (endpointCount <= 0) { @@ -62,19 +100,32 @@ export async function executeEndpointFlow(input: ExecuteEndpointFlowInput): Prom let finalStatus = 0; let finalErrText = 'unknown error'; + let finalRawErrText: string | undefined; for (let endpointIndex = 0; endpointIndex < endpointCount; endpointIndex += 1) { const endpoint = input.endpointCandidates[endpointIndex] as UpstreamEndpoint; const request = input.buildRequest(endpoint, endpointIndex); - const targetUrl = `${input.siteUrl}${request.path}`; - - let response = await fetch(targetUrl, withExplicitProxyRequestInit(input.proxyUrl, { - method: 'POST', - headers: request.headers, - body: JSON.stringify(request.body), - })); + const defaultTarget = buildUpstreamUrl(input.siteUrl, request.path); + const targetUrl = input.proxyUrl + ? buildUpstreamUrl(input.proxyUrl, request.path) + : defaultTarget; + + let response = input.dispatchRequest + ? await input.dispatchRequest(request, targetUrl) + : await fetch(targetUrl, await withSiteProxyRequestInit(targetUrl, { + method: 'POST', + headers: request.headers, + body: JSON.stringify(request.body), + })); if (response.ok) { + await runEndpointFlowHook(input.onAttemptSuccess, { + endpointIndex, + endpointCount, + request, + targetUrl, + response, + }, 'onAttemptSuccess'); return { ok: true, upstream: response, @@ -82,7 +133,7 @@ export async function executeEndpointFlow(input: ExecuteEndpointFlowInput): Prom }; } - let rawErrText = await response.text().catch(() => 'unknown error'); + let rawErrText = await readRuntimeResponseText(response).catch(() => 'unknown error'); const baseContext: EndpointAttemptContext = { endpointIndex, endpointCount, @@ -95,6 +146,19 @@ export async function executeEndpointFlow(input: ExecuteEndpointFlowInput): Prom if (input.tryRecover) { const recovered = await input.tryRecover(baseContext); if (recovered?.upstream?.ok) { + const recoveredRequest = recovered.request ?? baseContext.request; + const recoveredTargetUrl = recovered.targetUrl ?? ( + input.proxyUrl + ? buildUpstreamUrl(input.proxyUrl, recovered.upstreamPath) + : buildUpstreamUrl(input.siteUrl, recovered.upstreamPath) + ); + await runEndpointFlowHook(input.onAttemptSuccess, { + endpointIndex, + endpointCount, + request: recoveredRequest, + targetUrl: recoveredTargetUrl, + response: recovered.upstream, + }, 'onAttemptSuccess'); return { ok: true, upstream: recovered.upstream, @@ -110,19 +174,24 @@ export async function executeEndpointFlow(input: ExecuteEndpointFlowInput): Prom baseContext.request.path, summarizeUpstreamError(response.status, rawErrText), ); + await runEndpointFlowHook(input.onAttemptFailure, { + ...baseContext, + errText, + }, 'onAttemptFailure'); const isLastEndpoint = endpointIndex >= endpointCount - 1; const shouldDowngrade = !isLastEndpoint && !!input.shouldDowngrade?.(baseContext); if (shouldDowngrade) { - await input.onDowngrade?.({ + await runEndpointFlowHook(input.onDowngrade, { ...baseContext, errText, - }); + }, 'onDowngrade'); continue; } finalStatus = response.status; finalErrText = errText; + finalRawErrText = rawErrText; break; } @@ -130,5 +199,6 @@ export async function executeEndpointFlow(input: ExecuteEndpointFlowInput): Prom ok: false, status: finalStatus || 502, errText: finalErrText || 'unknown error', + rawErrText: finalRawErrText, }; } diff --git a/src/server/routes/proxy/files.ts b/src/server/routes/proxy/files.ts index e4f56dc0..2299e055 100644 --- a/src/server/routes/proxy/files.ts +++ b/src/server/routes/proxy/files.ts @@ -1,159 +1 @@ -import { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'; -import { getProxyResourceOwner } from '../../middleware/auth.js'; -import { - getProxyFileByPublicIdForOwner, - getProxyFileContentByPublicIdForOwner, - listProxyFilesByOwner, - saveProxyFile, - softDeleteProxyFileByPublicIdForOwner, -} from '../../services/proxyFileStore.js'; -import { ensureMultipartBufferParser, parseMultipartFormData } from './multipart.js'; - -function invalidRequest(reply: FastifyReply, message: string) { - return reply.code(400).send({ error: { message, type: 'invalid_request_error' } }); -} - -function notFound(reply: FastifyReply, message = 'file not found') { - return reply.code(404).send({ error: { message, type: 'not_found_error' } }); -} - -function toUnixSeconds(sqlDateTime: string | null | undefined): number { - if (!sqlDateTime) return Math.floor(Date.now() / 1000); - const parsed = Date.parse(sqlDateTime.replace(' ', 'T') + 'Z'); - if (!Number.isFinite(parsed)) return Math.floor(Date.now() / 1000); - return Math.floor(parsed / 1000); -} - -function toFileObject(record: { - publicId: string; - filename: string; - mimeType: string; - purpose: string | null; - byteSize: number; - createdAt: string | null; -}) { - return { - id: record.publicId, - object: 'file', - bytes: record.byteSize, - created_at: toUnixSeconds(record.createdAt), - filename: record.filename, - purpose: record.purpose || 'assistants', - mime_type: record.mimeType, - }; -} - -function inferMimeTypeFromFilename(filename: string): string { - const normalized = filename.trim().toLowerCase(); - if (normalized.endsWith('.pdf')) return 'application/pdf'; - if (normalized.endsWith('.txt')) return 'text/plain'; - if (normalized.endsWith('.md') || normalized.endsWith('.markdown')) return 'text/markdown'; - if (normalized.endsWith('.json')) return 'application/json'; - if (normalized.endsWith('.png')) return 'image/png'; - if (normalized.endsWith('.jpg') || normalized.endsWith('.jpeg')) return 'image/jpeg'; - if (normalized.endsWith('.gif')) return 'image/gif'; - if (normalized.endsWith('.webp')) return 'image/webp'; - if (normalized.endsWith('.wav')) return 'audio/wav'; - if (normalized.endsWith('.mp3')) return 'audio/mpeg'; - return 'application/octet-stream'; -} - -function isSupportedProxyFileMimeType(mimeType: string): boolean { - return mimeType === 'application/pdf' - || mimeType === 'text/plain' - || mimeType === 'text/markdown' - || mimeType === 'application/json' - || mimeType.startsWith('image/') - || mimeType.startsWith('audio/'); -} - -export async function filesProxyRoute(app: FastifyInstance) { - ensureMultipartBufferParser(app); - - app.post('/v1/files', async (request: FastifyRequest, reply: FastifyReply) => { - const owner = getProxyResourceOwner(request); - if (!owner) { - return reply.code(401).send({ error: { message: 'Missing proxy auth context', type: 'authentication_error' } }); - } - - const formData = await parseMultipartFormData(request); - if (!formData) { - return invalidRequest(reply, 'multipart/form-data with a file field is required'); - } - - const fileEntry = formData.get('file'); - if (!fileEntry || typeof fileEntry !== 'object' || typeof (fileEntry as File).arrayBuffer !== 'function') { - return invalidRequest(reply, 'file field is required'); - } - - const filename = fileEntry.name || 'upload.bin'; - const mimeType = (fileEntry.type || inferMimeTypeFromFilename(filename)).trim().toLowerCase(); - if (!isSupportedProxyFileMimeType(mimeType)) { - return invalidRequest(reply, `unsupported file mime type: ${mimeType || 'application/octet-stream'}`); - } - - const purposeValue = formData.get('purpose'); - const purpose = typeof purposeValue === 'string' && purposeValue.trim().length > 0 - ? purposeValue.trim() - : 'assistants'; - const buffer = Buffer.from(await fileEntry.arrayBuffer()); - - const saved = await saveProxyFile({ - ...owner, - filename, - mimeType, - purpose, - contentBase64: buffer.toString('base64'), - }); - return reply.send(toFileObject(saved)); - }); - - app.get('/v1/files', async (request: FastifyRequest, reply: FastifyReply) => { - const owner = getProxyResourceOwner(request); - if (!owner) { - return reply.code(401).send({ error: { message: 'Missing proxy auth context', type: 'authentication_error' } }); - } - const files = await listProxyFilesByOwner(owner); - return reply.send({ - object: 'list', - data: files.map((item) => toFileObject(item)), - has_more: false, - }); - }); - - app.get<{ Params: { fileId: string } }>('/v1/files/:fileId', async (request, reply) => { - const owner = getProxyResourceOwner(request); - if (!owner) { - return reply.code(401).send({ error: { message: 'Missing proxy auth context', type: 'authentication_error' } }); - } - const file = await getProxyFileByPublicIdForOwner(request.params.fileId, owner); - if (!file) return notFound(reply); - return reply.send(toFileObject(file)); - }); - - app.get<{ Params: { fileId: string } }>('/v1/files/:fileId/content', async (request, reply) => { - const owner = getProxyResourceOwner(request); - if (!owner) { - return reply.code(401).send({ error: { message: 'Missing proxy auth context', type: 'authentication_error' } }); - } - const file = await getProxyFileContentByPublicIdForOwner(request.params.fileId, owner); - if (!file) return notFound(reply); - reply.type(file.mimeType); - reply.header('Content-Disposition', `inline; filename="${encodeURIComponent(file.filename)}"`); - return reply.send(file.buffer); - }); - - app.delete<{ Params: { fileId: string } }>('/v1/files/:fileId', async (request, reply) => { - const owner = getProxyResourceOwner(request); - if (!owner) { - return reply.code(401).send({ error: { message: 'Missing proxy auth context', type: 'authentication_error' } }); - } - const deleted = await softDeleteProxyFileByPublicIdForOwner(request.params.fileId, owner); - if (!deleted) return notFound(reply); - return reply.send({ - id: request.params.fileId, - object: 'file', - deleted: true, - }); - }); -} +export { filesProxyRoute } from '../../proxy-core/surfaces/filesSurface.js'; diff --git a/src/server/routes/proxy/gemini.test.ts b/src/server/routes/proxy/gemini.test.ts index b0abebe0..926eaa57 100644 --- a/src/server/routes/proxy/gemini.test.ts +++ b/src/server/routes/proxy/gemini.test.ts @@ -2,25 +2,70 @@ import Fastify, { type FastifyInstance } from 'fastify'; import { readFileSync } from 'node:fs'; import path from 'node:path'; import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { resetUpstreamEndpointRuntimeState } from './upstreamEndpoint.js'; const fetchMock = vi.fn(); +const fetchModelPricingCatalogMock = vi.fn(); +const refreshModelsAndRebuildRoutesMock = vi.fn(); +const refreshOauthAccessTokenSingleflightMock = vi.fn(); const selectChannelMock = vi.fn(); const selectNextChannelMock = vi.fn(); +const recordSuccessMock = vi.fn(); const recordFailureMock = vi.fn(); +const explainSelectionMock = vi.fn(); const invalidateTokenRouterCacheMock = vi.fn(); const authorizeDownstreamTokenMock = vi.fn(); const consumeManagedKeyRequestMock = vi.fn(); +const isModelAllowedByPolicyOrAllowedRoutesMock = vi.fn(); +const dbSelectAllMock = vi.fn(); +const dbSelectGetMock = vi.fn(); +const dbInsertValuesMock = vi.fn((_values?: unknown) => ({ + run: () => undefined, +})); +const dbInsertMock = vi.fn((_table?: unknown) => ({ + values: (values: unknown) => dbInsertValuesMock(values), +})); + +function createDbSelectChain() { + return { + from() { + return this; + }, + innerJoin() { + return this; + }, + where() { + return this; + }, + all: (...args: unknown[]) => dbSelectAllMock(...args), + get: (...args: unknown[]) => dbSelectGetMock(...args), + }; +} vi.mock('undici', () => ({ fetch: (...args: unknown[]) => fetchMock(...args), + Response: globalThis.Response, +})); + +vi.mock('../../services/modelPricingService.js', () => ({ + fetchModelPricingCatalog: (...args: unknown[]) => fetchModelPricingCatalogMock(...args), +})); + +vi.mock('../../services/modelService.js', () => ({ + refreshModelsAndRebuildRoutes: (...args: unknown[]) => refreshModelsAndRebuildRoutesMock(...args), +})); + +vi.mock('../../services/oauth/refreshSingleflight.js', () => ({ + refreshOauthAccessTokenSingleflight: (...args: unknown[]) => refreshOauthAccessTokenSingleflightMock(...args), })); vi.mock('../../services/tokenRouter.js', () => ({ tokenRouter: { selectChannel: (...args: unknown[]) => selectChannelMock(...args), selectNextChannel: (...args: unknown[]) => selectNextChannelMock(...args), + recordSuccess: (...args: unknown[]) => recordSuccessMock(...args), recordFailure: (...args: unknown[]) => recordFailureMock(...args), - explainSelection: vi.fn(async () => ({ selectedChannelId: 11 })), + explainSelection: (...args: unknown[]) => explainSelectionMock(...args), }, invalidateTokenRouterCache: (...args: unknown[]) => invalidateTokenRouterCacheMock(...args), })); @@ -28,10 +73,43 @@ vi.mock('../../services/tokenRouter.js', () => ({ vi.mock('../../services/downstreamApiKeyService.js', () => ({ authorizeDownstreamToken: (...args: unknown[]) => authorizeDownstreamTokenMock(...args), consumeManagedKeyRequest: (...args: unknown[]) => consumeManagedKeyRequestMock(...args), + isModelAllowedByPolicyOrAllowedRoutes: (...args: unknown[]) => isModelAllowedByPolicyOrAllowedRoutesMock(...args), +})); + +vi.mock('../../db/index.js', () => ({ + db: { + select: (..._args: unknown[]) => createDbSelectChain(), + insert: (arg: unknown) => dbInsertMock(arg), + }, + hasProxyLogBillingDetailsColumn: async () => false, + hasProxyLogClientColumns: async () => false, + hasProxyLogDownstreamApiKeyIdColumn: async () => false, + schema: { + proxyLogs: {}, + modelAvailability: { + modelName: Symbol('modelAvailability.modelName'), + accountId: Symbol('modelAvailability.accountId'), + available: Symbol('modelAvailability.available'), + }, + accounts: { + id: Symbol('accounts.id'), + siteId: Symbol('accounts.siteId'), + status: Symbol('accounts.status'), + }, + sites: { + id: Symbol('sites.id'), + status: Symbol('sites.status'), + }, + tokenRoutes: { + displayName: Symbol('tokenRoutes.displayName'), + enabled: Symbol('tokenRoutes.enabled'), + }, + }, })); function parseSsePayloads(body: string): Array> { return body + .replace(/\r\n/g, '\n') .split('\n\n') .map((block) => block.trim()) .filter(Boolean) @@ -78,41 +156,1130 @@ describe('gemini native proxy routes', () => { await app.register(proxyRoutes); }); - beforeEach(() => { - fetchMock.mockReset(); - selectChannelMock.mockReset(); - selectNextChannelMock.mockReset(); - recordFailureMock.mockReset(); - authorizeDownstreamTokenMock.mockReset(); - consumeManagedKeyRequestMock.mockReset(); - + beforeEach(() => { + fetchMock.mockReset(); + fetchModelPricingCatalogMock.mockReset(); + refreshModelsAndRebuildRoutesMock.mockReset(); + refreshOauthAccessTokenSingleflightMock.mockReset(); + selectChannelMock.mockReset(); + selectNextChannelMock.mockReset(); + recordSuccessMock.mockReset(); + recordFailureMock.mockReset(); + explainSelectionMock.mockReset(); + authorizeDownstreamTokenMock.mockReset(); + consumeManagedKeyRequestMock.mockReset(); + isModelAllowedByPolicyOrAllowedRoutesMock.mockReset(); + dbInsertMock.mockClear(); + dbInsertValuesMock.mockClear(); + dbSelectAllMock.mockReset(); + dbSelectGetMock.mockReset(); + + authorizeDownstreamTokenMock.mockResolvedValue({ + ok: true, + source: 'global', + token: 'sk-managed-gemini', + policy: {}, + }); + fetchModelPricingCatalogMock.mockResolvedValue(null); + refreshModelsAndRebuildRoutesMock.mockResolvedValue(undefined); + dbSelectGetMock.mockResolvedValue(null); + dbSelectAllMock.mockResolvedValue([]); + + selectChannelMock.mockReturnValue({ + channel: { id: 11, routeId: 22 }, + site: { id: 44, name: 'gemini-site', url: 'https://generativelanguage.googleapis.com', platform: 'gemini' }, + account: { id: 33, username: 'demo-user' }, + tokenName: 'default', + tokenValue: 'gemini-key', + actualModel: 'gemini-2.5-flash', + }); + selectNextChannelMock.mockReturnValue(null); + recordSuccessMock.mockResolvedValue(undefined); + recordFailureMock.mockResolvedValue(undefined); + resetUpstreamEndpointRuntimeState(); + explainSelectionMock.mockResolvedValue({ selectedChannelId: 11 }); + isModelAllowedByPolicyOrAllowedRoutesMock.mockResolvedValue(true); + }); + + afterAll(async () => { + await app.close(); + }); + + it('accepts x-goog-api-key on /v1beta/models and returns gemini model list shape', async () => { + fetchMock.mockResolvedValue(new Response(JSON.stringify({ + models: [ + { name: 'models/gemini-2.5-flash', displayName: 'Gemini 2.5 Flash' }, + ], + }), { + status: 200, + headers: { 'content-type': 'application/json' }, + })); + + const response = await app.inject({ + method: 'GET', + url: '/v1beta/models', + headers: { + 'x-goog-api-key': 'sk-managed-gemini', + }, + }); + + expect(response.statusCode).toBe(200); + expect(response.json()).toEqual({ + models: [ + { + name: 'models/gemini-2.5-flash', + displayName: 'Gemini 2.5 Flash', + }, + ], + }); + }); + + it('falls back to the next channel for listModels when first Gemini channel fails', async () => { + selectNextChannelMock.mockReturnValue({ + channel: { id: 12, routeId: 22 }, + site: { id: 45, name: 'gemini-site-2', url: 'https://generativelanguage.googleapis.com', platform: 'gemini' }, + account: { id: 34, username: 'demo-user-2' }, + tokenName: 'fallback', + tokenValue: 'gemini-key-2', + actualModel: 'gemini-2.5-flash', + }); + + fetchMock + .mockResolvedValueOnce(new Response(JSON.stringify({ + error: { message: 'first channel failed' }, + }), { + status: 500, + headers: { 'content-type': 'application/json' }, + })) + .mockResolvedValueOnce(new Response(JSON.stringify({ + models: [ + { name: 'models/gemini-2.5-flash', displayName: 'Gemini 2.5 Flash' }, + ], + }), { + status: 200, + headers: { 'content-type': 'application/json' }, + })); + + const response = await app.inject({ + method: 'GET', + url: '/v1beta/models', + headers: { + 'x-goog-api-key': 'sk-managed-gemini', + }, + }); + + expect(response.statusCode).toBe(200); + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(recordFailureMock).toHaveBeenCalledWith(11, expect.objectContaining({ + status: 500, + errorText: JSON.stringify({ error: { message: 'first channel failed' } }), + })); + const [firstUrl] = fetchMock.mock.calls[0] as [string, RequestInit]; + const [secondUrl] = fetchMock.mock.calls[1] as [string, RequestInit]; + expect(firstUrl).toContain('key=gemini-key'); + expect(secondUrl).toContain('key=gemini-key-2'); + }); + + it('serves gemini-cli model list from local static catalog without upstream fetch', async () => { + selectChannelMock.mockReturnValue({ + channel: { id: 21, routeId: 22 }, + site: { id: 55, name: 'gemini-cli-site', url: 'https://cloudcode-pa.googleapis.com', platform: 'gemini-cli' }, + account: { + id: 35, + username: 'gemini-cli-user@example.com', + extraConfig: JSON.stringify({ + credentialMode: 'session', + oauth: { + provider: 'gemini-cli', + email: 'gemini-cli-user@example.com', + projectId: 'project-demo', + }, + }), + }, + tokenName: 'default', + tokenValue: 'oauth-access-token', + actualModel: 'gemini-2.5-pro', + }); + explainSelectionMock.mockImplementation(async (modelName: string) => ( + modelName === 'gemini-2.5-pro' + ? { selectedChannelId: 21 } + : { selectedChannelId: undefined } + )); + + const response = await app.inject({ + method: 'GET', + url: '/v1beta/models', + headers: { + authorization: 'Bearer sk-managed-gemini', + }, + }); + + expect(response.statusCode).toBe(200); + expect(fetchMock).not.toHaveBeenCalled(); + expect(response.json()).toMatchObject({ + models: expect.arrayContaining([ + { + name: 'models/gemini-2.5-pro', + displayName: 'Gemini 2.5 Pro', + }, + ]), + }); + }); + + it('synthesizes /v1beta/models from locally available routed models for non-gemini upstreams', async () => { + selectChannelMock.mockReturnValue({ + channel: { id: 41, routeId: 22 }, + site: { id: 77, name: 'openai-site', url: 'https://api.openai.com', platform: 'openai' }, + account: { id: 37, username: 'openai-user@example.com' }, + tokenName: 'default', + tokenValue: 'openai-access-token', + actualModel: 'gpt-4.1', + }); + dbSelectAllMock + .mockResolvedValueOnce([ + { modelName: 'gpt-4.1' }, + { modelName: 'claude-sonnet-4-5-20250929' }, + ]) + .mockResolvedValueOnce([ + { displayName: 'gemini-2.5-flash' }, + ]); + + const response = await app.inject({ + method: 'GET', + url: '/v1beta/models', + headers: { + authorization: 'Bearer sk-managed-gemini', + }, + }); + + expect(response.statusCode).toBe(200); + expect(fetchMock).not.toHaveBeenCalled(); + expect(response.json()).toEqual({ + models: [ + { + name: 'models/claude-sonnet-4-5-20250929', + displayName: 'claude-sonnet-4-5-20250929', + }, + { + name: 'models/gemini-2.5-flash', + displayName: 'gemini-2.5-flash', + }, + { + name: 'models/gpt-4.1', + displayName: 'gpt-4.1', + }, + ], + }); + }); + + it('forwards native generateContent requests through the gemini route group', async () => { + fetchMock.mockResolvedValue(new Response(JSON.stringify({ + candidates: [ + { + content: { + parts: [{ text: 'hello from gemini' }], + role: 'model', + }, + finishReason: 'STOP', + }, + ], + }), { + status: 200, + headers: { 'content-type': 'application/json' }, + })); + + const response = await app.inject({ + method: 'POST', + url: '/v1beta/models/gemini-2.5-flash:generateContent', + headers: { + 'x-goog-api-key': 'sk-managed-gemini', + }, + payload: { + contents: [ + { + role: 'user', + parts: [{ text: 'hello' }], + }, + ], + }, + }); + + expect(response.statusCode).toBe(200); + const [targetUrl, requestInit] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(targetUrl).toContain('/v1beta/models/gemini-2.5-flash:generateContent'); + expect(targetUrl).toContain('key=gemini-key'); + expect(JSON.parse(String(requestInit.body))).toEqual({ + contents: [ + { + role: 'user', + parts: [{ text: 'hello' }], + }, + ], + }); + expect(response.json()).toEqual({ + responseId: '', + modelVersion: '', + candidates: [ + { + index: 0, + content: { + parts: [{ text: 'hello from gemini' }], + role: 'model', + }, + finishReason: 'STOP', + }, + ], + }); + }); + + it('wraps gemini-cli native generateContent requests and unwraps the response payload', async () => { + selectChannelMock.mockReturnValue({ + channel: { id: 31, routeId: 22 }, + site: { id: 66, name: 'gemini-cli-site', url: 'https://cloudcode-pa.googleapis.com', platform: 'gemini-cli' }, + account: { + id: 36, + username: 'gemini-cli-user@example.com', + extraConfig: JSON.stringify({ + credentialMode: 'session', + oauth: { + provider: 'gemini-cli', + email: 'gemini-cli-user@example.com', + projectId: 'project-demo', + }, + }), + }, + tokenName: 'default', + tokenValue: 'oauth-access-token', + actualModel: 'gemini-2.5-pro', + }); + fetchMock.mockResolvedValue(new Response(JSON.stringify({ + response: { + candidates: [ + { + content: { + parts: [{ text: 'hello from gemini cli' }], + role: 'model', + }, + finishReason: 'STOP', + }, + ], + }, + }), { + status: 200, + headers: { 'content-type': 'application/json' }, + })); + + const response = await app.inject({ + method: 'POST', + url: '/v1beta/models/gemini-2.5-pro:generateContent', + headers: { + authorization: 'Bearer sk-managed-gemini', + }, + payload: { + contents: [ + { + role: 'user', + parts: [{ text: 'hello' }], + }, + ], + }, + }); + + expect(response.statusCode).toBe(200); + const [targetUrl, requestInit] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(targetUrl).toBe('https://cloudcode-pa.googleapis.com/v1internal:generateContent'); + expect(requestInit.headers).toMatchObject({ + Authorization: 'Bearer oauth-access-token', + }); + expect((requestInit.headers as Record)['User-Agent']).toContain('GeminiCLI/'); + expect((requestInit.headers as Record)['X-Goog-Api-Client']).toContain('google-genai-sdk/'); + expect(JSON.parse(String(requestInit.body))).toEqual({ + project: 'project-demo', + model: 'gemini-2.5-pro', + request: { + contents: [ + { + role: 'user', + parts: [{ text: 'hello' }], + }, + ], + }, + }); + expect(response.json()).toEqual({ + responseId: '', + modelVersion: '', + candidates: [ + { + index: 0, + content: { + parts: [{ text: 'hello from gemini cli' }], + role: 'model', + }, + finishReason: 'STOP', + }, + ], + }); + }); + + it('refreshes gemini-cli oauth token and retries the same internal request on 401', async () => { + selectChannelMock.mockReturnValue({ + channel: { id: 31, routeId: 22 }, + site: { id: 66, name: 'gemini-cli-site', url: 'https://cloudcode-pa.googleapis.com', platform: 'gemini-cli' }, + account: { + id: 36, + username: 'gemini-cli-user@example.com', + accessToken: 'oauth-access-token', + extraConfig: JSON.stringify({ + credentialMode: 'session', + oauth: { + provider: 'gemini-cli', + email: 'gemini-cli-user@example.com', + projectId: 'project-before-refresh', + refreshToken: 'gemini-refresh-token', + }, + }), + }, + tokenName: 'default', + tokenValue: 'oauth-access-token', + actualModel: 'gemini-2.5-pro', + }); + refreshOauthAccessTokenSingleflightMock.mockResolvedValue({ + accountId: 36, + accessToken: 'refreshed-access-token', + extraConfig: JSON.stringify({ + credentialMode: 'session', + oauth: { + provider: 'gemini-cli', + email: 'gemini-cli-user@example.com', + projectId: 'project-after-refresh', + refreshToken: 'gemini-refresh-token', + }, + }), + }); + fetchMock + .mockResolvedValueOnce(new Response(JSON.stringify({ + error: { message: 'token expired' }, + }), { + status: 401, + headers: { 'content-type': 'application/json' }, + })) + .mockResolvedValueOnce(new Response(JSON.stringify({ + response: { + candidates: [ + { + content: { + parts: [{ text: 'hello after refresh' }], + role: 'model', + }, + finishReason: 'STOP', + }, + ], + }, + }), { + status: 200, + headers: { 'content-type': 'application/json' }, + })); + + const response = await app.inject({ + method: 'POST', + url: '/v1beta/models/gemini-2.5-pro:generateContent', + headers: { + authorization: 'Bearer sk-managed-gemini', + }, + payload: { + contents: [ + { + role: 'user', + parts: [{ text: 'hello' }], + }, + ], + }, + }); + + expect(response.statusCode).toBe(200); + expect(refreshOauthAccessTokenSingleflightMock).toHaveBeenCalledWith(36); + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(selectNextChannelMock).not.toHaveBeenCalled(); + + const [, firstRequestInit] = fetchMock.mock.calls[0] as [string, RequestInit]; + const [, secondRequestInit] = fetchMock.mock.calls[1] as [string, RequestInit]; + expect(firstRequestInit.headers).toMatchObject({ + Authorization: 'Bearer oauth-access-token', + }); + expect(secondRequestInit.headers).toMatchObject({ + Authorization: 'Bearer refreshed-access-token', + }); + expect(JSON.parse(String(secondRequestInit.body))).toEqual({ + project: 'project-after-refresh', + model: 'gemini-2.5-pro', + request: { + contents: [ + { + role: 'user', + parts: [{ text: 'hello' }], + }, + ], + }, + }); + expect(response.json()).toEqual({ + responseId: '', + modelVersion: '', + candidates: [ + { + index: 0, + content: { + parts: [{ text: 'hello after refresh' }], + role: 'model', + }, + finishReason: 'STOP', + }, + ], + }); + }); + + it('returns a server error when gemini-cli oauth project metadata is missing at runtime', async () => { + selectChannelMock.mockReturnValue({ + channel: { id: 31, routeId: 22 }, + site: { id: 66, name: 'gemini-cli-site', url: 'https://cloudcode-pa.googleapis.com', platform: 'gemini-cli' }, + account: { + id: 36, + username: 'gemini-cli-user@example.com', + accessToken: 'oauth-access-token', + extraConfig: JSON.stringify({ + credentialMode: 'session', + oauth: { + provider: 'gemini-cli', + email: 'gemini-cli-user@example.com', + }, + }), + }, + tokenName: 'default', + tokenValue: 'oauth-access-token', + actualModel: 'gemini-2.5-pro', + }); + selectNextChannelMock.mockReturnValue(null); + + const response = await app.inject({ + method: 'POST', + url: '/v1beta/models/gemini-2.5-pro:generateContent', + headers: { + authorization: 'Bearer sk-managed-gemini', + }, + payload: { + contents: [ + { + role: 'user', + parts: [{ text: 'hello' }], + }, + ], + }, + }); + + expect(response.statusCode).toBe(500); + expect(fetchMock).not.toHaveBeenCalled(); + expect(recordFailureMock).toHaveBeenCalledWith(31, { + status: 500, + errorText: 'Gemini CLI OAuth project is missing', + }); + expect(JSON.parse(response.body)).toEqual({ + error: { + message: 'Gemini CLI OAuth project is missing', + type: 'server_error', + }, + }); + }); + + it('routes Gemini native generateContent requests to openai upstreams and serializes the response back to Gemini shape', async () => { + selectChannelMock.mockReturnValue({ + channel: { id: 41, routeId: 22 }, + site: { id: 77, name: 'openai-site', url: 'https://api.openai.com', platform: 'openai' }, + account: { id: 37, username: 'openai-user@example.com' }, + tokenName: 'default', + tokenValue: 'openai-access-token', + actualModel: 'gpt-4.1', + }); + fetchMock.mockResolvedValue(new Response(JSON.stringify({ + id: 'chatcmpl-openai-1', + object: 'chat.completion', + created: 1_742_160_000, + model: 'gpt-4.1', + choices: [ + { + index: 0, + message: { + role: 'assistant', + content: 'hello from openai', + }, + finish_reason: 'stop', + }, + ], + usage: { + prompt_tokens: 4, + completion_tokens: 3, + total_tokens: 7, + }, + }), { + status: 200, + headers: { 'content-type': 'application/json' }, + })); + + const response = await app.inject({ + method: 'POST', + url: '/v1beta/models/gemini-2.5-flash:generateContent', + headers: { + authorization: 'Bearer sk-managed-gemini', + }, + payload: { + contents: [ + { + role: 'user', + parts: [{ text: 'hello' }], + }, + ], + }, + }); + + expect(response.statusCode).toBe(200); + const [targetUrl, requestInit] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(targetUrl).toBe('https://api.openai.com/v1/chat/completions'); + expect(requestInit.headers).toMatchObject({ + Authorization: 'Bearer openai-access-token', + 'Content-Type': 'application/json', + }); + expect(JSON.parse(String(requestInit.body))).toEqual({ + model: 'gpt-4.1', + stream: false, + messages: [ + { + role: 'user', + content: 'hello', + }, + ], + }); + expect(response.json()).toEqual({ + responseId: 'chatcmpl-openai-1', + modelVersion: 'gpt-4.1', + candidates: [ + { + index: 0, + content: { + role: 'model', + parts: [{ text: 'hello from openai' }], + }, + finishReason: 'STOP', + }, + ], + usageMetadata: { + promptTokenCount: 4, + candidatesTokenCount: 3, + totalTokenCount: 7, + }, + }); + }); + + it('serializes non-streaming generic upstream JSON into Gemini SSE when alt=sse is requested', async () => { + selectChannelMock.mockReturnValue({ + channel: { id: 41, routeId: 22 }, + site: { id: 77, name: 'openai-site', url: 'https://api.openai.com', platform: 'openai' }, + account: { id: 37, username: 'openai-user@example.com' }, + tokenName: 'default', + tokenValue: 'openai-access-token', + actualModel: 'gpt-4.1', + }); + fetchMock.mockResolvedValue(new Response(JSON.stringify({ + id: 'chatcmpl-openai-stream-1', + object: 'chat.completion', + created: 1_742_160_003, + model: 'gpt-4.1', + choices: [ + { + index: 0, + message: { + role: 'assistant', + content: 'hello from openai stream fallback', + }, + finish_reason: 'stop', + }, + ], + usage: { + prompt_tokens: 5, + completion_tokens: 6, + total_tokens: 11, + }, + }), { + status: 200, + headers: { 'content-type': 'application/json' }, + })); + + const response = await app.inject({ + method: 'POST', + url: '/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse', + headers: { + authorization: 'Bearer sk-managed-gemini', + }, + payload: { + contents: [ + { + role: 'user', + parts: [{ text: 'hello' }], + }, + ], + }, + }); + + expect(response.statusCode).toBe(200); + expect(response.headers['content-type']).toContain('text/event-stream'); + expect(parseSsePayloads(response.body)).toEqual([ + { + responseId: 'chatcmpl-openai-stream-1', + modelVersion: 'gpt-4.1', + candidates: [ + { + index: 0, + content: { + role: 'model', + parts: [{ text: 'hello from openai stream fallback' }], + }, + finishReason: 'STOP', + }, + ], + usageMetadata: { + promptTokenCount: 5, + candidatesTokenCount: 6, + totalTokenCount: 11, + }, + }, + ]); + }); + + it('exposes GeminiCLI downstream generateContent endpoint and wraps the downstream response payload', async () => { + selectChannelMock.mockReturnValue({ + channel: { id: 42, routeId: 22 }, + site: { id: 78, name: 'openai-site', url: 'https://api.openai.com', platform: 'openai' }, + account: { id: 38, username: 'openai-user@example.com' }, + tokenName: 'default', + tokenValue: 'openai-access-token', + actualModel: 'gpt-4.1', + }); + fetchMock.mockResolvedValue(new Response(JSON.stringify({ + id: 'chatcmpl-openai-2', + object: 'chat.completion', + created: 1_742_160_001, + model: 'gpt-4.1', + choices: [ + { + index: 0, + message: { + role: 'assistant', + content: 'hello from gemini cli downstream', + }, + finish_reason: 'stop', + }, + ], + usage: { + prompt_tokens: 6, + completion_tokens: 5, + total_tokens: 11, + }, + }), { + status: 200, + headers: { 'content-type': 'application/json' }, + })); + + const response = await app.inject({ + method: 'POST', + url: '/v1internal:generateContent', + headers: { + authorization: 'Bearer sk-managed-gemini', + }, + payload: { + model: 'gpt-4.1', + contents: [ + { + role: 'user', + parts: [{ text: 'hello' }], + }, + ], + }, + }); + + expect(response.statusCode).toBe(200); + const [targetUrl, requestInit] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(targetUrl).toBe('https://api.openai.com/v1/chat/completions'); + expect(JSON.parse(String(requestInit.body))).toEqual({ + model: 'gpt-4.1', + stream: false, + messages: [ + { + role: 'user', + content: 'hello', + }, + ], + }); + expect(response.json()).toEqual({ + response: { + responseId: 'chatcmpl-openai-2', + modelVersion: 'gpt-4.1', + candidates: [ + { + index: 0, + content: { + role: 'model', + parts: [{ text: 'hello from gemini cli downstream' }], + }, + finishReason: 'STOP', + }, + ], + usageMetadata: { + promptTokenCount: 6, + candidatesTokenCount: 5, + totalTokenCount: 11, + }, + }, + }); + }); + + it('routes Gemini native document requests to responses endpoints on openai-compatible upstreams', async () => { + selectChannelMock.mockReturnValue({ + channel: { id: 42, routeId: 22 }, + site: { id: 78, name: 'openai-site', url: 'https://api.openai.com', platform: 'openai' }, + account: { id: 38, username: 'openai-user@example.com' }, + tokenName: 'default', + tokenValue: 'openai-access-token', + actualModel: 'gpt-4.1', + }); + fetchMock.mockImplementation(async (target: unknown) => { + const url = String(target); + if (url === 'https://api.openai.com/v1/responses') { + return new Response(JSON.stringify({ + id: 'resp-openai-file-1', + object: 'response', + model: 'gpt-4.1', + output: [ + { + type: 'message', + role: 'assistant', + content: [{ type: 'output_text', text: 'document summary from responses' }], + }, + ], + usage: { + input_tokens: 9, + output_tokens: 4, + total_tokens: 13, + }, + }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + } + if (url === 'https://api.openai.com/v1/chat/completions') { + return new Response(JSON.stringify({ + id: 'chatcmpl-openai-file-1', + object: 'chat.completion', + created: 1_742_160_002, + model: 'gpt-4.1', + choices: [ + { + index: 0, + message: { + role: 'assistant', + content: 'document summary from chat', + }, + finish_reason: 'stop', + }, + ], + }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + } + throw new Error(`unexpected target url: ${url}`); + }); + + const response = await app.inject({ + method: 'POST', + url: '/v1beta/models/gemini-2.5-flash:generateContent', + headers: { + authorization: 'Bearer sk-managed-gemini', + }, + payload: { + contents: [ + { + role: 'user', + parts: [ + { text: 'summarize this pdf' }, + { + fileData: { + fileUri: 'https://example.com/brief.pdf', + mimeType: 'application/pdf', + }, + }, + ], + }, + ], + }, + }); + + expect(response.statusCode).toBe(200); + const [targetUrl] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(targetUrl).toBe('https://api.openai.com/v1/responses'); + }); + + it('routes Gemini native generateContent requests to antigravity upstreams through the internal content endpoint', async () => { + selectChannelMock.mockReturnValue({ + channel: { id: 43, routeId: 22 }, + site: { id: 79, name: 'antigravity-site', url: 'https://cloudcode-pa.googleapis.com', platform: 'antigravity' }, + account: { + id: 39, + username: 'antigravity-user@example.com', + extraConfig: JSON.stringify({ + oauth: { + provider: 'antigravity', + email: 'antigravity-user@example.com', + projectId: 'project-demo', + }, + }), + }, + tokenName: 'default', + tokenValue: 'antigravity-access-token', + actualModel: 'gemini-3-pro-preview', + }); + fetchMock.mockResolvedValue(new Response(JSON.stringify({ + response: { + responseId: 'antigravity-response-1', + modelVersion: 'gemini-3-pro-preview', + candidates: [ + { + content: { + role: 'model', + parts: [{ text: 'hello from antigravity' }], + }, + finishReason: 'STOP', + }, + ], + usageMetadata: { + promptTokenCount: 8, + candidatesTokenCount: 4, + totalTokenCount: 12, + }, + }, + }), { + status: 200, + headers: { 'content-type': 'application/json' }, + })); + + const response = await app.inject({ + method: 'POST', + url: '/v1beta/models/gemini-3-pro-preview:generateContent', + headers: { + authorization: 'Bearer sk-managed-gemini', + }, + payload: { + contents: [ + { + role: 'user', + parts: [{ text: 'hello' }], + }, + ], + }, + }); + + expect(response.statusCode).toBe(200); + const [targetUrl, requestInit] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(targetUrl).toBe('https://daily-cloudcode-pa.googleapis.com/v1internal:generateContent'); + expect(requestInit.headers).toMatchObject({ + Authorization: 'Bearer antigravity-access-token', + 'Content-Type': 'application/json', + Accept: 'application/json', + 'User-Agent': 'antigravity/1.19.6 darwin/arm64', + }); + const upstreamBody = JSON.parse(String(requestInit.body)); + expect(upstreamBody).toMatchObject({ + project: 'project-demo', + model: 'gemini-3-pro-preview', + userAgent: 'antigravity', + requestType: 'agent', + request: { + sessionId: expect.any(String), + contents: [ + { + role: 'user', + parts: [{ text: 'hello' }], + }, + ], + }, + }); + expect(upstreamBody.requestId).toMatch(/^agent-[0-9a-f-]{36}$/i); + expect(String(upstreamBody.request.sessionId)).toMatch(/^-\d+$/); + expect(response.json()).toEqual({ + responseId: 'antigravity-response-1', + modelVersion: 'gemini-3-pro-preview', + candidates: [ + { + index: 0, + content: { + role: 'model', + parts: [{ text: 'hello from antigravity' }], + }, + finishReason: 'STOP', + }, + ], + usageMetadata: { + promptTokenCount: 8, + candidatesTokenCount: 4, + totalTokenCount: 12, + }, + }); + }); + + it('exposes GeminiCLI downstream streamGenerateContent endpoint and preserves GeminiCLI response envelopes', async () => { + selectChannelMock.mockReturnValue({ + channel: { id: 44, routeId: 22 }, + site: { id: 80, name: 'gemini-cli-site', url: 'https://cloudcode-pa.googleapis.com', platform: 'gemini-cli' }, + account: { + id: 40, + username: 'gemini-cli-user@example.com', + extraConfig: JSON.stringify({ + oauth: { + provider: 'gemini-cli', + email: 'gemini-cli-user@example.com', + projectId: 'project-demo', + }, + }), + }, + tokenName: 'default', + tokenValue: 'gemini-cli-access-token', + actualModel: 'gemini-2.5-pro', + }); + + const encoder = new TextEncoder(); + const upstreamBody = new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode('data: {"response":{"responseId":"cli-stream-1","candidates":[{"content":{"role":"model","parts":[{"text":"hello from cli stream"}]},"finishReason":"STOP"}]}}\n\n')); + controller.enqueue(encoder.encode('data: [DONE]\n\n')); + controller.close(); + }, + }); + + fetchMock.mockResolvedValue(new Response(upstreamBody, { + status: 200, + headers: { 'content-type': 'text/event-stream; charset=utf-8' }, + })); + + const response = await app.inject({ + method: 'POST', + url: '/v1internal:streamGenerateContent', + headers: { + authorization: 'Bearer sk-managed-gemini', + }, + payload: { + model: 'gemini-2.5-pro', + contents: [ + { + role: 'user', + parts: [{ text: 'hello' }], + }, + ], + }, + }); + + expect(response.statusCode).toBe(200); + expect(response.headers['content-type']).toContain('text/event-stream'); + expect(response.body).toContain('data: {"response":{"responseId":"cli-stream-1"'); + expect(response.body).toContain('hello from cli stream'); + }); + + it('exposes GeminiCLI downstream countTokens endpoint', async () => { + selectChannelMock.mockReturnValue({ + channel: { id: 45, routeId: 22 }, + site: { id: 81, name: 'gemini-cli-site', url: 'https://cloudcode-pa.googleapis.com', platform: 'gemini-cli' }, + account: { + id: 41, + username: 'gemini-cli-user@example.com', + extraConfig: JSON.stringify({ + oauth: { + provider: 'gemini-cli', + email: 'gemini-cli-user@example.com', + projectId: 'project-demo', + }, + }), + }, + tokenName: 'default', + tokenValue: 'gemini-cli-access-token', + actualModel: 'gemini-2.5-pro', + }); + fetchMock.mockResolvedValue(new Response(JSON.stringify({ + totalTokens: 13, + promptTokensDetails: [ + { + modality: 'TEXT', + tokenCount: 13, + }, + ], + }), { + status: 200, + headers: { 'content-type': 'application/json' }, + })); + + const response = await app.inject({ + method: 'POST', + url: '/v1internal:countTokens', + headers: { + authorization: 'Bearer sk-managed-gemini', + }, + payload: { + model: 'gemini-2.5-pro', + contents: [ + { + role: 'user', + parts: [{ text: 'hello' }], + }, + ], + }, + }); + + expect(response.statusCode).toBe(200); + const [targetUrl, requestInit] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(targetUrl).toBe('https://cloudcode-pa.googleapis.com/v1internal:countTokens'); + expect(JSON.parse(String(requestInit.body))).toEqual({ + request: { + contents: [ + { + role: 'user', + parts: [{ text: 'hello' }], + }, + ], + }, + }); + expect(response.json()).toEqual({ + totalTokens: 13, + promptTokensDetails: [ + { + modality: 'TEXT', + tokenCount: 13, + }, + ], + }); + }); + + it('filters Gemini native model list by downstream managed-key policy', async () => { authorizeDownstreamTokenMock.mockResolvedValue({ ok: true, - source: 'global', + source: 'managed', token: 'sk-managed-gemini', - policy: {}, - }); - - selectChannelMock.mockReturnValue({ - channel: { id: 11, routeId: 22 }, - site: { id: 44, name: 'gemini-site', url: 'https://generativelanguage.googleapis.com', platform: 'gemini' }, - account: { id: 33, username: 'demo-user' }, - tokenName: 'default', - tokenValue: 'gemini-key', - actualModel: 'gemini-2.5-flash', + key: { id: 91 }, + policy: { supportedModels: ['gemini-2.5-flash'], allowedRouteIds: [], siteWeightMultipliers: {} }, }); - selectNextChannelMock.mockReturnValue(null); - recordFailureMock.mockResolvedValue(undefined); - }); - - afterAll(async () => { - await app.close(); - }); - - it('accepts x-goog-api-key on /v1beta/models and returns gemini model list shape', async () => { + isModelAllowedByPolicyOrAllowedRoutesMock.mockImplementation(async (modelName: string) => modelName === 'gemini-2.5-flash'); + explainSelectionMock.mockImplementation(async (modelName: string) => ( + modelName === 'gemini-2.5-flash' + ? { selectedChannelId: 11 } + : { selectedChannelId: undefined } + )); fetchMock.mockResolvedValue(new Response(JSON.stringify({ models: [ { name: 'models/gemini-2.5-flash', displayName: 'Gemini 2.5 Flash' }, + { name: 'models/gemini-2.0-flash', displayName: 'Gemini 2.0 Flash' }, ], }), { status: 200, @@ -123,7 +1290,7 @@ describe('gemini native proxy routes', () => { method: 'GET', url: '/v1beta/models', headers: { - 'x-goog-api-key': 'sk-managed-gemini', + authorization: 'Bearer sk-managed-gemini', }, }); @@ -136,66 +1303,90 @@ describe('gemini native proxy routes', () => { }, ], }); + expect(isModelAllowedByPolicyOrAllowedRoutesMock).toHaveBeenCalledWith('gemini-2.5-flash', { supportedModels: ['gemini-2.5-flash'], allowedRouteIds: [], siteWeightMultipliers: {} }); + expect(isModelAllowedByPolicyOrAllowedRoutesMock).toHaveBeenCalledWith('gemini-2.0-flash', { supportedModels: ['gemini-2.5-flash'], allowedRouteIds: [], siteWeightMultipliers: {} }); }); - it('falls back to the next channel for listModels when first Gemini channel fails', async () => { - selectNextChannelMock.mockReturnValue({ - channel: { id: 12, routeId: 22 }, - site: { id: 45, name: 'gemini-site-2', url: 'https://generativelanguage.googleapis.com', platform: 'gemini' }, - account: { id: 34, username: 'demo-user-2' }, - tokenName: 'fallback', - tokenValue: 'gemini-key-2', - actualModel: 'gemini-2.5-flash', - }); - - fetchMock - .mockResolvedValueOnce(new Response(JSON.stringify({ - error: { message: 'first channel failed' }, - }), { - status: 500, - headers: { 'content-type': 'application/json' }, - })) - .mockResolvedValueOnce(new Response(JSON.stringify({ - models: [ - { name: 'models/gemini-2.5-flash', displayName: 'Gemini 2.5 Flash' }, - ], - }), { - status: 200, - headers: { 'content-type': 'application/json' }, - })); + it('writes a proxy log row for successful native generateContent requests', async () => { + fetchMock.mockResolvedValue(new Response(JSON.stringify({ + candidates: [ + { + content: { + parts: [{ text: 'hello from gemini' }], + role: 'model', + }, + finishReason: 'STOP', + }, + ], + usageMetadata: { + promptTokenCount: 10, + candidatesTokenCount: 5, + totalTokenCount: 15, + }, + }), { + status: 200, + headers: { 'content-type': 'application/json' }, + })); const response = await app.inject({ - method: 'GET', - url: '/v1beta/models', + method: 'POST', + url: '/v1beta/models/gemini-2.5-flash:generateContent', headers: { 'x-goog-api-key': 'sk-managed-gemini', }, + payload: { + contents: [ + { + role: 'user', + parts: [{ text: 'hello' }], + }, + ], + }, }); expect(response.statusCode).toBe(200); - expect(fetchMock).toHaveBeenCalledTimes(2); - expect(recordFailureMock).toHaveBeenCalledWith(11); - const [firstUrl] = fetchMock.mock.calls[0] as [string, RequestInit]; - const [secondUrl] = fetchMock.mock.calls[1] as [string, RequestInit]; - expect(firstUrl).toContain('key=gemini-key'); - expect(secondUrl).toContain('key=gemini-key-2'); + expect(recordSuccessMock).toHaveBeenCalledWith(11, expect.any(Number), 0, 'gemini-2.5-flash'); + expect(dbInsertMock).toHaveBeenCalledTimes(1); + expect(dbInsertValuesMock).toHaveBeenCalledWith(expect.objectContaining({ + routeId: 22, + channelId: 11, + accountId: 33, + modelRequested: 'gemini-2.5-flash', + modelActual: 'gemini-2.5-flash', + status: 'success', + httpStatus: 200, + retryCount: 0, + promptTokens: 10, + completionTokens: 5, + totalTokens: 15, + errorMessage: '[downstream:/v1beta/models/gemini-2.5-flash:generateContent] [upstream:/v1beta/models/gemini-2.5-flash:generateContent]', + createdAt: expect.any(String), + })); }); - it('forwards native generateContent requests through the gemini route group', async () => { + it('keeps returning a successful Gemini response when channel success bookkeeping fails', async () => { fetchMock.mockResolvedValue(new Response(JSON.stringify({ candidates: [ { content: { - parts: [{ text: 'hello from gemini' }], + parts: [{ text: 'hello despite bookkeeping failure' }], role: 'model', }, finishReason: 'STOP', }, ], + usageMetadata: { + promptTokenCount: 10, + candidatesTokenCount: 5, + totalTokenCount: 15, + }, }), { status: 200, headers: { 'content-type': 'application/json' }, })); + recordSuccessMock.mockImplementation(async () => { + throw new Error('record success failed'); + }); const response = await app.inject({ method: 'POST', @@ -214,17 +1405,8 @@ describe('gemini native proxy routes', () => { }); expect(response.statusCode).toBe(200); - const [targetUrl, requestInit] = fetchMock.mock.calls[0] as [string, RequestInit]; - expect(targetUrl).toContain('/v1beta/models/gemini-2.5-flash:generateContent'); - expect(targetUrl).toContain('key=gemini-key'); - expect(JSON.parse(String(requestInit.body))).toEqual({ - contents: [ - { - role: 'user', - parts: [{ text: 'hello' }], - }, - ], - }); + expect(selectNextChannelMock).not.toHaveBeenCalled(); + expect(recordFailureMock).not.toHaveBeenCalled(); expect(response.json()).toEqual({ responseId: '', modelVersion: '', @@ -232,12 +1414,17 @@ describe('gemini native proxy routes', () => { { index: 0, content: { - parts: [{ text: 'hello from gemini' }], + parts: [{ text: 'hello despite bookkeeping failure' }], role: 'model', }, finishReason: 'STOP', }, ], + usageMetadata: { + promptTokenCount: 10, + candidatesTokenCount: 5, + totalTokenCount: 15, + }, }); }); @@ -390,21 +1577,32 @@ describe('gemini native proxy routes', () => { }); }); - it('keeps non-sse json-array streaming payloads on the wire as chunk responses', async () => { - fetchMock.mockResolvedValue(new Response(JSON.stringify([ + it('keeps non-sse json-array streaming payloads on the wire as raw chunk responses', async () => { + const upstreamPayload = [ { + promptFeedback: { blockReason: 'BLOCK_REASON_UNSPECIFIED' }, candidates: [ { - content: { role: 'model', parts: [{ text: 'first' }] }, + content: { + role: 'model', + parts: [ + { + functionCall: { id: 'tool-1', name: 'lookup_weather', args: { city: 'Shanghai' } }, + thoughtSignature: 'sig-tool-1', + }, + ], + }, groundingMetadata: { source: 'web' }, }, ], }, { + serverContent: { modelTurn: { parts: [{ text: 'tool result received' }] } }, candidates: [ { content: { role: 'model', parts: [{ text: 'second', thoughtSignature: 'sig-1' }] }, citationMetadata: { citations: [{ startIndex: 0, endIndex: 5 }] }, + finishReason: 'STOP', }, ], usageMetadata: { @@ -415,7 +1613,9 @@ describe('gemini native proxy routes', () => { thoughtsTokenCount: 3, }, }, - ]), { + ]; + + fetchMock.mockResolvedValue(new Response(JSON.stringify(upstreamPayload), { status: 200, headers: { 'content-type': 'application/json' }, })); @@ -437,45 +1637,7 @@ describe('gemini native proxy routes', () => { }); expect(response.statusCode).toBe(200); - expect(response.json()).toEqual([ - { - responseId: '', - modelVersion: '', - candidates: [ - { - index: 0, - finishReason: 'STOP', - content: { - role: 'model', - parts: [{ text: 'first' }], - }, - groundingMetadata: { source: 'web' }, - }, - ], - }, - { - responseId: '', - modelVersion: '', - candidates: [ - { - index: 0, - finishReason: 'STOP', - content: { - role: 'model', - parts: [{ text: 'second', thoughtSignature: 'sig-1' }], - }, - citationMetadata: { citations: [{ startIndex: 0, endIndex: 5 }] }, - }, - ], - usageMetadata: { - promptTokenCount: 11, - candidatesTokenCount: 6, - totalTokenCount: 17, - cachedContentTokenCount: 2, - thoughtsTokenCount: 3, - }, - }, - ]); + expect(response.json()).toEqual(upstreamPayload); }); it('derives gemini-3 thinkingLevel from OpenAI-style reasoning inputs in the runtime request path', async () => { @@ -535,12 +1697,13 @@ describe('gemini native proxy routes', () => { }); }); - it('streams SSE payloads as normalized chunk events instead of cumulative aggregate snapshots', async () => { + it('streams SSE payloads as raw upstream events without reserializing tool-calling chunks', async () => { const encoder = new TextEncoder(); const upstreamBody = new ReadableStream({ start(controller) { - controller.enqueue(encoder.encode('data: {"responseId":"resp-sse","modelVersion":"gemini-2.5-flash","candidates":[{"content":{"role":"model","parts":[{"text":"first"}]},"groundingMetadata":{"source":"web"}}]}\r\n\r\n')); - controller.enqueue(encoder.encode('data: {"candidates":[{"content":{"role":"model","parts":[{"text":"second","thoughtSignature":"sig-1"}]},"citationMetadata":{"citations":[{"startIndex":0,"endIndex":5}]},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":11,"candidatesTokenCount":6,"totalTokenCount":17,"cachedContentTokenCount":2,"thoughtsTokenCount":3}}\r\n\r\n')); + controller.enqueue(encoder.encode('data: {"promptFeedback":{"blockReason":"BLOCK_REASON_UNSPECIFIED"},"candidates":[{"content":{"role":"model","parts":[{"functionCall":{"id":"tool-1","name":"lookup_weather","args":{"city":"Shanghai"}},"thoughtSignature":"sig-tool-1"}]},"groundingMetadata":{"source":"web"}}]}\r\n\r\n')); + controller.enqueue(encoder.encode('data: {"serverContent":{"modelTurn":{"parts":[{"text":"tool result received"}]}},"candidates":[{"content":{"role":"model","parts":[{"text":"second","thoughtSignature":"sig-1"}]},"citationMetadata":{"citations":[{"startIndex":0,"endIndex":5}]},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":11,"candidatesTokenCount":6,"totalTokenCount":17,"cachedContentTokenCount":2,"thoughtsTokenCount":3}}\r\n\r\n')); + controller.enqueue(encoder.encode('data: [DONE]\r\n\r\n')); controller.close(); }, }); @@ -572,25 +1735,30 @@ describe('gemini native proxy routes', () => { expect(events).toHaveLength(2); expect(events[0]).toMatchObject({ - responseId: 'resp-sse', - modelVersion: 'gemini-2.5-flash', + promptFeedback: { blockReason: 'BLOCK_REASON_UNSPECIFIED' }, candidates: [ { content: { - parts: [{ text: 'first' }], + parts: [ + { + functionCall: { id: 'tool-1', name: 'lookup_weather', args: { city: 'Shanghai' } }, + thoughtSignature: 'sig-tool-1', + }, + ], }, groundingMetadata: { source: 'web' }, }, ], }); - expect(events[0]).not.toMatchObject({ - candidates: [ - { - citationMetadata: expect.anything(), - }, - ], - }); + expect(events[0]).not.toHaveProperty('responseId'); + expect(events[0]).not.toHaveProperty('modelVersion'); + expect(events[0].candidates?.[0]).not.toHaveProperty('finishReason'); expect(events[1]).toMatchObject({ + serverContent: { + modelTurn: { + parts: [{ text: 'tool result received' }], + }, + }, usageMetadata: { promptTokenCount: 11, candidatesTokenCount: 6, @@ -608,14 +1776,50 @@ describe('gemini native proxy routes', () => { }, ], }); - expect(events[1]).not.toMatchObject({ - candidates: [ - { - groundingMetadata: expect.anything(), - }, - ], + expect(response.body).toContain('\r\n\r\n'); + expect(response.body).toContain('data: [DONE]\r\n\r\n'); + }); + + it('does not retry a Gemini SSE stream when channel success bookkeeping fails after bytes are written', async () => { + const encoder = new TextEncoder(); + const upstreamBody = new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode('data: {"candidates":[{"content":{"role":"model","parts":[{"text":"hello after bookkeeping failure"}]},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7,"candidatesTokenCount":4,"totalTokenCount":11}}\r\n\r\n')); + controller.enqueue(encoder.encode('data: [DONE]\r\n\r\n')); + controller.close(); + }, + }); + + fetchMock.mockResolvedValue(new Response(upstreamBody, { + status: 200, + headers: { 'content-type': 'text/event-stream; charset=utf-8' }, + })); + recordSuccessMock.mockImplementation(async () => { + throw new Error('record success failed'); + }); + + const response = await app.inject({ + method: 'POST', + url: '/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse', + headers: { + 'x-goog-api-key': 'sk-managed-gemini', + }, + payload: { + contents: [ + { + role: 'user', + parts: [{ text: 'hello' }], + }, + ], + }, }); - expect(response.body).not.toContain('\r\n\r\n'); + + expect(response.statusCode).toBe(200); + expect(response.headers['content-type']).toContain('text/event-stream'); + expect(response.body).toContain('hello after bookkeeping failure'); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(selectNextChannelMock).not.toHaveBeenCalled(); + expect(recordFailureMock).not.toHaveBeenCalled(); }); it('falls back to the next channel when first Gemini channel returns 400 before any bytes are written', async () => { @@ -660,7 +1864,10 @@ describe('gemini native proxy routes', () => { expect(response.statusCode).toBe(200); expect(fetchMock).toHaveBeenCalledTimes(2); - expect(recordFailureMock).toHaveBeenCalledWith(11); + expect(recordFailureMock).toHaveBeenCalledWith(11, expect.objectContaining({ + status: 400, + errorText: JSON.stringify({ error: { message: 'bad request on first channel' } }), + })); const [firstUrl] = fetchMock.mock.calls[0] as [string, RequestInit]; const [secondUrl] = fetchMock.mock.calls[1] as [string, RequestInit]; expect(firstUrl).toContain('key=gemini-key'); @@ -710,7 +1917,10 @@ describe('gemini native proxy routes', () => { expect(response.statusCode).toBe(200); expect(fetchMock).toHaveBeenCalledTimes(2); - expect(recordFailureMock).toHaveBeenCalledWith(11); + expect(recordFailureMock).toHaveBeenCalledWith(11, expect.objectContaining({ + status: 403, + errorText: JSON.stringify({ error: { message: 'forbidden on first channel' } }), + })); }); it('falls back to the next channel when first Gemini channel returns 500 before any bytes are written', async () => { @@ -753,7 +1963,10 @@ describe('gemini native proxy routes', () => { expect(response.statusCode).toBe(200); expect(fetchMock).toHaveBeenCalledTimes(2); - expect(recordFailureMock).toHaveBeenCalledWith(11); + expect(recordFailureMock).toHaveBeenCalledWith(11, expect.objectContaining({ + status: 500, + errorText: 'upstream crash', + })); }); it('falls back to the next channel when first Gemini channel throws before any bytes are written', async () => { @@ -793,7 +2006,9 @@ describe('gemini native proxy routes', () => { expect(response.statusCode).toBe(200); expect(fetchMock).toHaveBeenCalledTimes(2); - expect(recordFailureMock).toHaveBeenCalledWith(11); + expect(recordFailureMock).toHaveBeenCalledWith(11, expect.objectContaining({ + errorText: 'socket hang up', + })); }); it('falls back to the next channel for SSE requests before any bytes are written', async () => { @@ -839,7 +2054,73 @@ describe('gemini native proxy routes', () => { expect(response.statusCode).toBe(200); expect(fetchMock).toHaveBeenCalledTimes(2); - expect(recordFailureMock).toHaveBeenCalledWith(11); + expect(recordFailureMock).toHaveBeenCalledWith(11, expect.objectContaining({ + status: 500, + errorText: JSON.stringify({ error: { message: 'upstream unavailable' } }), + })); expect(response.body).toContain('hello from fallback sse'); }); + + it('writes failed and successful proxy log rows for Gemini-native stream retries', async () => { + selectNextChannelMock.mockReturnValue({ + channel: { id: 12, routeId: 22 }, + site: { id: 45, name: 'gemini-site-2', url: 'https://generativelanguage.googleapis.com', platform: 'gemini' }, + account: { id: 34, username: 'demo-user-2' }, + tokenName: 'fallback', + tokenValue: 'gemini-key-2', + actualModel: 'gemini-2.5-flash', + }); + + const encoder = new TextEncoder(); + const upstreamBody = new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode('data: {"responseId":"resp-fallback","candidates":[{"content":{"role":"model","parts":[{"text":"hello from fallback sse"}]},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":11,"candidatesTokenCount":6,"totalTokenCount":17}}\r\n\r\n')); + controller.close(); + }, + }); + + fetchMock + .mockResolvedValueOnce(new Response(JSON.stringify({ + error: { message: 'upstream unavailable' }, + }), { + status: 500, + headers: { 'content-type': 'application/json' }, + })) + .mockResolvedValueOnce(new Response(upstreamBody, { + status: 200, + headers: { 'content-type': 'text/event-stream; charset=utf-8' }, + })); + + const response = await app.inject({ + method: 'POST', + url: '/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse', + headers: { + 'x-goog-api-key': 'sk-managed-gemini', + }, + payload: { + contents: [{ role: 'user', parts: [{ text: 'hello' }] }], + }, + }); + + expect(response.statusCode).toBe(200); + expect(dbInsertMock).toHaveBeenCalledTimes(2); + expect(dbInsertValuesMock).toHaveBeenNthCalledWith(1, expect.objectContaining({ + channelId: 11, + status: 'failed', + httpStatus: 500, + retryCount: 0, + errorMessage: '[downstream:/v1beta/models/gemini-2.5-flash:streamGenerateContent] [upstream:/v1beta/models/gemini-2.5-flash:streamGenerateContent] {\"error\":{\"message\":\"upstream unavailable\"}}', + })); + expect(dbInsertValuesMock).toHaveBeenNthCalledWith(2, expect.objectContaining({ + channelId: 12, + status: 'success', + httpStatus: 200, + retryCount: 1, + promptTokens: 11, + completionTokens: 6, + totalTokens: 17, + errorMessage: '[downstream:/v1beta/models/gemini-2.5-flash:streamGenerateContent] [upstream:/v1beta/models/gemini-2.5-flash:streamGenerateContent]', + })); + expect(recordSuccessMock).toHaveBeenCalledWith(12, expect.any(Number), 0, 'gemini-2.5-flash'); + }); }); diff --git a/src/server/routes/proxy/gemini.ts b/src/server/routes/proxy/gemini.ts index 7a80d841..c4c24bb9 100644 --- a/src/server/routes/proxy/gemini.ts +++ b/src/server/routes/proxy/gemini.ts @@ -1,252 +1 @@ -import { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'; -import { TextDecoder } from 'node:util'; -import { fetch } from 'undici'; -import { tokenRouter } from '../../services/tokenRouter.js'; -import { getDownstreamRoutingPolicy } from './downstreamPolicy.js'; -import { - geminiGenerateContentTransformer, -} from '../../transformers/gemini/generate-content/index.js'; - -const MAX_RETRIES = 2; -const GEMINI_MODEL_PROBES = [ - 'gemini-2.5-flash', - 'gemini-2.0-flash', - 'gemini-1.5-flash', - 'gemini-pro', -]; - -async function selectGeminiChannel(request: FastifyRequest) { - const policy = getDownstreamRoutingPolicy(request); - for (const candidate of GEMINI_MODEL_PROBES) { - const selected = await tokenRouter.selectChannel(candidate, policy); - if (selected) return selected; - } - return null; -} - -async function selectNextGeminiProbeChannel(request: FastifyRequest, excludeChannelIds: number[]) { - const policy = getDownstreamRoutingPolicy(request); - for (const candidate of GEMINI_MODEL_PROBES) { - const selected = await tokenRouter.selectNextChannel(candidate, excludeChannelIds, policy); - if (selected) return selected; - } - return null; -} - -export async function geminiProxyRoute(app: FastifyInstance) { - const listModels = async (request: FastifyRequest, reply: FastifyReply) => { - const apiVersion = geminiGenerateContentTransformer.resolveProxyApiVersion( - request.params as { geminiApiVersion?: string } | undefined, - ); - const excludeChannelIds: number[] = []; - let retryCount = 0; - let lastStatus = 503; - let lastText = 'No available channels for Gemini models'; - let lastContentType = 'application/json'; - - while (retryCount <= MAX_RETRIES) { - const selected = retryCount === 0 - ? await selectGeminiChannel(request) - : await selectNextGeminiProbeChannel(request, excludeChannelIds); - if (!selected) { - return reply.code(lastStatus).type(lastContentType).send(lastText); - } - - excludeChannelIds.push(selected.channel.id); - - try { - const upstream = await fetch( - geminiGenerateContentTransformer.resolveModelsUrl(selected.site.url, apiVersion, selected.tokenValue), - { method: 'GET' }, - ); - const text = await upstream.text(); - if (!upstream.ok) { - lastStatus = upstream.status; - lastText = text; - lastContentType = upstream.headers.get('content-type') || 'application/json'; - await tokenRouter.recordFailure?.(selected.channel.id); - if (retryCount < MAX_RETRIES) { - retryCount += 1; - continue; - } - } - - try { - return reply.code(upstream.status).send(JSON.parse(text)); - } catch { - return reply.code(upstream.status).type(upstream.headers.get('content-type') || 'application/json').send(text); - } - } catch (error) { - await tokenRouter.recordFailure?.(selected.channel.id); - lastStatus = 502; - lastContentType = 'application/json'; - lastText = JSON.stringify({ - error: { - message: error instanceof Error ? error.message : 'Gemini upstream request failed', - type: 'upstream_error', - }, - }); - if (retryCount < MAX_RETRIES) { - retryCount += 1; - continue; - } - } - } - }; - - const generateContent = async (request: FastifyRequest, reply: FastifyReply) => { - const parsedPath = geminiGenerateContentTransformer.parseProxyRequestPath({ - rawUrl: request.raw.url || request.url || '', - params: request.params as { geminiApiVersion?: string } | undefined, - }); - const { apiVersion, modelActionPath, isStreamAction, requestedModel } = parsedPath; - if (!requestedModel) { - return reply.code(400).send({ - error: { message: 'Gemini model path is required', type: 'invalid_request_error' }, - }); - } - - const policy = getDownstreamRoutingPolicy(request); - const excludeChannelIds: number[] = []; - let retryCount = 0; - let lastStatus = 503; - let lastText = 'No available channels for this model'; - let lastContentType = 'application/json'; - - while (retryCount <= MAX_RETRIES) { - const selected = retryCount === 0 - ? await tokenRouter.selectChannel(requestedModel, policy) - : await tokenRouter.selectNextChannel(requestedModel, excludeChannelIds, policy); - if (!selected) { - return reply.code(lastStatus).type(lastContentType).send(lastText); - } - - excludeChannelIds.push(selected.channel.id); - - const body = geminiGenerateContentTransformer.inbound.normalizeRequest( - request.body || {}, - selected.actualModel || requestedModel, - ); - - const actualModelAction = modelActionPath.replace( - /^models\/[^:]+/, - `models/${selected.actualModel || requestedModel}`, - ); - const query = new URLSearchParams(request.query as Record).toString(); - try { - const upstream = await fetch( - geminiGenerateContentTransformer.resolveActionUrl( - selected.site.url, - apiVersion, - actualModelAction, - selected.tokenValue, - query, - ), - { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(body), - }, - ); - const contentType = upstream.headers.get('content-type') || 'application/json'; - if (!upstream.ok) { - lastStatus = upstream.status; - lastContentType = contentType; - lastText = await upstream.text(); - await tokenRouter.recordFailure?.(selected.channel.id); - if (retryCount < MAX_RETRIES) { - retryCount += 1; - continue; - } - - try { - return reply.code(lastStatus).send(JSON.parse(lastText)); - } catch { - return reply.code(lastStatus).type(lastContentType).send(lastText); - } - } - - if (geminiGenerateContentTransformer.stream.isSseContentType(contentType)) { - reply.hijack(); - reply.raw.statusCode = upstream.status; - reply.raw.setHeader('Content-Type', contentType || 'text/event-stream'); - const reader = upstream.body?.getReader(); - if (!reader) { - reply.raw.end(); - return; - } - const aggregateState = geminiGenerateContentTransformer.stream.createAggregateState(); - const decoder = new TextDecoder(); - let rest = ''; - try { - while (true) { - const { done, value } = await reader.read(); - if (done) break; - if (!value) continue; - const chunkText = decoder.decode(value, { stream: true }); - const consumed = geminiGenerateContentTransformer.stream.consumeUpstreamSseBuffer( - aggregateState, - rest + chunkText, - ); - rest = consumed.rest; - for (const line of consumed.lines) { - reply.raw.write(line); - } - } - const tail = decoder.decode(); - if (tail) { - const consumed = geminiGenerateContentTransformer.stream.consumeUpstreamSseBuffer( - aggregateState, - rest + tail, - ); - for (const line of consumed.lines) { - reply.raw.write(line); - } - } - } finally { - reader.releaseLock(); - reply.raw.end(); - } - return; - } - - const text = await upstream.text(); - try { - const parsed = JSON.parse(text); - const aggregateState = geminiGenerateContentTransformer.stream.createAggregateState(); - return reply.code(upstream.status).send( - geminiGenerateContentTransformer.stream.serializeUpstreamJsonPayload( - aggregateState, - parsed, - isStreamAction, - ), - ); - } catch { - return reply.code(upstream.status).type(contentType || 'application/json').send(text); - } - } catch (error) { - lastStatus = 502; - lastContentType = 'application/json'; - lastText = JSON.stringify({ - error: { - message: error instanceof Error ? error.message : 'Gemini upstream request failed', - type: 'upstream_error', - }, - }); - await tokenRouter.recordFailure?.(selected.channel.id); - if (retryCount < MAX_RETRIES) { - retryCount += 1; - continue; - } - return reply.code(lastStatus).type(lastContentType).send(lastText); - } - } - }; - - app.get('/v1beta/models', listModels); - app.get('/gemini/:geminiApiVersion/models', listModels); - app.post('/v1beta/models/*', generateContent); - app.post('/gemini/:geminiApiVersion/models/*', generateContent); -} +export { geminiProxyRoute } from '../../proxy-core/surfaces/geminiSurface.js'; diff --git a/src/server/routes/proxy/geminiCliCompat.thoughtSignature.test.ts b/src/server/routes/proxy/geminiCliCompat.thoughtSignature.test.ts new file mode 100644 index 00000000..5fe466aa --- /dev/null +++ b/src/server/routes/proxy/geminiCliCompat.thoughtSignature.test.ts @@ -0,0 +1,196 @@ +import { describe, it, expect } from 'vitest'; +import { buildGeminiGenerateContentRequestFromOpenAi } from './geminiCliCompat.js'; + +describe('thoughtSignature injection in OpenAI→Gemini conversion', () => { + it('injects thoughtSignature from provider_specific_fields into functionCall parts', () => { + const result = buildGeminiGenerateContentRequestFromOpenAi({ + body: { + model: 'gemini-3-flash-preview', + messages: [ + { role: 'user', content: 'What is the weather?' }, + { + role: 'assistant', + content: 'Let me check.', + tool_calls: [ + { + id: 'call_123', + type: 'function', + function: { name: 'get_weather', arguments: '{"city":"Tokyo"}' }, + provider_specific_fields: { thought_signature: 'real_sig_abc' }, + }, + ], + }, + { role: 'tool', tool_call_id: 'call_123', content: '{"temp":"22C"}' }, + ], + }, + modelName: 'gemini-3-flash-preview', + }) as Record; + + const contents = result.contents as Array>; + // Find the model message with functionCall + const modelMsgs = contents.filter((c) => c.role === 'model'); + const fcParts = modelMsgs + .flatMap((m) => (m.parts as Array>)) + .filter((p) => 'functionCall' in p); + + expect(fcParts.length).toBe(1); + expect(fcParts[0].thoughtSignature).toBe('real_sig_abc'); + }); + + it('splits text and signed functionCall parts into separate model messages', () => { + const result = buildGeminiGenerateContentRequestFromOpenAi({ + body: { + model: 'gemini-3-flash-preview', + messages: [ + { role: 'user', content: 'Read the file.' }, + { + role: 'assistant', + content: 'I will read it.', + tool_calls: [ + { + id: 'call_456', + type: 'function', + function: { name: 'Read', arguments: '{"path":"/tmp/x"}' }, + provider_specific_fields: { thought_signature: 'sig_split_test' }, + }, + ], + }, + { role: 'tool', tool_call_id: 'call_456', content: 'file content here' }, + ], + }, + modelName: 'gemini-3-flash-preview', + }) as Record; + + const contents = result.contents as Array>; + const modelMsgs = contents.filter((c) => c.role === 'model'); + + // Should be 2 model messages: text (unsigned) + functionCall (signed) + expect(modelMsgs.length).toBe(2); + + const firstParts = modelMsgs[0].parts as Array>; + const secondParts = modelMsgs[1].parts as Array>; + + // First: text only, no thoughtSignature + expect(firstParts.every((p) => 'text' in p)).toBe(true); + expect(firstParts.every((p) => !('thoughtSignature' in p))).toBe(true); + + // Second: functionCall with thoughtSignature + expect(secondParts.every((p) => 'functionCall' in p)).toBe(true); + expect(secondParts[0].thoughtSignature).toBe('sig_split_test'); + }); + + it('injects dummy sentinel when thinking enabled but no signature available', () => { + const result = buildGeminiGenerateContentRequestFromOpenAi({ + body: { + model: 'gemini-3-flash-preview', + reasoning_effort: 'high', + messages: [ + { role: 'user', content: 'Do something.' }, + { + role: 'assistant', + tool_calls: [ + { + id: 'call_no_sig', + type: 'function', + function: { name: 'Bash', arguments: '{"command":"ls"}' }, + // No provider_specific_fields — signature missing + }, + ], + }, + { role: 'tool', tool_call_id: 'call_no_sig', content: 'file1\nfile2' }, + ], + }, + modelName: 'gemini-3-flash-preview', + }) as Record; + + const contents = result.contents as Array>; + const modelMsgs = contents.filter((c) => c.role === 'model'); + const fcParts = modelMsgs + .flatMap((m) => (m.parts as Array>)) + .filter((p) => 'functionCall' in p); + + expect(fcParts.length).toBe(1); + // Should have a dummy sentinel, not be missing + expect(typeof fcParts[0].thoughtSignature).toBe('string'); + expect((fcParts[0].thoughtSignature as string).length).toBeGreaterThan(0); + }); + + it('does not inject thoughtSignature when thinking is not enabled', () => { + const result = buildGeminiGenerateContentRequestFromOpenAi({ + body: { + model: 'gemini-2.5-flash', + messages: [ + { role: 'user', content: 'Hello' }, + { + role: 'assistant', + tool_calls: [ + { + id: 'call_no_think', + type: 'function', + function: { name: 'Read', arguments: '{"path":"/x"}' }, + }, + ], + }, + { role: 'tool', tool_call_id: 'call_no_think', content: 'data' }, + ], + }, + modelName: 'gemini-2.5-flash', + }) as Record; + + const contents = result.contents as Array>; + const modelMsgs = contents.filter((c) => c.role === 'model'); + const fcParts = modelMsgs + .flatMap((m) => (m.parts as Array>)) + .filter((p) => 'functionCall' in p); + + expect(fcParts.length).toBe(1); + // No thinking → no signature injected + expect(fcParts[0].thoughtSignature).toBeUndefined(); + }); + + it('preserves functionResponse count matching functionCall count', () => { + const result = buildGeminiGenerateContentRequestFromOpenAi({ + body: { + model: 'gemini-3-flash-preview', + messages: [ + { role: 'user', content: 'Read two files.' }, + { + role: 'assistant', + tool_calls: [ + { + id: 'call_a', + type: 'function', + function: { name: 'Read', arguments: '{"path":"/a"}' }, + provider_specific_fields: { thought_signature: 'sig_a' }, + }, + { + id: 'call_b', + type: 'function', + function: { name: 'Read', arguments: '{"path":"/b"}' }, + provider_specific_fields: { thought_signature: 'sig_b' }, + }, + ], + }, + { role: 'tool', tool_call_id: 'call_a', content: 'content a' }, + { role: 'tool', tool_call_id: 'call_b', content: 'content b' }, + ], + }, + modelName: 'gemini-3-flash-preview', + }) as Record; + + const contents = result.contents as Array>; + + // Count functionCall and functionResponse parts + let fcCount = 0; + let frCount = 0; + for (const content of contents) { + for (const part of (content.parts as Array>)) { + if ('functionCall' in part) fcCount++; + if ('functionResponse' in part) frCount++; + } + } + + expect(fcCount).toBe(2); + expect(frCount).toBe(2); + }); +}); diff --git a/src/server/routes/proxy/geminiCliCompat.ts b/src/server/routes/proxy/geminiCliCompat.ts new file mode 100644 index 00000000..090d60c1 --- /dev/null +++ b/src/server/routes/proxy/geminiCliCompat.ts @@ -0,0 +1,396 @@ +import { TextDecoder, TextEncoder } from 'node:util'; +import { resolveGeminiThinkingConfigFromRequest } from '../../transformers/gemini/generate-content/convert.js'; + +// Dummy sentinel used when no real thoughtSignature is available but thinking +// mode is enabled. Gemini accepts any base64 string and won't reject this. +const DUMMY_THOUGHT_SIGNATURE = 'c2tpcF90aG91Z2h0X3NpZ25hdHVyZV92YWxpZGF0b3I='; + +function isRecord(value: unknown): value is Record { + return !!value && typeof value === 'object' && !Array.isArray(value); +} + +function asTrimmedString(value: unknown): string { + return typeof value === 'string' ? value.trim() : ''; +} + +function parseDataUrl(url: string): { mimeType: string; data: string } | null { + if (!url.startsWith('data:')) return null; + const [, rest] = url.split('data:', 2); + const [meta, data] = rest.split(',', 2); + if (!meta || !data) return null; + const [mimeType] = meta.split(';', 1); + return { + mimeType: mimeType || 'application/octet-stream', + data, + }; +} + +function normalizeFunctionResponseResult(value: unknown): unknown { + if (typeof value !== 'string') return value; + const trimmed = value.trim(); + if (!trimmed) return value; + try { + return JSON.parse(trimmed); + } catch { + return value; + } +} + +function convertContentToGeminiParts(content: unknown): Array> { + if (typeof content === 'string') { + const trimmed = content.trim(); + return trimmed ? [{ text: trimmed }] : []; + } + + if (isRecord(content)) { + if (typeof content.text === 'string') { + const trimmed = content.text.trim(); + return trimmed ? [{ text: trimmed }] : []; + } + return []; + } + + if (!Array.isArray(content)) return []; + + const parts: Array> = []; + for (const item of content) { + if (!isRecord(item)) continue; + const type = asTrimmedString(item.type).toLowerCase(); + if (type === 'text') { + const text = asTrimmedString(item.text); + if (text) parts.push({ text }); + continue; + } + if (type === 'image_url') { + const imageUrl = asTrimmedString(item.image_url && isRecord(item.image_url) ? item.image_url.url : item.url); + const parsed = imageUrl ? parseDataUrl(imageUrl) : null; + if (parsed) { + parts.push({ + inlineData: { + mime_type: parsed.mimeType, + data: parsed.data, + }, + }); + } + continue; + } + if (type === 'input_audio') { + const data = asTrimmedString(item.data); + if (data) { + parts.push({ + inlineData: { + mime_type: 'audio/wav', + data, + }, + }); + } + } + } + return parts; +} + +function buildGeminiTools(tools: unknown): Array> | undefined { + if (!Array.isArray(tools)) return undefined; + const declarations = tools + .filter((item) => isRecord(item)) + .flatMap((item) => { + if (asTrimmedString(item.type) !== 'function' || !isRecord(item.function)) return []; + const fn = item.function as Record; + const name = asTrimmedString(fn.name); + if (!name) return []; + return [{ + name, + ...(asTrimmedString(fn.description) ? { description: asTrimmedString(fn.description) } : {}), + parametersJsonSchema: isRecord(fn.parameters) ? fn.parameters : { type: 'object', properties: {} }, + }]; + }); + + if (declarations.length <= 0) return undefined; + return [{ functionDeclarations: declarations }]; +} + +function buildGeminiToolConfig(toolChoice: unknown): Record | undefined { + if (typeof toolChoice === 'string') { + const normalized = toolChoice.trim().toLowerCase(); + if (normalized === 'none') { + return { functionCallingConfig: { mode: 'NONE' } }; + } + if (normalized === 'required') { + return { functionCallingConfig: { mode: 'ANY' } }; + } + return { functionCallingConfig: { mode: 'AUTO' } }; + } + return undefined; +} + +export function buildGeminiGenerateContentRequestFromOpenAi(input: { + body: Record; + modelName: string; + instructions?: string; +}) { + const request: Record = { + contents: [], + }; + + const messages = Array.isArray(input.body.messages) ? input.body.messages : []; + const toolNameById = new Map(); + for (const message of messages) { + if (!isRecord(message) || asTrimmedString(message.role) !== 'assistant') continue; + const toolCalls = Array.isArray(message.tool_calls) ? message.tool_calls : []; + for (const toolCall of toolCalls) { + if (!isRecord(toolCall) || !isRecord(toolCall.function)) continue; + const id = asTrimmedString(toolCall.id); + const name = asTrimmedString(toolCall.function.name); + if (id && name) { + toolNameById.set(id, name); + } + } + } + + // Detect if thinking mode is enabled (needed for dummy signature fallback) + const hasThinkingEnabled = !!resolveGeminiThinkingConfigFromRequest(input.modelName, input.body); + + // Collect thoughtSignatures from assistant tool_calls for injection. + // OpenAI format stores signatures in provider_specific_fields or encoded in tool call IDs. + const thoughtSignatureById = new Map(); + for (const message of messages) { + if (!isRecord(message) || asTrimmedString(message.role) !== 'assistant') continue; + const toolCalls = Array.isArray(message.tool_calls) ? message.tool_calls : []; + for (const toolCall of toolCalls) { + if (!isRecord(toolCall)) continue; + const id = asTrimmedString(toolCall.id); + if (!id) continue; + // Check provider_specific_fields first + const providerFields = isRecord(toolCall.provider_specific_fields) ? toolCall.provider_specific_fields : null; + if (providerFields && typeof providerFields.thought_signature === 'string') { + thoughtSignatureById.set(id, providerFields.thought_signature); + } + } + } + + const systemParts: Array> = []; + if (typeof input.instructions === 'string' && input.instructions.trim()) { + systemParts.push({ text: input.instructions.trim() }); + } + + for (const message of messages) { + if (!isRecord(message)) continue; + const role = asTrimmedString(message.role).toLowerCase(); + if (role === 'system' || role === 'developer') { + systemParts.push(...convertContentToGeminiParts(message.content)); + continue; + } + if (role === 'tool') { + const toolCallId = asTrimmedString(message.tool_call_id); + const name = toolNameById.get(toolCallId) || 'unknown'; + const result = normalizeFunctionResponseResult(message.content); + request.contents = [ + ...(Array.isArray(request.contents) ? request.contents : []), + { + role: 'user', + parts: [{ + functionResponse: { + name, + response: { + result, + }, + }, + }], + }, + ]; + continue; + } + + const textParts = convertContentToGeminiParts(message.content); + const fcParts: Array> = []; + const toolCalls = Array.isArray(message.tool_calls) ? message.tool_calls : []; + for (const toolCall of toolCalls) { + if (!isRecord(toolCall) || !isRecord(toolCall.function)) continue; + const name = asTrimmedString(toolCall.function.name); + if (!name) continue; + const rawArguments = toolCall.function.arguments; + let args: unknown = {}; + if (typeof rawArguments === 'string' && rawArguments.trim()) { + try { + args = JSON.parse(rawArguments); + } catch { + args = { raw: rawArguments }; + } + } else if (isRecord(rawArguments)) { + args = rawArguments; + } + const fcPart: Record = { + functionCall: { name, args }, + }; + // Inject thoughtSignature if available from provider_specific_fields. + // If no signature is found, use a dummy sentinel so Gemini doesn't reject + // the request outright. Gemini 3+ requires thoughtSignature on all + // functionCall parts when thinking is enabled. + // Ref: https://ai.google.dev/gemini-api/docs/thought-signatures + const id = asTrimmedString(toolCall.id); + const signature = thoughtSignatureById.get(id); + if (signature) { + fcPart.thoughtSignature = signature; + } else if (hasThinkingEnabled) { + fcPart.thoughtSignature = DUMMY_THOUGHT_SIGNATURE; + } + fcParts.push(fcPart); + } + + const geminiRole = role === 'assistant' ? 'model' : 'user'; + + // Gemini requires that parts WITH thoughtSignature and parts WITHOUT + // are not mixed in the same message. Split if needed. + // Ref: https://ai.google.dev/gemini-api/docs/thought-signatures + const hasSigned = fcParts.some((p) => 'thoughtSignature' in p); + if (hasSigned && textParts.length > 0 && fcParts.length > 0) { + // Emit text parts first, then functionCall parts in a separate message + request.contents = [ + ...(Array.isArray(request.contents) ? request.contents : []), + { role: geminiRole, parts: textParts }, + { role: geminiRole, parts: fcParts }, + ]; + } else { + const allParts = [...textParts, ...fcParts]; + if (allParts.length <= 0) continue; + request.contents = [ + ...(Array.isArray(request.contents) ? request.contents : []), + { role: geminiRole, parts: allParts }, + ]; + } + } + + if (systemParts.length > 0) { + request.systemInstruction = { + role: 'user', + parts: systemParts, + }; + } + + const generationConfig: Record = {}; + const maxOutputTokens = Number( + input.body.max_output_tokens + ?? input.body.max_completion_tokens + ?? input.body.max_tokens + ?? 0, + ); + if (Number.isFinite(maxOutputTokens) && maxOutputTokens > 0) { + generationConfig.maxOutputTokens = Math.trunc(maxOutputTokens); + } + const temperature = Number(input.body.temperature); + if (Number.isFinite(temperature)) generationConfig.temperature = temperature; + const topP = Number(input.body.top_p); + if (Number.isFinite(topP)) generationConfig.topP = topP; + const topK = Number(input.body.top_k); + if (Number.isFinite(topK)) generationConfig.topK = topK; + if (Array.isArray(input.body.stop) && input.body.stop.length > 0) { + generationConfig.stopSequences = input.body.stop.filter((item): item is string => typeof item === 'string' && item.trim().length > 0); + } + const thinkingConfig = resolveGeminiThinkingConfigFromRequest(input.modelName, input.body); + if (thinkingConfig) { + generationConfig.thinkingConfig = thinkingConfig; + } + if (Object.keys(generationConfig).length > 0) { + request.generationConfig = generationConfig; + } + + const geminiTools = buildGeminiTools(input.body.tools); + if (geminiTools) { + request.tools = geminiTools; + } + const toolConfig = buildGeminiToolConfig(input.body.tool_choice); + if (toolConfig) { + request.toolConfig = toolConfig; + } + + return request; +} + +export function wrapGeminiCliRequest(input: { + modelName: string; + projectId: string; + request: Record; +}) { + const { model, ...requestPayload } = input.request; + return { + project: input.projectId, + model: input.modelName, + request: requestPayload, + }; +} + +export function unwrapGeminiCliPayload(payload: T): unknown { + if (!isRecord(payload)) return payload; + if (payload.response !== undefined) { + return payload.response; + } + return payload; +} + +function rewriteGeminiCliSseEventBlock(block: string): string { + const lines = block.split(/\r?\n/g); + return lines.map((line) => { + if (!line.startsWith('data:')) return line; + const data = line.slice(5).trim(); + if (!data || data === '[DONE]') return line; + try { + const parsed = JSON.parse(data); + return `data: ${JSON.stringify(unwrapGeminiCliPayload(parsed))}`; + } catch { + return line; + } + }).join('\n'); +} + +export function createGeminiCliStreamReader(reader: { + read(): Promise<{ done: boolean; value?: Uint8Array }>; + cancel(reason?: unknown): Promise; + releaseLock(): void; +}) { + const decoder = new TextDecoder(); + const encoder = new TextEncoder(); + const outputQueue: Uint8Array[] = []; + let buffer = ''; + let done = false; + + async function fillQueue() { + while (outputQueue.length <= 0 && !done) { + const result = await reader.read(); + if (result.done) { + done = true; + const tail = decoder.decode(); + if (tail) buffer += tail; + if (buffer.trim()) { + outputQueue.push(encoder.encode(`${rewriteGeminiCliSseEventBlock(buffer)}\n\n`)); + buffer = ''; + } + break; + } + if (!result.value) continue; + buffer += decoder.decode(result.value, { stream: true }); + let separatorIndex = buffer.indexOf('\n\n'); + while (separatorIndex >= 0) { + const block = buffer.slice(0, separatorIndex); + buffer = buffer.slice(separatorIndex + 2); + outputQueue.push(encoder.encode(`${rewriteGeminiCliSseEventBlock(block)}\n\n`)); + separatorIndex = buffer.indexOf('\n\n'); + } + } + } + + return { + async read() { + await fillQueue(); + if (outputQueue.length > 0) { + return { done: false, value: outputQueue.shift() }; + } + return { done: true, value: undefined }; + }, + cancel(reason?: unknown) { + return reader.cancel(reason); + }, + releaseLock() { + reader.releaseLock(); + }, + }; +} diff --git a/src/server/routes/proxy/images.edits.test.ts b/src/server/routes/proxy/images.edits.test.ts index 9ff0ceb1..db5507fe 100644 --- a/src/server/routes/proxy/images.edits.test.ts +++ b/src/server/routes/proxy/images.edits.test.ts @@ -58,6 +58,9 @@ vi.mock('../../db/index.js', () => ({ db: { insert: (arg: any) => dbInsertMock(arg), }, + hasProxyLogBillingDetailsColumn: async () => false, + hasProxyLogClientColumns: async () => false, + hasProxyLogDownstreamApiKeyIdColumn: async () => false, schema: { proxyLogs: {}, }, @@ -140,6 +143,79 @@ describe('/v1/images/edits route', () => { expect(targetUrl).toBe('https://upstream.example.com/v1/images/edits'); }); + it('retries the next channel when image generation JSON is malformed', async () => { + selectNextChannelMock.mockReturnValueOnce({ + channel: { id: 12, routeId: 23 }, + site: { id: 45, name: 'fallback-site', url: 'https://fallback.example.com', platform: 'openai' }, + account: { id: 34, username: 'fallback-user' }, + tokenName: 'fallback', + tokenValue: 'sk-fallback', + actualModel: 'fallback-gpt-image', + }); + fetchMock + .mockResolvedValueOnce(new Response('not-json', { + status: 200, + headers: { 'content-type': 'application/json' }, + })) + .mockResolvedValueOnce(new Response(JSON.stringify({ + created: 2, + data: [{ b64_json: 'ZmFsbGJhY2s=' }], + }), { + status: 200, + headers: { 'content-type': 'application/json' }, + })); + + const response = await app.inject({ + method: 'POST', + url: '/v1/images/generations', + headers: { + authorization: 'Bearer sk-demo', + }, + payload: { + model: 'gpt-image-1', + prompt: 'draw a cat', + }, + }); + + expect(response.statusCode).toBe(200); + expect(response.json()).toEqual({ + created: 2, + data: [{ b64_json: 'ZmFsbGJhY2s=' }], + }); + expect(selectNextChannelMock).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it('keeps returning a successful image edit response when post-success accounting fails', async () => { + fetchMock.mockResolvedValue(new Response(JSON.stringify({ + created: 1, + data: [{ b64_json: 'iVBORw0KGgo=' }], + }), { + status: 200, + headers: { 'content-type': 'application/json' }, + })); + estimateProxyCostMock.mockRejectedValueOnce(new Error('cost failed')); + + const boundary = 'metapi-boundary-accounting'; + const response = await app.inject({ + method: 'POST', + url: '/v1/images/edits', + headers: { + authorization: 'Bearer sk-demo', + 'content-type': `multipart/form-data; boundary=${boundary}`, + }, + payload: buildMultipartBody(boundary), + }); + + expect(response.statusCode).toBe(200); + expect(response.json()).toEqual({ + created: 1, + data: [{ b64_json: 'iVBORw0KGgo=' }], + }); + expect(selectNextChannelMock).not.toHaveBeenCalled(); + expect(recordFailureMock).not.toHaveBeenCalled(); + }); + it('returns explicit not-supported error for /v1/images/variations', async () => { const response = await app.inject({ method: 'POST', diff --git a/src/server/routes/proxy/images.ts b/src/server/routes/proxy/images.ts index 1b87a177..2b6c2f83 100644 --- a/src/server/routes/proxy/images.ts +++ b/src/server/routes/proxy/images.ts @@ -1,20 +1,23 @@ -import { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'; -import { fetch } from 'undici'; -import { db, schema } from '../../db/index.js'; -import { tokenRouter } from '../../services/tokenRouter.js'; -import { refreshModelsAndRebuildRoutes } from '../../services/modelService.js'; +import { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'; +import { fetch } from 'undici'; +import { tokenRouter } from '../../services/tokenRouter.js'; +import * as routeRefreshWorkflow from '../../services/routeRefreshWorkflow.js'; import { reportProxyAllFailed, reportTokenExpired } from '../../services/alertService.js'; import { isTokenExpiredError } from '../../services/alertRules.js'; import { estimateProxyCost } from '../../services/modelPricingService.js'; import { shouldRetryProxyRequest } from '../../services/proxyRetryPolicy.js'; import { ensureModelAllowedForDownstreamKey, getDownstreamRoutingPolicy, recordDownstreamCostUsage } from './downstreamPolicy.js'; import { withSiteRecordProxyRequestInit } from '../../services/siteProxy.js'; +import { getProxyUrlFromExtraConfig } from '../../services/accountExtraConfig.js'; import { composeProxyLogMessage } from './logPathMeta.js'; import { formatUtcSqlDateTime } from '../../services/localTimeService.js'; import { cloneFormDataWithOverrides, ensureMultipartBufferParser, parseMultipartFormData } from './multipart.js'; - -const MAX_RETRIES = 2; - +import { getProxyAuthContext } from '../../middleware/auth.js'; +import { buildUpstreamUrl } from './upstreamUrl.js'; +import { detectDownstreamClientContext, type DownstreamClientContext } from './downstreamClientContext.js'; +import { insertProxyLog } from '../../services/proxyLogStore.js'; +import { canRetryProxyChannel, getProxyMaxChannelRetries } from '../../services/proxyChannelRetry.js'; + export async function imagesProxyRoute(app: FastifyInstance) { ensureMultipartBufferParser(app); @@ -23,36 +26,44 @@ export async function imagesProxyRoute(app: FastifyInstance) { const requestedModel = body?.model || 'gpt-image-1'; if (!await ensureModelAllowedForDownstreamKey(request, reply, requestedModel)) return; const downstreamPolicy = getDownstreamRoutingPolicy(request); + const downstreamApiKeyId = getProxyAuthContext(request)?.keyId ?? null; + const downstreamPath = '/v1/images/generations'; + const clientContext = detectDownstreamClientContext({ + downstreamPath, + headers: request.headers as Record, + body, + }); const excludeChannelIds: number[] = []; let retryCount = 0; - while (retryCount <= MAX_RETRIES) { + while (retryCount <= getProxyMaxChannelRetries()) { let selected = retryCount === 0 ? await tokenRouter.selectChannel(requestedModel, downstreamPolicy) : await tokenRouter.selectNextChannel(requestedModel, excludeChannelIds, downstreamPolicy); if (!selected && retryCount === 0) { - await refreshModelsAndRebuildRoutes(); + await routeRefreshWorkflow.refreshModelsAndRebuildRoutes(); selected = await tokenRouter.selectChannel(requestedModel, downstreamPolicy); } - - if (!selected) { - await reportProxyAllFailed({ - model: requestedModel, - reason: 'No available channels after retries', - }); - return reply.code(503).send({ - error: { message: 'No available channels for this model', type: 'server_error' }, - }); - } - - excludeChannelIds.push(selected.channel.id); - - const targetUrl = `${selected.site.url}/v1/images/generations`; - const forwardBody = { ...body, model: selected.actualModel }; - const startTime = Date.now(); - - try { + + if (!selected) { + await reportProxyAllFailed({ + model: requestedModel, + reason: 'No available channels after retries', + }); + return reply.code(503).send({ + error: { message: 'No available channels for this model', type: 'server_error' }, + }); + } + + excludeChannelIds.push(selected.channel.id); + + const targetUrl = buildUpstreamUrl(selected.site.url, '/v1/images/generations'); + const upstreamModel = selected.actualModel || requestedModel; + const forwardBody = { ...body, model: upstreamModel }; + const startTime = Date.now(); + + try { const upstream = await fetch(targetUrl, withSiteRecordProxyRequestInit(selected.site, { method: 'POST', headers: { @@ -60,61 +71,130 @@ export async function imagesProxyRoute(app: FastifyInstance) { 'Authorization': `Bearer ${selected.tokenValue}`, }, body: JSON.stringify(forwardBody), - })); - - const text = await upstream.text(); - if (!upstream.ok) { - tokenRouter.recordFailure(selected.channel.id); - logProxy(selected, requestedModel, 'failed', upstream.status, Date.now() - startTime, text, retryCount); - if (isTokenExpiredError({ status: upstream.status, message: text })) { - await reportTokenExpired({ - accountId: selected.account.id, - username: selected.account.username, - siteName: selected.site.name, - detail: `HTTP ${upstream.status}`, - }); - } - if (shouldRetryProxyRequest(upstream.status, text) && retryCount < MAX_RETRIES) { + }, getProxyUrlFromExtraConfig(selected.account.extraConfig))); + + const text = await upstream.text(); + if (!upstream.ok) { + await recordTokenRouterEventBestEffort('record channel failure', () => tokenRouter.recordFailure(selected.channel.id, { + status: upstream.status, + errorText: text, + modelName: upstreamModel, + })); + logProxy( + selected, + requestedModel, + 'failed', + upstream.status, + Date.now() - startTime, + text, + retryCount, + downstreamApiKeyId, + 0, + downstreamPath, + clientContext, + ); + if (isTokenExpiredError({ status: upstream.status, message: text })) { + await reportTokenExpired({ + accountId: selected.account.id, + username: selected.account.username, + siteName: selected.site.name, + detail: `HTTP ${upstream.status}`, + }); + } + if (shouldRetryProxyRequest(upstream.status, text) && canRetryProxyChannel(retryCount)) { retryCount++; continue; } - await reportProxyAllFailed({ - model: requestedModel, - reason: `upstream returned HTTP ${upstream.status}`, - }); - return reply.code(upstream.status).send({ error: { message: text, type: 'upstream_error' } }); - } - - let data: any = {}; - try { data = JSON.parse(text); } catch { data = { data: [] }; } - - const latency = Date.now() - startTime; - const estimatedCost = await estimateProxyCost({ - site: selected.site, - account: selected.account, - modelName: selected.actualModel || requestedModel, - promptTokens: 0, - completionTokens: 0, - totalTokens: 0, + await reportProxyAllFailed({ + model: requestedModel, + reason: `upstream returned HTTP ${upstream.status}`, + }); + return reply.code(upstream.status).send({ error: { message: text, type: 'upstream_error' } }); + } + + const data = parseUpstreamImageResponse(text); + if (!data.ok) { + await recordTokenRouterEventBestEffort('record malformed upstream response', () => tokenRouter.recordFailure(selected.channel.id, { + status: 502, + errorText: data.message, + modelName: upstreamModel, + })); + logProxy( + selected, + requestedModel, + 'failed', + 502, + Date.now() - startTime, + data.message, + retryCount, + downstreamApiKeyId, + 0, + downstreamPath, + clientContext, + ); + if (canRetryProxyChannel(retryCount)) { + retryCount++; + continue; + } + await reportProxyAllFailed({ + model: requestedModel, + reason: data.message, + }); + return reply.code(502).send({ + error: { message: data.message, type: 'upstream_error' }, + }); + } + + const latency = Date.now() - startTime; + let estimatedCost = 0; + await recordTokenRouterEventBestEffort('estimate proxy cost', async () => { + estimatedCost = await estimateProxyCost({ + site: selected.site, + account: selected.account, + modelName: upstreamModel, + promptTokens: 0, + completionTokens: 0, + totalTokens: 0, + }); + }); + await recordTokenRouterEventBestEffort('record channel success', () => ( + tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost, upstreamModel) + )); + await recordTokenRouterEventBestEffort('record downstream cost usage', () => ( + recordDownstreamCostUsage(request, estimatedCost) + )); + logProxy(selected, requestedModel, 'success', upstream.status, latency, null, retryCount, downstreamApiKeyId, estimatedCost, downstreamPath, clientContext); + return reply.code(upstream.status).send(data.value); + } catch (err: any) { + await recordTokenRouterEventBestEffort('record channel failure', () => tokenRouter.recordFailure(selected.channel.id, { + status: 0, + errorText: err.message, + modelName: upstreamModel, + })); + logProxy( + selected, + requestedModel, + 'failed', + 0, + Date.now() - startTime, + err.message, + retryCount, + downstreamApiKeyId, + 0, + downstreamPath, + clientContext, + ); + if (canRetryProxyChannel(retryCount)) { + retryCount++; + continue; + } + await reportProxyAllFailed({ + model: requestedModel, + reason: err.message || 'network failure', + }); + return reply.code(502).send({ + error: { message: `Upstream error: ${err.message}`, type: 'upstream_error' }, }); - tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost); - recordDownstreamCostUsage(request, estimatedCost); - logProxy(selected, requestedModel, 'success', upstream.status, latency, null, retryCount, estimatedCost); - return reply.code(upstream.status).send(data); - } catch (err: any) { - tokenRouter.recordFailure(selected.channel.id); - logProxy(selected, requestedModel, 'failed', 0, Date.now() - startTime, err.message, retryCount); - if (retryCount < MAX_RETRIES) { - retryCount++; - continue; - } - await reportProxyAllFailed({ - model: requestedModel, - reason: err.message || 'network failure', - }); - return reply.code(502).send({ - error: { message: `Upstream error: ${err.message}`, type: 'upstream_error' }, - }); } } }); @@ -130,16 +210,23 @@ export async function imagesProxyRoute(app: FastifyInstance) { if (!await ensureModelAllowedForDownstreamKey(request, reply, requestedModel)) return; const downstreamPolicy = getDownstreamRoutingPolicy(request); + const downstreamApiKeyId = getProxyAuthContext(request)?.keyId ?? null; + const downstreamPath = '/v1/images/edits'; + const clientContext = detectDownstreamClientContext({ + downstreamPath, + headers: request.headers as Record, + body: jsonBody || Object.fromEntries(multipartForm?.entries?.() || []), + }); const excludeChannelIds: number[] = []; let retryCount = 0; - while (retryCount <= MAX_RETRIES) { + while (retryCount <= getProxyMaxChannelRetries()) { let selected = retryCount === 0 ? await tokenRouter.selectChannel(requestedModel, downstreamPolicy) : await tokenRouter.selectNextChannel(requestedModel, excludeChannelIds, downstreamPolicy); if (!selected && retryCount === 0) { - await refreshModelsAndRebuildRoutes(); + await routeRefreshWorkflow.refreshModelsAndRebuildRoutes(); selected = await tokenRouter.selectChannel(requestedModel, downstreamPolicy); } @@ -154,7 +241,8 @@ export async function imagesProxyRoute(app: FastifyInstance) { } excludeChannelIds.push(selected.channel.id); - const targetUrl = `${selected.site.url}/v1/images/edits`; + const targetUrl = buildUpstreamUrl(selected.site.url, '/v1/images/edits'); + const upstreamModel = selected.actualModel || requestedModel; const startTime = Date.now(); try { @@ -165,9 +253,9 @@ export async function imagesProxyRoute(app: FastifyInstance) { Authorization: `Bearer ${selected.tokenValue}`, }, body: cloneFormDataWithOverrides(multipartForm, { - model: selected.actualModel || requestedModel, + model: upstreamModel, }) as any, - }) + }, getProxyUrlFromExtraConfig(selected.account.extraConfig)) : withSiteRecordProxyRequestInit(selected.site, { method: 'POST', headers: { @@ -176,15 +264,31 @@ export async function imagesProxyRoute(app: FastifyInstance) { }, body: JSON.stringify({ ...(jsonBody || {}), - model: selected.actualModel || requestedModel, + model: upstreamModel, }), - }); + }, getProxyUrlFromExtraConfig(selected.account.extraConfig)); const upstream = await fetch(targetUrl, requestInit); const text = await upstream.text(); if (!upstream.ok) { - tokenRouter.recordFailure(selected.channel.id); - logProxy(selected, requestedModel, 'failed', upstream.status, Date.now() - startTime, text, retryCount, 0, '/v1/images/edits'); + await recordTokenRouterEventBestEffort('record channel failure', () => tokenRouter.recordFailure(selected.channel.id, { + status: upstream.status, + errorText: text, + modelName: upstreamModel, + })); + logProxy( + selected, + requestedModel, + 'failed', + upstream.status, + Date.now() - startTime, + text, + retryCount, + downstreamApiKeyId, + 0, + downstreamPath, + clientContext, + ); if (isTokenExpiredError({ status: upstream.status, message: text })) { await reportTokenExpired({ accountId: selected.account.id, @@ -193,7 +297,7 @@ export async function imagesProxyRoute(app: FastifyInstance) { detail: `HTTP ${upstream.status}`, }); } - if (shouldRetryProxyRequest(upstream.status, text) && retryCount < MAX_RETRIES) { + if (shouldRetryProxyRequest(upstream.status, text) && canRetryProxyChannel(retryCount)) { retryCount++; continue; } @@ -204,26 +308,79 @@ export async function imagesProxyRoute(app: FastifyInstance) { return reply.code(upstream.status).send({ error: { message: text, type: 'upstream_error' } }); } - let data: any = {}; - try { data = JSON.parse(text); } catch { data = { data: [] }; } + const data = parseUpstreamImageResponse(text); + if (!data.ok) { + await recordTokenRouterEventBestEffort('record malformed upstream response', () => tokenRouter.recordFailure(selected.channel.id, { + status: 502, + errorText: data.message, + modelName: upstreamModel, + })); + logProxy( + selected, + requestedModel, + 'failed', + 502, + Date.now() - startTime, + data.message, + retryCount, + downstreamApiKeyId, + 0, + downstreamPath, + clientContext, + ); + if (canRetryProxyChannel(retryCount)) { + retryCount++; + continue; + } + await reportProxyAllFailed({ + model: requestedModel, + reason: data.message, + }); + return reply.code(502).send({ + error: { message: data.message, type: 'upstream_error' }, + }); + } const latency = Date.now() - startTime; - const estimatedCost = await estimateProxyCost({ - site: selected.site, - account: selected.account, - modelName: selected.actualModel || requestedModel, - promptTokens: 0, - completionTokens: 0, - totalTokens: 0, + let estimatedCost = 0; + await recordTokenRouterEventBestEffort('estimate proxy cost', async () => { + estimatedCost = await estimateProxyCost({ + site: selected.site, + account: selected.account, + modelName: upstreamModel, + promptTokens: 0, + completionTokens: 0, + totalTokens: 0, + }); }); - tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost); - recordDownstreamCostUsage(request, estimatedCost); - logProxy(selected, requestedModel, 'success', upstream.status, latency, null, retryCount, estimatedCost, '/v1/images/edits'); - return reply.code(upstream.status).send(data); + await recordTokenRouterEventBestEffort('record channel success', () => ( + tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost, upstreamModel) + )); + await recordTokenRouterEventBestEffort('record downstream cost usage', () => ( + recordDownstreamCostUsage(request, estimatedCost) + )); + logProxy(selected, requestedModel, 'success', upstream.status, latency, null, retryCount, downstreamApiKeyId, estimatedCost, downstreamPath, clientContext); + return reply.code(upstream.status).send(data.value); } catch (err: any) { - tokenRouter.recordFailure(selected.channel.id); - logProxy(selected, requestedModel, 'failed', 0, Date.now() - startTime, err.message, retryCount, 0, '/v1/images/edits'); - if (retryCount < MAX_RETRIES) { + await recordTokenRouterEventBestEffort('record channel failure', () => tokenRouter.recordFailure(selected.channel.id, { + status: 0, + errorText: err.message, + modelName: upstreamModel, + })); + logProxy( + selected, + requestedModel, + 'failed', + 0, + Date.now() - startTime, + err.message, + retryCount, + downstreamApiKeyId, + 0, + downstreamPath, + clientContext, + ); + if (canRetryProxyChannel(retryCount)) { retryCount++; continue; } @@ -247,30 +404,38 @@ export async function imagesProxyRoute(app: FastifyInstance) { }); }); } - -async function logProxy( - selected: any, - modelRequested: string, - status: string, - httpStatus: number, + +async function logProxy( + selected: any, + modelRequested: string, + status: string, + httpStatus: number, latencyMs: number, errorMessage: string | null, retryCount: number, + downstreamApiKeyId: number | null = null, estimatedCost = 0, downstreamPath = '/v1/images/generations', + clientContext: DownstreamClientContext | null = null, ) { try { const createdAt = formatUtcSqlDateTime(new Date()); const normalizedErrorMessage = composeProxyLogMessage({ + clientKind: clientContext?.clientKind && clientContext.clientKind !== 'generic' + ? clientContext.clientKind + : null, + sessionId: clientContext?.sessionId || null, + traceHint: clientContext?.traceHint || null, downstreamPath, errorMessage, }); - await db.insert(schema.proxyLogs).values({ + await insertProxyLog({ routeId: selected.channel.routeId, channelId: selected.channel.id, accountId: selected.account.id, + downstreamApiKeyId, modelRequested, - modelActual: selected.actualModel, + modelActual: selected.actualModel || modelRequested, status, httpStatus, latencyMs, @@ -278,12 +443,34 @@ async function logProxy( completionTokens: 0, totalTokens: 0, estimatedCost, + clientFamily: clientContext?.clientKind || null, + clientAppId: clientContext?.clientAppId || null, + clientAppName: clientContext?.clientAppName || null, + clientConfidence: clientContext?.clientConfidence || null, errorMessage: normalizedErrorMessage, retryCount, createdAt, - }).run(); + }); } catch (error) { console.warn('[proxy/images] failed to write proxy log', error); } } - + +async function recordTokenRouterEventBestEffort( + label: string, + operation: () => Promise | unknown, +): Promise { + try { + await operation(); + } catch (error) { + console.warn(`[proxy/images] failed to ${label}`, error); + } +} + +function parseUpstreamImageResponse(text: string): { ok: true; value: any } | { ok: false; message: string } { + try { + return { ok: true, value: JSON.parse(text) }; + } catch { + return { ok: false, message: text || 'Upstream returned malformed JSON' }; + } +} diff --git a/src/server/routes/proxy/inputFiles.test.ts b/src/server/routes/proxy/inputFiles.test.ts index c5c33090..7b2a6046 100644 --- a/src/server/routes/proxy/inputFiles.test.ts +++ b/src/server/routes/proxy/inputFiles.test.ts @@ -2,16 +2,20 @@ import { describe, expect, it, vi, beforeEach } from 'vitest'; const getProxyFileByPublicIdForOwnerMock = vi.fn(); -vi.mock('../../services/proxyFileStore.js', () => ({ - getProxyFileByPublicIdForOwner: (...args: unknown[]) => getProxyFileByPublicIdForOwnerMock(...args), -})); +vi.mock('../../services/proxyFileStore.js', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + getProxyFileByPublicIdForOwner: (...args: unknown[]) => getProxyFileByPublicIdForOwnerMock(...args), + }; +}); describe('inlineLocalInputFileReferences', () => { beforeEach(() => { getProxyFileByPublicIdForOwnerMock.mockReset(); }); - it('replaces local responses input_file ids with inline file_data payloads', async () => { + it('replaces local responses input_file ids with inline-only file_data payloads', async () => { getProxyFileByPublicIdForOwnerMock.mockResolvedValue({ publicId: 'file-metapi-123', filename: 'report.pdf', diff --git a/src/server/routes/proxy/inputFiles.ts b/src/server/routes/proxy/inputFiles.ts index 46f3d9c3..bee47e2e 100644 --- a/src/server/routes/proxy/inputFiles.ts +++ b/src/server/routes/proxy/inputFiles.ts @@ -1,114 +1,4 @@ -import type { ProxyResourceOwner } from '../../middleware/auth.js'; -import { getProxyFileByPublicIdForOwner } from '../../services/proxyFileStore.js'; -import { - inferInputFileMimeType, - normalizeInputFileBlock, - toOpenAiChatFileBlock, - toResponsesInputFileBlock, - type NormalizedInputFile, -} from '../../transformers/shared/inputFile.js'; - -function isRecord(value: unknown): value is Record { - return !!value && typeof value === 'object'; -} - -function isSupportedFileMimeType(mimeType: string): boolean { - return mimeType === 'application/pdf' - || mimeType === 'text/plain' - || mimeType === 'text/markdown' - || mimeType === 'application/json' - || mimeType.startsWith('image/') - || mimeType.startsWith('audio/'); -} - -export class ProxyInputFileResolutionError extends Error { - statusCode: number; - payload: unknown; - - constructor(statusCode: number, payload: unknown) { - super(typeof payload === 'object' && payload && 'error' in (payload as Record) - ? String(((payload as { error?: { message?: unknown } }).error?.message) || 'input file resolution failed') - : 'input file resolution failed'); - this.name = 'ProxyInputFileResolutionError'; - this.statusCode = statusCode; - this.payload = payload; - } -} - -function buildInvalidRequest(message: string): ProxyInputFileResolutionError { - return new ProxyInputFileResolutionError(400, { - error: { - message, - type: 'invalid_request_error', - }, - }); -} - -function buildNotFound(message: string): ProxyInputFileResolutionError { - return new ProxyInputFileResolutionError(404, { - error: { - message, - type: 'not_found_error', - }, - }); -} - -async function resolveInputFileBlock( - item: Record, - owner: ProxyResourceOwner, -): Promise> { - const normalized = normalizeInputFileBlock(item); - if (!normalized) return item; - - let resolved: NormalizedInputFile = { ...normalized }; - - if (!resolved.fileData && resolved.fileId?.startsWith('file-metapi-')) { - const stored = await getProxyFileByPublicIdForOwner(resolved.fileId, owner); - if (!stored) { - throw buildNotFound(`file not found: ${resolved.fileId}`); - } - resolved = { - filename: stored.filename, - mimeType: stored.mimeType, - fileData: stored.contentBase64, - }; - } - - const inferredMimeType = inferInputFileMimeType(resolved); - if (resolved.fileData && (!inferredMimeType || !isSupportedFileMimeType(inferredMimeType))) { - throw buildInvalidRequest(`unsupported file mime type: ${inferredMimeType || 'application/octet-stream'}`); - } - if (inferredMimeType) { - resolved.mimeType = inferredMimeType; - } - - const type = typeof item.type === 'string' ? item.type : ''; - if (type === 'input_file') { - return toResponsesInputFileBlock(resolved); - } - if (type === 'file') { - return toOpenAiChatFileBlock(resolved); - } - return item; -} - -export async function inlineLocalInputFileReferences( - value: unknown, - owner: ProxyResourceOwner, -): Promise { - if (Array.isArray(value)) { - return Promise.all(value.map((item) => inlineLocalInputFileReferences(item, owner))); - } - - if (!isRecord(value)) return value; - - const type = typeof value.type === 'string' ? value.type : ''; - if (type === 'input_file' || type === 'file') { - return resolveInputFileBlock(value, owner); - } - - const entries = await Promise.all( - Object.entries(value).map(async ([key, entryValue]) => [key, await inlineLocalInputFileReferences(entryValue, owner)] as const), - ); - return Object.fromEntries(entries); -} +export { + inlineLocalInputFileReferences, + ProxyInputFileResolutionError, +} from '../../proxy-core/surfaces/inputFilesSurface.js'; diff --git a/src/server/routes/proxy/logPathMeta.test.ts b/src/server/routes/proxy/logPathMeta.test.ts index a0e5e77a..68b39eca 100644 --- a/src/server/routes/proxy/logPathMeta.test.ts +++ b/src/server/routes/proxy/logPathMeta.test.ts @@ -31,4 +31,22 @@ describe('composeProxyLogMessage', () => { errorMessage: '', })).toBe(null); }); + + it('adds client and session metadata ahead of path metadata', () => { + expect(composeProxyLogMessage({ + clientKind: 'codex', + sessionId: 'codex-session-123', + downstreamPath: '/v1/responses', + errorMessage: 'upstream failed', + })).toBe('[client:codex] [session:codex-session-123] [downstream:/v1/responses] upstream failed'); + }); + + it('reuses existing client and session metadata without duplication', () => { + expect(composeProxyLogMessage({ + clientKind: 'claude_code', + sessionId: 'session-123', + downstreamPath: '/v1/messages', + errorMessage: '[client:claude_code] [session:session-123] [upstream:/v1/messages] bad request', + })).toBe('[client:claude_code] [session:session-123] [downstream:/v1/messages] [upstream:/v1/messages] bad request'); + }); }); diff --git a/src/server/routes/proxy/logPathMeta.ts b/src/server/routes/proxy/logPathMeta.ts index 59fbb65e..eb4c3cbc 100644 --- a/src/server/routes/proxy/logPathMeta.ts +++ b/src/server/routes/proxy/logPathMeta.ts @@ -1,38 +1,39 @@ +import { parseProxyLogMetadata, type ParsedProxyLogMetadata } from '../../../shared/proxyLogMeta.js'; + type ComposeProxyLogMessageArgs = { + clientKind?: string | null; + sessionId?: string | null; + traceHint?: string | null; downstreamPath?: string | null; upstreamPath?: string | null; errorMessage?: string | null; }; -type ParsedPathMeta = { - downstreamPath: string | null; - upstreamPath: string | null; - messageText: string; -}; +export type ParsedProxyLogMessageMeta = ParsedProxyLogMetadata; -function parseExistingPathMeta(rawMessage: string): ParsedPathMeta { - const downstreamMatch = rawMessage.match(/\[downstream:([^\]]+)\]/i); - const upstreamMatch = rawMessage.match(/\[upstream:([^\]]+)\]/i); - const messageText = rawMessage.replace(/^\s*(?:\[(?:downstream|upstream):[^\]]+\]\s*)+/i, '').trim(); - return { - downstreamPath: downstreamMatch?.[1]?.trim() || null, - upstreamPath: upstreamMatch?.[1]?.trim() || null, - messageText, - }; +export function parseProxyLogMessageMeta(rawMessage: string): ParsedProxyLogMessageMeta { + return parseProxyLogMetadata(rawMessage); } export function composeProxyLogMessage({ + clientKind, + sessionId, + traceHint, downstreamPath, upstreamPath, errorMessage, }: ComposeProxyLogMessageArgs): string | null { const rawMessage = typeof errorMessage === 'string' ? errorMessage.trim() : ''; - const parsed = parseExistingPathMeta(rawMessage); + const parsed = parseProxyLogMessageMeta(rawMessage); + const finalClientKind = (clientKind || parsed.clientKind || '').trim(); + const finalSessionId = (sessionId || traceHint || parsed.sessionId || '').trim(); const finalDownstreamPath = (downstreamPath || parsed.downstreamPath || '').trim(); const finalUpstreamPath = (upstreamPath || parsed.upstreamPath || '').trim(); const finalMessageText = parsed.messageText.trim(); const prefixParts: string[] = []; + if (finalClientKind) prefixParts.push(`[client:${finalClientKind}]`); + if (finalSessionId) prefixParts.push(`[session:${finalSessionId}]`); if (finalDownstreamPath) prefixParts.push(`[downstream:${finalDownstreamPath}]`); if (finalUpstreamPath) prefixParts.push(`[upstream:${finalUpstreamPath}]`); diff --git a/src/server/routes/proxy/models.test.ts b/src/server/routes/proxy/models.test.ts index 15462d07..a6d3a5c6 100644 --- a/src/server/routes/proxy/models.test.ts +++ b/src/server/routes/proxy/models.test.ts @@ -7,12 +7,16 @@ import { join } from 'node:path'; type DbModule = typeof import('../../db/index.js'); type ProxyRouterModule = typeof import('./router.js'); type TokenRouterModule = typeof import('../../services/tokenRouter.js'); +type TokensRoutesModule = typeof import('../api/tokens.js'); +type ConfigModule = typeof import('../../config.js'); describe('/v1/models route', () => { let db: DbModule['db']; let schema: DbModule['schema']; let proxyRoutes: ProxyRouterModule['proxyRoutes']; + let tokensRoutes: TokensRoutesModule['tokensRoutes']; let invalidateTokenRouterCache: TokenRouterModule['invalidateTokenRouterCache']; + let config: ConfigModule['config']; let app: FastifyInstance; let dataDir = ''; @@ -24,13 +28,19 @@ describe('/v1/models route', () => { const dbModule = await import('../../db/index.js'); const proxyRouterModule = await import('./router.js'); const tokenRouterModule = await import('../../services/tokenRouter.js'); + const tokensRoutesModule = await import('../api/tokens.js'); + const configModule = await import('../../config.js'); db = dbModule.db; schema = dbModule.schema; proxyRoutes = proxyRouterModule.proxyRoutes; + tokensRoutes = tokensRoutesModule.tokensRoutes; invalidateTokenRouterCache = tokenRouterModule.invalidateTokenRouterCache; + config = configModule.config; + config.proxyToken = 'sk-global-proxy-token'; app = Fastify(); + await app.register(tokensRoutes); await app.register(proxyRoutes); }); @@ -103,6 +113,7 @@ describe('/v1/models route', () => { name: 'managed-key', key: 'sk-managed-models', enabled: true, + supportedModels: JSON.stringify(['routable-model']), }).run(); const response = await app.inject({ @@ -124,6 +135,64 @@ describe('/v1/models route', () => { expect(ids).not.toContain('orphan-model'); }); + it('keeps global proxy token unrestricted when no managed key matches', async () => { + const site = await db.insert(schema.sites).values({ + name: 'global-site', + url: 'https://global.example.com', + platform: 'openai', + status: 'active', + }).returning().get(); + + const account = await db.insert(schema.accounts).values({ + siteId: site.id, + accessToken: 'global-access-token', + status: 'active', + }).returning().get(); + + const token = await db.insert(schema.accountTokens).values({ + accountId: account.id, + name: 'default', + token: 'global-api-token', + enabled: true, + isDefault: true, + }).returning().get(); + + await db.insert(schema.modelAvailability).values({ + accountId: account.id, + modelName: 'global-routable-model', + available: true, + }).run(); + + const route = await db.insert(schema.tokenRoutes).values({ + modelPattern: 'global-routable-model', + enabled: true, + }).returning().get(); + + await db.insert(schema.routeChannels).values({ + routeId: route.id, + accountId: account.id, + tokenId: token.id, + sourceModel: 'global-routable-model', + enabled: true, + }).run(); + + const response = await app.inject({ + method: 'GET', + url: '/v1/models', + headers: { + authorization: 'Bearer sk-global-proxy-token', + }, + }); + + expect(response.statusCode).toBe(200); + const body = response.json() as { + object: 'list'; + data: Array<{ id: string }>; + }; + + expect(body.data.map((item) => item.id)).toContain('global-routable-model'); + }); + it('returns only whitelist models for managed key with supportedModels policy', async () => { const site = await db.insert(schema.sites).values({ name: 'test-site', @@ -285,6 +354,158 @@ describe('/v1/models route', () => { expect(ids).not.toContain('claude-sonnet-4-5'); }); + it('returns no models for managed key with empty model and group selections', async () => { + const site = await db.insert(schema.sites).values({ + name: 'deny-all-site', + url: 'https://deny-all.example.com', + platform: 'openai', + status: 'active', + }).returning().get(); + + const account = await db.insert(schema.accounts).values({ + siteId: site.id, + accessToken: 'deny-all-access-token', + status: 'active', + }).returning().get(); + + await db.insert(schema.modelAvailability).values([ + { + accountId: account.id, + modelName: 'gpt-4o-mini', + available: true, + }, + { + accountId: account.id, + modelName: 'claude-opus-4-6', + available: true, + }, + ]).run(); + + await db.insert(schema.downstreamApiKeys).values({ + name: 'managed-key-deny-all', + key: 'sk-managed-deny-all', + enabled: true, + supportedModels: JSON.stringify([]), + allowedRouteIds: JSON.stringify([]), + }).run(); + + const response = await app.inject({ + method: 'GET', + url: '/v1/models', + headers: { + authorization: 'Bearer sk-managed-deny-all', + }, + }); + + expect(response.statusCode).toBe(200); + const body = response.json() as { + object: 'list'; + data: Array<{ id: string }>; + }; + + expect(body.data).toEqual([]); + }); + + it('returns only explicit-group public name while hiding source exact routes', async () => { + const site = await db.insert(schema.sites).values({ + name: 'explicit-group-site', + url: 'https://explicit-group.example.com', + platform: 'openai', + status: 'active', + }).returning().get(); + + const account = await db.insert(schema.accounts).values({ + siteId: site.id, + accessToken: 'explicit-group-access-token', + status: 'active', + }).returning().get(); + + const token = await db.insert(schema.accountTokens).values({ + accountId: account.id, + name: 'default', + token: 'explicit-group-api-token', + enabled: true, + isDefault: true, + }).returning().get(); + + await db.insert(schema.modelAvailability).values([ + { + accountId: account.id, + modelName: 'claude-opus-4-5', + available: true, + }, + { + accountId: account.id, + modelName: 'claude-sonnet-4-5', + available: true, + }, + ]).run(); + + const sourceRouteA = await db.insert(schema.tokenRoutes).values({ + modelPattern: 'claude-opus-4-5', + enabled: true, + }).returning().get(); + const sourceRouteB = await db.insert(schema.tokenRoutes).values({ + modelPattern: 'claude-sonnet-4-5', + enabled: true, + }).returning().get(); + + await db.insert(schema.routeChannels).values([ + { + routeId: sourceRouteA.id, + accountId: account.id, + tokenId: token.id, + sourceModel: 'claude-opus-4-5', + enabled: true, + }, + { + routeId: sourceRouteB.id, + accountId: account.id, + tokenId: token.id, + sourceModel: 'claude-sonnet-4-5', + enabled: true, + }, + ]).run(); + + const groupResponse = await app.inject({ + method: 'POST', + url: '/api/routes', + payload: { + routeMode: 'explicit_group', + displayName: 'claude-opus-4-6', + sourceRouteIds: [sourceRouteA.id, sourceRouteB.id], + }, + }); + expect(groupResponse.statusCode).toBe(200); + const groupId = (groupResponse.json() as { id: number }).id; + + await db.insert(schema.downstreamApiKeys).values({ + name: 'managed-explicit-group-key', + key: 'sk-managed-explicit-group', + enabled: true, + allowedRouteIds: JSON.stringify([groupId]), + }).run(); + + const response = await app.inject({ + method: 'GET', + url: '/v1/models', + headers: { + authorization: 'Bearer sk-managed-explicit-group', + }, + }); + + expect(response.statusCode).toBe(200); + const body = response.json() as { + object: 'list'; + data: Array<{ id: string }>; + }; + + const ids = body.data.map((item) => item.id); + expect(ids).toContain('claude-opus-4-6'); + expect(ids).not.toContain('claude-opus-4-5'); + expect(ids).not.toContain('claude-sonnet-4-5'); + }); + it('filters search pseudo models out of /v1/models', async () => { const site = await db.insert(schema.sites).values({ name: 'search-site', @@ -352,11 +573,18 @@ describe('/v1/models route', () => { }, ]).run(); + await db.insert(schema.downstreamApiKeys).values({ + name: 'search-key', + key: 'sk-search-key', + enabled: true, + supportedModels: JSON.stringify(['gpt-4.1']), + }).run(); + const response = await app.inject({ method: 'GET', url: '/v1/models', headers: { - authorization: 'Bearer change-me-proxy-sk-token', + authorization: 'Bearer sk-search-key', }, }); diff --git a/src/server/routes/proxy/models.ts b/src/server/routes/proxy/models.ts index 065ad9bf..e9e7aff1 100644 --- a/src/server/routes/proxy/models.ts +++ b/src/server/routes/proxy/models.ts @@ -1,90 +1,21 @@ import { FastifyInstance } from 'fastify'; -import { db, schema } from '../../db/index.js'; -import { and, eq } from 'drizzle-orm'; -import { refreshModelsAndRebuildRoutes } from '../../services/modelService.js'; +import { listModelsSurface } from '../../proxy-core/surfaces/modelsSurface.js'; +import * as routeRefreshWorkflow from '../../services/routeRefreshWorkflow.js'; import { getDownstreamRoutingPolicy } from './downstreamPolicy.js'; import { tokenRouter } from '../../services/tokenRouter.js'; import { isModelAllowedByPolicyOrAllowedRoutes } from '../../services/downstreamApiKeyService.js'; -function isSearchPseudoModel(modelName: string): boolean { - const normalized = (modelName || '').trim().toLowerCase(); - if (!normalized) return false; - return normalized === '__search' || /^__.+_search$/.test(normalized); -} - export async function modelsProxyRoute(app: FastifyInstance) { app.get('/v1/models', async (request) => { const downstreamPolicy = getDownstreamRoutingPolicy(request); - - const readModels = async () => { - const rows = await db.select({ modelName: schema.modelAvailability.modelName }) - .from(schema.modelAvailability) - .innerJoin(schema.accounts, eq(schema.modelAvailability.accountId, schema.accounts.id)) - .innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id)) - .where( - and( - eq(schema.modelAvailability.available, true), - eq(schema.accounts.status, 'active'), - eq(schema.sites.status, 'active'), - ), - ) - .all(); - const routeAliases = (await db.select({ displayName: schema.tokenRoutes.displayName }) - .from(schema.tokenRoutes) - .where(eq(schema.tokenRoutes.enabled, true)) - .all()) - .map((row) => (row.displayName || '').trim()) - .filter((name) => name.length > 0); - const deduped = Array.from(new Set([ - ...rows.map((r) => r.modelName), - ...routeAliases, - ])) - .filter((modelName) => !isSearchPseudoModel(modelName)) - .sort(); - const allowed: string[] = []; - for (const modelName of deduped) { - if (!await isModelAllowedByPolicyOrAllowedRoutes(modelName, downstreamPolicy)) { - continue; - } - const decision = await tokenRouter.explainSelection(modelName, [], downstreamPolicy); - if (typeof decision.selectedChannelId === 'number') { - allowed.push(modelName); - } - } - return allowed; - }; - - let models = await readModels(); - if (models.length === 0) { - await refreshModelsAndRebuildRoutes(); - models = await readModels(); - } - const wantsClaudeFormat = typeof request.headers['anthropic-version'] === 'string' || typeof request.headers['x-api-key'] === 'string'; - if (wantsClaudeFormat) { - const data = models.map((id) => ({ - id, - type: 'model', - display_name: id, - created_at: new Date().toISOString(), - })); - return { - data, - first_id: data[0]?.id || null, - last_id: data[data.length - 1]?.id || null, - has_more: false, - }; - } - - return { - object: 'list', - data: models.map(id => ({ - id, - object: 'model', - created: Math.floor(Date.now() / 1000), - owned_by: 'metapi', - })), - }; + return listModelsSurface({ + downstreamPolicy, + responseFormat: wantsClaudeFormat ? 'claude' : 'openai', + tokenRouter, + refreshModelsAndRebuildRoutes: routeRefreshWorkflow.refreshModelsAndRebuildRoutes, + isModelAllowed: isModelAllowedByPolicyOrAllowedRoutes, + }); }); } diff --git a/src/server/routes/proxy/proxyFailureJudge.test.ts b/src/server/routes/proxy/proxyFailureJudge.test.ts new file mode 100644 index 00000000..4b6b8507 --- /dev/null +++ b/src/server/routes/proxy/proxyFailureJudge.test.ts @@ -0,0 +1,139 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { config } from '../../config.js'; +import { detectProxyFailure } from './proxyFailureJudge.js'; + +describe('detectProxyFailure (empty content)', () => { + const originalEmptyFail = config.proxyEmptyContentFailEnabled; + const originalKeywords = Array.isArray(config.proxyErrorKeywords) ? [...config.proxyErrorKeywords] : config.proxyErrorKeywords; + + afterEach(() => { + config.proxyEmptyContentFailEnabled = originalEmptyFail; + config.proxyErrorKeywords = originalKeywords as any; + }); + + it('flags empty assistant content even when total tokens > 0', () => { + config.proxyEmptyContentFailEnabled = true; + + const rawText = JSON.stringify({ + id: 'chatcmpl_empty', + object: 'chat.completion', + choices: [{ + index: 0, + message: { role: 'assistant', content: '' }, + finish_reason: 'stop', + }], + usage: { prompt_tokens: 12, completion_tokens: 0, total_tokens: 12 }, + }); + + const failure = detectProxyFailure({ + rawText, + usage: { promptTokens: 12, completionTokens: 0, totalTokens: 12 }, + }); + + expect(failure).toMatchObject({ status: 502 }); + }); + + it('does not flag when output exists even if usage is missing', () => { + config.proxyEmptyContentFailEnabled = true; + + const rawText = JSON.stringify({ + id: 'chatcmpl_has_output', + object: 'chat.completion', + choices: [{ + index: 0, + message: { role: 'assistant', content: 'hi' }, + finish_reason: 'stop', + }], + }); + + const failure = detectProxyFailure({ + rawText, + usage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 }, + }); + + expect(failure).toBeNull(); + }); + + it('does not treat tool call payloads as empty content', () => { + config.proxyEmptyContentFailEnabled = true; + + const rawText = JSON.stringify({ + id: 'resp_tool', + object: 'response', + status: 'completed', + output: [{ + type: 'function_call', + id: 'fc_1', + call_id: 'call_abc', + name: 'Glob', + arguments: '{"pattern":"README*"}', + }], + output_text: '', + }); + + const failure = detectProxyFailure({ + rawText, + usage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 }, + }); + + expect(failure).toBeNull(); + }); + + it('flags empty SSE streams that contain no content deltas', () => { + config.proxyEmptyContentFailEnabled = true; + + const rawText = [ + 'data: {"id":"evt_1","choices":[{"delta":{}}]}', + '', + 'data: [DONE]', + '', + '', + ].join('\n'); + + const failure = detectProxyFailure({ + rawText, + usage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 }, + }); + + expect(failure).toMatchObject({ status: 502 }); + }); + + it('treats DONE-only SSE as no output and flags failure', () => { + config.proxyEmptyContentFailEnabled = true; + + const rawText = [ + 'data: [DONE]', + '', + '', + ].join('\n'); + + const failure = detectProxyFailure({ + rawText, + usage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 }, + }); + + expect(failure).toMatchObject({ status: 502 }); + }); + + it('truncates fractional token counts before empty-content detection', () => { + config.proxyEmptyContentFailEnabled = true; + + const rawText = JSON.stringify({ + id: 'chatcmpl_fractional_empty', + object: 'chat.completion', + choices: [{ + index: 0, + message: { role: 'assistant', content: '' }, + finish_reason: 'stop', + }], + usage: { prompt_tokens: 3.9, completion_tokens: 0.6, total_tokens: 4.5 }, + }); + + const failure = detectProxyFailure({ + rawText, + usage: { promptTokens: 3.9, completionTokens: 0.6, totalTokens: 4.5 } as any, + }); + + expect(failure).toMatchObject({ status: 502 }); + }); +}); diff --git a/src/server/routes/proxy/proxyFailureJudge.ts b/src/server/routes/proxy/proxyFailureJudge.ts new file mode 100644 index 00000000..1e0c3b12 --- /dev/null +++ b/src/server/routes/proxy/proxyFailureJudge.ts @@ -0,0 +1,203 @@ +import { config } from '../../config.js'; +import { pullSseDataEvents } from '../../services/proxyUsageParser.js'; + +type FailureResult = { + status: number; + reason: string; +}; + +type UsageSummary = { + promptTokens?: number; + completionTokens?: number; + totalTokens?: number; +}; + +function normalizeKeywords(values: string[]): string[] { + return values + .map((item) => (typeof item === 'string' ? item.trim() : '')) + .filter((item) => item.length > 0) + .map((item) => item.toLowerCase()); +} + +function toNonNegativeInt(value: unknown): number { + const n = typeof value === 'number' ? value : Number(value); + if (!Number.isFinite(n)) return 0; + return Math.max(0, Math.trunc(n)); +} + +function hasNonEmptyString(value: unknown): boolean { + return typeof value === 'string' && value.trim().length > 0; +} + +function isRecord(value: unknown): value is Record { + return !!value && typeof value === 'object' && !Array.isArray(value); +} + +function hasToolCallLike(value: unknown): boolean { + if (!value) return false; + if (Array.isArray(value)) return value.length > 0; + if (isRecord(value)) return Object.keys(value).length > 0; + return false; +} + +function hasCompletionContentFromChoice(choice: any): boolean { + if (hasNonEmptyString(choice?.text)) return true; + if (hasNonEmptyString(choice?.completion)) return true; + if (hasNonEmptyString(choice?.output_text)) return true; + + const message = choice?.message; + if (hasNonEmptyString(message?.content)) return true; + if (Array.isArray(message?.content)) { + for (const part of message.content) { + if (hasNonEmptyString(part?.text) || hasNonEmptyString(part?.output_text) || hasNonEmptyString(part?.content)) { + return true; + } + } + } + + if (hasNonEmptyString(message?.refusal)) return true; + if (hasToolCallLike(message?.tool_calls)) return true; + if (hasToolCallLike(message?.toolCalls)) return true; + if (hasToolCallLike(message?.function_call)) return true; + if (hasToolCallLike(message?.functionCall)) return true; + + if (hasToolCallLike(choice?.tool_calls)) return true; + if (hasToolCallLike(choice?.toolCalls)) return true; + if (hasToolCallLike(choice?.function_call)) return true; + if (hasToolCallLike(choice?.functionCall)) return true; + + const delta = choice?.delta; + if (hasNonEmptyString(delta?.content)) return true; + if (hasNonEmptyString(delta?.refusal)) return true; + if (hasToolCallLike(delta?.tool_calls)) return true; + if (hasToolCallLike(delta?.toolCalls)) return true; + if (hasToolCallLike(delta?.function_call)) return true; + if (hasToolCallLike(delta?.functionCall)) return true; + + return false; +} + +function hasCompletionContentFromPayload(payload: unknown): boolean { + if (!payload || typeof payload !== 'object') return false; + const obj: any = payload; + + if (Array.isArray(obj?.choices)) { + for (const choice of obj.choices) { + if (hasCompletionContentFromChoice(choice)) return true; + } + if (hasCompletionContentFromChoice(obj)) return true; + } + + if (hasNonEmptyString(obj?.output_text)) return true; + if (hasNonEmptyString(obj?.outputText)) return true; + + if (Array.isArray(obj?.output)) { + for (const item of obj.output) { + if (!isRecord(item)) continue; + const type = String((item as any).type || '').toLowerCase(); + if (type.includes('function_call') || type.includes('tool_call')) return true; + + if (hasNonEmptyString((item as any).text) || hasNonEmptyString((item as any).output_text)) return true; + + if (Array.isArray((item as any).content)) { + for (const part of (item as any).content) { + if (hasNonEmptyString((part as any)?.text) || hasNonEmptyString((part as any)?.output_text) || hasNonEmptyString((part as any)?.content)) { + return true; + } + const partType = String((part as any)?.type || '').toLowerCase(); + if (partType.includes('function_call') || partType.includes('tool_call')) return true; + } + } + + if (hasToolCallLike((item as any).tool_calls) || hasToolCallLike((item as any).toolCalls)) return true; + if (hasToolCallLike((item as any).function_call) || hasToolCallLike((item as any).functionCall)) return true; + } + } + + if (Array.isArray(obj?.content)) { + for (const part of obj.content) { + if (hasNonEmptyString((part as any)?.text) || hasNonEmptyString((part as any)?.output_text) || hasNonEmptyString((part as any)?.content)) { + return true; + } + const partType = String((part as any)?.type || '').toLowerCase(); + if (partType.includes('function_call') || partType.includes('tool_call')) return true; + } + } + + if (hasNonEmptyString(obj?.delta)) return true; + if (hasNonEmptyString(obj?.text)) return true; + if (hasToolCallLike(obj?.tool_calls) || hasToolCallLike(obj?.toolCalls)) return true; + if (hasToolCallLike(obj?.function_call) || hasToolCallLike(obj?.functionCall)) return true; + + return false; +} + +function detectHasUpstreamOutput(rawText: string): boolean { + const text = typeof rawText === 'string' ? rawText : ''; + const trimmed = text.trim(); + if (!trimmed) return false; + + try { + const parsed = JSON.parse(trimmed); + return hasCompletionContentFromPayload(parsed); + } catch { + // Important: don't trim before SSE parsing, otherwise `data: [DONE]\n\n` can be lost. + const pulled = pullSseDataEvents(text); + if (pulled.events.length > 0) { + for (const event of pulled.events) { + const payload = event.trim(); + if (!payload || payload === '[DONE]') continue; + try { + const parsedEvent = JSON.parse(payload); + if (hasCompletionContentFromPayload(parsedEvent)) return true; + } catch { + // Non-JSON payload still counts as upstream output. + return true; + } + } + // SSE payloads exist but none contain output. + return false; + } + + // Looks like SSE but contains no non-DONE payloads. + if (text.includes('data:')) return false; + + // Not JSON and not SSE: assume it's plain text output. + return true; + } +} + +export function detectProxyFailure(input: { + rawText: string; + usage?: UsageSummary | null; + // Backward-compatible fields (older call sites) + completionTokens?: number; + totalTokens?: number; +}): FailureResult | null { + const rawText = typeof input.rawText === 'string' ? input.rawText : ''; + const keywords = normalizeKeywords(config.proxyErrorKeywords || []); + if (keywords.length > 0) { + const normalizedText = rawText.toLowerCase(); + const matched = keywords.find((keyword) => normalizedText.includes(keyword)); + if (matched) { + return { + status: 502, + reason: `Upstream response matched failure keyword: ${matched}`, + }; + } + } + + if (config.proxyEmptyContentFailEnabled) { + const completionTokens = toNonNegativeInt(input.usage?.completionTokens ?? input.completionTokens); + const hasOutput = detectHasUpstreamOutput(rawText); + + if (!hasOutput && completionTokens <= 0) { + return { + status: 502, + reason: 'Upstream returned empty content', + }; + } + } + + return null; +} diff --git a/src/server/routes/proxy/responses.codex-oauth.test.ts b/src/server/routes/proxy/responses.codex-oauth.test.ts new file mode 100644 index 00000000..7948f250 --- /dev/null +++ b/src/server/routes/proxy/responses.codex-oauth.test.ts @@ -0,0 +1,960 @@ +import Fastify, { type FastifyInstance } from 'fastify'; +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { config } from '../../config.js'; +import { resetCodexHttpSessionQueue } from '../../proxy-core/runtime/codexHttpSessionQueue.js'; + +const fetchMock = vi.fn(); +const selectChannelMock = vi.fn(); +const selectNextChannelMock = vi.fn(); +const recordSuccessMock = vi.fn(); +const recordFailureMock = vi.fn(); +const refreshModelsAndRebuildRoutesMock = vi.fn(); +const reportProxyAllFailedMock = vi.fn(); +const reportTokenExpiredMock = vi.fn(); +const resolveProxyUsageWithSelfLogFallbackMock = vi.fn(async ({ usage }: any) => ({ + ...usage, + estimatedCostFromQuota: 0, + recoveredFromSelfLog: false, +})); +const refreshOauthAccessTokenSingleflightMock = vi.fn(); +const recordOauthQuotaResetHintMock = vi.fn(); +const insertedProxyLogs: Record[] = []; +const originalProxyEmptyContentFailEnabled = config.proxyEmptyContentFailEnabled; +const originalProxyStickySessionEnabled = config.proxyStickySessionEnabled; +const originalProxySessionChannelConcurrencyLimit = config.proxySessionChannelConcurrencyLimit; +const originalProxySessionChannelQueueWaitMs = config.proxySessionChannelQueueWaitMs; +const dbInsertMock = vi.fn((_arg?: any) => ({ + values: (values: Record) => { + insertedProxyLogs.push(values); + return { + run: () => undefined, + }; + }, +})); + +vi.mock('undici', () => ({ + fetch: (...args: unknown[]) => fetchMock(...args), +})); + +vi.mock('../../services/tokenRouter.js', () => ({ + tokenRouter: { + selectChannel: (...args: unknown[]) => selectChannelMock(...args), + selectNextChannel: (...args: unknown[]) => selectNextChannelMock(...args), + recordSuccess: (...args: unknown[]) => recordSuccessMock(...args), + recordFailure: (...args: unknown[]) => recordFailureMock(...args), + }, +})); + +vi.mock('../../services/modelService.js', () => ({ + refreshModelsAndRebuildRoutes: (...args: unknown[]) => refreshModelsAndRebuildRoutesMock(...args), +})); + +vi.mock('../../services/alertService.js', () => ({ + reportProxyAllFailed: (...args: unknown[]) => reportProxyAllFailedMock(...args), + reportTokenExpired: (...args: unknown[]) => reportTokenExpiredMock(...args), +})); + +vi.mock('../../services/alertRules.js', () => ({ + isTokenExpiredError: ({ status }: { status?: number }) => status === 401, +})); + +vi.mock('../../services/modelPricingService.js', () => ({ + estimateProxyCost: async () => 0, + buildProxyBillingDetails: async () => null, + fetchModelPricingCatalog: async () => null, +})); + +vi.mock('../../services/proxyRetryPolicy.js', () => ({ + shouldRetryProxyRequest: () => false, +})); + +vi.mock('../../services/proxyUsageFallbackService.js', () => ({ + resolveProxyUsageWithSelfLogFallback: (arg: any) => resolveProxyUsageWithSelfLogFallbackMock(arg), +})); + +vi.mock('../../services/oauth/refreshSingleflight.js', () => ({ + refreshOauthAccessTokenSingleflight: (...args: unknown[]) => refreshOauthAccessTokenSingleflightMock(...args), +})); + +vi.mock('../../services/oauth/quota.js', () => ({ + recordOauthQuotaResetHint: (...args: unknown[]) => recordOauthQuotaResetHintMock(...args), +})); + +vi.mock('../../db/index.js', () => ({ + db: { + insert: (arg: any) => dbInsertMock(arg), + }, + hasProxyLogBillingDetailsColumn: async () => false, + hasProxyLogClientColumns: async () => false, + hasProxyLogDownstreamApiKeyIdColumn: async () => false, + schema: { + proxyLogs: {}, + }, +})); + +describe('responses proxy codex oauth refresh', () => { + let app: FastifyInstance; + + const createDeferred = () => { + let resolve!: (value: T | PromiseLike) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; + }; + + const waitFor = async (predicate: () => boolean, timeoutMs = 1000) => { + const startedAt = Date.now(); + while (!predicate()) { + if ((Date.now() - startedAt) >= timeoutMs) { + throw new Error('Timed out waiting for test condition'); + } + await new Promise((resolve) => setTimeout(resolve, 0)); + } + }; + + const createSseResponse = (chunks: string[], status = 200) => { + const encoder = new TextEncoder(); + return new Response(new ReadableStream({ + start(controller) { + for (const chunk of chunks) { + controller.enqueue(encoder.encode(chunk)); + } + controller.close(); + }, + }), { + status, + headers: { 'content-type': 'text/event-stream; charset=utf-8' }, + }); + }; + + beforeAll(async () => { + const { responsesProxyRoute } = await import('./responses.js'); + app = Fastify(); + await app.register(responsesProxyRoute); + }); + + beforeEach(() => { + resetCodexHttpSessionQueue(); + config.proxyEmptyContentFailEnabled = false; + config.proxyStickySessionEnabled = originalProxyStickySessionEnabled; + config.proxySessionChannelConcurrencyLimit = originalProxySessionChannelConcurrencyLimit; + config.proxySessionChannelQueueWaitMs = originalProxySessionChannelQueueWaitMs; + fetchMock.mockReset(); + selectChannelMock.mockReset(); + selectNextChannelMock.mockReset(); + recordSuccessMock.mockReset(); + recordFailureMock.mockReset(); + refreshModelsAndRebuildRoutesMock.mockReset(); + reportProxyAllFailedMock.mockReset(); + reportTokenExpiredMock.mockReset(); + resolveProxyUsageWithSelfLogFallbackMock.mockClear(); + refreshOauthAccessTokenSingleflightMock.mockReset(); + recordOauthQuotaResetHintMock.mockReset(); + dbInsertMock.mockClear(); + insertedProxyLogs.length = 0; + + selectChannelMock.mockReturnValue({ + channel: { id: 11, routeId: 22 }, + site: { name: 'codex-site', url: 'https://chatgpt.com/backend-api/codex', platform: 'codex' }, + account: { + id: 33, + username: 'codex-user@example.com', + extraConfig: JSON.stringify({ + credentialMode: 'session', + oauth: { + provider: 'codex', + accountId: 'chatgpt-account-123', + email: 'codex-user@example.com', + planType: 'plus', + }, + }), + }, + tokenName: 'default', + tokenValue: 'expired-access-token', + actualModel: 'gpt-5.2-codex', + }); + selectNextChannelMock.mockReturnValue(null); + refreshOauthAccessTokenSingleflightMock.mockResolvedValue({ + accessToken: 'fresh-access-token', + accountId: 33, + accountKey: 'chatgpt-account-123', + }); + }); + + afterAll(async () => { + config.proxyEmptyContentFailEnabled = originalProxyEmptyContentFailEnabled; + config.proxyStickySessionEnabled = originalProxyStickySessionEnabled; + config.proxySessionChannelConcurrencyLimit = originalProxySessionChannelConcurrencyLimit; + config.proxySessionChannelQueueWaitMs = originalProxySessionChannelQueueWaitMs; + await app.close(); + }); + + it('refreshes codex oauth token and retries the same responses request on 401', async () => { + fetchMock + .mockResolvedValueOnce(new Response(JSON.stringify({ + error: { message: 'expired token', type: 'invalid_request_error' }, + }), { + status: 401, + headers: { 'content-type': 'application/json' }, + })) + .mockResolvedValueOnce(new Response(JSON.stringify({ + id: 'resp_codex_refreshed', + object: 'response', + model: 'gpt-5.2-codex', + status: 'completed', + output_text: 'ok after codex token refresh', + usage: { input_tokens: 4, output_tokens: 2, total_tokens: 6 }, + }), { + status: 200, + headers: { 'content-type': 'application/json' }, + })); + + const response = await app.inject({ + method: 'POST', + url: '/v1/responses', + headers: { + 'user-agent': 'CodexClient/1.0', + 'Chatgpt-Account-Id': 'spoofed-account', + }, + payload: { + model: 'gpt-5.2-codex', + input: 'hello codex', + }, + }); + + expect(response.statusCode).toBe(200); + expect(refreshOauthAccessTokenSingleflightMock).toHaveBeenCalledWith(33); + expect(fetchMock).toHaveBeenCalledTimes(2); + + const [firstUrl, firstOptions] = fetchMock.mock.calls[0] as [string, any]; + const [secondUrl, secondOptions] = fetchMock.mock.calls[1] as [string, any]; + expect(firstUrl).toBe('https://chatgpt.com/backend-api/codex/responses'); + expect(secondUrl).toBe('https://chatgpt.com/backend-api/codex/responses'); + expect(firstOptions.headers.Authorization).toBe('Bearer expired-access-token'); + expect(secondOptions.headers.Authorization).toBe('Bearer fresh-access-token'); + expect(secondOptions.headers.Originator || secondOptions.headers.originator).toBe('codex_cli_rs'); + expect(secondOptions.headers['Chatgpt-Account-Id'] || secondOptions.headers['chatgpt-account-id']).toBe('chatgpt-account-123'); + expect(secondOptions.headers.Version || secondOptions.headers.version).toBe('0.101.0'); + expect(String(secondOptions.headers.Session_id || secondOptions.headers.session_id || '')).toMatch(/^[0-9a-f-]{36}$/i); + expect(secondOptions.headers.Conversation_id || secondOptions.headers.conversation_id).toBeUndefined(); + expect(secondOptions.headers['User-Agent'] || secondOptions.headers['user-agent']).toBe('CodexClient/1.0'); + expect(secondOptions.headers.Accept || secondOptions.headers.accept).toBe('text/event-stream'); + expect(secondOptions.headers.Connection || secondOptions.headers.connection).toBe('Keep-Alive'); + expect(response.json()?.output_text).toContain('ok after codex token refresh'); + }); + + it('refreshes codex oauth token and retries the same responses request on 403', async () => { + fetchMock + .mockResolvedValueOnce(new Response(JSON.stringify({ + error: { message: 'forbidden account mismatch', type: 'invalid_request_error' }, + }), { + status: 403, + headers: { 'content-type': 'application/json' }, + })) + .mockResolvedValueOnce(new Response(JSON.stringify({ + id: 'resp_codex_refreshed_403', + object: 'response', + model: 'gpt-5.2-codex', + status: 'completed', + output_text: 'ok after codex forbidden refresh', + usage: { input_tokens: 4, output_tokens: 2, total_tokens: 6 }, + }), { + status: 200, + headers: { 'content-type': 'application/json' }, + })); + + const response = await app.inject({ + method: 'POST', + url: '/v1/responses', + headers: { + 'user-agent': 'CodexClient/1.0', + }, + payload: { + model: 'gpt-5.2-codex', + input: 'hello codex', + }, + }); + + expect(response.statusCode).toBe(200); + expect(refreshOauthAccessTokenSingleflightMock).toHaveBeenCalledWith(33); + expect(fetchMock).toHaveBeenCalledTimes(2); + const [, secondOptions] = fetchMock.mock.calls[1] as [string, any]; + expect(secondOptions.headers.Authorization).toBe('Bearer fresh-access-token'); + expect(response.json()?.output_text).toContain('ok after codex forbidden refresh'); + }); + + it('does not refresh codex oauth token on non-auth 403 responses', async () => { + fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({ + error: { message: 'quota exceeded for workspace', type: 'usage_limit_reached' }, + }), { + status: 403, + headers: { 'content-type': 'application/json' }, + })); + + const response = await app.inject({ + method: 'POST', + url: '/v1/responses', + payload: { + model: 'gpt-5.2-codex', + input: 'hello codex', + }, + }); + + expect(response.statusCode).toBe(403); + expect(refreshOauthAccessTokenSingleflightMock).not.toHaveBeenCalled(); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(response.json()).toMatchObject({ + error: { + message: expect.stringContaining('quota exceeded for workspace'), + }, + }); + }); + + it('retries oauth responses requests with a normalized upstream URL after refresh', async () => { + selectChannelMock.mockReturnValue({ + channel: { id: 11, routeId: 22 }, + site: { name: 'openai-site', url: 'https://gateway.example.com/v1/', platform: 'openai' }, + account: { + id: 33, + username: 'oauth-user@example.com', + extraConfig: JSON.stringify({ + credentialMode: 'session', + oauth: { + provider: 'codex', + accountId: 'chatgpt-account-123', + email: 'oauth-user@example.com', + planType: 'plus', + }, + }), + }, + tokenName: 'default', + tokenValue: 'expired-access-token', + actualModel: 'gpt-4.1-mini', + }); + + fetchMock + .mockResolvedValueOnce(new Response(JSON.stringify({ + error: { message: 'expired token', type: 'invalid_request_error' }, + }), { + status: 401, + headers: { 'content-type': 'application/json' }, + })) + .mockResolvedValueOnce(new Response(JSON.stringify({ + id: 'resp_openai_refreshed', + object: 'response', + model: 'gpt-4.1-mini', + status: 'completed', + output_text: 'ok after refresh', + usage: { input_tokens: 4, output_tokens: 2, total_tokens: 6 }, + }), { + status: 200, + headers: { 'content-type': 'application/json' }, + })); + + const response = await app.inject({ + method: 'POST', + url: '/v1/responses', + payload: { + model: 'gpt-4.1-mini', + input: 'hello oauth', + }, + }); + + expect(response.statusCode).toBe(200); + expect(refreshOauthAccessTokenSingleflightMock).toHaveBeenCalledWith(33); + expect(fetchMock).toHaveBeenCalledTimes(2); + + const [firstUrl, firstOptions] = fetchMock.mock.calls[0] as [string, any]; + const [secondUrl, secondOptions] = fetchMock.mock.calls[1] as [string, any]; + expect(firstUrl).toBe('https://gateway.example.com/v1/responses'); + expect(secondUrl).toBe('https://gateway.example.com/v1/responses'); + expect(firstOptions.headers.Authorization).toBe('Bearer expired-access-token'); + expect(secondOptions.headers.Authorization).toBe('Bearer fresh-access-token'); + expect(response.json()?.output_text).toBe('ok after refresh'); + }); + + it('sends an explicit empty instructions field to codex responses when downstream body has no system prompt', async () => { + fetchMock.mockResolvedValue(new Response(JSON.stringify({ + id: 'resp_codex_no_system', + object: 'response', + model: 'gpt-5.2-codex', + status: 'completed', + output_text: 'ok without system prompt', + usage: { input_tokens: 4, output_tokens: 2, total_tokens: 6 }, + }), { + status: 200, + headers: { 'content-type': 'application/json' }, + })); + + const response = await app.inject({ + method: 'POST', + url: '/v1/responses', + payload: { + model: 'gpt-5.2-codex', + input: 'hello codex', + }, + }); + + expect(response.statusCode).toBe(200); + expect(fetchMock).toHaveBeenCalledTimes(1); + + const [, options] = fetchMock.mock.calls[0] as [string, any]; + const forwardedBody = JSON.parse(options.body); + expect(forwardedBody.instructions).toBe(''); + expect(forwardedBody.prompt_cache_key).toBeUndefined(); + expect(forwardedBody.stream).toBe(true); + expect(forwardedBody.input).toEqual([ + { + type: 'message', + role: 'user', + content: [{ type: 'input_text', text: 'hello codex' }], + }, + ]); + }); + + it('preserves explicit prompt_cache_key for codex responses requests without converting it into codex session headers', async () => { + fetchMock.mockResolvedValue(new Response(JSON.stringify({ + id: 'resp_codex_with_cache_key', + object: 'response', + model: 'gpt-5.2-codex', + status: 'completed', + output_text: 'ok with cache key', + usage: { input_tokens: 4, output_tokens: 2, total_tokens: 6 }, + }), { + status: 200, + headers: { 'content-type': 'application/json' }, + })); + + const response = await app.inject({ + method: 'POST', + url: '/v1/responses', + payload: { + model: 'gpt-5.2-codex', + prompt_cache_key: 'codex-cache-123', + input: 'hello codex', + }, + }); + + expect(response.statusCode).toBe(200); + expect(fetchMock).toHaveBeenCalledTimes(1); + + const [, options] = fetchMock.mock.calls[0] as [string, any]; + const forwardedBody = JSON.parse(options.body); + expect(String(options.headers.Session_id || options.headers.session_id || '')).toMatch(/^[0-9a-f-]{36}$/i); + expect(options.headers.Conversation_id || options.headers.conversation_id).toBeUndefined(); + expect(forwardedBody.prompt_cache_key).toBe('codex-cache-123'); + }); + + it('strips generic downstream headers before forwarding codex responses upstream', async () => { + fetchMock.mockResolvedValue(new Response(JSON.stringify({ + id: 'resp_codex_header_filter', + object: 'response', + model: 'gpt-5.2-codex', + status: 'completed', + output_text: 'ok with filtered headers', + usage: { input_tokens: 4, output_tokens: 2, total_tokens: 6 }, + }), { + status: 200, + headers: { 'content-type': 'application/json' }, + })); + + const response = await app.inject({ + method: 'POST', + url: '/v1/responses', + headers: { + 'openai-beta': 'responses-2025-03-11', + 'x-openai-client-user-agent': '{"client":"openclaw"}', + origin: 'https://openclaw.example', + referer: 'https://openclaw.example/app', + 'user-agent': 'OpenClaw/1.0', + version: '0.202.0', + session_id: 'session-from-client', + }, + payload: { + model: 'gpt-5.2-codex', + input: 'hello codex', + }, + }); + + expect(response.statusCode).toBe(200); + expect(fetchMock).toHaveBeenCalledTimes(1); + + const [, options] = fetchMock.mock.calls[0] as [string, any]; + expect(options.headers.Version || options.headers.version).toBe('0.202.0'); + expect(options.headers.Session_id || options.headers.session_id).toBe('session-from-client'); + expect(options.headers['User-Agent'] || options.headers['user-agent']).toBe('OpenClaw/1.0'); + expect(options.headers['openai-beta']).toBeUndefined(); + expect(options.headers['x-openai-client-user-agent']).toBeUndefined(); + expect(options.headers.origin).toBeUndefined(); + expect(options.headers.referer).toBeUndefined(); + }); + + it('serializes concurrent codex HTTP responses requests that share the same session id', async () => { + const firstUpstream = createDeferred(); + fetchMock + .mockImplementationOnce(() => firstUpstream.promise) + .mockResolvedValueOnce(new Response(JSON.stringify({ + id: 'resp_codex_serial_2', + object: 'response', + model: 'gpt-5.2-codex', + status: 'completed', + output_text: 'second request finished', + usage: { input_tokens: 4, output_tokens: 2, total_tokens: 6 }, + }), { + status: 200, + headers: { 'content-type': 'application/json' }, + })); + + const firstResponsePromise = app.inject({ + method: 'POST', + url: '/v1/responses', + headers: { + session_id: 'session-http-serial-1', + }, + payload: { + model: 'gpt-5.2-codex', + input: 'first request', + }, + }); + + await waitFor(() => fetchMock.mock.calls.length === 1); + + const secondResponsePromise = app.inject({ + method: 'POST', + url: '/v1/responses', + headers: { + session_id: 'session-http-serial-1', + }, + payload: { + model: 'gpt-5.2-codex', + input: 'second request', + }, + }); + + await new Promise((resolve) => setTimeout(resolve, 0)); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(fetchMock).toHaveBeenCalledTimes(1); + + firstUpstream.resolve(new Response(JSON.stringify({ + id: 'resp_codex_serial_1', + object: 'response', + model: 'gpt-5.2-codex', + status: 'completed', + output_text: 'first request finished', + usage: { input_tokens: 4, output_tokens: 2, total_tokens: 6 }, + }), { + status: 200, + headers: { 'content-type': 'application/json' }, + })); + + const firstResponse = await firstResponsePromise; + expect(firstResponse.statusCode).toBe(200); + expect(firstResponse.json()).toMatchObject({ + output_text: 'first request finished', + }); + + await waitFor(() => fetchMock.mock.calls.length === 2); + + const secondResponse = await secondResponsePromise; + expect(secondResponse.statusCode).toBe(200); + expect(secondResponse.json()).toMatchObject({ + output_text: 'second request finished', + }); + + const [, firstOptions] = fetchMock.mock.calls[0] as [string, any]; + const [, secondOptions] = fetchMock.mock.calls[1] as [string, any]; + expect(firstOptions.headers.Session_id || firstOptions.headers.session_id).toBe('session-http-serial-1'); + expect(secondOptions.headers.Session_id || secondOptions.headers.session_id).toBe('session-http-serial-1'); + }); + + it('does not gate codex responses requests without a downstream session id behind the session lease queue', async () => { + config.proxyStickySessionEnabled = true; + config.proxySessionChannelConcurrencyLimit = 1; + config.proxySessionChannelQueueWaitMs = 20; + + const firstUpstream = createDeferred(); + fetchMock + .mockImplementationOnce(() => firstUpstream.promise) + .mockResolvedValueOnce(new Response(JSON.stringify({ + id: 'resp_codex_parallel_2', + object: 'response', + model: 'gpt-5.2-codex', + status: 'completed', + output_text: 'second request finished', + usage: { input_tokens: 4, output_tokens: 2, total_tokens: 6 }, + }), { + status: 200, + headers: { 'content-type': 'application/json' }, + })); + + const firstResponsePromise = app.inject({ + method: 'POST', + url: '/v1/responses', + payload: { + model: 'gpt-5.2-codex', + input: 'first request', + }, + }); + + await waitFor(() => fetchMock.mock.calls.length === 1); + + const secondResponsePromise = app.inject({ + method: 'POST', + url: '/v1/responses', + payload: { + model: 'gpt-5.2-codex', + input: 'second request', + }, + }); + + await waitFor(() => fetchMock.mock.calls.length === 2); + + firstUpstream.resolve(new Response(JSON.stringify({ + id: 'resp_codex_parallel_1', + object: 'response', + model: 'gpt-5.2-codex', + status: 'completed', + output_text: 'first request finished', + usage: { input_tokens: 4, output_tokens: 2, total_tokens: 6 }, + }), { + status: 200, + headers: { 'content-type': 'application/json' }, + })); + + const [firstResponse, secondResponse] = await Promise.all([ + firstResponsePromise, + secondResponsePromise, + ]); + expect(firstResponse.statusCode).toBe(200); + expect(secondResponse.statusCode).toBe(200); + }); + + it('records codex usage_limit_reached reset hints on upstream 429 failures', async () => { + fetchMock.mockResolvedValue(new Response(JSON.stringify({ + error: { + type: 'usage_limit_reached', + resets_at: 1773800400, + message: 'quota exceeded', + }, + }), { + status: 429, + headers: { 'content-type': 'application/json' }, + })); + + const response = await app.inject({ + method: 'POST', + url: '/v1/responses', + payload: { + model: 'gpt-5.2-codex', + input: 'hello codex', + }, + }); + + expect(response.statusCode).toBe(429); + expect(recordOauthQuotaResetHintMock).toHaveBeenCalledWith({ + accountId: 33, + statusCode: 429, + errorText: JSON.stringify({ + error: { + type: 'usage_limit_reached', + resets_at: 1773800400, + message: 'quota exceeded', + }, + }), + }); + }); + + it('forces codex upstream responses requests to stream and aggregates the SSE payload for non-stream downstream callers', async () => { + fetchMock.mockResolvedValue(createSseResponse([ + 'event: response.created\n', + 'data: {"type":"response.created","response":{"id":"resp_codex_stream","model":"gpt-5.4","created_at":1706000000,"status":"in_progress","output":[]}}\n\n', + 'event: response.output_item.added\n', + 'data: {"type":"response.output_item.added","output_index":0,"item":{"id":"msg_codex_stream","type":"message","role":"assistant","status":"in_progress","content":[]}}\n\n', + 'event: response.output_text.delta\n', + 'data: {"type":"response.output_text.delta","output_index":0,"item_id":"msg_codex_stream","delta":"pong"}\n\n', + 'event: response.completed\n', + 'data: {"type":"response.completed","response":{"id":"resp_codex_stream","model":"gpt-5.4","status":"completed","usage":{"input_tokens":3,"output_tokens":1,"total_tokens":4}}}\n\n', + 'data: [DONE]\n\n', + ])); + + const response = await app.inject({ + method: 'POST', + url: '/v1/responses', + payload: { + model: 'gpt-5.4', + input: 'hello codex', + }, + }); + + expect(response.statusCode).toBe(200); + expect(fetchMock).toHaveBeenCalledTimes(1); + + const [, options] = fetchMock.mock.calls[0] as [string, any]; + const forwardedBody = JSON.parse(options.body); + expect(forwardedBody.stream).toBe(true); + expect(forwardedBody.instructions).toBe(''); + expect(forwardedBody.store).toBe(false); + expect(forwardedBody.max_output_tokens).toBeUndefined(); + + expect(response.json()).toMatchObject({ + id: 'resp_codex_stream', + model: 'gpt-5.4', + status: 'completed', + output_text: 'pong', + usage: { + input_tokens: 3, + output_tokens: 1, + total_tokens: 4, + }, + }); + }); + + it('preserves codex-required instructions and store fields across responses compatibility retries', async () => { + fetchMock.mockResolvedValue(new Response(JSON.stringify({ + error: { message: 'upstream_error', type: 'upstream_error' }, + }), { + status: 400, + headers: { 'content-type': 'application/json' }, + })); + + const response = await app.inject({ + method: 'POST', + url: '/v1/responses', + payload: { + model: 'gpt-5.2-codex', + input: 'hello codex', + metadata: { trace: 'compatibility-retry' }, + }, + }); + + expect(response.statusCode).toBe(400); + expect(fetchMock.mock.calls.length).toBeGreaterThanOrEqual(2); + + const [, firstOptions] = fetchMock.mock.calls[0] as [string, any]; + const [, secondOptions] = fetchMock.mock.calls[1] as [string, any]; + const firstBody = JSON.parse(firstOptions.body); + const secondBody = JSON.parse(secondOptions.body); + + expect(firstBody.instructions).toBe(''); + expect(firstBody.store).toBe(false); + expect(firstBody.stream).toBe(true); + expect(firstBody.max_output_tokens).toBeUndefined(); + expect(secondBody.instructions).toBe(''); + expect(secondBody.store).toBe(false); + expect(secondBody.stream).toBe(true); + expect(secondBody.max_output_tokens).toBeUndefined(); + }); + + it('does not record success when a streaming responses request ends with response.failed', async () => { + fetchMock.mockResolvedValue(createSseResponse([ + 'event: response.created\n', + 'data: {"type":"response.created","response":{"id":"resp_codex_failed","model":"gpt-5.4","created_at":1706000000,"status":"in_progress","output":[]}}\n\n', + 'event: response.failed\n', + 'data: {"type":"response.failed","response":{"id":"resp_codex_failed","model":"gpt-5.4","status":"failed","error":{"message":"tool execution failed"}}}\n\n', + 'data: [DONE]\n\n', + ])); + + const response = await app.inject({ + method: 'POST', + url: '/v1/responses', + payload: { + model: 'gpt-5.4', + input: 'hello codex', + stream: true, + }, + }); + + expect(response.statusCode).toBe(200); + expect(response.body).toContain('response.failed'); + expect(response.body).not.toContain('response.completed'); + expect(recordSuccessMock).not.toHaveBeenCalled(); + expect(recordFailureMock).toHaveBeenCalledTimes(1); + expect(insertedProxyLogs.at(-1)).toMatchObject({ + status: 'failed', + httpStatus: 200, + }); + expect(String(insertedProxyLogs.at(-1)?.errorMessage || '')).toContain('tool execution failed'); + }); + + it('does not record success when a native responses stream closes before response.completed', async () => { + fetchMock.mockResolvedValue(createSseResponse([ + 'event: response.created\n', + 'data: {"type":"response.created","response":{"id":"resp_codex_truncated","model":"gpt-5.4","created_at":1706000000,"status":"in_progress","output":[]}}\n\n', + 'event: response.output_item.added\n', + 'data: {"type":"response.output_item.added","output_index":0,"item":{"id":"msg_codex_truncated","type":"message","role":"assistant","status":"in_progress","content":[]}}\n\n', + 'event: response.output_text.delta\n', + 'data: {"type":"response.output_text.delta","output_index":0,"item_id":"msg_codex_truncated","delta":"partial"}\n\n', + 'data: [DONE]\n\n', + ])); + + const response = await app.inject({ + method: 'POST', + url: '/v1/responses', + payload: { + model: 'gpt-5.4', + input: 'hello codex', + stream: true, + }, + }); + + expect(response.statusCode).toBe(200); + expect(response.body).toContain('event: response.failed'); + expect(response.body).not.toContain('event: response.completed'); + expect(recordSuccessMock).not.toHaveBeenCalled(); + expect(recordFailureMock).toHaveBeenCalledTimes(1); + expect(insertedProxyLogs.at(-1)).toMatchObject({ + status: 'failed', + httpStatus: 200, + }); + expect(String(insertedProxyLogs.at(-1)?.errorMessage || '')).toContain('stream closed before response.completed'); + }); + + it('does not record success when a native responses stream completes with empty content and empty usage while empty-content failure is enabled', async () => { + config.proxyEmptyContentFailEnabled = true; + + fetchMock.mockResolvedValue(createSseResponse([ + 'event: response.created\n', + 'data: {"type":"response.created","response":{"id":"resp_codex_empty","model":"gpt-5.4","created_at":1706000000,"status":"in_progress","output":[]}}\n\n', + 'event: response.completed\n', + 'data: {"type":"response.completed","response":{"id":"resp_codex_empty","model":"gpt-5.4","status":"completed","output":[],"usage":{"input_tokens":0,"output_tokens":0,"total_tokens":0}}}\n\n', + 'data: [DONE]\n\n', + ])); + + const response = await app.inject({ + method: 'POST', + url: '/v1/responses', + payload: { + model: 'gpt-5.4', + input: 'hello codex', + stream: true, + }, + }); + + expect(response.statusCode).toBe(200); + expect(response.body).toContain('event: response.failed'); + expect(response.body).not.toContain('event: response.completed'); + expect(recordSuccessMock).not.toHaveBeenCalled(); + expect(recordFailureMock).toHaveBeenCalledTimes(1); + expect(insertedProxyLogs.at(-1)).toMatchObject({ + status: 'failed', + httpStatus: 200, + }); + expect(String(insertedProxyLogs.at(-1)?.errorMessage || '')).toContain('empty content'); + }); + + it('does not retry or mark failure after converting a non-stream upstream payload into SSE when post-stream usage accounting fails', async () => { + resolveProxyUsageWithSelfLogFallbackMock.mockRejectedValueOnce(new Error('usage accounting failed')); + fetchMock.mockResolvedValue(new Response(JSON.stringify({ + id: 'resp_nonstream_final', + object: 'response', + model: 'gpt-5.4', + status: 'completed', + output: [ + { + id: 'msg_nonstream_final', + type: 'message', + role: 'assistant', + status: 'completed', + content: [ + { + type: 'output_text', + text: 'pong', + }, + ], + }, + ], + output_text: 'pong', + usage: { input_tokens: 3, output_tokens: 1, total_tokens: 4 }, + }), { + status: 200, + headers: { 'content-type': 'application/json' }, + })); + + const response = await app.inject({ + method: 'POST', + url: '/v1/responses', + payload: { + model: 'gpt-5.4', + input: 'hello codex', + stream: true, + }, + }); + + expect(response.statusCode).toBe(200); + expect(response.body).toContain('event: response.completed'); + expect(response.body).toContain('"output_text":"pong"'); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(recordFailureMock).not.toHaveBeenCalled(); + expect(recordSuccessMock).toHaveBeenCalledTimes(1); + }); + + it('does not retry or mark failure after streaming SSE success when post-stream usage accounting fails', async () => { + resolveProxyUsageWithSelfLogFallbackMock.mockRejectedValueOnce(new Error('usage accounting failed')); + fetchMock.mockResolvedValue(createSseResponse([ + 'event: response.created\n', + 'data: {"type":"response.created","response":{"id":"resp_codex_stream_ok","model":"gpt-5.4","created_at":1706000000,"status":"in_progress","output":[]}}\n\n', + 'event: response.output_item.added\n', + 'data: {"type":"response.output_item.added","output_index":0,"item":{"id":"msg_codex_stream_ok","type":"message","role":"assistant","status":"in_progress","content":[]}}\n\n', + 'event: response.output_text.delta\n', + 'data: {"type":"response.output_text.delta","output_index":0,"item_id":"msg_codex_stream_ok","delta":"pong"}\n\n', + 'event: response.completed\n', + 'data: {"type":"response.completed","response":{"id":"resp_codex_stream_ok","model":"gpt-5.4","status":"completed","usage":{"input_tokens":3,"output_tokens":1,"total_tokens":4}}}\n\n', + 'data: [DONE]\n\n', + ])); + + const response = await app.inject({ + method: 'POST', + url: '/v1/responses', + payload: { + model: 'gpt-5.4', + input: 'hello codex', + stream: true, + }, + }); + + expect(response.statusCode).toBe(200); + expect(response.body).toContain('event: response.completed'); + expect(response.body).toContain('"id":"resp_codex_stream_ok"'); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(recordFailureMock).not.toHaveBeenCalled(); + expect(recordSuccessMock).toHaveBeenCalledTimes(1); + }); + + it('replays raw codex SSE when upstream mislabels a streaming responses body as application/json', async () => { + fetchMock.mockResolvedValue(new Response([ + 'event: response.created\n', + 'data: {"type":"response.created","response":{"id":"resp_codex_stream_header_miss","model":"gpt-5.4","created_at":1706000000,"status":"in_progress","output":[]}}\n\n', + 'event: response.output_item.added\n', + 'data: {"type":"response.output_item.added","output_index":0,"item":{"id":"msg_codex_stream_header_miss","type":"message","role":"assistant","status":"in_progress","content":[]}}\n\n', + 'event: response.output_text.delta\n', + 'data: {"type":"response.output_text.delta","output_index":0,"item_id":"msg_codex_stream_header_miss","delta":"pong"}\n\n', + 'event: response.completed\n', + 'data: {"type":"response.completed","response":{"id":"resp_codex_stream_header_miss","model":"gpt-5.4","status":"completed","usage":{"input_tokens":3,"output_tokens":1,"total_tokens":4}}}\n\n', + 'data: [DONE]\n\n', + ].join(''), { + status: 200, + headers: { 'content-type': 'application/json' }, + })); + + const response = await app.inject({ + method: 'POST', + url: '/v1/responses', + payload: { + model: 'gpt-5.4', + input: 'hello codex', + stream: true, + }, + }); + + expect(response.statusCode).toBe(200); + expect(response.body).toContain('event: response.output_text.delta'); + expect(response.body).toContain('"delta":"pong"'); + expect(response.body).not.toContain('"output_text":"event: response.created'); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(recordFailureMock).not.toHaveBeenCalled(); + expect(recordSuccessMock).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/server/routes/proxy/responses.compact-upstream.test.ts b/src/server/routes/proxy/responses.compact-upstream.test.ts index 9c6c2441..ce93ae2b 100644 --- a/src/server/routes/proxy/responses.compact-upstream.test.ts +++ b/src/server/routes/proxy/responses.compact-upstream.test.ts @@ -67,6 +67,9 @@ vi.mock('../../db/index.js', () => ({ db: { insert: (arg: any) => dbInsertMock(arg), }, + hasProxyLogBillingDetailsColumn: async () => false, + hasProxyLogClientColumns: async () => false, + hasProxyLogDownstreamApiKeyIdColumn: async () => false, schema: { proxyLogs: {}, }, @@ -137,4 +140,165 @@ describe('responses proxy compact upstream routing', () => { const [targetUrl] = fetchMock.mock.calls[0] as [string, any]; expect(targetUrl).toContain('/v1/responses/compact'); }); + + it('preserves native response.compaction payloads instead of coercing them into object=response', async () => { + fetchMock.mockResolvedValue(new Response(JSON.stringify({ + id: 'cmp_123', + object: 'response.compaction', + input_tokens: 1234, + output_tokens: 321, + total_tokens: 1555, + output: [ + { + id: 'rs_123', + type: 'compaction', + encrypted_content: 'enc-compact-payload', + }, + ], + }), { + status: 200, + headers: { 'content-type': 'application/json' }, + })); + + const response = await app.inject({ + method: 'POST', + url: '/v1/responses/compact', + payload: { + model: 'gpt-5.2', + input: 'hello', + }, + }); + + expect(response.statusCode).toBe(200); + expect(response.json()).toEqual({ + id: 'cmp_123', + object: 'response.compaction', + input_tokens: 1234, + output_tokens: 321, + total_tokens: 1555, + output: [ + { + id: 'rs_123', + type: 'compaction', + encrypted_content: 'enc-compact-payload', + }, + ], + }); + }); + + it('preserves native response.compaction payloads when the upstream compact surface closes via SSE', async () => { + fetchMock.mockResolvedValue(new Response([ + 'event: response.output_item.added', + `data: ${JSON.stringify({ + type: 'response.output_item.added', + output_index: 0, + item: { + id: 'rs_123', + type: 'compaction', + encrypted_content: 'enc-compact-payload', + }, + })}`, + '', + 'event: response.completed', + `data: ${JSON.stringify({ + type: 'response.completed', + response: { + id: 'cmp_123', + object: 'response.compaction', + created_at: 1700000000, + output: [ + { + id: 'rs_123', + type: 'compaction', + encrypted_content: 'enc-compact-payload', + }, + ], + usage: { + input_tokens: 1234, + output_tokens: 321, + total_tokens: 1555, + }, + }, + })}`, + '', + 'data: [DONE]', + '', + ].join('\n'), { + status: 200, + headers: { 'content-type': 'text/event-stream' }, + })); + + const response = await app.inject({ + method: 'POST', + url: '/v1/responses/compact', + payload: { + model: 'gpt-5.2', + input: 'hello', + }, + }); + + expect(response.statusCode).toBe(200); + expect(response.json()).toEqual({ + id: 'cmp_123', + object: 'response.compaction', + created_at: 1700000000, + output: [ + { + id: 'rs_123', + type: 'compaction', + encrypted_content: 'enc-compact-payload', + }, + ], + usage: { + input_tokens: 1234, + output_tokens: 321, + total_tokens: 1555, + }, + }); + }); + + it('collects final payloads when non-stream compact upstreams still respond with SSE final payloads directly', async () => { + fetchMock.mockResolvedValue(new Response([ + 'event: response.completed', + 'data: {"id":"cmp_sse_123","object":"response.compaction","input_tokens":12,"output_tokens":3,"total_tokens":15,"output":[{"id":"rs_123","type":"compaction","encrypted_content":"enc-from-sse"}]}', + '', + 'data: [DONE]', + '', + ].join('\n'), { + status: 200, + headers: { 'content-type': 'text/event-stream; charset=utf-8' }, + })); + + const response = await app.inject({ + method: 'POST', + url: '/v1/responses/compact', + payload: { + model: 'gpt-5.2', + input: 'hello', + }, + }); + + expect(response.statusCode).toBe(200); + const body = response.json(); + expect(body).toMatchObject({ + id: 'cmp_sse_123', + object: 'response.compaction', + input_tokens: 12, + output_tokens: 3, + total_tokens: 15, + output: [ + { + id: 'rs_123', + type: 'compaction', + encrypted_content: 'enc-from-sse', + }, + ], + usage: { + input_tokens: 12, + output_tokens: 3, + total_tokens: 15, + }, + }); + expect(body.created_at).toEqual(expect.any(Number)); + }); }); diff --git a/src/server/routes/proxy/responses.compact.test.ts b/src/server/routes/proxy/responses.compact.test.ts index 0927df24..c5a1cfae 100644 --- a/src/server/routes/proxy/responses.compact.test.ts +++ b/src/server/routes/proxy/responses.compact.test.ts @@ -29,4 +29,24 @@ describe('responses proxy compact route', () => { }, }); }); + + it('rejects streaming /v1/responses/compact requests because compact is non-streaming', async () => { + const response = await app.inject({ + method: 'POST', + url: '/v1/responses/compact', + payload: { + model: 'gpt-5.2', + input: 'hello', + stream: true, + }, + }); + + expect(response.statusCode).toBe(400); + expect(response.json()).toEqual({ + error: { + message: 'stream is not supported on /v1/responses/compact', + type: 'invalid_request_error', + }, + }); + }); }); diff --git a/src/server/routes/proxy/responses.ts b/src/server/routes/proxy/responses.ts index 690ad693..a27395bf 100644 --- a/src/server/routes/proxy/responses.ts +++ b/src/server/routes/proxy/responses.ts @@ -1,654 +1,19 @@ -import { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'; -import { fetch } from 'undici'; -import { db, schema } from '../../db/index.js'; -import { tokenRouter } from '../../services/tokenRouter.js'; -import { refreshModelsAndRebuildRoutes } from '../../services/modelService.js'; -import { reportProxyAllFailed, reportTokenExpired } from '../../services/alertService.js'; -import { isTokenExpiredError } from '../../services/alertRules.js'; -import { shouldRetryProxyRequest } from '../../services/proxyRetryPolicy.js'; -import { resolveProxyUsageWithSelfLogFallback } from '../../services/proxyUsageFallbackService.js'; -import { mergeProxyUsage, parseProxyUsage } from '../../services/proxyUsageParser.js'; -import { resolveProxyUrlForSite, withSiteRecordProxyRequestInit } from '../../services/siteProxy.js'; -import { openAiResponsesTransformer } from '../../transformers/openai/responses/index.js'; -import { - buildMinimalJsonHeadersForCompatibility, - buildUpstreamEndpointRequest, - isEndpointDowngradeError, - isUnsupportedMediaTypeError, - resolveUpstreamEndpointCandidates, -} from './upstreamEndpoint.js'; -import { ensureModelAllowedForDownstreamKey, getDownstreamRoutingPolicy, recordDownstreamCostUsage } from './downstreamPolicy.js'; -import { composeProxyLogMessage } from './logPathMeta.js'; -import { executeEndpointFlow, withUpstreamPath } from './endpointFlow.js'; -import { formatUtcSqlDateTime } from '../../services/localTimeService.js'; -import { resolveProxyLogBilling } from './proxyBilling.js'; -import { getProxyResourceOwner } from '../../middleware/auth.js'; -import { - ProxyInputFileResolutionError, - hasNonImageFileInputInOpenAiBody, - resolveResponsesBodyInputFiles, -} from '../../services/proxyInputFileResolver.js'; - -const MAX_RETRIES = 2; - -function isRecord(value: unknown): value is Record { - return !!value && typeof value === 'object'; -} - -function normalizeIncludeList(value: unknown): string[] { - if (typeof value === 'string') { - const trimmed = value.trim(); - return trimmed ? [trimmed] : []; - } - if (!Array.isArray(value)) return []; - return value - .map((item) => (typeof item === 'string' ? item.trim() : '')) - .filter((item) => item.length > 0); -} - -function hasExplicitInclude(body: Record): boolean { - return Object.prototype.hasOwnProperty.call(body, 'include'); -} - -function hasResponsesReasoningRequest(value: unknown): boolean { - if (!isRecord(value)) return false; - const relevantKeys = ['effort', 'budget_tokens', 'budgetTokens', 'max_tokens', 'maxTokens', 'summary']; - return relevantKeys.some((key) => { - const entry = value[key]; - if (typeof entry === 'string') return entry.trim().length > 0; - return entry !== undefined && entry !== null; - }); -} - -function carriesResponsesReasoningContinuity(value: unknown): boolean { - if (Array.isArray(value)) { - return value.some((item) => carriesResponsesReasoningContinuity(item)); - } - if (!isRecord(value)) return false; - - const type = typeof value.type === 'string' ? value.type.trim().toLowerCase() : ''; - if (type === 'reasoning') { - if (typeof value.encrypted_content === 'string' && value.encrypted_content.trim()) { - return true; - } - if (Array.isArray(value.summary) && value.summary.length > 0) { - return true; - } - } - - if (typeof value.reasoning_signature === 'string' && value.reasoning_signature.trim()) { - return true; - } - - return carriesResponsesReasoningContinuity(value.input) - || carriesResponsesReasoningContinuity(value.content); -} - -function isCodexResponsesSurface(headers?: Record): boolean { - if (!headers) return false; - - const normalizeHeaderValue = (value: unknown): string => { - if (typeof value === 'string') return value.trim(); - if (Array.isArray(value)) { - return value - .filter((item): item is string => typeof item === 'string') - .map((item) => item.trim()) - .find((item) => item.length > 0) || ''; - } - return ''; - }; - - let sawOpenAiBeta = false; - let sawStainless = false; - - for (const [rawKey, rawValue] of Object.entries(headers)) { - const key = rawKey.trim().toLowerCase(); - const value = normalizeHeaderValue(rawValue); - if (!key || !value) continue; - - if (key === 'originator' && value.toLowerCase() === 'codex_cli_rs') { - return true; - } - if (key === 'openai-beta') { - sawOpenAiBeta = true; - } - if (key.startsWith('x-stainless-')) { - sawStainless = true; - } - } - - return sawOpenAiBeta || sawStainless; -} - -function wantsNativeResponsesReasoning(body: unknown): boolean { - if (!isRecord(body)) return false; - const include = normalizeIncludeList(body.include); - if (include.some((item) => item.toLowerCase() === 'reasoning.encrypted_content')) { - return true; - } - if (carriesResponsesReasoningContinuity(body.input)) { - return true; - } - if (hasExplicitInclude(body)) { - return false; - } - return hasResponsesReasoningRequest(body.reasoning); -} - -type UsageSummary = ReturnType; +import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'; +import { handleOpenAiResponsesSurfaceRequest } from '../../proxy-core/surfaces/openAiResponsesSurface.js'; +import { ensureResponsesWebsocketTransport } from './responsesWebsocket.js'; export async function responsesProxyRoute(app: FastifyInstance) { - const handleResponsesRequest = async ( - request: FastifyRequest, - reply: FastifyReply, - downstreamPath: string, - ) => { - const body = request.body as any; - const requestedModel = typeof body?.model === 'string' ? body.model.trim() : ''; - if (!requestedModel) { - return reply.code(400).send({ error: { message: 'model is required', type: 'invalid_request_error' } }); - } - if (!await ensureModelAllowedForDownstreamKey(request, reply, requestedModel)) return; - const downstreamPolicy = getDownstreamRoutingPolicy(request); - const isCompactRequest = downstreamPath === '/v1/responses/compact'; - - const isStream = body.stream === true; - const excludeChannelIds: number[] = []; - let retryCount = 0; - - while (retryCount <= MAX_RETRIES) { - let selected = retryCount === 0 - ? await tokenRouter.selectChannel(requestedModel, downstreamPolicy) - : await tokenRouter.selectNextChannel(requestedModel, excludeChannelIds, downstreamPolicy); - - if (!selected && retryCount === 0) { - await refreshModelsAndRebuildRoutes(); - selected = await tokenRouter.selectChannel(requestedModel, downstreamPolicy); - } - - if (!selected) { - await reportProxyAllFailed({ - model: requestedModel, - reason: 'No available channels after retries', - }); - return reply.code(503).send({ - error: { message: 'No available channels for this model', type: 'server_error' }, - }); - } - - excludeChannelIds.push(selected.channel.id); - - const modelName = selected.actualModel || requestedModel; - const owner = getProxyResourceOwner(request); - const defaultEncryptedReasoningInclude = isCodexResponsesSurface( - request.headers as Record, - ); - let normalizedResponsesBody = openAiResponsesTransformer.inbound.sanitizeProxyBody( - body, - modelName, - isStream, - { defaultEncryptedReasoningInclude }, - ); - if (owner) { - try { - normalizedResponsesBody = await resolveResponsesBodyInputFiles(normalizedResponsesBody, owner); - } catch (error) { - if (error instanceof ProxyInputFileResolutionError) { - return reply.code(error.statusCode).send(error.payload); - } - throw error; - } - } - const openAiBody = openAiResponsesTransformer.inbound.toOpenAiBody( - normalizedResponsesBody, - modelName, - isStream, - { defaultEncryptedReasoningInclude }, - ); - const hasNonImageFileInput = hasNonImageFileInputInOpenAiBody(openAiBody); - const prefersNativeResponsesReasoning = wantsNativeResponsesReasoning(normalizedResponsesBody); - const endpointCandidates = await resolveUpstreamEndpointCandidates( - { - site: selected.site, - account: selected.account, - }, - modelName, - 'responses', - requestedModel, - { - hasNonImageFileInput, - wantsNativeResponsesReasoning: prefersNativeResponsesReasoning, - }, - ); - if (endpointCandidates.length === 0) { - endpointCandidates.push('responses', 'chat', 'messages'); - } - - const startTime = Date.now(); - - try { - const endpointResult = await executeEndpointFlow({ - siteUrl: selected.site.url, - proxyUrl: resolveProxyUrlForSite(selected.site), - endpointCandidates, - buildRequest: (endpoint) => { - const endpointRequest = buildUpstreamEndpointRequest({ - endpoint, - modelName, - stream: isStream, - tokenValue: selected.tokenValue, - sitePlatform: selected.site.platform, - siteUrl: selected.site.url, - openaiBody: openAiBody, - downstreamFormat: 'responses', - responsesOriginalBody: normalizedResponsesBody, - downstreamHeaders: request.headers as Record, - }); - const upstreamPath = ( - isCompactRequest && endpoint === 'responses' - ? `${endpointRequest.path}/compact` - : endpointRequest.path - ); - return { - endpoint, - path: upstreamPath, - headers: endpointRequest.headers, - body: endpointRequest.body as Record, - }; - }, - tryRecover: async (ctx) => { - if (openAiResponsesTransformer.compatibility.shouldRetry({ - endpoint: ctx.request.endpoint, - status: ctx.response.status, - rawErrText: ctx.rawErrText, - })) { - const compatibilityBodies = openAiResponsesTransformer.compatibility.buildRetryBodies(ctx.request.body); - const compatibilityHeaders = openAiResponsesTransformer.compatibility.buildRetryHeaders( - ctx.request.headers, - isStream, - ); - - for (const compatibilityHeadersCandidate of compatibilityHeaders) { - for (const compatibilityBody of compatibilityBodies) { - const compatibilityResponse = await fetch( - ctx.targetUrl, - withSiteRecordProxyRequestInit(selected.site, { - method: 'POST', - headers: compatibilityHeadersCandidate, - body: JSON.stringify(compatibilityBody), - }), - ); - if (compatibilityResponse.ok) { - return { - upstream: compatibilityResponse, - upstreamPath: ctx.request.path, - }; - } - - ctx.request = { - ...ctx.request, - headers: compatibilityHeadersCandidate, - body: compatibilityBody, - }; - ctx.response = compatibilityResponse; - ctx.rawErrText = await compatibilityResponse.text().catch(() => 'unknown error'); - } - } - } - - if (!isUnsupportedMediaTypeError(ctx.response.status, ctx.rawErrText)) { - return null; - } - - const minimalHeaders = buildMinimalJsonHeadersForCompatibility({ - headers: ctx.request.headers, - endpoint: ctx.request.endpoint, - stream: isStream, - }); - const minimalResponse = await fetch( - ctx.targetUrl, - withSiteRecordProxyRequestInit(selected.site, { - method: 'POST', - headers: minimalHeaders, - body: JSON.stringify(ctx.request.body), - }), - ); - if (minimalResponse.ok) { - return { - upstream: minimalResponse, - upstreamPath: ctx.request.path, - }; - } - - ctx.request = { - ...ctx.request, - headers: minimalHeaders, - }; - ctx.response = minimalResponse; - ctx.rawErrText = await minimalResponse.text().catch(() => 'unknown error'); - return null; - }, - shouldDowngrade: (ctx) => ( - ctx.response.status >= 500 - || isEndpointDowngradeError(ctx.response.status, ctx.rawErrText) - || openAiResponsesTransformer.compatibility.shouldDowngradeChatToMessages( - ctx.request.path, - ctx.response.status, - ctx.rawErrText, - ) - ), - onDowngrade: (ctx) => { - logProxy( - selected, - requestedModel, - 'failed', - ctx.response.status, - Date.now() - startTime, - ctx.errText, - retryCount, - downstreamPath, - ); - }, - }); - - if (!endpointResult.ok) { - const status = endpointResult.status || 502; - const errText = endpointResult.errText || 'unknown error'; - tokenRouter.recordFailure(selected.channel.id); - logProxy(selected, requestedModel, 'failed', status, Date.now() - startTime, errText, retryCount, downstreamPath); - - if (isTokenExpiredError({ status, message: errText })) { - await reportTokenExpired({ - accountId: selected.account.id, - username: selected.account.username, - siteName: selected.site.name, - detail: `HTTP ${status}`, - }); - } - - if (shouldRetryProxyRequest(status, errText) && retryCount < MAX_RETRIES) { - retryCount += 1; - continue; - } - - await reportProxyAllFailed({ - model: requestedModel, - reason: `upstream returned HTTP ${status}`, - }); - return reply.code(status).send({ error: { message: errText, type: 'upstream_error' } }); - } - - const upstream = endpointResult.upstream; - const successfulUpstreamPath = endpointResult.upstreamPath; - - if (isStream) { - reply.raw.statusCode = 200; - reply.raw.setHeader('Content-Type', 'text/event-stream; charset=utf-8'); - reply.raw.setHeader('Cache-Control', 'no-cache, no-transform'); - reply.raw.setHeader('Connection', 'keep-alive'); - reply.raw.setHeader('X-Accel-Buffering', 'no'); - - const reader = upstream.body?.getReader(); - if (!reader) { - reply.raw.end(); - return; - } - - const decoder = new TextDecoder(); - let parsedUsage: UsageSummary = { - promptTokens: 0, - completionTokens: 0, - totalTokens: 0, - cacheReadTokens: 0, - cacheCreationTokens: 0, - promptTokensIncludeCache: null, - }; - let sseBuffer = ''; - - const passthroughResponsesStream = successfulUpstreamPath === '/v1/responses'; - const streamContext = openAiResponsesTransformer.createStreamContext(modelName); - const responsesState = openAiResponsesTransformer.aggregator.createState(modelName); - - const writeLines = (lines: string[]) => { - for (const line of lines) reply.raw.write(line); - }; - - const consumeSseBuffer = (incoming: string): string => { - const pulled = openAiResponsesTransformer.pullSseEvents(incoming); - for (const eventBlock of pulled.events) { - if (eventBlock.data === '[DONE]') { - if (passthroughResponsesStream) { - reply.raw.write('data: [DONE]\n\n'); - } else if (!responsesState.completed) { - writeLines(openAiResponsesTransformer.aggregator.complete(responsesState, streamContext, parsedUsage)); - } - continue; - } - - let parsedPayload: unknown = null; - try { - parsedPayload = JSON.parse(eventBlock.data); - } catch { - parsedPayload = null; - } - - if (parsedPayload && typeof parsedPayload === 'object') { - parsedUsage = mergeProxyUsage(parsedUsage, parseProxyUsage(parsedPayload)); - } - - if (passthroughResponsesStream) { - const eventName = eventBlock.event ? `event: ${eventBlock.event}\n` : ''; - reply.raw.write(`${eventName}data: ${eventBlock.data}\n\n`); - continue; - } - - const payloadType = (isRecord(parsedPayload) && typeof parsedPayload.type === 'string') - ? parsedPayload.type - : ''; - const isFailureEvent = ( - eventBlock.event === 'error' - || eventBlock.event === 'response.failed' - || payloadType === 'error' - || payloadType === 'response.failed' - ); - if (isFailureEvent) { - writeLines(openAiResponsesTransformer.aggregator.fail(responsesState, streamContext, parsedUsage, parsedPayload)); - continue; - } - - if (parsedPayload && typeof parsedPayload === 'object') { - const normalizedEvent = openAiResponsesTransformer.transformStreamEvent(parsedPayload, streamContext, modelName); - writeLines(openAiResponsesTransformer.aggregator.serialize({ - state: responsesState, - streamContext, - event: normalizedEvent, - usage: parsedUsage, - })); - continue; - } - - writeLines(openAiResponsesTransformer.aggregator.serialize({ - state: responsesState, - streamContext, - event: { contentDelta: eventBlock.data }, - usage: parsedUsage, - })); - } - - return pulled.rest; - }; - - try { - while (true) { - const { done, value } = await reader.read(); - if (done) break; - if (!value) continue; - - sseBuffer += decoder.decode(value, { stream: true }); - sseBuffer = consumeSseBuffer(sseBuffer); - } - - sseBuffer += decoder.decode(); - if (sseBuffer.trim().length > 0) { - sseBuffer = consumeSseBuffer(`${sseBuffer}\n\n`); - } - } finally { - reader.releaseLock(); - if (!passthroughResponsesStream && !responsesState.completed) { - writeLines(openAiResponsesTransformer.aggregator.complete(responsesState, streamContext, parsedUsage)); - } - reply.raw.end(); - } - - const latency = Date.now() - startTime; - const resolvedUsage = await resolveProxyUsageWithSelfLogFallback({ - site: selected.site, - account: selected.account, - tokenValue: selected.tokenValue, - tokenName: selected.tokenName, - modelName: selected.actualModel || requestedModel, - requestStartedAtMs: startTime, - requestEndedAtMs: startTime + latency, - localLatencyMs: latency, - usage: { - promptTokens: parsedUsage.promptTokens, - completionTokens: parsedUsage.completionTokens, - totalTokens: parsedUsage.totalTokens, - }, - }); - const { estimatedCost, billingDetails } = await resolveProxyLogBilling({ - site: selected.site, - account: selected.account, - modelName: selected.actualModel || requestedModel, - parsedUsage, - resolvedUsage, - }); - tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost); - recordDownstreamCostUsage(request, estimatedCost); - logProxy( - selected, requestedModel, 'success', 200, latency, null, retryCount, downstreamPath, - resolvedUsage.promptTokens, resolvedUsage.completionTokens, resolvedUsage.totalTokens, estimatedCost, billingDetails, - successfulUpstreamPath, - ); - return; - } - - const rawText = await upstream.text(); - let upstreamData: unknown = rawText; - try { - upstreamData = JSON.parse(rawText); - } catch { - upstreamData = rawText; - } - const latency = Date.now() - startTime; - const parsedUsage = parseProxyUsage(upstreamData); - const normalized = openAiResponsesTransformer.transformFinalResponse( - upstreamData, - modelName, - rawText, - ); - const downstreamData = openAiResponsesTransformer.outbound.serializeFinal({ - upstreamPayload: upstreamData, - normalized, - usage: parsedUsage, - }); - const resolvedUsage = await resolveProxyUsageWithSelfLogFallback({ - site: selected.site, - account: selected.account, - tokenValue: selected.tokenValue, - tokenName: selected.tokenName, - modelName: selected.actualModel || requestedModel, - requestStartedAtMs: startTime, - requestEndedAtMs: startTime + latency, - localLatencyMs: latency, - usage: { - promptTokens: parsedUsage.promptTokens, - completionTokens: parsedUsage.completionTokens, - totalTokens: parsedUsage.totalTokens, - }, - }); - const { estimatedCost, billingDetails } = await resolveProxyLogBilling({ - site: selected.site, - account: selected.account, - modelName: selected.actualModel || requestedModel, - parsedUsage, - resolvedUsage, - }); - - tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost); - recordDownstreamCostUsage(request, estimatedCost); - logProxy( - selected, requestedModel, 'success', 200, latency, null, retryCount, downstreamPath, - resolvedUsage.promptTokens, resolvedUsage.completionTokens, resolvedUsage.totalTokens, estimatedCost, billingDetails, - successfulUpstreamPath, - ); - return reply.send(downstreamData); - } catch (err: any) { - tokenRouter.recordFailure(selected.channel.id); - logProxy(selected, requestedModel, 'failed', 0, Date.now() - startTime, err.message, retryCount, downstreamPath); - if (retryCount < MAX_RETRIES) { - retryCount += 1; - continue; - } - await reportProxyAllFailed({ - model: requestedModel, - reason: err.message || 'network failure', - }); - return reply.code(502).send({ - error: { message: `Upstream error: ${err.message}`, type: 'upstream_error' }, - }); - } - } - }; + ensureResponsesWebsocketTransport(app); app.post('/v1/responses', async (request: FastifyRequest, reply: FastifyReply) => - handleResponsesRequest(request, reply, '/v1/responses')); + handleOpenAiResponsesSurfaceRequest(request, reply, '/v1/responses')); + app.get('/v1/responses', async (_request: FastifyRequest, reply: FastifyReply) => + reply.code(426).send({ + error: { + message: 'WebSocket upgrade required for GET /v1/responses', + type: 'invalid_request_error', + }, + })); app.post('/v1/responses/compact', async (request: FastifyRequest, reply: FastifyReply) => - handleResponsesRequest(request, reply, '/v1/responses/compact')); -} - -async function logProxy( - selected: any, - modelRequested: string, - status: string, - httpStatus: number, - latencyMs: number, - errorMessage: string | null, - retryCount: number, - downstreamPath: string, - promptTokens = 0, - completionTokens = 0, - totalTokens = 0, - estimatedCost = 0, - billingDetails: unknown = null, - upstreamPath: string | null = null, -) { - try { - const createdAt = formatUtcSqlDateTime(new Date()); - const normalizedErrorMessage = composeProxyLogMessage({ - downstreamPath, - upstreamPath, - errorMessage, - }); - await db.insert(schema.proxyLogs).values({ - routeId: selected.channel.routeId, - channelId: selected.channel.id, - accountId: selected.account.id, - modelRequested, - modelActual: selected.actualModel, - status, - httpStatus, - latencyMs, - promptTokens, - completionTokens, - totalTokens, - estimatedCost, - billingDetails: billingDetails ? JSON.stringify(billingDetails) : null, - errorMessage: normalizedErrorMessage, - retryCount, - createdAt, - }).run(); - } catch (error) { - console.warn('[proxy/responses] failed to write proxy log', error); - } + handleOpenAiResponsesSurfaceRequest(request, reply, '/v1/responses/compact')); } diff --git a/src/server/routes/proxy/responses.websocket.test.ts b/src/server/routes/proxy/responses.websocket.test.ts new file mode 100644 index 00000000..eae898d9 --- /dev/null +++ b/src/server/routes/proxy/responses.websocket.test.ts @@ -0,0 +1,1989 @@ +import Fastify, { type FastifyInstance } from 'fastify'; +import { createServer, type Server } from 'node:http'; +import { AddressInfo } from 'node:net'; +import WebSocket, { WebSocketServer } from 'ws'; +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { config } from '../../config.js'; + +const fetchMock = vi.fn(); +const selectChannelMock = vi.fn(); +const selectNextChannelMock = vi.fn(); +const previewSelectedChannelMock = vi.fn(); +const recordSuccessMock = vi.fn(); +const recordFailureMock = vi.fn(); +const authorizeDownstreamTokenMock = vi.fn(); +const consumeManagedKeyRequestMock = vi.fn(); +const refreshModelsAndRebuildRoutesMock = vi.fn(); +const reportProxyAllFailedMock = vi.fn(); +const reportTokenExpiredMock = vi.fn(); +const resolveProxyUsageWithSelfLogFallbackMock = vi.fn(async ({ usage }: any) => ({ + ...usage, + estimatedCostFromQuota: 0, + recoveredFromSelfLog: false, +})); +const dbInsertMock = vi.fn((_arg?: any) => ({ + values: () => ({ + run: () => undefined, + }), +})); + +vi.mock('undici', () => ({ + fetch: (...args: unknown[]) => fetchMock(...args), +})); + +vi.mock('../../services/tokenRouter.js', () => ({ + tokenRouter: { + selectChannel: (...args: unknown[]) => selectChannelMock(...args), + selectNextChannel: (...args: unknown[]) => selectNextChannelMock(...args), + previewSelectedChannel: (...args: unknown[]) => previewSelectedChannelMock(...args), + recordSuccess: (...args: unknown[]) => recordSuccessMock(...args), + recordFailure: (...args: unknown[]) => recordFailureMock(...args), + }, +})); + +vi.mock('../../services/modelService.js', () => ({ + refreshModelsAndRebuildRoutes: (...args: unknown[]) => refreshModelsAndRebuildRoutesMock(...args), +})); + +vi.mock('../../services/alertService.js', () => ({ + reportProxyAllFailed: (...args: unknown[]) => reportProxyAllFailedMock(...args), + reportTokenExpired: (...args: unknown[]) => reportTokenExpiredMock(...args), +})); + +vi.mock('../../services/downstreamApiKeyService.js', () => ({ + authorizeDownstreamToken: (...args: unknown[]) => authorizeDownstreamTokenMock(...args), + consumeManagedKeyRequest: (...args: unknown[]) => consumeManagedKeyRequestMock(...args), + isModelAllowedByPolicyOrAllowedRoutes: async ( + model: string, + policy: { supportedModels?: string[]; allowedRouteIds?: number[]; denyAllWhenEmpty?: boolean }, + ) => { + const supportedModels = Array.isArray(policy?.supportedModels) ? policy.supportedModels : []; + const allowedRouteIds = Array.isArray(policy?.allowedRouteIds) ? policy.allowedRouteIds : []; + if (supportedModels.length === 0 && allowedRouteIds.length === 0) { + return policy?.denyAllWhenEmpty === true ? false : true; + } + return supportedModels.includes(model); + }, +})); + +vi.mock('../../services/alertRules.js', () => ({ + isTokenExpiredError: () => false, +})); + +vi.mock('../../services/modelPricingService.js', () => ({ + estimateProxyCost: async () => 0, + buildProxyBillingDetails: async () => null, + fetchModelPricingCatalog: async () => null, +})); + +vi.mock('../../services/proxyRetryPolicy.js', () => ({ + shouldRetryProxyRequest: () => false, +})); + +vi.mock('../../services/proxyUsageFallbackService.js', () => ({ + resolveProxyUsageWithSelfLogFallback: (arg: any) => resolveProxyUsageWithSelfLogFallbackMock(arg), +})); + +vi.mock('../../services/oauth/quota.js', () => ({ + recordOauthQuotaResetHint: async () => undefined, +})); + +vi.mock('../../db/index.js', () => ({ + db: { + insert: (arg: any) => dbInsertMock(arg), + }, + hasProxyLogBillingDetailsColumn: async () => false, + hasProxyLogClientColumns: async () => false, + hasProxyLogDownstreamApiKeyIdColumn: async () => false, + schema: { + proxyLogs: {}, + }, +})); + +function createSseResponse(chunks: string[], status = 200) { + const encoder = new TextEncoder(); + return new Response(new ReadableStream({ + start(controller) { + for (const chunk of chunks) { + controller.enqueue(encoder.encode(chunk)); + } + controller.close(); + }, + }), { + status, + headers: { 'content-type': 'text/event-stream; charset=utf-8' }, + }); +} + +function createSelectedChannel(options?: { + siteName?: string; + siteUrl?: string; + sitePlatform?: string; + username?: string; + extraConfig?: unknown; + tokenValue?: string; + actualModel?: string; +}) { + const sitePlatform = options?.sitePlatform ?? 'codex'; + const isCodex = sitePlatform === 'codex'; + return { + channel: { id: 11, routeId: 22 }, + site: { + name: options?.siteName ?? (isCodex ? 'codex-site' : 'openai-site'), + url: options?.siteUrl ?? (isCodex ? 'https://chatgpt.com/backend-api/codex' : 'https://api.openai.com'), + platform: sitePlatform, + }, + account: { + id: 33, + username: options?.username ?? (isCodex ? 'codex-user@example.com' : 'openai-user@example.com'), + extraConfig: options?.extraConfig ?? (isCodex + ? JSON.stringify({ + credentialMode: 'session', + oauth: { + provider: 'codex', + accountId: 'chatgpt-account-123', + email: 'codex-user@example.com', + }, + }) + : '{}'), + }, + tokenName: 'default', + tokenValue: options?.tokenValue ?? (isCodex ? 'oauth-access-token' : 'sk-openai-token'), + actualModel: options?.actualModel ?? (isCodex ? 'gpt-5.4' : 'gpt-4.1'), + }; +} + +function waitForSocketOpen(socket: WebSocket) { + return new Promise((resolve, reject) => { + socket.once('open', () => resolve()); + socket.once('error', reject); + }); +} + +function waitForSocketUpgrade(socket: WebSocket) { + return new Promise<{ headers: Record }>((resolve, reject) => { + socket.once('upgrade', (response) => resolve({ headers: response.headers as Record })); + socket.once('error', reject); + }); +} + +function waitForSocketMessages(socket: WebSocket, count: number, timeoutMs = 1000) { + return new Promise((resolve, reject) => { + const messages: any[] = []; + const timeout = setTimeout(() => { + socket.off('message', onMessage); + socket.off('error', onError); + reject(new Error(`Timed out waiting for ${count} websocket messages`)); + }, timeoutMs); + const onMessage = (payload: WebSocket.RawData) => { + messages.push(JSON.parse(String(payload))); + if (messages.length >= count) { + clearTimeout(timeout); + socket.off('message', onMessage); + socket.off('error', onError); + resolve(messages); + } + }; + const onError = (error: Error) => { + clearTimeout(timeout); + socket.off('message', onMessage); + reject(error); + }; + socket.on('message', onMessage); + socket.once('error', onError); + }); +} + +function waitForSocketMessageMatching( + socket: WebSocket, + predicate: (message: any) => boolean, + timeoutMs = 1000, +) { + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + socket.off('message', onMessage); + socket.off('error', onError); + reject(new Error('Timed out waiting for matching websocket message')); + }, timeoutMs); + const onMessage = (payload: WebSocket.RawData) => { + const message = JSON.parse(String(payload)); + if (!predicate(message)) return; + clearTimeout(timeout); + socket.off('message', onMessage); + socket.off('error', onError); + resolve(message); + }; + const onError = (error: Error) => { + clearTimeout(timeout); + socket.off('message', onMessage); + reject(error); + }; + socket.on('message', onMessage); + socket.once('error', onError); + }); +} + +function createDeferred() { + let resolve!: (value: T | PromiseLike) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +function createClientSocket(baseUrl: string, headers: Record = {}) { + return new WebSocket(`${baseUrl}/v1/responses`, { + headers: { + Authorization: 'Bearer sk-global-proxy-token', + ...headers, + }, + }); +} + +function createClientSocketForPath(path: string, headers: Record = {}) { + return new WebSocket(path, { headers }); +} + +describe('responses websocket transport', () => { + const originalCodexResponsesWebsocketBeta = config.codexResponsesWebsocketBeta; + const originalCodexUpstreamWebsocketEnabled = config.codexUpstreamWebsocketEnabled; + let app: FastifyInstance; + let baseUrl: string; + let upstreamServer: WebSocketServer; + let upstreamSiteUrl: string; + let upstreamConnectionCount: number; + let upstreamUpgradeHeaders: Record; + let upstreamRequests: Record[]; + let upstreamMessageHandler: (socket: WebSocket, parsed: Record, requestIndex: number) => void; + let rejectedUpgradeServer: Server; + let rejectedUpgradeSiteUrl: string; + let rejectedUpgradeStatus: number; + let rejectedUpgradeStatusText: string; + let rejectedUpgradeBody: string; + + beforeAll(async () => { + const { responsesProxyRoute } = await import('./responses.js'); + app = Fastify(); + await app.register(responsesProxyRoute); + await app.listen({ port: 0, host: '127.0.0.1' }); + const address = app.server.address() as AddressInfo; + baseUrl = `ws://127.0.0.1:${address.port}`; + + upstreamServer = new WebSocketServer({ port: 0 }); + upstreamServer.on('connection', (socket, request) => { + upstreamConnectionCount += 1; + upstreamUpgradeHeaders = Object.fromEntries( + Object.entries(request.headers) + .map(([key, value]) => [key, Array.isArray(value) ? value[0] || '' : value || '']), + ); + socket.on('message', (payload) => { + const parsed = JSON.parse(String(payload)) as Record; + upstreamRequests.push(parsed); + upstreamMessageHandler(socket, parsed, upstreamRequests.length); + }); + }); + await new Promise((resolve) => upstreamServer.once('listening', () => resolve())); + const upstreamAddress = upstreamServer.address() as AddressInfo; + upstreamSiteUrl = `http://127.0.0.1:${upstreamAddress.port}/backend-api/codex`; + + rejectedUpgradeServer = createServer(); + rejectedUpgradeServer.on('upgrade', (_request, socket) => { + const body = rejectedUpgradeBody; + socket.write( + `HTTP/1.1 ${rejectedUpgradeStatus} ${rejectedUpgradeStatusText}\r\n` + + 'Content-Type: text/plain\r\n' + + `Content-Length: ${Buffer.byteLength(body)}\r\n` + + 'Connection: close\r\n' + + '\r\n' + + body, + ); + socket.destroy(); + }); + await new Promise((resolve) => rejectedUpgradeServer.listen(0, '127.0.0.1', () => resolve())); + const rejectedAddress = rejectedUpgradeServer.address() as AddressInfo; + rejectedUpgradeSiteUrl = `http://127.0.0.1:${rejectedAddress.port}/backend-api/codex`; + }); + + beforeEach(() => { + fetchMock.mockReset(); + selectChannelMock.mockReset(); + selectNextChannelMock.mockReset(); + previewSelectedChannelMock.mockReset(); + recordSuccessMock.mockReset(); + recordFailureMock.mockReset(); + authorizeDownstreamTokenMock.mockReset(); + consumeManagedKeyRequestMock.mockReset(); + refreshModelsAndRebuildRoutesMock.mockReset(); + reportProxyAllFailedMock.mockReset(); + reportTokenExpiredMock.mockReset(); + resolveProxyUsageWithSelfLogFallbackMock.mockClear(); + dbInsertMock.mockClear(); + + const selectedChannel = createSelectedChannel(); + selectChannelMock.mockReturnValue(selectedChannel); + selectNextChannelMock.mockReturnValue(null); + previewSelectedChannelMock.mockResolvedValue(selectedChannel); + upstreamConnectionCount = 0; + upstreamUpgradeHeaders = {}; + upstreamRequests = []; + (config as any).codexResponsesWebsocketBeta = originalCodexResponsesWebsocketBeta; + (config as any).codexUpstreamWebsocketEnabled = true; + rejectedUpgradeStatus = 426; + rejectedUpgradeStatusText = 'Upgrade Required'; + rejectedUpgradeBody = 'Upgrade Required'; + authorizeDownstreamTokenMock.mockResolvedValue({ + ok: true, + source: 'global', + token: 'sk-global-proxy-token', + key: null, + policy: { + supportedModels: [], + allowedRouteIds: [], + siteWeightMultipliers: {}, + }, + }); + upstreamMessageHandler = (socket, parsed, requestIndex) => { + const responseId = `resp_upstream_${requestIndex}`; + socket.send(JSON.stringify({ + type: 'response.completed', + response: { + id: responseId, + object: 'response', + model: parsed.model || 'gpt-5.4', + status: 'completed', + output: [], + usage: { + input_tokens: 1, + output_tokens: 1, + total_tokens: 2, + }, + }, + })); + }; + }); + + afterAll(async () => { + (config as any).codexUpstreamWebsocketEnabled = originalCodexUpstreamWebsocketEnabled; + await new Promise((resolve) => rejectedUpgradeServer.close(() => resolve())); + await new Promise((resolve) => upstreamServer.close(() => resolve())); + await app.close(); + }); + + it('accepts response.create over GET /v1/responses websocket and forwards streamed responses events', async () => { + const selectedChannel = createSelectedChannel({ + siteUrl: upstreamSiteUrl, + }); + selectChannelMock.mockReturnValue(selectedChannel); + previewSelectedChannelMock.mockResolvedValue(selectedChannel); + upstreamMessageHandler = (socket) => { + socket.send(JSON.stringify({ + type: 'response.created', + response: { + id: 'resp_ws', + model: 'gpt-5.4', + created_at: 1706000000, + status: 'in_progress', + output: [], + }, + })); + socket.send(JSON.stringify({ + type: 'response.output_item.added', + output_index: 0, + item: { + id: 'msg_ws', + type: 'message', + role: 'assistant', + status: 'in_progress', + content: [], + }, + })); + socket.send(JSON.stringify({ + type: 'response.output_text.delta', + output_index: 0, + item_id: 'msg_ws', + delta: 'pong', + })); + socket.send(JSON.stringify({ + type: 'response.completed', + response: { + id: 'resp_ws', + model: 'gpt-5.4', + status: 'completed', + output: [{ + id: 'msg_ws', + type: 'message', + role: 'assistant', + status: 'completed', + content: [{ type: 'output_text', text: 'pong' }], + }], + usage: { + input_tokens: 3, + output_tokens: 1, + total_tokens: 4, + }, + }, + })); + }; + + const socket = createClientSocket(baseUrl); + await waitForSocketOpen(socket); + const messagesPromise = waitForSocketMessages(socket, 4); + + socket.send(JSON.stringify({ + type: 'response.create', + model: 'gpt-5.4', + input: [ + { + type: 'message', + role: 'user', + content: [{ type: 'input_text', text: 'hello websocket' }], + }, + ], + })); + + const messages = await messagesPromise; + socket.close(); + + expect(messages.map((message) => message.type)).toEqual([ + 'response.created', + 'response.output_item.added', + 'response.output_text.delta', + 'response.completed', + ]); + expect(messages[3]?.response?.output?.[0]?.content?.[0]?.text).toBe('pong'); + expect(fetchMock).toHaveBeenCalledTimes(0); + expect(upstreamConnectionCount).toBe(1); + }); + + it('echoes x-codex-turn-state on websocket upgrade responses', async () => { + const socket = createClientSocket(baseUrl, { + 'x-codex-turn-state': 'turn-state-123', + }); + + const [upgrade] = await Promise.all([ + waitForSocketUpgrade(socket), + waitForSocketOpen(socket), + ]); + socket.close(); + + expect(upgrade.headers['x-codex-turn-state']).toBe('turn-state-123'); + }); + + it('reuses one upstream codex websocket session across sequential websocket turns', async () => { + (config as any).codexResponsesWebsocketBeta = 'responses_websockets=2099-01-01'; + const selectedChannel = createSelectedChannel({ + siteUrl: upstreamSiteUrl, + }); + selectChannelMock.mockReturnValue(selectedChannel); + previewSelectedChannelMock.mockResolvedValue(selectedChannel); + + const socket = createClientSocket(baseUrl, { + 'x-codex-turn-state': 'turn-state-123', + 'x-codex-beta-features': 'feature-a,feature-b', + }); + await waitForSocketOpen(socket); + const firstMessagesPromise = waitForSocketMessages(socket, 1); + + socket.send(JSON.stringify({ + type: 'response.create', + model: 'gpt-5.4', + input: [], + })); + + const firstMessages = await firstMessagesPromise; + + const secondMessagesPromise = waitForSocketMessages(socket, 1); + socket.send(JSON.stringify({ + type: 'response.create', + model: 'gpt-5.4', + previous_response_id: firstMessages[0]?.response?.id, + input: [], + })); + + const secondMessages = await secondMessagesPromise; + socket.close(); + + expect(fetchMock).toHaveBeenCalledTimes(0); + expect(firstMessages[0]?.type).toBe('response.completed'); + expect(secondMessages[0]?.type).toBe('response.completed'); + expect(upstreamConnectionCount).toBe(1); + expect(upstreamRequests).toHaveLength(2); + expect(upstreamRequests[0]).toMatchObject({ + type: 'response.create', + model: 'gpt-5.4', + }); + expect(upstreamRequests[1]).toMatchObject({ + type: 'response.create', + previous_response_id: firstMessages[0]?.response?.id, + }); + expect(upstreamUpgradeHeaders['x-codex-turn-state']).toBe('turn-state-123'); + expect(upstreamUpgradeHeaders['x-codex-beta-features']).toBe('feature-a,feature-b'); + expect(upstreamUpgradeHeaders['openai-beta']).toContain('responses_websockets=2099-01-01'); + }); + + it('falls back to the HTTP responses executor when the upstream codex websocket upgrade returns 426', async () => { + const selectedChannel = createSelectedChannel({ + siteUrl: rejectedUpgradeSiteUrl, + }); + selectChannelMock.mockReturnValue(selectedChannel); + previewSelectedChannelMock.mockResolvedValue(selectedChannel); + fetchMock.mockResolvedValueOnce(createSseResponse([ + 'event: response.completed\n', + 'data: {"type":"response.completed","response":{"id":"resp_http_fallback","model":"gpt-5.4","status":"completed","output":[],"usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2}}}\n\n', + 'data: [DONE]\n\n', + ])); + + const socket = createClientSocket(baseUrl); + await waitForSocketOpen(socket); + const messagesPromise = waitForSocketMessageMatching( + socket, + (message) => message?.type === 'response.completed', + ); + + socket.send(JSON.stringify({ + type: 'response.create', + model: 'gpt-5.4', + input: [], + })); + + const message = await messagesPromise; + socket.close(); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(message?.type).toBe('response.completed'); + expect(message?.response?.id).toBe('resp_http_fallback'); + }); + + it('treats response.incomplete as a terminal HTTP fallback payload without appending websocket error', async () => { + const selectedChannel = createSelectedChannel({ + siteUrl: rejectedUpgradeSiteUrl, + }); + selectChannelMock.mockReturnValue(selectedChannel); + previewSelectedChannelMock.mockResolvedValue(selectedChannel); + fetchMock.mockResolvedValueOnce(createSseResponse([ + 'event: response.incomplete\n', + 'data: {"type":"response.incomplete","response":{"id":"resp_http_incomplete","model":"gpt-5.4","status":"incomplete","output":[{"id":"msg_http_incomplete","type":"message","role":"assistant","status":"incomplete","content":[{"type":"output_text","text":"partial"}]}],"output_text":"partial","incomplete_details":{"reason":"max_output_tokens"},"usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2}}}\n\n', + 'data: [DONE]\n\n', + ])); + + const socket = createClientSocket(baseUrl); + await waitForSocketOpen(socket); + const messagesPromise = waitForSocketMessages(socket, 2); + + socket.send(JSON.stringify({ + type: 'response.create', + model: 'gpt-5.4', + input: [], + })); + + const messages = await messagesPromise; + socket.close(); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(messages.map((message) => message?.type)).toEqual([ + 'response.created', + 'response.incomplete', + ]); + expect(messages.some((message) => message?.type === 'error')).toBe(false); + const terminalMessage = messages[1]; + expect(terminalMessage?.response?.incomplete_details?.reason).toBe('max_output_tokens'); + }); + + it('falls back to the HTTP responses executor when the upstream codex websocket upgrade returns 401', async () => { + rejectedUpgradeStatus = 401; + rejectedUpgradeStatusText = 'Unauthorized'; + rejectedUpgradeBody = JSON.stringify({ + error: { + message: 'expired token', + type: 'invalid_request_error', + }, + }); + const selectedChannel = createSelectedChannel({ + siteUrl: rejectedUpgradeSiteUrl, + }); + selectChannelMock.mockReturnValue(selectedChannel); + previewSelectedChannelMock.mockResolvedValue(selectedChannel); + fetchMock.mockResolvedValueOnce(createSseResponse([ + 'event: response.completed\n', + 'data: {"type":"response.completed","response":{"id":"resp_http_fallback_401","model":"gpt-5.4","status":"completed","output":[],"usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2}}}\n\n', + 'data: [DONE]\n\n', + ])); + + const socket = createClientSocket(baseUrl); + await waitForSocketOpen(socket); + const messagesPromise = waitForSocketMessageMatching( + socket, + (message) => message?.type === 'response.completed', + ); + + socket.send(JSON.stringify({ + type: 'response.create', + model: 'gpt-5.4', + input: [], + })); + + const message = await messagesPromise; + socket.close(); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(message?.type).toBe('response.completed'); + expect(message?.response?.id).toBe('resp_http_fallback_401'); + }); + + it('treats response.incomplete as a terminal HTTP fallback event instead of appending a websocket error', async () => { + rejectedUpgradeStatus = 426; + rejectedUpgradeStatusText = 'Upgrade Required'; + rejectedUpgradeBody = JSON.stringify({ + error: { + message: 'upgrade required', + type: 'invalid_request_error', + }, + }); + const selectedChannel = createSelectedChannel({ + siteUrl: rejectedUpgradeSiteUrl, + }); + selectChannelMock.mockReturnValue(selectedChannel); + previewSelectedChannelMock.mockResolvedValue(selectedChannel); + fetchMock.mockResolvedValueOnce(createSseResponse([ + 'event: response.incomplete\n', + 'data: {"type":"response.incomplete","response":{"id":"resp_http_incomplete","model":"gpt-5.4","status":"incomplete","output":[{"id":"msg_incomplete","type":"message","role":"assistant","status":"incomplete","content":[{"type":"output_text","text":"partial"}]}],"output_text":"partial","incomplete_details":{"reason":"max_output_tokens"},"usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2}}}\n\n', + 'data: [DONE]\n\n', + ])); + + const socket = createClientSocket(baseUrl); + await waitForSocketOpen(socket); + const messagePromise = waitForSocketMessageMatching( + socket, + (message) => message?.type === 'response.incomplete', + ); + + socket.send(JSON.stringify({ + type: 'response.create', + model: 'gpt-5.4', + input: [], + })); + + const message = await messagePromise; + await expect( + waitForSocketMessageMatching( + socket, + (nextMessage) => nextMessage?.type === 'error', + 150, + ), + ).rejects.toThrow('Timed out waiting for matching websocket message'); + socket.close(); + + expect(message?.response?.id).toBe('resp_http_incomplete'); + }); + + it('preserves previous_response_id when websocket upgrade fallback uses HTTP on incremental-capable upstreams', async () => { + rejectedUpgradeStatus = 426; + rejectedUpgradeStatusText = 'Upgrade Required'; + rejectedUpgradeBody = JSON.stringify({ + error: { + message: 'upgrade required', + type: 'invalid_request_error', + }, + }); + + const selectedChannel = createSelectedChannel({ + siteUrl: rejectedUpgradeSiteUrl, + }); + selectChannelMock.mockReturnValue(selectedChannel); + previewSelectedChannelMock.mockResolvedValue(selectedChannel); + fetchMock + .mockResolvedValueOnce(createSseResponse([ + 'event: response.completed\n', + 'data: {"type":"response.completed","response":{"id":"resp_http_fallback_1","model":"gpt-5.4","status":"completed","output":[{"id":"msg_1","type":"message","role":"assistant","status":"completed","content":[{"type":"output_text","text":"first"}]}],"usage":{"input_tokens":3,"output_tokens":1,"total_tokens":4}}}\n\n', + 'data: [DONE]\n\n', + ])) + .mockResolvedValueOnce(createSseResponse([ + 'event: response.completed\n', + 'data: {"type":"response.completed","response":{"id":"resp_http_fallback_2","model":"gpt-5.4","status":"completed","output":[{"id":"msg_2","type":"message","role":"assistant","status":"completed","content":[{"type":"output_text","text":"second"}]}],"usage":{"input_tokens":5,"output_tokens":1,"total_tokens":6}}}\n\n', + 'data: [DONE]\n\n', + ])); + + const socket = createClientSocket(baseUrl); + await waitForSocketOpen(socket); + const firstResponsePromise = waitForSocketMessageMatching( + socket, + (message) => message?.type === 'response.completed', + ); + + socket.send(JSON.stringify({ + type: 'response.create', + model: 'gpt-5.4', + instructions: 'be helpful', + input: [], + })); + + const firstMessage = await firstResponsePromise; + const secondResponsePromise = waitForSocketMessageMatching( + socket, + (message) => message?.type === 'response.completed' && message?.response?.id === 'resp_http_fallback_2', + ); + socket.send(JSON.stringify({ + type: 'response.create', + model: 'gpt-5.4', + previous_response_id: 'resp_http_fallback_1', + input: [ + { + id: 'tool_out_1', + type: 'function_call_output', + call_id: 'call_1', + output: 'tool result', + }, + ], + })); + + await secondResponsePromise; + socket.close(); + + expect(firstMessage?.type).toBe('response.completed'); + expect(fetchMock).toHaveBeenCalledTimes(2); + const [, secondOptions] = fetchMock.mock.calls[1] as [string, RequestInit]; + const secondBody = JSON.parse(String(secondOptions.body)); + expect(secondBody.previous_response_id).toBe('resp_http_fallback_1'); + expect(secondBody.input).toEqual([ + { + id: 'tool_out_1', + type: 'function_call_output', + call_id: 'call_1', + output: 'tool result', + }, + ]); + }); + + it('carries forward incomplete-turn output into the next fallback websocket request input', async () => { + rejectedUpgradeStatus = 426; + rejectedUpgradeStatusText = 'Upgrade Required'; + rejectedUpgradeBody = JSON.stringify({ + error: { + message: 'upgrade required', + type: 'invalid_request_error', + }, + }); + + const selectedChannel = createSelectedChannel({ + siteUrl: rejectedUpgradeSiteUrl, + }); + selectChannelMock.mockReturnValue(selectedChannel); + previewSelectedChannelMock.mockResolvedValue(selectedChannel); + fetchMock + .mockResolvedValueOnce(createSseResponse([ + 'event: response.incomplete\n', + 'data: {"type":"response.incomplete","response":{"id":"resp_http_incomplete_1","model":"gpt-5.4","status":"incomplete","output":[{"id":"msg_http_incomplete_1","type":"message","role":"assistant","status":"incomplete","content":[{"type":"output_text","text":"carry me"}]}],"output_text":"carry me","incomplete_details":{"reason":"max_output_tokens"},"usage":{"input_tokens":3,"output_tokens":1,"total_tokens":4}}}\n\n', + 'data: [DONE]\n\n', + ])) + .mockResolvedValueOnce(createSseResponse([ + 'event: response.completed\n', + 'data: {"type":"response.completed","response":{"id":"resp_http_incomplete_2","model":"gpt-5.4","status":"completed","output":[],"usage":{"input_tokens":5,"output_tokens":1,"total_tokens":6}}}\n\n', + 'data: [DONE]\n\n', + ])); + + const socket = createClientSocket(baseUrl); + await waitForSocketOpen(socket); + const firstResponsePromise = waitForSocketMessageMatching( + socket, + (message) => message?.type === 'response.incomplete', + ); + + socket.send(JSON.stringify({ + type: 'response.create', + model: 'gpt-5.4', + input: [], + })); + + const firstMessage = await firstResponsePromise; + const secondResponsePromise = waitForSocketMessageMatching( + socket, + (message) => message?.type === 'response.completed', + ); + socket.send(JSON.stringify({ + type: 'response.create', + model: 'gpt-5.4', + input: [ + { + type: 'message', + role: 'user', + content: [{ type: 'input_text', text: 'continue' }], + }, + ], + })); + + await secondResponsePromise; + socket.close(); + + expect(firstMessage?.type).toBe('response.incomplete'); + expect(fetchMock).toHaveBeenCalledTimes(2); + const [, secondOptions] = fetchMock.mock.calls[1] as [string, RequestInit]; + const secondBody = JSON.parse(String(secondOptions.body)); + expect(secondBody.input).toHaveLength(2); + expect(secondBody.input[0]).toMatchObject({ + type: 'message', + role: 'assistant', + status: 'incomplete', + content: [{ type: 'output_text', text: 'carry me' }], + }); + expect(secondBody.input[1]).toEqual({ + type: 'message', + role: 'user', + content: [{ type: 'input_text', text: 'continue' }], + }); + }); + + it('carries forward terminal output from incomplete HTTP fallback turns on non-incremental upstreams', async () => { + rejectedUpgradeStatus = 426; + rejectedUpgradeStatusText = 'Upgrade Required'; + rejectedUpgradeBody = JSON.stringify({ + error: { + message: 'upgrade required', + type: 'invalid_request_error', + }, + }); + + const selectedChannel = createSelectedChannel({ + sitePlatform: 'openai', + siteUrl: rejectedUpgradeSiteUrl, + actualModel: 'gpt-4.1', + }); + selectChannelMock.mockReturnValue(selectedChannel); + previewSelectedChannelMock.mockResolvedValue(selectedChannel); + fetchMock + .mockResolvedValueOnce(createSseResponse([ + 'event: response.incomplete\n', + 'data: {"type":"response.incomplete","response":{"id":"resp_http_incomplete_1","model":"gpt-4.1","status":"incomplete","output":[{"id":"msg_1","type":"message","role":"assistant","status":"incomplete","content":[{"type":"output_text","text":"partial tool call"}]}],"output_text":"partial tool call","incomplete_details":{"reason":"max_output_tokens"},"usage":{"input_tokens":3,"output_tokens":1,"total_tokens":4}}}\n\n', + 'data: [DONE]\n\n', + ])) + .mockResolvedValueOnce(createSseResponse([ + 'event: response.completed\n', + 'data: {"type":"response.completed","response":{"id":"resp_http_complete_2","model":"gpt-4.1","status":"completed","output":[{"id":"msg_2","type":"message","role":"assistant","status":"completed","content":[{"type":"output_text","text":"done"}]}],"usage":{"input_tokens":5,"output_tokens":1,"total_tokens":6}}}\n\n', + 'data: [DONE]\n\n', + ])); + + const socket = createClientSocket(baseUrl); + await waitForSocketOpen(socket); + + const firstResponsePromise = waitForSocketMessageMatching( + socket, + (message) => message?.type === 'response.incomplete', + ); + socket.send(JSON.stringify({ + type: 'response.create', + model: 'gpt-4.1', + instructions: 'be helpful', + input: [ + { + id: 'msg_user_1', + type: 'message', + role: 'user', + content: [{ type: 'input_text', text: 'call the tool' }], + }, + ], + })); + await firstResponsePromise; + + const secondResponsePromise = waitForSocketMessageMatching( + socket, + (message) => message?.type === 'response.completed', + ); + socket.send(JSON.stringify({ + type: 'response.create', + previous_response_id: 'resp_http_incomplete_1', + input: [ + { + id: 'tool_out_1', + type: 'function_call_output', + call_id: 'call_1', + output: 'tool result', + }, + ], + })); + await secondResponsePromise; + socket.close(); + + expect(fetchMock).toHaveBeenCalledTimes(2); + const [, secondOptions] = fetchMock.mock.calls[1] as [string, RequestInit]; + const secondBody = JSON.parse(String(secondOptions.body)); + expect(secondBody.previous_response_id).toBeUndefined(); + expect(secondBody.model).toBe('gpt-4.1'); + expect(secondBody.instructions).toBe('be helpful'); + expect(secondBody.input).toHaveLength(3); + expect(secondBody.input[0]).toEqual({ + id: 'msg_user_1', + type: 'message', + role: 'user', + content: [{ type: 'input_text', text: 'call the tool' }], + }); + expect(secondBody.input[1]).toMatchObject({ + type: 'message', + role: 'assistant', + status: 'incomplete', + content: [{ type: 'output_text', text: 'partial tool call' }], + }); + expect(secondBody.input[2]).toEqual({ + id: 'tool_out_1', + type: 'function_call_output', + call_id: 'call_1', + output: 'tool result', + }); + }); + + it('preserves query parameter auth when websocket transport falls back to the HTTP responses route', async () => { + rejectedUpgradeStatus = 401; + rejectedUpgradeStatusText = 'Unauthorized'; + rejectedUpgradeBody = JSON.stringify({ + error: { + message: 'expired token', + type: 'invalid_request_error', + }, + }); + const selectedChannel = createSelectedChannel({ + siteUrl: rejectedUpgradeSiteUrl, + }); + selectChannelMock.mockReturnValue(selectedChannel); + previewSelectedChannelMock.mockResolvedValue(selectedChannel); + authorizeDownstreamTokenMock.mockResolvedValueOnce({ + ok: true, + source: 'global', + token: 'sk-query-auth', + key: null, + policy: { + supportedModels: [], + allowedRouteIds: [], + siteWeightMultipliers: {}, + }, + }); + fetchMock.mockResolvedValueOnce(createSseResponse([ + 'event: response.completed\n', + 'data: {"type":"response.completed","response":{"id":"resp_http_fallback_query","model":"gpt-5.4","status":"completed","output":[],"usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2}}}\n\n', + 'data: [DONE]\n\n', + ])); + + const socket = createClientSocketForPath(`${baseUrl}/v1/responses?key=sk-query-auth`); + await waitForSocketOpen(socket); + const messagesPromise = waitForSocketMessages(socket, 1); + + socket.send(JSON.stringify({ + type: 'response.create', + model: 'gpt-5.4', + input: [], + })); + + const messages = await messagesPromise; + socket.close(); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(messages[0]?.response?.id).toBe('resp_http_fallback_query'); + }); + + it('rejects websocket turns whose model is blocked by the downstream key policy before channel selection', async () => { + authorizeDownstreamTokenMock.mockResolvedValueOnce({ + ok: true, + source: 'managed', + token: 'sk-managed-denied', + key: { + id: 99, + name: 'limited-key', + }, + policy: { + supportedModels: ['gpt-4.1'], + allowedRouteIds: [], + siteWeightMultipliers: {}, + }, + }); + + const socket = createClientSocket(baseUrl); + await waitForSocketOpen(socket); + const messagesPromise = waitForSocketMessages(socket, 1); + + socket.send(JSON.stringify({ + type: 'response.create', + model: 'gpt-5.4', + input: [], + })); + + const messages = await messagesPromise; + socket.close(); + + expect(messages[0]).toMatchObject({ + type: 'error', + status: 403, + }); + expect(selectChannelMock).not.toHaveBeenCalled(); + }); + + it('merges follow-up response.create payloads when the selected upstream does not support incremental mode', async () => { + const selectedChannel = createSelectedChannel({ + sitePlatform: 'openai', + actualModel: 'gpt-4.1', + }); + selectChannelMock.mockReturnValue(selectedChannel); + previewSelectedChannelMock.mockResolvedValue(selectedChannel); + fetchMock + .mockResolvedValueOnce(createSseResponse([ + 'event: response.output_item.done\n', + 'data: {"type":"response.output_item.done","output_index":0,"item":{"id":"fc_1","type":"function_call","call_id":"call_1"}}\n\n', + 'event: response.output_item.done\n', + 'data: {"type":"response.output_item.done","output_index":1,"item":{"id":"msg_1","type":"message","role":"assistant","status":"completed","content":[{"type":"output_text","text":"call tool"}]}}\n\n', + 'event: response.completed\n', + 'data: {"type":"response.completed","response":{"id":"resp_ws_1","model":"gpt-4.1","status":"completed","output":[{"id":"fc_1","type":"function_call","call_id":"call_1"},{"id":"msg_1","type":"message","role":"assistant","status":"completed","content":[{"type":"output_text","text":"call tool"}]}],"usage":{"input_tokens":3,"output_tokens":1,"total_tokens":4}}}\n\n', + 'data: [DONE]\n\n', + ])) + .mockResolvedValueOnce(createSseResponse([ + 'event: response.completed\n', + 'data: {"type":"response.completed","response":{"id":"resp_ws_2","model":"gpt-4.1","status":"completed","output":[{"id":"msg_2","type":"message","role":"assistant","status":"completed","content":[{"type":"output_text","text":"done"}]}],"usage":{"input_tokens":5,"output_tokens":1,"total_tokens":6}}}\n\n', + 'data: [DONE]\n\n', + ])); + + const socket = createClientSocket(baseUrl); + await waitForSocketOpen(socket); + + const firstResponsePromise = waitForSocketMessageMatching( + socket, + (message) => message?.type === 'response.completed', + ); + socket.send(JSON.stringify({ + type: 'response.create', + model: 'gpt-4.1', + instructions: 'be helpful', + input: [ + { + id: 'msg_user_1', + type: 'message', + role: 'user', + content: [{ type: 'input_text', text: 'call the tool' }], + }, + ], + })); + await firstResponsePromise; + + const secondResponsePromise = waitForSocketMessageMatching( + socket, + (message) => message?.type === 'response.completed', + ); + socket.send(JSON.stringify({ + type: 'response.create', + previous_response_id: 'resp_ws_1', + input: [ + { + id: 'tool_out_1', + type: 'function_call_output', + call_id: 'call_1', + output: 'tool result', + }, + ], + })); + await secondResponsePromise; + socket.close(); + + expect(fetchMock).toHaveBeenCalledTimes(2); + const [, firstOptions] = fetchMock.mock.calls[0] as [string, RequestInit]; + const [, secondOptions] = fetchMock.mock.calls[1] as [string, RequestInit]; + const firstBody = JSON.parse(String(firstOptions.body)); + const secondBody = JSON.parse(String(secondOptions.body)); + + expect(firstBody.input).toHaveLength(1); + expect(secondBody.previous_response_id).toBeUndefined(); + expect(secondBody.model).toBe('gpt-4.1'); + expect(secondBody.instructions).toBe('be helpful'); + expect(secondBody.input).toHaveLength(4); + expect(secondBody.input[0]).toEqual({ + id: 'msg_user_1', + type: 'message', + role: 'user', + content: [{ type: 'input_text', text: 'call the tool' }], + }); + expect(secondBody.input[1]).toMatchObject({ + id: 'fc_1', + type: 'function_call', + call_id: 'call_1', + }); + expect(secondBody.input[2]).toEqual({ + id: 'msg_1', + type: 'message', + role: 'assistant', + status: 'completed', + content: [{ type: 'output_text', text: 'call tool' }], + }); + expect(secondBody.input[3]).toEqual({ + id: 'tool_out_1', + type: 'function_call_output', + call_id: 'call_1', + output: 'tool result', + }); + }); + + it('keeps streamed output items for follow-up turns when the terminal HTTP fallback payload has an empty output array', async () => { + const selectedChannel = createSelectedChannel({ + sitePlatform: 'openai', + actualModel: 'gpt-4.1', + }); + selectChannelMock.mockReturnValue(selectedChannel); + previewSelectedChannelMock.mockResolvedValue(selectedChannel); + fetchMock + .mockResolvedValueOnce(createSseResponse([ + 'event: response.output_item.done\n', + 'data: {"type":"response.output_item.done","output_index":0,"item":{"id":"fc_1","type":"function_call","call_id":"call_1"}}\n\n', + 'event: response.output_item.done\n', + 'data: {"type":"response.output_item.done","output_index":1,"item":{"id":"msg_1","type":"message","role":"assistant","status":"completed","content":[{"type":"output_text","text":"call tool"}]}}\n\n', + 'event: response.completed\n', + 'data: {"type":"response.completed","response":{"id":"resp_ws_empty_output","model":"gpt-4.1","status":"completed","output":[],"usage":{"input_tokens":3,"output_tokens":1,"total_tokens":4}}}\n\n', + 'data: [DONE]\n\n', + ])) + .mockResolvedValueOnce(createSseResponse([ + 'event: response.completed\n', + 'data: {"type":"response.completed","response":{"id":"resp_ws_followup","model":"gpt-4.1","status":"completed","output":[{"id":"msg_2","type":"message","role":"assistant","status":"completed","content":[{"type":"output_text","text":"done"}]}],"usage":{"input_tokens":5,"output_tokens":1,"total_tokens":6}}}\n\n', + 'data: [DONE]\n\n', + ])); + + const socket = createClientSocket(baseUrl); + await waitForSocketOpen(socket); + + const firstResponsePromise = waitForSocketMessageMatching( + socket, + (message) => message?.type === 'response.completed', + ); + socket.send(JSON.stringify({ + type: 'response.create', + model: 'gpt-4.1', + instructions: 'be helpful', + input: [ + { + id: 'msg_user_1', + type: 'message', + role: 'user', + content: [{ type: 'input_text', text: 'call the tool' }], + }, + ], + })); + await firstResponsePromise; + + const secondResponsePromise = waitForSocketMessageMatching( + socket, + (message) => message?.type === 'response.completed', + ); + socket.send(JSON.stringify({ + type: 'response.create', + model: 'gpt-4.1', + instructions: 'be helpful', + previous_response_id: 'resp_ws_empty_output', + input: [ + { + id: 'tool_out_1', + type: 'function_call_output', + call_id: 'call_1', + output: 'tool result', + }, + ], + })); + await secondResponsePromise; + socket.close(); + + expect(fetchMock).toHaveBeenCalledTimes(2); + const [, secondOptions] = fetchMock.mock.calls[1] as [string, RequestInit]; + const secondBody = JSON.parse(String(secondOptions.body)); + + expect(secondBody.input).toHaveLength(4); + expect(secondBody.input[1]).toMatchObject({ + id: 'fc_1', + type: 'function_call', + call_id: 'call_1', + }); + expect(secondBody.input[2]).toEqual({ + id: 'msg_1', + type: 'message', + role: 'assistant', + status: 'completed', + content: [{ type: 'output_text', text: 'call tool' }], + }); + }); + + it('serializes websocket messages per connection so follow-up turns wait for the previous HTTP fallback to finish', async () => { + const selectedChannel = createSelectedChannel({ + sitePlatform: 'openai', + actualModel: 'gpt-4.1', + }); + selectChannelMock.mockReturnValue(selectedChannel); + previewSelectedChannelMock.mockResolvedValue(selectedChannel); + const firstResponseGate = createDeferred(); + fetchMock + .mockImplementationOnce(() => firstResponseGate.promise) + .mockResolvedValueOnce(createSseResponse([ + 'event: response.completed\n', + 'data: {"type":"response.completed","response":{"id":"resp_ws_2","model":"gpt-4.1","status":"completed","output":[{"id":"msg_2","type":"message","role":"assistant","status":"completed","content":[{"type":"output_text","text":"done"}]}],"usage":{"input_tokens":5,"output_tokens":1,"total_tokens":6}}}\n\n', + 'data: [DONE]\n\n', + ])); + + const socket = createClientSocket(baseUrl); + await waitForSocketOpen(socket); + + socket.send(JSON.stringify({ + type: 'response.create', + model: 'gpt-4.1', + instructions: 'be helpful', + input: [ + { + id: 'msg_user_1', + type: 'message', + role: 'user', + content: [{ type: 'input_text', text: 'call the tool' }], + }, + ], + })); + + while (fetchMock.mock.calls.length < 1) { + await new Promise((resolve) => setTimeout(resolve, 0)); + } + + socket.send(JSON.stringify({ + type: 'response.create', + previous_response_id: 'resp_ws_1', + input: [ + { + id: 'tool_out_1', + type: 'function_call_output', + call_id: 'call_1', + output: 'tool result', + }, + ], + })); + + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(fetchMock).toHaveBeenCalledTimes(1); + + const secondTurnPromise = waitForSocketMessageMatching( + socket, + (message) => message?.type === 'response.completed' && message?.response?.id === 'resp_ws_2', + ); + firstResponseGate.resolve(createSseResponse([ + 'event: response.output_item.done\n', + 'data: {"type":"response.output_item.done","output_index":0,"item":{"id":"fc_1","type":"function_call","call_id":"call_1"}}\n\n', + 'event: response.output_item.done\n', + 'data: {"type":"response.output_item.done","output_index":1,"item":{"id":"msg_1","type":"message","role":"assistant","status":"completed","content":[{"type":"output_text","text":"call tool"}]}}\n\n', + 'event: response.completed\n', + 'data: {"type":"response.completed","response":{"id":"resp_ws_1","model":"gpt-4.1","status":"completed","output":[{"id":"fc_1","type":"function_call","call_id":"call_1"},{"id":"msg_1","type":"message","role":"assistant","status":"completed","content":[{"type":"output_text","text":"call tool"}]}],"usage":{"input_tokens":3,"output_tokens":1,"total_tokens":4}}}\n\n', + 'data: [DONE]\n\n', + ])); + + while (fetchMock.mock.calls.length < 2) { + await new Promise((resolve) => setTimeout(resolve, 0)); + } + + const secondTurnMessage = await secondTurnPromise; + socket.close(); + + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(secondTurnMessage).toMatchObject({ + type: 'response.completed', + response: { + id: 'resp_ws_2', + }, + }); + const [, secondOptions] = fetchMock.mock.calls[1] as [string, RequestInit]; + const secondBody = JSON.parse(String(secondOptions.body)); + expect(secondBody.input).toHaveLength(4); + expect(secondBody.input[0]).toEqual({ + id: 'msg_user_1', + type: 'message', + role: 'user', + content: [{ type: 'input_text', text: 'call the tool' }], + }); + expect(secondBody.input[1]).toMatchObject({ + id: 'fc_1', + type: 'function_call', + call_id: 'call_1', + }); + expect(secondBody.input[2]).toEqual({ + id: 'msg_1', + type: 'message', + role: 'assistant', + status: 'completed', + content: [{ type: 'output_text', text: 'call tool' }], + }); + expect(secondBody.input[3]).toEqual({ + id: 'tool_out_1', + type: 'function_call_output', + call_id: 'call_1', + output: 'tool result', + }); + }); + + it('preserves incremental response.create payloads with previous_response_id for websocket-capable upstreams', async () => { + const selectedChannel = createSelectedChannel({ + siteUrl: upstreamSiteUrl, + }); + selectChannelMock.mockReturnValue(selectedChannel); + previewSelectedChannelMock.mockResolvedValue(selectedChannel); + upstreamMessageHandler = (socket, _parsed, requestIndex) => { + if (requestIndex === 1) { + socket.send(JSON.stringify({ + type: 'response.completed', + response: { + id: 'resp_ws_1', + model: 'gpt-5.4', + status: 'completed', + output: [{ + id: 'msg_1', + type: 'message', + role: 'assistant', + status: 'completed', + content: [{ type: 'output_text', text: 'call tool' }], + }], + usage: { + input_tokens: 3, + output_tokens: 1, + total_tokens: 4, + }, + }, + })); + return; + } + socket.send(JSON.stringify({ + type: 'response.completed', + response: { + id: 'resp_ws_2', + model: 'gpt-5.4', + status: 'completed', + output: [{ + id: 'msg_2', + type: 'message', + role: 'assistant', + status: 'completed', + content: [{ type: 'output_text', text: 'done' }], + }], + usage: { + input_tokens: 5, + output_tokens: 1, + total_tokens: 6, + }, + }, + })); + }; + + const socket = createClientSocket(baseUrl); + await waitForSocketOpen(socket); + + const firstResponsePromise = waitForSocketMessageMatching( + socket, + (message) => message?.type === 'response.completed', + ); + socket.send(JSON.stringify({ + type: 'response.create', + model: 'gpt-5.4', + instructions: 'be helpful', + input: [ + { + id: 'msg_user_1', + type: 'message', + role: 'user', + content: [{ type: 'input_text', text: 'call the tool' }], + }, + ], + })); + await firstResponsePromise; + + const secondResponsePromise = waitForSocketMessageMatching( + socket, + (message) => message?.type === 'response.completed' && message?.response?.id === 'resp_ws_2', + ); + socket.send(JSON.stringify({ + type: 'response.create', + previous_response_id: 'resp_ws_1', + input: [ + { + id: 'tool_out_1', + type: 'function_call_output', + call_id: 'call_1', + output: 'tool result', + }, + ], + })); + const secondResponse = await secondResponsePromise; + socket.close(); + + expect(secondResponse).toMatchObject({ + type: 'response.completed', + response: { + id: 'resp_ws_2', + }, + }); + expect(upstreamConnectionCount).toBe(1); + expect(upstreamRequests).toHaveLength(2); + expect(upstreamRequests[0]).toMatchObject({ + type: 'response.create', + model: 'gpt-5.4', + instructions: 'be helpful', + }); + expect(upstreamRequests[1]).toMatchObject({ + type: 'response.create', + previous_response_id: 'resp_ws_1', + model: 'gpt-5.4', + instructions: 'be helpful', + input: [ + { + id: 'tool_out_1', + type: 'function_call_output', + call_id: 'call_1', + output: 'tool result', + }, + ], + }); + }); + + it('falls back to the HTTP responses route when codex upstream websocket is globally disabled', async () => { + (config as any).codexUpstreamWebsocketEnabled = false; + + fetchMock + .mockResolvedValueOnce(createSseResponse([ + 'event: response.completed\n', + 'data: {"type":"response.completed","response":{"id":"resp_http_1","model":"gpt-5.4","status":"completed","output":[{"id":"msg_1","type":"message","role":"assistant","status":"completed","content":[{"type":"output_text","text":"first"}]}],"usage":{"input_tokens":3,"output_tokens":1,"total_tokens":4}}}\n\n', + 'data: [DONE]\n\n', + ])) + .mockResolvedValueOnce(createSseResponse([ + 'event: response.completed\n', + 'data: {"type":"response.completed","response":{"id":"resp_http_2","model":"gpt-5.4","status":"completed","output":[{"id":"msg_2","type":"message","role":"assistant","status":"completed","content":[{"type":"output_text","text":"second"}]}],"usage":{"input_tokens":5,"output_tokens":1,"total_tokens":6}}}\n\n', + 'data: [DONE]\n\n', + ])); + + const socket = createClientSocket(baseUrl); + await waitForSocketOpen(socket); + const firstResponsePromise = waitForSocketMessageMatching( + socket, + (message) => message?.type === 'response.completed' && message?.response?.id === 'resp_http_1', + ); + + socket.send(JSON.stringify({ + type: 'response.create', + model: 'gpt-5.4', + instructions: 'be helpful', + input: [], + })); + + const firstMessage = await firstResponsePromise; + const secondResponsePromise = waitForSocketMessageMatching( + socket, + (message) => message?.type === 'response.completed' && message?.response?.id === 'resp_http_2', + ); + socket.send(JSON.stringify({ + type: 'response.create', + model: 'gpt-5.4', + previous_response_id: 'resp_http_1', + input: [ + { + id: 'tool_out_1', + type: 'function_call_output', + call_id: 'call_1', + output: 'tool result', + }, + ], + })); + const secondMessage = await secondResponsePromise; + socket.close(); + + expect(firstMessage).toMatchObject({ + type: 'response.completed', + response: { + id: 'resp_http_1', + }, + }); + expect(secondMessage).toMatchObject({ + type: 'response.completed', + response: { + id: 'resp_http_2', + }, + }); + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(fetchMock.mock.calls[0]?.[1]).toMatchObject({ + method: 'POST', + body: JSON.stringify({ + model: 'gpt-5.4', + instructions: 'be helpful', + input: [], + stream: true, + store: false, + }), + }); + const [, secondOptions] = fetchMock.mock.calls[1] as [string, RequestInit]; + const secondBody = JSON.parse(String(secondOptions.body)); + expect(secondBody.previous_response_id).toBeUndefined(); + expect(secondBody.instructions).toBe('be helpful'); + expect(secondBody.stream).toBe(true); + expect(secondBody.store).toBe(false); + expect(secondBody.input).toEqual([ + { + id: 'msg_1', + type: 'message', + role: 'assistant', + status: 'completed', + content: [{ type: 'output_text', text: 'first' }], + }, + { + id: 'tool_out_1', + type: 'function_call_output', + call_id: 'call_1', + output: 'tool result', + }, + ]); + }); + + it('disables codex websocket incremental transport when the selected account marks websockets as disabled', async () => { + const selectedChannel = createSelectedChannel({ + extraConfig: { + credentialMode: 'session', + websockets: false, + oauth: { + provider: 'codex', + accountId: 'chatgpt-account-123', + email: 'codex-user@example.com', + }, + }, + }); + selectChannelMock.mockReturnValue(selectedChannel); + previewSelectedChannelMock.mockResolvedValue(selectedChannel); + fetchMock + .mockResolvedValueOnce(createSseResponse([ + 'event: response.completed\n', + 'data: {"type":"response.completed","response":{"id":"resp_http_1","model":"gpt-5.4","status":"completed","output":[{"id":"msg_1","type":"message","role":"assistant","status":"completed","content":[{"type":"output_text","text":"first"}]}],"usage":{"input_tokens":3,"output_tokens":1,"total_tokens":4}}}\n\n', + 'data: [DONE]\n\n', + ])) + .mockResolvedValueOnce(createSseResponse([ + 'event: response.completed\n', + 'data: {"type":"response.completed","response":{"id":"resp_http_2","model":"gpt-5.4","status":"completed","output":[{"id":"msg_2","type":"message","role":"assistant","status":"completed","content":[{"type":"output_text","text":"second"}]}],"usage":{"input_tokens":5,"output_tokens":1,"total_tokens":6}}}\n\n', + 'data: [DONE]\n\n', + ])); + + const socket = createClientSocket(baseUrl); + await waitForSocketOpen(socket); + const firstResponsePromise = waitForSocketMessageMatching( + socket, + (message) => message?.type === 'response.completed' && message?.response?.id === 'resp_http_1', + ); + + socket.send(JSON.stringify({ + type: 'response.create', + model: 'gpt-5.4', + instructions: 'be helpful', + input: [], + })); + + const firstMessage = await firstResponsePromise; + const secondResponsePromise = waitForSocketMessageMatching( + socket, + (message) => message?.type === 'response.completed' && message?.response?.id === 'resp_http_2', + ); + socket.send(JSON.stringify({ + type: 'response.create', + model: 'gpt-5.4', + previous_response_id: 'resp_http_1', + input: [ + { + id: 'tool_out_1', + type: 'function_call_output', + call_id: 'call_1', + output: 'tool result', + }, + ], + })); + + await secondResponsePromise; + socket.close(); + + expect(firstMessage?.type).toBe('response.completed'); + expect(upstreamConnectionCount).toBe(0); + expect(fetchMock).toHaveBeenCalledTimes(2); + const [, secondOptions] = fetchMock.mock.calls[1] as [string, RequestInit]; + const secondBody = JSON.parse(String(secondOptions.body)); + expect(secondBody.previous_response_id).toBeUndefined(); + expect(secondBody.input).toEqual([ + { + id: 'msg_1', + type: 'message', + role: 'assistant', + status: 'completed', + content: [{ type: 'output_text', text: 'first' }], + }, + { + id: 'tool_out_1', + type: 'function_call_output', + call_id: 'call_1', + output: 'tool result', + }, + ]); + }); + + it('handles generate=false locally only for non-websocket-capable upstreams', async () => { + const selectedChannel = createSelectedChannel({ + sitePlatform: 'openai', + actualModel: 'gpt-4.1', + }); + selectChannelMock.mockReturnValue(selectedChannel); + previewSelectedChannelMock.mockResolvedValue(selectedChannel); + fetchMock.mockResolvedValueOnce(createSseResponse([ + 'event: response.completed\n', + 'data: {"type":"response.completed","response":{"id":"resp_ws_after_prewarm","model":"gpt-4.1","status":"completed","output":[{"id":"msg_2","type":"message","role":"assistant","status":"completed","content":[{"type":"output_text","text":"done"}]}],"usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2}}}\n\n', + 'data: [DONE]\n\n', + ])); + + const socket = createClientSocket(baseUrl); + await waitForSocketOpen(socket); + + const prewarmMessagesPromise = waitForSocketMessages(socket, 2); + socket.send(JSON.stringify({ + type: 'response.create', + model: 'gpt-4.1', + generate: false, + })); + const prewarmMessages = await prewarmMessagesPromise; + expect(prewarmMessages.map((message) => message.type)).toEqual(['response.created', 'response.completed']); + expect(fetchMock).toHaveBeenCalledTimes(0); + + const secondResponsePromise = waitForSocketMessages(socket, 1); + socket.send(JSON.stringify({ + type: 'response.create', + previous_response_id: prewarmMessages[0]?.response?.id, + input: [ + { + id: 'msg_followup_1', + type: 'message', + role: 'user', + content: [{ type: 'input_text', text: 'continue' }], + }, + ], + })); + await secondResponsePromise; + socket.close(); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const [, options] = fetchMock.mock.calls[0] as [string, RequestInit]; + const forwardedBody = JSON.parse(String(options.body)); + expect(forwardedBody.generate).toBeUndefined(); + expect(forwardedBody.previous_response_id).toBeUndefined(); + expect(forwardedBody.model).toBe('gpt-4.1'); + expect(forwardedBody.input).toEqual([ + { + id: 'msg_followup_1', + type: 'message', + role: 'user', + content: [{ type: 'input_text', text: 'continue' }], + }, + ]); + }); + + it('forwards generate=false upstream for websocket-capable upstreams instead of synthesizing prewarm events', async () => { + const selectedChannel = createSelectedChannel({ + siteUrl: upstreamSiteUrl, + }); + selectChannelMock.mockReturnValue(selectedChannel); + previewSelectedChannelMock.mockResolvedValue(selectedChannel); + + const socket = createClientSocket(baseUrl); + await waitForSocketOpen(socket); + const messagesPromise = waitForSocketMessages(socket, 1); + + socket.send(JSON.stringify({ + type: 'response.create', + model: 'gpt-5.4', + generate: false, + })); + + const messages = await messagesPromise; + socket.close(); + + expect(messages.map((message) => message.type)).toEqual(['response.completed']); + expect(fetchMock).toHaveBeenCalledTimes(0); + expect(upstreamRequests[0]).toMatchObject({ + type: 'response.create', + generate: false, + }); + }); + + it('emits websocket error when the upstream stream closes before a terminal responses event', async () => { + const selectedChannel = createSelectedChannel({ + siteUrl: upstreamSiteUrl, + }); + selectChannelMock.mockReturnValue(selectedChannel); + previewSelectedChannelMock.mockResolvedValue(selectedChannel); + upstreamMessageHandler = (socket) => { + socket.send(JSON.stringify({ + type: 'response.created', + response: { + id: 'resp_incomplete', + model: 'gpt-5.4', + created_at: 1706000000, + status: 'in_progress', + output: [], + }, + })); + socket.send(JSON.stringify({ + type: 'response.output_text.delta', + output_index: 0, + item_id: 'msg_ws', + delta: 'partial', + })); + socket.close(); + }; + + const socket = createClientSocket(baseUrl); + await waitForSocketOpen(socket); + const messagesPromise = waitForSocketMessages(socket, 3, 400); + + socket.send(JSON.stringify({ + type: 'response.create', + model: 'gpt-5.4', + input: [ + { + type: 'message', + role: 'user', + content: [{ type: 'input_text', text: 'hello websocket' }], + }, + ], + })); + + const messages = await messagesPromise; + socket.close(); + + expect(messages.map((message) => message.type)).toEqual([ + 'response.created', + 'response.output_text.delta', + 'error', + ]); + expect(messages[2]?.error?.message).toContain('stream closed before response.completed'); + }); + + it('does not append websocket error after an upstream response.incomplete terminal event', async () => { + const selectedChannel = createSelectedChannel({ + siteUrl: upstreamSiteUrl, + }); + selectChannelMock.mockReturnValue(selectedChannel); + previewSelectedChannelMock.mockResolvedValue(selectedChannel); + upstreamMessageHandler = (socket) => { + socket.send(JSON.stringify({ + type: 'response.incomplete', + response: { + id: 'resp_ws_incomplete', + model: 'gpt-5.4', + status: 'incomplete', + output: [{ + id: 'msg_ws_incomplete', + type: 'message', + role: 'assistant', + status: 'incomplete', + content: [{ type: 'output_text', text: 'partial' }], + }], + incomplete_details: { + reason: 'max_output_tokens', + }, + }, + })); + socket.close(); + }; + + const socket = createClientSocket(baseUrl); + await waitForSocketOpen(socket); + const incompletePromise = waitForSocketMessageMatching( + socket, + (message) => message?.type === 'response.incomplete', + ); + + socket.send(JSON.stringify({ + type: 'response.create', + model: 'gpt-5.4', + input: [], + })); + + const message = await incompletePromise; + socket.close(); + + expect(message?.type).toBe('response.incomplete'); + expect(message?.response?.incomplete_details?.reason).toBe('max_output_tokens'); + }); + + it('does not append websocket error after an upstream response.failed terminal event with output', async () => { + const selectedChannel = createSelectedChannel({ + siteUrl: upstreamSiteUrl, + }); + selectChannelMock.mockReturnValue(selectedChannel); + previewSelectedChannelMock.mockResolvedValue(selectedChannel); + upstreamMessageHandler = (socket) => { + socket.send(JSON.stringify({ + type: 'response.failed', + response: { + id: 'resp_ws_failed', + model: 'gpt-5.4', + status: 'failed', + output: [{ + id: 'msg_ws_failed', + type: 'message', + role: 'assistant', + status: 'failed', + content: [{ type: 'output_text', text: 'partial before failure' }], + }], + error: { + message: 'tool crashed', + type: 'server_error', + }, + }, + })); + socket.close(); + }; + + const socket = createClientSocket(baseUrl); + await waitForSocketOpen(socket); + const failedPromise = waitForSocketMessageMatching( + socket, + (message) => message?.type === 'response.failed', + ); + + socket.send(JSON.stringify({ + type: 'response.create', + model: 'gpt-5.4', + input: [], + })); + + const message = await failedPromise; + socket.close(); + + expect(message?.type).toBe('response.failed'); + expect(message?.response?.error?.message).toBe('tool crashed'); + expect(message?.response?.output?.[0]?.content?.[0]?.text).toBe('partial before failure'); + }); + + it('carries forward output from response.incomplete terminal payloads on non-incremental websocket turns', async () => { + const selectedChannel = createSelectedChannel({ + sitePlatform: 'openai', + actualModel: 'gpt-4.1', + }); + selectChannelMock.mockReturnValue(selectedChannel); + previewSelectedChannelMock.mockResolvedValue(selectedChannel); + fetchMock + .mockResolvedValueOnce(createSseResponse([ + 'event: response.incomplete\n', + 'data: {"type":"response.incomplete","response":{"id":"resp_incomplete_followup","model":"gpt-5.4","status":"incomplete","output":[{"id":"msg_incomplete_followup","type":"message","role":"assistant","status":"incomplete","content":[{"type":"output_text","text":"partial"}]}],"output_text":"partial","incomplete_details":{"reason":"max_output_tokens"},"usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2}}}\n\n', + 'data: [DONE]\n\n', + ])) + .mockResolvedValueOnce(createSseResponse([ + 'event: response.completed\n', + 'data: {"type":"response.completed","response":{"id":"resp_followup_done","model":"gpt-5.4","status":"completed","output":[],"usage":{"input_tokens":2,"output_tokens":1,"total_tokens":3}}}\n\n', + 'data: [DONE]\n\n', + ])); + + const socket = createClientSocket(baseUrl); + await waitForSocketOpen(socket); + const firstMessagesPromise = waitForSocketMessageMatching( + socket, + (message) => message?.type === 'response.incomplete', + ); + + socket.send(JSON.stringify({ + type: 'response.create', + model: 'gpt-5.4', + input: [], + })); + + const firstMessage = await firstMessagesPromise; + const secondMessagesPromise = waitForSocketMessageMatching( + socket, + (message) => message?.type === 'response.completed' && message?.response?.id === 'resp_followup_done', + ); + socket.send(JSON.stringify({ + type: 'response.create', + model: 'gpt-5.4', + previous_response_id: 'resp_incomplete_followup', + input: [], + })); + + await secondMessagesPromise; + socket.close(); + + expect(firstMessage?.response?.output).toEqual([ + { + id: 'msg_incomplete_followup', + type: 'message', + role: 'assistant', + status: 'incomplete', + content: [{ type: 'output_text', text: 'partial' }], + }, + ]); + expect(fetchMock).toHaveBeenCalledTimes(2); + const [, secondOptions] = fetchMock.mock.calls[1] as [string, RequestInit]; + const secondBody = JSON.parse(String(secondOptions.body)); + expect(secondBody.input).toHaveLength(1); + expect(secondBody.input[0]).toMatchObject({ + type: 'message', + role: 'assistant', + status: 'incomplete', + content: [{ type: 'output_text', text: 'partial' }], + }); + }); + + it('carries forward output from response.failed terminal payloads on non-incremental websocket turns', async () => { + const selectedChannel = createSelectedChannel({ + sitePlatform: 'openai', + actualModel: 'gpt-4.1', + }); + selectChannelMock.mockReturnValue(selectedChannel); + previewSelectedChannelMock.mockResolvedValue(selectedChannel); + fetchMock + .mockResolvedValueOnce(createSseResponse([ + 'event: response.failed\n', + 'data: {"type":"response.failed","response":{"id":"resp_failed_followup","model":"gpt-4.1","status":"failed","output":[{"id":"msg_failed_followup","type":"message","role":"assistant","status":"failed","content":[{"type":"output_text","text":"partial failure"}]}],"error":{"message":"tool crashed","type":"server_error"},"usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2}}}\n\n', + 'data: [DONE]\n\n', + ])) + .mockResolvedValueOnce(createSseResponse([ + 'event: response.completed\n', + 'data: {"type":"response.completed","response":{"id":"resp_failed_followup_done","model":"gpt-4.1","status":"completed","output":[],"usage":{"input_tokens":2,"output_tokens":1,"total_tokens":3}}}\n\n', + 'data: [DONE]\n\n', + ])); + + const socket = createClientSocket(baseUrl); + await waitForSocketOpen(socket); + const firstMessagePromise = waitForSocketMessageMatching( + socket, + (message) => message?.type === 'response.failed', + ); + + socket.send(JSON.stringify({ + type: 'response.create', + model: 'gpt-4.1', + input: [], + })); + + const firstMessage = await firstMessagePromise; + const secondMessagePromise = waitForSocketMessageMatching( + socket, + (message) => message?.type === 'response.completed' && message?.response?.id === 'resp_failed_followup_done', + ); + socket.send(JSON.stringify({ + type: 'response.create', + model: 'gpt-4.1', + previous_response_id: 'resp_failed_followup', + input: [], + })); + + await secondMessagePromise; + socket.close(); + + expect(firstMessage?.response?.output).toEqual([ + { + id: 'msg_failed_followup', + type: 'message', + role: 'assistant', + status: 'failed', + content: [{ type: 'output_text', text: 'partial failure' }], + }, + ]); + expect(fetchMock).toHaveBeenCalledTimes(2); + const [, secondOptions] = fetchMock.mock.calls[1] as [string, RequestInit]; + const secondBody = JSON.parse(String(secondOptions.body)); + expect(secondBody.input).toHaveLength(1); + expect(secondBody.input[0]).toMatchObject({ + type: 'message', + role: 'assistant', + content: [{ type: 'output_text', text: 'partial failure' }], + }); + expect(['failed', 'incomplete']).toContain(secondBody.input[0]?.status); + }); +}); diff --git a/src/server/routes/proxy/responsesSseFinal.test.ts b/src/server/routes/proxy/responsesSseFinal.test.ts new file mode 100644 index 00000000..eaba0a6c --- /dev/null +++ b/src/server/routes/proxy/responsesSseFinal.test.ts @@ -0,0 +1,232 @@ +import { describe, expect, it } from 'vitest'; + +import { collectResponsesFinalPayloadFromSse } from './responsesSseFinal.js'; + +describe('collectResponsesFinalPayloadFromSse', () => { + it('treats event:error payloads as upstream failures', async () => { + const upstream = { + async text() { + return [ + 'event: error', + 'data: {"error":{"message":"quota exceeded"},"type":"error"}', + '', + 'data: [DONE]', + '', + ].join('\n'); + }, + }; + + await expect(collectResponsesFinalPayloadFromSse(upstream, 'gpt-5.4')) + .rejects + .toThrow('quota exceeded'); + }); + + it('prefers aggregated stream content when response.completed only carries an empty output array', async () => { + const upstream = { + async text() { + return [ + 'event: response.created', + 'data: {"type":"response.created","response":{"id":"resp_empty_completed","model":"gpt-5.4","created_at":1706000000,"status":"in_progress","output":[]}}', + '', + 'event: response.output_item.added', + 'data: {"type":"response.output_item.added","output_index":0,"item":{"id":"msg_empty_completed","type":"message","role":"assistant","status":"in_progress","content":[]}}', + '', + 'event: response.output_text.delta', + 'data: {"type":"response.output_text.delta","output_index":0,"item_id":"msg_empty_completed","delta":"pong"}', + '', + 'event: response.completed', + 'data: {"type":"response.completed","response":{"id":"resp_empty_completed","model":"gpt-5.4","status":"completed","output":[],"usage":{"input_tokens":3,"output_tokens":1,"total_tokens":4}}}', + '', + 'data: [DONE]', + '', + ].join('\n'); + }, + }; + + await expect(collectResponsesFinalPayloadFromSse(upstream, 'gpt-5.4')) + .resolves + .toMatchObject({ + payload: { + id: 'resp_empty_completed', + status: 'completed', + output: [ + { + id: 'msg_empty_completed', + type: 'message', + role: 'assistant', + status: 'completed', + content: [ + { + type: 'output_text', + text: 'pong', + }, + ], + }, + ], + output_text: 'pong', + usage: { + input_tokens: 3, + output_tokens: 1, + total_tokens: 4, + }, + }, + }); + }); + + it('returns response.incomplete payloads instead of treating them as upstream failures', async () => { + const upstream = { + async text() { + return [ + 'event: response.created', + 'data: {"type":"response.created","response":{"id":"resp_incomplete_terminal","model":"gpt-5.4","created_at":1706000000,"status":"in_progress","output":[]}}', + '', + 'event: response.incomplete', + 'data: {"type":"response.incomplete","response":{"id":"resp_incomplete_terminal","model":"gpt-5.4","status":"incomplete","output":[{"id":"msg_incomplete_1","type":"message","role":"assistant","status":"incomplete","content":[{"type":"output_text","text":"partial answer"}]}],"output_text":"partial answer","incomplete_details":{"reason":"max_output_tokens"},"usage":{"input_tokens":3,"output_tokens":1,"total_tokens":4}}}', + '', + 'data: [DONE]', + '', + ].join('\n'); + }, + }; + + await expect(collectResponsesFinalPayloadFromSse(upstream, 'gpt-5.4')) + .resolves + .toMatchObject({ + payload: { + id: 'resp_incomplete_terminal', + status: 'incomplete', + output_text: 'partial answer', + incomplete_details: { + reason: 'max_output_tokens', + }, + }, + }); + }); + + it('returns response.incomplete terminal payloads instead of throwing', async () => { + const upstream = { + async text() { + return [ + 'event: response.created', + 'data: {"type":"response.created","response":{"id":"resp_incomplete_1","model":"gpt-5.4","created_at":1706000000,"status":"in_progress","output":[]}}', + '', + 'event: response.output_text.delta', + 'data: {"type":"response.output_text.delta","output_index":0,"item_id":"msg_incomplete_1","delta":"partial"}', + '', + 'event: response.incomplete', + 'data: {"type":"response.incomplete","response":{"id":"resp_incomplete_1","model":"gpt-5.4","status":"incomplete","output":[{"id":"msg_incomplete_1","type":"message","role":"assistant","status":"incomplete","content":[{"type":"output_text","text":"partial"}]}],"incomplete_details":{"reason":"max_output_tokens"},"usage":{"input_tokens":3,"output_tokens":1,"total_tokens":4}}}', + '', + 'data: [DONE]', + '', + ].join('\n'); + }, + }; + + await expect(collectResponsesFinalPayloadFromSse(upstream, 'gpt-5.4')) + .resolves + .toMatchObject({ + payload: { + id: 'resp_incomplete_1', + status: 'incomplete', + incomplete_details: { + reason: 'max_output_tokens', + }, + output_text: 'partial', + }, + }); + }); + + it('treats response.incomplete as terminal even when only response.output carries the visible text', async () => { + const upstream = { + async text() { + return [ + 'event: response.created', + 'data: {"type":"response.created","response":{"id":"resp_incomplete_output_only","model":"gpt-5.4","created_at":1706000000,"status":"in_progress","output":[]}}', + '', + 'event: response.incomplete', + 'data: {"type":"response.incomplete","response":{"id":"resp_incomplete_output_only","model":"gpt-5.4","status":"incomplete","output":[{"id":"msg_incomplete_output_only","type":"message","role":"assistant","status":"incomplete","content":[{"type":"output_text","text":"partial from output"}]}],"incomplete_details":{"reason":"max_output_tokens"},"usage":{"input_tokens":3,"output_tokens":1,"total_tokens":4}}}', + '', + 'data: [DONE]', + '', + ].join('\n'); + }, + }; + + await expect(collectResponsesFinalPayloadFromSse(upstream, 'gpt-5.4')) + .resolves + .toMatchObject({ + payload: { + id: 'resp_incomplete_output_only', + status: 'incomplete', + output: [ + { + id: 'msg_incomplete_output_only', + type: 'message', + role: 'assistant', + status: 'incomplete', + content: [ + { + type: 'output_text', + text: 'partial from output', + }, + ], + }, + ], + output_text: 'partial from output', + incomplete_details: { + reason: 'max_output_tokens', + }, + }, + }); + }); + + it('preserves incomplete item status when response.incomplete needs aggregate output repair', async () => { + const upstream = { + async text() { + return [ + 'event: response.created', + 'data: {"type":"response.created","response":{"id":"resp_incomplete_repair","model":"gpt-5.4","created_at":1706000000,"status":"in_progress","output":[]}}', + '', + 'event: response.output_item.added', + 'data: {"type":"response.output_item.added","output_index":0,"item":{"id":"msg_incomplete_repair","type":"message","role":"assistant","status":"in_progress","content":[]}}', + '', + 'event: response.output_text.delta', + 'data: {"type":"response.output_text.delta","output_index":0,"item_id":"msg_incomplete_repair","delta":"partial repair"}', + '', + 'event: response.incomplete', + 'data: {"type":"response.incomplete","response":{"id":"resp_incomplete_repair","model":"gpt-5.4","status":"incomplete","output":[],"incomplete_details":{"reason":"max_output_tokens"},"usage":{"input_tokens":3,"output_tokens":1,"total_tokens":4}}}', + '', + 'data: [DONE]', + '', + ].join('\n'); + }, + }; + + await expect(collectResponsesFinalPayloadFromSse(upstream, 'gpt-5.4')) + .resolves + .toMatchObject({ + payload: { + id: 'resp_incomplete_repair', + status: 'incomplete', + output: [ + { + id: 'msg_incomplete_repair', + type: 'message', + role: 'assistant', + status: 'incomplete', + content: [ + { + type: 'output_text', + text: 'partial repair', + }, + ], + }, + ], + output_text: 'partial repair', + incomplete_details: { + reason: 'max_output_tokens', + }, + }, + }); + }); +}); diff --git a/src/server/routes/proxy/responsesSseFinal.ts b/src/server/routes/proxy/responsesSseFinal.ts new file mode 100644 index 00000000..48633844 --- /dev/null +++ b/src/server/routes/proxy/responsesSseFinal.ts @@ -0,0 +1,427 @@ +import { openAiResponsesTransformer } from '../../transformers/openai/responses/index.js'; +import { mergeProxyUsage, parseProxyUsage } from '../../services/proxyUsageParser.js'; + +type ResponsesTerminalStatus = 'completed' | 'incomplete'; + +function isRecord(value: unknown): value is Record { + return !!value && typeof value === 'object'; +} + +function asTrimmedString(value: unknown): string { + return typeof value === 'string' ? value.trim() : ''; +} + +function parseResponsesSsePayload(data: string): Record | null { + try { + const parsed = JSON.parse(data); + return isRecord(parsed) ? parsed : null; + } catch { + return null; + } +} + +function getResponsesFailureMessage(payload: Record): string { + if (isRecord(payload.error) && typeof payload.error.message === 'string' && payload.error.message.trim()) { + return payload.error.message.trim(); + } + if (typeof payload.message === 'string' && payload.message.trim()) { + return payload.message.trim(); + } + return 'upstream stream failed'; +} + +function hasMeaningfulMessageContent(content: unknown): boolean { + if (!Array.isArray(content)) return false; + return content.some((part) => { + if (!isRecord(part)) return false; + const partType = typeof part.type === 'string' ? part.type.trim().toLowerCase() : ''; + if (partType === 'output_text' || partType === 'text') { + return typeof part.text === 'string' && part.text.length > 0; + } + return true; + }); +} + +function hasMeaningfulResponsesOutput(output: unknown): boolean { + if (!Array.isArray(output)) return false; + return output.some((item) => { + if (!isRecord(item)) return false; + const itemType = typeof item.type === 'string' ? item.type.trim().toLowerCase() : ''; + if (itemType === 'message') { + return hasMeaningfulMessageContent(item.content); + } + if (itemType === 'reasoning') { + return ( + (Array.isArray(item.summary) && item.summary.length > 0) + || (typeof item.encrypted_content === 'string' && item.encrypted_content.trim().length > 0) + ); + } + return itemType.length > 0; + }); +} + +function hasCompleteFinalResponsesPayload(payload: Record): boolean { + return ( + payload.object === 'response.compaction' + || Array.isArray(payload.output) + || Object.prototype.hasOwnProperty.call(payload, 'output_text') + ); +} + +function hasMeaningfulFinalResponsesPayload(payload: Record): boolean { + if (payload.object === 'response.compaction') { + return Array.isArray(payload.output) && payload.output.length > 0; + } + if (typeof payload.output_text === 'string' && payload.output_text.length > 0) { + return true; + } + return hasMeaningfulResponsesOutput(payload.output); +} + +function collectResponsesOutputText(payload: Record): string { + const output = Array.isArray(payload.output) ? payload.output : []; + const parts: string[] = []; + + for (const item of output) { + if (!isRecord(item)) continue; + if (asTrimmedString(item.type).toLowerCase() !== 'message') continue; + const content = Array.isArray(item.content) ? item.content : []; + for (const part of content) { + if (!isRecord(part)) continue; + const partType = asTrimmedString(part.type).toLowerCase(); + const text = typeof part.text === 'string' ? part.text : ''; + if ((partType === 'output_text' || partType === 'text') && text) { + parts.push(text); + } + } + } + + return parts.join(''); +} + +function rememberStreamResponseEnvelope( + streamContext: ReturnType, + payload: Record, +): void { + const responsePayload = isRecord(payload.response) ? payload.response : payload; + if (typeof responsePayload.id === 'string' && responsePayload.id.trim().length > 0) { + streamContext.id = responsePayload.id; + } + if (typeof responsePayload.model === 'string' && responsePayload.model.trim().length > 0) { + streamContext.model = responsePayload.model; + } + const createdAt = ( + typeof responsePayload.created_at === 'number' && Number.isFinite(responsePayload.created_at) + ? responsePayload.created_at + : (typeof responsePayload.created === 'number' && Number.isFinite(responsePayload.created) + ? responsePayload.created + : null) + ); + if (createdAt !== null) { + streamContext.created = createdAt; + } +} + +function ensureResponseId(rawId: string): string { + const trimmed = rawId.trim() || `resp_${Date.now()}`; + return trimmed.startsWith('resp_') ? trimmed : `resp_${trimmed}`; +} + +function buildUsagePayload(usage: ReturnType): Record { + const payload: Record = { + input_tokens: usage.promptTokens, + output_tokens: usage.completionTokens, + total_tokens: usage.totalTokens, + }; + const inputDetails: Record = {}; + if ((usage.cacheReadTokens || 0) > 0) inputDetails.cached_tokens = usage.cacheReadTokens; + if ((usage.cacheCreationTokens || 0) > 0) inputDetails.cache_creation_tokens = usage.cacheCreationTokens; + if (Object.keys(inputDetails).length > 0) payload.input_tokens_details = inputDetails; + return payload; +} + +function cloneAggregateOutputItem( + item: unknown, + terminalStatus: ResponsesTerminalStatus, +): Record | null { + if (!isRecord(item)) return null; + const next = structuredClone(item); + const currentStatus = asTrimmedString(next.status).toLowerCase(); + next.status = currentStatus && currentStatus !== 'in_progress' + ? currentStatus + : terminalStatus; + return next; +} + +function materializeTerminalPayloadFromAggregate( + aggregateState: ReturnType, + streamContext: ReturnType, + usage: ReturnType, + terminalStatus: ResponsesTerminalStatus, +): Record { + const output = aggregateState.outputItems + .map((item) => cloneAggregateOutputItem(item, terminalStatus)) + .filter((item): item is Record => !!item); + const usagePayload = buildUsagePayload(usage); + const usageWithExtras = Object.keys(aggregateState.usageExtras).length > 0 + ? { ...usagePayload, ...structuredClone(aggregateState.usageExtras) } + : usagePayload; + + return { + id: ensureResponseId( + asTrimmedString(aggregateState.responseId) + || asTrimmedString(streamContext.id) + || asTrimmedString(aggregateState.modelName), + ), + object: 'response', + created_at: ( + typeof aggregateState.createdAt === 'number' && Number.isFinite(aggregateState.createdAt) + ? aggregateState.createdAt + : streamContext.created + ) || Math.floor(Date.now() / 1000), + status: terminalStatus, + model: asTrimmedString(streamContext.model) || asTrimmedString(aggregateState.modelName), + output, + output_text: collectResponsesOutputText({ output }), + usage: usageWithExtras, + }; +} + +function enrichTerminalPayload( + payload: Record, + aggregateState: ReturnType, + streamContext: ReturnType, + usage: ReturnType, + terminalStatus: ResponsesTerminalStatus, +): Record { + const next = structuredClone(payload); + const materialized = materializeTerminalPayloadFromAggregate( + aggregateState, + streamContext, + usage, + terminalStatus, + ); + + if (!hasMeaningfulResponsesOutput(next.output) && materialized && hasMeaningfulResponsesOutput(materialized.output)) { + next.output = materialized.output; + } + + const currentOutputText = typeof next.output_text === 'string' ? next.output_text : ''; + if (!currentOutputText) { + const derivedOutputText = collectResponsesOutputText(next) + || (materialized && typeof materialized.output_text === 'string' ? materialized.output_text : ''); + if (derivedOutputText) { + next.output_text = derivedOutputText; + } + } + + if (materialized && next.usage === undefined && materialized.usage !== undefined) { + next.usage = materialized.usage; + } + + return next; +} + +function mergeMissingResponsesTerminalFields( + payload: Record, + fallbackPayload: Record | null, +): Record { + if (!fallbackPayload) return payload; + const merged = { ...payload }; + if (merged.output === undefined && fallbackPayload.output !== undefined) { + merged.output = fallbackPayload.output; + } + if ( + (typeof merged.output_text !== 'string' || merged.output_text.length === 0) + && typeof fallbackPayload.output_text === 'string' + && fallbackPayload.output_text.length > 0 + ) { + merged.output_text = fallbackPayload.output_text; + } + if (merged.object === undefined && fallbackPayload.object !== undefined) { + merged.object = fallbackPayload.object; + } + if (merged.created_at === undefined && fallbackPayload.created_at !== undefined) { + merged.created_at = fallbackPayload.created_at; + } + if (merged.usage === undefined && fallbackPayload.usage !== undefined) { + merged.usage = fallbackPayload.usage; + } + return merged; +} + +export function looksLikeResponsesSseText(rawText: string): boolean { + const { events, rest } = openAiResponsesTransformer.pullSseEvents(rawText); + if (events.length === 0 || rest.trim().length > 0) return false; + return events.some((event) => { + if (event.data === '[DONE]') return true; + if (event.event === 'error' || event.event.startsWith('response.')) return true; + const payload = parseResponsesSsePayload(event.data); + const payloadType = typeof payload?.type === 'string' ? payload.type : ''; + return payloadType === 'error' || payloadType.startsWith('response.'); + }); +} + +export function createSingleChunkStreamReader(rawText: string): { + read(): Promise<{ done: boolean; value?: Uint8Array }>; + cancel(reason?: unknown): Promise; + releaseLock(): void; +} { + const chunk = Buffer.from(rawText, 'utf8'); + let done = false; + return { + async read() { + if (done) return { done: true }; + done = true; + return { done: false, value: chunk }; + }, + async cancel() { + done = true; + return undefined; + }, + releaseLock() {}, + }; +} + +export function collectResponsesFinalPayloadFromSseText( + rawText: string, + modelName: string, +): { payload: Record; rawText: string } { + const { events } = openAiResponsesTransformer.pullSseEvents(rawText); + const streamContext = openAiResponsesTransformer.createStreamContext(modelName); + const aggregateState = openAiResponsesTransformer.aggregator.createState(modelName); + let usage = { + promptTokens: 0, + completionTokens: 0, + totalTokens: 0, + cacheReadTokens: 0, + cacheCreationTokens: 0, + promptTokensIncludeCache: null as boolean | null, + }; + let completedPayload: Record | null = null; + let terminalStatus: ResponsesTerminalStatus = 'completed'; + + const captureCompletedPayloadFromEvent = ( + eventType: string, + payload: Record, + ) => { + if (completedPayload) return; + if (eventType === 'response.failed' || eventType === 'error') { + throw new Error(getResponsesFailureMessage(payload)); + } + if (eventType !== 'response.completed' && eventType !== 'response.incomplete') { + return; + } + terminalStatus = eventType === 'response.incomplete' ? 'incomplete' : 'completed'; + if (isRecord(payload.response) && hasCompleteFinalResponsesPayload(payload.response)) { + completedPayload = payload.response; + return; + } + if (hasCompleteFinalResponsesPayload(payload)) { + completedPayload = payload; + } + }; + + const captureCompletedPayloadFromLines = (lines: string[]) => { + if (completedPayload) return; + const parsed = openAiResponsesTransformer.pullSseEvents(lines.join('')); + for (const event of parsed.events) { + if (event.data === '[DONE]') continue; + const payload = parseResponsesSsePayload(event.data); + if (!payload) continue; + const payloadType = typeof payload.type === 'string' ? payload.type : ''; + captureCompletedPayloadFromEvent(payloadType || event.event, payload); + if (completedPayload) { + return; + } + } + }; + + for (const event of events) { + if (event.data === '[DONE]') continue; + const payload = parseResponsesSsePayload(event.data); + if (!payload) continue; + + const payloadType = typeof payload.type === 'string' ? payload.type : ''; + const eventType = payloadType || event.event; + rememberStreamResponseEnvelope(streamContext, payload); + usage = mergeProxyUsage(usage, parseProxyUsage(payload)); + captureCompletedPayloadFromEvent(eventType, payload); + if (completedPayload) { + continue; + } + const normalizedEvent = openAiResponsesTransformer.transformStreamEvent( + payload, + streamContext, + modelName, + ); + captureCompletedPayloadFromLines(openAiResponsesTransformer.aggregator.serialize({ + state: aggregateState, + streamContext, + event: normalizedEvent, + usage, + })); + } + + if ( + completedPayload + && !hasMeaningfulFinalResponsesPayload(completedPayload) + && hasMeaningfulResponsesOutput(aggregateState.outputItems) + ) { + completedPayload = mergeMissingResponsesTerminalFields( + completedPayload, + materializeTerminalPayloadFromAggregate( + aggregateState, + streamContext, + usage, + terminalStatus, + ), + ); + } + + if (completedPayload) { + completedPayload = mergeMissingResponsesTerminalFields( + completedPayload, + materializeTerminalPayloadFromAggregate( + aggregateState, + streamContext, + usage, + terminalStatus, + ), + ); + } + + if (!completedPayload) { + const materialized = materializeTerminalPayloadFromAggregate( + aggregateState, + streamContext, + usage, + terminalStatus, + ); + if (materialized) { + completedPayload = materialized; + } + } + + if (completedPayload) { + return { + payload: enrichTerminalPayload( + completedPayload, + aggregateState, + streamContext, + usage, + terminalStatus, + ), + rawText, + }; + } + + throw new Error('stream disconnected before terminal responses event'); +} + +export async function collectResponsesFinalPayloadFromSse( + upstream: { text(): Promise }, + modelName: string, +): Promise<{ payload: Record; rawText: string }> { + return collectResponsesFinalPayloadFromSseText(await upstream.text(), modelName); +} diff --git a/src/server/routes/proxy/responsesWebsocket.ts b/src/server/routes/proxy/responsesWebsocket.ts new file mode 100644 index 00000000..08470bdc --- /dev/null +++ b/src/server/routes/proxy/responsesWebsocket.ts @@ -0,0 +1,743 @@ +import { randomUUID } from 'node:crypto'; +import type { FastifyInstance } from 'fastify'; +import type { IncomingMessage } from 'node:http'; +import type { Duplex } from 'node:stream'; +import { WebSocketServer, type RawData, type WebSocket } from 'ws'; +import { createCodexWebsocketRuntime, CodexWebsocketRuntimeError } from '../../proxy-core/runtime/codexWebsocketRuntime.js'; +import { + authorizeDownstreamToken, + consumeManagedKeyRequest, + isModelAllowedByPolicyOrAllowedRoutes, + type DownstreamTokenAuthSuccess, +} from '../../services/downstreamApiKeyService.js'; +import { tokenRouter } from '../../services/tokenRouter.js'; +import { buildOauthProviderHeaders } from '../../services/oauth/service.js'; +import { openAiResponsesTransformer } from '../../transformers/openai/responses/index.js'; +import { buildUpstreamEndpointRequest } from './upstreamEndpoint.js'; +import { config } from '../../config.js'; + +const installedApps = new WeakSet(); +const WS_TURN_STATE_HEADER = 'x-codex-turn-state'; +const RESPONSES_WEBSOCKET_MODE_HEADER = 'x-metapi-responses-websocket-mode'; +const RESPONSES_WEBSOCKET_TRANSPORT_HEADER = 'x-metapi-responses-websocket-transport'; +const codexWebsocketRuntime = createCodexWebsocketRuntime(); + +type SelectedChannel = NonNullable>>; +type ResponsesWebsocketAuthContext = DownstreamTokenAuthSuccess; + +type NormalizedResponsesWebsocketRequest = + | { + ok: true; + request: Record; + nextRequestSnapshot: Record; + } + | { + ok: false; + status: number; + message: string; + }; + +function isRecord(value: unknown): value is Record { + return !!value && typeof value === 'object' && !Array.isArray(value); +} + +function asTrimmedString(value: unknown): string { + return typeof value === 'string' ? value.trim() : ''; +} + +function headerValueToTrimmedString(value: unknown): string { + if (typeof value === 'string') return value.trim(); + if (Array.isArray(value)) { + for (const item of value) { + if (typeof item !== 'string') continue; + const trimmed = item.trim(); + if (trimmed) return trimmed; + } + } + return ''; +} + +function toBooleanLike(value: unknown): boolean | null { + if (typeof value === 'boolean') return value; + if (typeof value === 'string') { + const normalized = value.trim().toLowerCase(); + if (normalized === 'true' || normalized === '1' || normalized === 'yes' || normalized === 'on') return true; + if (normalized === 'false' || normalized === '0' || normalized === 'no' || normalized === 'off') return false; + } + return null; +} + +function parseExtraConfigRecord(extraConfig: unknown): Record | null { + if (isRecord(extraConfig)) return extraConfig; + if (typeof extraConfig !== 'string') return null; + try { + const parsed = JSON.parse(extraConfig); + return isRecord(parsed) ? parsed : null; + } catch { + return null; + } +} + +function readNestedRecord(value: unknown, key: string): Record | null { + if (!isRecord(value)) return null; + const nested = value[key]; + return isRecord(nested) ? nested : null; +} +function selectedChannelModelMatches( + selectedChannel: SelectedChannel | null, + requestModel: string, +): boolean { + if (!selectedChannel) return false; + const selectedModel = asTrimmedString(selectedChannel.actualModel).toLowerCase(); + const normalizedRequestModel = asTrimmedString(requestModel).toLowerCase(); + if (!selectedModel || !normalizedRequestModel) return true; + return selectedModel === normalizedRequestModel; +} + +function selectedChannelSupportsCodexWebsocketTransport( + selectedChannel: SelectedChannel | null, + requestModel: string, +): boolean { + if (!selectedChannel) return false; + const platform = asTrimmedString(selectedChannel.site?.platform).toLowerCase(); + if (platform !== 'codex') return false; + if (!selectedChannelModelMatches(selectedChannel, requestModel)) return false; + if (!config.codexUpstreamWebsocketEnabled) return false; + + const extraConfig = parseExtraConfigRecord(selectedChannel.account.extraConfig); + const oauth = readNestedRecord(extraConfig, 'oauth'); + const providerData = readNestedRecord(oauth, 'providerData'); + const candidateFlags = [ + extraConfig?.websockets, + readNestedRecord(extraConfig, 'attributes')?.websockets, + readNestedRecord(extraConfig, 'metadata')?.websockets, + providerData?.websockets, + readNestedRecord(providerData, 'attributes')?.websockets, + readNestedRecord(providerData, 'metadata')?.websockets, + ]; + for (const candidate of candidateFlags) { + const parsed = toBooleanLike(candidate); + if (parsed !== null) return parsed; + } + return true; +} + +function selectedChannelSupportsIncrementalInput( + selectedChannel: SelectedChannel | null, + requestModel: string, +): boolean { + return selectedChannelSupportsCodexWebsocketTransport(selectedChannel, requestModel); +} + +function shouldReuseSelectedChannel( + selectedChannel: SelectedChannel | null, + requestModel: string, +): boolean { + if (!selectedChannel) return false; + const selectedModel = asTrimmedString(selectedChannel.actualModel).toLowerCase(); + const normalizedRequestModel = asTrimmedString(requestModel).toLowerCase(); + if (!selectedModel || !normalizedRequestModel) return true; + return selectedModel === normalizedRequestModel; +} + +function deriveCodexExplicitSessionId(body: Record, sessionId: string): string { + void body; + return sessionId; +} + +function parseJsonObject(raw: RawData): Record | null { + try { + const parsed = JSON.parse(String(raw)); + return isRecord(parsed) ? parsed : null; + } catch { + return null; + } +} + +function cloneJsonObject(value: T): T { + return structuredClone(value); +} + +function toResponseInputArray(value: unknown): unknown[] { + return Array.isArray(value) ? cloneJsonObject(value) : []; +} + +function normalizeResponsesWebsocketRequest( + parsed: Record, + lastRequest: Record | null, + lastResponseOutput: unknown[], + supportsIncrementalInput: boolean, +): NormalizedResponsesWebsocketRequest { + const requestType = asTrimmedString(parsed.type); + if (requestType !== 'response.create' && requestType !== 'response.append') { + return { + ok: false, + status: 400, + message: `unsupported websocket request type: ${requestType || 'unknown'}`, + }; + } + + if (!lastRequest) { + if (requestType !== 'response.create') { + return { + ok: false, + status: 400, + message: 'websocket request received before response.create', + }; + } + const next = cloneJsonObject(parsed); + delete next.type; + if (!supportsIncrementalInput && parsed.generate === false) { + delete next.generate; + } + next.stream = true; + if (!Array.isArray(next.input)) next.input = []; + const modelName = asTrimmedString(next.model); + if (!modelName) { + return { + ok: false, + status: 400, + message: 'missing model in response.create request', + }; + } + return { + ok: true, + request: next, + nextRequestSnapshot: cloneJsonObject(next), + }; + } + + if (!Array.isArray(parsed.input)) { + return { + ok: false, + status: 400, + message: 'websocket request requires array field: input', + }; + } + + const next = cloneJsonObject(parsed); + delete next.type; + next.stream = true; + if (!('model' in next) && typeof lastRequest.model === 'string') { + next.model = lastRequest.model; + } + if (!('instructions' in next) && lastRequest.instructions !== undefined) { + next.instructions = cloneJsonObject(lastRequest.instructions); + } + + if (supportsIncrementalInput && requestType === 'response.create' && asTrimmedString(parsed.previous_response_id)) { + return { + ok: true, + request: next, + nextRequestSnapshot: cloneJsonObject(next), + }; + } + + const mergedInput = [ + ...toResponseInputArray(lastRequest.input), + ...cloneJsonObject(lastResponseOutput), + ...cloneJsonObject(parsed.input), + ]; + delete next.previous_response_id; + next.input = mergedInput; + + return { + ok: true, + request: next, + nextRequestSnapshot: cloneJsonObject(next), + }; +} + +function shouldHandleResponsesWebsocketPrewarmLocally( + parsed: Record, + lastRequest: Record | null, + supportsIncrementalInput: boolean, +): boolean { + if (supportsIncrementalInput || lastRequest) return false; + if (asTrimmedString(parsed.type) !== 'response.create') return false; + return parsed.generate === false; +} + +function writeResponsesWebsocketError( + socket: WebSocket, + status: number, + message: string, + errorPayload?: unknown, +) { + socket.send(JSON.stringify({ + type: 'error', + status, + error: isRecord(errorPayload) && isRecord(errorPayload.error) + ? errorPayload.error + : { + type: status >= 500 ? 'server_error' : 'invalid_request_error', + message, + }, + })); +} + +function synthesizePrewarmResponsePayloads(request: Record) { + const responseId = `resp_prewarm_${randomUUID()}`; + const modelName = asTrimmedString(request.model) || 'unknown'; + const createdAt = Math.floor(Date.now() / 1000); + return [ + { + type: 'response.created', + response: { + id: responseId, + object: 'response', + created_at: createdAt, + status: 'in_progress', + model: modelName, + output: [], + }, + }, + { + type: 'response.completed', + response: { + id: responseId, + object: 'response', + created_at: createdAt, + status: 'completed', + model: modelName, + output: [], + usage: { + input_tokens: 0, + output_tokens: 0, + total_tokens: 0, + }, + }, + }, + ]; +} + +function collectResponsesOutput(payloads: unknown[]): unknown[] { + const outputByIndex = new Map(); + let completedOutput: unknown[] | null = null; + const fallbackStatusForType = (type: string): string => { + if (type === 'response.completed') return 'completed'; + if (type === 'response.failed') return 'failed'; + return 'incomplete'; + }; + + for (const payload of payloads) { + if (!isRecord(payload)) continue; + const type = asTrimmedString(payload.type); + if ((type === 'response.output_item.added' || type === 'response.output_item.done') + && Number.isInteger(payload.output_index) + && payload.item !== undefined) { + outputByIndex.set(Number(payload.output_index), cloneJsonObject(payload.item)); + continue; + } + if ( + (type === 'response.completed' || type === 'response.incomplete' || type === 'response.failed') + && isRecord(payload.response) + && Array.isArray(payload.response.output) + ) { + const terminalOutput = cloneJsonObject(payload.response.output); + if (terminalOutput.length > 0 || outputByIndex.size === 0) { + completedOutput = terminalOutput; + } + continue; + } + if ( + (type === 'response.completed' || type === 'response.incomplete' || type === 'response.failed') + && isRecord(payload.response) + && typeof payload.response.output_text === 'string' + && payload.response.output_text.trim() + ) { + completedOutput = [{ + id: `msg_${asTrimmedString(payload.response.id) || type}`, + type: 'message', + role: 'assistant', + status: asTrimmedString(payload.response.status) || fallbackStatusForType(type), + content: [{ + type: 'output_text', + text: payload.response.output_text, + }], + }]; + continue; + } + if (Array.isArray(payload.output)) { + const terminalOutput = cloneJsonObject(payload.output); + if (terminalOutput.length > 0 || outputByIndex.size === 0) { + completedOutput = terminalOutput; + } + continue; + } + if (typeof payload.output_text === 'string' && payload.output_text.trim()) { + const fallbackStatus = asTrimmedString(payload.status) || fallbackStatusForType(type || 'response.completed'); + completedOutput = [{ + id: `msg_${type || 'response'}`, + type: 'message', + role: 'assistant', + status: fallbackStatus, + content: [{ + type: 'output_text', + text: payload.output_text, + }], + }]; + } + } + + if (completedOutput) return completedOutput; + return [...outputByIndex.entries()] + .sort((left, right) => left[0] - right[0]) + .map(([, value]) => value); +} + +async function forwardResponsesRequestViaHttp(input: { + app: FastifyInstance; + socket: WebSocket; + request: IncomingMessage; + payload: Record; + preserveIncrementalMode: boolean; + authToken: string; +}): Promise { + const injectHeaders: Record = { + ...buildInjectHeaders(input.request), + [RESPONSES_WEBSOCKET_TRANSPORT_HEADER]: '1', + ...(input.preserveIncrementalMode ? { [RESPONSES_WEBSOCKET_MODE_HEADER]: 'incremental' } : {}), + }; + if ( + !headerValueToTrimmedString(injectHeaders.authorization) + && !headerValueToTrimmedString(injectHeaders['x-api-key']) + && !headerValueToTrimmedString(injectHeaders['x-goog-api-key']) + ) { + injectHeaders.authorization = `Bearer ${input.authToken}`; + } + + const response = await input.app.inject({ + method: 'POST', + url: '/v1/responses', + headers: injectHeaders, + payload: input.payload, + }); + + if (response.statusCode < 200 || response.statusCode >= 300) { + let payload: unknown = null; + try { + payload = JSON.parse(response.body); + } catch { + payload = null; + } + writeResponsesWebsocketError( + input.socket, + response.statusCode, + response.statusMessage || 'Upstream error', + payload, + ); + return null; + } + + const contentType = String(response.headers['content-type'] || '').toLowerCase(); + if (!contentType.includes('text/event-stream')) { + try { + const payload = JSON.parse(response.body); + const output = collectResponsesOutput([payload]); + input.socket.send(JSON.stringify(payload)); + return output; + } catch { + writeResponsesWebsocketError(input.socket, 502, 'Unexpected non-JSON websocket proxy response'); + return null; + } + } + + const pulled = openAiResponsesTransformer.pullSseEvents(response.body); + const forwardedPayloads: unknown[] = []; + let sawTerminalPayload = false; + for (const event of pulled.events) { + if (event.data === '[DONE]') continue; + try { + const payload = JSON.parse(event.data); + forwardedPayloads.push(payload); + const type = isRecord(payload) ? asTrimmedString(payload.type) : ''; + if (type === 'response.completed' || type === 'response.failed' || type === 'response.incomplete') { + sawTerminalPayload = true; + } + input.socket.send(JSON.stringify(payload)); + } catch { + // Ignore malformed SSE frames; the HTTP route already normalizes them. + } + } + if (!sawTerminalPayload) { + writeResponsesWebsocketError(input.socket, 408, 'stream closed before response.completed'); + } + return collectResponsesOutput(forwardedPayloads); +} + +function buildInjectHeaders(request: IncomingMessage): Record { + const headers: Record = {}; + for (const [rawKey, rawValue] of Object.entries(request.headers)) { + const key = rawKey.toLowerCase(); + if (!rawValue) continue; + if ( + key === 'host' + || key === 'connection' + || key === 'upgrade' + || key === 'sec-websocket-key' + || key === 'sec-websocket-version' + || key === 'sec-websocket-extensions' + || key === 'sec-websocket-protocol' + ) { + continue; + } + headers[rawKey] = rawValue as string | string[]; + } + return headers; +} + +function extractWebsocketAuthToken(request: IncomingMessage, url: URL): string { + const auth = headerValueToTrimmedString(request.headers.authorization); + if (auth) return auth.replace(/^Bearer\s+/i, '').trim(); + const apiKey = headerValueToTrimmedString(request.headers['x-api-key']); + if (apiKey) return apiKey; + const googApiKey = headerValueToTrimmedString(request.headers['x-goog-api-key']); + if (googApiKey) return googApiKey; + return asTrimmedString(url.searchParams.get('key')); +} + +function writeUpgradeHttpError(socket: Duplex, status: number, message: string): void { + const statusText = status === 401 + ? 'Unauthorized' + : status === 403 + ? 'Forbidden' + : status === 400 + ? 'Bad Request' + : 'Error'; + const body = JSON.stringify({ error: message }); + socket.end( + `HTTP/1.1 ${status} ${statusText}\r\n` + + 'Content-Type: application/json\r\n' + + `Content-Length: ${Buffer.byteLength(body)}\r\n` + + 'Connection: close\r\n' + + '\r\n' + + body, + ); +} + +async function supportsResponsesWebsocketIncrementalInput( + parsed: Record, + lastRequest: Record | null, + authContext: ResponsesWebsocketAuthContext, +): Promise { + const requestModel = asTrimmedString(parsed.model) || asTrimmedString(lastRequest?.model); + if (!requestModel) return false; + + try { + const selected = await tokenRouter.previewSelectedChannel(requestModel, authContext.policy); + return selectedChannelSupportsIncrementalInput(selected, requestModel); + } catch { + return false; + } +} + +async function handleResponsesWebsocketConnection( + app: FastifyInstance, + socket: WebSocket, + request: IncomingMessage, + authContext: ResponsesWebsocketAuthContext, +) { + const websocketSessionId = headerValueToTrimmedString(request.headers['session_id']) + || headerValueToTrimmedString(request.headers['session-id']) + || randomUUID(); + let lastRequest: Record | null = null; + let lastResponseOutput: unknown[] = []; + let selectedChannel: SelectedChannel | null = null; + let messageQueue = Promise.resolve(); + + socket.once('close', () => { + void codexWebsocketRuntime.closeSession(websocketSessionId); + }); + + socket.on('message', (raw) => { + messageQueue = messageQueue + .catch(() => undefined) + .then(async () => { + try { + const parsed = parseJsonObject(raw); + if (!parsed) { + writeResponsesWebsocketError(socket, 400, 'Invalid websocket JSON payload'); + return; + } + + const requestModel = asTrimmedString(parsed.model) || asTrimmedString(lastRequest?.model); + if (requestModel && !await isModelAllowedByPolicyOrAllowedRoutes(requestModel, authContext.policy)) { + writeResponsesWebsocketError(socket, 403, 'model is not allowed for this downstream key'); + return; + } + const supportsIncrementalInput = selectedChannelSupportsIncrementalInput(selectedChannel, requestModel) + || await supportsResponsesWebsocketIncrementalInput(parsed, lastRequest, authContext); + const shouldHandleLocalPrewarm = shouldHandleResponsesWebsocketPrewarmLocally( + parsed, + lastRequest, + supportsIncrementalInput, + ); + const normalized = normalizeResponsesWebsocketRequest( + parsed, + lastRequest, + lastResponseOutput, + supportsIncrementalInput, + ); + if (!normalized.ok) { + writeResponsesWebsocketError(socket, normalized.status, normalized.message); + return; + } + + if (authContext.source === 'managed' && authContext.key?.id) { + await consumeManagedKeyRequest(authContext.key.id); + } + + lastRequest = normalized.nextRequestSnapshot; + if (shouldHandleLocalPrewarm) { + lastResponseOutput = []; + for (const payload of synthesizePrewarmResponsePayloads(normalized.request)) { + socket.send(JSON.stringify(payload)); + } + return; + } + + if (!shouldReuseSelectedChannel(selectedChannel, requestModel)) { + selectedChannel = requestModel + ? await tokenRouter.selectChannel(requestModel, authContext.policy) + : null; + } + + const codexWebsocketChannel = selectedChannelSupportsCodexWebsocketTransport(selectedChannel, requestModel) + ? selectedChannel + : null; + + if (codexWebsocketChannel) { + const downstreamHeaders: Record = { + ...(request.headers as Record), + [RESPONSES_WEBSOCKET_TRANSPORT_HEADER]: '1', + ...(supportsIncrementalInput ? { [RESPONSES_WEBSOCKET_MODE_HEADER]: 'incremental' } : {}), + }; + const providerHeaders = buildOauthProviderHeaders({ + account: codexWebsocketChannel.account, + downstreamHeaders, + }); + const prepared = buildUpstreamEndpointRequest({ + endpoint: 'responses', + modelName: asTrimmedString(codexWebsocketChannel.actualModel) || requestModel, + stream: true, + tokenValue: codexWebsocketChannel.tokenValue, + sitePlatform: codexWebsocketChannel.site.platform, + siteUrl: codexWebsocketChannel.site.url, + openaiBody: normalized.request, + downstreamFormat: 'responses', + responsesOriginalBody: normalized.request, + downstreamHeaders, + providerHeaders, + codexExplicitSessionId: deriveCodexExplicitSessionId(normalized.request, websocketSessionId), + }); + const requestUrl = `${codexWebsocketChannel.site.url.replace(/\/+$/, '')}${prepared.path}`; + + try { + const runtimeResult = await codexWebsocketRuntime.sendRequest({ + sessionId: websocketSessionId, + requestUrl, + headers: prepared.headers, + body: prepared.body, + }); + lastResponseOutput = collectResponsesOutput(runtimeResult.events); + for (const payload of runtimeResult.events) { + socket.send(JSON.stringify(payload)); + } + } catch (error) { + const runtimeError = error instanceof CodexWebsocketRuntimeError + ? error + : new CodexWebsocketRuntimeError('upstream websocket request failed'); + if (runtimeError.status && runtimeError.events.length === 0) { + const forwarded = await forwardResponsesRequestViaHttp({ + app, + socket, + request, + payload: normalized.request, + preserveIncrementalMode: supportsIncrementalInput, + authToken: authContext.token, + }); + if (forwarded) { + lastResponseOutput = forwarded; + } + return; + } + lastResponseOutput = collectResponsesOutput(runtimeError.events); + for (const payload of runtimeError.events) { + socket.send(JSON.stringify(payload)); + } + const emittedTerminalResponsesEvent = runtimeError.events.some((payload) => { + if (!isRecord(payload)) return false; + const type = asTrimmedString(payload.type); + return type === 'response.completed' || type === 'response.failed' || type === 'response.incomplete'; + }); + if (!emittedTerminalResponsesEvent) { + writeResponsesWebsocketError( + socket, + runtimeError.status || 408, + runtimeError.message, + runtimeError.payload, + ); + } + } + return; + } + + const forwarded = await forwardResponsesRequestViaHttp({ + app, + socket, + request, + payload: normalized.request, + preserveIncrementalMode: supportsIncrementalInput, + authToken: authContext.token, + }); + if (forwarded) { + lastResponseOutput = forwarded; + } + } catch { + writeResponsesWebsocketError(socket, 500, 'internal websocket proxy error'); + } + }); + }); +} + +export function ensureResponsesWebsocketTransport(app: FastifyInstance) { + if (installedApps.has(app)) return; + installedApps.add(app); + + const websocketServer = new WebSocketServer({ noServer: true }); + websocketServer.on('headers', (headers, request) => { + const turnState = headerValueToTrimmedString(request.headers[WS_TURN_STATE_HEADER]); + if (!turnState) return; + headers.push(`${WS_TURN_STATE_HEADER}: ${turnState}`); + }); + + app.server.on('upgrade', (request, socket, head) => { + void (async () => { + const url = new URL(request.url || '/', 'http://localhost'); + if (url.pathname !== '/v1/responses') return; + const token = extractWebsocketAuthToken(request, url); + if (!token) { + writeUpgradeHttpError(socket, 401, 'Missing Authorization, x-api-key, x-goog-api-key, or key query parameter'); + return; + } + const authResult = await authorizeDownstreamToken(token); + if (!authResult.ok) { + writeUpgradeHttpError(socket, authResult.statusCode, authResult.error); + return; + } + websocketServer.handleUpgrade(request, socket, head, (client) => { + void handleResponsesWebsocketConnection(app, client, request, authResult); + }); + })().catch(() => { + writeUpgradeHttpError(socket, 500, 'internal websocket proxy error'); + }); + }); + + app.addHook('onClose', async () => { + await codexWebsocketRuntime.closeAllSessions(); + await new Promise((resolve) => { + websocketServer.close(() => resolve()); + }); + }); +} diff --git a/src/server/routes/proxy/runtimeExecutor.test.ts b/src/server/routes/proxy/runtimeExecutor.test.ts new file mode 100644 index 00000000..eb839394 --- /dev/null +++ b/src/server/routes/proxy/runtimeExecutor.test.ts @@ -0,0 +1,225 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { fetch } from 'undici'; +import type { BuiltEndpointRequest } from './endpointFlow.js'; + +vi.mock('undici', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + fetch: vi.fn(), + }; +}); + +const fetchMock = vi.mocked(fetch); + +describe('dispatchRuntimeRequest', () => { + beforeEach(() => { + fetchMock.mockReset(); + }); + + it('routes antigravity runtime requests through daily then sandbox base urls and rewrites the payload fingerprint', async () => { + const { dispatchRuntimeRequest } = await import('./runtimeExecutor.js'); + const request: BuiltEndpointRequest = { + endpoint: 'chat', + path: '/v1internal:generateContent', + headers: { + Authorization: 'Bearer antigravity-token', + 'Content-Type': 'application/json', + 'User-Agent': 'google-api-nodejs-client/9.15.1', + }, + body: { + project: 'project-demo', + model: 'gemini-3-pro-preview', + request: { + contents: [ + { + role: 'user', + parts: [{ text: 'hello runtime executor' }], + }, + ], + }, + }, + runtime: { + executor: 'antigravity', + modelName: 'gemini-3-pro-preview', + stream: false, + }, + }; + + fetchMock + .mockResolvedValueOnce(new Response(JSON.stringify({ + error: { message: 'try fallback base url' }, + }), { + status: 429, + headers: { 'content-type': 'application/json' }, + }) as unknown as Awaited>) + .mockResolvedValueOnce(new Response(JSON.stringify({ + response: { responseId: 'ok' }, + }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }) as unknown as Awaited>); + + const response = await dispatchRuntimeRequest({ + siteUrl: 'https://cloudcode-pa.googleapis.com', + request, + buildInit: (_url, nextRequest) => ({ + method: 'POST', + headers: nextRequest.headers, + body: JSON.stringify(nextRequest.body), + }), + }); + + expect(response.ok).toBe(true); + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(fetchMock.mock.calls[0]?.[0]).toBe('https://daily-cloudcode-pa.googleapis.com/v1internal:generateContent'); + expect(fetchMock.mock.calls[1]?.[0]).toBe('https://daily-cloudcode-pa.sandbox.googleapis.com/v1internal:generateContent'); + + const firstInit = fetchMock.mock.calls[0]?.[1] as RequestInit; + expect(firstInit.headers).toMatchObject({ + Authorization: 'Bearer antigravity-token', + 'Content-Type': 'application/json', + Accept: 'application/json', + 'User-Agent': 'antigravity/1.19.6 darwin/arm64', + }); + + const upstreamBody = JSON.parse(String(firstInit.body)); + expect(upstreamBody).toMatchObject({ + project: 'project-demo', + model: 'gemini-3-pro-preview', + userAgent: 'antigravity', + requestType: 'agent', + request: { + sessionId: expect.any(String), + contents: [ + { + role: 'user', + parts: [{ text: 'hello runtime executor' }], + }, + ], + }, + }); + expect(upstreamBody.requestId).toMatch(/^agent-[0-9a-f-]{36}$/i); + }); + + it('keeps gemini-cli countTokens payload lean while forcing a model-aware user agent', async () => { + const { dispatchRuntimeRequest } = await import('./runtimeExecutor.js'); + const request: BuiltEndpointRequest = { + endpoint: 'chat', + path: '/v1internal:countTokens', + headers: { + Authorization: 'Bearer gemini-cli-token', + 'Content-Type': 'application/json', + 'User-Agent': 'GeminiCLI/0.31.0/unknown (win32; x64)', + 'X-Goog-Api-Client': 'google-genai-sdk/1.41.0 gl-node/v22.19.0', + }, + body: { + project: 'project-demo', + model: 'gemini-2.5-pro', + request: { + contents: [ + { + role: 'user', + parts: [{ text: 'count these tokens' }], + }, + ], + }, + }, + runtime: { + executor: 'gemini-cli', + modelName: 'gemini-2.5-pro', + stream: false, + action: 'countTokens', + }, + }; + + fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({ + totalTokens: 12, + }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }) as unknown as Awaited>); + + const response = await dispatchRuntimeRequest({ + siteUrl: 'https://cloudcode-pa.googleapis.com', + request, + buildInit: (_url, nextRequest) => ({ + method: 'POST', + headers: nextRequest.headers, + body: JSON.stringify(nextRequest.body), + }), + }); + + expect(response.ok).toBe(true); + const requestInit = fetchMock.mock.calls[0]?.[1] as RequestInit; + expect(requestInit.headers).toMatchObject({ + Authorization: 'Bearer gemini-cli-token', + 'Content-Type': 'application/json', + 'User-Agent': 'GeminiCLI/0.31.0/gemini-2.5-pro (win32; x64)', + 'X-Goog-Api-Client': 'google-genai-sdk/1.41.0 gl-node/v22.19.0', + }); + expect(JSON.parse(String(requestInit.body))).toEqual({ + request: { + contents: [ + { + role: 'user', + parts: [{ text: 'count these tokens' }], + }, + ], + }, + }); + }); + + it('retries antigravity runtime requests on transport errors before falling back to the next base url', async () => { + const { dispatchRuntimeRequest } = await import('./runtimeExecutor.js'); + const request: BuiltEndpointRequest = { + endpoint: 'chat', + path: '/v1internal:generateContent', + headers: { + Authorization: 'Bearer antigravity-token', + 'Content-Type': 'application/json', + }, + body: { + project: 'project-demo', + model: 'gemini-3-pro-preview', + request: { + contents: [ + { + role: 'user', + parts: [{ text: 'hello runtime executor' }], + }, + ], + }, + }, + runtime: { + executor: 'antigravity', + modelName: 'gemini-3-pro-preview', + stream: false, + }, + }; + + fetchMock + .mockRejectedValueOnce(new Error('socket hang up')) + .mockResolvedValueOnce(new Response(JSON.stringify({ + response: { responseId: 'ok' }, + }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }) as unknown as Awaited>); + + const response = await dispatchRuntimeRequest({ + siteUrl: 'https://cloudcode-pa.googleapis.com', + request, + buildInit: (_url, nextRequest) => ({ + method: 'POST', + headers: nextRequest.headers, + body: JSON.stringify(nextRequest.body), + }), + }); + + expect(response.ok).toBe(true); + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(fetchMock.mock.calls[0]?.[0]).toBe('https://daily-cloudcode-pa.googleapis.com/v1internal:generateContent'); + expect(fetchMock.mock.calls[1]?.[0]).toBe('https://daily-cloudcode-pa.sandbox.googleapis.com/v1internal:generateContent'); + }); +}); diff --git a/src/server/routes/proxy/runtimeExecutor.ts b/src/server/routes/proxy/runtimeExecutor.ts new file mode 100644 index 00000000..80d8a98a --- /dev/null +++ b/src/server/routes/proxy/runtimeExecutor.ts @@ -0,0 +1,24 @@ +import { antigravityExecutor } from '../../proxy-core/executors/antigravityExecutor.js'; +import { claudeExecutor } from '../../proxy-core/executors/claudeExecutor.js'; +import { codexExecutor } from '../../proxy-core/executors/codexExecutor.js'; +import { geminiCliExecutor } from '../../proxy-core/executors/geminiCliExecutor.js'; +import type { RuntimeDispatchInput, RuntimeResponse } from '../../proxy-core/executors/types.js'; + +export async function dispatchRuntimeRequest( + input: RuntimeDispatchInput, +): Promise { + const executor = input.request.runtime?.executor || 'default'; + if (executor === 'codex') { + return codexExecutor.dispatch(input); + } + if (executor === 'claude') { + return claudeExecutor.dispatch(input); + } + if (executor === 'gemini-cli') { + return geminiCliExecutor.dispatch(input); + } + if (executor === 'antigravity') { + return antigravityExecutor.dispatch(input); + } + return codexExecutor.dispatch(input); +} diff --git a/src/server/routes/proxy/search.test.ts b/src/server/routes/proxy/search.test.ts index ebfc17ad..a6182577 100644 --- a/src/server/routes/proxy/search.test.ts +++ b/src/server/routes/proxy/search.test.ts @@ -54,6 +54,9 @@ vi.mock('../../db/index.js', () => ({ db: { insert: (arg: any) => dbInsertMock(arg), }, + hasProxyLogBillingDetailsColumn: async () => false, + hasProxyLogClientColumns: async () => false, + hasProxyLogDownstreamApiKeyIdColumn: async () => false, schema: { proxyLogs: {}, }, @@ -126,6 +129,36 @@ describe('/v1/search route', () => { }); }); + it('keeps returning a successful search response when channel success bookkeeping fails', async () => { + fetchMock.mockResolvedValue(new Response(JSON.stringify({ + object: 'search.result', + data: [{ title: 'AxonHub' }], + }), { + status: 200, + headers: { 'content-type': 'application/json' }, + })); + recordSuccessMock.mockRejectedValueOnce(new Error('record success failed')); + + const response = await app.inject({ + method: 'POST', + url: '/v1/search', + headers: { + authorization: 'Bearer sk-demo', + }, + payload: { + query: 'axonhub', + }, + }); + + expect(response.statusCode).toBe(200); + expect(response.json()).toEqual({ + object: 'search.result', + data: [{ title: 'AxonHub' }], + }); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(selectNextChannelMock).not.toHaveBeenCalled(); + }); + it('rejects max_results outside the allowed range', async () => { const response = await app.inject({ method: 'POST', diff --git a/src/server/routes/proxy/search.ts b/src/server/routes/proxy/search.ts index 2f04ca92..543b9e13 100644 --- a/src/server/routes/proxy/search.ts +++ b/src/server/routes/proxy/search.ts @@ -1,17 +1,20 @@ import { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'; import { fetch } from 'undici'; -import { db, schema } from '../../db/index.js'; import { tokenRouter } from '../../services/tokenRouter.js'; -import { refreshModelsAndRebuildRoutes } from '../../services/modelService.js'; +import * as routeRefreshWorkflow from '../../services/routeRefreshWorkflow.js'; import { reportProxyAllFailed, reportTokenExpired } from '../../services/alertService.js'; import { isTokenExpiredError } from '../../services/alertRules.js'; import { shouldRetryProxyRequest } from '../../services/proxyRetryPolicy.js'; import { ensureModelAllowedForDownstreamKey, getDownstreamRoutingPolicy, recordDownstreamCostUsage } from './downstreamPolicy.js'; import { withSiteRecordProxyRequestInit } from '../../services/siteProxy.js'; +import { getProxyUrlFromExtraConfig } from '../../services/accountExtraConfig.js'; import { composeProxyLogMessage } from './logPathMeta.js'; import { formatUtcSqlDateTime } from '../../services/localTimeService.js'; - -const MAX_RETRIES = 2; +import { getProxyAuthContext } from '../../middleware/auth.js'; +import { buildUpstreamUrl } from './upstreamUrl.js'; +import { detectDownstreamClientContext, type DownstreamClientContext } from './downstreamClientContext.js'; +import { insertProxyLog } from '../../services/proxyLogStore.js'; +import { canRetryProxyChannel, getProxyMaxChannelRetries } from '../../services/proxyChannelRetry.js'; const DEFAULT_SEARCH_MODEL = '__search'; const DEFAULT_MAX_RESULTS = 10; const MAX_MAX_RESULTS = 20; @@ -51,16 +54,23 @@ export async function searchProxyRoute(app: FastifyInstance) { if (!await ensureModelAllowedForDownstreamKey(request, reply, requestedModel)) return; const downstreamPolicy = getDownstreamRoutingPolicy(request); + const downstreamApiKeyId = getProxyAuthContext(request)?.keyId ?? null; + const downstreamPath = '/v1/search'; + const clientContext = detectDownstreamClientContext({ + downstreamPath, + headers: request.headers as Record, + body, + }); const excludeChannelIds: number[] = []; let retryCount = 0; - while (retryCount <= MAX_RETRIES) { + while (retryCount <= getProxyMaxChannelRetries()) { let selected = retryCount === 0 ? await tokenRouter.selectChannel(requestedModel, downstreamPolicy) : await tokenRouter.selectNextChannel(requestedModel, excludeChannelIds, downstreamPolicy); if (!selected && retryCount === 0) { - await refreshModelsAndRebuildRoutes(); + await routeRefreshWorkflow.refreshModelsAndRebuildRoutes(); selected = await tokenRouter.selectChannel(requestedModel, downstreamPolicy); } @@ -75,11 +85,12 @@ export async function searchProxyRoute(app: FastifyInstance) { } excludeChannelIds.push(selected.channel.id); - const targetUrl = `${selected.site.url}/v1/search`; + const targetUrl = buildUpstreamUrl(selected.site.url, '/v1/search'); + const upstreamModel = selected.actualModel || requestedModel; const forwardBody = { ...body, max_results: maxResults, - model: selected.actualModel || requestedModel, + model: upstreamModel, }; const startTime = Date.now(); @@ -91,12 +102,27 @@ export async function searchProxyRoute(app: FastifyInstance) { Authorization: `Bearer ${selected.tokenValue}`, }, body: JSON.stringify(forwardBody), - })); + }, getProxyUrlFromExtraConfig(selected.account.extraConfig))); const text = await upstream.text(); if (!upstream.ok) { - tokenRouter.recordFailure(selected.channel.id); - logProxy(selected, requestedModel, 'failed', upstream.status, Date.now() - startTime, text, retryCount); + await recordTokenRouterEventBestEffort('record channel failure', () => tokenRouter.recordFailure(selected.channel.id, { + status: upstream.status, + errorText: text, + modelName: upstreamModel, + })); + logProxy( + selected, + requestedModel, + 'failed', + upstream.status, + Date.now() - startTime, + text, + retryCount, + downstreamApiKeyId, + clientContext, + downstreamPath, + ); if (isTokenExpiredError({ status: upstream.status, message: text })) { await reportTokenExpired({ accountId: selected.account.id, @@ -105,7 +131,7 @@ export async function searchProxyRoute(app: FastifyInstance) { detail: `HTTP ${upstream.status}`, }); } - if (shouldRetryProxyRequest(upstream.status, text) && retryCount < MAX_RETRIES) { + if (shouldRetryProxyRequest(upstream.status, text) && canRetryProxyChannel(retryCount)) { retryCount += 1; continue; } @@ -120,14 +146,31 @@ export async function searchProxyRoute(app: FastifyInstance) { try { data = JSON.parse(text); } catch { data = { data: [] }; } const latency = Date.now() - startTime; - tokenRouter.recordSuccess(selected.channel.id, latency, 0); + await recordTokenRouterEventBestEffort('record channel success', () => ( + tokenRouter.recordSuccess(selected.channel.id, latency, 0, upstreamModel) + )); recordDownstreamCostUsage(request, 0); - logProxy(selected, requestedModel, 'success', upstream.status, latency, null, retryCount); + logProxy(selected, requestedModel, 'success', upstream.status, latency, null, retryCount, downstreamApiKeyId, clientContext, downstreamPath); return reply.code(upstream.status).send(data); } catch (error: any) { - tokenRouter.recordFailure(selected.channel.id); - logProxy(selected, requestedModel, 'failed', 0, Date.now() - startTime, error?.message || 'network error', retryCount); - if (retryCount < MAX_RETRIES) { + await recordTokenRouterEventBestEffort('record channel failure', () => tokenRouter.recordFailure(selected.channel.id, { + status: 0, + errorText: error?.message || 'network error', + modelName: upstreamModel, + })); + logProxy( + selected, + requestedModel, + 'failed', + 0, + Date.now() - startTime, + error?.message || 'network error', + retryCount, + downstreamApiKeyId, + clientContext, + downstreamPath, + ); + if (canRetryProxyChannel(retryCount)) { retryCount += 1; continue; } @@ -151,15 +194,19 @@ async function logProxy( latencyMs: number, errorMessage: string | null, retryCount: number, + downstreamApiKeyId: number | null = null, + clientContext: DownstreamClientContext | null = null, + downstreamPath = '/v1/search', ) { try { const createdAt = formatUtcSqlDateTime(new Date()); - await db.insert(schema.proxyLogs).values({ + await insertProxyLog({ routeId: selected.channel.routeId, channelId: selected.channel.id, accountId: selected.account.id, + downstreamApiKeyId, modelRequested, - modelActual: selected.actualModel, + modelActual: selected.actualModel || modelRequested, status, httpStatus, latencyMs, @@ -168,13 +215,33 @@ async function logProxy( totalTokens: 0, estimatedCost: 0, errorMessage: composeProxyLogMessage({ - downstreamPath: '/v1/search', + clientKind: clientContext?.clientKind && clientContext.clientKind !== 'generic' + ? clientContext.clientKind + : null, + sessionId: clientContext?.sessionId || null, + traceHint: clientContext?.traceHint || null, + downstreamPath, errorMessage, }), + clientFamily: clientContext?.clientKind || null, + clientAppId: clientContext?.clientAppId || null, + clientAppName: clientContext?.clientAppName || null, + clientConfidence: clientContext?.clientConfidence || null, retryCount, createdAt, - }).run(); + }); } catch (error) { console.warn('[proxy/search] failed to write proxy log', error); } } + +async function recordTokenRouterEventBestEffort( + label: string, + operation: () => Promise, +): Promise { + try { + await operation(); + } catch (error) { + console.warn(`[proxy/search] failed to ${label}`, error); + } +} diff --git a/src/server/routes/proxy/upstreamEndpoint.test.ts b/src/server/routes/proxy/upstreamEndpoint.test.ts index 2cfcfe00..b174b43b 100644 --- a/src/server/routes/proxy/upstreamEndpoint.test.ts +++ b/src/server/routes/proxy/upstreamEndpoint.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { config } from '../../config.js'; const fetchModelPricingCatalogMock = vi.fn(async (_arg?: unknown): Promise => null); @@ -11,7 +12,13 @@ import { buildUpstreamEndpointRequest, isUnsupportedMediaTypeError, isEndpointDowngradeError, + recordUpstreamEndpointFailure, + recordUpstreamEndpointSuccess, + resetUpstreamEndpointRuntimeState, resolveUpstreamEndpointCandidates, + boundEndpointRuntimeModelKey, + MAX_ENDPOINT_RUNTIME_MODEL_KEY_LENGTH, + MODEL_KEY_HASH_SUFFIX_LENGTH, } from './upstreamEndpoint.js'; const baseContext = { @@ -32,6 +39,18 @@ describe('resolveUpstreamEndpointCandidates', () => { beforeEach(() => { fetchModelPricingCatalogMock.mockReset(); fetchModelPricingCatalogMock.mockResolvedValue(null); + resetUpstreamEndpointRuntimeState(); + (config as any).codexHeaderDefaults = { + userAgent: '', + betaFeatures: '', + }; + (config as any).payloadRules = { + default: [], + defaultRaw: [], + override: [], + overrideRaw: [], + filter: [], + }; }); it('uses downstream-aligned endpoint priority for unknown platforms', async () => { @@ -151,6 +170,16 @@ describe('resolveUpstreamEndpointCandidates', () => { 'claude', ); expect(claudeOrder).toEqual(['messages']); + + const codexOrder = await resolveUpstreamEndpointCandidates( + { + ...baseContext, + site: { ...baseContext.site, platform: 'codex', url: 'https://chatgpt.com/backend-api/codex' }, + }, + 'gpt-5.2-codex', + 'openai', + ); + expect(codexOrder).toEqual(['responses']); }); it('prefers document-capable endpoints when downstream content contains non-image files', async () => { @@ -170,6 +199,249 @@ describe('resolveUpstreamEndpointCandidates', () => { expect(order).toEqual(['responses', 'messages', 'chat']); }); + it('does not apply runtime endpoint memory to image attachments', async () => { + recordUpstreamEndpointSuccess({ + siteId: baseContext.site.id, + endpoint: 'responses', + downstreamFormat: 'openai', + modelName: 'gpt-5.3', + requestCapabilities: { + conversationFileSummary: { + hasImage: true, + hasAudio: false, + hasDocument: false, + hasRemoteDocumentUrl: false, + }, + }, + }); + + const order = await resolveUpstreamEndpointCandidates( + { + ...baseContext, + site: { ...baseContext.site, platform: 'new-api' }, + }, + 'gpt-5.3', + 'openai', + undefined, + { + conversationFileSummary: { + hasImage: true, + hasAudio: false, + hasDocument: false, + hasRemoteDocumentUrl: false, + }, + }, + ); + + expect(order).toEqual(['chat', 'messages', 'responses']); + }); + + it('does not apply runtime endpoint memory to document attachments', async () => { + recordUpstreamEndpointSuccess({ + siteId: baseContext.site.id, + endpoint: 'messages', + downstreamFormat: 'openai', + modelName: 'gpt-5.3', + requestCapabilities: { + hasNonImageFileInput: true, + conversationFileSummary: { + hasImage: false, + hasAudio: false, + hasDocument: true, + hasRemoteDocumentUrl: false, + }, + }, + }); + + const order = await resolveUpstreamEndpointCandidates( + { + ...baseContext, + site: { ...baseContext.site, platform: 'new-api' }, + }, + 'gpt-5.3', + 'openai', + undefined, + { + hasNonImageFileInput: true, + conversationFileSummary: { + hasImage: false, + hasAudio: false, + hasDocument: true, + hasRemoteDocumentUrl: false, + }, + }, + ); + + expect(order).toEqual(['responses', 'messages', 'chat']); + }); + + it('remembers the last successful endpoint per site capability profile', async () => { + recordUpstreamEndpointSuccess({ + siteId: baseContext.site.id, + endpoint: 'responses', + downstreamFormat: 'openai', + modelName: 'gpt-5.3', + }); + + const order = await resolveUpstreamEndpointCandidates( + { + ...baseContext, + site: { ...baseContext.site, platform: 'new-api' }, + }, + 'gpt-5.3', + 'openai', + ); + + expect(order).toEqual(['responses', 'chat', 'messages']); + }); + + it('keeps learned endpoint state scoped to the model key', async () => { + recordUpstreamEndpointSuccess({ + siteId: baseContext.site.id, + endpoint: 'responses', + downstreamFormat: 'openai', + modelName: 'gpt-5.3', + }); + + const learnedOrder = await resolveUpstreamEndpointCandidates( + { + ...baseContext, + site: { ...baseContext.site, platform: 'new-api' }, + }, + 'gpt-5.3', + 'openai', + ); + + const unrelatedModelOrder = await resolveUpstreamEndpointCandidates( + { + ...baseContext, + site: { ...baseContext.site, platform: 'new-api' }, + }, + 'gpt-4.1', + 'openai', + ); + + expect(learnedOrder).toEqual(['responses', 'chat', 'messages']); + expect(unrelatedModelOrder).toEqual(['chat', 'messages', 'responses']); + }); + + it('bounds runtime model keys before storing them', () => { + const longModelName = 'gpt-' + 'a'.repeat(MAX_ENDPOINT_RUNTIME_MODEL_KEY_LENGTH + 32); + const boundedKey = boundEndpointRuntimeModelKey(longModelName); + + expect(boundedKey.length).toBeLessThanOrEqual( + MAX_ENDPOINT_RUNTIME_MODEL_KEY_LENGTH + 1 + MODEL_KEY_HASH_SUFFIX_LENGTH, + ); + expect(boundedKey.startsWith(longModelName.slice(0, MAX_ENDPOINT_RUNTIME_MODEL_KEY_LENGTH))).toBe(true); + expect(boundedKey).toMatch( + new RegExp(`-[0-9a-f]{${MODEL_KEY_HASH_SUFFIX_LENGTH}}$`), + ); + expect(boundEndpointRuntimeModelKey(longModelName)).toEqual(boundedKey); + }); + + it('keeps remote-document-url requests on a separate runtime preference bucket from inline document requests', async () => { + recordUpstreamEndpointSuccess({ + siteId: baseContext.site.id, + endpoint: 'chat', + downstreamFormat: 'openai', + modelName: 'gpt-5.3', + requestCapabilities: { + hasNonImageFileInput: true, + conversationFileSummary: { + hasImage: false, + hasAudio: false, + hasDocument: true, + hasRemoteDocumentUrl: false, + }, + }, + }); + + const order = await resolveUpstreamEndpointCandidates( + { + ...baseContext, + site: { ...baseContext.site, platform: 'new-api' }, + }, + 'gpt-5.3', + 'openai', + undefined, + { + hasNonImageFileInput: true, + conversationFileSummary: { + hasImage: false, + hasAudio: false, + hasDocument: true, + hasRemoteDocumentUrl: true, + }, + }, + ); + + expect(order).toEqual(['responses']); + }); + + it('does not remember messages fallback success for generic /v1/responses requests', async () => { + recordUpstreamEndpointSuccess({ + siteId: baseContext.site.id, + endpoint: 'messages', + downstreamFormat: 'responses', + modelName: 'gpt-5.3', + }); + + const order = await resolveUpstreamEndpointCandidates( + { + ...baseContext, + site: { ...baseContext.site, platform: 'new-api' }, + }, + 'gpt-5.3', + 'responses', + ); + + expect(order).toEqual(['responses', 'chat', 'messages']); + }); + + it('does not block generic /v1/responses endpoints on transient upstream errors', async () => { + recordUpstreamEndpointFailure({ + siteId: baseContext.site.id, + endpoint: 'responses', + downstreamFormat: 'responses', + modelName: 'gpt-5.3', + status: 504, + errorText: '{"error":{"message":"Gateway time-out","type":"upstream_error"}}', + }); + + const order = await resolveUpstreamEndpointCandidates( + { + ...baseContext, + site: { ...baseContext.site, platform: 'new-api' }, + }, + 'gpt-5.3', + 'responses', + ); + + expect(order).toEqual(['responses', 'chat', 'messages']); + }); + + it('learns a better endpoint from explicit upstream protocol errors', async () => { + recordUpstreamEndpointFailure({ + siteId: baseContext.site.id, + endpoint: 'chat', + downstreamFormat: 'openai', + modelName: 'gpt-5.3', + status: 400, + errorText: 'Unsupported legacy protocol: /v1/chat/completions is not supported. Please use /v1/responses.', + }); + + const order = await resolveUpstreamEndpointCandidates( + { + ...baseContext, + site: { ...baseContext.site, platform: 'new-api' }, + }, + 'gpt-5.3', + 'openai', + ); + + expect(order).toEqual(['responses', 'messages']); + }); + it('keeps claude models messages-first even when openai platform catalog prefers chat', async () => { fetchModelPricingCatalogMock.mockResolvedValue({ models: [ @@ -319,6 +591,512 @@ describe('buildUpstreamEndpointRequest', () => { ]); }); + it('builds codex responses requests against backend-api path and preserves oauth provider headers', () => { + const request = buildUpstreamEndpointRequest({ + endpoint: 'responses', + modelName: 'gpt-5.2-codex', + stream: false, + tokenValue: 'oauth-access-token', + sitePlatform: 'codex', + siteUrl: 'https://chatgpt.com/backend-api/codex', + openaiBody: { + model: 'gpt-5.2-codex', + messages: [{ role: 'user', content: 'hello codex' }], + temperature: 0.2, + top_p: 0.9, + user: 'drop-me', + service_tier: 'auto', + }, + downstreamFormat: 'openai', + providerHeaders: { + Originator: 'codex_cli_rs', + 'Chatgpt-Account-Id': 'chatgpt-account-123', + }, + codexSessionCacheKey: 'gpt-5.2-codex:proxy:test-key', + } as any); + + expect(request.path).toBe('/responses'); + expect(request.headers.Authorization).toBe('Bearer oauth-access-token'); + expect(request.headers.Originator).toBe('codex_cli_rs'); + expect(request.headers['Chatgpt-Account-Id']).toBe('chatgpt-account-123'); + expect(request.headers.Version).toBe('0.101.0'); + expect(request.headers.Session_id).toMatch(/^[0-9a-f-]{36}$/i); + expect(request.headers.Conversation_id).toBe(request.headers.Session_id); + expect(request.headers['User-Agent']).toBe('codex_cli_rs/0.101.0 (Mac OS 26.0.1; arm64) Apple_Terminal/464'); + expect(request.headers.Accept).toBe('text/event-stream'); + expect(request.headers.Connection).toBe('Keep-Alive'); + expect(request.body.instructions).toBe(''); + expect(request.body.prompt_cache_key).toBeUndefined(); + expect(request.body.stream).toBe(false); + expect(request.body.store).toBe(false); + expect(request.body.parallel_tool_calls).toBeUndefined(); + expect(request.body.include).toBeUndefined(); + expect(request.body.max_output_tokens).toBe(4096); + expect(request.body.temperature).toBe(0.2); + expect(request.body.top_p).toBe(0.9); + expect(request.body.user).toBe('drop-me'); + expect(request.body.service_tier).toBe('auto'); + }); + + it('reuses a stable codex session id when the same downstream continuity key is provided', () => { + const firstRequest = buildUpstreamEndpointRequest({ + endpoint: 'responses', + modelName: 'gpt-5.4', + stream: false, + tokenValue: 'oauth-access-token', + sitePlatform: 'codex', + siteUrl: 'https://chatgpt.com/backend-api/codex', + openaiBody: { + model: 'gpt-5.4', + messages: [{ role: 'user', content: 'hello codex' }], + }, + downstreamFormat: 'openai', + providerHeaders: { + Originator: 'codex_cli_rs', + }, + codexSessionCacheKey: 'gpt-5.4:user-123', + } as any); + + const secondRequest = buildUpstreamEndpointRequest({ + endpoint: 'responses', + modelName: 'gpt-5.4', + stream: false, + tokenValue: 'oauth-access-token', + sitePlatform: 'codex', + siteUrl: 'https://chatgpt.com/backend-api/codex', + openaiBody: { + model: 'gpt-5.4', + messages: [{ role: 'user', content: 'hello again codex' }], + }, + downstreamFormat: 'openai', + providerHeaders: { + Originator: 'codex_cli_rs', + }, + codexSessionCacheKey: 'gpt-5.4:user-123', + } as any); + + expect(firstRequest.headers.Session_id).toBe(secondRequest.headers.Session_id); + expect(firstRequest.headers.Conversation_id).toBe(secondRequest.headers.Conversation_id); + expect(firstRequest.body.prompt_cache_key).toBe(secondRequest.body.prompt_cache_key); + }); + + it('does not synthesize prompt_cache_key or conversation_id for native codex responses requests without one', () => { + const request = buildUpstreamEndpointRequest({ + endpoint: 'responses', + modelName: 'gpt-5.4', + stream: false, + tokenValue: 'oauth-access-token', + sitePlatform: 'codex', + siteUrl: 'https://chatgpt.com/backend-api/codex', + openaiBody: {}, + downstreamFormat: 'responses', + responsesOriginalBody: { + model: 'gpt-5.4', + input: 'hello codex', + }, + providerHeaders: { + Originator: 'codex_cli_rs', + }, + } as any); + + expect(request.headers.Session_id).toMatch(/^[0-9a-f-]{36}$/i); + expect(request.headers.Conversation_id).toBeUndefined(); + expect(request.body.prompt_cache_key).toBeUndefined(); + }); + + it('preserves explicit prompt_cache_key for native codex responses requests without mapping it into codex session headers', () => { + const request = buildUpstreamEndpointRequest({ + endpoint: 'responses', + modelName: 'gpt-5.4', + stream: false, + tokenValue: 'oauth-access-token', + sitePlatform: 'codex', + siteUrl: 'https://chatgpt.com/backend-api/codex', + openaiBody: {}, + downstreamFormat: 'responses', + responsesOriginalBody: { + model: 'gpt-5.4', + prompt_cache_key: 'codex-cache-123', + input: 'hello codex', + }, + providerHeaders: { + Originator: 'codex_cli_rs', + }, + } as any); + + expect(request.headers.Session_id).toMatch(/^[0-9a-f-]{36}$/i); + expect(request.headers.Conversation_id).toBeUndefined(); + expect(request.body.prompt_cache_key).toBe('codex-cache-123'); + }); + + it('preserves native codex responses continuity and request fields without compatibility rewrites', () => { + const request = buildUpstreamEndpointRequest({ + endpoint: 'responses', + modelName: 'gpt-5.4', + stream: false, + tokenValue: 'oauth-access-token', + sitePlatform: 'codex', + siteUrl: 'https://chatgpt.com/backend-api/codex', + openaiBody: {}, + downstreamFormat: 'responses', + responsesOriginalBody: { + model: 'gpt-5.4', + input: 'hello codex', + stream: false, + store: true, + parallel_tool_calls: false, + include: ['reasoning.encrypted_content', 'mcp_approval_request.details'], + previous_response_id: 'resp_prev_123', + temperature: 0.3, + top_p: 0.8, + max_output_tokens: 512, + }, + providerHeaders: { + Originator: 'codex_cli_rs', + }, + codexSessionCacheKey: 'gpt-5.4:user-456', + } as any); + + expect(request.headers.Session_id).toMatch(/^[0-9a-f-]{36}$/i); + expect(request.headers.Conversation_id).toBe(request.headers.Session_id); + expect(request.body.prompt_cache_key).toBeUndefined(); + expect(request.body.instructions).toBe(''); + expect(request.body.stream).toBe(false); + expect(request.body.store).toBe(false); + expect(request.body.parallel_tool_calls).toBe(false); + expect(request.body.include).toEqual(['reasoning.encrypted_content', 'mcp_approval_request.details']); + expect(request.body.previous_response_id).toBe('resp_prev_123'); + expect(request.body.temperature).toBe(0.3); + expect(request.body.top_p).toBe(0.8); + expect(request.body.max_output_tokens).toBe(512); + }); + + it('applies configured codex header defaults with CLIProxyAPI-compatible precedence', () => { + (config as any).codexHeaderDefaults = { + userAgent: 'codex-config-ua/1.0', + betaFeatures: 'multi_agent', + }; + + const websocketRequest = buildUpstreamEndpointRequest({ + endpoint: 'responses', + modelName: 'gpt-5.4', + stream: true, + tokenValue: 'oauth-access-token', + oauthProvider: 'codex', + sitePlatform: 'codex', + siteUrl: 'https://chatgpt.com/backend-api/codex', + openaiBody: { + model: 'gpt-5.4', + messages: [{ role: 'user', content: 'hello codex' }], + }, + downstreamFormat: 'openai', + downstreamHeaders: { + 'x-metapi-responses-websocket-transport': '1', + }, + providerHeaders: { + Originator: 'codex_cli_rs', + }, + } as any); + + expect(websocketRequest.headers['User-Agent']).toBe('codex-config-ua/1.0'); + expect(websocketRequest.headers['x-codex-beta-features']).toBe('multi_agent'); + + const clientHeaderRequest = buildUpstreamEndpointRequest({ + endpoint: 'responses', + modelName: 'gpt-5.4', + stream: true, + tokenValue: 'oauth-access-token', + oauthProvider: 'codex', + sitePlatform: 'codex', + siteUrl: 'https://chatgpt.com/backend-api/codex', + openaiBody: { + model: 'gpt-5.4', + messages: [{ role: 'user', content: 'hello again codex' }], + }, + downstreamFormat: 'openai', + downstreamHeaders: { + 'x-metapi-responses-websocket-transport': '1', + 'user-agent': 'client-ua/2.0', + 'x-codex-beta-features': 'client-beta', + }, + providerHeaders: { + Originator: 'codex_cli_rs', + }, + } as any); + + expect(clientHeaderRequest.headers['User-Agent']).toBe('codex-config-ua/1.0'); + expect(clientHeaderRequest.headers['x-codex-beta-features']).toBe('client-beta'); + + const httpRequest = buildUpstreamEndpointRequest({ + endpoint: 'responses', + modelName: 'gpt-5.4', + stream: false, + tokenValue: 'oauth-access-token', + oauthProvider: 'codex', + sitePlatform: 'codex', + siteUrl: 'https://chatgpt.com/backend-api/codex', + openaiBody: { + model: 'gpt-5.4', + messages: [{ role: 'user', content: 'plain http codex' }], + }, + downstreamFormat: 'openai', + providerHeaders: { + Originator: 'codex_cli_rs', + }, + } as any); + + expect(httpRequest.headers['x-codex-beta-features']).toBeUndefined(); + }); + + it('applies configured payload rules before preparing codex responses requests while forcing store false', () => { + (config as any).payloadRules = { + default: [ + { + models: [{ name: 'gpt-*', protocol: 'codex' }], + params: { + 'reasoning.effort': 'high', + }, + }, + ], + defaultRaw: [], + override: [ + { + models: [{ name: 'gpt-5.4', protocol: 'codex' }], + params: { + 'text.verbosity': 'low', + store: true, + }, + }, + ], + overrideRaw: [], + filter: [ + { + models: [{ name: 'gpt-5.4', protocol: 'codex' }], + params: ['safety_identifier'], + }, + ], + }; + + const request = buildUpstreamEndpointRequest({ + endpoint: 'responses', + modelName: 'gpt-5.4', + stream: false, + tokenValue: 'oauth-access-token', + oauthProvider: 'codex', + sitePlatform: 'codex', + siteUrl: 'https://chatgpt.com/backend-api/codex', + openaiBody: { + model: 'gpt-5.4', + messages: [{ role: 'user', content: 'hello codex' }], + verbosity: 'high', + safety_identifier: 'drop-me', + }, + downstreamFormat: 'openai', + providerHeaders: { + Originator: 'codex_cli_rs', + }, + } as any); + + expect(request.body.reasoning).toEqual({ effort: 'high' }); + expect(request.body.text).toEqual({ verbosity: 'low' }); + expect(request.body.safety_identifier).toBeUndefined(); + expect(request.body.store).toBe(false); + }); + + it('builds gemini-cli native requests with project envelope and bearer headers', () => { + const request = buildUpstreamEndpointRequest({ + endpoint: 'responses', + modelName: 'gemini-2.5-pro', + stream: true, + tokenValue: 'oauth-access-token', + oauthProvider: 'gemini-cli', + oauthProjectId: 'project-demo', + sitePlatform: 'gemini-cli', + siteUrl: 'https://cloudcode-pa.googleapis.com', + openaiBody: { + model: 'gemini-2.5-pro', + messages: [ + { role: 'system', content: 'be concise' }, + { role: 'user', content: 'hello gemini cli' }, + ], + temperature: 0.4, + }, + downstreamFormat: 'openai', + providerHeaders: { + 'User-Agent': 'GeminiCLI/0.31.0/unknown (win32; x64)', + 'X-Goog-Api-Client': 'google-genai-sdk/1.41.0 gl-node/v22.19.0', + }, + }); + + expect(request.path).toBe('/v1internal:streamGenerateContent?alt=sse'); + expect(request.headers.Authorization).toBe('Bearer oauth-access-token'); + expect(request.headers['User-Agent']).toBe('GeminiCLI/0.31.0/gemini-2.5-pro (win32; x64)'); + expect(request.headers['X-Goog-Api-Client']).toContain('google-genai-sdk/'); + expect(request.body.project).toBe('project-demo'); + expect(request.body.model).toBe('gemini-2.5-pro'); + expect(request.body.request).toMatchObject({ + generationConfig: { + temperature: 0.4, + }, + systemInstruction: { + role: 'user', + }, + contents: [ + { + role: 'user', + parts: [{ text: 'hello gemini cli' }], + }, + ], + }); + }); + + it('builds antigravity native requests with the same internal Gemini envelope', () => { + const request = buildUpstreamEndpointRequest({ + endpoint: 'chat', + modelName: 'gemini-3-pro-preview', + stream: false, + tokenValue: 'oauth-access-token', + oauthProvider: 'antigravity', + oauthProjectId: 'project-demo', + sitePlatform: 'antigravity', + siteUrl: 'https://cloudcode-pa.googleapis.com', + openaiBody: { + model: 'gemini-3-pro-preview', + messages: [ + { role: 'system', content: 'be concise' }, + { role: 'user', content: 'hello antigravity' }, + ], + }, + downstreamFormat: 'openai', + providerHeaders: { + 'User-Agent': 'google-api-nodejs-client/9.15.1', + 'X-Goog-Api-Client': 'google-cloud-sdk vscode_cloudshelleditor/0.1', + }, + }); + + expect(request.path).toBe('/v1internal:generateContent'); + expect(request.headers.Authorization).toBe('Bearer oauth-access-token'); + expect(request.headers['User-Agent']).toBe('antigravity/1.19.6 darwin/arm64'); + expect(request.headers['X-Goog-Api-Client']).toBeUndefined(); + expect(request.headers['Client-Metadata']).toBeUndefined(); + expect(request.body).toEqual({ + project: 'project-demo', + model: 'gemini-3-pro-preview', + request: { + systemInstruction: { + role: 'user', + parts: [{ text: 'be concise' }], + }, + contents: [ + { + role: 'user', + parts: [{ text: 'hello antigravity' }], + }, + ], + }, + }); + }); + + it('uses claude-code runtime headers for claude oauth upstream requests', () => { + const request = buildUpstreamEndpointRequest({ + endpoint: 'messages', + modelName: 'claude-opus-4-6', + stream: true, + tokenValue: 'oauth-access-token', + oauthProvider: 'claude', + sitePlatform: 'claude', + siteUrl: 'https://api.anthropic.com', + openaiBody: { + model: 'claude-opus-4-6', + messages: [{ role: 'user', content: 'hello claude oauth' }], + }, + downstreamFormat: 'openai', + }); + + expect(request.path).toBe('/v1/messages'); + expect(request.headers.Authorization).toBe('Bearer oauth-access-token'); + expect(request.headers['x-api-key']).toBeUndefined(); + expect(request.headers['anthropic-version']).toBe('2023-06-01'); + expect(request.headers['Anthropic-Dangerous-Direct-Browser-Access']).toBe('true'); + expect(request.headers['X-App']).toBe('cli'); + expect(request.headers['X-Stainless-Retry-Count']).toBe('0'); + expect(request.headers['X-Stainless-Runtime-Version']).toBe('v24.3.0'); + expect(request.headers['X-Stainless-Package-Version']).toBe('0.74.0'); + expect(request.headers['X-Stainless-Runtime']).toBe('node'); + expect(request.headers['X-Stainless-Lang']).toBe('js'); + expect(request.headers['X-Stainless-Arch']).toBe('x64'); + expect(request.headers['X-Stainless-Os']).toBe('Windows'); + expect(request.headers['X-Stainless-Timeout']).toBe('600'); + expect(request.headers['User-Agent']).toBe('claude-cli/2.1.63 (external, cli)'); + expect(request.headers.Connection).toBe('keep-alive'); + expect(request.headers.Accept).toBe('text/event-stream'); + expect(request.headers['Accept-Encoding']).toBe('gzip, deflate, br, zstd'); + }); + + it('uses claude-code beta headers and uncompressed non-stream responses for claude upstream requests', () => { + const request = buildUpstreamEndpointRequest({ + endpoint: 'messages', + modelName: 'claude-opus-4-6', + stream: false, + tokenValue: 'oauth-access-token', + oauthProvider: 'claude', + sitePlatform: 'claude', + siteUrl: 'https://api.anthropic.com', + openaiBody: { + model: 'claude-opus-4-6', + messages: [{ role: 'user', content: 'hello claude oauth' }], + }, + downstreamFormat: 'openai', + }); + + expect(request.headers['anthropic-beta']).toContain('claude-code-20250219'); + expect(request.headers['anthropic-beta']).toContain('oauth-2025-04-20'); + expect(request.headers['anthropic-beta']).toContain('context-management-2025-06-27'); + expect(request.headers.Accept).toBe('application/json'); + expect(request.headers['Accept-Encoding']).toBe('gzip, deflate, br, zstd'); + }); + + it('converts system roles to developer in native codex responses bodies', () => { + const request = buildUpstreamEndpointRequest({ + endpoint: 'responses', + modelName: 'gpt-5.4', + stream: false, + tokenValue: 'oauth-access-token', + sitePlatform: 'codex', + siteUrl: 'https://chatgpt.com/backend-api/codex', + openaiBody: {}, + downstreamFormat: 'responses', + responsesOriginalBody: { + model: 'gpt-5.4', + input: [ + { + type: 'message', + role: 'system', + content: [{ type: 'input_text', text: 'be careful' }], + }, + { + type: 'message', + role: 'user', + content: [{ type: 'input_text', text: 'hello' }], + }, + ], + }, + }); + + expect(request.body.input).toEqual([ + { + type: 'message', + role: 'developer', + content: [{ type: 'input_text', text: 'be careful' }], + }, + { + type: 'message', + role: 'user', + content: [{ type: 'input_text', text: 'hello' }], + }, + ]); + }); + it('normalizes downstream responses input string before forwarding upstream', () => { const request = buildUpstreamEndpointRequest({ endpoint: 'responses', @@ -461,7 +1239,7 @@ describe('buildUpstreamEndpointRequest', () => { ]); }); - it('serializes file uploads into Responses input_file blocks for upstream responses endpoints', () => { + it('serializes file uploads into Responses input_file blocks without conflicting file ids', () => { const request = buildUpstreamEndpointRequest({ endpoint: 'responses', modelName: 'gpt-5.2', @@ -499,7 +1277,6 @@ describe('buildUpstreamEndpointRequest', () => { { type: 'input_text', text: 'read this' }, { type: 'input_file', - file_id: 'file_local_123', filename: 'paper.pdf', file_data: 'data:application/pdf;base64,JVBERi0xLjQK', }, @@ -508,7 +1285,7 @@ describe('buildUpstreamEndpointRequest', () => { ]); }); - it('applies global responses standardization and drops non-standard fields', () => { + it('preserves unknown native responses fields while still normalizing known compatibility fields', () => { const request = buildUpstreamEndpointRequest({ endpoint: 'responses', modelName: 'upstream-gpt', @@ -523,13 +1300,13 @@ describe('buildUpstreamEndpointRequest', () => { input: 'hello', metadata: { trace: 'abc123' }, max_completion_tokens: 512, - custom_vendor_flag: 'drop-me', + custom_vendor_flag: 'keep-me', }, }); expect(request.path).toBe('/v1/responses'); expect(request.body.metadata).toEqual({ trace: 'abc123' }); - expect(request.body.custom_vendor_flag).toBeUndefined(); + expect(request.body.custom_vendor_flag).toBe('keep-me'); expect(request.body.max_completion_tokens).toBeUndefined(); expect(request.body.max_output_tokens).toBe(512); expect(request.body.input).toEqual([ @@ -615,6 +1392,105 @@ describe('buildUpstreamEndpointRequest', () => { expect(request.headers['Content-Type']).toBe('application/json'); }); + it('strips unsupported openai parameters like frequency_penalty for Gemini models on chat endpoints', () => { + const request = buildUpstreamEndpointRequest({ + endpoint: 'chat', + modelName: 'gemini-1.5-pro', + stream: false, + tokenValue: 'sk-test', + sitePlatform: 'gemini', + openaiBody: { + model: 'gemini-1.5-pro', + messages: [{ role: 'user', content: 'hello' }], + frequency_penalty: 0.5, + presence_penalty: 0.2, + logit_bias: { '100': 1 }, + logprobs: true, + top_logprobs: 2, + store: true, + temperature: 0.8, + top_p: 1.0, + }, + downstreamFormat: 'openai', + }); + + expect(request.path).toBe('/v1beta/openai/chat/completions'); + expect(request.body.frequency_penalty).toBeUndefined(); + expect(request.body.presence_penalty).toBeUndefined(); + expect(request.body.logit_bias).toBeUndefined(); + expect(request.body.logprobs).toBeUndefined(); + expect(request.body.top_logprobs).toBeUndefined(); + expect(request.body.store).toBeUndefined(); + expect(request.body.temperature).toBe(0.8); + expect(request.body.top_p).toBe(1.0); + }); + + it('strips unsupported openai parameters like frequency_penalty for Gemini models on responses endpoints', () => { + const request = buildUpstreamEndpointRequest({ + endpoint: 'responses', + modelName: 'gemini-1.5-pro', + stream: false, + tokenValue: 'sk-test', + sitePlatform: 'gemini', + openaiBody: { + model: 'gemini-1.5-pro', + messages: [{ role: 'user', content: 'hello' }], + frequency_penalty: 0.5, + presence_penalty: 0.2, + logit_bias: { '100': 1 }, + logprobs: true, + top_logprobs: 2, + store: true, + temperature: 0.8, + top_p: 1.0, + }, + downstreamFormat: 'openai', + }); + + expect(request.path).toBe('/v1beta/openai/responses'); + expect(request.body.frequency_penalty).toBeUndefined(); + expect(request.body.presence_penalty).toBeUndefined(); + expect(request.body.logit_bias).toBeUndefined(); + expect(request.body.logprobs).toBeUndefined(); + expect(request.body.top_logprobs).toBeUndefined(); + expect(request.body.store).toBeUndefined(); + expect(request.body.temperature).toBe(0.8); + }); + + it('strips unsupported openai parameters like frequency_penalty for Gemini models from downstream responses bodies', () => { + const request = buildUpstreamEndpointRequest({ + endpoint: 'responses', + modelName: 'gemini-1.5-pro', + stream: false, + tokenValue: 'sk-test', + sitePlatform: 'gemini', + openaiBody: {}, + downstreamFormat: 'responses', + responsesOriginalBody: { + model: 'gemini-1.5-pro', + input: 'hello', + frequency_penalty: 0.5, + presence_penalty: 0.2, + logit_bias: { '100': 1 }, + logprobs: true, + top_logprobs: 2, + store: true, + temperature: 0.8, + top_p: 1.0, + }, + }); + + expect(request.path).toBe('/v1beta/openai/responses'); + expect(request.body.frequency_penalty).toBeUndefined(); + expect(request.body.presence_penalty).toBeUndefined(); + expect(request.body.logit_bias).toBeUndefined(); + expect(request.body.logprobs).toBeUndefined(); + expect(request.body.top_logprobs).toBeUndefined(); + expect(request.body.store).toBeUndefined(); + expect(request.body.temperature).toBe(0.8); + expect(request.body.top_p).toBe(1.0); + }); + it('preserves structured responses content blocks instead of flattening them', () => { const request = buildUpstreamEndpointRequest({ endpoint: 'responses', @@ -712,6 +1588,51 @@ describe('buildUpstreamEndpointRequest', () => { ]); }); + it('preserves structured input_file file_url blocks on downstream responses bodies', () => { + const request = buildUpstreamEndpointRequest({ + endpoint: 'responses', + modelName: 'upstream-gpt', + stream: false, + tokenValue: 'sk-test', + sitePlatform: 'openai', + siteUrl: 'https://example.com', + openaiBody: {}, + downstreamFormat: 'responses', + responsesOriginalBody: { + model: 'gpt-5.2', + input: [ + { + type: 'message', + role: 'user', + content: [ + { type: 'input_text', text: 'read this remote file' }, + { + type: 'input_file', + filename: 'remote.pdf', + file_url: 'https://example.com/remote.pdf', + }, + ], + }, + ], + }, + }); + + expect(request.body.input).toEqual([ + { + type: 'message', + role: 'user', + content: [ + { type: 'input_text', text: 'read this remote file' }, + { + type: 'input_file', + filename: 'remote.pdf', + file_url: 'https://example.com/remote.pdf', + }, + ], + }, + ]); + }); + it('maps OpenAI file blocks to Anthropic document blocks', () => { const request = buildUpstreamEndpointRequest({ endpoint: 'messages', @@ -1048,6 +1969,79 @@ describe('buildUpstreamEndpointRequest', () => { ]); }); + it('drops Responses-only tools when /v1/responses falls back to /v1/chat/completions', () => { + const request = buildUpstreamEndpointRequest({ + endpoint: 'chat', + modelName: 'gpt-5.4', + stream: false, + tokenValue: 'sk-test', + sitePlatform: 'openai', + siteUrl: 'https://example.com', + downstreamFormat: 'responses', + openaiBody: { + model: 'gpt-5.4', + messages: [ + { + role: 'user', + content: 'summarize the workspace state', + }, + ], + tools: [ + { + type: 'function', + function: { + name: 'Glob', + description: 'Search files', + parameters: { + type: 'object', + properties: { + pattern: { type: 'string' }, + }, + required: ['pattern'], + }, + }, + }, + { + type: 'custom', + name: 'browser', + format: { type: 'text' }, + }, + { + type: 'image_generation', + size: '1024x1024', + }, + ], + tool_choice: { + type: 'custom', + name: 'browser', + }, + }, + }); + + expect(request.path).toBe('/v1/chat/completions'); + expect(request.body).toMatchObject({ + model: 'gpt-5.4', + stream: false, + tools: [ + { + type: 'function', + function: { + name: 'Glob', + description: 'Search files', + parameters: { + type: 'object', + properties: { + pattern: { type: 'string' }, + }, + required: ['pattern'], + }, + }, + }, + ], + }); + expect(request.body.tool_choice).toBeUndefined(); + }); + it('preserves Anthropic image and tool_result blocks instead of flattening to plain text', () => { const request = buildUpstreamEndpointRequest({ endpoint: 'messages', diff --git a/src/server/routes/proxy/upstreamEndpoint.ts b/src/server/routes/proxy/upstreamEndpoint.ts index 71160c4b..1037e16b 100644 --- a/src/server/routes/proxy/upstreamEndpoint.ts +++ b/src/server/routes/proxy/upstreamEndpoint.ts @@ -1,4 +1,12 @@ +import { randomUUID, createHash } from 'node:crypto'; +import { + rankConversationFileEndpoints, + type ConversationFileInputSummary, +} from '../../proxy-core/capabilities/conversationFileCapabilities.js'; +import { resolveProviderProfile } from '../../proxy-core/providers/registry.js'; +import { config } from '../../config.js'; import { fetchModelPricingCatalog } from '../../services/modelPricingService.js'; +import { applyPayloadRules } from '../../services/payloadRules.js'; import type { DownstreamFormat } from '../../transformers/shared/normalized.js'; import { convertOpenAiBodyToResponsesBody as convertOpenAiBodyToResponsesBodyViaTransformer, @@ -8,10 +16,51 @@ import { convertOpenAiBodyToAnthropicMessagesBody, sanitizeAnthropicMessagesBody, } from '../../transformers/anthropic/messages/conversion.js'; +import { + buildGeminiGenerateContentRequestFromOpenAi, +} from './geminiCliCompat.js'; +import { + buildMinimalJsonHeadersForCompatibility, + isEndpointDispatchDeniedError, + isEndpointDowngradeError, + isUnsupportedMediaTypeError, + promoteResponsesCandidateAfterLegacyChatError, + shouldPreferResponsesAfterLegacyChatError, +} from '../../transformers/shared/endpointCompatibility.js'; +import { + buildClaudeRuntimeHeaders, + buildGeminiCliUserAgent, + headerValueToString, +} from '../../proxy-core/providers/headerUtils.js'; +export { + buildMinimalJsonHeadersForCompatibility, + isEndpointDispatchDeniedError, + isEndpointDowngradeError, + isUnsupportedMediaTypeError, + promoteResponsesCandidateAfterLegacyChatError, + shouldPreferResponsesAfterLegacyChatError, +}; export type UpstreamEndpoint = 'chat' | 'messages' | 'responses'; export type EndpointPreference = DownstreamFormat | 'responses'; +type EndpointCapabilityProfile = { + modelKey: string; + preferMessagesForClaudeModel: boolean; + hasImageInput: boolean; + hasAudioInput: boolean; + hasNonImageFileInput: boolean; + hasRemoteDocumentUrl: boolean; + wantsNativeResponsesReasoning: boolean; +}; + +type EndpointRuntimeState = { + preferredEndpoint: UpstreamEndpoint | null; + preferredUpdatedAtMs: number; + lastTouchedAtMs: number; + blockedUntilMsByEndpoint: Partial>; +}; + type ChannelContext = { site: { id: number; @@ -26,6 +75,26 @@ type ChannelContext = { }; }; +const ENDPOINT_RUNTIME_PREFERRED_TTL_MS = 24 * 60 * 60 * 1000; +const ENDPOINT_RUNTIME_BLOCK_TTL_MS = 6 * 60 * 60 * 1000; +const MAX_ENDPOINT_RUNTIME_STATES = 512; +export const MAX_ENDPOINT_RUNTIME_MODEL_KEY_LENGTH = 64; +export const MODEL_KEY_HASH_SUFFIX_LENGTH = 8; +const endpointRuntimeStates = new Map(); + +export function boundEndpointRuntimeModelKey(value: string): string { + if (value.length <= MAX_ENDPOINT_RUNTIME_MODEL_KEY_LENGTH) { + return value; + } + + const prefix = value.slice(0, MAX_ENDPOINT_RUNTIME_MODEL_KEY_LENGTH); + const hash = createHash('sha256') + .update(value) + .digest('hex') + .slice(0, MODEL_KEY_HASH_SUFFIX_LENGTH); + return `${prefix}-${hash}`; +} + function isRecord(value: unknown): value is Record { return !!value && typeof value === 'object'; } @@ -34,6 +103,28 @@ function asTrimmedString(value: unknown): string { return typeof value === 'string' ? value.trim() : ''; } +function normalizeEndpointRuntimeModelKey(...values: Array): string { + for (const value of values) { + const normalized = asTrimmedString(value).toLowerCase(); + if (normalized) return boundEndpointRuntimeModelKey(normalized); + } + return boundEndpointRuntimeModelKey('unknown-model'); +} + +function resolveRequestedModelForPayloadRules(input: { + modelName: string; + openaiBody: Record; + claudeOriginalBody?: Record; + responsesOriginalBody?: Record; +}): string { + return ( + asTrimmedString(input.responsesOriginalBody?.model) + || asTrimmedString(input.claudeOriginalBody?.model) + || asTrimmedString(input.openaiBody.model) + || asTrimmedString(input.modelName) + ); +} + function normalizePlatformName(platform: unknown): string { return asTrimmedString(platform).toLowerCase(); } @@ -44,23 +135,6 @@ function isClaudeFamilyModel(modelName: string): boolean { return normalized === 'claude' || normalized.startsWith('claude-') || normalized.includes('claude'); } -function headerValueToString(value: unknown): string | null { - if (typeof value === 'string') { - const trimmed = value.trim(); - return trimmed || null; - } - - if (Array.isArray(value)) { - for (const item of value) { - if (typeof item !== 'string') continue; - const trimmed = item.trim(); - if (trimmed) return trimmed; - } - } - - return null; -} - const HOP_BY_HOP_HEADERS = new Set([ 'connection', 'keep-alive', @@ -86,6 +160,8 @@ const BLOCKED_PASSTHROUGH_HEADERS = new Set([ 'sec-websocket-extensions', ]); +const ANTIGRAVITY_RUNTIME_USER_AGENT = 'antigravity/1.19.6 darwin/arm64'; + function shouldSkipPassthroughHeader(key: string): boolean { return HOP_BY_HOP_HEADERS.has(key) || BLOCKED_PASSTHROUGH_HEADERS.has(key); } @@ -156,6 +232,49 @@ function extractResponsesPassthroughHeaders( return forwarded; } +function extractClaudeBetasFromBody(body: Record): { + body: Record; + betas: string[]; +} { + const next = { ...body }; + const rawBetas = next.betas; + delete next.betas; + + if (typeof rawBetas === 'string') { + return { + body: next, + betas: rawBetas.split(',').map((entry) => entry.trim()).filter(Boolean), + }; + } + + if (Array.isArray(rawBetas)) { + return { + body: next, + betas: rawBetas + .map((entry) => asTrimmedString(entry)) + .filter(Boolean), + }; + } + + return { + body: next, + betas: [], + }; +} + +function buildAntigravityRuntimeHeaders(input: { + baseHeaders: Record; + stream: boolean; +}): Record { + const headers: Record = { + Authorization: input.baseHeaders.Authorization, + 'Content-Type': 'application/json', + Accept: input.stream ? 'text/event-stream' : 'application/json', + 'User-Agent': ANTIGRAVITY_RUNTIME_USER_AGENT, + }; + return headers; +} + function ensureStreamAcceptHeader( headers: Record, stream: boolean, @@ -174,10 +293,164 @@ function ensureStreamAcceptHeader( }; } +function normalizeResponsesFallbackChatFunctionTool(rawTool: unknown): Record | null { + if (!isRecord(rawTool)) return null; + if (asTrimmedString(rawTool.type).toLowerCase() !== 'function') return null; + + if (isRecord(rawTool.function)) { + const name = asTrimmedString(rawTool.function.name); + if (!name) return null; + return { + ...rawTool, + type: 'function', + function: { + ...rawTool.function, + name, + }, + }; + } + + const name = asTrimmedString(rawTool.name); + if (!name) return null; + + const fn: Record = { name }; + const description = asTrimmedString(rawTool.description); + if (description) fn.description = description; + if (rawTool.parameters !== undefined) fn.parameters = rawTool.parameters; + if (rawTool.strict !== undefined) fn.strict = rawTool.strict; + + return { + type: 'function', + function: fn, + }; +} + +function normalizeResponsesFallbackChatToolChoice( + rawToolChoice: unknown, + allowedToolNames: Set, +): unknown { + if (rawToolChoice === undefined) return undefined; + + if (typeof rawToolChoice === 'string') { + const normalized = rawToolChoice.trim().toLowerCase(); + if (normalized === 'none') return 'none'; + if (allowedToolNames.size <= 0) return undefined; + if (normalized === 'auto' || normalized === 'required') return normalized; + return undefined; + } + + if (!isRecord(rawToolChoice)) return undefined; + if (asTrimmedString(rawToolChoice.type).toLowerCase() !== 'function') return undefined; + + const nestedFunction = isRecord(rawToolChoice.function) ? rawToolChoice.function : null; + const name = asTrimmedString(nestedFunction?.name ?? rawToolChoice.name); + if (!name || !allowedToolNames.has(name)) return undefined; + + return { + type: 'function', + function: { + ...(nestedFunction || {}), + name, + }, + }; +} + +function sanitizeResponsesFallbackChatBody( + body: Record, +): Record { + const next: Record = { ...body }; + const normalizedTools = Array.isArray(body.tools) + ? body.tools + .map((tool) => normalizeResponsesFallbackChatFunctionTool(tool)) + .filter((tool): tool is Record => !!tool) + : []; + + if (normalizedTools.length > 0) { + next.tools = normalizedTools; + } else { + delete next.tools; + } + + const allowedToolNames = new Set( + normalizedTools + .map((tool) => ( + isRecord(tool.function) + ? asTrimmedString(tool.function.name) + : '' + )) + .filter((name) => name.length > 0), + ); + const normalizedToolChoice = normalizeResponsesFallbackChatToolChoice( + body.tool_choice, + allowedToolNames, + ); + if (normalizedToolChoice !== undefined) { + next.tool_choice = normalizedToolChoice; + } else { + delete next.tool_choice; + } + + return next; +} + function toFiniteNumber(value: unknown): number | null { return typeof value === 'number' && Number.isFinite(value) ? value : null; } +function ensureCodexResponsesInstructions( + body: Record, + sitePlatform: string, +): Record { + if (sitePlatform !== 'codex') return body; + if (typeof body.instructions === 'string') return body; + return { + ...body, + instructions: '', + }; +} + +function ensureCodexResponsesStoreFalse( + body: Record, + sitePlatform: string, +): Record { + if (sitePlatform !== 'codex') return body; + return { + ...body, + store: false, + }; +} + +function convertCodexSystemRoleToDeveloper(input: unknown): unknown { + if (!Array.isArray(input)) return input; + return input.map((item) => { + if (!isRecord(item)) return item; + if (asTrimmedString(item.type).toLowerCase() !== 'message') return item; + if (asTrimmedString(item.role).toLowerCase() !== 'system') return item; + return { + ...item, + role: 'developer', + }; + }); +} + +function applyCodexResponsesCompatibility( + body: Record, + sitePlatform: string, +): Record { + if (sitePlatform !== 'codex') return body; + + const next: Record = { + ...body, + input: convertCodexSystemRoleToDeveloper(body.input), + }; + + if (typeof next.instructions !== 'string') { + next.instructions = ''; + } + + return next; +} + function normalizeEndpointTypes(value: unknown): UpstreamEndpoint[] { const raw = asTrimmedString(value).toLowerCase(); @@ -222,6 +495,277 @@ function normalizeEndpointTypes(value: unknown): UpstreamEndpoint[] { return Array.from(normalized); } +function buildEndpointCapabilityProfile(input?: { + modelName?: string; + requestedModelHint?: string; + requestCapabilities?: { + hasNonImageFileInput?: boolean; + conversationFileSummary?: ConversationFileInputSummary; + wantsNativeResponsesReasoning?: boolean; + }; +}): EndpointCapabilityProfile { + const conversationFileSummary = input?.requestCapabilities?.conversationFileSummary; + return { + modelKey: normalizeEndpointRuntimeModelKey(input?.modelName, input?.requestedModelHint), + preferMessagesForClaudeModel: ( + isClaudeFamilyModel(asTrimmedString(input?.modelName)) + || isClaudeFamilyModel(asTrimmedString(input?.requestedModelHint)) + ), + hasImageInput: conversationFileSummary?.hasImage === true, + hasAudioInput: conversationFileSummary?.hasAudio === true, + hasNonImageFileInput: ( + conversationFileSummary?.hasDocument === true + || input?.requestCapabilities?.hasNonImageFileInput === true + ), + hasRemoteDocumentUrl: ( + conversationFileSummary?.hasRemoteDocumentUrl === true + ), + wantsNativeResponsesReasoning: input?.requestCapabilities?.wantsNativeResponsesReasoning === true, + }; +} + +function shouldUseEndpointRuntimeMemory(capabilityProfile: EndpointCapabilityProfile): boolean { + // Attachment-capable requests are not protocol-equivalent across chat/messages/responses. + // A transient 200 on one endpoint should not bias later multimodal requests onto a lossy path. + return ( + !capabilityProfile.hasImageInput + && !capabilityProfile.hasAudioInput + && !capabilityProfile.hasNonImageFileInput + ); +} + +function buildEndpointRuntimeStateKey(input: { + siteId: number; + downstreamFormat: EndpointPreference; + capabilityProfile: EndpointCapabilityProfile; +}): string { + const capabilityProfile = input.capabilityProfile; + return [ + String(input.siteId), + input.downstreamFormat, + capabilityProfile.modelKey, + capabilityProfile.hasNonImageFileInput ? 'files' : 'nofiles', + capabilityProfile.hasRemoteDocumentUrl ? 'remoteurl' : 'noremoteurl', + capabilityProfile.wantsNativeResponsesReasoning ? 'reasoning' : 'noreasoning', + ].join(':'); +} + +function getOrCreateEndpointRuntimeState(key: string, nowMs = Date.now()): EndpointRuntimeState { + sweepEndpointRuntimeStates(nowMs); + const existing = endpointRuntimeStates.get(key); + if (existing) { + existing.lastTouchedAtMs = nowMs; + return existing; + } + + const initial: EndpointRuntimeState = { + preferredEndpoint: null, + preferredUpdatedAtMs: nowMs, + lastTouchedAtMs: nowMs, + blockedUntilMsByEndpoint: {}, + }; + endpointRuntimeStates.set(key, initial); + enforceEndpointRuntimeStateLimit(); + return initial; +} + +function maybeDeleteEndpointRuntimeState(key: string, nowMs = Date.now()): void { + const state = endpointRuntimeStates.get(key); + if (!state) return; + + const hasActiveBlock = Object.values(state.blockedUntilMsByEndpoint).some((untilMs) => ( + typeof untilMs === 'number' && untilMs > nowMs + )); + const preferredFresh = ( + !!state.preferredEndpoint + && (state.preferredUpdatedAtMs + ENDPOINT_RUNTIME_PREFERRED_TTL_MS) > nowMs + ); + if (!hasActiveBlock && !preferredFresh) { + endpointRuntimeStates.delete(key); + } +} + +function applyEndpointRuntimePreference( + candidates: UpstreamEndpoint[], + key: string, + nowMs = Date.now(), +): UpstreamEndpoint[] { + const state = endpointRuntimeStates.get(key); + if (!state || candidates.length <= 1) return candidates; + state.lastTouchedAtMs = nowMs; + + const blocked = new Set(); + for (const endpoint of candidates) { + const untilMs = state.blockedUntilMsByEndpoint[endpoint]; + if (typeof untilMs === 'number' && untilMs > nowMs) { + blocked.add(endpoint); + } + } + + let next = candidates.filter((endpoint) => !blocked.has(endpoint)); + if (next.length === 0) { + next = [...candidates]; + } + + const preferredFresh = ( + !!state.preferredEndpoint + && (state.preferredUpdatedAtMs + ENDPOINT_RUNTIME_PREFERRED_TTL_MS) > nowMs + ); + if (preferredFresh && state.preferredEndpoint && next.includes(state.preferredEndpoint)) { + next = [ + state.preferredEndpoint, + ...next.filter((endpoint) => endpoint !== state.preferredEndpoint), + ]; + } + + maybeDeleteEndpointRuntimeState(key, nowMs); + return next; +} + +function sweepEndpointRuntimeStates(nowMs = Date.now()): void { + for (const [key, state] of endpointRuntimeStates.entries()) { + const hasActiveBlock = Object.values(state.blockedUntilMsByEndpoint).some((untilMs) => ( + typeof untilMs === 'number' && untilMs > nowMs + )); + const preferredFresh = ( + !!state.preferredEndpoint + && (state.preferredUpdatedAtMs + ENDPOINT_RUNTIME_PREFERRED_TTL_MS) > nowMs + ); + const recentlyTouched = (state.lastTouchedAtMs + ENDPOINT_RUNTIME_PREFERRED_TTL_MS) > nowMs; + if (!hasActiveBlock && !preferredFresh && !recentlyTouched) { + endpointRuntimeStates.delete(key); + } + } +} + +function enforceEndpointRuntimeStateLimit(): void { + if (endpointRuntimeStates.size <= MAX_ENDPOINT_RUNTIME_STATES) return; + + const entries = [...endpointRuntimeStates.entries()] + .sort((left, right) => left[1].lastTouchedAtMs - right[1].lastTouchedAtMs); + const overflowCount = endpointRuntimeStates.size - MAX_ENDPOINT_RUNTIME_STATES; + for (const [key] of entries.slice(0, overflowCount)) { + endpointRuntimeStates.delete(key); + } +} + +function inferSuggestedEndpointFromError(errorText?: string | null): UpstreamEndpoint | null { + const text = (errorText || '').toLowerCase(); + if (!text) return null; + if (text.includes('/v1/responses')) return 'responses'; + if (text.includes('/v1/messages')) return 'messages'; + if (text.includes('/v1/chat/completions')) return 'chat'; + return null; +} + +function shouldBlockEndpointByError(status: number, errorText?: string | null): boolean { + if (isEndpointDispatchDeniedError(status, errorText)) return true; + if (status === 404 || status === 405 || status === 415 || status === 501) return true; + if (isUnsupportedMediaTypeError(status, errorText)) return true; + + const text = (errorText || '').toLowerCase(); + return ( + text.includes('convert_request_failed') + || text.includes('endpoint_not_found') + || text.includes('unknown_endpoint') + || text.includes('unsupported_endpoint') + || text.includes('unsupported_path') + || text.includes('not_found_error') + || text.includes('unsupported legacy protocol') + || text.includes('please use /v1/') + || text.includes('does not allow /v1/') + || text.includes('unknown endpoint') + || text.includes('unsupported endpoint') + || text.includes('unsupported path') + || text.includes('unrecognized request url') + || text.includes('no route matched') + || text.includes('does not exist') + ); +} + +function shouldRememberSuccessfulEndpoint(input: { + endpoint: UpstreamEndpoint; + downstreamFormat: EndpointPreference; +}): boolean { + if (input.downstreamFormat !== 'responses') return true; + return input.endpoint === 'responses'; +} + +export function resetUpstreamEndpointRuntimeState(): void { + endpointRuntimeStates.clear(); +} + +export function recordUpstreamEndpointSuccess(input: { + siteId: number; + endpoint: UpstreamEndpoint; + downstreamFormat: EndpointPreference; + modelName?: string; + requestedModelHint?: string; + requestCapabilities?: { + hasNonImageFileInput?: boolean; + conversationFileSummary?: ConversationFileInputSummary; + wantsNativeResponsesReasoning?: boolean; + }; +}): void { + const capabilityProfile = buildEndpointCapabilityProfile({ + modelName: input.modelName, + requestedModelHint: input.requestedModelHint, + requestCapabilities: input.requestCapabilities, + }); + if (!shouldUseEndpointRuntimeMemory(capabilityProfile)) return; + if (!shouldRememberSuccessfulEndpoint(input)) return; + + const nowMs = Date.now(); + const key = buildEndpointRuntimeStateKey({ + siteId: input.siteId, + downstreamFormat: input.downstreamFormat, + capabilityProfile, + }); + const state = getOrCreateEndpointRuntimeState(key, nowMs); + state.preferredEndpoint = input.endpoint; + state.preferredUpdatedAtMs = nowMs; + delete state.blockedUntilMsByEndpoint[input.endpoint]; +} + +export function recordUpstreamEndpointFailure(input: { + siteId: number; + endpoint: UpstreamEndpoint; + downstreamFormat: EndpointPreference; + status: number; + errorText?: string | null; + modelName?: string; + requestedModelHint?: string; + requestCapabilities?: { + hasNonImageFileInput?: boolean; + conversationFileSummary?: ConversationFileInputSummary; + wantsNativeResponsesReasoning?: boolean; + }; +}): void { + const capabilityProfile = buildEndpointCapabilityProfile({ + modelName: input.modelName, + requestedModelHint: input.requestedModelHint, + requestCapabilities: input.requestCapabilities, + }); + if (!shouldUseEndpointRuntimeMemory(capabilityProfile)) return; + if (!shouldBlockEndpointByError(input.status, input.errorText)) return; + + const nowMs = Date.now(); + const key = buildEndpointRuntimeStateKey({ + siteId: input.siteId, + downstreamFormat: input.downstreamFormat, + capabilityProfile, + }); + const state = getOrCreateEndpointRuntimeState(key, nowMs); + state.blockedUntilMsByEndpoint[input.endpoint] = nowMs + ENDPOINT_RUNTIME_BLOCK_TTL_MS; + + const suggestedEndpoint = inferSuggestedEndpointFromError(input.errorText); + if (suggestedEndpoint && suggestedEndpoint !== input.endpoint) { + state.preferredEndpoint = suggestedEndpoint; + state.preferredUpdatedAtMs = nowMs; + delete state.blockedUntilMsByEndpoint[suggestedEndpoint]; + } +} + function preferredEndpointOrder( downstreamFormat: EndpointPreference, sitePlatform?: string, @@ -229,11 +773,23 @@ function preferredEndpointOrder( ): UpstreamEndpoint[] { const platform = normalizePlatformName(sitePlatform); + if (platform === 'codex') { + return ['responses']; + } + if (platform === 'gemini') { // Gemini upstream is routed through OpenAI-compatible chat endpoint. return ['chat']; } + if (platform === 'gemini-cli') { + return ['chat']; + } + + if (platform === 'antigravity') { + return ['chat']; + } + if (platform === 'openai') { if (preferMessagesForClaudeModel && downstreamFormat !== 'responses') { // Some OpenAI-compatible gateways expose Claude natively via /v1/messages. @@ -279,27 +835,46 @@ export async function resolveUpstreamEndpointCandidates( requestedModelHint?: string, requestCapabilities?: { hasNonImageFileInput?: boolean; + conversationFileSummary?: ConversationFileInputSummary; wantsNativeResponsesReasoning?: boolean; }, ): Promise { const sitePlatform = normalizePlatformName(context.site.platform); - const preferMessagesForClaudeModel = ( - isClaudeFamilyModel(modelName) - || isClaudeFamilyModel(asTrimmedString(requestedModelHint)) + const capabilityProfile = buildEndpointCapabilityProfile({ + modelName, + requestedModelHint, + requestCapabilities, + }); + const preferMessagesForClaudeModel = capabilityProfile.preferMessagesForClaudeModel; + const hasNonImageFileInput = capabilityProfile.hasNonImageFileInput; + const wantsNativeResponsesReasoning = capabilityProfile.wantsNativeResponsesReasoning; + const runtimeStateKey = buildEndpointRuntimeStateKey({ + siteId: context.site.id, + downstreamFormat, + capabilityProfile, + }); + const applyRuntimePreference = (candidates: UpstreamEndpoint[]) => ( + shouldUseEndpointRuntimeMemory(capabilityProfile) + ? applyEndpointRuntimePreference(candidates, runtimeStateKey) + : candidates ); - const hasNonImageFileInput = requestCapabilities?.hasNonImageFileInput === true; - const wantsNativeResponsesReasoning = requestCapabilities?.wantsNativeResponsesReasoning === true; + const conversationFileSummary = requestCapabilities?.conversationFileSummary ?? { + hasImage: false, + hasAudio: false, + hasDocument: hasNonImageFileInput, + hasRemoteDocumentUrl: false, + }; if (sitePlatform === 'anyrouter') { // anyrouter deployments are effectively anthropic-protocol first. if (hasNonImageFileInput) { - return downstreamFormat === 'responses' + return applyRuntimePreference(downstreamFormat === 'responses' ? ['responses', 'messages', 'chat'] - : ['messages', 'responses', 'chat']; + : ['messages', 'responses', 'chat']); } if (downstreamFormat === 'responses') { - return ['responses', 'messages', 'chat']; + return applyRuntimePreference(['responses', 'messages', 'chat']); } - return ['messages', 'chat', 'responses']; + return applyRuntimePreference(['messages', 'chat', 'responses']); } const preferred = preferredEndpointOrder( @@ -311,8 +886,15 @@ export async function resolveUpstreamEndpointCandidates( ? (() => { if (sitePlatform === 'claude') return ['messages'] as UpstreamEndpoint[]; if (sitePlatform === 'gemini') return ['responses', 'chat'] as UpstreamEndpoint[]; - if (preferMessagesForClaudeModel) return ['messages', 'responses', 'chat'] as UpstreamEndpoint[]; - return ['responses', 'messages', 'chat'] as UpstreamEndpoint[]; + if (sitePlatform === 'gemini-cli' || sitePlatform === 'antigravity') return ['chat'] as UpstreamEndpoint[]; + return rankConversationFileEndpoints({ + sitePlatform, + requestedOrder: preferMessagesForClaudeModel + ? ['messages', 'responses', 'chat'] + : ['responses', 'messages', 'chat'], + summary: conversationFileSummary, + preferMessagesForClaudeModel, + }); })() : preferred; const prioritizedPreferredEndpoints: UpstreamEndpoint[] = ( @@ -330,6 +912,8 @@ export async function resolveUpstreamEndpointCandidates( && preferMessagesForClaudeModel && sitePlatform !== 'openai' && sitePlatform !== 'gemini' + && sitePlatform !== 'antigravity' + && sitePlatform !== 'gemini-cli' ); try { @@ -349,20 +933,20 @@ export async function resolveUpstreamEndpointCandidates( }); if (!catalog || !Array.isArray(catalog.models) || catalog.models.length === 0) { - return prioritizedPreferredEndpoints; + return applyRuntimePreference(prioritizedPreferredEndpoints); } const matched = catalog.models.find((item) => asTrimmedString(item?.modelName).toLowerCase() === modelName.toLowerCase(), ); - if (!matched) return prioritizedPreferredEndpoints; + if (!matched) return applyRuntimePreference(prioritizedPreferredEndpoints); const shouldIgnoreCatalogOrderingForClaudeMessages = ( preferMessagesForClaudeModel && (downstreamFormat !== 'responses' || sitePlatform !== 'openai') ); if (shouldIgnoreCatalogOrderingForClaudeMessages) { - return prioritizedPreferredEndpoints; + return applyRuntimePreference(prioritizedPreferredEndpoints); } const supportedRaw = Array.isArray(matched.supportedEndpointTypes) ? matched.supportedEndpointTypes : []; @@ -382,7 +966,7 @@ export async function resolveUpstreamEndpointCandidates( if (forceMessagesFirstForClaudeModel && !hasConcreteEndpointHint) { // Generic labels like openai/anthropic are too coarse for Claude models; // keep messages-first order in this case. - return prioritizedPreferredEndpoints; + return applyRuntimePreference(prioritizedPreferredEndpoints); } const supported = new Set(); @@ -393,19 +977,19 @@ export async function resolveUpstreamEndpointCandidates( } } - if (supported.size === 0) return prioritizedPreferredEndpoints; + if (supported.size === 0) return applyRuntimePreference(prioritizedPreferredEndpoints); const firstSupported = prioritizedPreferredEndpoints.find((endpoint) => supported.has(endpoint)); - if (!firstSupported) return prioritizedPreferredEndpoints; + if (!firstSupported) return applyRuntimePreference(prioritizedPreferredEndpoints); // Catalog metadata can be incomplete/inaccurate, so only use it to pick // the first attempt. Keep downstream-driven fallback order unchanged. - return [ + return applyRuntimePreference([ firstSupported, ...prioritizedPreferredEndpoints.filter((endpoint) => endpoint !== firstSupported), - ]; + ]); } catch { - return prioritizedPreferredEndpoints; + return applyRuntimePreference(prioritizedPreferredEndpoints); } } @@ -414,6 +998,8 @@ export function buildUpstreamEndpointRequest(input: { modelName: string; stream: boolean; tokenValue: string; + oauthProvider?: string; + oauthProjectId?: string; sitePlatform?: string; siteUrl?: string; openaiBody: Record; @@ -422,13 +1008,29 @@ export function buildUpstreamEndpointRequest(input: { forceNormalizeClaudeBody?: boolean; responsesOriginalBody?: Record; downstreamHeaders?: Record; + providerHeaders?: Record; + codexSessionCacheKey?: string | null; + codexExplicitSessionId?: string | null; }): { path: string; headers: Record; body: Record; + runtime?: { + executor: 'default' | 'codex' | 'gemini-cli' | 'antigravity' | 'claude'; + modelName?: string; + stream?: boolean; + oauthProjectId?: string | null; + action?: 'generateContent' | 'streamGenerateContent' | 'countTokens'; + }; } { const sitePlatform = normalizePlatformName(input.sitePlatform); + const providerProfile = resolveProviderProfile(sitePlatform); const isClaudeUpstream = sitePlatform === 'claude'; + const isGeminiUpstream = sitePlatform === 'gemini'; + const isGeminiCliUpstream = sitePlatform === 'gemini-cli'; + const isAntigravityUpstream = sitePlatform === 'antigravity'; + const isInternalGeminiUpstream = isGeminiCliUpstream || isAntigravityUpstream; + const isClaudeOauthUpstream = isClaudeUpstream && input.oauthProvider === 'claude'; const resolveGeminiEndpointPath = (endpoint: UpstreamEndpoint): string => { const normalizedSiteUrl = asTrimmedString(input.siteUrl).toLowerCase(); @@ -444,7 +1046,7 @@ export function buildUpstreamEndpointRequest(input: { }; const resolveEndpointPath = (endpoint: UpstreamEndpoint): string => { - if (sitePlatform === 'gemini') { + if (isGeminiUpstream) { return resolveGeminiEndpointPath(endpoint); } @@ -454,6 +1056,16 @@ export function buildUpstreamEndpointRequest(input: { return '/v1/chat/completions'; } + if (sitePlatform === 'codex') { + return '/responses'; + } + + if (sitePlatform === 'gemini-cli' || sitePlatform === 'antigravity') { + return input.stream + ? '/v1internal:streamGenerateContent?alt=sse' + : '/v1internal:generateContent'; + } + if (sitePlatform === 'claude') { return '/v1/messages'; } @@ -467,11 +1079,88 @@ export function buildUpstreamEndpointRequest(input: { const commonHeaders: Record = { ...passthroughHeaders, 'Content-Type': 'application/json', + ...(input.providerHeaders || {}), }; if (!isClaudeUpstream) { commonHeaders.Authorization = `Bearer ${input.tokenValue}`; } + const stripGeminiUnsupportedFields = (body: Record) => { + const next = { ...body }; + if (isGeminiUpstream || isInternalGeminiUpstream) { + for (const key of [ + 'frequency_penalty', + 'presence_penalty', + 'logit_bias', + 'logprobs', + 'top_logprobs', + 'store', + ]) { + delete next[key]; + } + } + return next; + }; + + const openaiBody = stripGeminiUnsupportedFields(input.openaiBody); + const runtime = { + executor: ( + sitePlatform === 'codex' + ? 'codex' + : sitePlatform === 'gemini-cli' + ? 'gemini-cli' + : sitePlatform === 'antigravity' + ? 'antigravity' + : sitePlatform === 'claude' + ? 'claude' + : 'default' + ) as 'default' | 'codex' | 'gemini-cli' | 'antigravity' | 'claude', + modelName: input.modelName, + stream: input.stream, + oauthProjectId: asTrimmedString(input.oauthProjectId) || null, + }; + const requestedModelForPayloadRules = resolveRequestedModelForPayloadRules(input); + const applyConfiguredPayloadRules = >(body: T): T => ( + applyPayloadRules({ + rules: config.payloadRules, + payload: body, + modelName: input.modelName, + requestedModel: requestedModelForPayloadRules, + protocol: sitePlatform, + }) as T + ); + + if (isInternalGeminiUpstream) { + const instructions = ( + input.downstreamFormat === 'responses' + && typeof input.responsesOriginalBody?.instructions === 'string' + ) + ? input.responsesOriginalBody.instructions + : undefined; + const geminiRequest = buildGeminiGenerateContentRequestFromOpenAi({ + body: openaiBody, + modelName: input.modelName, + instructions, + }); + const configuredGeminiRequest = applyConfiguredPayloadRules(geminiRequest); + if (!providerProfile) { + throw new Error(`missing provider profile for platform: ${sitePlatform}`); + } + return providerProfile.prepareRequest({ + endpoint: input.endpoint, + modelName: input.modelName, + stream: input.stream, + tokenValue: input.tokenValue, + oauthProvider: input.oauthProvider, + oauthProjectId: input.oauthProjectId, + sitePlatform, + baseHeaders: commonHeaders, + providerHeaders: input.providerHeaders, + body: configuredGeminiRequest, + action: input.stream ? 'streamGenerateContent' : 'generateContent', + }); + } + if (input.endpoint === 'messages') { const claudeHeaders = input.downstreamFormat === 'claude' ? extractClaudePassthroughHeaders(input.downstreamHeaders) @@ -506,249 +1195,218 @@ export function buildUpstreamEndpointRequest(input: { const sanitizedBody = nativeClaudeBody ?? normalizedClaudeBody ?? sanitizeAnthropicMessagesBody( - convertOpenAiBodyToAnthropicMessagesBody(input.openaiBody, input.modelName, input.stream), + convertOpenAiBodyToAnthropicMessagesBody(openaiBody, input.modelName, input.stream), ); + const configuredClaudeBody = applyConfiguredPayloadRules(sanitizedBody); - const headers = ensureStreamAcceptHeader({ - ...commonHeaders, - ...claudeHeaders, - 'x-api-key': input.tokenValue, - 'anthropic-version': anthropicVersion, - }, input.stream); + if (providerProfile?.id === 'claude') { + return providerProfile.prepareRequest({ + endpoint: 'messages', + modelName: input.modelName, + stream: input.stream, + tokenValue: input.tokenValue, + oauthProvider: input.oauthProvider, + oauthProjectId: input.oauthProjectId, + sitePlatform, + baseHeaders: commonHeaders, + claudeHeaders, + body: configuredClaudeBody, + }); + } + + const headers = buildClaudeRuntimeHeaders({ + baseHeaders: commonHeaders, + claudeHeaders, + anthropicVersion, + stream: input.stream, + isClaudeOauthUpstream, + tokenValue: input.tokenValue, + }); return { path: resolveEndpointPath('messages'), headers, - body: sanitizedBody, + body: configuredClaudeBody, + runtime, }; } if (input.endpoint === 'responses') { + const websocketMode = Object.entries(input.downstreamHeaders || {}).find(([rawKey]) => rawKey.trim().toLowerCase() === 'x-metapi-responses-websocket-mode'); + const preserveWebsocketIncrementalMode = asTrimmedString(websocketMode?.[1]).toLowerCase() === 'incremental'; const responsesHeaders = input.downstreamFormat === 'responses' ? extractResponsesPassthroughHeaders(input.downstreamHeaders) : {}; const rawBody = ( input.downstreamFormat === 'responses' && input.responsesOriginalBody ? { - ...input.responsesOriginalBody, + ...stripGeminiUnsupportedFields(input.responsesOriginalBody), model: input.modelName, stream: input.stream, } - : convertOpenAiBodyToResponsesBodyViaTransformer(input.openaiBody, input.modelName, input.stream) + : convertOpenAiBodyToResponsesBodyViaTransformer(openaiBody, input.modelName, input.stream) + ); + const sanitizedResponsesBody = sanitizeResponsesBodyForProxyViaTransformer(rawBody, input.modelName, input.stream); + if (preserveWebsocketIncrementalMode && rawBody.generate === false) { + sanitizedResponsesBody.generate = false; + } + const body = ensureCodexResponsesStoreFalse( + ensureCodexResponsesInstructions( + applyCodexResponsesCompatibility( + sanitizedResponsesBody, + sitePlatform, + ), + sitePlatform, + ), + sitePlatform, + ); + const configuredResponsesBody = ensureCodexResponsesStoreFalse( + applyConfiguredPayloadRules(body), + sitePlatform, ); - const body = sanitizeResponsesBodyForProxyViaTransformer(rawBody, input.modelName, input.stream); + + if (sitePlatform === 'codex') { + if (providerProfile?.id !== 'codex') { + throw new Error(`missing codex provider profile for platform: ${sitePlatform}`); + } + return providerProfile.prepareRequest({ + endpoint: 'responses', + modelName: input.modelName, + stream: input.stream, + tokenValue: input.tokenValue, + oauthProvider: input.oauthProvider, + oauthProjectId: input.oauthProjectId, + sitePlatform, + baseHeaders: { + ...commonHeaders, + ...responsesHeaders, + }, + providerHeaders: input.providerHeaders, + codexSessionCacheKey: input.codexSessionCacheKey, + codexExplicitSessionId: input.codexExplicitSessionId, + body: configuredResponsesBody, + }); + } const headers = ensureStreamAcceptHeader({ ...commonHeaders, ...responsesHeaders, }, input.stream); - return { path: resolveEndpointPath('responses'), headers, - body, + body: configuredResponsesBody, + runtime, }; } const headers = ensureStreamAcceptHeader(commonHeaders, input.stream); + const chatBody = { + ...openaiBody, + model: input.modelName, + stream: input.stream, + }; + const configuredChatBody = applyConfiguredPayloadRules( + input.downstreamFormat === 'responses' + ? sanitizeResponsesFallbackChatBody(chatBody) + : chatBody, + ); return { path: resolveEndpointPath('chat'), headers, - body: { - ...input.openaiBody, - model: input.modelName, - stream: input.stream, - }, + body: configuredChatBody, + runtime, }; } -function normalizeHeaderMap(headers: Record): Record { - const normalized: Record = {}; - for (const [rawKey, rawValue] of Object.entries(headers)) { - const key = rawKey.trim().toLowerCase(); - if (!key) continue; - const value = headerValueToString(rawValue); - if (!value) continue; - normalized[key] = value; - } - return normalized; -} - -export function buildMinimalJsonHeadersForCompatibility(input: { +export function buildClaudeCountTokensUpstreamRequest(input: { + modelName: string; + tokenValue: string; + oauthProvider?: string; + sitePlatform?: string; + claudeBody: Record; + downstreamHeaders?: Record; +}): { + path: string; headers: Record; - endpoint: UpstreamEndpoint; - stream: boolean; -}): Record { - const source = normalizeHeaderMap(input.headers); - const minimal: Record = {}; - - if (source.authorization) minimal.authorization = source.authorization; - if (source['x-api-key']) minimal['x-api-key'] = source['x-api-key']; - - if (input.endpoint === 'messages') { - for (const [key, value] of Object.entries(source)) { - if (!key.startsWith('anthropic-')) continue; - minimal[key] = value; - } - if (!minimal['anthropic-version']) { - minimal['anthropic-version'] = '2023-06-01'; - } - } - - minimal['content-type'] = 'application/json'; - minimal.accept = input.stream ? 'text/event-stream' : 'application/json'; - return minimal; -} - -export function isUnsupportedMediaTypeError(status: number, upstreamErrorText?: string | null): boolean { - if (status < 400) return false; - if (status !== 400 && status !== 415) return false; - const text = (upstreamErrorText || '').toLowerCase(); - if (!text) return status === 415; - - return ( - text.includes('unsupported media type') - || text.includes("only 'application/json' is allowed") - || text.includes('only "application/json" is allowed') - || text.includes('application/json') - || text.includes('content-type') - ); -} - -export function isEndpointDispatchDeniedError(status: number, upstreamErrorText?: string | null): boolean { - if (status !== 403) return false; - const text = (upstreamErrorText || '').toLowerCase(); - if (!text) return false; - - return ( - /does\s+not\s+allow\s+\/v1\/[a-z0-9/_:-]+\s+dispatch/i.test(upstreamErrorText || '') - || text.includes('dispatch denied') - ); -} - -export function shouldPreferResponsesAfterLegacyChatError(input: { - status: number; - upstreamErrorText?: string | null; - downstreamFormat: EndpointPreference; - sitePlatform?: string | null; - modelName?: string | null; - requestedModelHint?: string | null; - currentEndpoint?: UpstreamEndpoint | null; -}): boolean { - if (input.status < 400) return false; - if (input.downstreamFormat !== 'openai') return false; - if (input.currentEndpoint !== 'chat') return false; - + body: Record; + runtime: { + executor: 'claude'; + modelName: string; + stream: false; + action: 'countTokens'; + }; +} { const sitePlatform = normalizePlatformName(input.sitePlatform); - if (sitePlatform === 'openai' || sitePlatform === 'claude' || sitePlatform === 'gemini' || sitePlatform === 'anyrouter') { - return false; - } - - const modelName = asTrimmedString(input.modelName); - const requestedModelHint = asTrimmedString(input.requestedModelHint); - if (isClaudeFamilyModel(modelName) || isClaudeFamilyModel(requestedModelHint)) { - return false; - } - - const text = (input.upstreamErrorText || '').toLowerCase(); - return ( - text.includes('unsupported legacy protocol') - && text.includes('/v1/chat/completions') - && text.includes('/v1/responses') - ); -} - -export function promoteResponsesCandidateAfterLegacyChatError( - endpointCandidates: UpstreamEndpoint[], - input: Parameters[0], -): void { - if (!shouldPreferResponsesAfterLegacyChatError(input)) return; - - const currentIndex = endpointCandidates.findIndex((endpoint) => endpoint === input.currentEndpoint); - const responsesIndex = endpointCandidates.indexOf('responses'); - if (currentIndex < 0 || responsesIndex < 0 || responsesIndex <= currentIndex + 1) return; - - endpointCandidates.splice(responsesIndex, 1); - endpointCandidates.splice(currentIndex + 1, 0, 'responses'); -} + const claudeHeaders = extractClaudePassthroughHeaders(input.downstreamHeaders); + const { body: bodyWithoutBetas, betas } = extractClaudeBetasFromBody({ + ...input.claudeBody, + model: input.modelName, + }); + const sanitizedBody = sanitizeAnthropicMessagesBody(bodyWithoutBetas); + delete sanitizedBody.max_tokens; + delete sanitizedBody.maxTokens; + delete sanitizedBody.stream; + const providerProfile = resolveProviderProfile(sitePlatform); + const effectiveClaudeHeaders = { + ...claudeHeaders, + ...(betas.length > 0 ? { 'anthropic-beta': betas.join(',') } : {}), + }; -export function isEndpointDowngradeError(status: number, upstreamErrorText?: string | null): boolean { - if (status < 400) return false; - const text = (upstreamErrorText || '').toLowerCase(); - if (status === 404 || status === 405 || status === 415 || status === 501) return true; - if (!text) return false; + if (providerProfile?.id === 'claude') { + const prepared = providerProfile.prepareRequest({ + endpoint: 'messages', + modelName: input.modelName, + stream: false, + tokenValue: input.tokenValue, + oauthProvider: input.oauthProvider, + sitePlatform, + baseHeaders: { + 'Content-Type': 'application/json', + }, + claudeHeaders: effectiveClaudeHeaders, + body: sanitizedBody, + action: 'countTokens', + }); - let parsedCode = ''; - let parsedType = ''; - let parsedMessage = ''; - try { - const parsed = JSON.parse(upstreamErrorText || '{}') as Record; - const error = (parsed.error && typeof parsed.error === 'object') - ? parsed.error as Record - : parsed; - parsedCode = asTrimmedString(error.code).toLowerCase(); - parsedType = asTrimmedString(error.type).toLowerCase(); - parsedMessage = asTrimmedString(error.message).toLowerCase(); - } catch { - parsedCode = ''; - parsedType = ''; - parsedMessage = ''; + return { + path: prepared.path, + headers: prepared.headers, + body: prepared.body, + runtime: { + executor: 'claude', + modelName: input.modelName, + stream: false, + action: 'countTokens', + }, + }; } - return ( - isEndpointDispatchDeniedError(status, upstreamErrorText) - || - text.includes('convert_request_failed') - || text.includes('not found') - || text.includes('unknown endpoint') - || text.includes('unsupported endpoint') - || text.includes('unsupported path') - || text.includes('unrecognized request url') - || text.includes('no route matched') - || text.includes('does not exist') - || text.includes('openai_error') - || text.includes('upstream_error') - || text.includes('bad_response_status_code') - || text.includes('unsupported media type') - || text.includes("only 'application/json' is allowed") - || text.includes('only "application/json" is allowed') - || (status === 400 && text.includes('unsupported')) - || text.includes('not implemented') - || text.includes('api not implemented') - || text.includes('unsupported legacy protocol') - || parsedCode === 'convert_request_failed' - || parsedCode === 'not_found' - || parsedCode === 'endpoint_not_found' - || parsedCode === 'unknown_endpoint' - || parsedCode === 'unsupported_endpoint' - || parsedCode === 'bad_response_status_code' - || parsedCode === 'openai_error' - || parsedCode === 'upstream_error' - || parsedType === 'not_found_error' - || parsedType === 'invalid_request_error' - || parsedType === 'unsupported_endpoint' - || parsedType === 'unsupported_path' - || parsedType === 'bad_response_status_code' - || parsedType === 'openai_error' - || parsedType === 'upstream_error' - || parsedMessage.includes('unknown endpoint') - || parsedMessage.includes('unsupported endpoint') - || parsedMessage.includes('unsupported path') - || parsedMessage.includes('unrecognized request url') - || parsedMessage.includes('no route matched') - || parsedMessage.includes('does not exist') - || parsedMessage.includes('bad_response_status_code') - || parsedMessage === 'openai_error' - || parsedMessage === 'upstream_error' - || parsedMessage.includes('unsupported media type') - || parsedMessage.includes("only 'application/json' is allowed") - || parsedMessage.includes('only "application/json" is allowed') - || ( - status === 400 - && parsedCode === 'invalid_request' - && parsedType === 'new_api_error' - && (parsedMessage.includes('claude code cli') || text.includes('claude code cli')) - ) + const anthropicVersion = ( + effectiveClaudeHeaders['anthropic-version'] + || '2023-06-01' ); -} + const isClaudeOauthUpstream = sitePlatform === 'claude' && input.oauthProvider === 'claude'; + const headers = buildClaudeRuntimeHeaders({ + baseHeaders: { + 'Content-Type': 'application/json', + }, + claudeHeaders: effectiveClaudeHeaders, + anthropicVersion, + stream: false, + isClaudeOauthUpstream, + tokenValue: input.tokenValue, + }); + return { + path: '/v1/messages/count_tokens?beta=true', + headers, + body: sanitizedBody, + runtime: { + executor: 'claude', + modelName: input.modelName, + stream: false, + action: 'countTokens', + }, + }; +} diff --git a/src/server/routes/proxy/upstreamUrl.ts b/src/server/routes/proxy/upstreamUrl.ts new file mode 100644 index 00000000..97ae8c83 --- /dev/null +++ b/src/server/routes/proxy/upstreamUrl.ts @@ -0,0 +1,58 @@ +function formatUrlOrigin(url: URL): string { + // URL.origin drops credentials; preserve them if present. + const username = url.username ? encodeURIComponent(url.username) : ''; + const password = url.password ? encodeURIComponent(url.password) : ''; + const auth = username + ? `${username}${password ? `:${password}` : ''}@` + : ''; + + return `${url.protocol}//${auth}${url.host}`; +} + +function joinPath(basePath: string, requestPath: string): string { + const base = basePath.replace(/\/+$/, ''); + const path = requestPath.startsWith('/') ? requestPath : `/${requestPath}`; + + if (!base || base === '/') return path || '/'; + if (!path || path === '/') return base; + return `${base}${path}`; +} + +export function buildUpstreamUrl(siteUrl: string, requestPath: string): string { + const baseRaw = typeof siteUrl === 'string' ? siteUrl.trim() : ''; + const pathRaw = typeof requestPath === 'string' ? requestPath.trim() : ''; + const fallbackBase = baseRaw.replace(/\/+$/, ''); + let path = pathRaw.startsWith('/') ? pathRaw : `/${pathRaw}`; + + if (!fallbackBase) return path || '/'; + if (!path || path === '/') return fallbackBase; + + try { + const parsed = new URL(baseRaw); + const basePath = parsed.pathname.replace(/\/+$/, ''); + const baseHasVersionSuffix = /\/(?:api\/)?v1$/i.test(basePath); + if (baseHasVersionSuffix) { + if (path === '/v1') { + path = '/'; + } else if (path.startsWith('/v1/')) { + path = path.slice('/v1'.length) || '/'; + } + } + + const joinedPath = joinPath(basePath, path); + return `${formatUrlOrigin(parsed)}${joinedPath}${parsed.search}${parsed.hash}`; + } catch { + // Ignore URL parsing errors and fall back to naive join. + const baseHasVersionSuffix = /\/(?:api\/)?v1$/i.test(fallbackBase); + if (baseHasVersionSuffix) { + if (path === '/v1') { + path = '/'; + } else if (path.startsWith('/v1/')) { + path = path.slice('/v1'.length) || '/'; + } + } + + return `${fallbackBase}${path}`; + } +} + diff --git a/src/server/routes/proxy/videos.ts b/src/server/routes/proxy/videos.ts index 95feaa7c..9be4c3cf 100644 --- a/src/server/routes/proxy/videos.ts +++ b/src/server/routes/proxy/videos.ts @@ -1,22 +1,23 @@ import { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'; import { fetch } from 'undici'; import { tokenRouter } from '../../services/tokenRouter.js'; -import { refreshModelsAndRebuildRoutes } from '../../services/modelService.js'; +import * as routeRefreshWorkflow from '../../services/routeRefreshWorkflow.js'; import { reportProxyAllFailed, reportTokenExpired } from '../../services/alertService.js'; import { isTokenExpiredError } from '../../services/alertRules.js'; import { estimateProxyCost } from '../../services/modelPricingService.js'; import { shouldRetryProxyRequest } from '../../services/proxyRetryPolicy.js'; import { ensureModelAllowedForDownstreamKey, getDownstreamRoutingPolicy, recordDownstreamCostUsage } from './downstreamPolicy.js'; -import { withSiteRecordProxyRequestInit } from '../../services/siteProxy.js'; +import { withSiteProxyRequestInit, withSiteRecordProxyRequestInit } from '../../services/siteProxy.js'; +import { getProxyUrlFromExtraConfig } from '../../services/accountExtraConfig.js'; import { cloneFormDataWithOverrides, ensureMultipartBufferParser, parseMultipartFormData } from './multipart.js'; +import { buildUpstreamUrl } from './upstreamUrl.js'; import { deleteProxyVideoTaskByPublicId, getProxyVideoTaskByPublicId, refreshProxyVideoTaskSnapshot, saveProxyVideoTask, } from '../../services/proxyVideoTaskStore.js'; - -const MAX_RETRIES = 2; +import { canRetryProxyChannel, getProxyMaxChannelRetries } from '../../services/proxyChannelRetry.js'; function rewriteVideoResponsePublicId(payload: unknown, publicId: string): unknown { if (!payload || typeof payload !== 'object') return payload; @@ -49,13 +50,13 @@ export async function videosProxyRoute(app: FastifyInstance) { const excludeChannelIds: number[] = []; let retryCount = 0; - while (retryCount <= MAX_RETRIES) { + while (retryCount <= getProxyMaxChannelRetries()) { let selected = retryCount === 0 ? await tokenRouter.selectChannel(requestedModel, downstreamPolicy) : await tokenRouter.selectNextChannel(requestedModel, excludeChannelIds, downstreamPolicy); if (!selected && retryCount === 0) { - await refreshModelsAndRebuildRoutes(); + await routeRefreshWorkflow.refreshModelsAndRebuildRoutes(); selected = await tokenRouter.selectChannel(requestedModel, downstreamPolicy); } @@ -70,10 +71,12 @@ export async function videosProxyRoute(app: FastifyInstance) { } excludeChannelIds.push(selected.channel.id); - const targetUrl = `${selected.site.url}/v1/videos`; + const targetUrl = buildUpstreamUrl(selected.site.url, '/v1/videos'); + const upstreamModel = selected.actualModel || requestedModel; const startTime = Date.now(); try { + const accountProxy = getProxyUrlFromExtraConfig(selected.account.extraConfig); const requestInit = multipartForm ? withSiteRecordProxyRequestInit(selected.site, { method: 'POST', @@ -81,9 +84,9 @@ export async function videosProxyRoute(app: FastifyInstance) { Authorization: `Bearer ${selected.tokenValue}`, }, body: cloneFormDataWithOverrides(multipartForm, { - model: selected.actualModel || requestedModel, + model: upstreamModel, }) as any, - }) + }, accountProxy) : withSiteRecordProxyRequestInit(selected.site, { method: 'POST', headers: { @@ -92,14 +95,18 @@ export async function videosProxyRoute(app: FastifyInstance) { }, body: JSON.stringify({ ...(jsonBody || {}), - model: selected.actualModel || requestedModel, + model: upstreamModel, }), - }); + }, accountProxy); const upstream = await fetch(targetUrl, requestInit); const text = await upstream.text(); if (!upstream.ok) { - tokenRouter.recordFailure(selected.channel.id); + await recordTokenRouterEventBestEffort('record channel failure', () => tokenRouter.recordFailure(selected.channel.id, { + status: upstream.status, + errorText: text, + modelName: upstreamModel, + })); if (isTokenExpiredError({ status: upstream.status, message: text })) { await reportTokenExpired({ accountId: selected.account.id, @@ -108,7 +115,7 @@ export async function videosProxyRoute(app: FastifyInstance) { detail: `HTTP ${upstream.status}`, }); } - if (shouldRetryProxyRequest(upstream.status, text) && retryCount < MAX_RETRIES) { + if (shouldRetryProxyRequest(upstream.status, text) && canRetryProxyChannel(retryCount)) { retryCount += 1; continue; } @@ -133,7 +140,7 @@ export async function videosProxyRoute(app: FastifyInstance) { siteUrl: selected.site.url, tokenValue: selected.tokenValue, requestedModel, - actualModel: selected.actualModel || requestedModel, + actualModel: upstreamModel, channelId: typeof selected.channel.id === 'number' ? selected.channel.id : null, accountId: typeof selected.account.id === 'number' ? selected.account.id : null, statusSnapshot: data, @@ -147,17 +154,23 @@ export async function videosProxyRoute(app: FastifyInstance) { const estimatedCost = await estimateProxyCost({ site: selected.site, account: selected.account, - modelName: selected.actualModel || requestedModel, + modelName: upstreamModel, promptTokens: 0, completionTokens: 0, totalTokens: 0, }); - tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost); + await recordTokenRouterEventBestEffort('record channel success', () => ( + tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost, upstreamModel) + )); recordDownstreamCostUsage(request, estimatedCost); return reply.code(upstream.status).send(rewriteVideoResponsePublicId(data, mapping.publicId)); } catch (error: any) { - tokenRouter.recordFailure(selected.channel.id); - if (retryCount < MAX_RETRIES) { + await recordTokenRouterEventBestEffort('record channel failure', () => tokenRouter.recordFailure(selected.channel.id, { + status: 0, + errorText: error?.message || 'network failure', + modelName: upstreamModel, + })); + if (canRetryProxyChannel(retryCount)) { retryCount += 1; continue; } @@ -180,12 +193,13 @@ export async function videosProxyRoute(app: FastifyInstance) { }); } - const upstream = await fetch(`${mapping.siteUrl}/v1/videos/${encodeURIComponent(mapping.upstreamVideoId)}`, { + const targetUrl = buildUpstreamUrl(mapping.siteUrl, `/v1/videos/${encodeURIComponent(mapping.upstreamVideoId)}`); + const upstream = await fetch(targetUrl, await withSiteProxyRequestInit(targetUrl, { method: 'GET', headers: { Authorization: `Bearer ${mapping.tokenValue}`, }, - }); + })); const text = await upstream.text(); try { const data = JSON.parse(text); @@ -210,12 +224,13 @@ export async function videosProxyRoute(app: FastifyInstance) { }); } - const upstream = await fetch(`${mapping.siteUrl}/v1/videos/${encodeURIComponent(mapping.upstreamVideoId)}`, { + const targetUrl = buildUpstreamUrl(mapping.siteUrl, `/v1/videos/${encodeURIComponent(mapping.upstreamVideoId)}`); + const upstream = await fetch(targetUrl, await withSiteProxyRequestInit(targetUrl, { method: 'DELETE', headers: { Authorization: `Bearer ${mapping.tokenValue}`, }, - }); + })); if (upstream.ok) { await deleteProxyVideoTaskByPublicId(mapping.publicId); return reply.code(upstream.status).send(); @@ -227,3 +242,14 @@ export async function videosProxyRoute(app: FastifyInstance) { }); }); } + +async function recordTokenRouterEventBestEffort( + label: string, + operation: () => Promise, +): Promise { + try { + await operation(); + } catch (error) { + console.warn(`[proxy/videos] failed to ${label}`, error); + } +} diff --git a/src/server/runtimeDatabaseBootstrap.test.ts b/src/server/runtimeDatabaseBootstrap.test.ts new file mode 100644 index 00000000..b71f8fba --- /dev/null +++ b/src/server/runtimeDatabaseBootstrap.test.ts @@ -0,0 +1,52 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { runSqliteMigrations } from './db/migrate.js'; +import { + __runtimeDatabaseBootstrapTestUtils, + ensureRuntimeDatabaseReady, + runSqliteRuntimeMigrations, +} from './runtimeDatabaseBootstrap.js'; + +vi.mock('./db/migrate.js', () => ({ + runSqliteMigrations: vi.fn(), +})); + +describe('runtimeDatabaseBootstrap', () => { + beforeEach(() => { + __runtimeDatabaseBootstrapTestUtils.resetSqliteMigrationsBootstrapped(); + vi.clearAllMocks(); + }); + + it('runs sqlite migrations on the first runtime bootstrap call', async () => { + await runSqliteRuntimeMigrations(); + + expect(runSqliteMigrations).toHaveBeenCalledTimes(1); + }); + + it('runs sqlite runtime migrations when dialect is sqlite', async () => { + const runSqliteRuntimeMigrations = vi.fn(async () => {}); + const ensureExternalRuntimeSchema = vi.fn(async () => {}); + + await ensureRuntimeDatabaseReady({ + dialect: 'sqlite', + runSqliteRuntimeMigrations, + ensureExternalRuntimeSchema, + }); + + expect(runSqliteRuntimeMigrations).toHaveBeenCalledTimes(1); + expect(ensureExternalRuntimeSchema).not.toHaveBeenCalled(); + }); + + it.each(['postgres', 'mysql'] as const)('bootstraps external schema when dialect is %s', async (dialect) => { + const runSqliteRuntimeMigrations = vi.fn(async () => {}); + const ensureExternalRuntimeSchema = vi.fn(async () => {}); + + await ensureRuntimeDatabaseReady({ + dialect, + runSqliteRuntimeMigrations, + ensureExternalRuntimeSchema, + }); + + expect(ensureExternalRuntimeSchema).toHaveBeenCalledTimes(1); + expect(runSqliteRuntimeMigrations).not.toHaveBeenCalled(); + }); +}); diff --git a/src/server/runtimeDatabaseBootstrap.ts b/src/server/runtimeDatabaseBootstrap.ts new file mode 100644 index 00000000..2fbd9808 --- /dev/null +++ b/src/server/runtimeDatabaseBootstrap.ts @@ -0,0 +1,50 @@ +import { + bootstrapRuntimeDatabaseSchema, + type RuntimeSchemaDialect, +} from './db/runtimeSchemaBootstrap.js'; + +let sqliteMigrationsBootstrapped = false; + +export async function runSqliteRuntimeMigrations(): Promise { + const migrateModule = await import('./db/migrate.js'); + if (!sqliteMigrationsBootstrapped) { + sqliteMigrationsBootstrapped = true; + } + migrateModule.runSqliteMigrations(); +} + +type EnsureRuntimeDatabaseReadyInput = { + dialect: RuntimeSchemaDialect; + connectionString?: string; + ssl?: boolean; + runSqliteRuntimeMigrations?: () => Promise; + ensureExternalRuntimeSchema?: () => Promise; +}; + +export async function ensureRuntimeDatabaseReady(input: EnsureRuntimeDatabaseReadyInput): Promise { + if (input.dialect === 'sqlite') { + const runSqlite = input.runSqliteRuntimeMigrations || runSqliteRuntimeMigrations; + await runSqlite(); + return; + } + + const ensureExternal = input.ensureExternalRuntimeSchema || (async () => { + const connectionString = (input.connectionString || '').trim(); + if (!connectionString) { + throw new Error(`DB_URL is required when DB_TYPE=${input.dialect}`); + } + await bootstrapRuntimeDatabaseSchema({ + dialect: input.dialect, + connectionString, + ssl: !!input.ssl, + }); + }); + + await ensureExternal(); +} + +export const __runtimeDatabaseBootstrapTestUtils = { + resetSqliteMigrationsBootstrapped() { + sqliteMigrationsBootstrapped = false; + }, +}; diff --git a/src/server/services/accountExtraConfig.test.ts b/src/server/services/accountExtraConfig.test.ts index a6917963..ef968226 100644 --- a/src/server/services/accountExtraConfig.test.ts +++ b/src/server/services/accountExtraConfig.test.ts @@ -1,18 +1,24 @@ import { describe, expect, it } from 'vitest'; import { + buildStoredSub2ApiSubscriptionSummary, getCredentialModeFromExtraConfig, getPlatformUserIdFromExtraConfig, + getProxyUrlFromExtraConfig, getSub2ApiAuthFromExtraConfig, + getSub2ApiSubscriptionFromExtraConfig, guessPlatformUserIdFromUsername, mergeAccountExtraConfig, normalizeCredentialMode, resolvePlatformUserId, + requiresManagedAccountTokens, + supportsDirectAccountRoutingConnection, } from './accountExtraConfig.js'; describe('accountExtraConfig', () => { it('reads platformUserId from extra config when present', () => { expect(getPlatformUserIdFromExtraConfig(JSON.stringify({ platformUserId: 11494 }))).toBe(11494); expect(getPlatformUserIdFromExtraConfig(JSON.stringify({ platformUserId: '7659' }))).toBe(7659); + expect(getPlatformUserIdFromExtraConfig({ platformUserId: 2233 })).toBe(2233); }); it('guesses platformUserId from username suffix digits', () => { @@ -42,6 +48,27 @@ describe('accountExtraConfig', () => { expect(parsed.platformUserId).toBe(7659); }); + it('merges object extra config without dropping existing keys', () => { + const merged = mergeAccountExtraConfig( + { + foo: 'bar', + credentialMode: 'session', + autoRelogin: { username: 'demo', passwordCipher: 'cipher' }, + }, + { platformUserId: 9001 }, + ); + + expect(JSON.parse(merged)).toEqual(expect.objectContaining({ + foo: 'bar', + credentialMode: 'session', + platformUserId: 9001, + autoRelogin: expect.objectContaining({ + username: 'demo', + passwordCipher: 'cipher', + }), + })); + }); + it('parses credential mode from extra config', () => { expect(getCredentialModeFromExtraConfig(JSON.stringify({ credentialMode: 'apikey' }))).toBe('apikey'); expect(getCredentialModeFromExtraConfig(JSON.stringify({ credentialMode: 'session' }))).toBe('session'); @@ -67,4 +94,87 @@ describe('accountExtraConfig', () => { sub2apiAuth: { refreshToken: ' ' }, }))).toBeNull(); }); + + it('reads proxyUrl from extra config', () => { + expect(getProxyUrlFromExtraConfig(JSON.stringify({ proxyUrl: 'http://127.0.0.1:7890' }))).toBe('http://127.0.0.1:7890'); + expect(getProxyUrlFromExtraConfig(JSON.stringify({ proxyUrl: ' socks5://proxy.local:1080 ' }))).toBe('socks5://proxy.local:1080'); + }); + + it('returns null for missing or empty proxyUrl', () => { + expect(getProxyUrlFromExtraConfig(JSON.stringify({}))).toBeNull(); + expect(getProxyUrlFromExtraConfig(JSON.stringify({ proxyUrl: '' }))).toBeNull(); + expect(getProxyUrlFromExtraConfig(JSON.stringify({ proxyUrl: ' ' }))).toBeNull(); + expect(getProxyUrlFromExtraConfig(null)).toBeNull(); + expect(getProxyUrlFromExtraConfig(undefined)).toBeNull(); + expect(getProxyUrlFromExtraConfig('invalid-json')).toBeNull(); + }); + + it('treats auto-mode api token connections as direct-account routable', () => { + expect(supportsDirectAccountRoutingConnection({ + accessToken: '', + apiToken: 'sk-demo', + extraConfig: null, + })).toBe(true); + expect(requiresManagedAccountTokens({ + accessToken: '', + apiToken: 'sk-demo', + extraConfig: null, + })).toBe(false); + }); + + it('treats oauth and session connections as non-managed-token direct routes only when intended', () => { + expect(supportsDirectAccountRoutingConnection({ + accessToken: 'oauth-access-token', + apiToken: null, + extraConfig: JSON.stringify({ credentialMode: 'session', oauth: { provider: 'codex' } }), + })).toBe(true); + expect(requiresManagedAccountTokens({ + accessToken: 'oauth-access-token', + apiToken: null, + extraConfig: JSON.stringify({ credentialMode: 'session', oauth: { provider: 'codex' } }), + })).toBe(false); + expect(supportsDirectAccountRoutingConnection({ + accessToken: 'session-token', + apiToken: 'sk-default', + extraConfig: JSON.stringify({ credentialMode: 'session' }), + })).toBe(false); + expect(requiresManagedAccountTokens({ + accessToken: 'session-token', + apiToken: 'sk-default', + extraConfig: JSON.stringify({ credentialMode: 'session' }), + })).toBe(true); + }); + + it('parses stored sub2api subscription summary from extra config', () => { + const extraConfig = mergeAccountExtraConfig(null, { + sub2apiSubscription: buildStoredSub2ApiSubscriptionSummary({ + activeCount: 1, + totalUsedUsd: 3.5, + subscriptions: [ + { + id: 7, + groupName: 'Pro', + expiresAt: '2026-04-01T00:00:00.000Z', + monthlyUsedUsd: 3.5, + monthlyLimitUsd: 20, + }, + ], + }, 1760000000000), + }); + + expect(getSub2ApiSubscriptionFromExtraConfig(extraConfig)).toEqual({ + activeCount: 1, + totalUsedUsd: 3.5, + subscriptions: [ + { + id: 7, + groupName: 'Pro', + expiresAt: '2026-04-01T00:00:00.000Z', + monthlyUsedUsd: 3.5, + monthlyLimitUsd: 20, + }, + ], + updatedAt: 1760000000000, + }); + }); }); diff --git a/src/server/services/accountExtraConfig.ts b/src/server/services/accountExtraConfig.ts index 280ee697..46b9d27c 100644 --- a/src/server/services/accountExtraConfig.ts +++ b/src/server/services/accountExtraConfig.ts @@ -1,3 +1,5 @@ +import type { SubscriptionPlanSummary, SubscriptionSummary } from './platforms/base.js'; + type AutoReloginConfig = { username?: unknown; passwordCipher?: unknown; @@ -9,6 +11,13 @@ type Sub2ApiAuthConfig = { tokenExpiresAt?: unknown; }; +type Sub2ApiSubscriptionConfig = { + updatedAt?: unknown; + activeCount?: unknown; + totalUsedUsd?: unknown; + subscriptions?: unknown; +}; + export type AccountCredentialMode = 'auto' | 'session' | 'apikey'; const VALID_CREDENTIAL_MODES = new Set([ @@ -20,16 +29,29 @@ const VALID_CREDENTIAL_MODES = new Set([ type AccountExtraConfig = { platformUserId?: unknown; credentialMode?: unknown; + oauth?: { + provider?: unknown; + [key: string]: unknown; + }; autoRelogin?: AutoReloginConfig; sub2apiAuth?: Sub2ApiAuthConfig; + sub2apiSubscription?: Sub2ApiSubscriptionConfig; [key: string]: unknown; }; -function parseExtraConfig(extraConfig?: string | null): AccountExtraConfig { +type ExtraConfigInput = string | Record | null | undefined; + +function isRecord(value: unknown): value is Record { + return !!value && typeof value === 'object' && !Array.isArray(value); +} + +function parseExtraConfig(extraConfig?: ExtraConfigInput): AccountExtraConfig { if (!extraConfig) return {}; + if (isRecord(extraConfig)) return extraConfig as AccountExtraConfig; + if (typeof extraConfig !== 'string') return {}; try { const parsed = JSON.parse(extraConfig) as unknown; - if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {}; + if (!isRecord(parsed)) return {}; return parsed as AccountExtraConfig; } catch { return {}; @@ -60,6 +82,39 @@ function normalizeTimestampMs(raw: unknown): number | undefined { return undefined; } +function normalizeNonNegativeNumber(raw: unknown): number | undefined { + if (typeof raw === 'number' && Number.isFinite(raw) && raw >= 0) { + return Math.round(raw * 1_000_000) / 1_000_000; + } + if (typeof raw === 'string') { + const parsed = Number(raw.trim()); + if (Number.isFinite(parsed) && parsed >= 0) { + return Math.round(parsed * 1_000_000) / 1_000_000; + } + } + return undefined; +} + +function normalizeIsoDateTime(raw: unknown): string | undefined { + if (typeof raw === 'number' && Number.isFinite(raw) && raw > 0) { + const ms = raw > 10_000_000_000 ? raw : raw * 1000; + return new Date(ms).toISOString(); + } + if (typeof raw !== 'string') return undefined; + const trimmed = raw.trim(); + if (!trimmed) return undefined; + + const numeric = Number(trimmed); + if (Number.isFinite(numeric) && numeric > 0) { + const ms = numeric > 10_000_000_000 ? numeric : numeric * 1000; + return new Date(ms).toISOString(); + } + + const parsed = Date.parse(trimmed); + if (Number.isFinite(parsed)) return new Date(parsed).toISOString(); + return undefined; +} + export function normalizeCredentialMode(raw: unknown): AccountCredentialMode | undefined { if (typeof raw !== 'string') return undefined; const normalized = raw.trim().toLowerCase(); @@ -67,22 +122,74 @@ export function normalizeCredentialMode(raw: unknown): AccountCredentialMode | u return normalized as AccountCredentialMode; } -export function getPlatformUserIdFromExtraConfig(extraConfig?: string | null): number | undefined { +export function getProxyUrlFromExtraConfig(extraConfig?: ExtraConfigInput): string | null { + const parsed = parseExtraConfig(extraConfig); + return normalizeNonEmptyString(parsed.proxyUrl) ?? null; +} + +export function getPlatformUserIdFromExtraConfig(extraConfig?: ExtraConfigInput): number | undefined { const parsed = parseExtraConfig(extraConfig); return normalizeUserId(parsed.platformUserId); } -export function getCredentialModeFromExtraConfig(extraConfig?: string | null): AccountCredentialMode | undefined { +export function getCredentialModeFromExtraConfig(extraConfig?: ExtraConfigInput): AccountCredentialMode | undefined { const parsed = parseExtraConfig(extraConfig); return normalizeCredentialMode(parsed.credentialMode); } +export function getOauthProviderFromExtraConfig(extraConfig?: ExtraConfigInput): string | undefined { + const parsed = parseExtraConfig(extraConfig); + return normalizeNonEmptyString(parsed.oauth?.provider); +} + +export function hasOauthProvider(extraConfig?: ExtraConfigInput): boolean { + return !!getOauthProviderFromExtraConfig(extraConfig); +} + +type DirectAccountRoutingInput = { + accessToken?: string | null; + apiToken?: string | null; + extraConfig?: ExtraConfigInput; +}; + +function hasCredentialValue(value: string | null | undefined): boolean { + return typeof value === 'string' && value.trim().length > 0; +} + +export function supportsDirectAccountRoutingConnection(account: DirectAccountRoutingInput): boolean { + const credentialMode = getCredentialModeFromExtraConfig(account.extraConfig); + if (hasOauthProvider(account.extraConfig)) { + return hasCredentialValue(account.accessToken) || hasCredentialValue(account.apiToken); + } + if (credentialMode === 'apikey') { + return hasCredentialValue(account.apiToken); + } + if (credentialMode === 'session') { + return false; + } + if (hasCredentialValue(account.accessToken)) return false; + return hasCredentialValue(account.apiToken); +} + +export function requiresManagedAccountTokens(account: DirectAccountRoutingInput): boolean { + const credentialMode = getCredentialModeFromExtraConfig(account.extraConfig); + if (hasOauthProvider(account.extraConfig)) return false; + if (credentialMode === 'apikey') return false; + if (credentialMode === 'session') return true; + if (hasCredentialValue(account.apiToken) && !hasCredentialValue(account.accessToken)) return false; + return true; +} + export type ManagedSub2ApiAuth = { refreshToken: string; tokenExpiresAt?: number; }; -export function getSub2ApiAuthFromExtraConfig(extraConfig?: string | null): ManagedSub2ApiAuth | null { +export type StoredSub2ApiSubscriptionSummary = SubscriptionSummary & { + updatedAt: number; +}; + +export function getSub2ApiAuthFromExtraConfig(extraConfig?: ExtraConfigInput): ManagedSub2ApiAuth | null { const parsed = parseExtraConfig(extraConfig); const raw = parsed.sub2apiAuth; if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null; @@ -94,6 +201,103 @@ export function getSub2ApiAuthFromExtraConfig(extraConfig?: string | null): Mana : { refreshToken }; } +function normalizeSubscriptionItem(raw: unknown): SubscriptionPlanSummary | null { + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null; + + const item = raw as Record; + const normalized: SubscriptionPlanSummary = {}; + + const id = normalizeUserId(item.id); + if (id) normalized.id = id; + + const groupId = normalizeUserId(item.groupId ?? item.group_id); + if (groupId) normalized.groupId = groupId; + + const groupName = normalizeNonEmptyString(item.groupName ?? item.group_name); + if (groupName) normalized.groupName = groupName; + + const status = normalizeNonEmptyString(item.status); + if (status) normalized.status = status; + + const expiresAt = normalizeIsoDateTime( + item.expiresAt + ?? item.expires_at + ?? item.expiredAt + ?? item.expired_at + ?? item.endAt + ?? item.end_at, + ); + if (expiresAt) normalized.expiresAt = expiresAt; + + const dailyUsedUsd = normalizeNonNegativeNumber(item.dailyUsedUsd ?? item.daily_used_usd); + if (dailyUsedUsd !== undefined) normalized.dailyUsedUsd = dailyUsedUsd; + + const dailyLimitUsd = normalizeNonNegativeNumber(item.dailyLimitUsd ?? item.daily_limit_usd); + if (dailyLimitUsd !== undefined) normalized.dailyLimitUsd = dailyLimitUsd; + + const weeklyUsedUsd = normalizeNonNegativeNumber(item.weeklyUsedUsd ?? item.weekly_used_usd); + if (weeklyUsedUsd !== undefined) normalized.weeklyUsedUsd = weeklyUsedUsd; + + const weeklyLimitUsd = normalizeNonNegativeNumber(item.weeklyLimitUsd ?? item.weekly_limit_usd); + if (weeklyLimitUsd !== undefined) normalized.weeklyLimitUsd = weeklyLimitUsd; + + const monthlyUsedUsd = normalizeNonNegativeNumber(item.monthlyUsedUsd ?? item.monthly_used_usd); + if (monthlyUsedUsd !== undefined) normalized.monthlyUsedUsd = monthlyUsedUsd; + + const monthlyLimitUsd = normalizeNonNegativeNumber(item.monthlyLimitUsd ?? item.monthly_limit_usd); + if (monthlyLimitUsd !== undefined) normalized.monthlyLimitUsd = monthlyLimitUsd; + + return Object.keys(normalized).length > 0 ? normalized : null; +} + +function normalizeSubscriptionItems(raw: unknown): SubscriptionPlanSummary[] { + if (!Array.isArray(raw)) return []; + return raw + .map((item) => normalizeSubscriptionItem(item)) + .filter((item): item is SubscriptionPlanSummary => !!item); +} + +export function normalizeSub2ApiSubscriptionSummary( + raw: unknown, +): StoredSub2ApiSubscriptionSummary | null { + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null; + + const body = raw as Record; + const subscriptions = normalizeSubscriptionItems(body.subscriptions); + const activeCount = normalizeNonNegativeNumber(body.activeCount ?? body.active_count); + const totalUsedUsd = normalizeNonNegativeNumber(body.totalUsedUsd ?? body.total_used_usd); + const updatedAt = normalizeTimestampMs(body.updatedAt ?? body.updated_at); + + return { + activeCount: Math.trunc(activeCount ?? subscriptions.length), + totalUsedUsd: totalUsedUsd ?? 0, + subscriptions, + updatedAt: updatedAt ?? Date.now(), + }; +} + +export function buildStoredSub2ApiSubscriptionSummary( + summary: SubscriptionSummary, + updatedAt = Date.now(), +): StoredSub2ApiSubscriptionSummary { + return normalizeSub2ApiSubscriptionSummary({ + ...summary, + updatedAt, + }) || { + activeCount: Math.max(0, Math.trunc(summary.activeCount || 0)), + totalUsedUsd: normalizeNonNegativeNumber(summary.totalUsedUsd) ?? 0, + subscriptions: normalizeSubscriptionItems(summary.subscriptions), + updatedAt, + }; +} + +export function getSub2ApiSubscriptionFromExtraConfig( + extraConfig?: ExtraConfigInput, +): StoredSub2ApiSubscriptionSummary | null { + const parsed = parseExtraConfig(extraConfig); + return normalizeSub2ApiSubscriptionSummary(parsed.sub2apiSubscription); +} + export function guessPlatformUserIdFromUsername(username?: string | null): number | undefined { const text = (username || '').trim(); if (!text) return undefined; @@ -102,12 +306,12 @@ export function guessPlatformUserIdFromUsername(username?: string | null): numbe return normalizeUserId(match[1]); } -export function resolvePlatformUserId(extraConfig?: string | null, username?: string | null): number | undefined { +export function resolvePlatformUserId(extraConfig?: ExtraConfigInput, username?: string | null): number | undefined { return getPlatformUserIdFromExtraConfig(extraConfig) || guessPlatformUserIdFromUsername(username); } export function mergeAccountExtraConfig( - extraConfig: string | null | undefined, + extraConfig: ExtraConfigInput, patch: Record, ): string { const merged: Record = { @@ -117,7 +321,7 @@ export function mergeAccountExtraConfig( return JSON.stringify(merged); } -export function getAutoReloginConfig(extraConfig?: string | null): { +export function getAutoReloginConfig(extraConfig?: ExtraConfigInput): { username: string; passwordCipher: string; } | null { diff --git a/src/server/services/accountHealthService.test.ts b/src/server/services/accountHealthService.test.ts index edecfde0..cba44129 100644 --- a/src/server/services/accountHealthService.test.ts +++ b/src/server/services/accountHealthService.test.ts @@ -79,6 +79,28 @@ describe('accountHealthService', () => { }); }); + it('returns stored runtime health when extra config is already a parsed object', () => { + const health = buildRuntimeHealthForAccount({ + accountStatus: 'active', + siteStatus: 'active', + extraConfig: { + runtimeHealth: { + state: 'healthy', + reason: '余额刷新成功', + source: 'balance', + checkedAt: '2026-02-25T12:00:00.000Z', + }, + }, + }); + + expect(health).toMatchObject({ + state: 'healthy', + reason: '余额刷新成功', + source: 'balance', + checkedAt: '2026-02-25T12:00:00.000Z', + }); + }); + it('falls back to unknown when no runtime health info exists', () => { const health = buildRuntimeHealthForAccount({ accountStatus: 'active', diff --git a/src/server/services/accountHealthService.ts b/src/server/services/accountHealthService.ts index d2a7151d..9ea05173 100644 --- a/src/server/services/accountHealthService.ts +++ b/src/server/services/accountHealthService.ts @@ -19,12 +19,17 @@ const VALID_RUNTIME_HEALTH_STATES = new Set([ 'disabled', ]); -function parseObject(value: string | null | undefined): Record { +function isRecord(value: unknown): value is Record { + return !!value && typeof value === 'object' && !Array.isArray(value); +} + +function parseObject(value: string | Record | null | undefined): Record { if (!value) return {}; + if (isRecord(value)) return value; + if (typeof value !== 'string') return {}; try { const parsed = JSON.parse(value); - if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {}; - return parsed as Record; + return isRecord(parsed) ? parsed : {}; } catch { return {}; } @@ -77,7 +82,7 @@ function defaultHealthReason(state: RuntimeHealthState): string { } } -export function extractRuntimeHealth(extraConfig?: string | null): RuntimeHealthInfo | null { +export function extractRuntimeHealth(extraConfig?: string | Record | null): RuntimeHealthInfo | null { const parsed = parseObject(extraConfig); return normalizeRuntimeHealthRecord(parsed.runtimeHealth); } @@ -94,7 +99,7 @@ function isProxyOnlyAuthFailure( export function buildRuntimeHealthForAccount(input: { accountStatus?: string | null; siteStatus?: string | null; - extraConfig?: string | null; + extraConfig?: string | Record | null; sessionCapable?: boolean; hasDiscoveredModels?: boolean; }): RuntimeHealthInfo { diff --git a/src/server/services/accountMutationWorkflow.test.ts b/src/server/services/accountMutationWorkflow.test.ts new file mode 100644 index 00000000..9be12b54 --- /dev/null +++ b/src/server/services/accountMutationWorkflow.test.ts @@ -0,0 +1,167 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const ensureDefaultTokenForAccountMock = vi.fn(); +const syncTokensFromUpstreamMock = vi.fn(); +const refreshBalanceMock = vi.fn(); +const refreshModelsForAccountMock = vi.fn(); +const rebuildTokenRoutesFromAvailabilityMock = vi.fn(); + +vi.mock('./accountTokenService.js', () => ({ + ensureDefaultTokenForAccount: (...args: unknown[]) => ensureDefaultTokenForAccountMock(...args), + syncTokensFromUpstream: (...args: unknown[]) => syncTokensFromUpstreamMock(...args), +})); + +vi.mock('./balanceService.js', () => ({ + refreshBalance: (...args: unknown[]) => refreshBalanceMock(...args), +})); + +vi.mock('./modelService.js', () => ({ + refreshModelsForAccount: (...args: unknown[]) => refreshModelsForAccountMock(...args), + rebuildTokenRoutesFromAvailability: (...args: unknown[]) => rebuildTokenRoutesFromAvailabilityMock(...args), +})); + +describe('accountMutationWorkflow', () => { + beforeEach(() => { + ensureDefaultTokenForAccountMock.mockReset(); + syncTokensFromUpstreamMock.mockReset(); + refreshBalanceMock.mockReset(); + refreshModelsForAccountMock.mockReset(); + rebuildTokenRoutesFromAvailabilityMock.mockReset(); + }); + + it('can ensure a preferred token before syncing upstream tokens', async () => { + ensureDefaultTokenForAccountMock.mockResolvedValue(10); + syncTokensFromUpstreamMock.mockResolvedValue({ total: 2, created: 1, updated: 1 }); + refreshBalanceMock.mockResolvedValue({ balance: 1 }); + refreshModelsForAccountMock.mockResolvedValue({ accountId: 1, refreshed: true }); + rebuildTokenRoutesFromAvailabilityMock.mockResolvedValue({ createdRoutes: 1 }); + + const { convergeAccountMutation } = await import('./accountMutationWorkflow.js'); + const upstreamTokens = [{ name: 'default', key: 'sk-upstream', enabled: true }]; + const result = await convergeAccountMutation({ + accountId: 1, + preferredApiToken: 'sk-preferred', + defaultTokenSource: 'manual', + ensurePreferredTokenBeforeSync: true, + upstreamTokens, + refreshBalance: true, + refreshModels: true, + rebuildRoutes: true, + }); + + expect(ensureDefaultTokenForAccountMock).toHaveBeenCalledWith(1, 'sk-preferred', { + name: 'default', + source: 'manual', + }); + expect(syncTokensFromUpstreamMock).toHaveBeenCalledWith(1, upstreamTokens); + expect(refreshBalanceMock).toHaveBeenCalledWith(1); + expect(refreshModelsForAccountMock).toHaveBeenCalledWith(1); + expect(rebuildTokenRoutesFromAvailabilityMock).toHaveBeenCalledTimes(1); + expect(ensureDefaultTokenForAccountMock.mock.invocationCallOrder[0]).toBeLessThan( + syncTokensFromUpstreamMock.mock.invocationCallOrder[0]!, + ); + expect(result.defaultTokenId).toBe(10); + expect(result.tokenSync).toEqual({ total: 2, created: 1, updated: 1 }); + expect(result.refreshedBalance).toBe(true); + expect(result.refreshedModels).toBe(true); + expect(result.rebuiltRoutes).toBe(true); + }); + + it('falls back to ensuring the preferred token when upstream tokens are absent', async () => { + ensureDefaultTokenForAccountMock.mockResolvedValue(22); + + const { convergeAccountMutation } = await import('./accountMutationWorkflow.js'); + const result = await convergeAccountMutation({ + accountId: 2, + preferredApiToken: 'sk-fallback', + defaultTokenSource: 'sync', + }); + + expect(syncTokensFromUpstreamMock).not.toHaveBeenCalled(); + expect(ensureDefaultTokenForAccountMock).toHaveBeenCalledWith(2, 'sk-fallback', { + name: 'default', + source: 'sync', + }); + expect(result.defaultTokenId).toBe(22); + expect(result.tokenSync).toBeNull(); + }); + + it('continues through later refresh steps when continueOnError is enabled', async () => { + refreshBalanceMock.mockRejectedValue(new Error('balance failed')); + refreshModelsForAccountMock.mockResolvedValue({ accountId: 3, refreshed: true }); + rebuildTokenRoutesFromAvailabilityMock.mockResolvedValue({ createdRoutes: 0 }); + + const { convergeAccountMutation } = await import('./accountMutationWorkflow.js'); + const result = await convergeAccountMutation({ + accountId: 3, + refreshBalance: true, + refreshModels: true, + rebuildRoutes: true, + continueOnError: true, + }); + + expect(result.refreshedBalance).toBe(false); + expect(result.refreshedModels).toBe(true); + expect(result.rebuiltRoutes).toBe(true); + expect(refreshModelsForAccountMock).toHaveBeenCalledWith(3); + expect(rebuildTokenRoutesFromAvailabilityMock).toHaveBeenCalledTimes(1); + }); + + it('only marks refreshedModels when the refresh result explicitly says it refreshed', async () => { + refreshModelsForAccountMock.mockResolvedValue({ accountId: 4, refreshed: false, status: 'skipped' }); + + const { convergeAccountMutation } = await import('./accountMutationWorkflow.js'); + const result = await convergeAccountMutation({ + accountId: 4, + refreshModels: true, + }); + + expect(result.modelRefreshResult).toEqual({ accountId: 4, refreshed: false, status: 'skipped' }); + expect(result.refreshedModels).toBe(false); + }); + + it('refreshes model coverage in batches and maps failures', async () => { + refreshModelsForAccountMock + .mockResolvedValueOnce({ accountId: 1, refreshed: true, status: 'success' }) + .mockRejectedValueOnce(new Error('coverage failed')); + rebuildTokenRoutesFromAvailabilityMock.mockResolvedValue({ createdRoutes: 2 }); + + const { refreshAccountCoverageBatch } = await import('./accountMutationWorkflow.js'); + const result = await refreshAccountCoverageBatch({ + accountIds: [1, 2], + batchSize: 1, + mapFailure: (accountId, errorMessage) => ({ accountId, errorMessage }), + }); + + expect(result.refresh).toEqual([ + { accountId: 1, refreshed: true, status: 'success' }, + { accountId: 2, errorMessage: 'coverage failed' }, + ]); + expect(result.rebuild).toEqual({ + success: true, + result: { createdRoutes: 2 }, + }); + }); + + it('rejects invalid batch sizes before starting the batch loop', async () => { + const { refreshAccountCoverageBatch } = await import('./accountMutationWorkflow.js'); + + await expect(refreshAccountCoverageBatch({ + accountIds: [1, 2], + batchSize: 1.5, + mapFailure: (accountId, errorMessage) => ({ accountId, errorMessage }), + })).rejects.toThrow('batchSize must be a positive integer'); + + expect(refreshModelsForAccountMock).not.toHaveBeenCalled(); + }); + + it('exposes a best-effort route rebuild helper for controller callers', async () => { + rebuildTokenRoutesFromAvailabilityMock.mockResolvedValueOnce({ createdRoutes: 1 }); + + const { rebuildRoutesBestEffort } = await import('./accountMutationWorkflow.js'); + await expect(rebuildRoutesBestEffort()).resolves.toBe(true); + + rebuildTokenRoutesFromAvailabilityMock.mockRejectedValueOnce(new Error('rebuild failed')); + await expect(rebuildRoutesBestEffort()).resolves.toBe(false); + }); +}); diff --git a/src/server/services/accountMutationWorkflow.ts b/src/server/services/accountMutationWorkflow.ts new file mode 100644 index 00000000..7749ab51 --- /dev/null +++ b/src/server/services/accountMutationWorkflow.ts @@ -0,0 +1,191 @@ +import { refreshBalance } from './balanceService.js'; +import { + ensureDefaultTokenForAccount, + syncTokensFromUpstream, +} from './accountTokenService.js'; +import { + refreshModelsForAccount, + type ModelRefreshResult, +} from './modelService.js'; +import * as routeRefreshWorkflow from './routeRefreshWorkflow.js'; + +type UpstreamTokenLike = { + name?: string | null; + key?: string | null; + enabled?: boolean | null; + tokenGroup?: string | null; +}; + +export type CoverageBatchRebuildResult = + | { success: true; result: Awaited> } + | { success: false; error: string }; + +export async function rebuildRoutesBestEffort(): Promise { + return routeRefreshWorkflow.rebuildRoutesBestEffort(); +} + +export async function convergeAccountMutation(input: { + accountId: number; + preferredApiToken?: string | null; + defaultTokenSource?: string; + ensurePreferredTokenBeforeSync?: boolean; + upstreamTokens?: UpstreamTokenLike[]; + refreshBalance?: boolean; + refreshModels?: boolean; + rebuildRoutes?: boolean; + continueOnError?: boolean; +}): Promise<{ + defaultTokenId: number | null; + tokenSync: Awaited> | null; + refreshedBalance: boolean; + refreshedModels: boolean; + rebuiltRoutes: boolean; + balanceResult: Awaited> | null; + modelRefreshResult: ModelRefreshResult | null; + rebuildResult: Awaited> | null; +}> { + const result = { + defaultTokenId: null as number | null, + tokenSync: null as Awaited> | null, + refreshedBalance: false, + refreshedModels: false, + rebuiltRoutes: false, + balanceResult: null as Awaited> | null, + modelRefreshResult: null as ModelRefreshResult | null, + rebuildResult: null as Awaited> | null, + }; + + const runStep = async (fn: () => Promise): Promise => { + if (!input.continueOnError) return fn(); + try { + return await fn(); + } catch { + return null; + } + }; + + if (input.ensurePreferredTokenBeforeSync && input.preferredApiToken?.trim()) { + const defaultTokenId = await runStep(() => ensureDefaultTokenForAccount( + input.accountId, + input.preferredApiToken!, + { name: 'default', source: input.defaultTokenSource || 'manual' }, + )); + if (defaultTokenId != null) { + result.defaultTokenId = defaultTokenId; + } + } + + if ((input.upstreamTokens?.length || 0) > 0) { + const tokenSync = await runStep(() => syncTokensFromUpstream(input.accountId, input.upstreamTokens!)); + if (tokenSync) { + result.tokenSync = tokenSync; + result.defaultTokenId = tokenSync.defaultTokenId ?? result.defaultTokenId; + } + if (!input.ensurePreferredTokenBeforeSync && input.preferredApiToken?.trim()) { + const defaultTokenId = await runStep(() => ensureDefaultTokenForAccount( + input.accountId, + input.preferredApiToken!, + { name: 'default', source: input.defaultTokenSource || 'manual' }, + )); + if (defaultTokenId != null) { + result.defaultTokenId = defaultTokenId; + } + } + } else if (!input.ensurePreferredTokenBeforeSync && input.preferredApiToken?.trim()) { + const defaultTokenId = await runStep(() => ensureDefaultTokenForAccount( + input.accountId, + input.preferredApiToken!, + { name: 'default', source: input.defaultTokenSource || 'manual' }, + )); + if (defaultTokenId != null) { + result.defaultTokenId = defaultTokenId; + } + } + + if (input.refreshBalance) { + const balanceResult = await runStep(() => refreshBalance(input.accountId)); + if (balanceResult) { + result.balanceResult = balanceResult; + result.refreshedBalance = true; + } + } + + if (input.refreshModels) { + const modelRefreshResult = await runStep(() => refreshModelsForAccount(input.accountId)); + if (modelRefreshResult) { + result.modelRefreshResult = modelRefreshResult; + result.refreshedModels = modelRefreshResult.refreshed === true; + } + } + + if (input.rebuildRoutes) { + const rebuildResult = await runStep(() => routeRefreshWorkflow.rebuildRoutesOnly()); + if (rebuildResult) { + result.rebuildResult = rebuildResult; + result.rebuiltRoutes = true; + } + } + + return result; +} + +export async function refreshAccountCoverageBatch(input: { + accountIds: number[]; + batchSize: number; + mapFailure: (accountId: number, errorMessage: string) => TFailure; +}): Promise<{ + refresh: Array; + rebuild: CoverageBatchRebuildResult | null; +}> { + if (!Number.isInteger(input.batchSize) || input.batchSize <= 0) { + throw new Error('batchSize must be a positive integer'); + } + + const batchSize = input.batchSize; + const uniqueAccountIds = Array.from(new Set( + input.accountIds.filter((id) => Number.isFinite(id) && id > 0), + )); + + if (uniqueAccountIds.length === 0) { + return { refresh: [], rebuild: null }; + } + + const refresh: Array = []; + for (let offset = 0; offset < uniqueAccountIds.length; offset += batchSize) { + const batch = uniqueAccountIds.slice(offset, offset + batchSize); + const settled = await Promise.allSettled( + batch.map(async (accountId) => refreshModelsForAccount(accountId)), + ); + settled.forEach((entry, index) => { + if (entry.status === 'fulfilled') { + refresh.push(entry.value); + return; + } + + const accountId = batch[index] || 0; + const errorMessage = entry.reason instanceof Error + ? entry.reason.message + : String(entry.reason || 'coverage refresh failed'); + refresh.push(input.mapFailure(accountId, errorMessage)); + }); + } + + try { + return { + refresh, + rebuild: { + success: true, + result: await routeRefreshWorkflow.rebuildRoutesOnly(), + }, + }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error || 'route rebuild failed'); + return { + refresh, + rebuild: { + success: false, + error: errorMessage, + }, + }; + } +} diff --git a/src/server/services/accountTokenService.ts b/src/server/services/accountTokenService.ts index 57cf7138..8e85dbcd 100644 --- a/src/server/services/accountTokenService.ts +++ b/src/server/services/accountTokenService.ts @@ -9,6 +9,14 @@ type UpstreamApiToken = { tokenGroup?: string | null; }; +type AccountTokenRow = typeof schema.accountTokens.$inferSelect; + +export const ACCOUNT_TOKEN_VALUE_STATUS_READY = 'ready' as const; +export const ACCOUNT_TOKEN_VALUE_STATUS_MASKED_PENDING = 'masked_pending' as const; +export type AccountTokenValueStatus = + | typeof ACCOUNT_TOKEN_VALUE_STATUS_READY + | typeof ACCOUNT_TOKEN_VALUE_STATUS_MASKED_PENDING; + export function normalizeTokenForDisplay(token?: string | null, platform?: string | null): string { if (!token) return ''; const value = token.trim(); @@ -46,6 +54,86 @@ function normalizeTokenValue(token: string | null | undefined): string | null { return trimmed.length > 0 ? trimmed : null; } +export function isMaskedTokenValue(token: string | null | undefined): boolean { + const value = (token || '').trim(); + if (!value) return false; + return value.includes('*') || value.includes('•'); +} + +function normalizeMaskedTokenForCompare(token: string | null | undefined): string { + return normalizeTokenForDisplay(token).replace(/•/g, '*'); +} + +function matchesMaskedTokenValue( + fullToken: string | null | undefined, + maskedToken: string | null | undefined, +): boolean { + const normalizedFull = normalizeTokenForDisplay(fullToken); + const normalizedMasked = normalizeMaskedTokenForCompare(maskedToken); + if (!normalizedFull || !normalizedMasked) return false; + + if (!isMaskedTokenValue(normalizedMasked)) { + return normalizedFull === normalizedMasked; + } + + const firstMaskIndex = normalizedMasked.search(/[\*]/); + const lastMaskIndex = Math.max( + normalizedMasked.lastIndexOf('*'), + normalizedMasked.lastIndexOf('•'), + ); + if (firstMaskIndex < 0 || lastMaskIndex < firstMaskIndex) { + return normalizedFull === normalizedMasked; + } + + const prefix = normalizedMasked.slice(0, firstMaskIndex); + const suffix = normalizedMasked.slice(lastMaskIndex + 1); + const visiblePrefix = prefix.replace(/^sk-/i, ''); + if (!visiblePrefix && !suffix) return false; + if (normalizedFull.length < prefix.length + suffix.length) return false; + if (prefix && !normalizedFull.startsWith(prefix)) return false; + if (suffix && !normalizedFull.endsWith(suffix)) return false; + return true; +} + +function normalizeTokenValueStatus(value: string | null | undefined): AccountTokenValueStatus { + const normalized = (value || '').trim().toLowerCase(); + return normalized === ACCOUNT_TOKEN_VALUE_STATUS_MASKED_PENDING + ? ACCOUNT_TOKEN_VALUE_STATUS_MASKED_PENDING + : ACCOUNT_TOKEN_VALUE_STATUS_READY; +} + +export function resolveAccountTokenValueStatus( + value: Pick | string | null | undefined, +): AccountTokenValueStatus { + if (typeof value === 'string' || value == null) { + return normalizeTokenValueStatus(value); + } + + const explicit = normalizeTokenValueStatus(value.valueStatus); + if (explicit === ACCOUNT_TOKEN_VALUE_STATUS_MASKED_PENDING) { + return ACCOUNT_TOKEN_VALUE_STATUS_MASKED_PENDING; + } + return isMaskedTokenValue(value.token) + ? ACCOUNT_TOKEN_VALUE_STATUS_MASKED_PENDING + : ACCOUNT_TOKEN_VALUE_STATUS_READY; +} + +export function isReadyAccountToken(token: Pick | null | undefined): boolean { + if (!token) return false; + return resolveAccountTokenValueStatus(token) === ACCOUNT_TOKEN_VALUE_STATUS_READY + && !isMaskedTokenValue(token.token); +} + +export function isMaskedPendingAccountToken(token: Pick | null | undefined): boolean { + if (!token) return false; + return resolveAccountTokenValueStatus(token) === ACCOUNT_TOKEN_VALUE_STATUS_MASKED_PENDING; +} + +export function isUsableAccountToken(token: AccountTokenRow | null | undefined): boolean { + if (!token) return false; + return token.enabled === true && isReadyAccountToken(token); +} + function normalizeTokenGroup(value: string | null | undefined, tokenName?: string | null): string | null { const explicit = (value || '').trim(); if (explicit.length > 0) return explicit; @@ -60,6 +148,15 @@ function normalizeTokenGroup(value: string | null | undefined, tokenName?: strin return name; } +function sameTokenGroup( + leftGroup: string | null | undefined, + leftName: string | null | undefined, + rightGroup: string | null | undefined, + rightName: string | null | undefined, +): boolean { + return normalizeTokenGroup(leftGroup, leftName) === normalizeTokenGroup(rightGroup, rightName); +} + async function updateAccountApiToken(accountId: number, tokenValue: string | null) { await db.update(schema.accounts) .set({ apiToken: tokenValue || null, updatedAt: new Date().toISOString() }) @@ -79,9 +176,10 @@ export async function getPreferredAccountToken(accountId: number) { .where(and(eq(schema.accountTokens.accountId, accountId), eq(schema.accountTokens.enabled, true))) .all(); - if (tokens.length === 0) return null; + const usableTokens = tokens.filter(isUsableAccountToken); + if (usableTokens.length === 0) return null; - const preferred = tokens.find((t) => t.isDefault) || tokens[0]; + const preferred = usableTokens.find((t) => t.isDefault) || usableTokens[0]; return preferred; } @@ -92,6 +190,7 @@ export async function ensureDefaultTokenForAccount( ): Promise { const normalizedToken = normalizeTokenValue(tokenValue); if (!normalizedToken) return null; + if (isMaskedTokenValue(normalizedToken)) return null; const tokenGroup = normalizeTokenGroup(options?.tokenGroup, options?.name) || 'default'; const now = new Date().toISOString(); @@ -108,6 +207,7 @@ export async function ensureDefaultTokenForAccount( name: normalizeTokenName(options?.name, tokens.length + 1), token: normalizedToken, tokenGroup, + valueStatus: ACCOUNT_TOKEN_VALUE_STATUS_READY, source: options?.source || 'manual', enabled: options?.enabled ?? true, isDefault: true, @@ -125,6 +225,7 @@ export async function ensureDefaultTokenForAccount( .set({ name: options?.name ? normalizeTokenName(options.name) : target.name, tokenGroup, + valueStatus: ACCOUNT_TOKEN_VALUE_STATUS_READY, source: options?.source || target.source || 'manual', enabled: options?.enabled ?? target.enabled, isDefault: true, @@ -145,7 +246,7 @@ export async function ensureDefaultTokenForAccount( export async function setDefaultToken(tokenId: number): Promise { const target = await db.select().from(schema.accountTokens).where(eq(schema.accountTokens.id, tokenId)).get(); - if (!target) return false; + if (!target || !isUsableAccountToken(target)) return false; const now = new Date().toISOString(); await db.update(schema.accountTokens) @@ -168,7 +269,7 @@ export async function repairDefaultToken(accountId: number) { .where(eq(schema.accountTokens.accountId, accountId)) .all(); - const enabled = tokens.filter((t) => t.enabled); + const enabled = tokens.filter(isUsableAccountToken); if (enabled.length === 0) { await updateAccountApiToken(accountId, null); return null; @@ -200,22 +301,30 @@ export async function syncTokensFromUpstream(accountId: number, upstreamTokens: let created = 0; let updated = 0; + let maskedPending = 0; + const pendingTokenIds: number[] = []; let index = existing.length + 1; for (const upstream of upstreamTokens) { const tokenValue = normalizeTokenValue(upstream.key); if (!tokenValue) continue; - const tokenName = normalizeTokenName(upstream.name, index); const enabled = upstream.enabled ?? true; const tokenGroup = normalizeTokenGroup(upstream.tokenGroup, tokenName); - - const byToken = existing.find((row) => row.token === tokenValue); + const nextValueStatus = isMaskedTokenValue(tokenValue) + ? ACCOUNT_TOKEN_VALUE_STATUS_MASKED_PENDING + : ACCOUNT_TOKEN_VALUE_STATUS_READY; + + const byToken = existing.find((row) => ( + row.token === tokenValue + && resolveAccountTokenValueStatus(row) === ACCOUNT_TOKEN_VALUE_STATUS_READY + )); if (byToken) { await db.update(schema.accountTokens) .set({ name: tokenName, tokenGroup, + valueStatus: ACCOUNT_TOKEN_VALUE_STATUS_READY, source: 'sync', enabled, updatedAt: now, @@ -224,6 +333,7 @@ export async function syncTokensFromUpstream(accountId: number, upstreamTokens: .run(); byToken.name = tokenName; byToken.tokenGroup = tokenGroup; + byToken.valueStatus = ACCOUNT_TOKEN_VALUE_STATUS_READY; byToken.enabled = enabled; byToken.source = 'sync'; byToken.updatedAt = now; @@ -231,14 +341,108 @@ export async function syncTokensFromUpstream(accountId: number, upstreamTokens: continue; } + const matchingReadyByMaskedValue = nextValueStatus === ACCOUNT_TOKEN_VALUE_STATUS_MASKED_PENDING + ? existing.filter((row) => ( + resolveAccountTokenValueStatus(row) === ACCOUNT_TOKEN_VALUE_STATUS_READY + && matchesMaskedTokenValue(row.token, tokenValue) + && row.name === tokenName + && sameTokenGroup(row.tokenGroup, row.name, tokenGroup, tokenName) + )) + : []; + const readyMaskedMatch = matchingReadyByMaskedValue.length === 1 + ? matchingReadyByMaskedValue[0] + : null; + if (readyMaskedMatch) { + const staleMaskedPlaceholders = existing.filter((row) => ( + row.id !== readyMaskedMatch.id + && resolveAccountTokenValueStatus(row) === ACCOUNT_TOKEN_VALUE_STATUS_MASKED_PENDING + && matchesMaskedTokenValue(row.token, tokenValue) + && row.name === tokenName + && sameTokenGroup(row.tokenGroup, row.name, tokenGroup, tokenName) + )); + + await db.update(schema.accountTokens) + .set({ + name: tokenName, + tokenGroup, + valueStatus: ACCOUNT_TOKEN_VALUE_STATUS_READY, + source: 'sync', + enabled, + updatedAt: now, + }) + .where(eq(schema.accountTokens.id, readyMaskedMatch.id)) + .run(); + readyMaskedMatch.name = tokenName; + readyMaskedMatch.tokenGroup = tokenGroup; + readyMaskedMatch.valueStatus = ACCOUNT_TOKEN_VALUE_STATUS_READY; + readyMaskedMatch.enabled = enabled; + readyMaskedMatch.source = 'sync'; + readyMaskedMatch.updatedAt = now; + + if (staleMaskedPlaceholders.length > 0) { + for (const placeholder of staleMaskedPlaceholders) { + await db.delete(schema.accountTokens) + .where(eq(schema.accountTokens.id, placeholder.id)) + .run(); + } + for (const placeholder of staleMaskedPlaceholders) { + const placeholderIndex = existing.findIndex((row) => row.id === placeholder.id); + if (placeholderIndex >= 0) { + existing.splice(placeholderIndex, 1); + } + } + } + + updated++; + continue; + } + + const matchingPlaceholder = existing.find((row) => ( + isMaskedPendingAccountToken(row) + && row.name === tokenName + && sameTokenGroup(row.tokenGroup, row.name, tokenGroup, tokenName) + )); + + if (matchingPlaceholder) { + const nextEnabled = nextValueStatus === ACCOUNT_TOKEN_VALUE_STATUS_READY ? enabled : false; + await db.update(schema.accountTokens) + .set({ + name: tokenName, + token: tokenValue, + tokenGroup, + valueStatus: nextValueStatus, + source: 'sync', + enabled: nextEnabled, + isDefault: false, + updatedAt: now, + }) + .where(eq(schema.accountTokens.id, matchingPlaceholder.id)) + .run(); + matchingPlaceholder.name = tokenName; + matchingPlaceholder.token = tokenValue; + matchingPlaceholder.tokenGroup = tokenGroup; + matchingPlaceholder.valueStatus = nextValueStatus; + matchingPlaceholder.source = 'sync'; + matchingPlaceholder.enabled = nextEnabled; + matchingPlaceholder.isDefault = false; + matchingPlaceholder.updatedAt = now; + updated++; + if (nextValueStatus === ACCOUNT_TOKEN_VALUE_STATUS_MASKED_PENDING) { + maskedPending++; + pendingTokenIds.push(matchingPlaceholder.id); + } + continue; + } + const inserted = await db.insert(schema.accountTokens) .values({ accountId, name: tokenName, token: tokenValue, tokenGroup, + valueStatus: nextValueStatus, source: 'sync', - enabled, + enabled: nextValueStatus === ACCOUNT_TOKEN_VALUE_STATUS_READY ? enabled : false, isDefault: false, createdAt: now, updatedAt: now, @@ -252,6 +456,10 @@ export async function syncTokensFromUpstream(accountId: number, upstreamTokens: existing.push(createdRow); created++; index++; + if (nextValueStatus === ACCOUNT_TOKEN_VALUE_STATUS_MASKED_PENDING) { + maskedPending++; + pendingTokenIds.push(createdRow.id); + } } const repaired = await repairDefaultToken(accountId); @@ -259,6 +467,8 @@ export async function syncTokensFromUpstream(accountId: number, upstreamTokens: return { created, updated, + maskedPending, + pendingTokenIds, total: existing.length, defaultTokenId: repaired?.id || null, }; @@ -280,6 +490,7 @@ export async function listTokensWithRelations(accountId?: number) { const { token, ...tokenMeta } = row.account_tokens; return { ...tokenMeta, + valueStatus: resolveAccountTokenValueStatus(row.account_tokens), tokenMasked: maskToken(token, row.sites.platform), account: { id: row.accounts.id, @@ -295,4 +506,4 @@ export async function listTokensWithRelations(accountId?: number) { }; }); } - + diff --git a/src/server/services/alertRules.test.ts b/src/server/services/alertRules.test.ts index dc77ab11..218b5bc0 100644 --- a/src/server/services/alertRules.test.ts +++ b/src/server/services/alertRules.test.ts @@ -10,7 +10,8 @@ describe('alertRules', () => { it('detects token expiration by status or message', () => { expect(isTokenExpiredError({ status: 401, message: 'Unauthorized' })).toBe(true); - expect(isTokenExpiredError({ status: 403, message: 'Forbidden' })).toBe(true); + expect(isTokenExpiredError({ status: 403, message: 'Forbidden' })).toBe(false); + expect(isTokenExpiredError({ message: 'HTTP 401: access token required' })).toBe(true); expect(isTokenExpiredError({ message: 'jwt expired' })).toBe(true); expect(isTokenExpiredError({ message: 'token invalid' })).toBe(true); expect(isTokenExpiredError({ message: 'invalid access token' })).toBe(true); @@ -19,6 +20,20 @@ describe('alertRules', () => { expect(isTokenExpiredError({ status: 500, message: 'upstream error' })).toBe(false); }); + it('does not treat endpoint dispatch denial as token expiration', () => { + expect(isTokenExpiredError({ + status: 403, + message: 'This group does not allow /v1/messages dispatch', + })).toBe(false); + expect(isTokenExpiredError({ + status: 403, + message: 'dispatch denied for /v1/responses', + })).toBe(false); + expect(isTokenExpiredError({ + message: 'unauthorized', + })).toBe(false); + }); + it('appends rebind hint for invalid access token messages', () => { expect(appendSessionTokenRebindHint('无权进行此操作,access token 无效')) .toContain('请在中转站重新生成系统访问令牌后重新绑定账号'); diff --git a/src/server/services/alertRules.ts b/src/server/services/alertRules.ts index 297c0bfd..d18a5c99 100644 --- a/src/server/services/alertRules.ts +++ b/src/server/services/alertRules.ts @@ -6,9 +6,25 @@ export function isCloudflareChallenge(message?: string | null): boolean { const SESSION_TOKEN_REBIND_HINT = '请在中转站重新生成系统访问令牌后重新绑定账号'; +function isEndpointDispatchDeniedMessage(message?: string | null): boolean { + if (!message) return false; + const text = message.toLowerCase(); + return ( + /does\s+not\s+allow\s+\/v1\/[a-z0-9/_:-]+\s+dispatch/i.test(message) + || text.includes('dispatch denied') + ); +} + +function containsHttpStatus(message: string | null | undefined, status: number): boolean { + if (!message) return false; + return new RegExp(`(?:^|\\b)(?:http\\s*)?${status}(?:\\b|:)`, 'i').test(message); +} + export function isTokenExpiredError(input: { status?: number; message?: string | null }): boolean { - if (input.status === 401 || input.status === 403) return true; + const rawMessage = input.message || ''; const text = (input.message || '').toLowerCase(); + if (isEndpointDispatchDeniedMessage(rawMessage)) return false; + if (input.status === 401 || containsHttpStatus(rawMessage, 401)) return true; if (!text) return false; // NewAPI-like sites may return this when session context is missing for an action, @@ -24,9 +40,7 @@ export function isTokenExpiredError(input: { status?: number; message?: string | text.includes('token expired') || (tokenPhrase && (hasInvalid || hasExpired)) || /invalid\s+access\s+token/.test(text) || - /access\s+token\s+is\s+invalid/.test(text) || - text.includes('unauthorized') || - text.includes('forbidden') + /access\s+token\s+is\s+invalid/.test(text) ); } diff --git a/src/server/services/backgroundTaskService.ts b/src/server/services/backgroundTaskService.ts index 140554cc..04e27401 100644 --- a/src/server/services/backgroundTaskService.ts +++ b/src/server/services/backgroundTaskService.ts @@ -40,6 +40,7 @@ const TASK_CLEANUP_INTERVAL_MS = 60 * 1000; const tasks = new Map(); const dedupeTaskIds = new Map(); +let cleanupTimer: ReturnType | null = null; function nowIso() { return new Date().toISOString(); @@ -157,13 +158,17 @@ function cleanupExpiredTasks() { } } -const cleanupTimer = setInterval(cleanupExpiredTasks, TASK_CLEANUP_INTERVAL_MS); -cleanupTimer.unref?.(); +function ensureCleanupTimer() { + if (cleanupTimer) return; + cleanupTimer = setInterval(cleanupExpiredTasks, TASK_CLEANUP_INTERVAL_MS); + cleanupTimer.unref?.(); +} export function startBackgroundTask( options: BackgroundTaskStartOptions, runner: () => Promise, ): { task: BackgroundTask; reused: boolean } { + ensureCleanupTimer(); const dedupeKey = options.dedupeKey?.trim() || ''; if (dedupeKey) { const existingTaskId = dedupeTaskIds.get(dedupeKey); @@ -241,4 +246,8 @@ export function summarizeCheckinResults(results: Array<{ result?: any }>): { tot export function __resetBackgroundTasksForTests() { tasks.clear(); dedupeTaskIds.clear(); + if (cleanupTimer) { + clearInterval(cleanupTimer); + cleanupTimer = null; + } } diff --git a/src/server/services/backupService.test.ts b/src/server/services/backupService.test.ts index 2a1de05e..71e068c7 100644 --- a/src/server/services/backupService.test.ts +++ b/src/server/services/backupService.test.ts @@ -1,4 +1,4 @@ -import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; import { mkdtempSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -28,14 +28,21 @@ describe('backupService', () => { beforeEach(async () => { await db.delete(schema.routeChannels).run(); + await db.delete(schema.routeGroupSources).run(); await db.delete(schema.tokenRoutes).run(); await db.delete(schema.tokenModelAvailability).run(); await db.delete(schema.modelAvailability).run(); await db.delete(schema.proxyLogs).run(); await db.delete(schema.checkinLogs).run(); + await db.delete(schema.siteAnnouncements).run(); + await db.delete(schema.siteDisabledModels).run(); await db.delete(schema.accountTokens).run(); await db.delete(schema.accounts).run(); await db.delete(schema.sites).run(); + await db.delete(schema.downstreamApiKeys).run(); + await db.delete(schema.proxyFiles).run(); + await db.delete(schema.proxyVideoTasks).run(); + await db.delete(schema.events).run(); await db.delete(schema.settings).run(); }); @@ -43,13 +50,18 @@ describe('backupService', () => { delete process.env.DATA_DIR; }); - it('preserves extended fields in full backup import/export roundtrip', async () => { + it('exports backup-owned config in v2.1 backups and still roundtrips core connection fields', async () => { const now = new Date().toISOString(); const site = await db.insert(schema.sites).values({ name: 'roundtrip-site', url: 'https://roundtrip.example.com', platform: 'new-api', + externalCheckinUrl: 'https://checkin.roundtrip.example.com', proxyUrl: 'http://127.0.0.1:8080', + useSystemProxy: true, + customHeaders: JSON.stringify({ + 'cf-access-client-id': 'roundtrip-client', + }), status: 'active', isPinned: true, sortOrder: 9, @@ -63,6 +75,9 @@ describe('backupService', () => { username: 'roundtrip-user', accessToken: 'session-token', apiToken: 'api-token', + oauthProvider: 'codex', + oauthAccountKey: 'roundtrip-account-key', + oauthProjectId: 'roundtrip-project-id', balance: 12.3, balanceUsed: 4.5, quota: 99.9, @@ -88,16 +103,34 @@ describe('backupService', () => { updatedAt: now, }).returning().get(); + const sourceRoute = await db.insert(schema.tokenRoutes).values({ + modelPattern: 'gpt-source-*', + displayName: 'gpt-source', + routeMode: 'pattern', + enabled: true, + createdAt: now, + updatedAt: now, + }).returning().get(); + const route = await db.insert(schema.tokenRoutes).values({ modelPattern: 'gpt-*', displayName: 'gpt-route', displayIcon: 'icon-gpt', modelMapping: JSON.stringify({ to: 'gpt-4o-mini' }), + routeMode: 'explicit_group', + decisionSnapshot: JSON.stringify({ channelIds: [1, 2] }), + decisionRefreshedAt: now, + routingStrategy: 'round_robin', enabled: true, createdAt: now, updatedAt: now, }).returning().get(); + await db.insert(schema.routeGroupSources).values({ + groupRouteId: route.id, + sourceRouteId: sourceRoute.id, + }).run(); + await db.insert(schema.routeChannels).values({ routeId: route.id, accountId: account.id, @@ -116,28 +149,855 @@ describe('backupService', () => { cooldownUntil: now, }).run(); - const exported = await backupService.exportBackup('all'); - const result = await backupService.importBackup(exported as unknown as Record); + await db.insert(schema.siteDisabledModels).values({ + siteId: site.id, + modelName: 'gpt-hidden', + createdAt: now, + }).run(); + + await db.insert(schema.modelAvailability).values([ + { + accountId: account.id, + modelName: 'gpt-manual', + available: true, + isManual: true, + latencyMs: null, + checkedAt: now, + }, + { + accountId: account.id, + modelName: 'gpt-discovered', + available: true, + isManual: false, + latencyMs: 42, + checkedAt: now, + }, + ]).run(); + + await db.insert(schema.downstreamApiKeys).values({ + name: 'Shared Downstream', + key: 'downstream-roundtrip-key', + description: 'shared quota', + groupName: 'team-a', + tags: '["prod"]', + enabled: false, + expiresAt: now, + maxCost: 100, + usedCost: 11.5, + maxRequests: 500, + usedRequests: 33, + supportedModels: '["gpt-4o-mini"]', + allowedRouteIds: `[${route.id}]`, + siteWeightMultipliers: `{"${site.id}":1.5}`, + lastUsedAt: now, + createdAt: now, + updatedAt: now, + }).run(); + + const exported = await backupService.exportBackup('all') as any; + expect(exported.version).toBe('2.1'); + expect(exported.accounts.siteDisabledModels).toEqual([ + { siteId: site.id, modelName: 'gpt-hidden' }, + ]); + expect(exported.accounts.manualModels).toEqual([ + { accountId: account.id, modelName: 'gpt-manual' }, + ]); + expect(exported.accounts.downstreamApiKeys).toEqual([ + expect.objectContaining({ + name: 'Shared Downstream', + key: 'downstream-roundtrip-key', + description: 'shared quota', + groupName: 'team-a', + tags: '["prod"]', + enabled: false, + expiresAt: now, + maxCost: 100, + maxRequests: 500, + supportedModels: '["gpt-4o-mini"]', + allowedRouteIds: `[${route.id}]`, + siteWeightMultipliers: `{"${site.id}":1.5}`, + }), + ]); + expect(exported.accounts.accounts[0]).not.toHaveProperty('balanceUsed'); + expect(exported.accounts.accounts[0]).not.toHaveProperty('lastCheckinAt'); + expect(exported.accounts.accounts[0]).not.toHaveProperty('lastBalanceRefresh'); + expect(exported.accounts.routeChannels[0]).not.toHaveProperty('successCount'); + expect(exported.accounts.routeChannels[0]).not.toHaveProperty('lastUsedAt'); + expect(exported.accounts.downstreamApiKeys[0]).not.toHaveProperty('usedCost'); + expect(exported.accounts.downstreamApiKeys[0]).not.toHaveProperty('usedRequests'); + expect(exported.accounts.downstreamApiKeys[0]).not.toHaveProperty('lastUsedAt'); + + const result = await backupService.importBackup(exported as Record); expect(result.allImported).toBe(true); expect(result.sections.accounts).toBe(true); + expect(result.summary).toBeUndefined(); + expect(result.warnings).toBeUndefined(); const restoredSite = await db.select().from(schema.sites).where(eq(schema.sites.id, site.id)).get(); const restoredAccount = await db.select().from(schema.accounts).where(eq(schema.accounts.id, account.id)).get(); const restoredRoute = await db.select().from(schema.tokenRoutes).where(eq(schema.tokenRoutes.id, route.id)).get(); const restoredChannel = await db.select().from(schema.routeChannels).where(eq(schema.routeChannels.routeId, route.id)).get(); + const restoredDisabledModels = await db.select().from(schema.siteDisabledModels).all(); + const restoredModelAvailability = await db.select().from(schema.modelAvailability).all(); + const restoredDownstreamKeys = await db.select().from(schema.downstreamApiKeys).all(); expect(restoredSite?.proxyUrl).toBe('http://127.0.0.1:8080'); + expect(restoredSite?.externalCheckinUrl).toBe('https://checkin.roundtrip.example.com'); + expect(restoredSite?.useSystemProxy).toBe(true); + expect(restoredSite?.customHeaders).toBe('{"cf-access-client-id":"roundtrip-client"}'); expect(restoredSite?.isPinned).toBe(true); expect(restoredSite?.sortOrder).toBe(9); expect(restoredAccount?.isPinned).toBe(true); expect(restoredAccount?.sortOrder).toBe(7); + expect(restoredAccount?.oauthProvider).toBe('codex'); + expect(restoredAccount?.oauthAccountKey).toBe('roundtrip-account-key'); + expect(restoredAccount?.oauthProjectId).toBe('roundtrip-project-id'); expect(restoredRoute?.displayName).toBe('gpt-route'); expect(restoredRoute?.displayIcon).toBe('icon-gpt'); + expect(restoredRoute?.routeMode).toBe('explicit_group'); + expect(restoredRoute?.decisionSnapshot).toBe('{"channelIds":[1,2]}'); + expect(restoredRoute?.decisionRefreshedAt).toBe(now); + expect(restoredRoute?.routingStrategy).toBe('round_robin'); + const restoredGroupSource = await db.select().from(schema.routeGroupSources).where(eq(schema.routeGroupSources.groupRouteId, route.id)).get(); + expect(restoredGroupSource?.sourceRouteId).toBe(sourceRoute.id); expect(restoredChannel?.sourceModel).toBe('gpt-4o'); + expect(restoredDisabledModels).toEqual([ + expect.objectContaining({ siteId: site.id, modelName: 'gpt-hidden' }), + ]); + expect(restoredModelAvailability.some((row) => row.modelName === 'gpt-manual' && row.isManual)).toBe(true); + expect(restoredModelAvailability.some((row) => row.modelName === 'gpt-discovered' && !row.isManual)).toBe(true); + expect(restoredDownstreamKeys).toEqual([ + expect.objectContaining({ + name: 'Shared Downstream', + key: 'downstream-roundtrip-key', + description: 'shared quota', + groupName: 'team-a', + tags: '["prod"]', + enabled: false, + maxCost: 100, + usedCost: 11.5, + maxRequests: 500, + usedRequests: 33, + supportedModels: '["gpt-4o-mini"]', + allowedRouteIds: `[${route.id}]`, + siteWeightMultipliers: `{"${site.id}":1.5}`, + lastUsedAt: now, + }), + ]); + }); + + it('does not export runtime database config in preferences backups', async () => { + await db.insert(schema.settings).values([ + { key: 'db_type', value: JSON.stringify('postgres') }, + { key: 'db_url', value: JSON.stringify('postgres://metapi:secret@db.example.com:5432/metapi') }, + { key: 'db_ssl', value: JSON.stringify(true) }, + { key: 'routing_fallback_unit_cost', value: JSON.stringify(0.25) }, + ]).run(); + + const exported = await backupService.exportBackup('preferences') as any; + const exportedSettingKeys = exported.preferences.settings.map((row: { key: string }) => row.key); + + expect(exportedSettingKeys).toContain('routing_fallback_unit_cost'); + expect(exportedSettingKeys).not.toContain('db_type'); + expect(exportedSettingKeys).not.toContain('db_url'); + expect(exportedSettingKeys).not.toContain('db_ssl'); + }); + + it('ignores imported runtime database config settings', async () => { + const result = await backupService.importBackup({ + version: '2.1', + timestamp: Date.now(), + type: 'preferences', + preferences: { + settings: [ + { key: 'db_type', value: 'postgres' }, + { key: 'db_url', value: 'postgres://metapi:secret@db.example.com:5432/metapi' }, + { key: 'db_ssl', value: true }, + { key: 'routing_fallback_unit_cost', value: 0.25 }, + ], + }, + }); + + expect(result.sections.preferences).toBe(true); + expect(result.appliedSettings).toEqual([ + { key: 'routing_fallback_unit_cost', value: 0.25 }, + ]); + + const settingsRows = await db.select().from(schema.settings).all(); + const savedKeys = settingsRows.map((row) => row.key); + + expect(savedKeys).toContain('routing_fallback_unit_cost'); + expect(savedKeys).not.toContain('db_type'); + expect(savedKeys).not.toContain('db_url'); + expect(savedKeys).not.toContain('db_ssl'); + }); + + it('preserves local logs and runtime stats when importing account backups', async () => { + const exportedAt = '2026-03-20T09:00:00.000Z'; + const localRuntimeAt = '2026-03-21T10:30:00.000Z'; + const site = await db.insert(schema.sites).values({ + name: 'backup-site', + url: 'https://preserve.example.com', + platform: 'new-api', + status: 'active', + createdAt: exportedAt, + updatedAt: exportedAt, + }).returning().get(); + + const account = await db.insert(schema.accounts).values({ + siteId: site.id, + username: 'preserve-user', + accessToken: 'session-token', + apiToken: 'api-token', + balance: 20, + balanceUsed: 3, + quota: 100, + status: 'active', + checkinEnabled: true, + createdAt: exportedAt, + updatedAt: exportedAt, + }).returning().get(); + + const accountToken = await db.insert(schema.accountTokens).values({ + accountId: account.id, + name: 'default', + token: 'sk-preserve-token', + source: 'manual', + enabled: true, + isDefault: true, + createdAt: exportedAt, + updatedAt: exportedAt, + }).returning().get(); + + const route = await db.insert(schema.tokenRoutes).values({ + modelPattern: 'gpt-preserve-*', + displayName: 'backup-route', + modelMapping: JSON.stringify({ to: 'gpt-4o-mini' }), + routeMode: 'pattern', + routingStrategy: 'weighted', + enabled: true, + createdAt: exportedAt, + updatedAt: exportedAt, + }).returning().get(); + + await db.insert(schema.routeChannels).values({ + routeId: route.id, + accountId: account.id, + tokenId: accountToken.id, + sourceModel: 'gpt-4o', + priority: 1, + weight: 10, + enabled: true, + manualOverride: false, + successCount: 1, + failCount: 0, + totalLatencyMs: 200, + totalCost: 0.5, + lastUsedAt: exportedAt, + lastSelectedAt: exportedAt, + lastFailAt: null, + consecutiveFailCount: 0, + cooldownLevel: 0, + cooldownUntil: null, + }).run(); + + const insertedChannel = await db.select() + .from(schema.routeChannels) + .where(eq(schema.routeChannels.routeId, route.id)) + .get(); + + expect(insertedChannel).toBeTruthy(); + + const exported = await backupService.exportBackup('all'); + + await db.update(schema.sites).set({ + name: 'mutated-local-site', + updatedAt: localRuntimeAt, + }).where(eq(schema.sites.id, site.id)).run(); + + await db.update(schema.tokenRoutes).set({ + displayName: 'mutated-local-route', + updatedAt: localRuntimeAt, + }).where(eq(schema.tokenRoutes.id, route.id)).run(); + + await db.update(schema.accounts).set({ + balanceUsed: 88, + updatedAt: localRuntimeAt, + }).where(eq(schema.accounts.id, account.id)).run(); + + await db.update(schema.routeChannels).set({ + successCount: 77, + failCount: 9, + totalLatencyMs: 4321, + totalCost: 7.89, + lastUsedAt: localRuntimeAt, + lastSelectedAt: localRuntimeAt, + lastFailAt: localRuntimeAt, + consecutiveFailCount: 4, + cooldownLevel: 2, + cooldownUntil: localRuntimeAt, + }).where(eq(schema.routeChannels.id, insertedChannel!.id)).run(); + + await db.insert(schema.checkinLogs).values({ + accountId: account.id, + status: 'success', + message: 'local-checkin', + reward: '1.5', + createdAt: localRuntimeAt, + }).run(); + + await db.insert(schema.proxyLogs).values({ + routeId: route.id, + channelId: insertedChannel!.id, + accountId: account.id, + modelRequested: 'gpt-4o', + modelActual: 'gpt-4o', + status: 'success', + totalTokens: 321, + estimatedCost: 0.123, + createdAt: localRuntimeAt, + }).run(); + + const result = await backupService.importBackup(exported as unknown as Record); + + expect(result.allImported).toBe(true); + expect(result.sections.accounts).toBe(true); + + const restoredSite = await db.select().from(schema.sites).where(eq(schema.sites.id, site.id)).get(); + const restoredAccount = await db.select().from(schema.accounts).where(eq(schema.accounts.id, account.id)).get(); + const restoredRoute = await db.select().from(schema.tokenRoutes).where(eq(schema.tokenRoutes.id, route.id)).get(); + const restoredChannel = await db.select().from(schema.routeChannels).where(eq(schema.routeChannels.id, insertedChannel!.id)).get(); + const restoredProxyLogs = await db.select().from(schema.proxyLogs).all(); + const restoredCheckinLogs = await db.select().from(schema.checkinLogs).all(); + + expect(restoredSite?.name).toBe('backup-site'); + expect(restoredRoute?.displayName).toBe('backup-route'); + expect(restoredAccount?.balanceUsed).toBe(88); + expect(restoredChannel?.successCount).toBe(77); + expect(restoredChannel?.failCount).toBe(9); + expect(restoredChannel?.totalLatencyMs).toBe(4321); + expect(restoredChannel?.totalCost).toBe(7.89); + expect(restoredChannel?.lastUsedAt).toBe(localRuntimeAt); + expect(restoredChannel?.lastSelectedAt).toBe(localRuntimeAt); + expect(restoredChannel?.lastFailAt).toBe(localRuntimeAt); + expect(restoredChannel?.consecutiveFailCount).toBe(4); + expect(restoredChannel?.cooldownLevel).toBe(2); + expect(restoredChannel?.cooldownUntil).toBe(localRuntimeAt); + expect(restoredProxyLogs).toHaveLength(1); + expect(restoredProxyLogs[0]?.accountId).toBe(account.id); + expect(restoredProxyLogs[0]?.routeId).toBe(route.id); + expect(restoredProxyLogs[0]?.channelId).toBe(insertedChannel!.id); + expect(restoredProxyLogs[0]?.totalTokens).toBe(321); + expect(restoredCheckinLogs).toHaveLength(1); + expect(restoredCheckinLogs[0]?.accountId).toBe(account.id); + expect(restoredCheckinLogs[0]?.message).toBe('local-checkin'); + }); + + it('preserves local-only state while replacing backup-owned config during account imports', async () => { + const exportedAt = '2026-03-20T09:00:00.000Z'; + const localRuntimeAt = '2026-03-21T10:30:00.000Z'; + const site = await db.insert(schema.sites).values({ + name: 'backup-site', + url: 'https://preserve-local-state.example.com', + platform: 'new-api', + status: 'active', + createdAt: exportedAt, + updatedAt: exportedAt, + }).returning().get(); + + const account = await db.insert(schema.accounts).values({ + siteId: site.id, + username: 'preserve-user', + accessToken: 'session-token', + apiToken: 'api-token', + balance: 20, + balanceUsed: 3, + quota: 100, + status: 'active', + checkinEnabled: true, + createdAt: exportedAt, + updatedAt: exportedAt, + }).returning().get(); + + const accountToken = await db.insert(schema.accountTokens).values({ + accountId: account.id, + name: 'default', + token: 'sk-preserve-token', + source: 'manual', + enabled: true, + isDefault: true, + createdAt: exportedAt, + updatedAt: exportedAt, + }).returning().get(); + + const route = await db.insert(schema.tokenRoutes).values({ + modelPattern: 'gpt-preserve-*', + displayName: 'backup-route', + modelMapping: JSON.stringify({ to: 'gpt-4o-mini' }), + routeMode: 'pattern', + routingStrategy: 'weighted', + enabled: true, + createdAt: exportedAt, + updatedAt: exportedAt, + }).returning().get(); + + await db.insert(schema.routeChannels).values({ + routeId: route.id, + accountId: account.id, + tokenId: accountToken.id, + sourceModel: 'gpt-4o', + priority: 1, + weight: 10, + enabled: true, + manualOverride: false, + successCount: 1, + failCount: 0, + totalLatencyMs: 200, + totalCost: 0.5, + lastUsedAt: exportedAt, + lastSelectedAt: exportedAt, + lastFailAt: null, + consecutiveFailCount: 0, + cooldownLevel: 0, + cooldownUntil: null, + }).run(); + + const insertedChannel = await db.select() + .from(schema.routeChannels) + .where(eq(schema.routeChannels.routeId, route.id)) + .get(); + + expect(insertedChannel).toBeTruthy(); + + await db.insert(schema.siteDisabledModels).values({ + siteId: site.id, + modelName: 'gpt-backup-disabled', + createdAt: exportedAt, + }).run(); + + await db.insert(schema.modelAvailability).values([ + { + accountId: account.id, + modelName: 'gpt-backup-manual', + available: true, + isManual: true, + latencyMs: null, + checkedAt: exportedAt, + }, + { + accountId: account.id, + modelName: 'gpt-cached', + available: false, + isManual: false, + latencyMs: 50, + checkedAt: exportedAt, + }, + ]).run(); + + await db.insert(schema.tokenModelAvailability).values({ + tokenId: accountToken.id, + modelName: 'gpt-token-cache', + available: false, + latencyMs: 33, + checkedAt: exportedAt, + }).run(); + + await db.insert(schema.siteAnnouncements).values({ + siteId: site.id, + platform: 'new-api', + sourceKey: 'notice-1', + title: 'Backup banner', + content: 'Backup content', + level: 'info', + firstSeenAt: exportedAt, + lastSeenAt: exportedAt, + readAt: null, + dismissedAt: null, + rawPayload: '{"revision":"backup"}', + }).run(); + + const downstreamKey = await db.insert(schema.downstreamApiKeys).values({ + name: 'Backup Downstream', + key: 'downstream-shared', + description: 'backup config', + groupName: 'team-a', + tags: '["backup"]', + enabled: false, + expiresAt: '2026-12-31T00:00:00.000Z', + maxCost: 25, + usedCost: 1.5, + maxRequests: 250, + usedRequests: 2, + supportedModels: '["gpt-4o"]', + allowedRouteIds: `[${route.id}]`, + siteWeightMultipliers: `{"${site.id}":1.25}`, + lastUsedAt: exportedAt, + createdAt: exportedAt, + updatedAt: exportedAt, + }).returning().get(); + + const exported = await backupService.exportBackup('accounts') as any; + expect(exported.version).toBe('2.1'); + + await db.insert(schema.events).values({ + type: 'status', + title: 'keep-event', + message: 'should stay after import', + level: 'info', + createdAt: localRuntimeAt, + }).run(); + + await db.insert(schema.proxyVideoTasks).values({ + publicId: 'video-task-1', + upstreamVideoId: 'upstream-video-1', + siteUrl: site.url, + tokenValue: account.accessToken, + createdAt: localRuntimeAt, + updatedAt: localRuntimeAt, + }).run(); + + await db.insert(schema.proxyFiles).values({ + publicId: 'proxy-file-1', + ownerType: 'message', + ownerId: 'msg-1', + filename: 'snapshot.json', + mimeType: 'application/json', + byteSize: 4, + sha256: 'abcd', + contentBase64: 'e30=', + createdAt: localRuntimeAt, + updatedAt: localRuntimeAt, + }).run(); + + await db.update(schema.sites).set({ + name: 'local-site-name', + updatedAt: localRuntimeAt, + }).where(eq(schema.sites.id, site.id)).run(); + + await db.update(schema.tokenRoutes).set({ + displayName: 'local-route-name', + updatedAt: localRuntimeAt, + }).where(eq(schema.tokenRoutes.id, route.id)).run(); + + await db.update(schema.accounts).set({ + balanceUsed: 99, + lastCheckinAt: localRuntimeAt, + lastBalanceRefresh: localRuntimeAt, + updatedAt: localRuntimeAt, + }).where(eq(schema.accounts.id, account.id)).run(); + + await db.update(schema.routeChannels).set({ + successCount: 77, + failCount: 9, + totalLatencyMs: 4321, + totalCost: 7.89, + lastUsedAt: localRuntimeAt, + lastSelectedAt: localRuntimeAt, + lastFailAt: localRuntimeAt, + consecutiveFailCount: 4, + cooldownLevel: 2, + cooldownUntil: localRuntimeAt, + }).where(eq(schema.routeChannels.id, insertedChannel!.id)).run(); + + await db.delete(schema.siteDisabledModels) + .where(eq(schema.siteDisabledModels.siteId, site.id)) + .run(); + await db.insert(schema.siteDisabledModels).values({ + siteId: site.id, + modelName: 'gpt-local-disabled', + createdAt: localRuntimeAt, + }).run(); + + await db.delete(schema.modelAvailability) + .where(eq(schema.modelAvailability.accountId, account.id)) + .run(); + await db.insert(schema.modelAvailability).values([ + { + accountId: account.id, + modelName: 'gpt-local-manual', + available: true, + isManual: true, + latencyMs: null, + checkedAt: localRuntimeAt, + }, + { + accountId: account.id, + modelName: 'gpt-cached', + available: true, + isManual: false, + latencyMs: 777, + checkedAt: localRuntimeAt, + }, + ]).run(); + + await db.update(schema.tokenModelAvailability).set({ + available: true, + latencyMs: 888, + checkedAt: localRuntimeAt, + }).where(eq(schema.tokenModelAvailability.tokenId, accountToken.id)).run(); + + await db.update(schema.siteAnnouncements).set({ + title: 'Local banner', + content: 'Local content', + lastSeenAt: localRuntimeAt, + readAt: localRuntimeAt, + dismissedAt: localRuntimeAt, + rawPayload: '{"revision":"local"}', + }).where(eq(schema.siteAnnouncements.siteId, site.id)).run(); + + await db.update(schema.downstreamApiKeys).set({ + name: 'Local Mutated Downstream', + description: 'local config', + groupName: 'team-local', + tags: '["local"]', + enabled: true, + expiresAt: '2027-01-01T00:00:00.000Z', + maxCost: 999, + usedCost: 44, + maxRequests: 999, + usedRequests: 55, + supportedModels: '["gpt-local"]', + allowedRouteIds: '[999]', + siteWeightMultipliers: '{"999":9}', + lastUsedAt: localRuntimeAt, + updatedAt: localRuntimeAt, + }).where(eq(schema.downstreamApiKeys.id, downstreamKey.id)).run(); + + const localOnlyDownstreamKey = await db.insert(schema.downstreamApiKeys).values({ + name: 'Local Only Downstream', + key: 'downstream-local-only', + usedCost: 7, + usedRequests: 8, + lastUsedAt: localRuntimeAt, + createdAt: localRuntimeAt, + updatedAt: localRuntimeAt, + }).returning().get(); + + await db.insert(schema.checkinLogs).values({ + accountId: account.id, + status: 'success', + message: 'local-checkin', + reward: '1.5', + createdAt: localRuntimeAt, + }).run(); + + await db.insert(schema.proxyLogs).values([ + { + routeId: route.id, + channelId: insertedChannel!.id, + accountId: account.id, + downstreamApiKeyId: downstreamKey.id, + modelRequested: 'gpt-4o', + modelActual: 'gpt-4o', + status: 'success', + totalTokens: 321, + estimatedCost: 0.123, + createdAt: localRuntimeAt, + }, + { + routeId: route.id, + channelId: insertedChannel!.id, + accountId: account.id, + downstreamApiKeyId: localOnlyDownstreamKey.id, + modelRequested: 'gpt-4o-mini', + modelActual: 'gpt-4o-mini', + status: 'failed', + totalTokens: 654, + estimatedCost: 0.456, + createdAt: localRuntimeAt, + }, + ]).run(); + + const result = await backupService.importBackup(exported as Record); + + expect(result.allImported).toBe(true); + expect(result.sections.accounts).toBe(true); + + const restoredSite = await db.select().from(schema.sites).where(eq(schema.sites.id, site.id)).get(); + const restoredAccount = await db.select().from(schema.accounts).where(eq(schema.accounts.id, account.id)).get(); + const restoredRoute = await db.select().from(schema.tokenRoutes).where(eq(schema.tokenRoutes.id, route.id)).get(); + const restoredChannel = await db.select().from(schema.routeChannels).where(eq(schema.routeChannels.id, insertedChannel!.id)).get(); + const restoredDisabledModels = await db.select().from(schema.siteDisabledModels).all(); + const restoredAvailability = await db.select().from(schema.modelAvailability).all(); + const restoredTokenAvailability = await db.select().from(schema.tokenModelAvailability).all(); + const restoredAnnouncements = await db.select().from(schema.siteAnnouncements).all(); + const restoredDownstreamKeys = await db.select().from(schema.downstreamApiKeys).all(); + const restoredProxyLogs = await db.select().from(schema.proxyLogs).all(); + const restoredCheckinLogs = await db.select().from(schema.checkinLogs).all(); + const restoredEvents = await db.select().from(schema.events).all(); + const restoredProxyVideoTasks = await db.select().from(schema.proxyVideoTasks).all(); + const restoredProxyFiles = await db.select().from(schema.proxyFiles).all(); + + expect(restoredSite?.name).toBe('backup-site'); + expect(restoredRoute?.displayName).toBe('backup-route'); + expect(restoredAccount?.balanceUsed).toBe(99); + expect(restoredAccount?.lastCheckinAt).toBe(localRuntimeAt); + expect(restoredAccount?.lastBalanceRefresh).toBe(localRuntimeAt); + expect(restoredChannel?.successCount).toBe(77); + expect(restoredChannel?.failCount).toBe(9); + expect(restoredChannel?.totalLatencyMs).toBe(4321); + expect(restoredChannel?.totalCost).toBe(7.89); + expect(restoredChannel?.lastUsedAt).toBe(localRuntimeAt); + expect(restoredChannel?.lastSelectedAt).toBe(localRuntimeAt); + expect(restoredChannel?.lastFailAt).toBe(localRuntimeAt); + expect(restoredChannel?.consecutiveFailCount).toBe(4); + expect(restoredChannel?.cooldownLevel).toBe(2); + expect(restoredChannel?.cooldownUntil).toBe(localRuntimeAt); + + expect(restoredDisabledModels).toEqual([ + expect.objectContaining({ siteId: site.id, modelName: 'gpt-backup-disabled' }), + ]); + + const restoredManualModels = restoredAvailability + .filter((row) => row.isManual) + .map((row) => row.modelName) + .sort(); + expect(restoredManualModels).toEqual(['gpt-backup-manual']); + const restoredCachedModel = restoredAvailability.find((row) => row.modelName === 'gpt-cached' && !row.isManual); + expect(restoredCachedModel).toEqual(expect.objectContaining({ + available: true, + latencyMs: 777, + checkedAt: localRuntimeAt, + })); + + expect(restoredTokenAvailability).toEqual([ + expect.objectContaining({ + tokenId: accountToken.id, + modelName: 'gpt-token-cache', + available: true, + latencyMs: 888, + checkedAt: localRuntimeAt, + }), + ]); + + expect(restoredAnnouncements).toEqual([ + expect.objectContaining({ + siteId: site.id, + title: 'Local banner', + content: 'Local content', + lastSeenAt: localRuntimeAt, + readAt: localRuntimeAt, + dismissedAt: localRuntimeAt, + rawPayload: '{"revision":"local"}', + }), + ]); + + expect(restoredDownstreamKeys).toEqual([ + expect.objectContaining({ + name: 'Backup Downstream', + key: 'downstream-shared', + description: 'backup config', + groupName: 'team-a', + tags: '["backup"]', + enabled: false, + expiresAt: '2026-12-31T00:00:00.000Z', + maxCost: 25, + usedCost: 44, + maxRequests: 250, + usedRequests: 55, + supportedModels: '["gpt-4o"]', + allowedRouteIds: `[${route.id}]`, + siteWeightMultipliers: `{"${site.id}":1.25}`, + lastUsedAt: localRuntimeAt, + }), + ]); + + expect(restoredProxyLogs).toHaveLength(2); + const matchedDownstreamLog = restoredProxyLogs.find((row) => row.totalTokens === 321); + const orphanedDownstreamLog = restoredProxyLogs.find((row) => row.totalTokens === 654); + expect(matchedDownstreamLog?.downstreamApiKeyId).toBe(restoredDownstreamKeys[0]?.id); + expect(orphanedDownstreamLog?.downstreamApiKeyId).toBeNull(); + expect(restoredCheckinLogs).toEqual([ + expect.objectContaining({ + accountId: account.id, + message: 'local-checkin', + }), + ]); + expect(restoredEvents).toHaveLength(1); + expect(restoredProxyVideoTasks).toHaveLength(1); + expect(restoredProxyFiles).toHaveLength(1); + }); + + it('keeps importing native v2.0 backups without the new v2.1 config arrays', async () => { + const localDownstreamKey = await db.insert(schema.downstreamApiKeys).values({ + name: 'Local downstream', + key: 'local-downstream-key', + usedCost: 12, + usedRequests: 3, + createdAt: '2026-03-21T08:00:00.000Z', + updatedAt: '2026-03-21T08:00:00.000Z', + }).returning().get(); + + const payload = { + version: '2.0', + timestamp: Date.now(), + type: 'accounts', + accounts: { + sites: [ + { + id: 1, + name: 'Legacy native site', + url: 'https://legacy-native.example.com', + externalCheckinUrl: null, + platform: 'new-api', + proxyUrl: null, + useSystemProxy: false, + customHeaders: null, + status: 'active', + isPinned: false, + sortOrder: 0, + globalWeight: 1, + apiKey: null, + createdAt: '2026-03-20T00:00:00.000Z', + updatedAt: '2026-03-20T00:00:00.000Z', + }, + ], + accounts: [ + { + id: 1, + siteId: 1, + username: 'legacy-user', + accessToken: 'legacy-session-token', + apiToken: 'legacy-api-token', + balance: 10, + quota: 20, + unitCost: null, + valueScore: 0, + status: 'active', + isPinned: false, + sortOrder: 0, + checkinEnabled: true, + extraConfig: null, + createdAt: '2026-03-20T00:00:00.000Z', + updatedAt: '2026-03-20T00:00:00.000Z', + }, + ], + accountTokens: [], + tokenRoutes: [], + routeChannels: [], + routeGroupSources: [], + }, + } as Record; + + const result = await backupService.importBackup(payload); + + expect(result.allImported).toBe(true); + expect(result.sections.accounts).toBe(true); + + const restoredSites = await db.select().from(schema.sites).all(); + const restoredAccounts = await db.select().from(schema.accounts).all(); + const restoredDownstreamKeys = await db.select().from(schema.downstreamApiKeys).all(); + + expect(restoredSites).toHaveLength(1); + expect(restoredAccounts).toHaveLength(1); + expect(restoredAccounts[0]?.username).toBe('legacy-user'); + expect(restoredDownstreamKeys).toHaveLength(1); + expect(restoredDownstreamKeys[0]?.id).toBe(localDownstreamKey.id); + expect(restoredDownstreamKeys[0]?.key).toBe('local-downstream-key'); }); it('imports ALL-API-Hub style payload with accounts and preferences', async () => { @@ -195,4 +1055,614 @@ describe('backupService', () => { expect(accounts[0].username).toBe('legacy-user'); expect(settings.some((row) => row.key === 'legacy_preferences_ref_v2')).toBe(true); }); + + it('imports ALL-API-Hub V2 backups into native offline connections and summaries', async () => { + const fetchSpy = vi.spyOn(globalThis, 'fetch'); + fetchSpy.mockImplementation(async () => { + throw new Error('network access should not happen during offline import'); + }); + + try { + const payload = { + version: '2.0', + timestamp: Date.now(), + accounts: { + accounts: [ + { + id: 'managed-account', + site_url: 'https://newapi.example.com', + site_type: 'new-api', + site_name: 'Managed Site', + authType: 'access_token', + account_info: { + id: 7788, + username: 'managed-user', + access_token: 'managed-session-token', + quota: 100000, + today_quota_consumption: 50000, + }, + checkIn: { + autoCheckInEnabled: true, + }, + created_at: '2026-02-01T00:00:00.000Z', + updated_at: '2026-02-02T00:00:00.000Z', + }, + { + id: 'cookie-account', + site_url: 'https://onehub.example.com', + site_type: 'one-hub', + site_name: 'Cookie Site', + username: 'cookie-user', + authType: 'cookie', + cookieAuth: { + sessionCookie: 'sid=cookie-session', + }, + checkIn: { + autoCheckInEnabled: false, + }, + created_at: '2026-02-03T00:00:00.000Z', + updated_at: '2026-02-04T00:00:00.000Z', + }, + { + id: 'direct-openai-account', + site_url: 'https://api.openai.com', + site_type: 'openai', + site_name: 'OpenAI Direct', + username: 'openai-account', + authType: 'access_token', + account_info: { + username: 'openai-account', + access_token: 'sk-openai-account', + }, + created_at: '2026-02-05T00:00:00.000Z', + updated_at: '2026-02-06T00:00:00.000Z', + }, + { + id: 'sub2api-account', + site_url: 'https://sub2api.example.com', + site_type: 'sub2api', + site_name: 'Sub2API', + authType: 'access_token', + account_info: { + id: 99, + username: 'sub2-user', + access_token: 'sub2-session-token', + }, + sub2apiAuth: { + refreshToken: 'sub2-refresh-token', + tokenExpiresAt: 1735689600000, + }, + checkIn: { + autoCheckInEnabled: true, + }, + created_at: '2026-02-07T00:00:00.000Z', + updated_at: '2026-02-08T00:00:00.000Z', + }, + { + id: 'skipped-none-account', + site_url: 'https://skip-none.example.com', + site_type: 'new-api', + site_name: 'Skip None', + authType: 'none', + username: 'skip-none-user', + created_at: '2026-02-09T00:00:00.000Z', + updated_at: '2026-02-10T00:00:00.000Z', + }, + { + id: 'skipped-empty-account', + site_url: 'https://skip-empty.example.com', + site_type: 'new-api', + site_name: 'Skip Empty', + authType: 'access_token', + account_info: { + username: 'skip-empty-user', + }, + created_at: '2026-02-11T00:00:00.000Z', + updated_at: '2026-02-12T00:00:00.000Z', + }, + ], + bookmarks: [ + { + id: 'bookmark-1', + name: 'Ignored Bookmark', + url: 'https://bookmark.example.com', + }, + ], + pinnedAccountIds: ['direct-openai-account'], + orderedAccountIds: ['managed-account', 'cookie-account', 'direct-openai-account'], + last_updated: 1735689600000, + }, + preferences: { + language: 'zh-CN', + }, + channelConfigs: { + bySite: { + demo: { enabled: true }, + }, + }, + tagStore: { + version: 1, + tagsById: {}, + }, + apiCredentialProfiles: { + version: 2, + profiles: [ + { + id: 'profile-openai', + name: 'OpenAI Profile', + apiType: 'openai', + baseUrl: 'https://api.openai.com/v1', + apiKey: 'sk-profile-openai', + tagIds: [], + notes: '', + createdAt: 1735689601000, + updatedAt: 1735689602000, + }, + { + id: 'profile-anthropic', + name: 'Claude Profile', + apiType: 'anthropic', + baseUrl: 'https://api.anthropic.com/v1', + apiKey: 'sk-profile-claude', + tagIds: [], + notes: '', + createdAt: 1735689603000, + updatedAt: 1735689604000, + }, + { + id: 'profile-gemini', + name: 'Gemini Profile', + apiType: 'google', + baseUrl: 'https://generativelanguage.googleapis.com/v1beta', + apiKey: 'gemini-profile-key', + tagIds: [], + notes: '', + createdAt: 1735689605000, + updatedAt: 1735689606000, + }, + { + id: 'profile-compat-fallback', + name: 'Compat Profile', + apiType: 'openai-compatible', + baseUrl: 'https://compat.example.com/v1', + apiKey: 'sk-compat-profile', + tagIds: [], + notes: '', + createdAt: 1735689607000, + updatedAt: 1735689608000, + }, + ], + lastUpdated: 1735689609000, + }, + } as Record; + + const result = await backupService.importBackup(payload); + const summary = (result as any).summary; + const warnings = (result as any).warnings; + + expect(result.allImported).toBe(true); + expect(result.sections.accounts).toBe(true); + expect(result.sections.preferences).toBe(true); + expect(summary).toMatchObject({ + importedAccounts: 4, + importedProfiles: 4, + importedApiKeyConnections: 5, + importedSites: 7, + skippedAccounts: 2, + }); + expect(summary.ignoredSections).toEqual( + expect.arrayContaining(['accounts.bookmarks', 'channelConfigs', 'tagStore']), + ); + expect(warnings).toEqual( + expect.arrayContaining([ + expect.stringContaining('skipped-none-account'), + expect.stringContaining('skipped-empty-account'), + ]), + ); + + const sites = await db.select().from(schema.sites).all(); + const accounts = await db.select().from(schema.accounts).all(); + const accountTokens = await db.select().from(schema.accountTokens).all(); + const settings = await db.select().from(schema.settings).all(); + + expect(fetchSpy).not.toHaveBeenCalled(); + expect(sites).toHaveLength(7); + expect(accounts).toHaveLength(8); + expect(accountTokens).toHaveLength(5); + expect(settings.some((row) => row.key === 'legacy_preferences_ref_v2')).toBe(true); + expect(settings.some((row) => row.key === 'legacy_channel_configs_ref_v2')).toBe(true); + expect(settings.some((row) => row.key === 'legacy_tag_store_ref_v2')).toBe(true); + expect(settings.some((row) => row.key === 'legacy_api_credential_profiles_ref_v2')).toBe(false); + + const managedAccount = accounts.find((row) => row.username === 'managed-user'); + const cookieAccount = accounts.find((row) => row.username === 'cookie-user'); + const openAiAccount = accounts.find((row) => row.username === 'openai-account'); + const sub2apiAccount = accounts.find((row) => row.username === 'sub2-user'); + const openAiProfileAccount = accounts.find((row) => row.username === 'OpenAI Profile'); + const claudeProfileAccount = accounts.find((row) => row.username === 'Claude Profile'); + const geminiProfileAccount = accounts.find((row) => row.username === 'Gemini Profile'); + const compatProfileAccount = accounts.find((row) => row.username === 'Compat Profile'); + + expect(managedAccount?.accessToken).toBe('managed-session-token'); + expect(managedAccount?.apiToken).toBeNull(); + expect(managedAccount?.checkinEnabled).toBe(true); + expect(JSON.parse(managedAccount?.extraConfig || '{}')).toMatchObject({ + credentialMode: 'session', + platformUserId: 7788, + }); + + expect(cookieAccount?.accessToken).toBe('sid=cookie-session'); + expect(cookieAccount?.checkinEnabled).toBe(false); + expect(JSON.parse(cookieAccount?.extraConfig || '{}')).toMatchObject({ + credentialMode: 'session', + }); + + expect(openAiAccount?.accessToken).toBe(''); + expect(openAiAccount?.apiToken).toBe('sk-openai-account'); + expect(openAiAccount?.checkinEnabled).toBe(false); + expect(JSON.parse(openAiAccount?.extraConfig || '{}')).toMatchObject({ + credentialMode: 'apikey', + }); + + expect(sub2apiAccount?.accessToken).toBe('sub2-session-token'); + expect(JSON.parse(sub2apiAccount?.extraConfig || '{}')).toMatchObject({ + credentialMode: 'session', + platformUserId: 99, + sub2apiAuth: { + refreshToken: 'sub2-refresh-token', + tokenExpiresAt: 1735689600000, + }, + }); + + expect(openAiProfileAccount?.accessToken).toBe(''); + expect(openAiProfileAccount?.apiToken).toBe('sk-profile-openai'); + expect(JSON.parse(openAiProfileAccount?.extraConfig || '{}')).toMatchObject({ + credentialMode: 'apikey', + }); + expect(claudeProfileAccount?.apiToken).toBe('sk-profile-claude'); + expect(geminiProfileAccount?.apiToken).toBe('gemini-profile-key'); + expect(compatProfileAccount?.apiToken).toBe('sk-compat-profile'); + + const openAiSite = sites.find((row) => row.platform === 'openai' && row.url === 'https://api.openai.com'); + expect(openAiSite).toBeTruthy(); + expect(accounts.filter((row) => row.siteId === openAiSite?.id)).toHaveLength(2); + + expect(accountTokens.map((row) => row.token).sort()).toEqual([ + 'gemini-profile-key', + 'sk-compat-profile', + 'sk-openai-account', + 'sk-profile-claude', + 'sk-profile-openai', + ]); + expect(accountTokens.every((row) => row.name === 'default' && row.isDefault && row.source === 'legacy')).toBe(true); + } finally { + fetchSpy.mockRestore(); + } + }); + + it('backfills oauth columns from extraConfig when importing older backups', async () => { + const payload = { + timestamp: Date.now(), + accounts: { + sites: [ + { + id: 1, + name: 'codex-site', + url: 'https://codex.example.com', + platform: 'chatgpt-account', + proxyUrl: null, + status: 'active', + isPinned: false, + sortOrder: 0, + apiKey: null, + createdAt: '2026-03-01T00:00:00.000Z', + updatedAt: '2026-03-01T00:00:00.000Z', + externalCheckinUrl: null, + useSystemProxy: false, + globalWeight: 1, + customHeaders: null, + }, + ], + accounts: [ + { + id: 10, + siteId: 1, + username: 'oauth-user', + accessToken: 'oauth-access-token', + apiToken: null, + balance: 0, + balanceUsed: 0, + quota: 0, + unitCost: null, + valueScore: 0, + status: 'active', + isPinned: false, + sortOrder: 0, + checkinEnabled: true, + lastCheckinAt: null, + lastBalanceRefresh: null, + extraConfig: JSON.stringify({ + credentialMode: 'session', + oauth: { + provider: 'gemini-cli', + accountId: 'oauth-user@example.com', + accountKey: 'oauth-user@example.com', + projectId: 'oauth-project-id', + refreshToken: 'oauth-refresh-token', + }, + }), + createdAt: '2026-03-01T00:00:00.000Z', + updatedAt: '2026-03-01T00:00:00.000Z', + oauthProvider: null, + oauthAccountKey: null, + oauthProjectId: null, + }, + ], + accountTokens: [], + tokenRoutes: [], + routeChannels: [], + }, + } as Record; + + const result = await backupService.importBackup(payload); + + expect(result.allImported).toBe(true); + expect(result.sections.accounts).toBe(true); + + const restoredAccount = await db.select().from(schema.accounts).where(eq(schema.accounts.id, 10)).get(); + + expect(restoredAccount?.oauthProvider).toBe('gemini-cli'); + expect(restoredAccount?.oauthAccountKey).toBe('oauth-user@example.com'); + expect(restoredAccount?.oauthProjectId).toBe('oauth-project-id'); + }); + + it('exports configured backup payload to webdav and records sync state', async () => { + const fetchSpy = vi.spyOn(globalThis, 'fetch'); + fetchSpy.mockResolvedValue(new Response(null, { status: 201 })); + + await db.insert(schema.settings).values({ + key: 'backup_webdav_config_v1', + value: JSON.stringify({ + enabled: true, + fileUrl: 'https://dav.example.com/backups/metapi-preferences.json', + username: 'alice', + password: 'secret-pass', + exportType: 'preferences', + autoSyncEnabled: false, + autoSyncCron: '0 * * * *', + }), + }).run(); + await db.insert(schema.settings).values({ + key: 'ui_locale', + value: JSON.stringify('zh-CN'), + }).run(); + + const result = await (backupService as any).exportBackupToWebdav(); + + expect(result).toMatchObject({ + success: true, + fileUrl: 'https://dav.example.com/backups/metapi-preferences.json', + exportType: 'preferences', + }); + expect(fetchSpy).toHaveBeenCalledTimes(1); + const [targetUrl, init] = fetchSpy.mock.calls[0] as [string, RequestInit]; + expect(targetUrl).toBe('https://dav.example.com/backups/metapi-preferences.json'); + expect(init.method).toBe('PUT'); + expect(init.headers).toMatchObject({ + Authorization: `Basic ${Buffer.from('alice:secret-pass').toString('base64')}`, + 'Content-Type': 'application/json', + }); + const payload = JSON.parse(String(init.body)); + expect(payload.type).toBe('preferences'); + expect(payload.preferences.settings).toEqual( + expect.arrayContaining([ + expect.objectContaining({ key: 'ui_locale', value: 'zh-CN' }), + ]), + ); + + const syncState = await db.select().from(schema.settings).where(eq(schema.settings.key, 'backup_webdav_state_v1')).get(); + expect(syncState?.value).toContain('"lastSyncAt"'); + expect(syncState?.value).toContain('"lastError":null'); + + fetchSpy.mockRestore(); + }); + + it('imports backup payload from webdav into local data', async () => { + const fetchSpy = vi.spyOn(globalThis, 'fetch'); + const remotePayload = { + version: '2.0', + timestamp: Date.now(), + accounts: { + sites: [ + { + id: 1, + name: 'remote-site', + url: 'https://remote.example.com', + platform: 'new-api', + externalCheckinUrl: null, + proxyUrl: null, + useSystemProxy: false, + customHeaders: null, + status: 'active', + isPinned: false, + sortOrder: 0, + globalWeight: 1, + apiKey: null, + createdAt: '2026-03-20T00:00:00.000Z', + updatedAt: '2026-03-20T00:00:00.000Z', + }, + ], + accounts: [ + { + id: 1, + siteId: 1, + username: 'remote-user', + accessToken: 'remote-session', + apiToken: null, + oauthProvider: null, + oauthAccountKey: null, + oauthProjectId: null, + balance: 0, + balanceUsed: 0, + quota: 0, + unitCost: null, + valueScore: 0, + status: 'active', + isPinned: false, + sortOrder: 0, + checkinEnabled: false, + lastCheckinAt: null, + lastBalanceRefresh: null, + extraConfig: null, + createdAt: '2026-03-20T00:00:00.000Z', + updatedAt: '2026-03-20T00:00:00.000Z', + }, + ], + accountTokens: [], + tokenRoutes: [], + routeChannels: [], + routeGroupSources: [], + }, + }; + fetchSpy.mockResolvedValue(new Response(JSON.stringify(remotePayload), { + status: 200, + headers: { 'content-type': 'application/json' }, + })); + + await db.insert(schema.settings).values({ + key: 'backup_webdav_config_v1', + value: JSON.stringify({ + enabled: true, + fileUrl: 'https://dav.example.com/backups/metapi-all.json', + username: 'alice', + password: 'secret-pass', + exportType: 'all', + autoSyncEnabled: false, + autoSyncCron: '0 * * * *', + }), + }).run(); + + const result = await (backupService as any).importBackupFromWebdav(); + + expect(result.success).toBe(true); + expect(result.sections.accounts).toBe(true); + const sites = await db.select().from(schema.sites).all(); + const accounts = await db.select().from(schema.accounts).all(); + expect(sites).toHaveLength(1); + expect(accounts).toHaveLength(1); + expect(accounts[0].username).toBe('remote-user'); + expect(fetchSpy).toHaveBeenCalledWith( + 'https://dav.example.com/backups/metapi-all.json', + expect.objectContaining({ + method: 'GET', + }), + ); + + fetchSpy.mockRestore(); + }); + + it('times out stalled webdav export requests', async () => { + vi.useFakeTimers(); + const fetchSpy = vi.spyOn(globalThis, 'fetch'); + fetchSpy.mockImplementation((_, init) => { + const signal = init?.signal as AbortSignal | undefined; + if (!signal) { + return Promise.reject(new Error('missing abort signal')) as Promise; + } + return new Promise((_, reject) => { + signal.addEventListener('abort', () => { + reject(Object.assign(new Error('aborted'), { name: 'AbortError' })); + }, { once: true }); + }); + }); + + try { + await db.insert(schema.settings).values({ + key: 'backup_webdav_config_v1', + value: JSON.stringify({ + enabled: true, + fileUrl: 'https://dav.example.com/backups/metapi.json', + username: 'alice', + password: 'secret-pass', + exportType: 'all', + autoSyncEnabled: false, + autoSyncCron: '0 */6 * * *', + }), + }).run(); + + const exportAssertion = expect(backupService.exportBackupToWebdav()).rejects.toThrow('WebDAV 请求超时'); + await vi.advanceTimersByTimeAsync(16_000); + await exportAssertion; + } finally { + fetchSpy.mockRestore(); + vi.useRealTimers(); + } + }); + + it('times out stalled webdav import requests', async () => { + vi.useFakeTimers(); + const fetchSpy = vi.spyOn(globalThis, 'fetch'); + fetchSpy.mockImplementation((_, init) => { + const signal = init?.signal as AbortSignal | undefined; + if (!signal) { + return Promise.reject(new Error('missing abort signal')) as Promise; + } + return new Promise((_, reject) => { + signal.addEventListener('abort', () => { + reject(Object.assign(new Error('aborted'), { name: 'AbortError' })); + }, { once: true }); + }); + }); + + try { + await db.insert(schema.settings).values({ + key: 'backup_webdav_config_v1', + value: JSON.stringify({ + enabled: true, + fileUrl: 'https://dav.example.com/backups/metapi.json', + username: 'alice', + password: 'secret-pass', + exportType: 'all', + autoSyncEnabled: false, + autoSyncCron: '0 */6 * * *', + }), + }).run(); + + const importAssertion = expect(backupService.importBackupFromWebdav()).rejects.toThrow('WebDAV 请求超时'); + await vi.advanceTimersByTimeAsync(16_000); + await importAssertion; + } finally { + fetchSpy.mockRestore(); + vi.useRealTimers(); + } + }); + + it('does not schedule malformed imported webdav config', async () => { + const cronModule = await import('node-cron'); + const scheduleSpy = vi.spyOn(cronModule.default, 'schedule'); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => { }); + + try { + await db.insert(schema.settings).values({ + key: 'backup_webdav_config_v1', + value: JSON.stringify({ + enabled: true, + fileUrl: 'not-a-valid-url', + username: 'alice', + password: 'secret-pass', + exportType: 'all', + autoSyncEnabled: true, + autoSyncCron: '0 */6 * * *', + }), + }).run(); + + await expect(backupService.reloadBackupWebdavScheduler()).resolves.toBeUndefined(); + expect(scheduleSpy).not.toHaveBeenCalled(); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('invalid config')); + } finally { + backupService.__resetBackupWebdavSchedulerForTests(); + scheduleSpy.mockRestore(); + warnSpy.mockRestore(); + } + }); }); diff --git a/src/server/services/backupService.ts b/src/server/services/backupService.ts index cce67264..1df030d2 100644 --- a/src/server/services/backupService.ts +++ b/src/server/services/backupService.ts @@ -1,23 +1,112 @@ -import { asc } from 'drizzle-orm'; +import { asc, eq } from 'drizzle-orm'; import cron from 'node-cron'; import { db, schema } from '../db/index.js'; +import { upsertSetting } from '../db/upsertSetting.js'; +import { mergeAccountExtraConfig } from './accountExtraConfig.js'; +import { getOauthInfoFromAccount } from './oauth/oauthAccount.js'; +import { PLATFORM_ALIASES, detectPlatformByUrlHint } from '../../shared/platformIdentity.js'; -const BACKUP_VERSION = '2.0'; +const BACKUP_VERSION = '2.1'; export type BackupExportType = 'all' | 'accounts' | 'preferences'; +export interface BackupWebdavConfig { + enabled: boolean; + fileUrl: string; + username: string; + password: string; + exportType: BackupExportType; + autoSyncEnabled: boolean; + autoSyncCron: string; +} + +export interface BackupWebdavConfigView { + enabled: boolean; + fileUrl: string; + username: string; + exportType: BackupExportType; + autoSyncEnabled: boolean; + autoSyncCron: string; + hasPassword: boolean; + passwordMasked: string; +} + +export interface BackupWebdavState { + lastSyncAt: string | null; + lastError: string | null; +} + type SiteRow = typeof schema.sites.$inferSelect; type AccountRow = typeof schema.accounts.$inferSelect; type AccountTokenRow = typeof schema.accountTokens.$inferSelect; type TokenRouteRow = typeof schema.tokenRoutes.$inferSelect; type RouteChannelRow = typeof schema.routeChannels.$inferSelect; +type RouteGroupSourceRow = typeof schema.routeGroupSources.$inferSelect; +type SiteDisabledModelRow = typeof schema.siteDisabledModels.$inferSelect; +type ModelAvailabilityRow = typeof schema.modelAvailability.$inferSelect; +type TokenModelAvailabilityRow = typeof schema.tokenModelAvailability.$inferSelect; +type ProxyLogRow = typeof schema.proxyLogs.$inferSelect; +type CheckinLogRow = typeof schema.checkinLogs.$inferSelect; +type DownstreamApiKeyRow = typeof schema.downstreamApiKeys.$inferSelect; +type SiteAnnouncementRow = typeof schema.siteAnnouncements.$inferSelect; + +type BackupAccountRow = Omit + & Partial>; + +type BackupRouteChannelRow = Omit & Partial>; + +type BackupSiteDisabledModelRow = Pick; +type BackupManualModelRow = { + accountId: number; + modelName: string; +}; +type BackupDownstreamApiKeyRow = Pick & Partial>; interface AccountsBackupSection { sites: SiteRow[]; - accounts: AccountRow[]; + accounts: BackupAccountRow[]; accountTokens: AccountTokenRow[]; tokenRoutes: TokenRouteRow[]; - routeChannels: RouteChannelRow[]; + routeChannels: BackupRouteChannelRow[]; + routeGroupSources: RouteGroupSourceRow[]; + siteDisabledModels?: BackupSiteDisabledModelRow[]; + manualModels?: BackupManualModelRow[]; + downstreamApiKeys?: BackupDownstreamApiKeyRow[]; } interface PreferencesBackupSection { @@ -49,6 +138,75 @@ type BackupV2 = BackupFullV2 | BackupAccountsPartialV2 | BackupPreferencesPartia type RawBackupData = Record; +type AccountRuntimeSnapshot = { + balanceUsed: number | null; + lastCheckinAt: string | null; + lastBalanceRefresh: string | null; +}; + +type RouteChannelRuntimeSnapshot = Pick; + +type ProxyLogSnapshot = ProxyLogRow & { + accountKey: string | null; + routeKey: string | null; + channelKey: string | null; + downstreamApiKeyKey: string | null; +}; + +type CheckinLogSnapshot = CheckinLogRow & { + accountKey: string | null; +}; + +type SiteAnnouncementSnapshot = SiteAnnouncementRow & { + siteKey: string | null; +}; + +type ModelAvailabilitySnapshot = ModelAvailabilityRow & { + accountKey: string | null; +}; + +type TokenModelAvailabilitySnapshot = TokenModelAvailabilityRow & { + tokenKey: string | null; +}; + +type DownstreamApiKeyRuntimeSnapshot = Pick; + +interface RuntimeIdentityIndexes { + siteKeyById: Map; + siteIdByKey: Map; + accountKeyById: Map; + accountIdByKey: Map; + tokenKeyById: Map; + tokenIdByKey: Map; + routeKeyById: Map; + routeIdByKey: Map; + channelKeyById: Map; + channelIdByKey: Map; +} + +interface RuntimeStateSnapshot { + accountRuntimeByKey: Map; + routeChannelRuntimeByKey: Map; + siteAnnouncements: SiteAnnouncementSnapshot[]; + nonManualAvailability: ModelAvailabilitySnapshot[]; + tokenAvailability: TokenModelAvailabilitySnapshot[]; + downstreamApiKeyRuntimeByKey: Map; + downstreamApiKeyIdByKey: Map; + proxyLogs: ProxyLogSnapshot[]; + checkinLogs: CheckinLogSnapshot[]; +} + interface BackupImportResult { allImported: boolean; sections: { @@ -56,11 +214,39 @@ interface BackupImportResult { preferences: boolean; }; appliedSettings: Array<{ key: string; value: unknown }>; + summary?: { + importedSites: number; + importedAccounts: number; + importedProfiles: number; + importedApiKeyConnections: number; + skippedAccounts: number; + ignoredSections: string[]; + }; + warnings?: string[]; } const EXCLUDED_SETTING_KEYS = new Set([ // Keep current admin login credential unchanged to avoid accidental lock-out. 'auth_token', + // Runtime database selection is environment-bound and must not be propagated by backups. + 'db_type', + 'db_url', + 'db_ssl', +]); +const BACKUP_WEBDAV_CONFIG_SETTING_KEY = 'backup_webdav_config_v1'; +const BACKUP_WEBDAV_STATE_SETTING_KEY = 'backup_webdav_state_v1'; +const BACKUP_WEBDAV_DEFAULT_AUTO_SYNC_CRON = '0 */6 * * *'; +const BACKUP_WEBDAV_FETCH_TIMEOUT_MS = 15_000; +let backupWebdavTask: cron.ScheduledTask | null = null; + +const DIRECT_API_PLATFORMS = new Set([ + 'openai', + 'claude', + 'gemini', + 'cliproxyapi', + 'codex', + 'gemini-cli', + 'antigravity', ]); function isRecord(value: unknown): value is Record { @@ -103,6 +289,303 @@ function normalizeLegacyQuota(raw: unknown): number { return value; } +function resolveImportedOauthColumns(row: Pick) { + const oauth = getOauthInfoFromAccount(row); + const oauthProvider = row.oauthProvider || oauth?.provider || null; + const oauthAccountKey = row.oauthAccountKey || oauth?.accountKey || oauth?.accountId || null; + const oauthProjectId = row.oauthProjectId || oauth?.projectId || null; + return { + oauthProvider, + oauthAccountKey, + oauthProjectId, + }; +} + +function buildSiteIdentityKey(row: Pick): string { + return `${asString(row.platform).toLowerCase()}::${normalizeOriginUrl(asString(row.url))}`; +} + +function buildAccountIdentityKey(input: { + siteKey: string; + username?: string | null; + accessToken?: string | null; + apiToken?: string | null; + oauthProvider?: string | null; + oauthAccountKey?: string | null; + oauthProjectId?: string | null; +}): string { + const oauthProvider = asString(input.oauthProvider).toLowerCase(); + const oauthAccountKey = asString(input.oauthAccountKey); + const oauthProjectId = asString(input.oauthProjectId); + if (oauthProvider || oauthAccountKey || oauthProjectId) { + return `oauth::${input.siteKey}::${oauthProvider}::${oauthAccountKey}::${oauthProjectId}`; + } + + const apiToken = asString(input.apiToken); + if (apiToken) { + return `api::${input.siteKey}::${apiToken}`; + } + + const accessToken = asString(input.accessToken); + if (accessToken) { + return `session::${input.siteKey}::${accessToken}`; + } + + return `user::${input.siteKey}::${asString(input.username)}`; +} + +function buildTokenIdentityKey(row: Pick, accountKey: string): string { + const token = asString(row.token); + if (token) { + return `token::${accountKey}::${token}`; + } + + return [ + 'token-meta', + accountKey, + asString(row.name), + asString(row.tokenGroup), + asString(row.source), + row.isDefault ? '1' : '0', + ].join('::'); +} + +function buildRouteIdentityKey(row: Pick): string { + return [ + asString(row.modelPattern), + asString(row.routeMode), + ].join('::'); +} + +function buildModelAvailabilityIdentityKey(accountKey: string, modelName: string): string { + return [accountKey, asString(modelName)].join('::'); +} + +function buildRouteChannelIdentityKey( + row: Pick, + indexes: Pick, +): string | null { + const routeKey = indexes.routeKeyById.get(row.routeId); + const accountKey = indexes.accountKeyById.get(row.accountId); + if (!routeKey || !accountKey) return null; + + const tokenKey = row.tokenId ? (indexes.tokenKeyById.get(row.tokenId) || '') : ''; + return [routeKey, accountKey, tokenKey, asString(row.sourceModel)].join('::'); +} + +function buildRuntimeIdentityIndexesFromSection(section: AccountsBackupSection): RuntimeIdentityIndexes { + const siteKeyById = new Map(); + const siteIdByKey = new Map(); + const accountKeyById = new Map(); + const accountIdByKey = new Map(); + const tokenKeyById = new Map(); + const tokenIdByKey = new Map(); + const routeKeyById = new Map(); + const routeIdByKey = new Map(); + const channelKeyById = new Map(); + const channelIdByKey = new Map(); + + for (const row of section.sites) { + const siteKey = buildSiteIdentityKey(row); + siteKeyById.set(row.id, siteKey); + siteIdByKey.set(siteKey, row.id); + } + + for (const row of section.accounts) { + const siteKey = siteKeyById.get(row.siteId); + if (!siteKey) continue; + const oauthColumns = resolveImportedOauthColumns(row); + const accountKey = buildAccountIdentityKey({ + siteKey, + username: row.username, + accessToken: row.accessToken, + apiToken: row.apiToken, + oauthProvider: oauthColumns.oauthProvider, + oauthAccountKey: oauthColumns.oauthAccountKey, + oauthProjectId: oauthColumns.oauthProjectId, + }); + accountKeyById.set(row.id, accountKey); + accountIdByKey.set(accountKey, row.id); + } + + for (const row of section.accountTokens) { + const accountKey = accountKeyById.get(row.accountId); + if (!accountKey) continue; + const tokenKey = buildTokenIdentityKey(row, accountKey); + tokenKeyById.set(row.id, tokenKey); + tokenIdByKey.set(tokenKey, row.id); + } + + for (const row of section.tokenRoutes) { + const routeKey = buildRouteIdentityKey(row); + routeKeyById.set(row.id, routeKey); + routeIdByKey.set(routeKey, row.id); + } + + for (const row of section.routeChannels) { + const channelKey = buildRouteChannelIdentityKey(row, { + accountKeyById, + tokenKeyById, + routeKeyById, + }); + if (!channelKey) continue; + channelKeyById.set(row.id, channelKey); + channelIdByKey.set(channelKey, row.id); + } + + return { + siteKeyById, + siteIdByKey, + accountKeyById, + accountIdByKey, + tokenKeyById, + tokenIdByKey, + routeKeyById, + routeIdByKey, + channelKeyById, + channelIdByKey, + }; +} + +async function collectCurrentRuntimeStateSnapshot(): Promise { + const [ + sites, + accounts, + accountTokens, + tokenRoutes, + routeChannels, + proxyLogs, + checkinLogs, + siteAnnouncements, + modelAvailability, + tokenModelAvailability, + downstreamApiKeys, + ] = await Promise.all([ + db.select().from(schema.sites).all(), + db.select().from(schema.accounts).all(), + db.select().from(schema.accountTokens).all(), + db.select().from(schema.tokenRoutes).all(), + db.select().from(schema.routeChannels).all(), + db.select().from(schema.proxyLogs).all(), + db.select().from(schema.checkinLogs).all(), + db.select().from(schema.siteAnnouncements).all(), + db.select().from(schema.modelAvailability).all(), + db.select().from(schema.tokenModelAvailability).all(), + db.select().from(schema.downstreamApiKeys).all(), + ]); + + const siteKeyById = new Map(); + for (const row of sites) { + siteKeyById.set(row.id, buildSiteIdentityKey(row)); + } + + const accountKeyById = new Map(); + const accountRuntimeByKey = new Map(); + for (const row of accounts) { + const siteKey = siteKeyById.get(row.siteId); + if (!siteKey) continue; + const oauthColumns = resolveImportedOauthColumns(row); + const accountKey = buildAccountIdentityKey({ + siteKey, + username: row.username, + accessToken: row.accessToken, + apiToken: row.apiToken, + oauthProvider: oauthColumns.oauthProvider, + oauthAccountKey: oauthColumns.oauthAccountKey, + oauthProjectId: oauthColumns.oauthProjectId, + }); + accountKeyById.set(row.id, accountKey); + accountRuntimeByKey.set(accountKey, { + balanceUsed: row.balanceUsed ?? 0, + lastCheckinAt: row.lastCheckinAt ?? null, + lastBalanceRefresh: row.lastBalanceRefresh ?? null, + }); + } + + const tokenKeyById = new Map(); + for (const row of accountTokens) { + const accountKey = accountKeyById.get(row.accountId); + if (!accountKey) continue; + tokenKeyById.set(row.id, buildTokenIdentityKey(row, accountKey)); + } + + const routeKeyById = new Map(); + for (const row of tokenRoutes) { + routeKeyById.set(row.id, buildRouteIdentityKey(row)); + } + + const channelKeyById = new Map(); + const routeChannelRuntimeByKey = new Map(); + for (const row of routeChannels) { + const channelKey = buildRouteChannelIdentityKey(row, { + accountKeyById, + tokenKeyById, + routeKeyById, + }); + if (!channelKey) continue; + channelKeyById.set(row.id, channelKey); + routeChannelRuntimeByKey.set(channelKey, { + successCount: row.successCount, + failCount: row.failCount, + totalLatencyMs: row.totalLatencyMs, + totalCost: row.totalCost, + lastUsedAt: row.lastUsedAt ?? null, + lastSelectedAt: row.lastSelectedAt ?? null, + lastFailAt: row.lastFailAt ?? null, + consecutiveFailCount: row.consecutiveFailCount ?? 0, + cooldownLevel: row.cooldownLevel ?? 0, + cooldownUntil: row.cooldownUntil ?? null, + }); + } + + const downstreamApiKeyKeyById = new Map(); + const downstreamApiKeyIdByKey = new Map(); + const downstreamApiKeyRuntimeByKey = new Map(); + for (const row of downstreamApiKeys) { + const key = asString(row.key); + if (!key) continue; + downstreamApiKeyKeyById.set(row.id, key); + downstreamApiKeyIdByKey.set(key, row.id); + downstreamApiKeyRuntimeByKey.set(key, { + usedCost: row.usedCost ?? 0, + usedRequests: row.usedRequests ?? 0, + lastUsedAt: row.lastUsedAt ?? null, + }); + } + + return { + accountRuntimeByKey, + routeChannelRuntimeByKey, + siteAnnouncements: siteAnnouncements.map((row) => ({ + ...row, + siteKey: siteKeyById.get(row.siteId) || null, + })), + nonManualAvailability: modelAvailability + .filter((row) => !row.isManual) + .map((row) => ({ + ...row, + accountKey: accountKeyById.get(row.accountId) || null, + })), + tokenAvailability: tokenModelAvailability.map((row) => ({ + ...row, + tokenKey: tokenKeyById.get(row.tokenId) || null, + })), + downstreamApiKeyRuntimeByKey, + downstreamApiKeyIdByKey, + proxyLogs: proxyLogs.map((row) => ({ + ...row, + accountKey: row.accountId ? (accountKeyById.get(row.accountId) || null) : null, + routeKey: row.routeId ? (routeKeyById.get(row.routeId) || null) : null, + channelKey: row.channelId ? (channelKeyById.get(row.channelId) || null) : null, + downstreamApiKeyKey: row.downstreamApiKeyId ? (downstreamApiKeyKeyById.get(row.downstreamApiKeyId) || null) : null, + })), + checkinLogs: checkinLogs.map((row) => ({ + ...row, + accountKey: accountKeyById.get(row.accountId) || null, + })), + }; +} + function normalizeLegacyPlatform(raw: string): string { const value = raw.trim().toLowerCase(); if (!value) return 'new-api'; @@ -125,6 +608,350 @@ function normalizeLegacyPlatform(raw: string): string { return 'new-api'; } +function normalizeOriginUrl(raw: string): string { + const trimmed = raw.trim(); + if (!trimmed) return ''; + try { + return new URL(trimmed).origin; + } catch { + return trimmed.replace(/\/+$/, ''); + } +} + +function resolveImportedPlatform(rawPlatform: unknown, rawUrl: string): string { + const rawPlatformText = asString(rawPlatform).toLowerCase(); + const normalizedPlatform = rawPlatformText + ? ( + Object.prototype.hasOwnProperty.call(PLATFORM_ALIASES, rawPlatformText) + ? PLATFORM_ALIASES[rawPlatformText] + : (DIRECT_API_PLATFORMS.has(rawPlatformText) ? rawPlatformText : '') + ) + : ''; + if (normalizedPlatform) return normalizedPlatform; + + const urlHint = detectPlatformByUrlHint(rawUrl); + if (urlHint) return urlHint; + + return normalizeLegacyPlatform(asString(rawPlatform)); +} + +function resolveImportedProfilePlatform(apiType: unknown, baseUrl: string): string { + const normalizedType = asString(apiType).toLowerCase(); + if (normalizedType === 'openai') return 'openai'; + if (normalizedType === 'anthropic') return 'claude'; + if (normalizedType === 'google') return 'gemini'; + if (normalizedType === 'openai-compatible') { + return detectPlatformByUrlHint(baseUrl) || 'openai'; + } + return detectPlatformByUrlHint(baseUrl) || 'openai'; +} + +function pushDefaultImportedToken( + rows: AccountTokenRow[], + nextId: () => number, + accountId: number, + token: string | null, + createdAt: string, + updatedAt: string, +) { + if (!token) return; + rows.push({ + id: nextId(), + accountId, + name: 'default', + token, + tokenGroup: 'default', + valueStatus: 'ready', + source: 'legacy', + enabled: true, + isDefault: true, + createdAt, + updatedAt, + }); +} + +function buildAllApiHubV2AccountsSection(data: RawBackupData): { + section: AccountsBackupSection; + summary: NonNullable; + warnings: string[]; +} | null { + const accountsContainer = isRecord(data.accounts) ? data.accounts : null; + if (!accountsContainer || !Array.isArray(accountsContainer.accounts)) return null; + + if (coerceAccountsSection(accountsContainer)) return null; + + const looksLikeLegacyAccountRow = accountsContainer.accounts.some((row) => ( + isRecord(row) && ( + Object.prototype.hasOwnProperty.call(row, 'site_url') + || Object.prototype.hasOwnProperty.call(row, 'site_type') + || Object.prototype.hasOwnProperty.call(row, 'account_info') + || Object.prototype.hasOwnProperty.call(row, 'cookieAuth') + || Object.prototype.hasOwnProperty.call(row, 'authType') + || Object.prototype.hasOwnProperty.call(row, 'sub2apiAuth') + ) + )); + + const looksLikeV2 = + looksLikeLegacyAccountRow + && ( + (typeof data.version === 'string' && data.version.startsWith('2')) + || Object.prototype.hasOwnProperty.call(accountsContainer, 'last_updated') + || Array.isArray(accountsContainer.bookmarks) + || Array.isArray(accountsContainer.pinnedAccountIds) + || Array.isArray(accountsContainer.orderedAccountIds) + || (isRecord(data.apiCredentialProfiles) && Array.isArray(data.apiCredentialProfiles.profiles)) + ); + + if (!looksLikeV2) return null; + + const section: AccountsBackupSection = { + sites: [], + accounts: [], + accountTokens: [], + tokenRoutes: [], + routeChannels: [], + routeGroupSources: [], + }; + const siteIdByKey = new Map(); + let nextSiteId = 1; + let nextAccountId = 1; + let nextTokenId = 1; + const warnings: string[] = []; + const ignoredSections: string[] = []; + let importedAccounts = 0; + let importedProfiles = 0; + let importedApiKeyConnections = 0; + let skippedAccounts = 0; + + const nextToken = () => nextTokenId++; + const ensureSite = (input: { + platform: string; + url: string; + name?: string; + createdAt: string; + updatedAt: string; + }) => { + const normalizedUrl = normalizeOriginUrl(input.url); + if (!normalizedUrl) return null; + const key = `${input.platform}::${normalizedUrl}`; + const existingId = siteIdByKey.get(key); + if (existingId) return existingId; + + const siteId = nextSiteId++; + siteIdByKey.set(key, siteId); + section.sites.push({ + id: siteId, + name: asString(input.name) || normalizedUrl, + url: normalizedUrl, + externalCheckinUrl: null, + platform: input.platform, + proxyUrl: null, + useSystemProxy: false, + customHeaders: null, + status: 'active', + isPinned: false, + sortOrder: section.sites.length, + globalWeight: 1, + apiKey: null, + createdAt: input.createdAt, + updatedAt: input.updatedAt, + }); + return siteId; + }; + + const addIgnoredSection = (name: string, active: boolean) => { + if (active && !ignoredSections.includes(name)) ignoredSections.push(name); + }; + + addIgnoredSection('accounts.bookmarks', Array.isArray(accountsContainer.bookmarks) && accountsContainer.bookmarks.length > 0); + addIgnoredSection('channelConfigs', isRecord(data.channelConfigs)); + addIgnoredSection('tagStore', isRecord(data.tagStore)); + + for (const row of accountsContainer.accounts) { + if (!isRecord(row)) continue; + + const createdAt = toIsoString(row.created_at); + const updatedAt = toIsoString(row.updated_at); + const siteUrl = normalizeOriginUrl(asString(row.site_url)); + const siteName = asString(row.site_name) || siteUrl; + const platform = resolveImportedPlatform(row.site_type, siteUrl); + const authType = asString(row.authType).toLowerCase(); + const accountInfo = isRecord(row.account_info) ? row.account_info : {}; + const cookieAuth = isRecord(row.cookieAuth) ? row.cookieAuth : {}; + const sub2apiAuth = isRecord(row.sub2apiAuth) ? row.sub2apiAuth : {}; + const rawAccountId = asString(row.id) || asString(row.username) || siteName || `account-${nextAccountId}`; + const username = asString(accountInfo.username) || asString(row.username) || rawAccountId; + const platformUserId = asNumber(accountInfo.id, 0); + const checkin = isRecord(row.checkIn) ? row.checkIn : {}; + const accessTokenCandidate = asString(accountInfo.access_token) || asString(row.access_token); + const cookieSession = asString(cookieAuth.sessionCookie); + const isDirectApiPlatform = DIRECT_API_PLATFORMS.has(platform); + + let accessToken = ''; + let apiToken: string | null = null; + let credentialMode: 'session' | 'apikey' | null = null; + + if (authType === 'cookie') { + if (!cookieSession) { + skippedAccounts += 1; + warnings.push(`跳过 ALL-API-Hub 账号 ${rawAccountId}:cookieAuth.sessionCookie 缺失`); + continue; + } + accessToken = cookieSession; + credentialMode = 'session'; + } else if (authType === 'access_token') { + if (!accessTokenCandidate) { + skippedAccounts += 1; + warnings.push(`跳过 ALL-API-Hub 账号 ${rawAccountId}:access_token 缺失`); + continue; + } + if (isDirectApiPlatform) { + accessToken = ''; + apiToken = accessTokenCandidate; + credentialMode = 'apikey'; + } else { + accessToken = accessTokenCandidate; + credentialMode = 'session'; + } + } else { + skippedAccounts += 1; + warnings.push(`跳过 ALL-API-Hub 账号 ${rawAccountId}:authType=${authType || 'unknown'} 不支持离线迁移`); + continue; + } + + const siteId = ensureSite({ + platform, + url: siteUrl, + name: siteName, + createdAt, + updatedAt, + }); + if (!siteId) { + skippedAccounts += 1; + warnings.push(`跳过 ALL-API-Hub 账号 ${rawAccountId}:site_url 无效`); + continue; + } + + const importedBalance = normalizeLegacyQuota(accountInfo.quota); + const importedUsed = normalizeLegacyQuota(accountInfo.today_quota_consumption); + const importedQuota = importedBalance + importedUsed; + const extraConfigPatch: Record = { + credentialMode, + source: 'all-api-hub', + }; + if (platformUserId > 0) { + extraConfigPatch.platformUserId = platformUserId; + } + const refreshToken = asString(sub2apiAuth.refreshToken); + const tokenExpiresAt = asNumber(sub2apiAuth.tokenExpiresAt, 0); + if (refreshToken) { + extraConfigPatch.sub2apiAuth = tokenExpiresAt > 0 + ? { refreshToken, tokenExpiresAt } + : { refreshToken }; + } + + const accountId = nextAccountId++; + section.accounts.push({ + id: accountId, + siteId, + username, + accessToken, + apiToken, + oauthProvider: null, + oauthAccountKey: null, + oauthProjectId: null, + balance: importedBalance, + balanceUsed: importedUsed, + quota: importedQuota > 0 ? importedQuota : importedBalance, + unitCost: null, + valueScore: 0, + status: asBoolean(row.disabled, false) ? 'disabled' : 'active', + isPinned: false, + sortOrder: section.accounts.length, + checkinEnabled: credentialMode === 'session' ? asBoolean(checkin.autoCheckInEnabled, true) : false, + lastCheckinAt: null, + lastBalanceRefresh: null, + extraConfig: mergeAccountExtraConfig(undefined, extraConfigPatch), + createdAt, + updatedAt, + }); + pushDefaultImportedToken(section.accountTokens, nextToken, accountId, apiToken, createdAt, updatedAt); + if (credentialMode === 'apikey') importedApiKeyConnections += 1; + importedAccounts += 1; + } + + const profilesContainer = isRecord(data.apiCredentialProfiles) ? data.apiCredentialProfiles : null; + const profiles = Array.isArray(profilesContainer?.profiles) ? profilesContainer.profiles : []; + for (const profile of profiles) { + if (!isRecord(profile)) continue; + + const baseUrl = normalizeOriginUrl(asString(profile.baseUrl)); + const apiKey = asString(profile.apiKey); + if (!baseUrl || !apiKey) { + warnings.push(`跳过 ALL-API-Hub API 凭据 ${asString(profile.id) || asString(profile.name) || 'unknown'}:baseUrl 或 apiKey 缺失`); + continue; + } + + const createdAt = toIsoString(profile.createdAt); + const updatedAt = toIsoString(profile.updatedAt); + const platform = resolveImportedProfilePlatform(profile.apiType, asString(profile.baseUrl)); + const siteId = ensureSite({ + platform, + url: baseUrl, + name: baseUrl, + createdAt, + updatedAt, + }); + if (!siteId) continue; + + const accountId = nextAccountId++; + section.accounts.push({ + id: accountId, + siteId, + username: asString(profile.name) || asString(profile.id) || baseUrl, + accessToken: '', + apiToken: apiKey, + oauthProvider: null, + oauthAccountKey: null, + oauthProjectId: null, + balance: 0, + balanceUsed: 0, + quota: 0, + unitCost: null, + valueScore: 0, + status: 'active', + isPinned: false, + sortOrder: section.accounts.length, + checkinEnabled: false, + lastCheckinAt: null, + lastBalanceRefresh: null, + extraConfig: mergeAccountExtraConfig(undefined, { + credentialMode: 'apikey', + source: 'all-api-hub-profile', + importedProfileId: asString(profile.id) || undefined, + }), + createdAt, + updatedAt, + }); + pushDefaultImportedToken(section.accountTokens, nextToken, accountId, apiKey, createdAt, updatedAt); + importedApiKeyConnections += 1; + importedProfiles += 1; + } + + return { + section, + summary: { + importedSites: section.sites.length, + importedAccounts, + importedProfiles, + importedApiKeyConnections, + skippedAccounts, + ignoredSections, + }, + warnings, + }; +} + function buildAccountsSectionFromRefBackup(data: RawBackupData): AccountsBackupSection | null { const accountsContainer = isRecord(data.accounts) ? data.accounts : null; const rows = Array.isArray(accountsContainer?.accounts) ? accountsContainer.accounts : null; @@ -163,6 +990,7 @@ function buildAccountsSectionFromRefBackup(data: RawBackupData): AccountsBackupS platform, proxyUrl: null, useSystemProxy: false, + customHeaders: null, status: 'active', isPinned: false, sortOrder: sites.length, @@ -213,6 +1041,9 @@ function buildAccountsSectionFromRefBackup(data: RawBackupData): AccountsBackupS username, accessToken: accountAccessToken, apiToken, + oauthProvider: null, + oauthAccountKey: null, + oauthProjectId: null, balance: importedBalance, balanceUsed: importedUsed, quota: importedQuota > 0 ? importedQuota : importedBalance, @@ -236,6 +1067,7 @@ function buildAccountsSectionFromRefBackup(data: RawBackupData): AccountsBackupS name: 'default', token: apiToken, tokenGroup: 'default', + valueStatus: 'ready', source: 'legacy', enabled: true, isDefault: true, @@ -251,6 +1083,7 @@ function buildAccountsSectionFromRefBackup(data: RawBackupData): AccountsBackupS accountTokens, tokenRoutes, routeChannels, + routeGroupSources: [], }; } @@ -263,9 +1096,6 @@ function buildPreferencesSectionFromRefBackup(data: RawBackupData): PreferencesB if (isRecord(data.channelConfigs)) { settings.push({ key: 'legacy_channel_configs_ref_v2', value: data.channelConfigs }); } - if (isRecord(data.apiCredentialProfiles)) { - settings.push({ key: 'legacy_api_credential_profiles_ref_v2', value: data.apiCredentialProfiles }); - } if (isRecord(data.tagStore)) { settings.push({ key: 'legacy_tag_store_ref_v2', value: data.tagStore }); } @@ -287,15 +1117,148 @@ function stringifySettingValue(value: unknown): string { return JSON.stringify(value); } +function isValidBackupExportType(value: unknown): value is BackupExportType { + return value === 'all' || value === 'accounts' || value === 'preferences'; +} + +function maskSecret(value: string): string { + if (!value) return ''; + if (value.length <= 4) return '****'; + return `${value.slice(0, 2)}****${value.slice(-2)}`; +} + +function isValidHttpUrl(raw: string): boolean { + if (!raw.trim()) return false; + try { + const parsed = new URL(raw); + return parsed.protocol === 'http:' || parsed.protocol === 'https:'; + } catch { + return false; + } +} + +function normalizeBackupWebdavConfig(raw: unknown): BackupWebdavConfig { + const source = isRecord(raw) ? raw : {}; + const exportType = isValidBackupExportType(source.exportType) ? source.exportType : 'all'; + const autoSyncCron = typeof source.autoSyncCron === 'string' && cron.validate(source.autoSyncCron) + ? source.autoSyncCron + : BACKUP_WEBDAV_DEFAULT_AUTO_SYNC_CRON; + + return { + enabled: source.enabled === true, + fileUrl: asString(source.fileUrl), + username: asString(source.username), + password: typeof source.password === 'string' ? source.password : '', + exportType, + autoSyncEnabled: source.autoSyncEnabled === true, + autoSyncCron, + }; +} + +function normalizeBackupWebdavState(raw: unknown): BackupWebdavState { + const source = isRecord(raw) ? raw : {}; + return { + lastSyncAt: typeof source.lastSyncAt === 'string' && source.lastSyncAt.trim() ? source.lastSyncAt : null, + lastError: typeof source.lastError === 'string' && source.lastError.trim() ? source.lastError : null, + }; +} + +function toBackupWebdavConfigView(config: BackupWebdavConfig): BackupWebdavConfigView { + return { + enabled: config.enabled, + fileUrl: config.fileUrl, + username: config.username, + exportType: config.exportType, + autoSyncEnabled: config.autoSyncEnabled, + autoSyncCron: config.autoSyncCron, + hasPassword: config.password.length > 0, + passwordMasked: maskSecret(config.password), + }; +} + +async function readSettingValue(key: string): Promise { + const row = await db.select({ value: schema.settings.value }).from(schema.settings).where(eq(schema.settings.key, key)).get(); + return parseSettingValue(row?.value ?? null); +} + +async function loadBackupWebdavConfig(): Promise { + return normalizeBackupWebdavConfig(await readSettingValue(BACKUP_WEBDAV_CONFIG_SETTING_KEY)); +} + +async function loadBackupWebdavState(): Promise { + return normalizeBackupWebdavState(await readSettingValue(BACKUP_WEBDAV_STATE_SETTING_KEY)); +} + +async function writeBackupWebdavState(next: BackupWebdavState) { + await upsertSetting(BACKUP_WEBDAV_STATE_SETTING_KEY, next); +} + +function resolveBackupWebdavAuthHeader(config: BackupWebdavConfig): string | null { + if (!config.username && !config.password) return null; + return `Basic ${Buffer.from(`${config.username}:${config.password}`).toString('base64')}`; +} + +function validateBackupWebdavConfig(config: BackupWebdavConfig) { + if (config.enabled && !isValidHttpUrl(config.fileUrl)) { + throw new Error('WebDAV 文件地址无效,请填写 http/https 文件 URL'); + } + if (!isValidBackupExportType(config.exportType)) { + throw new Error('WebDAV 导出类型无效,仅支持 all/accounts/preferences'); + } + if (!cron.validate(config.autoSyncCron)) { + throw new Error('WebDAV 自动同步 Cron 表达式无效'); + } + if (config.autoSyncEnabled && !config.enabled) { + throw new Error('启用自动同步前请先启用 WebDAV 备份'); + } +} + +async function fetchBackupWebdav(url: string, init: RequestInit): Promise { + const controller = new AbortController(); + let timeoutHandle: ReturnType | null = setTimeout(() => { + controller.abort(); + }, BACKUP_WEBDAV_FETCH_TIMEOUT_MS); + + try { + return await fetch(url, { + ...init, + signal: controller.signal, + }); + } catch (error: any) { + if (error?.name === 'AbortError') { + throw new Error(`WebDAV 请求超时(${Math.max(1, Math.round(BACKUP_WEBDAV_FETCH_TIMEOUT_MS / 1000))}s)`); + } + throw error; + } finally { + if (timeoutHandle) { + clearTimeout(timeoutHandle); + timeoutHandle = null; + } + } +} + +function stopBackupWebdavScheduler() { + backupWebdavTask?.stop(); + backupWebdavTask = null; +} + function isFiniteNumber(value: unknown): value is number { return typeof value === 'number' && Number.isFinite(value); } function isSettingValueAcceptable(key: string, value: unknown): boolean { - if (key === 'checkin_cron' || key === 'balance_refresh_cron') { + if (key === 'checkin_cron' || key === 'balance_refresh_cron' || key === 'log_cleanup_cron') { return typeof value === 'string' && cron.validate(value); } + if (key === 'log_cleanup_usage_logs_enabled' || key === 'log_cleanup_program_logs_enabled') { + return typeof value === 'boolean'; + } + + if (key === 'log_cleanup_retention_days') { + return isFiniteNumber(value) && value >= 1; + } + if (key === 'proxy_token') { return typeof value === 'string' && value.trim().length >= 6 @@ -316,18 +1279,69 @@ function isSettingValueAcceptable(key: string, value: unknown): boolean { } async function exportAccountsSection(): Promise { - const sites = await db.select().from(schema.sites).orderBy(asc(schema.sites.id)).all(); - const accounts = await db.select().from(schema.accounts).orderBy(asc(schema.accounts.id)).all(); - const accountTokens = await db.select().from(schema.accountTokens).orderBy(asc(schema.accountTokens.id)).all(); - const tokenRoutes = await db.select().from(schema.tokenRoutes).orderBy(asc(schema.tokenRoutes.id)).all(); - const routeChannels = await db.select().from(schema.routeChannels).orderBy(asc(schema.routeChannels.id)).all(); - - return { + const [ sites, accounts, accountTokens, tokenRoutes, routeChannels, + routeGroupSources, + siteDisabledModels, + manualModels, + downstreamApiKeys, + ] = await Promise.all([ + db.select().from(schema.sites).orderBy(asc(schema.sites.id)).all(), + db.select().from(schema.accounts).orderBy(asc(schema.accounts.id)).all(), + db.select().from(schema.accountTokens).orderBy(asc(schema.accountTokens.id)).all(), + db.select().from(schema.tokenRoutes).orderBy(asc(schema.tokenRoutes.id)).all(), + db.select().from(schema.routeChannels).orderBy(asc(schema.routeChannels.id)).all(), + db.select().from(schema.routeGroupSources).orderBy(asc(schema.routeGroupSources.id)).all(), + db.select().from(schema.siteDisabledModels) + .orderBy(asc(schema.siteDisabledModels.siteId), asc(schema.siteDisabledModels.modelName)) + .all(), + db.select().from(schema.modelAvailability) + .where(eq(schema.modelAvailability.isManual, true)) + .orderBy(asc(schema.modelAvailability.accountId), asc(schema.modelAvailability.modelName)) + .all(), + db.select().from(schema.downstreamApiKeys).orderBy(asc(schema.downstreamApiKeys.id)).all(), + ]); + + return { + sites, + accounts: accounts.map(({ balanceUsed: _balanceUsed, lastCheckinAt: _lastCheckinAt, lastBalanceRefresh: _lastBalanceRefresh, ...row }) => row), + accountTokens, + tokenRoutes, + routeChannels: routeChannels.map(({ + successCount: _successCount, + failCount: _failCount, + totalLatencyMs: _totalLatencyMs, + totalCost: _totalCost, + lastUsedAt: _lastUsedAt, + lastSelectedAt: _lastSelectedAt, + lastFailAt: _lastFailAt, + consecutiveFailCount: _consecutiveFailCount, + cooldownLevel: _cooldownLevel, + cooldownUntil: _cooldownUntil, + ...row + }) => row), + routeGroupSources, + siteDisabledModels: siteDisabledModels.map((row) => ({ + siteId: row.siteId, + modelName: row.modelName, + })), + manualModels: manualModels.map((row) => ({ + accountId: row.accountId, + modelName: row.modelName, + })), + downstreamApiKeys: downstreamApiKeys.map(({ + id: _id, + usedCost: _usedCost, + usedRequests: _usedRequests, + lastUsedAt: _lastUsedAt, + createdAt: _createdAt, + updatedAt: _updatedAt, + ...row + }) => row), }; } @@ -374,10 +1388,22 @@ function coerceAccountsSection(input: unknown): AccountsBackupSection | null { if (!isRecord(input)) return null; const sites = Array.isArray(input.sites) ? input.sites as SiteRow[] : null; - const accounts = Array.isArray(input.accounts) ? input.accounts as AccountRow[] : null; + const accounts = Array.isArray(input.accounts) ? input.accounts as BackupAccountRow[] : null; const accountTokens = Array.isArray(input.accountTokens) ? input.accountTokens as AccountTokenRow[] : null; const tokenRoutes = Array.isArray(input.tokenRoutes) ? input.tokenRoutes as TokenRouteRow[] : null; - const routeChannels = Array.isArray(input.routeChannels) ? input.routeChannels as RouteChannelRow[] : null; + const routeChannels = Array.isArray(input.routeChannels) ? input.routeChannels as BackupRouteChannelRow[] : null; + const routeGroupSources = Array.isArray(input.routeGroupSources) + ? input.routeGroupSources as RouteGroupSourceRow[] + : []; + const siteDisabledModels = Array.isArray(input.siteDisabledModels) + ? input.siteDisabledModels as BackupSiteDisabledModelRow[] + : undefined; + const manualModels = Array.isArray(input.manualModels) + ? input.manualModels as BackupManualModelRow[] + : undefined; + const downstreamApiKeys = Array.isArray(input.downstreamApiKeys) + ? input.downstreamApiKeys as BackupDownstreamApiKeyRow[] + : undefined; if (!sites || !accounts || !accountTokens || !tokenRoutes || !routeChannels) return null; @@ -387,6 +1413,10 @@ function coerceAccountsSection(input: unknown): AccountsBackupSection | null { accountTokens, tokenRoutes, routeChannels, + routeGroupSources, + siteDisabledModels, + manualModels, + downstreamApiKeys, }; } @@ -421,6 +1451,9 @@ function detectAccountsSection(data: RawBackupData): AccountsBackupSection | nul if (legacyNested) return legacyNested; } + const allApiHubV2 = buildAllApiHubV2AccountsSection(data); + if (allApiHubV2) return allApiHubV2.section; + const refFormat = buildAccountsSectionFromRefBackup(data); if (refFormat) return refFormat; @@ -447,14 +1480,35 @@ function detectPreferencesSection(data: RawBackupData): PreferencesBackupSection return null; } +function detectImportMetadata(data: RawBackupData): { + summary?: BackupImportResult['summary']; + warnings?: string[]; +} { + const allApiHubV2 = buildAllApiHubV2AccountsSection(data); + if (!allApiHubV2) return {}; + return { + summary: allApiHubV2.summary, + warnings: allApiHubV2.warnings.length > 0 ? allApiHubV2.warnings : undefined, + }; +} + async function importAccountsSection(section: AccountsBackupSection): Promise { + const runtimeState = await collectCurrentRuntimeStateSnapshot(); + const importedIndexes = buildRuntimeIdentityIndexesFromSection(section); + const shouldReplaceSiteDisabledModels = Array.isArray(section.siteDisabledModels); + const shouldReplaceManualModels = Array.isArray(section.manualModels); + const shouldReplaceDownstreamApiKeys = Array.isArray(section.downstreamApiKeys); + await db.transaction(async (tx) => { + if (shouldReplaceDownstreamApiKeys) { + await tx.delete(schema.downstreamApiKeys).run(); + } + await tx.delete(schema.proxyLogs).run(); await tx.delete(schema.routeChannels).run(); + await tx.delete(schema.routeGroupSources).run(); await tx.delete(schema.tokenRoutes).run(); await tx.delete(schema.tokenModelAvailability).run(); await tx.delete(schema.modelAvailability).run(); - await tx.delete(schema.proxyLogs).run(); - await tx.delete(schema.checkinLogs).run(); await tx.delete(schema.accountTokens).run(); await tx.delete(schema.accounts).run(); await tx.delete(schema.sites).run(); @@ -464,8 +1518,11 @@ async function importAccountsSection(section: AccountsBackupSection): Promise(); + if (shouldReplaceManualModels) { + const checkedAt = new Date().toISOString(); + for (const row of section.manualModels || []) { + const accountKey = importedIndexes.accountKeyById.get(row.accountId); + if (accountKey) { + importedManualModelKeys.add(buildModelAvailabilityIdentityKey(accountKey, row.modelName)); + } + await tx.insert(schema.modelAvailability).values({ + accountId: row.accountId, + modelName: row.modelName, + available: true, + isManual: true, + latencyMs: null, + checkedAt, + }).run(); + } + } + + for (const row of runtimeState.nonManualAvailability) { + if (!row.accountKey) continue; + const accountId = importedIndexes.accountIdByKey.get(row.accountKey); + if (!accountId) continue; + const modelKey = buildModelAvailabilityIdentityKey(row.accountKey, row.modelName); + if (importedManualModelKeys.has(modelKey)) continue; + + await tx.insert(schema.modelAvailability).values({ + accountId, + modelName: row.modelName, + available: row.available, + isManual: false, + latencyMs: row.latencyMs ?? null, + checkedAt: row.checkedAt, + }).run(); + } + + for (const row of runtimeState.tokenAvailability) { + if (!row.tokenKey) continue; + const tokenId = importedIndexes.tokenIdByKey.get(row.tokenKey); + if (!tokenId) continue; + + await tx.insert(schema.tokenModelAvailability).values({ + tokenId, + modelName: row.modelName, + available: row.available, + latencyMs: row.latencyMs ?? null, + checkedAt: row.checkedAt, + }).run(); + } + + for (const row of runtimeState.siteAnnouncements) { + if (!row.siteKey) continue; + const siteId = importedIndexes.siteIdByKey.get(row.siteKey); + if (!siteId) continue; + + await tx.insert(schema.siteAnnouncements).values({ + siteId, + platform: row.platform, + sourceKey: row.sourceKey, + title: row.title, + content: row.content, + level: row.level, + sourceUrl: row.sourceUrl ?? null, + startsAt: row.startsAt ?? null, + endsAt: row.endsAt ?? null, + upstreamCreatedAt: row.upstreamCreatedAt ?? null, + upstreamUpdatedAt: row.upstreamUpdatedAt ?? null, + firstSeenAt: row.firstSeenAt ?? null, + lastSeenAt: row.lastSeenAt ?? null, + readAt: row.readAt ?? null, + dismissedAt: row.dismissedAt ?? null, + rawPayload: row.rawPayload ?? null, + }).run(); + } + + const downstreamApiKeyIdByKey = shouldReplaceDownstreamApiKeys + ? new Map() + : new Map(runtimeState.downstreamApiKeyIdByKey); + if (shouldReplaceDownstreamApiKeys) { + for (const row of section.downstreamApiKeys || []) { + const normalizedKey = asString(row.key); + if (!normalizedKey) continue; + const runtimeDownstream = runtimeState.downstreamApiKeyRuntimeByKey.get(normalizedKey); + const insertedKey = await tx.insert(schema.downstreamApiKeys).values({ + name: row.name, + key: normalizedKey, + description: row.description ?? null, + groupName: row.groupName ?? null, + tags: row.tags ?? null, + enabled: row.enabled ?? true, + expiresAt: row.expiresAt ?? null, + maxCost: row.maxCost ?? null, + usedCost: runtimeDownstream?.usedCost ?? row.usedCost ?? 0, + maxRequests: row.maxRequests ?? null, + usedRequests: runtimeDownstream?.usedRequests ?? row.usedRequests ?? 0, + supportedModels: row.supportedModels ?? null, + allowedRouteIds: row.allowedRouteIds ?? null, + siteWeightMultipliers: row.siteWeightMultipliers ?? null, + lastUsedAt: runtimeDownstream?.lastUsedAt ?? row.lastUsedAt ?? null, + }).returning({ id: schema.downstreamApiKeys.id }).get(); + downstreamApiKeyIdByKey.set(normalizedKey, insertedKey.id); + } + } + + for (const row of runtimeState.proxyLogs) { + const accountId = row.accountKey ? (importedIndexes.accountIdByKey.get(row.accountKey) ?? null) : null; + const routeId = row.routeKey ? (importedIndexes.routeIdByKey.get(row.routeKey) ?? null) : null; + const channelId = row.channelKey ? (importedIndexes.channelIdByKey.get(row.channelKey) ?? null) : null; + const downstreamApiKeyId = row.downstreamApiKeyKey + ? (downstreamApiKeyIdByKey.get(row.downstreamApiKeyKey) ?? null) + : null; + + await tx.insert(schema.proxyLogs).values({ + id: row.id, + routeId, + channelId, + accountId, + downstreamApiKeyId, + modelRequested: row.modelRequested ?? null, + modelActual: row.modelActual ?? null, + status: row.status ?? null, + httpStatus: row.httpStatus ?? null, + latencyMs: row.latencyMs ?? null, + promptTokens: row.promptTokens ?? null, + completionTokens: row.completionTokens ?? null, + totalTokens: row.totalTokens ?? null, + estimatedCost: row.estimatedCost ?? null, + billingDetails: row.billingDetails ?? null, + clientFamily: row.clientFamily ?? null, + clientAppId: row.clientAppId ?? null, + clientAppName: row.clientAppName ?? null, + clientConfidence: row.clientConfidence ?? null, + errorMessage: row.errorMessage ?? null, + retryCount: row.retryCount ?? 0, + createdAt: row.createdAt, + }).run(); + } + + for (const row of runtimeState.checkinLogs) { + const accountId = row.accountKey ? importedIndexes.accountIdByKey.get(row.accountKey) : undefined; + if (!accountId) continue; + + await tx.insert(schema.checkinLogs).values({ + id: row.id, + accountId, + status: row.status, + message: row.message ?? null, + reward: row.reward ?? null, + createdAt: row.createdAt, }).run(); } }); @@ -558,13 +1800,7 @@ async function importPreferencesSection(section: PreferencesBackupSection): Prom for (const row of section.settings) { if (!isSettingValueAcceptable(row.key, row.value)) continue; - await tx.insert(schema.settings).values({ - key: row.key, - value: stringifySettingValue(row.value), - }).onConflictDoUpdate({ - target: schema.settings.key, - set: { value: stringifySettingValue(row.value) }, - }).run(); + await upsertSetting(row.key, row.value, tx); applied.push({ key: row.key, value: row.value }); } }); @@ -583,6 +1819,7 @@ export async function importBackup(data: RawBackupData): Promise & { password?: string; clearPassword?: boolean }) { + const existing = await loadBackupWebdavConfig(); + const next: BackupWebdavConfig = { + enabled: input.enabled !== undefined ? input.enabled === true : existing.enabled, + fileUrl: input.fileUrl !== undefined ? asString(input.fileUrl) : existing.fileUrl, + username: input.username !== undefined ? asString(input.username) : existing.username, + password: input.clearPassword + ? '' + : (input.password !== undefined + ? String(input.password) + : existing.password), + exportType: isValidBackupExportType(input.exportType) ? input.exportType : existing.exportType, + autoSyncEnabled: input.autoSyncEnabled !== undefined ? input.autoSyncEnabled === true : existing.autoSyncEnabled, + autoSyncCron: typeof input.autoSyncCron === 'string' && input.autoSyncCron.trim() + ? input.autoSyncCron.trim() + : existing.autoSyncCron, + }; + + if (!next.enabled) { + next.autoSyncEnabled = false; + } + validateBackupWebdavConfig(next); + + await upsertSetting(BACKUP_WEBDAV_CONFIG_SETTING_KEY, next); + await reloadBackupWebdavScheduler(); + return getBackupWebdavConfig(); +} + +export async function exportBackupToWebdav(type?: BackupExportType) { + const config = await loadBackupWebdavConfig(); + validateBackupWebdavConfig(config); + if (!config.enabled) { + throw new Error('WebDAV 备份未启用'); + } + if (!config.fileUrl) { + throw new Error('WebDAV 文件地址不能为空'); + } + + const exportType = type && isValidBackupExportType(type) ? type : config.exportType; + const payload = await exportBackup(exportType); + const headers: Record = { + 'Content-Type': 'application/json', }; + const authHeader = resolveBackupWebdavAuthHeader(config); + if (authHeader) headers.Authorization = authHeader; + + try { + const response = await fetchBackupWebdav(config.fileUrl, { + method: 'PUT', + headers, + body: JSON.stringify(payload, null, 2), + }); + if (!response.ok) { + const text = await response.text().catch(() => ''); + throw new Error(`WebDAV 导出失败:HTTP ${response.status}${text ? ` ${text.slice(0, 120)}` : ''}`); + } + + const syncedAt = new Date().toISOString(); + await writeBackupWebdavState({ + lastSyncAt: syncedAt, + lastError: null, + }); + return { + success: true, + fileUrl: config.fileUrl, + exportType, + syncedAt, + lastSyncAt: syncedAt, + lastError: null, + }; + } catch (error: any) { + const previousState = await loadBackupWebdavState(); + await writeBackupWebdavState({ + lastSyncAt: previousState.lastSyncAt, + lastError: error?.message || 'WebDAV 导出失败', + }); + throw error; + } +} + +export async function importBackupFromWebdav() { + const config = await loadBackupWebdavConfig(); + validateBackupWebdavConfig(config); + if (!config.enabled) { + throw new Error('WebDAV 备份未启用'); + } + if (!config.fileUrl) { + throw new Error('WebDAV 文件地址不能为空'); + } + + const headers: Record = {}; + const authHeader = resolveBackupWebdavAuthHeader(config); + if (authHeader) headers.Authorization = authHeader; + + try { + const response = await fetchBackupWebdav(config.fileUrl, { + method: 'GET', + headers, + }); + if (!response.ok) { + const text = await response.text().catch(() => ''); + throw new Error(`WebDAV 导入失败:HTTP ${response.status}${text ? ` ${text.slice(0, 120)}` : ''}`); + } + + const raw = await response.text(); + const parsed = JSON.parse(raw) as RawBackupData; + const result = await importBackup(parsed); + const syncedAt = new Date().toISOString(); + await writeBackupWebdavState({ + lastSyncAt: syncedAt, + lastError: null, + }); + + return { + success: true, + fileUrl: config.fileUrl, + syncedAt, + lastSyncAt: syncedAt, + lastError: null, + ...result, + }; + } catch (error: any) { + const previousState = await loadBackupWebdavState(); + await writeBackupWebdavState({ + lastSyncAt: previousState.lastSyncAt, + lastError: error?.message || 'WebDAV 导入失败', + }); + throw error; + } +} + +export async function reloadBackupWebdavScheduler() { + stopBackupWebdavScheduler(); + const config = await loadBackupWebdavConfig(); + if (!config.enabled || !config.autoSyncEnabled) return; + + try { + validateBackupWebdavConfig(config); + } catch (error: any) { + console.warn(`[backup/webdav] invalid config: ${error?.message || 'unknown error'}`); + return; + } + + backupWebdavTask = cron.schedule(config.autoSyncCron, () => { + void exportBackupToWebdav(config.exportType).catch((error) => { + console.warn(`[backup/webdav] auto sync failed: ${(error as Error)?.message || 'unknown error'}`); + }); + }); +} + +export function __resetBackupWebdavSchedulerForTests() { + stopBackupWebdavScheduler(); } diff --git a/src/server/services/balanceService.autoRelogin.test.ts b/src/server/services/balanceService.autoRelogin.test.ts index 60f34839..6c6ae553 100644 --- a/src/server/services/balanceService.autoRelogin.test.ts +++ b/src/server/services/balanceService.autoRelogin.test.ts @@ -172,6 +172,60 @@ describe('balanceService auto relogin', () => { expect(reportTokenExpiredMock).toHaveBeenCalledTimes(1); }); + it('does not report token expired for generic forbidden balance errors', async () => { + selectAllMock.mockReturnValue([ + { + accounts: { + id: 12, + username: 'linuxdo_forbidden', + accessToken: 'stale-token', + status: 'active', + extraConfig: null, + }, + sites: { + id: 12, + name: 'kfc', + url: 'https://kfc-api.sxxe.net', + platform: 'new-api', + }, + }, + ]); + + adapterMock.getBalance.mockRejectedValueOnce(new Error('HTTP 403: forbidden')); + + const { refreshBalance } = await import('./balanceService.js'); + await expect(refreshBalance(12)).rejects.toThrow('forbidden'); + + expect(reportTokenExpiredMock).not.toHaveBeenCalled(); + }); + + it('does not report token expired for missing new-api-user errors', async () => { + selectAllMock.mockReturnValue([ + { + accounts: { + id: 13, + username: 'linuxdo_missing_user', + accessToken: 'stale-token', + status: 'active', + extraConfig: null, + }, + sites: { + id: 13, + name: 'kfc', + url: 'https://kfc-api.sxxe.net', + platform: 'new-api', + }, + }, + ]); + + adapterMock.getBalance.mockRejectedValueOnce(new Error('HTTP 400: new-api-user required')); + + const { refreshBalance } = await import('./balanceService.js'); + await expect(refreshBalance(13)).rejects.toThrow('new-api-user'); + + expect(reportTokenExpiredMock).not.toHaveBeenCalled(); + }); + it('proactively refreshes sub2api token when managed refresh token is near expiry', async () => { selectAllMock.mockReturnValue([ { diff --git a/src/server/services/balanceService.ts b/src/server/services/balanceService.ts index 62bcb609..5540a127 100644 --- a/src/server/services/balanceService.ts +++ b/src/server/services/balanceService.ts @@ -4,8 +4,10 @@ import { eq } from 'drizzle-orm'; import { appendSessionTokenRebindHint, isTokenExpiredError } from './alertRules.js'; import { reportTokenExpired } from './alertService.js'; import { + buildStoredSub2ApiSubscriptionSummary, getAutoReloginConfig, getCredentialModeFromExtraConfig, + getProxyUrlFromExtraConfig, getSub2ApiAuthFromExtraConfig, mergeAccountExtraConfig, resolvePlatformUserId, @@ -14,7 +16,7 @@ import { decryptAccountPassword } from './accountCredentialService.js'; import { extractRuntimeHealth, setAccountRuntimeHealth } from './accountHealthService.js'; import { updateTodayIncomeSnapshot } from './todayIncomeRewardService.js'; import type { BalanceInfo } from './platforms/base.js'; -import { resolveProxyUrlForSite, withExplicitProxyRequestInit, withSiteRecordProxyRequestInit } from './siteProxy.js'; +import { withAccountProxyOverride, withSiteProxyRequestInit, withSiteRecordProxyRequestInit } from './siteProxy.js'; function isSiteDisabled(status?: string | null): boolean { return (status || 'active') === 'disabled'; @@ -43,15 +45,7 @@ function shouldAttemptAutoRelogin(message?: string | null): boolean { function shouldReportExpired(message?: string | null): boolean { if (!message) return false; - if (isTokenExpiredError({ message })) return true; - - const text = message.toLowerCase(); - return ( - text.includes('access token') || - text.includes('new-api-user') || - text.includes('unauthorized') || - text.includes('forbidden') - ); + return isTokenExpiredError({ message }); } function isUnsupportedCheckinRuntimeHealth(health: ReturnType): boolean { @@ -207,7 +201,7 @@ async function refreshSub2ApiManagedSession(params: { method: 'POST', headers, body: JSON.stringify({ refresh_token: refreshToken }), - })); + }, getProxyUrlFromExtraConfig(params.account.extraConfig))); payload = await response.json().catch(() => null); } catch (err: any) { throw new Error(err?.message || 'sub2api token refresh request failed'); @@ -245,7 +239,6 @@ async function fetchTodayIncomeFromLogs(params: { accessToken: string; platform?: string | null; platformUserId?: number; - proxyUrl?: string | null; }): Promise { const baseUrl = params.baseUrl.trim(); const accessToken = params.accessToken.trim(); @@ -280,7 +273,8 @@ async function fetchTodayIncomeFromLogs(params: { }); try { - const response = await fetch(`${baseUrl}/api/log/self?${query.toString()}`, withExplicitProxyRequestInit(params.proxyUrl, { + const requestUrl = `${baseUrl}/api/log/self?${query.toString()}`; + const response = await fetch(requestUrl, await withSiteProxyRequestInit(requestUrl, { method: 'GET', headers, })); @@ -324,7 +318,10 @@ async function tryAutoRelogin(account: any, site: any): Promise { const password = decryptAccountPassword(relogin.passwordCipher); if (!password) return null; - const loginResult = await adapter.login(site.url, relogin.username, password); + const loginResult = await withAccountProxyOverride( + getProxyUrlFromExtraConfig(account.extraConfig), + () => adapter.login(site.url, relogin.username, password), + ); if (!loginResult.success || !loginResult.accessToken) return null; await db.update(schema.accounts) @@ -385,23 +382,25 @@ export async function refreshBalance(accountId: number) { let activeExtraConfig = account.extraConfig; let balanceInfo: BalanceInfo | null = null; + const accountProxyUrl = getProxyUrlFromExtraConfig(account.extraConfig); + if (isSub2ApiPlatform(site.platform)) { const managedAuth = getSub2ApiAuthFromExtraConfig(activeExtraConfig); if (managedAuth?.refreshToken && isNearTokenExpiry(managedAuth.tokenExpiresAt)) { try { - const refreshed = await refreshSub2ApiManagedSession({ + const refreshed = await withAccountProxyOverride(accountProxyUrl, () => refreshSub2ApiManagedSession({ account, site, currentAccessToken: activeAccessToken, currentExtraConfig: activeExtraConfig, - }); + })); activeAccessToken = refreshed.accessToken; activeExtraConfig = refreshed.extraConfig; } catch {} } } - - const readBalance = async (token: string) => adapter.getBalance(site.url, token, platformUserId); + const readBalance = async (token: string) => withAccountProxyOverride(accountProxyUrl, + () => adapter.getBalance(site.url, token, platformUserId)); const handleBalanceError = async (err: any) => { const message = appendSessionTokenRebindHint(err?.message || 'unknown error'); setAccountRuntimeHealth(account.id, { @@ -431,12 +430,12 @@ export async function refreshBalance(accountId: number) { if (canTryManagedSub2ApiRefresh) { try { - const refreshed = await refreshSub2ApiManagedSession({ + const refreshed = await withAccountProxyOverride(accountProxyUrl, () => refreshSub2ApiManagedSession({ account, site, currentAccessToken: activeAccessToken, currentExtraConfig: activeExtraConfig, - }); + })); activeAccessToken = refreshed.accessToken; activeExtraConfig = refreshed.extraConfig; balanceInfo = await readBalance(activeAccessToken); @@ -469,13 +468,12 @@ export async function refreshBalance(accountId: number) { supportsTodayIncomeLogFallback(site.platform) ) { try { - const fallbackIncome = await fetchTodayIncomeFromLogs({ + const fallbackIncome = await withAccountProxyOverride(accountProxyUrl, () => fetchTodayIncomeFromLogs({ baseUrl: site.url, accessToken: activeAccessToken, platform: site.platform, platformUserId, - proxyUrl: resolveProxyUrlForSite(site), - }); + })); if (typeof fallbackIncome === 'number' && Number.isFinite(fallbackIncome)) { balanceInfo.todayIncome = fallbackIncome; } @@ -486,6 +484,11 @@ export async function refreshBalance(accountId: number) { if (typeof balanceInfo.todayIncome === 'number' && Number.isFinite(balanceInfo.todayIncome)) { nextExtraConfig = updateTodayIncomeSnapshot(nextExtraConfig, balanceInfo.todayIncome); } + if (balanceInfo.subscriptionSummary && isSub2ApiPlatform(site.platform)) { + nextExtraConfig = mergeAccountExtraConfig(nextExtraConfig, { + sub2apiSubscription: buildStoredSub2ApiSubscriptionSummary(balanceInfo.subscriptionSummary), + }); + } const existingRuntimeHealth = extractRuntimeHealth(nextExtraConfig); const keepUnsupportedCheckinDegraded = isUnsupportedCheckinRuntimeHealth(existingRuntimeHealth); diff --git a/src/server/services/brandMatcher.ts b/src/server/services/brandMatcher.ts new file mode 100644 index 00000000..f4a93aca --- /dev/null +++ b/src/server/services/brandMatcher.ts @@ -0,0 +1,273 @@ +/** + * Server-side brand matching for global brand filtering. + * + * Context-building logic is ported from the frontend BrandIcon.tsx + * (collectBrandCandidates + buildMatchContext) to ensure identical + * matching behavior between route display and rebuild filtering. + */ + +type MatchMode = 'includes' | 'startsWith' | 'segment' | 'boundary'; +type KeywordRule = { keyword: string; mode: MatchMode }; + +const BRAND_RULES: Record = { + 'OpenAI': [ + { keyword: 'gpt', mode: 'startsWith' }, + { keyword: 'chatgpt', mode: 'startsWith' }, + { keyword: 'dall-e', mode: 'startsWith' }, + { keyword: 'whisper', mode: 'startsWith' }, + { keyword: 'text-embedding', mode: 'startsWith' }, + { keyword: 'text-moderation', mode: 'startsWith' }, + { keyword: 'davinci', mode: 'startsWith' }, + { keyword: 'babbage', mode: 'startsWith' }, + { keyword: 'codex-mini', mode: 'startsWith' }, + { keyword: 'o1', mode: 'startsWith' }, + { keyword: 'o3', mode: 'startsWith' }, + { keyword: 'o4', mode: 'startsWith' }, + { keyword: 'tts', mode: 'startsWith' }, + ], + 'Anthropic': [ + { keyword: 'claude', mode: 'includes' }, + ], + 'Google': [ + { keyword: 'gemini', mode: 'includes' }, + { keyword: 'gemma', mode: 'includes' }, + { keyword: 'google/', mode: 'includes' }, + { keyword: 'palm', mode: 'includes' }, + { keyword: 'paligemma', mode: 'includes' }, + { keyword: 'shieldgemma', mode: 'includes' }, + { keyword: 'recurrentgemma', mode: 'includes' }, + { keyword: 'deplot', mode: 'includes' }, + { keyword: 'codegemma', mode: 'includes' }, + { keyword: 'imagen', mode: 'includes' }, + { keyword: 'learnlm', mode: 'includes' }, + { keyword: 'aqa', mode: 'includes' }, + { keyword: 'veo', mode: 'startsWith' }, + ], + 'DeepSeek': [ + { keyword: 'deepseek', mode: 'includes' }, + { keyword: 'ds-chat', mode: 'segment' }, + ], + '通义千问': [ + { keyword: 'qwen', mode: 'includes' }, + { keyword: 'qwq', mode: 'includes' }, + { keyword: 'tongyi', mode: 'includes' }, + ], + '智谱 AI': [ + { keyword: 'glm', mode: 'includes' }, + { keyword: 'chatglm', mode: 'includes' }, + { keyword: 'codegeex', mode: 'includes' }, + { keyword: 'cogview', mode: 'includes' }, + { keyword: 'cogvideo', mode: 'includes' }, + ], + 'Meta': [ + { keyword: 'llama', mode: 'includes' }, + { keyword: 'code-llama', mode: 'includes' }, + { keyword: 'codellama', mode: 'includes' }, + ], + 'Mistral': [ + { keyword: 'mistral', mode: 'includes' }, + { keyword: 'mixtral', mode: 'includes' }, + { keyword: 'codestral', mode: 'includes' }, + { keyword: 'pixtral', mode: 'includes' }, + { keyword: 'ministral', mode: 'includes' }, + { keyword: 'voxtral', mode: 'includes' }, + { keyword: 'magistral', mode: 'includes' }, + ], + 'Moonshot': [ + { keyword: 'moonshot', mode: 'includes' }, + { keyword: 'kimi', mode: 'includes' }, + ], + '零一万物': [ + { keyword: 'yi-', mode: 'startsWith' }, + { keyword: 'yi', mode: 'boundary' }, + ], + '文心一言': [ + { keyword: 'ernie', mode: 'includes' }, + { keyword: 'eb-', mode: 'includes' }, + ], + '讯飞星火': [ + { keyword: 'spark', mode: 'includes' }, + { keyword: 'generalv', mode: 'includes' }, + ], + '腾讯混元': [ + { keyword: 'hunyuan', mode: 'includes' }, + { keyword: 'tencent-hunyuan', mode: 'includes' }, + ], + '豆包': [ + { keyword: 'doubao', mode: 'includes' }, + ], + 'MiniMax': [ + { keyword: 'minimax', mode: 'includes' }, + { keyword: 'abab', mode: 'includes' }, + { keyword: 'mini2.1', mode: 'segment' }, + ], + 'Cohere': [ + { keyword: 'command', mode: 'includes' }, + { keyword: 'c4ai-', mode: 'includes' }, + { keyword: 'embed-', mode: 'startsWith' }, + ], + 'Microsoft': [ + { keyword: 'microsoft/', mode: 'includes' }, + { keyword: 'phi-', mode: 'includes' }, + { keyword: 'kosmos', mode: 'includes' }, + { keyword: 'phi4', mode: 'segment' }, + ], + 'xAI': [ + { keyword: 'grok', mode: 'includes' }, + ], + '阶跃星辰': [ + { keyword: 'stepfun', mode: 'includes' }, + { keyword: 'step-', mode: 'startsWith' }, + { keyword: 'step3', mode: 'startsWith' }, + ], + 'Stability': [ + { keyword: 'flux', mode: 'includes' }, + { keyword: 'stablediffusion', mode: 'includes' }, + { keyword: 'stable-diffusion', mode: 'includes' }, + { keyword: 'sdxl', mode: 'includes' }, + { keyword: 'sd3', mode: 'startsWith' }, + ], + 'NVIDIA': [ + { keyword: 'nvidia/', mode: 'includes' }, + { keyword: 'nvclip', mode: 'includes' }, + { keyword: 'nemotron', mode: 'includes' }, + { keyword: 'nemoretriever', mode: 'includes' }, + { keyword: 'neva', mode: 'includes' }, + { keyword: 'riva-translate', mode: 'includes' }, + { keyword: 'cosmos', mode: 'includes' }, + { keyword: 'nv-', mode: 'startsWith' }, + ], + 'IBM': [ + { keyword: 'ibm/', mode: 'includes' }, + { keyword: 'granite', mode: 'includes' }, + ], + 'BAAI': [ + { keyword: 'baai/', mode: 'includes' }, + { keyword: 'bge-', mode: 'includes' }, + ], + 'ByteDance': [ + { keyword: 'bytedance', mode: 'includes' }, + { keyword: 'seed-oss', mode: 'includes' }, + { keyword: 'kolors', mode: 'includes' }, + { keyword: 'kwai', mode: 'includes' }, + { keyword: 'kwaipilot', mode: 'includes' }, + { keyword: 'wan-', mode: 'startsWith' }, + { keyword: 'kat-', mode: 'startsWith' }, + ], + 'InternLM': [ + { keyword: 'internlm', mode: 'includes' }, + ], + 'Midjourney': [ + { keyword: 'midjourney', mode: 'includes' }, + { keyword: 'mj_', mode: 'startsWith' }, + ], + 'DeepL': [ + { keyword: 'deepl/', mode: 'includes' }, + { keyword: 'deepl-', mode: 'startsWith' }, + ], + 'Jina AI': [ + { keyword: 'jina', mode: 'includes' }, + ], +}; + +// ── Context building (ported from frontend BrandIcon.tsx) ── + +function stripCommonWrappers(value: string): string { + return value + .replace(/^(?:\[[^\]]+\]|【[^】]+】)\s*/g, '') + .replace(/^re:\s*/g, '') + .replace(/^\^+/, '') + .replace(/\$+$/, '') + .trim(); +} + +/** + * Recursively split model name on `/`, `:`, `,` to build all candidate strings. + * Mirrors the frontend collectBrandCandidates exactly. + */ +function collectCandidates(modelName: string): string[] { + const queue: string[] = []; + const seen = new Set(); + const push = (value: string) => { + const normalized = value.trim().toLowerCase(); + if (!normalized || seen.has(normalized)) return; + seen.add(normalized); + queue.push(normalized); + }; + + push(modelName); + + for (let i = 0; i < queue.length; i++) { + const candidate = queue[i]!; + const cleaned = stripCommonWrappers(candidate); + push(cleaned); + if (cleaned.includes('/')) for (const part of cleaned.split('/')) push(part); + if (cleaned.includes(':')) for (const part of cleaned.split(':')) push(part); + if (cleaned.includes(',')) for (const part of cleaned.split(',')) push(part); + } + + return queue; +} + +type MatchContext = { + raw: string; // first candidate (normalized full name) + candidates: string[]; // all candidates from recursive splitting + segments: string[]; // candidates split on /,:,\s (NOT on - or _) +}; + +function buildMatchContext(modelName: string): MatchContext { + const candidates = collectCandidates(modelName); + const raw = candidates[0] || modelName.trim().toLowerCase(); + // Segments split on /,:,whitespace only — NOT on - or _ (matches frontend) + const segments = Array.from(new Set( + candidates + .flatMap((c) => c.split(/[/:,\s]+/g)) + .map((s) => s.trim()) + .filter(Boolean), + )); + return { raw, candidates, segments }; +} + +// ── Matching functions (mirror frontend includesAny / startsWithAny / hasExactSegment / matchesBoundary) ── + +function matchesRule(ctx: MatchContext, rule: KeywordRule): boolean { + switch (rule.mode) { + case 'includes': + return ctx.raw.includes(rule.keyword) + || ctx.candidates.some((c) => c.includes(rule.keyword)); + case 'startsWith': + return ctx.raw.startsWith(rule.keyword) + || ctx.segments.some((s) => s.startsWith(rule.keyword)) + || ctx.candidates.some((c) => c.startsWith(rule.keyword)); + case 'segment': + return ctx.segments.includes(rule.keyword); + case 'boundary': { + const pattern = new RegExp(`(^|[/:_\\-\\s])${rule.keyword}(?=$|[/:_\\-\\s])`); + return pattern.test(ctx.raw) || ctx.candidates.some((c) => pattern.test(c)); + } + default: + return false; + } +} + +// ── Public API ── + +export function getAllBrandNames(): string[] { + return Object.keys(BRAND_RULES); +} + +export function getBlockedBrandRules(blockedBrands: string[]): KeywordRule[] { + const rules: KeywordRule[] = []; + for (const brand of blockedBrands) { + const brandRules = BRAND_RULES[brand]; + if (brandRules) rules.push(...brandRules); + } + return rules; +} + +export function isModelBlockedByBrand(modelName: string, rules: KeywordRule[]): boolean { + if (!modelName || rules.length === 0) return false; + const ctx = buildMatchContext(modelName); + if (!ctx.raw) return false; + return rules.some((rule) => matchesRule(ctx, rule)); +} diff --git a/src/server/services/checkinScheduler.test.ts b/src/server/services/checkinScheduler.test.ts new file mode 100644 index 00000000..cce3ccbb --- /dev/null +++ b/src/server/services/checkinScheduler.test.ts @@ -0,0 +1,95 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const cronStopMock = vi.fn(); +const scheduleMock = vi.fn(() => ({ + stop: cronStopMock, +})); +const validateMock = vi.fn(() => true); +const allMock = vi.fn(); + +vi.mock('node-cron', () => ({ + default: { + schedule: (...args: unknown[]) => scheduleMock(...args), + validate: (...args: unknown[]) => validateMock(...args), + }, +})); + +vi.mock('../db/index.js', () => { + const queryChain = { + where: () => queryChain, + get: () => undefined, + all: () => [], + from: () => queryChain, + innerJoin: () => queryChain, + }; + + return { + db: { + select: () => queryChain, + }, + schema: { + settings: { key: 'key' }, + accounts: { checkinEnabled: 'checkinEnabled', status: 'status' }, + sites: { id: 'id' }, + }, + }; +}); + +vi.mock('./checkinService.js', () => ({ + checkinAll: (...args: unknown[]) => allMock(...args), +})); + +describe('checkinScheduler', () => { + beforeEach(() => { + vi.useFakeTimers(); + cronStopMock.mockReset(); + scheduleMock.mockClear(); + validateMock.mockClear(); + allMock.mockReset(); + }); + + afterEach(async () => { + const scheduler = await import('./checkinScheduler.js'); + scheduler.__resetCheckinSchedulerForTests(); + vi.useRealTimers(); + }); + + it('switches from cron mode to interval mode and back', async () => { + const setIntervalSpy = vi.spyOn(globalThis, 'setInterval'); + const clearIntervalSpy = vi.spyOn(globalThis, 'clearInterval'); + const scheduler = await import('./checkinScheduler.js'); + + scheduler.updateCheckinSchedule({ + mode: 'cron', + cronExpr: '0 8 * * *', + intervalHours: 6, + }); + expect(scheduleMock).toHaveBeenCalledTimes(1); + + scheduler.updateCheckinSchedule({ + mode: 'interval', + intervalHours: 6, + }); + expect(cronStopMock).toHaveBeenCalledTimes(1); + expect(setIntervalSpy).toHaveBeenCalledTimes(1); + + scheduler.updateCheckinSchedule({ + mode: 'cron', + cronExpr: '5 9 * * *', + intervalHours: 6, + }); + expect(clearIntervalSpy).toHaveBeenCalledTimes(1); + expect(scheduleMock).toHaveBeenCalledTimes(2); + }); + + it('selects due accounts from the last successful checkin time', async () => { + const scheduler = await import('./checkinScheduler.js'); + const now = new Date('2026-03-20T12:00:00.000Z'); + + expect(scheduler.selectDueIntervalCheckinAccountIds([ + { id: 1, lastCheckinAt: null }, + { id: 2, lastCheckinAt: '2026-03-20T05:59:59.000Z' }, + { id: 3, lastCheckinAt: '2026-03-20T06:30:00.000Z' }, + ], 6, now)).toEqual([1, 2]); + }); +}); diff --git a/src/server/services/checkinScheduler.ts b/src/server/services/checkinScheduler.ts index 7c8e98fe..09e9e2d6 100644 --- a/src/server/services/checkinScheduler.ts +++ b/src/server/services/checkinScheduler.ts @@ -4,22 +4,34 @@ import { config } from '../config.js'; import { db, schema } from '../db/index.js'; import { refreshAllBalances } from './balanceService.js'; import { checkinAll } from './checkinService.js'; -import { refreshModelsAndRebuildRoutes } from './modelService.js'; +import * as routeRefreshWorkflow from './routeRefreshWorkflow.js'; import { sendNotification } from './notifyService.js'; import { buildDailySummaryNotification, collectDailySummaryMetrics } from './dailySummaryService.js'; +import { cleanupConfiguredLogs, normalizeLogCleanupRetentionDays } from './logCleanupService.js'; + +export type CheckinScheduleMode = 'cron' | 'interval'; let checkinTask: cron.ScheduledTask | null = null; +let checkinIntervalTimer: ReturnType | null = null; let balanceTask: cron.ScheduledTask | null = null; let dailySummaryTask: cron.ScheduledTask | null = null; +let logCleanupTask: cron.ScheduledTask | null = null; +const intervalAttemptByAccount = new Map(); const DAILY_SUMMARY_DEFAULT_CRON = '58 23 * * *'; +const LOG_CLEANUP_DEFAULT_CRON = '0 6 * * *'; +const CHECKIN_INTERVAL_POLL_MS = 60_000; -async function resolveCronSetting(settingKey: string, fallback: string): Promise { +async function resolveJsonSetting( + settingKey: string, + isValid: (value: unknown) => value is T, + fallback: T, +): Promise { try { const row = await db.select().from(schema.settings).where(eq(schema.settings.key, settingKey)).get(); if (row?.value) { const parsed = JSON.parse(row.value); - if (typeof parsed === 'string' && cron.validate(parsed)) { + if (isValid(parsed)) { return parsed; } } @@ -27,11 +39,27 @@ async function resolveCronSetting(settingKey: string, fallback: string): Promise return fallback; } +async function resolveCronSetting(settingKey: string, fallback: string): Promise { + return resolveJsonSetting(settingKey, (value): value is string => typeof value === 'string' && cron.validate(value), fallback); +} + +async function resolveBooleanSetting(settingKey: string, fallback: boolean): Promise { + return resolveJsonSetting(settingKey, (value): value is boolean => typeof value === 'boolean', fallback); +} + +async function resolvePositiveIntegerSetting(settingKey: string, fallback: number): Promise { + return resolveJsonSetting( + settingKey, + (value): value is number => typeof value === 'number' && Number.isFinite(value) && value >= 1, + fallback, + ); +} + function createCheckinTask(cronExpr: string) { return cron.schedule(cronExpr, async () => { console.log(`[Scheduler] Running check-in at ${new Date().toISOString()}`); try { - const results = await checkinAll(); + const results = await checkinAll({ scheduleMode: 'cron' }); const success = results.filter((r) => r.result.success).length; const failed = results.length - success; console.log(`[Scheduler] Check-in complete: ${success} success, ${failed} failed`); @@ -41,12 +69,100 @@ function createCheckinTask(cronExpr: string) { }); } +type IntervalCheckinCandidate = { + id: number; + lastCheckinAt?: string | null; +}; + +export function selectDueIntervalCheckinAccountIds( + rows: IntervalCheckinCandidate[], + intervalHours: number, + now = new Date(), + attemptState = intervalAttemptByAccount, +) { + const nowMs = now.getTime(); + const intervalMs = Math.max(1, intervalHours) * 60 * 60 * 1000; + + return rows + .filter((row) => { + const lastCheckinMs = row.lastCheckinAt ? Date.parse(row.lastCheckinAt) : Number.NaN; + const lastAttemptMs = attemptState.get(row.id); + if (Number.isFinite(lastCheckinMs)) { + if (nowMs - lastCheckinMs < intervalMs) return false; + if (typeof lastAttemptMs === 'number' && lastAttemptMs >= lastCheckinMs && nowMs - lastAttemptMs < intervalMs) { + return false; + } + return true; + } + if (typeof lastAttemptMs === 'number' && nowMs - lastAttemptMs < intervalMs) return false; + return true; + }) + .map((row) => row.id); +} + +async function runIntervalCheckinPass(now = new Date()) { + const rows = await db + .select() + .from(schema.accounts) + .innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id)) + .all(); + + const dueAccountIds = selectDueIntervalCheckinAccountIds( + rows + .filter((row: any) => row.accounts?.checkinEnabled === true && row.accounts?.status === 'active' && row.sites?.status !== 'disabled') + .map((row: any) => ({ + id: row.accounts.id, + lastCheckinAt: row.accounts.lastCheckinAt, + })), + config.checkinIntervalHours, + now, + ); + + if (dueAccountIds.length === 0) return; + + try { + const results = await checkinAll({ + accountIds: dueAccountIds, + scheduleMode: 'interval', + }); + const nowMs = now.getTime(); + for (const item of results) { + intervalAttemptByAccount.set(item.accountId, nowMs); + } + const success = results.filter((r) => r.result.success).length; + const failed = results.length - success; + console.log(`[Scheduler] Interval check-in complete: ${success} success, ${failed} failed`); + } catch (err) { + console.error('[Scheduler] Interval check-in error:', err); + } +} + +function stopCheckinSchedule() { + checkinTask?.stop(); + checkinTask = null; + if (checkinIntervalTimer) { + clearInterval(checkinIntervalTimer); + checkinIntervalTimer = null; + } +} + +function startCheckinSchedule() { + stopCheckinSchedule(); + if (config.checkinScheduleMode === 'interval') { + checkinIntervalTimer = setInterval(() => { + void runIntervalCheckinPass(); + }, CHECKIN_INTERVAL_POLL_MS); + return; + } + checkinTask = createCheckinTask(config.checkinCron); +} + function createBalanceTask(cronExpr: string) { return cron.schedule(cronExpr, async () => { console.log(`[Scheduler] Refreshing balances at ${new Date().toISOString()}`); try { await refreshAllBalances(); - await refreshModelsAndRebuildRoutes(); + await routeRefreshWorkflow.refreshModelsAndRebuildRoutes(); console.log('[Scheduler] Balance refresh complete'); } catch (err) { console.error('[Scheduler] Balance refresh error:', err); @@ -72,27 +188,110 @@ function createDailySummaryTask(cronExpr: string) { }); } +function createLogCleanupTask(cronExpr: string) { + return cron.schedule(cronExpr, async () => { + if (!config.logCleanupConfigured) { + console.log('[Scheduler] Log cleanup skipped: legacy fallback mode is active'); + return; + } + console.log(`[Scheduler] Running log cleanup at ${new Date().toISOString()}`); + try { + const result = await cleanupConfiguredLogs(); + if (!result.enabled) { + console.log('[Scheduler] Log cleanup skipped: no log target enabled'); + return; + } + console.log( + `[Scheduler] Log cleanup complete: usage=${result.usageLogsDeleted}, program=${result.programLogsDeleted}, cutoff=${result.cutoffUtc}`, + ); + } catch (err) { + console.error('[Scheduler] Log cleanup error:', err); + } + }); +} + export async function startScheduler() { const activeCheckinCron = await resolveCronSetting('checkin_cron', config.checkinCron); + const activeCheckinScheduleMode = await resolveJsonSetting( + 'checkin_schedule_mode', + (value): value is CheckinScheduleMode => value === 'cron' || value === 'interval', + config.checkinScheduleMode as CheckinScheduleMode, + ); + const activeCheckinIntervalHours = await resolvePositiveIntegerSetting( + 'checkin_interval_hours', + config.checkinIntervalHours, + ); const activeBalanceCron = await resolveCronSetting('balance_refresh_cron', config.balanceRefreshCron); const activeDailySummaryCron = await resolveCronSetting('daily_summary_cron', DAILY_SUMMARY_DEFAULT_CRON); + const activeLogCleanupCron = await resolveCronSetting('log_cleanup_cron', config.logCleanupCron || LOG_CLEANUP_DEFAULT_CRON); + const activeLogCleanupUsageLogsEnabled = await resolveBooleanSetting( + 'log_cleanup_usage_logs_enabled', + config.logCleanupUsageLogsEnabled, + ); + const activeLogCleanupProgramLogsEnabled = await resolveBooleanSetting( + 'log_cleanup_program_logs_enabled', + config.logCleanupProgramLogsEnabled, + ); + const activeLogCleanupRetentionDays = await resolvePositiveIntegerSetting( + 'log_cleanup_retention_days', + normalizeLogCleanupRetentionDays(config.logCleanupRetentionDays), + ); config.checkinCron = activeCheckinCron; + config.checkinScheduleMode = activeCheckinScheduleMode; + config.checkinIntervalHours = Math.min(24, Math.max(1, activeCheckinIntervalHours)); config.balanceRefreshCron = activeBalanceCron; + config.logCleanupCron = activeLogCleanupCron; + config.logCleanupUsageLogsEnabled = activeLogCleanupUsageLogsEnabled; + config.logCleanupProgramLogsEnabled = activeLogCleanupProgramLogsEnabled; + config.logCleanupRetentionDays = activeLogCleanupRetentionDays; - checkinTask = createCheckinTask(activeCheckinCron); + stopCheckinSchedule(); + balanceTask?.stop(); + dailySummaryTask?.stop(); + logCleanupTask?.stop(); + startCheckinSchedule(); balanceTask = createBalanceTask(activeBalanceCron); dailySummaryTask = createDailySummaryTask(activeDailySummaryCron); + logCleanupTask = createLogCleanupTask(activeLogCleanupCron); - console.log(`[Scheduler] Check-in cron: ${activeCheckinCron}`); + console.log(`[Scheduler] Check-in schedule: ${config.checkinScheduleMode} (${config.checkinScheduleMode === 'cron' ? activeCheckinCron : `${config.checkinIntervalHours}h`})`); console.log(`[Scheduler] Balance refresh cron: ${activeBalanceCron}`); console.log(`[Scheduler] Daily summary cron: ${activeDailySummaryCron}`); + console.log( + `[Scheduler] Log cleanup cron: ${activeLogCleanupCron} (configured=${config.logCleanupConfigured}, usage=${activeLogCleanupUsageLogsEnabled}, program=${activeLogCleanupProgramLogsEnabled}, retentionDays=${activeLogCleanupRetentionDays})`, + ); } export function updateCheckinCron(cronExpr: string) { - if (!cron.validate(cronExpr)) throw new Error(`Invalid cron: ${cronExpr}`); - config.checkinCron = cronExpr; - checkinTask?.stop(); - checkinTask = createCheckinTask(cronExpr); + updateCheckinSchedule({ + mode: 'cron', + cronExpr, + intervalHours: config.checkinIntervalHours, + }); +} + +export function updateCheckinSchedule(input: { + mode: CheckinScheduleMode; + cronExpr?: string; + intervalHours?: number; +}) { + const nextMode = input.mode; + if (nextMode !== 'cron' && nextMode !== 'interval') { + throw new Error(`Invalid checkin schedule mode: ${String(nextMode)}`); + } + + const nextCronExpr = input.cronExpr ?? config.checkinCron; + if (!cron.validate(nextCronExpr)) throw new Error(`Invalid cron: ${nextCronExpr}`); + + const nextIntervalHours = input.intervalHours ?? config.checkinIntervalHours; + if (!Number.isFinite(nextIntervalHours) || nextIntervalHours < 1 || nextIntervalHours > 24) { + throw new Error(`Invalid interval hours: ${String(nextIntervalHours)}`); + } + + config.checkinScheduleMode = nextMode; + config.checkinCron = nextCronExpr; + config.checkinIntervalHours = Math.trunc(nextIntervalHours); + startCheckinSchedule(); } export function updateBalanceRefreshCron(cronExpr: string) { @@ -101,3 +300,34 @@ export function updateBalanceRefreshCron(cronExpr: string) { balanceTask?.stop(); balanceTask = createBalanceTask(cronExpr); } + +export function updateLogCleanupSettings(input: { + cronExpr?: string; + usageLogsEnabled?: boolean; + programLogsEnabled?: boolean; + retentionDays?: number; +}) { + const cronExpr = input.cronExpr ?? config.logCleanupCron; + if (!cron.validate(cronExpr)) throw new Error(`Invalid cron: ${cronExpr}`); + + const retentionDays = normalizeLogCleanupRetentionDays(input.retentionDays ?? config.logCleanupRetentionDays); + + config.logCleanupCron = cronExpr; + if (input.usageLogsEnabled !== undefined) config.logCleanupUsageLogsEnabled = !!input.usageLogsEnabled; + if (input.programLogsEnabled !== undefined) config.logCleanupProgramLogsEnabled = !!input.programLogsEnabled; + config.logCleanupRetentionDays = retentionDays; + + logCleanupTask?.stop(); + logCleanupTask = createLogCleanupTask(cronExpr); +} + +export function __resetCheckinSchedulerForTests() { + stopCheckinSchedule(); + balanceTask?.stop(); + dailySummaryTask?.stop(); + logCleanupTask?.stop(); + balanceTask = null; + dailySummaryTask = null; + logCleanupTask = null; + intervalAttemptByAccount.clear(); +} diff --git a/src/server/services/checkinService.autoRelogin.test.ts b/src/server/services/checkinService.autoRelogin.test.ts index 77bacff4..913c6e33 100644 --- a/src/server/services/checkinService.autoRelogin.test.ts +++ b/src/server/services/checkinService.autoRelogin.test.ts @@ -248,6 +248,63 @@ describe('checkinService auto relogin', () => { expect(notifyMock).not.toHaveBeenCalled(); }); + it('does not advance lastCheckinAt for already checked in responses in interval mode', async () => { + selectAllMock.mockReturnValue([ + { + accounts: { + id: 16, + username: 'interval-user', + accessToken: 'token', + status: 'active', + extraConfig: null, + }, + sites: { + id: 16, + name: 'demo', + url: 'https://example.com', + platform: 'new-api', + }, + }, + ]); + + adapterMock.checkin.mockResolvedValue({ success: false, message: '今天已经签到过啦' }); + + const { checkinAccount } = await import('./checkinService.js'); + const result = await checkinAccount(16, { scheduleMode: 'interval' }); + + expect(result.success).toBe(true); + expect(result.status).toBe('success'); + expect(updateSetMock).not.toHaveBeenCalledWith(expect.objectContaining({ lastCheckinAt: expect.any(String) })); + }); + + it('advances lastCheckinAt when interval mode gets a direct success', async () => { + selectAllMock.mockReturnValue([ + { + accounts: { + id: 17, + username: 'interval-success', + accessToken: 'token', + status: 'active', + extraConfig: null, + }, + sites: { + id: 17, + name: 'demo', + url: 'https://example.com', + platform: 'new-api', + }, + }, + ]); + + adapterMock.checkin.mockResolvedValue({ success: true, message: '签到成功' }); + + const { checkinAccount } = await import('./checkinService.js'); + const result = await checkinAccount(17, { scheduleMode: 'interval' }); + + expect(result.success).toBe(true); + expect(updateSetMock).toHaveBeenCalledWith(expect.objectContaining({ lastCheckinAt: expect.any(String) })); + }); + it('treats unsupported checkin endpoint responses as skipped', async () => { selectAllMock.mockReturnValue([ { @@ -283,6 +340,40 @@ describe('checkinService auto relogin', () => { expect(notifyMock).not.toHaveBeenCalled(); }); + it('skips account updates when unsupported checkin responses do not change account state', async () => { + selectAllMock.mockReturnValue([ + { + accounts: { + id: 18, + username: 'plain-user', + accessToken: 'token', + status: 'active', + extraConfig: null, + }, + sites: { + id: 18, + name: 'done-hub', + url: 'https://done.example.com', + platform: 'donehub', + }, + }, + ]); + + adapterMock.checkin.mockResolvedValue({ + success: false, + message: 'checkin endpoint not found', + }); + + const { checkinAccount } = await import('./checkinService.js'); + const result = await checkinAccount(18); + + expect(result.success).toBe(true); + expect(result.status).toBe('skipped'); + expect(updateSetMock).not.toHaveBeenCalled(); + const firstInsertPayload = insertValuesMock.mock.calls[0]?.[0] as Record; + expect(firstInsertPayload?.status).toBe('skipped'); + }); + it('treats sub2api checkin unsupported message as skipped', async () => { selectAllMock.mockReturnValue([ { diff --git a/src/server/services/checkinService.ts b/src/server/services/checkinService.ts index fc5c77ca..46fd1cd2 100644 --- a/src/server/services/checkinService.ts +++ b/src/server/services/checkinService.ts @@ -8,6 +8,7 @@ import { refreshBalance } from './balanceService.js'; import { parseCheckinRewardAmount } from './checkinRewardParser.js'; import { getAutoReloginConfig, + getProxyUrlFromExtraConfig, getPlatformUserIdFromExtraConfig, guessPlatformUserIdFromUsername, mergeAccountExtraConfig, @@ -16,6 +17,7 @@ import { import { decryptAccountPassword } from './accountCredentialService.js'; import { setAccountRuntimeHealth } from './accountHealthService.js'; import { formatUtcSqlDateTime } from './localTimeService.js'; +import { withAccountProxyOverride } from './siteProxy.js'; type CheckinExecutionStatus = 'success' | 'failed' | 'skipped'; @@ -100,7 +102,10 @@ async function tryAutoRelogin(account: any, site: any): Promise { const password = decryptAccountPassword(relogin.passwordCipher); if (!password) return null; - const result = await adapter.login(site.url, relogin.username, password); + const result = await withAccountProxyOverride( + getProxyUrlFromExtraConfig(account.extraConfig), + () => adapter.login(site.url, relogin.username, password), + ); if (!result.success || !result.accessToken) return null; await db.update(schema.accounts) @@ -115,7 +120,7 @@ async function tryAutoRelogin(account: any, site: any): Promise { return result.accessToken; } -export async function checkinAccount(accountId: number, options?: { skipEvent?: boolean }) { +export async function checkinAccount(accountId: number, options?: { skipEvent?: boolean; scheduleMode?: 'cron' | 'interval' }) { const rows = await db .select() .from(schema.accounts) @@ -172,14 +177,17 @@ export async function checkinAccount(accountId: number, options?: { skipEvent?: : guessPlatformUserIdFromUsername(account.username); const platformUserId = resolvePlatformUserId(account.extraConfig, account.username); + const accountProxyUrl = getProxyUrlFromExtraConfig(account.extraConfig); let activeAccessToken = account.accessToken; - let result = await adapter.checkin(site.url, activeAccessToken, platformUserId); + let result = await withAccountProxyOverride(accountProxyUrl, + () => adapter.checkin(site.url, activeAccessToken, platformUserId)); if (!result.success && shouldAttemptAutoRelogin(result.message)) { const refreshedAccessToken = await tryAutoRelogin(account, site); if (refreshedAccessToken) { activeAccessToken = refreshedAccessToken; - result = await adapter.checkin(site.url, activeAccessToken, platformUserId); + result = await withAccountProxyOverride(accountProxyUrl, + () => adapter.checkin(site.url, activeAccessToken, platformUserId)); } } @@ -192,6 +200,7 @@ export async function checkinAccount(accountId: number, options?: { skipEvent?: const effectiveSuccess = result.success || alreadyCheckedIn || unsupportedCheckin || manualVerificationRequired; const shouldRefreshBalance = result.success || alreadyCheckedIn; const directCheckinSuccess = result.success && !alreadyCheckedIn && !unsupportedCheckin; + const shouldAdvanceLastCheckinAt = directCheckinSuccess || (alreadyCheckedIn && options?.scheduleMode !== 'interval'); const normalizedStatus: CheckinExecutionStatus = effectiveSuccess ? ((unsupportedCheckin || manualVerificationRequired) ? 'skipped' : 'success') : 'failed'; @@ -211,9 +220,10 @@ export async function checkinAccount(accountId: number, options?: { skipEvent?: source: 'checkin', }); - const updates: Record = { - lastCheckinAt: new Date().toISOString(), - }; + const updates: Record = {}; + if (shouldAdvanceLastCheckinAt) { + updates.lastCheckinAt = new Date().toISOString(); + } if (!storedPlatformUserId && guessedPlatformUserId) { updates.extraConfig = mergeAccountExtraConfig(account.extraConfig, { platformUserId: guessedPlatformUserId, @@ -224,10 +234,12 @@ export async function checkinAccount(accountId: number, options?: { skipEvent?: updates.updatedAt = new Date().toISOString(); } - await db.update(schema.accounts) - .set(updates) - .where(eq(schema.accounts.id, accountId)) - .run(); + if (Object.keys(updates).length > 0) { + await db.update(schema.accounts) + .set(updates) + .where(eq(schema.accounts.id, accountId)) + .run(); + } if (shouldRefreshBalance) { try { @@ -308,7 +320,7 @@ export async function checkinAccount(accountId: number, options?: { skipEvent?: }; } -export async function checkinAll() { +export async function checkinAll(options?: { accountIds?: number[]; scheduleMode?: 'cron' | 'interval' }) { const rows = await db .select() .from(schema.accounts) @@ -321,10 +333,12 @@ export async function checkinAll() { ) .all(); + const scopedAccountIds = options?.accountIds ? new Set(options.accountIds) : null; const results: Array<{ accountId: number; username: string | null; site: string; result: any }> = []; const grouped = new Map(); for (const row of rows) { + if (scopedAccountIds && !scopedAccountIds.has(row.accounts.id)) continue; const siteId = row.sites.id; if (!grouped.has(siteId)) grouped.set(siteId, []); grouped.get(siteId)!.push(row); @@ -332,7 +346,10 @@ export async function checkinAll() { const promises = Array.from(grouped.entries()).map(async ([_, siteRows]) => { for (const row of siteRows) { - const r = await checkinAccount(row.accounts.id, { skipEvent: true }); + const r = await checkinAccount(row.accounts.id, { + skipEvent: true, + scheduleMode: options?.scheduleMode, + }); results.push({ accountId: row.accounts.id, username: row.accounts.username, diff --git a/src/server/services/databaseMigrationService.test.ts b/src/server/services/databaseMigrationService.test.ts index e7afb0b5..f9b302ed 100644 --- a/src/server/services/databaseMigrationService.test.ts +++ b/src/server/services/databaseMigrationService.test.ts @@ -1,10 +1,51 @@ -import { describe, expect, it } from 'vitest'; +import currentContract from '../db/generated/schemaContract.json' with { type: 'json' }; +import { describe, expect, it, vi } from 'vitest'; import { __databaseMigrationServiceTestUtils, maskConnectionString, normalizeMigrationInput, } from './databaseMigrationService.js'; +function cloneContract(value: T): T { + return JSON.parse(JSON.stringify(value)) as T; +} + +function createDbSchemaMock() { + return { + settings: { __table: 'settings' }, + sites: { __table: 'sites' }, + siteAnnouncements: { __table: 'siteAnnouncements' }, + siteDisabledModels: { __table: 'siteDisabledModels' }, + accounts: { __table: 'accounts' }, + accountTokens: { __table: 'accountTokens' }, + checkinLogs: { __table: 'checkinLogs' }, + modelAvailability: { __table: 'modelAvailability' }, + tokenModelAvailability: { __table: 'tokenModelAvailability' }, + tokenRoutes: { __table: 'tokenRoutes' }, + routeChannels: { __table: 'routeChannels' }, + routeGroupSources: { __table: 'routeGroupSources' }, + proxyLogs: { __table: 'proxyLogs' }, + proxyVideoTasks: { __table: 'proxyVideoTasks' }, + proxyFiles: { __table: 'proxyFiles' }, + downstreamApiKeys: { __table: 'downstreamApiKeys' }, + events: { __table: 'events' }, + }; +} + +function createDbMock(rowsByTable: Record) { + return { + select() { + return { + from(table: { __table: string }) { + return { + all: async () => rowsByTable[table.__table] ?? [], + }; + }, + }; + }, + }; +} + describe('databaseMigrationService', () => { it('accepts postgres migration input with normalized url', () => { const normalized = normalizeMigrationInput({ @@ -101,10 +142,16 @@ describe('databaseMigrationService', () => { expect(normalized.ssl).toBe(false); }); - it.each(['postgres', 'mysql', 'sqlite'] as const)('creates or patches sites schema with use_system_proxy for %s', async (dialect) => { + it.each(['postgres', 'mysql', 'sqlite'] as const)('creates or patches sites schema with use_system_proxy and custom_headers for %s', async (dialect) => { const executedSql: string[] = []; + const liveContract = cloneContract(currentContract); + delete liveContract.tables.sites.columns.use_system_proxy; + delete liveContract.tables.sites.columns.custom_headers; + await __databaseMigrationServiceTestUtils.ensureSchema({ dialect, + connectionString: dialect === 'sqlite' ? ':memory:' : `${dialect}://example.invalid/metapi`, + ssl: false, begin: async () => {}, commit: async () => {}, rollback: async () => {}, @@ -112,28 +159,29 @@ describe('databaseMigrationService', () => { executedSql.push(sqlText); return []; }, - queryScalar: async (sqlText, params = []) => { - if (sqlText.includes('sqlite_master') || sqlText.includes('information_schema.tables')) { - return 1; - } - if (sqlText.includes('pragma_table_info') || sqlText.includes('information_schema.columns')) { - const columnName = String(params[1] ?? sqlText.match(/name = '([^']+)'/)?.[1] ?? ''); - return columnName === 'use_system_proxy' ? 0 : 1; - } - return 0; - }, + queryScalar: async () => 1, close: async () => {}, + }, { + currentContract, + liveContract, }); const useSystemProxySql = executedSql.find((sqlText) => sqlText.includes('use_system_proxy')); + const customHeadersSql = executedSql.find((sqlText) => sqlText.includes('custom_headers')); expect(useSystemProxySql).toContain('use_system_proxy'); + expect(customHeadersSql).toContain('custom_headers'); }); it.each(['postgres', 'mysql'] as const)('patches token_routes decision snapshot columns for %s', async (dialect) => { const executedSql: string[] = []; + const liveContract = cloneContract(currentContract); + delete liveContract.tables.token_routes.columns.decision_snapshot; + await __databaseMigrationServiceTestUtils.ensureSchema({ dialect, + connectionString: `${dialect}://example.invalid/metapi`, + ssl: false, begin: async () => {}, commit: async () => {}, rollback: async () => {}, @@ -141,17 +189,11 @@ describe('databaseMigrationService', () => { executedSql.push(sqlText); return []; }, - queryScalar: async (sqlText, params = []) => { - if (sqlText.includes('information_schema.tables')) { - return 1; - } - if (sqlText.includes('information_schema.columns')) { - const columnName = String(params[1] ?? ''); - return columnName === 'decision_snapshot' ? 0 : 1; - } - return 0; - }, + queryScalar: async () => 1, close: async () => {}, + }, { + currentContract, + liveContract, }); expect( @@ -159,7 +201,7 @@ describe('databaseMigrationService', () => { ).toBe(true); }); - it('includes useSystemProxy when building site migration statements', () => { + it('includes useSystemProxy and customHeaders when building site migration statements', () => { const statements = __databaseMigrationServiceTestUtils.buildStatements({ version: 'test', timestamp: Date.now(), @@ -170,8 +212,11 @@ describe('databaseMigrationService', () => { url: 'https://example.com', platform: 'openai', useSystemProxy: true, + customHeaders: '{"x-site-scope":"internal"}', status: 'active', }], + siteAnnouncements: [], + siteDisabledModels: [], accounts: [], accountTokens: [], checkinLogs: [], @@ -180,6 +225,8 @@ describe('databaseMigrationService', () => { tokenRoutes: [], routeChannels: [], proxyLogs: [], + proxyVideoTasks: [], + proxyFiles: [], downstreamApiKeys: [], events: [], }, @@ -190,8 +237,720 @@ describe('databaseMigrationService', () => { const siteStatement = statements.find((statement) => statement.table === 'sites'); const useSystemProxyIndex = siteStatement?.columns.indexOf('use_system_proxy') ?? -1; + const customHeadersIndex = siteStatement?.columns.indexOf('custom_headers') ?? -1; expect(useSystemProxyIndex).toBeGreaterThanOrEqual(0); expect(siteStatement?.values[useSystemProxyIndex]).toBe(true); + expect(customHeadersIndex).toBeGreaterThanOrEqual(0); + expect(siteStatement?.values[customHeadersIndex]).toBe('{"x-site-scope":"internal"}'); + }); + + it('serializes parsed JSON-column values when building migration statements', () => { + const statements = __databaseMigrationServiceTestUtils.buildStatements({ + version: 'test', + timestamp: Date.now(), + accounts: { + sites: [{ + id: 1, + name: 'demo', + url: 'https://example.com', + platform: 'openai', + customHeaders: { 'x-site-scope': 'internal' }, + status: 'active', + }], + siteAnnouncements: [], + siteDisabledModels: [], + accounts: [{ + id: 2, + siteId: 1, + username: 'user-1', + accessToken: 'access-1', + extraConfig: { platformUserId: 42 }, + status: 'active', + }], + accountTokens: [], + checkinLogs: [], + modelAvailability: [], + tokenModelAvailability: [], + tokenRoutes: [{ + id: 3, + modelPattern: '*', + modelMapping: { '*': 'gpt-4o-mini' }, + decisionSnapshot: { channels: [1] }, + enabled: true, + }], + routeChannels: [], + routeGroupSources: [], + proxyLogs: [{ + id: 4, + billingDetails: { total: 1.25 }, + }], + proxyVideoTasks: [{ + id: 5, + publicId: 'video-public-id', + upstreamVideoId: 'upstream-video-id', + siteUrl: 'https://example.com', + tokenValue: 'sk-video', + statusSnapshot: { status: 'done' }, + upstreamResponseMeta: { id: 'video' }, + }], + proxyFiles: [], + downstreamApiKeys: [{ + id: 6, + name: 'managed-key', + key: 'mk-demo', + supportedModels: ['gpt-4o-mini'], + allowedRouteIds: [3], + siteWeightMultipliers: { 1: 1.5 }, + enabled: true, + }], + events: [], + }, + preferences: { + settings: [], + }, + } as any); + + const siteStatement = statements.find((statement) => statement.table === 'sites'); + const accountStatement = statements.find((statement) => statement.table === 'accounts'); + const tokenRouteStatement = statements.find((statement) => statement.table === 'token_routes'); + const proxyLogStatement = statements.find((statement) => statement.table === 'proxy_logs'); + const proxyVideoStatement = statements.find((statement) => statement.table === 'proxy_video_tasks'); + const downstreamKeyStatement = statements.find((statement) => statement.table === 'downstream_api_keys'); + + expect(siteStatement?.values[siteStatement.columns.indexOf('custom_headers')]).toBe('{"x-site-scope":"internal"}'); + expect(accountStatement?.values[accountStatement.columns.indexOf('extra_config')]).toBe('{"platformUserId":42}'); + expect(tokenRouteStatement?.values[tokenRouteStatement.columns.indexOf('model_mapping')]).toBe('{"*":"gpt-4o-mini"}'); + expect(tokenRouteStatement?.values[tokenRouteStatement.columns.indexOf('decision_snapshot')]).toBe('{"channels":[1]}'); + expect(proxyLogStatement?.values[proxyLogStatement.columns.indexOf('billing_details')]).toBe('{"total":1.25}'); + expect(proxyVideoStatement?.values[proxyVideoStatement.columns.indexOf('status_snapshot')]).toBe('{"status":"done"}'); + expect(proxyVideoStatement?.values[proxyVideoStatement.columns.indexOf('upstream_response_meta')]).toBe('{"id":"video"}'); + expect(downstreamKeyStatement?.values[downstreamKeyStatement.columns.indexOf('supported_models')]).toBe('["gpt-4o-mini"]'); + expect(downstreamKeyStatement?.values[downstreamKeyStatement.columns.indexOf('allowed_route_ids')]).toBe('[3]'); + expect(downstreamKeyStatement?.values[downstreamKeyStatement.columns.indexOf('site_weight_multipliers')]).toBe('{"1":1.5}'); + }); + + it('uses schema logical types to serialize JSON columns instead of String(value)', () => { + expect(__databaseMigrationServiceTestUtils.serializeColumnValue('sites', 'custom_headers', { + 'x-site-scope': 'internal', + })).toBe('{"x-site-scope":"internal"}'); + expect(__databaseMigrationServiceTestUtils.serializeColumnValue('downstream_api_keys', 'supported_models', [ + 'gpt-4o-mini', + ])).toBe('["gpt-4o-mini"]'); + expect(__databaseMigrationServiceTestUtils.serializeColumnValue('sites', 'name', { + demo: true, + })).toBe('[object Object]'); + }); + + it('serializes object-backed JSON columns when building migration statements', () => { + const statements = __databaseMigrationServiceTestUtils.buildStatements({ + version: 'test', + timestamp: Date.now(), + accounts: { + sites: [{ + id: 1, + name: 'demo', + url: 'https://example.com', + platform: 'openai', + customHeaders: { 'x-site-scope': 'internal' }, + status: 'active', + }], + siteAnnouncements: [], + siteDisabledModels: [], + accounts: [{ + id: 2, + siteId: 1, + accessToken: 'access', + extraConfig: { platformUserId: 1234 }, + status: 'active', + }], + accountTokens: [], + checkinLogs: [], + modelAvailability: [], + tokenModelAvailability: [], + tokenRoutes: [{ + id: 3, + modelPattern: 'gpt-*', + modelMapping: { 'gpt-*': 'gpt-5-mini' }, + decisionSnapshot: { candidates: [1, 2] }, + enabled: true, + }], + routeChannels: [], + routeGroupSources: [], + proxyLogs: [{ + id: 4, + billingDetails: { currency: 'usd', total: 1.23 }, + }], + proxyVideoTasks: [{ + id: 5, + publicId: 'video-public-id', + upstreamVideoId: 'upstream-video-id', + siteUrl: 'https://example.com', + tokenValue: 'sk-video', + statusSnapshot: { status: 'done' }, + upstreamResponseMeta: { id: 'video' }, + }], + proxyFiles: [], + downstreamApiKeys: [{ + id: 6, + name: 'managed', + key: 'sk-managed', + enabled: true, + supportedModels: ['gpt-5', 'gpt-5-mini'], + allowedRouteIds: [10, 11], + siteWeightMultipliers: { 1: 2, 2: 0.5 }, + }], + events: [], + }, + preferences: { + settings: [], + }, + } as any); + + const sitesStatement = statements.find((statement) => statement.table === 'sites'); + expect(sitesStatement?.values[sitesStatement.columns.indexOf('custom_headers')]).toBe('{"x-site-scope":"internal"}'); + + const accountsStatement = statements.find((statement) => statement.table === 'accounts'); + expect(accountsStatement?.values[accountsStatement.columns.indexOf('extra_config')]).toBe('{"platformUserId":1234}'); + + const tokenRoutesStatement = statements.find((statement) => statement.table === 'token_routes'); + expect(tokenRoutesStatement?.values[tokenRoutesStatement.columns.indexOf('model_mapping')]).toBe('{"gpt-*":"gpt-5-mini"}'); + expect(tokenRoutesStatement?.values[tokenRoutesStatement.columns.indexOf('decision_snapshot')]).toBe('{"candidates":[1,2]}'); + + const proxyLogsStatement = statements.find((statement) => statement.table === 'proxy_logs'); + expect(proxyLogsStatement?.values[proxyLogsStatement.columns.indexOf('billing_details')]).toBe('{"currency":"usd","total":1.23}'); + + const proxyVideoTasksStatement = statements.find((statement) => statement.table === 'proxy_video_tasks'); + expect(proxyVideoTasksStatement?.values[proxyVideoTasksStatement.columns.indexOf('status_snapshot')]).toBe('{"status":"done"}'); + expect(proxyVideoTasksStatement?.values[proxyVideoTasksStatement.columns.indexOf('upstream_response_meta')]).toBe('{"id":"video"}'); + + const downstreamApiKeysStatement = statements.find((statement) => statement.table === 'downstream_api_keys'); + expect(downstreamApiKeysStatement?.values[downstreamApiKeysStatement.columns.indexOf('supported_models')]).toBe('["gpt-5","gpt-5-mini"]'); + expect(downstreamApiKeysStatement?.values[downstreamApiKeysStatement.columns.indexOf('allowed_route_ids')]).toBe('[10,11]'); + expect(downstreamApiKeysStatement?.values[downstreamApiKeysStatement.columns.indexOf('site_weight_multipliers')]).toBe('{"1":2,"2":0.5}'); + }); + + it('serializes JSON logical-type columns from object and array values', () => { + const statements = __databaseMigrationServiceTestUtils.buildStatements({ + version: 'test', + timestamp: Date.now(), + accounts: { + sites: [{ + id: 1, + name: 'demo', + url: 'https://example.com', + platform: 'openai', + customHeaders: { 'x-site-scope': 'internal' }, + status: 'active', + }], + siteAnnouncements: [], + siteDisabledModels: [], + accounts: [{ + id: 2, + siteId: 1, + username: 'user-1', + accessToken: 'token-1', + extraConfig: { platformUserId: 1001, credentialMode: 'session' }, + status: 'active', + }], + accountTokens: [], + checkinLogs: [], + modelAvailability: [], + tokenModelAvailability: [], + tokenRoutes: [{ + id: 3, + modelPattern: 'gpt-*', + modelMapping: { 'gpt-*': 'gpt-4.1' }, + decisionSnapshot: { matched: true, channels: [1] }, + enabled: true, + }], + routeChannels: [], + routeGroupSources: [], + proxyLogs: [{ + id: 4, + billingDetails: { source: 'pricing', total: 1.25 }, + }], + proxyVideoTasks: [{ + id: 5, + publicId: 'video-1', + upstreamVideoId: 'upstream-1', + siteUrl: 'https://example.com', + tokenValue: 'sk-video', + requestedModel: 'veo-3', + actualModel: 'veo-3', + channelId: 9, + accountId: 2, + statusSnapshot: { status: 'done' }, + upstreamResponseMeta: { id: 'video' }, + }], + proxyFiles: [], + downstreamApiKeys: [{ + id: 6, + name: 'managed', + key: 'sk-managed', + supportedModels: ['gpt-4.1', 'gpt-4o'], + allowedRouteIds: [3, 8], + siteWeightMultipliers: { 1: 2 }, + enabled: true, + }], + events: [], + }, + preferences: { + settings: [], + }, + } as any); + + const siteStatement = statements.find((statement) => statement.table === 'sites'); + const accountStatement = statements.find((statement) => statement.table === 'accounts'); + const routeStatement = statements.find((statement) => statement.table === 'token_routes'); + const proxyLogStatement = statements.find((statement) => statement.table === 'proxy_logs'); + const videoStatement = statements.find((statement) => statement.table === 'proxy_video_tasks'); + const downstreamKeyStatement = statements.find((statement) => statement.table === 'downstream_api_keys'); + + expect(siteStatement?.values[siteStatement.columns.indexOf('custom_headers')]).toBe('{"x-site-scope":"internal"}'); + expect(accountStatement?.values[accountStatement.columns.indexOf('extra_config')]).toBe('{"platformUserId":1001,"credentialMode":"session"}'); + expect(routeStatement?.values[routeStatement.columns.indexOf('model_mapping')]).toBe('{"gpt-*":"gpt-4.1"}'); + expect(routeStatement?.values[routeStatement.columns.indexOf('decision_snapshot')]).toBe('{"matched":true,"channels":[1]}'); + expect(proxyLogStatement?.values[proxyLogStatement.columns.indexOf('billing_details')]).toBe('{"source":"pricing","total":1.25}'); + expect(videoStatement?.values[videoStatement.columns.indexOf('status_snapshot')]).toBe('{"status":"done"}'); + expect(videoStatement?.values[videoStatement.columns.indexOf('upstream_response_meta')]).toBe('{"id":"video"}'); + expect(downstreamKeyStatement?.values[downstreamKeyStatement.columns.indexOf('supported_models')]).toBe('["gpt-4.1","gpt-4o"]'); + expect(downstreamKeyStatement?.values[downstreamKeyStatement.columns.indexOf('allowed_route_ids')]).toBe('[3,8]'); + expect(downstreamKeyStatement?.values[downstreamKeyStatement.columns.indexOf('site_weight_multipliers')]).toBe('{"1":2}'); + }); + + it('serializes JSON logical-type columns from parsed objects and arrays', () => { + const statements = __databaseMigrationServiceTestUtils.buildStatements({ + version: 'test', + timestamp: Date.now(), + accounts: { + sites: [{ + id: 1, + name: 'demo', + url: 'https://example.com', + platform: 'openai', + customHeaders: { 'x-site-scope': 'internal' }, + status: 'active', + }], + siteAnnouncements: [], + siteDisabledModels: [], + accounts: [{ + id: 2, + siteId: 1, + username: 'demo-user', + accessToken: 'token', + extraConfig: { platformUserId: 42 }, + status: 'active', + }], + accountTokens: [], + checkinLogs: [], + modelAvailability: [], + tokenModelAvailability: [], + tokenRoutes: [{ + id: 3, + modelPattern: 'gpt-*', + modelMapping: { 'gpt-4.1': 'gpt-4o-mini' }, + decisionSnapshot: { matched: true, routeId: 3 }, + enabled: true, + }], + routeChannels: [], + routeGroupSources: [], + proxyLogs: [{ + id: 4, + billingDetails: { total: 1.25, currency: 'USD' }, + }], + proxyVideoTasks: [{ + id: 5, + publicId: 'vid_1', + upstreamVideoId: 'upstream_1', + siteUrl: 'https://example.com', + tokenValue: 'sk-video', + statusSnapshot: { status: 'done' }, + upstreamResponseMeta: { id: 'video-1' }, + }], + proxyFiles: [], + downstreamApiKeys: [{ + id: 6, + name: 'managed-key', + key: 'sk-managed', + supportedModels: ['gpt-4.1', 'gpt-4o-mini'], + allowedRouteIds: [3], + siteWeightMultipliers: { 1: 1.5 }, + }], + events: [], + }, + preferences: { + settings: [], + }, + } as any); + + const sitesStatement = statements.find((statement) => statement.table === 'sites'); + const accountsStatement = statements.find((statement) => statement.table === 'accounts'); + const tokenRoutesStatement = statements.find((statement) => statement.table === 'token_routes'); + const proxyLogsStatement = statements.find((statement) => statement.table === 'proxy_logs'); + const proxyVideoStatement = statements.find((statement) => statement.table === 'proxy_video_tasks'); + const downstreamStatement = statements.find((statement) => statement.table === 'downstream_api_keys'); + + expect(sitesStatement?.values[sitesStatement.columns.indexOf('custom_headers')]).toBe('{"x-site-scope":"internal"}'); + expect(accountsStatement?.values[accountsStatement.columns.indexOf('extra_config')]).toBe('{"platformUserId":42}'); + expect(tokenRoutesStatement?.values[tokenRoutesStatement.columns.indexOf('model_mapping')]).toBe('{"gpt-4.1":"gpt-4o-mini"}'); + expect(tokenRoutesStatement?.values[tokenRoutesStatement.columns.indexOf('decision_snapshot')]).toBe('{"matched":true,"routeId":3}'); + expect(proxyLogsStatement?.values[proxyLogsStatement.columns.indexOf('billing_details')]).toBe('{"total":1.25,"currency":"USD"}'); + expect(proxyVideoStatement?.values[proxyVideoStatement.columns.indexOf('status_snapshot')]).toBe('{"status":"done"}'); + expect(proxyVideoStatement?.values[proxyVideoStatement.columns.indexOf('upstream_response_meta')]).toBe('{"id":"video-1"}'); + expect(downstreamStatement?.values[downstreamStatement.columns.indexOf('supported_models')]).toBe('["gpt-4.1","gpt-4o-mini"]'); + expect(downstreamStatement?.values[downstreamStatement.columns.indexOf('allowed_route_ids')]).toBe('[3]'); + expect(downstreamStatement?.values[downstreamStatement.columns.indexOf('site_weight_multipliers')]).toBe('{"1":1.5}'); + }); + + it('serializes JSON logical-type columns without coercing objects to [object Object]', () => { + const statements = __databaseMigrationServiceTestUtils.buildStatements({ + version: 'test', + timestamp: Date.now(), + accounts: { + sites: [{ + id: 1, + name: 'demo', + url: 'https://example.com', + platform: 'openai', + customHeaders: { 'x-site-scope': 'internal' }, + status: 'active', + }], + siteAnnouncements: [], + siteDisabledModels: [], + accounts: [{ + id: 2, + siteId: 1, + username: 'user', + accessToken: 'access', + apiToken: 'api', + extraConfig: { platformUserId: 42, credentialMode: 'session' }, + status: 'active', + }], + accountTokens: [], + checkinLogs: [], + modelAvailability: [], + tokenModelAvailability: [], + tokenRoutes: [{ + id: 3, + modelPattern: '*', + modelMapping: { 'gpt-4.1': 'gpt-4o-mini' }, + decisionSnapshot: { matched: true, routeId: 3 }, + enabled: true, + }], + routeChannels: [], + routeGroupSources: [], + proxyLogs: [{ + id: 4, + billingDetails: { source: 'pricing', usd: 1.25 }, + }], + proxyVideoTasks: [{ + id: 5, + publicId: 'vid_1', + upstreamVideoId: 'upstream_1', + siteUrl: 'https://example.com', + tokenValue: 'sk-video', + requestedModel: 'veo-3', + actualModel: 'veo-3', + statusSnapshot: { status: 'done' }, + upstreamResponseMeta: { id: 'video' }, + }], + proxyFiles: [], + downstreamApiKeys: [{ + id: 6, + name: 'managed', + key: 'key-1', + supportedModels: ['gpt-4.1'], + allowedRouteIds: [3], + siteWeightMultipliers: { 1: 2 }, + enabled: true, + }], + events: [], + }, + preferences: { + settings: [], + }, + } as any); + + const sitesStatement = statements.find((statement) => statement.table === 'sites'); + const accountsStatement = statements.find((statement) => statement.table === 'accounts'); + const tokenRoutesStatement = statements.find((statement) => statement.table === 'token_routes'); + const proxyLogsStatement = statements.find((statement) => statement.table === 'proxy_logs'); + const proxyVideoTasksStatement = statements.find((statement) => statement.table === 'proxy_video_tasks'); + const downstreamKeysStatement = statements.find((statement) => statement.table === 'downstream_api_keys'); + + expect(sitesStatement?.values[sitesStatement.columns.indexOf('custom_headers')]).toBe('{"x-site-scope":"internal"}'); + expect(accountsStatement?.values[accountsStatement.columns.indexOf('extra_config')]).toBe('{"platformUserId":42,"credentialMode":"session"}'); + expect(tokenRoutesStatement?.values[tokenRoutesStatement.columns.indexOf('model_mapping')]).toBe('{"gpt-4.1":"gpt-4o-mini"}'); + expect(tokenRoutesStatement?.values[tokenRoutesStatement.columns.indexOf('decision_snapshot')]).toBe('{"matched":true,"routeId":3}'); + expect(proxyLogsStatement?.values[proxyLogsStatement.columns.indexOf('billing_details')]).toBe('{"source":"pricing","usd":1.25}'); + expect(proxyVideoTasksStatement?.values[proxyVideoTasksStatement.columns.indexOf('status_snapshot')]).toBe('{"status":"done"}'); + expect(proxyVideoTasksStatement?.values[proxyVideoTasksStatement.columns.indexOf('upstream_response_meta')]).toBe('{"id":"video"}'); + expect(downstreamKeysStatement?.values[downstreamKeysStatement.columns.indexOf('supported_models')]).toBe('["gpt-4.1"]'); + expect(downstreamKeysStatement?.values[downstreamKeysStatement.columns.indexOf('allowed_route_ids')]).toBe('[3]'); + expect(downstreamKeysStatement?.values[downstreamKeysStatement.columns.indexOf('site_weight_multipliers')]).toBe('{"1":2}'); + }); + + it('includes disabled models, proxy video tasks, and proxy files in migration statements', () => { + const statements = __databaseMigrationServiceTestUtils.buildStatements({ + version: 'test', + timestamp: Date.now(), + accounts: { + sites: [], + siteAnnouncements: [], + siteDisabledModels: [{ + id: 3, + siteId: 12, + modelName: 'claude-opus-4-6', + createdAt: '2026-03-14T00:00:00.000Z', + }], + accounts: [], + accountTokens: [], + checkinLogs: [], + modelAvailability: [], + tokenModelAvailability: [], + tokenRoutes: [{ + id: 10, + modelPattern: 'claude-opus-4-6', + displayName: 'claude-opus-4-6', + displayIcon: 'icon-claude', + modelMapping: null, + routeMode: 'explicit_group', + decisionSnapshot: '{"channels":[1]}', + decisionRefreshedAt: '2026-03-14T01:30:00.000Z', + routingStrategy: 'round_robin', + enabled: true, + createdAt: '2026-03-14T00:00:00.000Z', + updatedAt: '2026-03-14T01:00:00.000Z', + }], + routeChannels: [], + proxyLogs: [], + proxyVideoTasks: [{ + id: 5, + publicId: 'video-public-id', + upstreamVideoId: 'upstream-video-id', + siteUrl: 'https://example.com', + tokenValue: 'sk-video', + requestedModel: 'veo-3', + actualModel: 'veo-3', + channelId: 7, + accountId: 9, + statusSnapshot: '{"status":"done"}', + upstreamResponseMeta: '{"id":"video"}', + lastUpstreamStatus: 200, + lastPolledAt: '2026-03-14T01:00:00.000Z', + createdAt: '2026-03-14T00:00:00.000Z', + updatedAt: '2026-03-14T01:00:00.000Z', + }], + proxyFiles: [{ + id: 8, + publicId: 'file-public-id', + ownerType: 'downstream_key', + ownerId: 'key-1', + filename: 'demo.txt', + mimeType: 'text/plain', + purpose: 'assistants', + byteSize: 4, + sha256: 'abcd', + contentBase64: 'ZGVtbw==', + createdAt: '2026-03-14T00:00:00.000Z', + updatedAt: '2026-03-14T01:00:00.000Z', + deletedAt: null, + }], + routeGroupSources: [{ + id: 9, + groupRouteId: 12, + sourceRouteId: 13, + }], + downstreamApiKeys: [], + events: [], + }, + preferences: { + settings: [], + }, + } as any); + + expect(statements.some((statement) => statement.table === 'site_disabled_models')).toBe(true); + expect(statements.some((statement) => statement.table === 'proxy_video_tasks')).toBe(true); + expect(statements.some((statement) => statement.table === 'proxy_files')).toBe(true); + expect(statements.some((statement) => statement.table === 'route_group_sources')).toBe(true); + const tokenRouteStatement = statements.find((statement) => statement.table === 'token_routes'); + const routeModeIndex = tokenRouteStatement?.columns.indexOf('route_mode') ?? -1; + expect(routeModeIndex).toBeGreaterThanOrEqual(0); + expect(tokenRouteStatement?.values[routeModeIndex]).toBe('explicit_group'); + }); + + it('includes site announcements in migration statements', () => { + const statements = __databaseMigrationServiceTestUtils.buildStatements({ + version: 'test', + timestamp: Date.now(), + accounts: { + sites: [], + siteDisabledModels: [], + accounts: [], + accountTokens: [], + checkinLogs: [], + modelAvailability: [], + tokenModelAvailability: [], + tokenRoutes: [], + routeChannels: [], + routeGroupSources: [], + proxyLogs: [], + proxyVideoTasks: [], + proxyFiles: [], + downstreamApiKeys: [], + events: [], + siteAnnouncements: [{ + id: 11, + siteId: 3, + platform: 'openai', + sourceKey: 'notice-1', + title: '????', + content: '????', + level: 'warning', + sourceUrl: 'https://example.com/notice', + startsAt: '2026-03-20T00:00:00.000Z', + endsAt: '2026-03-21T00:00:00.000Z', + upstreamCreatedAt: '2026-03-19T00:00:00.000Z', + upstreamUpdatedAt: '2026-03-20T00:00:00.000Z', + firstSeenAt: '2026-03-20T00:00:00.000Z', + lastSeenAt: '2026-03-20T01:00:00.000Z', + readAt: null, + dismissedAt: null, + rawPayload: '{"id":"notice-1"}', + }], + }, + preferences: { + settings: [], + }, + } as any); + + const statement = statements.find((item) => item.table === 'site_announcements'); + expect(statement).toBeDefined(); + expect(statement?.columns).toContain('source_key'); + expect(statement?.values[statement?.columns.indexOf('title') ?? -1]).toBe('????'); + }); + + it('includes site announcements in migration summary', async () => { + vi.resetModules(); + + const rowsByTable = { + settings: [], + sites: [], + siteAnnouncements: [{ + id: 11, + siteId: 3, + platform: 'openai', + sourceKey: 'notice-1', + title: '????', + content: '????', + level: 'warning', + sourceUrl: 'https://example.com/notice', + startsAt: '2026-03-20T00:00:00.000Z', + endsAt: '2026-03-21T00:00:00.000Z', + upstreamCreatedAt: '2026-03-19T00:00:00.000Z', + upstreamUpdatedAt: '2026-03-20T00:00:00.000Z', + firstSeenAt: '2026-03-20T00:00:00.000Z', + lastSeenAt: '2026-03-20T01:00:00.000Z', + readAt: null, + dismissedAt: null, + rawPayload: '{"id":"notice-1"}', + }], + siteDisabledModels: [], + accounts: [], + accountTokens: [], + checkinLogs: [], + modelAvailability: [], + tokenModelAvailability: [], + tokenRoutes: [], + routeChannels: [], + routeGroupSources: [], + proxyLogs: [], + proxyVideoTasks: [], + proxyFiles: [], + downstreamApiKeys: [], + events: [], + }; + + const client = { + dialect: 'sqlite', + connectionString: ':memory:', + ssl: false, + begin: vi.fn(async () => {}), + commit: vi.fn(async () => {}), + rollback: vi.fn(async () => {}), + execute: vi.fn(async () => []), + queryScalar: vi.fn(async () => 0), + close: vi.fn(async () => {}), + }; + + vi.doMock('../db/index.js', () => ({ + db: createDbMock(rowsByTable), + schema: createDbSchemaMock(), + })); + vi.doMock('../db/runtimeSchemaBootstrap.js', () => ({ + createRuntimeSchemaClient: async () => client, + ensureRuntimeDatabaseSchema: async () => {}, + })); + + try { + const { migrateCurrentDatabase } = await import('./databaseMigrationService.js'); + const summary = await migrateCurrentDatabase({ + dialect: 'sqlite', + connectionString: ':memory:', + overwrite: true, + }); + + expect(summary.rows.siteAnnouncements).toBe(1); + expect(client.begin).toHaveBeenCalledTimes(1); + expect(client.commit).toHaveBeenCalledTimes(1); + expect(client.close).toHaveBeenCalledTimes(1); + } finally { + vi.doUnmock('../db/index.js'); + vi.doUnmock('../db/runtimeSchemaBootstrap.js'); + vi.resetModules(); + } + }); + + it('excludes runtime database config settings from migration statements', () => { + const statements = __databaseMigrationServiceTestUtils.buildStatements({ + version: 'test', + timestamp: Date.now(), + accounts: { + sites: [], + siteAnnouncements: [], + siteDisabledModels: [], + accounts: [], + accountTokens: [], + checkinLogs: [], + modelAvailability: [], + tokenModelAvailability: [], + tokenRoutes: [], + routeChannels: [], + routeGroupSources: [], + proxyLogs: [], + proxyVideoTasks: [], + proxyFiles: [], + downstreamApiKeys: [], + events: [], + }, + preferences: { + settings: [ + { key: 'db_type', value: 'sqlite' }, + { key: 'db_url', value: '/app/data/hub.db' }, + { key: 'db_ssl', value: false }, + { key: 'routing_fallback_unit_cost', value: 0.25 }, + ], + }, + } as any); + + const migratedSettingKeys = statements + .filter((statement) => statement.table === 'settings') + .map((statement) => statement.values[0]); + + expect(migratedSettingKeys).toContain('routing_fallback_unit_cost'); + expect(migratedSettingKeys).not.toContain('db_type'); + expect(migratedSettingKeys).not.toContain('db_url'); + expect(migratedSettingKeys).not.toContain('db_ssl'); }); }); diff --git a/src/server/services/databaseMigrationService.ts b/src/server/services/databaseMigrationService.ts index 45035da4..f3e5f2e6 100644 --- a/src/server/services/databaseMigrationService.ts +++ b/src/server/services/databaseMigrationService.ts @@ -1,14 +1,14 @@ import Database from 'better-sqlite3'; -import mysql from 'mysql2/promise'; -import pg from 'pg'; -import { mkdirSync } from 'node:fs'; -import { dirname, resolve } from 'node:path'; +import currentSchemaContract from '../db/generated/schemaContract.json' with { type: 'json' }; import { db, schema } from '../db/index.js'; -import { ensureSiteSchemaCompatibility, type SiteSchemaInspector } from '../db/siteSchemaCompatibility.js'; -import { ensureRouteGroupingSchemaCompatibility } from '../db/routeGroupingSchemaCompatibility.js'; -import { ensureProxyFileSchemaCompatibility } from '../db/proxyFileSchemaCompatibility.js'; +import { + createRuntimeSchemaClient, + ensureRuntimeDatabaseSchema, + type RuntimeSchemaClient, + type RuntimeSchemaDialect, +} from '../db/runtimeSchemaBootstrap.js'; -export type MigrationDialect = 'sqlite' | 'mysql' | 'postgres'; +export type MigrationDialect = RuntimeSchemaDialect; export interface DatabaseMigrationInput { dialect?: unknown; @@ -29,6 +29,8 @@ type BackupSnapshot = { timestamp: number; accounts: { sites: Array>; + siteAnnouncements: Array>; + siteDisabledModels: Array>; accounts: Array>; accountTokens: Array>; checkinLogs: Array>; @@ -36,7 +38,10 @@ type BackupSnapshot = { tokenModelAvailability: Array>; tokenRoutes: Array>; routeChannels: Array>; + routeGroupSources: Array>; proxyLogs: Array>; + proxyVideoTasks: Array>; + proxyFiles: Array>; downstreamApiKeys: Array>; events: Array>; }; @@ -53,29 +58,26 @@ export interface DatabaseMigrationSummary { timestamp: number; rows: { sites: number; + siteAnnouncements: number; + siteDisabledModels: number; accounts: number; accountTokens: number; tokenRoutes: number; routeChannels: number; + routeGroupSources: number; checkinLogs: number; modelAvailability: number; tokenModelAvailability: number; proxyLogs: number; + proxyVideoTasks: number; + proxyFiles: number; downstreamApiKeys: number; events: number; settings: number; }; } -interface SqlClient { - dialect: MigrationDialect; - begin(): Promise; - commit(): Promise; - rollback(): Promise; - execute(sqlText: string, params?: unknown[]): Promise; - queryScalar(sqlText: string, params?: unknown[]): Promise; - close(): Promise; -} +type SqlClient = RuntimeSchemaClient; interface InsertStatement { table: string; @@ -84,6 +86,16 @@ interface InsertStatement { } const DIALECTS: MigrationDialect[] = ['sqlite', 'mysql', 'postgres']; +const RUNTIME_DATABASE_SETTING_KEYS = new Set(['db_type', 'db_url', 'db_ssl']); +type SchemaContractShape = { + tables: Record; + }>; +}; +type LogicalColumnTypeShape = string | null; +const schemaContract = currentSchemaContract as SchemaContractShape; function asString(value: unknown): string { return typeof value === 'string' ? value.trim() : ''; @@ -111,6 +123,32 @@ function asNullableString(value: unknown): string | null { return String(value); } +function getColumnLogicalType( + table: string, + column: string, + contract: SchemaContractShape = schemaContract, +): LogicalColumnTypeShape | null { + return contract.tables[table]?.columns[column]?.logicalType ?? null; +} + +function serializeJsonColumnValue(value: unknown): string | null { + if (value === null || value === undefined) return null; + if (typeof value === 'string') return value; + return JSON.stringify(value); +} + +function serializeColumnValue( + table: string, + column: string, + value: unknown, + contract: SchemaContractShape = schemaContract, +): string | null { + if (getColumnLogicalType(table, column, contract) === 'json') { + return serializeJsonColumnValue(value); + } + return asNullableString(value); +} + function toJsonString(value: unknown): string { return JSON.stringify(value ?? null); } @@ -213,6 +251,8 @@ async function toBackupSnapshot(): Promise { timestamp: Date.now(), accounts: { sites: await db.select().from(schema.sites).all() as Array>, + siteAnnouncements: await db.select().from(schema.siteAnnouncements).all() as Array>, + siteDisabledModels: await db.select().from(schema.siteDisabledModels).all() as Array>, accounts: await db.select().from(schema.accounts).all() as Array>, accountTokens: await db.select().from(schema.accountTokens).all() as Array>, checkinLogs: await db.select().from(schema.checkinLogs).all() as Array>, @@ -220,7 +260,10 @@ async function toBackupSnapshot(): Promise { tokenModelAvailability: await db.select().from(schema.tokenModelAvailability).all() as Array>, tokenRoutes: await db.select().from(schema.tokenRoutes).all() as Array>, routeChannels: await db.select().from(schema.routeChannels).all() as Array>, + routeGroupSources: await db.select().from(schema.routeGroupSources).all() as Array>, proxyLogs: await db.select().from(schema.proxyLogs).all() as Array>, + proxyVideoTasks: await db.select().from(schema.proxyVideoTasks).all() as Array>, + proxyFiles: await db.select().from(schema.proxyFiles).all() as Array>, downstreamApiKeys: await db.select().from(schema.downstreamApiKeys).all() as Array>, events: await db.select().from(schema.events).all() as Array>, }, @@ -230,216 +273,8 @@ async function toBackupSnapshot(): Promise { }; } -async function createPostgresClient(connectionString: string, ssl: boolean): Promise { - const clientOptions: pg.ClientConfig = { connectionString }; - if (ssl) { - clientOptions.ssl = { rejectUnauthorized: false }; - } - const client = new pg.Client(clientOptions); - await client.connect(); - - return { - dialect: 'postgres', - begin: async () => { await client.query('BEGIN'); }, - commit: async () => { await client.query('COMMIT'); }, - rollback: async () => { await client.query('ROLLBACK'); }, - execute: async (sqlText, params = []) => client.query(sqlText, params), - queryScalar: async (sqlText, params = []) => { - const result = await client.query(sqlText, params); - const row = result.rows[0] as Record | undefined; - if (!row) return 0; - return Number(Object.values(row)[0]) || 0; - }, - close: async () => { await client.end(); }, - }; -} - -async function createMySqlClient(connectionString: string, ssl: boolean): Promise { - const connectionOptions: mysql.ConnectionOptions = { uri: connectionString }; - if (ssl) { - connectionOptions.ssl = { rejectUnauthorized: false }; - } - const connection = await mysql.createConnection(connectionOptions); - - return { - dialect: 'mysql', - begin: async () => { await connection.beginTransaction(); }, - commit: async () => { await connection.commit(); }, - rollback: async () => { await connection.rollback(); }, - execute: async (sqlText, params = []) => connection.execute(sqlText, params as any[]), - queryScalar: async (sqlText, params = []) => { - const [rows] = await connection.query(sqlText, params as any[]); - if (!Array.isArray(rows) || rows.length === 0) return 0; - const row = rows[0] as Record; - return Number(Object.values(row)[0]) || 0; - }, - close: async () => { await connection.end(); }, - }; -} - -async function createSqliteClient(connectionString: string): Promise { - const filePath = resolve(connectionString); - mkdirSync(dirname(filePath), { recursive: true }); - const sqlite = new Database(filePath); - sqlite.pragma('journal_mode = WAL'); - sqlite.pragma('foreign_keys = ON'); - - return { - dialect: 'sqlite', - begin: async () => { sqlite.exec('BEGIN'); }, - commit: async () => { sqlite.exec('COMMIT'); }, - rollback: async () => { sqlite.exec('ROLLBACK'); }, - execute: async (sqlText, params = []) => { - const lowered = sqlText.trim().toLowerCase(); - const stmt = sqlite.prepare(sqlText); - if (lowered.startsWith('select')) return await stmt.all(...params); - return await stmt.run(...params); - }, - queryScalar: async (sqlText, params = []) => { - const row = await sqlite.prepare(sqlText).get(...params) as Record | undefined; - if (!row) return 0; - return Number(Object.values(row)[0]) || 0; - }, - close: async () => { sqlite.close(); }, - }; -} - async function createClient(input: NormalizedDatabaseMigrationInput): Promise { - if (input.dialect === 'postgres') return createPostgresClient(input.connectionString, input.ssl); - if (input.dialect === 'mysql') return createMySqlClient(input.connectionString, input.ssl); - return createSqliteClient(input.connectionString); -} - -function validateIdentifier(identifier: string): string { - if (!/^[a-z_][a-z0-9_]*$/i.test(identifier)) { - throw new Error(`Invalid SQL identifier: ${identifier}`); - } - return identifier; -} - -function createSiteSchemaInspector(client: SqlClient): SiteSchemaInspector { - if (client.dialect === 'sqlite') { - return { - dialect: 'sqlite', - tableExists: async (table) => { - const normalizedTable = validateIdentifier(table); - return (await client.queryScalar( - `SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = '${normalizedTable}'`, - )) > 0; - }, - columnExists: async (table, column) => { - const normalizedTable = validateIdentifier(table); - const normalizedColumn = validateIdentifier(column); - return (await client.queryScalar( - `SELECT COUNT(*) FROM pragma_table_info('${normalizedTable}') WHERE name = '${normalizedColumn}'`, - )) > 0; - }, - execute: async (sqlText) => { - await client.execute(sqlText); - }, - }; - } - - if (client.dialect === 'mysql') { - return { - dialect: 'mysql', - tableExists: async (table) => { - return (await client.queryScalar( - 'SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = ?', - [table], - )) > 0; - }, - columnExists: async (table, column) => { - return (await client.queryScalar( - 'SELECT COUNT(*) FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = ? AND column_name = ?', - [table, column], - )) > 0; - }, - execute: async (sqlText) => { - await client.execute(sqlText); - }, - }; - } - - return { - dialect: 'postgres', - tableExists: async (table) => { - return (await client.queryScalar( - 'SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = current_schema() AND table_name = $1', - [table], - )) > 0; - }, - columnExists: async (table, column) => { - return (await client.queryScalar( - 'SELECT COUNT(*) FROM information_schema.columns WHERE table_schema = current_schema() AND table_name = $1 AND column_name = $2', - [table, column], - )) > 0; - }, - execute: async (sqlText) => { - await client.execute(sqlText); - }, - }; -} - -async function ensureSchema(client: SqlClient): Promise { - const statements = client.dialect === 'postgres' - ? [ - `CREATE TABLE IF NOT EXISTS "sites" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "name" TEXT NOT NULL, "url" TEXT NOT NULL, "external_checkin_url" TEXT, "platform" TEXT NOT NULL, "proxy_url" TEXT, "use_system_proxy" BOOLEAN DEFAULT FALSE, "status" TEXT DEFAULT 'active', "is_pinned" BOOLEAN DEFAULT FALSE, "sort_order" INTEGER DEFAULT 0, "global_weight" DOUBLE PRECISION DEFAULT 1, "api_key" TEXT, "created_at" TEXT, "updated_at" TEXT)`, - `CREATE TABLE IF NOT EXISTS "accounts" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "site_id" INTEGER NOT NULL REFERENCES "sites"("id") ON DELETE CASCADE, "username" TEXT, "access_token" TEXT NOT NULL, "api_token" TEXT, "balance" DOUBLE PRECISION DEFAULT 0, "balance_used" DOUBLE PRECISION DEFAULT 0, "quota" DOUBLE PRECISION DEFAULT 0, "unit_cost" DOUBLE PRECISION, "value_score" DOUBLE PRECISION DEFAULT 0, "status" TEXT DEFAULT 'active', "is_pinned" BOOLEAN DEFAULT FALSE, "sort_order" INTEGER DEFAULT 0, "checkin_enabled" BOOLEAN DEFAULT TRUE, "last_checkin_at" TEXT, "last_balance_refresh" TEXT, "extra_config" TEXT, "created_at" TEXT, "updated_at" TEXT)`, - `CREATE TABLE IF NOT EXISTS "account_tokens" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "account_id" INTEGER NOT NULL REFERENCES "accounts"("id") ON DELETE CASCADE, "name" TEXT NOT NULL, "token" TEXT NOT NULL, "token_group" TEXT, "source" TEXT DEFAULT 'manual', "enabled" BOOLEAN DEFAULT TRUE, "is_default" BOOLEAN DEFAULT FALSE, "created_at" TEXT, "updated_at" TEXT)`, - `CREATE TABLE IF NOT EXISTS "checkin_logs" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "account_id" INTEGER NOT NULL REFERENCES "accounts"("id") ON DELETE CASCADE, "status" TEXT NOT NULL, "message" TEXT, "reward" TEXT, "created_at" TEXT)`, - `CREATE TABLE IF NOT EXISTS "model_availability" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "account_id" INTEGER NOT NULL REFERENCES "accounts"("id") ON DELETE CASCADE, "model_name" TEXT NOT NULL, "available" BOOLEAN, "latency_ms" INTEGER, "checked_at" TEXT)`, - `CREATE TABLE IF NOT EXISTS "token_model_availability" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "token_id" INTEGER NOT NULL REFERENCES "account_tokens"("id") ON DELETE CASCADE, "model_name" TEXT NOT NULL, "available" BOOLEAN, "latency_ms" INTEGER, "checked_at" TEXT)`, - `CREATE TABLE IF NOT EXISTS "token_routes" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "model_pattern" TEXT NOT NULL, "display_name" TEXT, "display_icon" TEXT, "model_mapping" TEXT, "decision_snapshot" TEXT, "decision_refreshed_at" TEXT, "enabled" BOOLEAN DEFAULT TRUE, "created_at" TEXT, "updated_at" TEXT)`, - `CREATE TABLE IF NOT EXISTS "route_channels" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "route_id" INTEGER NOT NULL REFERENCES "token_routes"("id") ON DELETE CASCADE, "account_id" INTEGER NOT NULL REFERENCES "accounts"("id") ON DELETE CASCADE, "token_id" INTEGER REFERENCES "account_tokens"("id") ON DELETE SET NULL, "source_model" TEXT, "priority" INTEGER DEFAULT 0, "weight" INTEGER DEFAULT 10, "enabled" BOOLEAN DEFAULT TRUE, "manual_override" BOOLEAN DEFAULT FALSE, "success_count" INTEGER DEFAULT 0, "fail_count" INTEGER DEFAULT 0, "total_latency_ms" INTEGER DEFAULT 0, "total_cost" DOUBLE PRECISION DEFAULT 0, "last_used_at" TEXT, "last_fail_at" TEXT, "cooldown_until" TEXT)`, - `CREATE TABLE IF NOT EXISTS "proxy_logs" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "route_id" INTEGER, "channel_id" INTEGER, "account_id" INTEGER, "model_requested" TEXT, "model_actual" TEXT, "status" TEXT, "http_status" INTEGER, "latency_ms" INTEGER, "prompt_tokens" INTEGER, "completion_tokens" INTEGER, "total_tokens" INTEGER, "estimated_cost" DOUBLE PRECISION, "billing_details" TEXT, "error_message" TEXT, "retry_count" INTEGER DEFAULT 0, "created_at" TEXT)`, - `CREATE TABLE IF NOT EXISTS "proxy_video_tasks" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "public_id" TEXT NOT NULL UNIQUE, "upstream_video_id" TEXT NOT NULL, "site_url" TEXT NOT NULL, "token_value" TEXT NOT NULL, "requested_model" TEXT, "actual_model" TEXT, "channel_id" INTEGER, "account_id" INTEGER, "status_snapshot" TEXT, "upstream_response_meta" TEXT, "last_upstream_status" INTEGER, "last_polled_at" TEXT, "created_at" TEXT, "updated_at" TEXT)`, - `CREATE TABLE IF NOT EXISTS "proxy_files" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "public_id" TEXT NOT NULL UNIQUE, "owner_type" TEXT NOT NULL, "owner_id" TEXT NOT NULL, "filename" TEXT NOT NULL, "mime_type" TEXT NOT NULL, "purpose" TEXT, "byte_size" INTEGER NOT NULL, "sha256" TEXT NOT NULL, "content_base64" TEXT NOT NULL, "created_at" TEXT, "updated_at" TEXT, "deleted_at" TEXT)`, - `CREATE TABLE IF NOT EXISTS "downstream_api_keys" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "name" TEXT NOT NULL, "key" TEXT NOT NULL UNIQUE, "description" TEXT, "enabled" BOOLEAN DEFAULT TRUE, "expires_at" TEXT, "max_cost" DOUBLE PRECISION, "used_cost" DOUBLE PRECISION DEFAULT 0, "max_requests" INTEGER, "used_requests" INTEGER DEFAULT 0, "supported_models" TEXT, "allowed_route_ids" TEXT, "site_weight_multipliers" TEXT, "last_used_at" TEXT, "created_at" TEXT, "updated_at" TEXT)`, - `CREATE TABLE IF NOT EXISTS "events" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "type" TEXT NOT NULL, "title" TEXT NOT NULL, "message" TEXT, "level" TEXT DEFAULT 'info', "read" BOOLEAN DEFAULT FALSE, "related_id" INTEGER, "related_type" TEXT, "created_at" TEXT)`, - `CREATE TABLE IF NOT EXISTS "settings" ("key" TEXT PRIMARY KEY, "value" TEXT)`, - ] - : client.dialect === 'mysql' - ? [ - `CREATE TABLE IF NOT EXISTS \`sites\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`name\` TEXT NOT NULL, \`url\` TEXT NOT NULL, \`external_checkin_url\` TEXT NULL, \`platform\` VARCHAR(64) NOT NULL, \`proxy_url\` TEXT NULL, \`use_system_proxy\` BOOLEAN DEFAULT FALSE, \`status\` VARCHAR(32) DEFAULT 'active', \`is_pinned\` BOOLEAN DEFAULT FALSE, \`sort_order\` INT DEFAULT 0, \`global_weight\` DOUBLE DEFAULT 1, \`api_key\` TEXT NULL, \`created_at\` TEXT NULL, \`updated_at\` TEXT NULL)`, - `CREATE TABLE IF NOT EXISTS \`accounts\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`site_id\` INT NOT NULL, \`username\` TEXT NULL, \`access_token\` TEXT NOT NULL, \`api_token\` TEXT NULL, \`balance\` DOUBLE DEFAULT 0, \`balance_used\` DOUBLE DEFAULT 0, \`quota\` DOUBLE DEFAULT 0, \`unit_cost\` DOUBLE NULL, \`value_score\` DOUBLE DEFAULT 0, \`status\` VARCHAR(32) DEFAULT 'active', \`is_pinned\` BOOLEAN DEFAULT FALSE, \`sort_order\` INT DEFAULT 0, \`checkin_enabled\` BOOLEAN DEFAULT TRUE, \`last_checkin_at\` TEXT NULL, \`last_balance_refresh\` TEXT NULL, \`extra_config\` TEXT NULL, \`created_at\` TEXT NULL, \`updated_at\` TEXT NULL, CONSTRAINT \`accounts_site_fk\` FOREIGN KEY (\`site_id\`) REFERENCES \`sites\`(\`id\`) ON DELETE CASCADE)`, - `CREATE TABLE IF NOT EXISTS \`account_tokens\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`account_id\` INT NOT NULL, \`name\` TEXT NOT NULL, \`token\` TEXT NOT NULL, \`token_group\` TEXT NULL, \`source\` VARCHAR(32) DEFAULT 'manual', \`enabled\` BOOLEAN DEFAULT TRUE, \`is_default\` BOOLEAN DEFAULT FALSE, \`created_at\` TEXT NULL, \`updated_at\` TEXT NULL, CONSTRAINT \`account_tokens_account_fk\` FOREIGN KEY (\`account_id\`) REFERENCES \`accounts\`(\`id\`) ON DELETE CASCADE)`, - `CREATE TABLE IF NOT EXISTS \`checkin_logs\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`account_id\` INT NOT NULL, \`status\` VARCHAR(32) NOT NULL, \`message\` TEXT NULL, \`reward\` TEXT NULL, \`created_at\` TEXT NULL, CONSTRAINT \`checkin_logs_account_fk\` FOREIGN KEY (\`account_id\`) REFERENCES \`accounts\`(\`id\`) ON DELETE CASCADE)`, - `CREATE TABLE IF NOT EXISTS \`model_availability\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`account_id\` INT NOT NULL, \`model_name\` TEXT NOT NULL, \`available\` BOOLEAN NULL, \`latency_ms\` INT NULL, \`checked_at\` TEXT NULL, CONSTRAINT \`model_availability_account_fk\` FOREIGN KEY (\`account_id\`) REFERENCES \`accounts\`(\`id\`) ON DELETE CASCADE)`, - `CREATE TABLE IF NOT EXISTS \`token_model_availability\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`token_id\` INT NOT NULL, \`model_name\` TEXT NOT NULL, \`available\` BOOLEAN NULL, \`latency_ms\` INT NULL, \`checked_at\` TEXT NULL, CONSTRAINT \`token_model_availability_token_fk\` FOREIGN KEY (\`token_id\`) REFERENCES \`account_tokens\`(\`id\`) ON DELETE CASCADE)`, - `CREATE TABLE IF NOT EXISTS \`token_routes\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`model_pattern\` TEXT NOT NULL, \`display_name\` TEXT NULL, \`display_icon\` TEXT NULL, \`model_mapping\` TEXT NULL, \`decision_snapshot\` TEXT NULL, \`decision_refreshed_at\` TEXT NULL, \`enabled\` BOOLEAN DEFAULT TRUE, \`created_at\` TEXT NULL, \`updated_at\` TEXT NULL)`, - `CREATE TABLE IF NOT EXISTS \`route_channels\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`route_id\` INT NOT NULL, \`account_id\` INT NOT NULL, \`token_id\` INT NULL, \`source_model\` TEXT NULL, \`priority\` INT DEFAULT 0, \`weight\` INT DEFAULT 10, \`enabled\` BOOLEAN DEFAULT TRUE, \`manual_override\` BOOLEAN DEFAULT FALSE, \`success_count\` INT DEFAULT 0, \`fail_count\` INT DEFAULT 0, \`total_latency_ms\` INT DEFAULT 0, \`total_cost\` DOUBLE DEFAULT 0, \`last_used_at\` TEXT NULL, \`last_fail_at\` TEXT NULL, \`cooldown_until\` TEXT NULL, CONSTRAINT \`route_channels_route_fk\` FOREIGN KEY (\`route_id\`) REFERENCES \`token_routes\`(\`id\`) ON DELETE CASCADE, CONSTRAINT \`route_channels_account_fk\` FOREIGN KEY (\`account_id\`) REFERENCES \`accounts\`(\`id\`) ON DELETE CASCADE, CONSTRAINT \`route_channels_token_fk\` FOREIGN KEY (\`token_id\`) REFERENCES \`account_tokens\`(\`id\`) ON DELETE SET NULL)`, - `CREATE TABLE IF NOT EXISTS \`proxy_logs\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`route_id\` INT NULL, \`channel_id\` INT NULL, \`account_id\` INT NULL, \`model_requested\` TEXT NULL, \`model_actual\` TEXT NULL, \`status\` VARCHAR(32) NULL, \`http_status\` INT NULL, \`latency_ms\` INT NULL, \`prompt_tokens\` INT NULL, \`completion_tokens\` INT NULL, \`total_tokens\` INT NULL, \`estimated_cost\` DOUBLE NULL, \`billing_details\` TEXT NULL, \`error_message\` TEXT NULL, \`retry_count\` INT DEFAULT 0, \`created_at\` TEXT NULL)`, - `CREATE TABLE IF NOT EXISTS \`proxy_video_tasks\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`public_id\` VARCHAR(191) NOT NULL UNIQUE, \`upstream_video_id\` TEXT NOT NULL, \`site_url\` TEXT NOT NULL, \`token_value\` TEXT NOT NULL, \`requested_model\` TEXT NULL, \`actual_model\` TEXT NULL, \`channel_id\` INT NULL, \`account_id\` INT NULL, \`status_snapshot\` TEXT NULL, \`upstream_response_meta\` TEXT NULL, \`last_upstream_status\` INT NULL, \`last_polled_at\` TEXT NULL, \`created_at\` TEXT NULL, \`updated_at\` TEXT NULL)`, - `CREATE TABLE IF NOT EXISTS \`proxy_files\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`public_id\` VARCHAR(191) NOT NULL UNIQUE, \`owner_type\` VARCHAR(64) NOT NULL, \`owner_id\` VARCHAR(191) NOT NULL, \`filename\` TEXT NOT NULL, \`mime_type\` VARCHAR(191) NOT NULL, \`purpose\` TEXT NULL, \`byte_size\` INT NOT NULL, \`sha256\` VARCHAR(191) NOT NULL, \`content_base64\` LONGTEXT NOT NULL, \`created_at\` TEXT NULL, \`updated_at\` TEXT NULL, \`deleted_at\` TEXT NULL)`, - `CREATE TABLE IF NOT EXISTS \`downstream_api_keys\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`name\` TEXT NOT NULL, \`key\` VARCHAR(191) NOT NULL UNIQUE, \`description\` TEXT NULL, \`enabled\` BOOLEAN DEFAULT TRUE, \`expires_at\` TEXT NULL, \`max_cost\` DOUBLE NULL, \`used_cost\` DOUBLE DEFAULT 0, \`max_requests\` INT NULL, \`used_requests\` INT DEFAULT 0, \`supported_models\` TEXT NULL, \`allowed_route_ids\` TEXT NULL, \`site_weight_multipliers\` TEXT NULL, \`last_used_at\` TEXT NULL, \`created_at\` TEXT NULL, \`updated_at\` TEXT NULL)`, - `CREATE TABLE IF NOT EXISTS \`events\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`type\` VARCHAR(32) NOT NULL, \`title\` TEXT NOT NULL, \`message\` TEXT NULL, \`level\` VARCHAR(16) DEFAULT 'info', \`read\` BOOLEAN DEFAULT FALSE, \`related_id\` INT NULL, \`related_type\` VARCHAR(32) NULL, \`created_at\` TEXT NULL)`, - `CREATE TABLE IF NOT EXISTS \`settings\` (\`key\` VARCHAR(191) PRIMARY KEY, \`value\` TEXT NULL)`, - ] - : [ - `CREATE TABLE IF NOT EXISTS "sites" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "name" TEXT NOT NULL, "url" TEXT NOT NULL, "external_checkin_url" TEXT, "platform" TEXT NOT NULL, "proxy_url" TEXT, "use_system_proxy" INTEGER DEFAULT 0, "status" TEXT DEFAULT 'active', "is_pinned" INTEGER DEFAULT 0, "sort_order" INTEGER DEFAULT 0, "global_weight" REAL DEFAULT 1, "api_key" TEXT, "created_at" TEXT, "updated_at" TEXT)`, - `CREATE TABLE IF NOT EXISTS "accounts" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "site_id" INTEGER NOT NULL REFERENCES "sites"("id") ON DELETE CASCADE, "username" TEXT, "access_token" TEXT NOT NULL, "api_token" TEXT, "balance" REAL DEFAULT 0, "balance_used" REAL DEFAULT 0, "quota" REAL DEFAULT 0, "unit_cost" REAL, "value_score" REAL DEFAULT 0, "status" TEXT DEFAULT 'active', "is_pinned" INTEGER DEFAULT 0, "sort_order" INTEGER DEFAULT 0, "checkin_enabled" INTEGER DEFAULT 1, "last_checkin_at" TEXT, "last_balance_refresh" TEXT, "extra_config" TEXT, "created_at" TEXT, "updated_at" TEXT)`, - `CREATE TABLE IF NOT EXISTS "account_tokens" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "account_id" INTEGER NOT NULL REFERENCES "accounts"("id") ON DELETE CASCADE, "name" TEXT NOT NULL, "token" TEXT NOT NULL, "token_group" TEXT, "source" TEXT DEFAULT 'manual', "enabled" INTEGER DEFAULT 1, "is_default" INTEGER DEFAULT 0, "created_at" TEXT, "updated_at" TEXT)`, - `CREATE TABLE IF NOT EXISTS "checkin_logs" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "account_id" INTEGER NOT NULL REFERENCES "accounts"("id") ON DELETE CASCADE, "status" TEXT NOT NULL, "message" TEXT, "reward" TEXT, "created_at" TEXT)`, - `CREATE TABLE IF NOT EXISTS "model_availability" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "account_id" INTEGER NOT NULL REFERENCES "accounts"("id") ON DELETE CASCADE, "model_name" TEXT NOT NULL, "available" INTEGER, "latency_ms" INTEGER, "checked_at" TEXT)`, - `CREATE TABLE IF NOT EXISTS "token_model_availability" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "token_id" INTEGER NOT NULL REFERENCES "account_tokens"("id") ON DELETE CASCADE, "model_name" TEXT NOT NULL, "available" INTEGER, "latency_ms" INTEGER, "checked_at" TEXT)`, - `CREATE TABLE IF NOT EXISTS "token_routes" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "model_pattern" TEXT NOT NULL, "display_name" TEXT, "display_icon" TEXT, "model_mapping" TEXT, "decision_snapshot" TEXT, "decision_refreshed_at" TEXT, "enabled" INTEGER DEFAULT 1, "created_at" TEXT, "updated_at" TEXT)`, - `CREATE TABLE IF NOT EXISTS "route_channels" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "route_id" INTEGER NOT NULL REFERENCES "token_routes"("id") ON DELETE CASCADE, "account_id" INTEGER NOT NULL REFERENCES "accounts"("id") ON DELETE CASCADE, "token_id" INTEGER REFERENCES "account_tokens"("id") ON DELETE SET NULL, "source_model" TEXT, "priority" INTEGER DEFAULT 0, "weight" INTEGER DEFAULT 10, "enabled" INTEGER DEFAULT 1, "manual_override" INTEGER DEFAULT 0, "success_count" INTEGER DEFAULT 0, "fail_count" INTEGER DEFAULT 0, "total_latency_ms" INTEGER DEFAULT 0, "total_cost" REAL DEFAULT 0, "last_used_at" TEXT, "last_fail_at" TEXT, "cooldown_until" TEXT)`, - `CREATE TABLE IF NOT EXISTS "proxy_logs" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "route_id" INTEGER, "channel_id" INTEGER, "account_id" INTEGER, "model_requested" TEXT, "model_actual" TEXT, "status" TEXT, "http_status" INTEGER, "latency_ms" INTEGER, "prompt_tokens" INTEGER, "completion_tokens" INTEGER, "total_tokens" INTEGER, "estimated_cost" REAL, "billing_details" TEXT, "error_message" TEXT, "retry_count" INTEGER DEFAULT 0, "created_at" TEXT)`, - `CREATE TABLE IF NOT EXISTS "proxy_video_tasks" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "public_id" TEXT NOT NULL UNIQUE, "upstream_video_id" TEXT NOT NULL, "site_url" TEXT NOT NULL, "token_value" TEXT NOT NULL, "requested_model" TEXT, "actual_model" TEXT, "channel_id" INTEGER, "account_id" INTEGER, "status_snapshot" TEXT, "upstream_response_meta" TEXT, "last_upstream_status" INTEGER, "last_polled_at" TEXT, "created_at" TEXT, "updated_at" TEXT)`, - `CREATE TABLE IF NOT EXISTS "proxy_files" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "public_id" TEXT NOT NULL UNIQUE, "owner_type" TEXT NOT NULL, "owner_id" TEXT NOT NULL, "filename" TEXT NOT NULL, "mime_type" TEXT NOT NULL, "purpose" TEXT, "byte_size" INTEGER NOT NULL, "sha256" TEXT NOT NULL, "content_base64" TEXT NOT NULL, "created_at" TEXT, "updated_at" TEXT, "deleted_at" TEXT)`, - `CREATE TABLE IF NOT EXISTS "downstream_api_keys" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "name" TEXT NOT NULL, "key" TEXT NOT NULL UNIQUE, "description" TEXT, "enabled" INTEGER DEFAULT 1, "expires_at" TEXT, "max_cost" REAL, "used_cost" REAL DEFAULT 0, "max_requests" INTEGER, "used_requests" INTEGER DEFAULT 0, "supported_models" TEXT, "allowed_route_ids" TEXT, "site_weight_multipliers" TEXT, "last_used_at" TEXT, "created_at" TEXT, "updated_at" TEXT)`, - `CREATE TABLE IF NOT EXISTS "events" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "type" TEXT NOT NULL, "title" TEXT NOT NULL, "message" TEXT, "level" TEXT DEFAULT 'info', "read" INTEGER DEFAULT 0, "related_id" INTEGER, "related_type" TEXT, "created_at" TEXT)`, - `CREATE TABLE IF NOT EXISTS "settings" ("key" TEXT PRIMARY KEY, "value" TEXT)`, - ]; - - for (const sqlText of statements) { - await client.execute(sqlText); - } - - await ensureSiteSchemaCompatibility(createSiteSchemaInspector(client)); - await ensureRouteGroupingSchemaCompatibility(createSiteSchemaInspector(client)); - await ensureProxyFileSchemaCompatibility(createSiteSchemaInspector(client)); + return createRuntimeSchemaClient(input); } async function ensureTargetState(client: SqlClient, overwrite: boolean): Promise { @@ -453,12 +288,17 @@ async function ensureTargetState(client: SqlClient, overwrite: boolean): Promise async function clearTargetData(client: SqlClient): Promise { const tables = [ 'route_channels', + 'route_group_sources', 'token_model_availability', 'model_availability', 'checkin_logs', 'proxy_logs', + 'proxy_video_tasks', + 'proxy_files', 'account_tokens', 'accounts', + 'site_announcements', + 'site_disabled_models', 'token_routes', 'sites', 'downstream_api_keys', @@ -470,13 +310,16 @@ async function clearTargetData(client: SqlClient): Promise { } } -function buildStatements(snapshot: BackupSnapshot): InsertStatement[] { +function buildStatements( + snapshot: BackupSnapshot, + contract: SchemaContractShape = schemaContract, +): InsertStatement[] { const statements: InsertStatement[] = []; for (const row of snapshot.accounts.sites) { statements.push({ table: 'sites', - columns: ['id', 'name', 'url', 'external_checkin_url', 'platform', 'proxy_url', 'use_system_proxy', 'status', 'is_pinned', 'sort_order', 'global_weight', 'api_key', 'created_at', 'updated_at'], + columns: ['id', 'name', 'url', 'external_checkin_url', 'platform', 'proxy_url', 'use_system_proxy', 'custom_headers', 'status', 'is_pinned', 'sort_order', 'global_weight', 'api_key', 'created_at', 'updated_at'], values: [ asNumber(row.id, 0), asNullableString(row.name), @@ -485,6 +328,7 @@ function buildStatements(snapshot: BackupSnapshot): InsertStatement[] { asNullableString(row.platform), asNullableString(row.proxyUrl), asBoolean(row.useSystemProxy, false), + serializeColumnValue('sites', 'custom_headers', row.customHeaders, contract), asNullableString(row.status) ?? 'active', asBoolean(row.isPinned, false), asNumber(row.sortOrder, 0), @@ -496,6 +340,63 @@ function buildStatements(snapshot: BackupSnapshot): InsertStatement[] { }); } + for (const row of snapshot.accounts.siteDisabledModels) { + statements.push({ + table: 'site_disabled_models', + columns: ['id', 'site_id', 'model_name', 'created_at'], + values: [ + asNumber(row.id, 0), + asNumber(row.siteId, 0), + asNullableString(row.modelName), + asNullableString(row.createdAt), + ], + }); + } + + for (const row of snapshot.accounts.siteAnnouncements || []) { + statements.push({ + table: 'site_announcements', + columns: [ + 'id', + 'site_id', + 'platform', + 'source_key', + 'title', + 'content', + 'level', + 'source_url', + 'starts_at', + 'ends_at', + 'upstream_created_at', + 'upstream_updated_at', + 'first_seen_at', + 'last_seen_at', + 'read_at', + 'dismissed_at', + 'raw_payload', + ], + values: [ + asNumber(row.id, 0), + asNumber(row.siteId, 0), + asNullableString(row.platform), + asNullableString(row.sourceKey), + asNullableString(row.title), + asNullableString(row.content), + asNullableString(row.level) ?? 'info', + asNullableString(row.sourceUrl), + asNullableString(row.startsAt), + asNullableString(row.endsAt), + asNullableString(row.upstreamCreatedAt), + asNullableString(row.upstreamUpdatedAt), + asNullableString(row.firstSeenAt), + asNullableString(row.lastSeenAt), + asNullableString(row.readAt), + asNullableString(row.dismissedAt), + asNullableString(row.rawPayload), + ], + }); + } + for (const row of snapshot.accounts.accounts) { statements.push({ table: 'accounts', @@ -517,7 +418,7 @@ function buildStatements(snapshot: BackupSnapshot): InsertStatement[] { asBoolean(row.checkinEnabled, true), asNullableString(row.lastCheckinAt), asNullableString(row.lastBalanceRefresh), - asNullableString(row.extraConfig), + serializeColumnValue('accounts', 'extra_config', row.extraConfig, contract), asNullableString(row.createdAt), asNullableString(row.updatedAt), ], @@ -527,13 +428,14 @@ function buildStatements(snapshot: BackupSnapshot): InsertStatement[] { for (const row of snapshot.accounts.accountTokens) { statements.push({ table: 'account_tokens', - columns: ['id', 'account_id', 'name', 'token', 'token_group', 'source', 'enabled', 'is_default', 'created_at', 'updated_at'], + columns: ['id', 'account_id', 'name', 'token', 'token_group', 'value_status', 'source', 'enabled', 'is_default', 'created_at', 'updated_at'], values: [ asNumber(row.id, 0), asNumber(row.accountId, 0), asNullableString(row.name), asNullableString(row.token), asNullableString(row.tokenGroup), + asNullableString((row as { valueStatus?: unknown }).valueStatus) ?? 'ready', asNullableString(row.source) ?? 'manual', asBoolean(row.enabled, true), asBoolean(row.isDefault, false), @@ -591,15 +493,17 @@ function buildStatements(snapshot: BackupSnapshot): InsertStatement[] { for (const row of snapshot.accounts.tokenRoutes) { statements.push({ table: 'token_routes', - columns: ['id', 'model_pattern', 'display_name', 'display_icon', 'model_mapping', 'decision_snapshot', 'decision_refreshed_at', 'enabled', 'created_at', 'updated_at'], + columns: ['id', 'model_pattern', 'display_name', 'display_icon', 'model_mapping', 'route_mode', 'decision_snapshot', 'decision_refreshed_at', 'routing_strategy', 'enabled', 'created_at', 'updated_at'], values: [ asNumber(row.id, 0), asNullableString(row.modelPattern), asNullableString(row.displayName), asNullableString(row.displayIcon), - asNullableString(row.modelMapping), - asNullableString(row.decisionSnapshot), + serializeColumnValue('token_routes', 'model_mapping', row.modelMapping, contract), + asNullableString(row.routeMode) ?? 'pattern', + serializeColumnValue('token_routes', 'decision_snapshot', row.decisionSnapshot, contract), asNullableString(row.decisionRefreshedAt), + asNullableString(row.routingStrategy), asBoolean(row.enabled, true), asNullableString(row.createdAt), asNullableString(row.updatedAt), @@ -610,7 +514,7 @@ function buildStatements(snapshot: BackupSnapshot): InsertStatement[] { for (const row of snapshot.accounts.routeChannels) { statements.push({ table: 'route_channels', - columns: ['id', 'route_id', 'account_id', 'token_id', 'source_model', 'priority', 'weight', 'enabled', 'manual_override', 'success_count', 'fail_count', 'total_latency_ms', 'total_cost', 'last_used_at', 'last_fail_at', 'cooldown_until'], + columns: ['id', 'route_id', 'account_id', 'token_id', 'source_model', 'priority', 'weight', 'enabled', 'manual_override', 'success_count', 'fail_count', 'total_latency_ms', 'total_cost', 'last_used_at', 'last_selected_at', 'last_fail_at', 'consecutive_fail_count', 'cooldown_level', 'cooldown_until'], values: [ asNumber(row.id, 0), asNumber(row.routeId, 0), @@ -626,21 +530,37 @@ function buildStatements(snapshot: BackupSnapshot): InsertStatement[] { asNumber(row.totalLatencyMs, 0), asNumber(row.totalCost, 0), asNullableString(row.lastUsedAt), + asNullableString(row.lastSelectedAt), asNullableString(row.lastFailAt), + asNumber(row.consecutiveFailCount, 0), + asNumber(row.cooldownLevel, 0), asNullableString(row.cooldownUntil), ], }); } + for (const row of (snapshot.accounts.routeGroupSources || [])) { + statements.push({ + table: 'route_group_sources', + columns: ['id', 'group_route_id', 'source_route_id'], + values: [ + asNumber(row.id, 0), + asNumber(row.groupRouteId, 0), + asNumber(row.sourceRouteId, 0), + ], + }); + } + for (const row of snapshot.accounts.proxyLogs) { statements.push({ table: 'proxy_logs', - columns: ['id', 'route_id', 'channel_id', 'account_id', 'model_requested', 'model_actual', 'status', 'http_status', 'latency_ms', 'prompt_tokens', 'completion_tokens', 'total_tokens', 'estimated_cost', 'billing_details', 'error_message', 'retry_count', 'created_at'], + columns: ['id', 'route_id', 'channel_id', 'account_id', 'downstream_api_key_id', 'model_requested', 'model_actual', 'status', 'http_status', 'latency_ms', 'prompt_tokens', 'completion_tokens', 'total_tokens', 'estimated_cost', 'billing_details', 'error_message', 'retry_count', 'created_at'], values: [ asNumber(row.id, 0), asNumber(row.routeId, null), asNumber(row.channelId, null), asNumber(row.accountId, null), + asNumber((row as any).downstreamApiKeyId ?? (row as any).downstream_api_key_id, null), asNullableString(row.modelRequested), asNullableString(row.modelActual), asNullableString(row.status), @@ -650,7 +570,7 @@ function buildStatements(snapshot: BackupSnapshot): InsertStatement[] { asNumber(row.completionTokens, null), asNumber(row.totalTokens, null), asNumber(row.estimatedCost, null), - asNullableString(row.billingDetails), + serializeColumnValue('proxy_logs', 'billing_details', row.billingDetails, contract), asNullableString(row.errorMessage), asNumber(row.retryCount, 0), asNullableString(row.createdAt), @@ -658,6 +578,52 @@ function buildStatements(snapshot: BackupSnapshot): InsertStatement[] { }); } + for (const row of snapshot.accounts.proxyVideoTasks) { + statements.push({ + table: 'proxy_video_tasks', + columns: ['id', 'public_id', 'upstream_video_id', 'site_url', 'token_value', 'requested_model', 'actual_model', 'channel_id', 'account_id', 'status_snapshot', 'upstream_response_meta', 'last_upstream_status', 'last_polled_at', 'created_at', 'updated_at'], + values: [ + asNumber(row.id, 0), + asNullableString(row.publicId), + asNullableString(row.upstreamVideoId), + asNullableString(row.siteUrl), + asNullableString(row.tokenValue), + asNullableString(row.requestedModel), + asNullableString(row.actualModel), + asNumber(row.channelId, null), + asNumber(row.accountId, null), + serializeColumnValue('proxy_video_tasks', 'status_snapshot', row.statusSnapshot, contract), + serializeColumnValue('proxy_video_tasks', 'upstream_response_meta', row.upstreamResponseMeta, contract), + asNumber(row.lastUpstreamStatus, null), + asNullableString(row.lastPolledAt), + asNullableString(row.createdAt), + asNullableString(row.updatedAt), + ], + }); + } + + for (const row of snapshot.accounts.proxyFiles) { + statements.push({ + table: 'proxy_files', + columns: ['id', 'public_id', 'owner_type', 'owner_id', 'filename', 'mime_type', 'purpose', 'byte_size', 'sha256', 'content_base64', 'created_at', 'updated_at', 'deleted_at'], + values: [ + asNumber(row.id, 0), + asNullableString(row.publicId), + asNullableString(row.ownerType), + asNullableString(row.ownerId), + asNullableString(row.filename), + asNullableString(row.mimeType), + asNullableString(row.purpose), + asNumber(row.byteSize, 0), + asNullableString(row.sha256), + asNullableString(row.contentBase64), + asNullableString(row.createdAt), + asNullableString(row.updatedAt), + asNullableString(row.deletedAt), + ], + }); + } + for (const row of snapshot.accounts.downstreamApiKeys) { statements.push({ table: 'downstream_api_keys', @@ -673,9 +639,9 @@ function buildStatements(snapshot: BackupSnapshot): InsertStatement[] { asNumber(row.usedCost, 0), asNumber(row.maxRequests, null), asNumber(row.usedRequests, 0), - asNullableString(row.supportedModels), - asNullableString(row.allowedRouteIds), - asNullableString(row.siteWeightMultipliers), + serializeColumnValue('downstream_api_keys', 'supported_models', row.supportedModels, contract), + serializeColumnValue('downstream_api_keys', 'allowed_route_ids', row.allowedRouteIds, contract), + serializeColumnValue('downstream_api_keys', 'site_weight_multipliers', row.siteWeightMultipliers, contract), asNullableString(row.lastUsedAt), asNullableString(row.createdAt), asNullableString(row.updatedAt), @@ -702,6 +668,9 @@ function buildStatements(snapshot: BackupSnapshot): InsertStatement[] { } for (const row of snapshot.preferences.settings) { + if (RUNTIME_DATABASE_SETTING_KEYS.has(row.key)) { + continue; + } statements.push({ table: 'settings', columns: ['key', 'value'], @@ -739,6 +708,8 @@ async function syncPostgresSequences(client: SqlClient): Promise { if (client.dialect !== 'postgres') return; const tables = [ 'sites', + 'site_announcements', + 'site_disabled_models', 'accounts', 'account_tokens', 'checkin_logs', @@ -746,12 +717,29 @@ async function syncPostgresSequences(client: SqlClient): Promise { 'token_model_availability', 'token_routes', 'route_channels', + 'route_group_sources', 'proxy_logs', + 'proxy_video_tasks', + 'proxy_files', 'downstream_api_keys', 'events', ]; for (const table of tables) { - await client.execute(`SELECT setval(pg_get_serial_sequence('${table}', 'id'), COALESCE((SELECT MAX(id) FROM "${table}"), 1), TRUE)`); + await client.execute(`SELECT setval(pg_get_serial_sequence('${table}', 'id'), COALESCE((SELECT MAX(id) FROM "${table}"), 1), TRUE)`); + } + } + +export async function bootstrapRuntimeDatabaseSchema(input: Pick): Promise { + const client = await createClient({ + dialect: input.dialect, + connectionString: input.connectionString, + overwrite: true, + ssl: input.ssl, + }); + try { + await ensureRuntimeDatabaseSchema(client); + } finally { + await client.close(); } } @@ -761,7 +749,7 @@ export async function migrateCurrentDatabase(input: DatabaseMigrationInput): Pro const client = await createClient(normalized); try { - await ensureSchema(client); + await ensureRuntimeDatabaseSchema(client); await ensureTargetState(client, normalized.overwrite); await client.begin(); @@ -788,6 +776,8 @@ export async function migrateCurrentDatabase(input: DatabaseMigrationInput): Pro timestamp: snapshot.timestamp, rows: { sites: snapshot.accounts.sites.length, + siteAnnouncements: snapshot.accounts.siteAnnouncements.length, + siteDisabledModels: snapshot.accounts.siteDisabledModels.length, accounts: snapshot.accounts.accounts.length, accountTokens: snapshot.accounts.accountTokens.length, tokenRoutes: snapshot.accounts.tokenRoutes.length, @@ -796,9 +786,12 @@ export async function migrateCurrentDatabase(input: DatabaseMigrationInput): Pro modelAvailability: snapshot.accounts.modelAvailability.length, tokenModelAvailability: snapshot.accounts.tokenModelAvailability.length, proxyLogs: snapshot.accounts.proxyLogs.length, + proxyVideoTasks: snapshot.accounts.proxyVideoTasks.length, + proxyFiles: snapshot.accounts.proxyFiles.length, downstreamApiKeys: snapshot.accounts.downstreamApiKeys.length, events: snapshot.accounts.events.length, settings: snapshot.preferences.settings.length, + routeGroupSources: snapshot.accounts.routeGroupSources.length, }, }; } @@ -819,6 +812,7 @@ export async function testDatabaseConnection(input: DatabaseMigrationInput): Pro } export const __databaseMigrationServiceTestUtils = { - ensureSchema, + ensureSchema: ensureRuntimeDatabaseSchema, buildStatements, + serializeColumnValue, }; diff --git a/src/server/services/defaultSiteSeedService.ts b/src/server/services/defaultSiteSeedService.ts index d7329fb7..c012b148 100644 --- a/src/server/services/defaultSiteSeedService.ts +++ b/src/server/services/defaultSiteSeedService.ts @@ -1,5 +1,6 @@ import { eq } from 'drizzle-orm'; import { db, schema } from '../db/index.js'; +import { upsertSetting } from '../db/upsertSetting.js'; export const DEFAULT_SITE_SEED_SETTING_KEY = 'default_site_seed_v1'; @@ -53,16 +54,7 @@ type SeedSummary = { }; async function writeSeedMarker(tx: typeof db) { - await tx.insert(schema.settings) - .values({ - key: DEFAULT_SITE_SEED_SETTING_KEY, - value: JSON.stringify(true), - }) - .onConflictDoUpdate({ - target: schema.settings.key, - set: { value: JSON.stringify(true) }, - }) - .run(); + await upsertSetting(DEFAULT_SITE_SEED_SETTING_KEY, true, tx); } export async function ensureDefaultSitesSeeded(): Promise { diff --git a/src/server/services/downstreamApiKeyService.test.ts b/src/server/services/downstreamApiKeyService.test.ts index 7582b99f..c2c3bb12 100644 --- a/src/server/services/downstreamApiKeyService.test.ts +++ b/src/server/services/downstreamApiKeyService.test.ts @@ -116,6 +116,12 @@ describe('downstreamApiKeyService', () => { expect(service.isModelAllowedByPolicy('gemini-2.0-flash', result.policy)).toBe(false); }); + it('keeps all explicitly selected supported models when list exceeds 200 items', () => { + const selectedModels = Array.from({ length: 260 }, (_, index) => `model-${String(index + 1).padStart(3, '0')}`); + + expect(service.normalizeSupportedModelsInput(selectedModels)).toEqual(selectedModels); + }); + it('treats selected groups as additional allowed exposed route scope (union semantics)', async () => { const claudeGroup = await db.insert(schema.tokenRoutes).values({ modelPattern: 're:^claude-(opus|sonnet)-4-6$', @@ -136,6 +142,18 @@ describe('downstreamApiKeyService', () => { expect(await service.isModelAllowedByPolicyOrAllowedRoutes('gemini-2.0-flash', policy)).toBe(false); }); + it('denies all models when both supportedModels and allowedRouteIds are empty', async () => { + const policy = { + supportedModels: [], + allowedRouteIds: [], + siteWeightMultipliers: {}, + denyAllWhenEmpty: true, + }; + + expect(await service.isModelAllowedByPolicyOrAllowedRoutes('gpt-4o-mini', policy)).toBe(false); + expect(await service.isModelAllowedByPolicyOrAllowedRoutes('claude-opus-4-6', policy)).toBe(false); + }); + it('authorizes by selected group model pattern only, not arbitrary internal models', async () => { const virtualModelGroup = await db.insert(schema.tokenRoutes).values({ modelPattern: 'claude-opus-4-6', diff --git a/src/server/services/downstreamApiKeyService.ts b/src/server/services/downstreamApiKeyService.ts index ebba7d41..88068dba 100644 --- a/src/server/services/downstreamApiKeyService.ts +++ b/src/server/services/downstreamApiKeyService.ts @@ -12,6 +12,8 @@ export type DownstreamApiKeyPolicyView = { key: string; keyMasked: string; description: string | null; + groupName: string | null; + tags: string[]; enabled: boolean; expiresAt: string | null; maxCost: number | null; @@ -88,29 +90,67 @@ function normalizePositiveIntegerOrNull(value: unknown): number | null { return normalized; } -function parseJson(value: string | null | undefined): unknown { +function parseJson(value: unknown): unknown { + if (value === null || value === undefined) return null; + + // PostgreSQL JSONB columns are returned as parsed objects/arrays by the pg driver + if (typeof value === 'object') { + return value; + } + + // SQLite TEXT columns store JSON as strings that need parsing + if (typeof value === 'string') { + if (value === '') return null; + try { + return JSON.parse(value); + } catch { + return null; + } + } + + return null; +} + +export function normalizeGroupNameInput(input: unknown): string | null { + if (typeof input !== 'string') return null; + const value = input.trim(); if (!value) return null; - try { - return JSON.parse(value); - } catch { - return null; + return value.slice(0, 64); +} + +export function normalizeTagsInput(input: unknown): string[] { + const rawValues = Array.isArray(input) + ? input + : (typeof input === 'string' ? input.split(/[\r\n,,]+/g) : []); + + const tags: string[] = []; + const seen = new Set(); + for (const raw of rawValues) { + const value = String(raw || '').trim(); + if (!value) continue; + const normalized = value.slice(0, 32); + const dedupeKey = normalized.toLowerCase(); + if (seen.has(dedupeKey)) continue; + seen.add(dedupeKey); + tags.push(normalized); + if (tags.length >= 20) break; } + + return tags; } export function normalizeSupportedModelsInput(input: unknown): string[] { if (Array.isArray(input)) { return input .map((item) => (typeof item === 'string' ? item.trim() : '')) - .filter((item, index, arr) => item.length > 0 && arr.indexOf(item) === index) - .slice(0, 200); + .filter((item, index, arr) => item.length > 0 && arr.indexOf(item) === index); } if (typeof input === 'string') { return input .split(/\r?\n|,/g) .map((item) => item.trim()) - .filter((item, index, arr) => item.length > 0 && arr.indexOf(item) === index) - .slice(0, 200); + .filter((item, index, arr) => item.length > 0 && arr.indexOf(item) === index); } return []; @@ -204,7 +244,7 @@ export async function isModelAllowedByPolicyOrAllowedRoutes(model: string, polic const hasPatternRules = patterns.length > 0; const hasRouteRules = allowedRouteIds.length > 0; - if (!hasPatternRules && !hasRouteRules) return true; + if (!hasPatternRules && !hasRouteRules) return policy.denyAllWhenEmpty === true ? false : true; if (hasPatternRules && patterns.some((pattern) => matchesDownstreamModelPattern(model, pattern))) { return true; @@ -226,6 +266,8 @@ export function toDownstreamApiKeyPolicyView(row: DownstreamApiKeyRow): Downstre key: row.key, keyMasked: maskSecret(row.key), description: row.description || null, + groupName: normalizeGroupNameInput(row.groupName), + tags: normalizeTagsInput(parseJson(row.tags)), enabled: !!row.enabled, expiresAt: row.expiresAt || null, maxCost: row.maxCost ?? null, @@ -246,6 +288,7 @@ export function toPolicyFromView(view: Pick; + denyAllWhenEmpty?: boolean; } export const EMPTY_DOWNSTREAM_ROUTING_POLICY: DownstreamRoutingPolicy = { diff --git a/src/server/services/factoryResetService.test.ts b/src/server/services/factoryResetService.test.ts index afda64ab..5bfb60c8 100644 --- a/src/server/services/factoryResetService.test.ts +++ b/src/server/services/factoryResetService.test.ts @@ -54,7 +54,7 @@ describe('factoryResetService', () => { delete process.env.DATA_DIR; }); - it('clears current active data before switching runtime back to sqlite', async () => { + it('clears current active data while preserving external runtime connectivity', async () => { await db.insert(schema.sites).values({ name: 'External Runtime Site', url: 'https://external.example.com', @@ -81,10 +81,17 @@ describe('factoryResetService', () => { ensureDefaultSitesSeeded, }); - expect(switchRuntimeDatabase).toHaveBeenCalledWith('sqlite', '', false); - expect(runSqliteMigrations).toHaveBeenCalledTimes(1); + expect(switchRuntimeDatabase).toHaveBeenCalledWith('postgres', 'postgres://user:pass@127.0.0.1:5432/metapi', true); + expect(runSqliteMigrations).not.toHaveBeenCalled(); expect(ensureDefaultSitesSeeded).toHaveBeenCalledTimes(1); expect(await db.select().from(schema.sites).all()).toHaveLength(0); - expect(await db.select().from(schema.settings).all()).toHaveLength(0); + expect(await db.select().from(schema.settings).all()).toEqual([ + { key: 'auth_token', value: JSON.stringify('external-reset-token') }, + { key: 'proxy_token', value: JSON.stringify('change-me-proxy-sk-token') }, + { key: 'system_proxy_url', value: JSON.stringify('') }, + { key: 'db_type', value: JSON.stringify('postgres') }, + { key: 'db_url', value: JSON.stringify('postgres://user:pass@127.0.0.1:5432/metapi') }, + { key: 'db_ssl', value: JSON.stringify(true) }, + ]); }); }); diff --git a/src/server/services/factoryResetService.ts b/src/server/services/factoryResetService.ts index d7094b83..1d602850 100644 --- a/src/server/services/factoryResetService.ts +++ b/src/server/services/factoryResetService.ts @@ -1,7 +1,9 @@ import { buildConfig, config } from '../config.js'; import { db, schema, switchRuntimeDatabase } from '../db/index.js'; -import { updateBalanceRefreshCron, updateCheckinCron } from './checkinScheduler.js'; +import { upsertSetting } from '../db/upsertSetting.js'; +import { updateBalanceRefreshCron, updateCheckinCron, updateLogCleanupSettings } from './checkinScheduler.js'; import { ensureDefaultSitesSeeded } from './defaultSiteSeedService.js'; +import { startProxyLogRetentionService } from './proxyLogRetentionService.js'; import { invalidateSiteProxyCache } from './siteProxy.js'; export const FACTORY_RESET_ADMIN_TOKEN = 'change-me-admin-token'; @@ -12,6 +14,15 @@ type FactoryResetDependencies = { ensureDefaultSitesSeeded?: typeof ensureDefaultSitesSeeded; }; +type PreservedInfrastructureState = { + authToken: string; + proxyToken: string; + systemProxyUrl: string; + dbType: 'sqlite' | 'mysql' | 'postgres'; + dbUrl: string; + dbSsl: boolean; +}; + async function clearAllBusinessData() { await db.transaction(async (tx) => { await tx.delete(schema.routeChannels).run(); @@ -31,18 +42,65 @@ async function clearAllBusinessData() { }); } -function resetRuntimeConfigToInitialState() { +function captureInfrastructureState(): PreservedInfrastructureState { + return { + authToken: config.authToken, + proxyToken: config.proxyToken, + systemProxyUrl: config.systemProxyUrl, + dbType: config.dbType, + dbUrl: config.dbUrl, + dbSsl: config.dbSsl, + }; +} + +function shouldPreserveExternalRuntime(state: PreservedInfrastructureState): boolean { + return state.dbType !== 'sqlite' && !!state.dbUrl.trim(); +} + +function resetRuntimeConfigToInitialState(preserved: PreservedInfrastructureState) { const baseline = buildConfig(process.env); Object.assign(config, baseline); - config.authToken = FACTORY_RESET_ADMIN_TOKEN; - config.dbType = 'sqlite'; - config.dbUrl = ''; - config.dbSsl = false; + config.authToken = preserved.authToken || baseline.authToken || FACTORY_RESET_ADMIN_TOKEN; + config.proxyToken = preserved.proxyToken || baseline.proxyToken; + config.systemProxyUrl = preserved.systemProxyUrl || baseline.systemProxyUrl; + if (shouldPreserveExternalRuntime(preserved)) { + config.dbType = preserved.dbType; + config.dbUrl = preserved.dbUrl; + config.dbSsl = preserved.dbSsl; + } + config.logCleanupConfigured = false; + config.logCleanupUsageLogsEnabled = config.proxyLogRetentionDays > 0; + config.logCleanupProgramLogsEnabled = false; + config.logCleanupRetentionDays = Math.max(1, Math.trunc(config.proxyLogRetentionDays || config.logCleanupRetentionDays || 30)); updateCheckinCron(config.checkinCron); updateBalanceRefreshCron(config.balanceRefreshCron); + updateLogCleanupSettings({ + cronExpr: config.logCleanupCron, + usageLogsEnabled: config.logCleanupUsageLogsEnabled, + programLogsEnabled: config.logCleanupProgramLogsEnabled, + retentionDays: config.logCleanupRetentionDays, + }); + startProxyLogRetentionService(); invalidateSiteProxyCache(); } +async function restoreInfrastructureSettings(preserved: PreservedInfrastructureState): Promise { + await upsertSetting('auth_token', preserved.authToken || FACTORY_RESET_ADMIN_TOKEN); + await upsertSetting('proxy_token', preserved.proxyToken); + await upsertSetting('system_proxy_url', preserved.systemProxyUrl); + + if (shouldPreserveExternalRuntime(preserved)) { + await upsertSetting('db_type', preserved.dbType); + await upsertSetting('db_url', preserved.dbUrl); + await upsertSetting('db_ssl', preserved.dbSsl); + return; + } + + await upsertSetting('db_type', config.dbType); + await upsertSetting('db_url', config.dbUrl); + await upsertSetting('db_ssl', config.dbSsl); +} + async function runDefaultSqliteMigrations() { const migrateModule = await import('../db/migrate.js'); migrateModule.runSqliteMigrations(); @@ -52,11 +110,15 @@ export async function performFactoryReset(deps: FactoryResetDependencies = {}): const switchRuntimeDatabaseImpl = deps.switchRuntimeDatabase ?? switchRuntimeDatabase; const runSqliteMigrationsImpl = deps.runSqliteMigrations ?? runDefaultSqliteMigrations; const ensureDefaultSitesSeededImpl = deps.ensureDefaultSitesSeeded ?? ensureDefaultSitesSeeded; + const preserved = captureInfrastructureState(); await clearAllBusinessData(); - resetRuntimeConfigToInitialState(); - await switchRuntimeDatabaseImpl('sqlite', '', false); - await runSqliteMigrationsImpl(); + resetRuntimeConfigToInitialState(preserved); + await switchRuntimeDatabaseImpl(config.dbType, config.dbUrl, config.dbSsl); + if (config.dbType === 'sqlite') { + await runSqliteMigrationsImpl(); + } await clearAllBusinessData(); + await restoreInfrastructureSettings(preserved); await ensureDefaultSitesSeededImpl(); } diff --git a/src/server/services/localTimeService.ts b/src/server/services/localTimeService.ts index b5111a37..3572c423 100644 --- a/src/server/services/localTimeService.ts +++ b/src/server/services/localTimeService.ts @@ -1,5 +1,7 @@ const DAY_MS = 24 * 60 * 60 * 1000; +export type StoredUtcDateTimeInput = string | number | Date | null | undefined; + function pad2(value: number): string { return String(value).padStart(2, '0'); } @@ -20,11 +22,38 @@ export function formatUtcSqlDateTime(value: Date): string { return `${value.getUTCFullYear()}-${pad2(value.getUTCMonth() + 1)}-${pad2(value.getUTCDate())} ${pad2(value.getUTCHours())}:${pad2(value.getUTCMinutes())}:${pad2(value.getUTCSeconds())}`; } -export function parseStoredUtcDateTime(raw: string | null | undefined): Date | null { - if (!raw) return null; +function parseEpochDateTime(raw: number): Date | null { + if (!Number.isFinite(raw)) return null; + if (raw > 1_000_000_000_000) { + const parsed = new Date(Math.round(raw)); + return Number.isNaN(parsed.getTime()) ? null : parsed; + } + if (raw > 1_000_000_000) { + const parsed = new Date(Math.round(raw * 1000)); + return Number.isNaN(parsed.getTime()) ? null : parsed; + } + return null; +} + +export function parseStoredUtcDateTime(raw: StoredUtcDateTimeInput): Date | null { + if (raw == null) return null; + if (raw instanceof Date) { + return Number.isNaN(raw.getTime()) ? null : new Date(raw.getTime()); + } + if (typeof raw === 'number') { + return parseEpochDateTime(raw); + } + if (typeof raw !== 'string') return null; + const text = raw.trim(); if (!text) return null; + const numeric = Number(text); + if (Number.isFinite(numeric)) { + const parsedNumeric = parseEpochDateTime(numeric); + if (parsedNumeric) return parsedNumeric; + } + let parsed: Date; if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(text)) { parsed = new Date(`${text.replace(' ', 'T')}Z`); @@ -36,7 +65,7 @@ export function parseStoredUtcDateTime(raw: string | null | undefined): Date | n return parsed; } -export function toLocalDayKeyFromStoredUtc(raw: string | null | undefined): string | null { +export function toLocalDayKeyFromStoredUtc(raw: StoredUtcDateTimeInput): string | null { const parsed = parseStoredUtcDateTime(raw); if (!parsed) return null; return formatLocalDate(parsed); diff --git a/src/server/services/logCleanupService.test.ts b/src/server/services/logCleanupService.test.ts new file mode 100644 index 00000000..ac032e4f --- /dev/null +++ b/src/server/services/logCleanupService.test.ts @@ -0,0 +1,156 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; +import { mkdtempSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +type DbModule = typeof import('../db/index.js'); +type ConfigModule = typeof import('../config.js'); +type CleanupModule = typeof import('./logCleanupService.js'); + +describe('logCleanupService', () => { + let db: DbModule['db']; + let schema: DbModule['schema']; + let config: ConfigModule['config']; + let cleanupConfiguredLogs: CleanupModule['cleanupConfiguredLogs']; + let dataDir = ''; + let originalConfig: { + logCleanupUsageLogsEnabled: boolean; + logCleanupProgramLogsEnabled: boolean; + logCleanupRetentionDays: number; + }; + + beforeAll(async () => { + dataDir = mkdtempSync(join(tmpdir(), 'metapi-log-cleanup-')); + process.env.DATA_DIR = dataDir; + + await import('../db/migrate.js'); + const dbModule = await import('../db/index.js'); + const configModule = await import('../config.js'); + const cleanupModule = await import('./logCleanupService.js'); + db = dbModule.db; + schema = dbModule.schema; + config = configModule.config; + cleanupConfiguredLogs = cleanupModule.cleanupConfiguredLogs; + originalConfig = { + logCleanupUsageLogsEnabled: config.logCleanupUsageLogsEnabled, + logCleanupProgramLogsEnabled: config.logCleanupProgramLogsEnabled, + logCleanupRetentionDays: config.logCleanupRetentionDays, + }; + }); + + beforeEach(async () => { + await db.delete(schema.events).run(); + await db.delete(schema.proxyLogs).run(); + await db.delete(schema.accounts).run(); + await db.delete(schema.sites).run(); + + config.logCleanupUsageLogsEnabled = false; + config.logCleanupProgramLogsEnabled = false; + config.logCleanupRetentionDays = 30; + }); + + afterAll(() => { + config.logCleanupUsageLogsEnabled = originalConfig.logCleanupUsageLogsEnabled; + config.logCleanupProgramLogsEnabled = originalConfig.logCleanupProgramLogsEnabled; + config.logCleanupRetentionDays = originalConfig.logCleanupRetentionDays; + delete process.env.DATA_DIR; + }); + + async function seedAccount() { + const site = await db.insert(schema.sites).values({ + name: 'cleanup-site', + url: 'https://cleanup.example.com', + platform: 'new-api', + status: 'active', + }).returning().get(); + + return await db.insert(schema.accounts).values({ + siteId: site.id, + username: 'cleanup-user', + accessToken: 'cleanup-access-token', + apiToken: 'cleanup-api-token', + status: 'active', + }).returning().get(); + } + + it('cleans usage logs and program logs older than retention days', async () => { + const account = await seedAccount(); + + await db.insert(schema.proxyLogs).values([ + { + accountId: account.id, + modelRequested: 'gpt-4.1-mini', + status: 'success', + createdAt: '2026-02-01 08:00:00', + }, + { + accountId: account.id, + modelRequested: 'gpt-4.1-mini', + status: 'success', + createdAt: '2026-03-10 08:00:00', + }, + ]).run(); + + await db.insert(schema.events).values([ + { + type: 'status', + title: 'old event', + message: 'cleanup me', + createdAt: '2026-02-01 08:00:00', + }, + { + type: 'status', + title: 'new event', + message: 'keep me', + createdAt: '2026-03-10 08:00:00', + }, + ]).run(); + + const result = await cleanupConfiguredLogs({ + usageLogsEnabled: true, + programLogsEnabled: true, + retentionDays: 7, + nowMs: Date.parse('2026-03-12T00:00:00Z'), + }); + + expect(result.enabled).toBe(true); + expect(result.usageLogsDeleted).toBe(1); + expect(result.programLogsDeleted).toBe(1); + expect(result.totalDeleted).toBe(2); + + const remainingProxyLogs = await db.select().from(schema.proxyLogs).all(); + const remainingEvents = await db.select().from(schema.events).all(); + expect(remainingProxyLogs).toHaveLength(1); + expect(remainingProxyLogs[0]?.createdAt).toBe('2026-03-10 08:00:00'); + expect(remainingEvents).toHaveLength(1); + expect(remainingEvents[0]?.title).toBe('new event'); + }); + + it('skips cleanup when no target is enabled', async () => { + const account = await seedAccount(); + + await db.insert(schema.proxyLogs).values({ + accountId: account.id, + modelRequested: 'gpt-4.1-mini', + status: 'success', + createdAt: '2026-01-01 08:00:00', + }).run(); + await db.insert(schema.events).values({ + type: 'status', + title: 'still here', + createdAt: '2026-01-01 08:00:00', + }).run(); + + const result = await cleanupConfiguredLogs({ + usageLogsEnabled: false, + programLogsEnabled: false, + retentionDays: 7, + nowMs: Date.parse('2026-03-12T00:00:00Z'), + }); + + expect(result.enabled).toBe(false); + expect(result.totalDeleted).toBe(0); + expect(await db.select().from(schema.proxyLogs).all()).toHaveLength(1); + expect(await db.select().from(schema.events).all()).toHaveLength(1); + }); +}); diff --git a/src/server/services/logCleanupService.ts b/src/server/services/logCleanupService.ts new file mode 100644 index 00000000..0e8203a4 --- /dev/null +++ b/src/server/services/logCleanupService.ts @@ -0,0 +1,138 @@ +import { lt } from 'drizzle-orm'; +import { config } from '../config.js'; +import { db, schema } from '../db/index.js'; +import { formatUtcSqlDateTime } from './localTimeService.js'; + +const DAY_MS = 24 * 60 * 60 * 1000; + +export type LogCleanupOptions = { + usageLogsEnabled?: boolean; + programLogsEnabled?: boolean; + retentionDays?: number; + nowMs?: number; +}; + +export type LogCleanupResult = { + enabled: boolean; + usageLogsEnabled: boolean; + programLogsEnabled: boolean; + retentionDays: number; + cutoffUtc: string | null; + usageLogsDeleted: number; + programLogsDeleted: number; + totalDeleted: number; +}; + +export function normalizeLogCleanupRetentionDays(value: unknown, fallback = 30): number { + const parsed = Number(value); + if (Number.isFinite(parsed) && parsed >= 1) return Math.trunc(parsed); + + const fallbackParsed = Number(fallback); + if (Number.isFinite(fallbackParsed) && fallbackParsed >= 1) return Math.trunc(fallbackParsed); + + return 30; +} + +export function getLogCleanupCutoffUtc(retentionDays: number, nowMs = Date.now()): string | null { + const normalizedDays = normalizeLogCleanupRetentionDays(retentionDays); + return formatUtcSqlDateTime(new Date(nowMs - normalizedDays * DAY_MS)); +} + +export async function cleanupUsageLogs(retentionDays: number, nowMs = Date.now()): Promise<{ + retentionDays: number; + cutoffUtc: string | null; + deleted: number; +}> { + const normalizedDays = normalizeLogCleanupRetentionDays(retentionDays); + const cutoffUtc = getLogCleanupCutoffUtc(normalizedDays, nowMs); + if (!cutoffUtc) { + return { + retentionDays: normalizedDays, + cutoffUtc: null, + deleted: 0, + }; + } + + const deleted = ( + await db.delete(schema.proxyLogs) + .where(lt(schema.proxyLogs.createdAt, cutoffUtc)) + .run() + ).changes; + + return { + retentionDays: normalizedDays, + cutoffUtc, + deleted, + }; +} + +export async function cleanupProgramLogs(retentionDays: number, nowMs = Date.now()): Promise<{ + retentionDays: number; + cutoffUtc: string | null; + deleted: number; +}> { + const normalizedDays = normalizeLogCleanupRetentionDays(retentionDays); + const cutoffUtc = getLogCleanupCutoffUtc(normalizedDays, nowMs); + if (!cutoffUtc) { + return { + retentionDays: normalizedDays, + cutoffUtc: null, + deleted: 0, + }; + } + + const deleted = ( + await db.delete(schema.events) + .where(lt(schema.events.createdAt, cutoffUtc)) + .run() + ).changes; + + return { + retentionDays: normalizedDays, + cutoffUtc, + deleted, + }; +} + +export async function cleanupConfiguredLogs(options: LogCleanupOptions = {}): Promise { + const usageLogsEnabled = options.usageLogsEnabled ?? config.logCleanupUsageLogsEnabled; + const programLogsEnabled = options.programLogsEnabled ?? config.logCleanupProgramLogsEnabled; + const retentionDays = normalizeLogCleanupRetentionDays( + options.retentionDays ?? config.logCleanupRetentionDays, + config.logCleanupRetentionDays, + ); + const nowMs = options.nowMs ?? Date.now(); + const enabled = usageLogsEnabled || programLogsEnabled; + const cutoffUtc = enabled ? getLogCleanupCutoffUtc(retentionDays, nowMs) : null; + + if (!enabled || !cutoffUtc) { + return { + enabled: false, + usageLogsEnabled, + programLogsEnabled, + retentionDays, + cutoffUtc, + usageLogsDeleted: 0, + programLogsDeleted: 0, + totalDeleted: 0, + }; + } + + const usageResult = usageLogsEnabled + ? await cleanupUsageLogs(retentionDays, nowMs) + : { deleted: 0 }; + const programResult = programLogsEnabled + ? await cleanupProgramLogs(retentionDays, nowMs) + : { deleted: 0 }; + + return { + enabled: true, + usageLogsEnabled, + programLogsEnabled, + retentionDays, + cutoffUtc, + usageLogsDeleted: usageResult.deleted, + programLogsDeleted: programResult.deleted, + totalDeleted: usageResult.deleted + programResult.deleted, + }; +} diff --git a/src/server/services/modelAnalysisService.test.ts b/src/server/services/modelAnalysisService.test.ts index b1af10a8..df57092f 100644 --- a/src/server/services/modelAnalysisService.test.ts +++ b/src/server/services/modelAnalysisService.test.ts @@ -148,4 +148,35 @@ describe('buildModelAnalysis', () => { calls: 1, }); }); + + it('accepts Date-backed createdAt values from external database drivers', () => { + const logs = [ + { + createdAt: new Date('2026-02-24T12:30:00.000Z') as unknown as string, + modelActual: 'gpt-4o', + modelRequested: null, + status: 'success', + latencyMs: 250, + totalTokens: 800, + estimatedCost: 1.6, + }, + ]; + + const result = buildModelAnalysis(logs, { + now: new Date('2026-02-24T12:00:00.000Z'), + days: 1, + }); + + expect(result.totals).toEqual({ + calls: 1, + tokens: 800, + spend: 1.6, + }); + expect(result.callRanking[0]).toMatchObject({ + model: 'gpt-4o', + calls: 1, + successRate: 100, + avgLatencyMs: 250, + }); + }); }); diff --git a/src/server/services/modelAnalysisService.ts b/src/server/services/modelAnalysisService.ts index 9a172b93..b5b3a158 100644 --- a/src/server/services/modelAnalysisService.ts +++ b/src/server/services/modelAnalysisService.ts @@ -1,9 +1,9 @@ -import { formatLocalDate, parseStoredUtcDateTime } from './localTimeService.js'; +import { formatLocalDate, parseStoredUtcDateTime, type StoredUtcDateTimeInput } from './localTimeService.js'; const DAY_MS = 24 * 60 * 60 * 1000; interface ProxyLogLike { - createdAt: string | null; + createdAt: StoredUtcDateTimeInput; modelActual: string | null; modelRequested: string | null; status: string | null; diff --git a/src/server/services/modelService.discovery.architecture.test.ts b/src/server/services/modelService.discovery.architecture.test.ts new file mode 100644 index 00000000..f7f16a7a --- /dev/null +++ b/src/server/services/modelService.discovery.architecture.test.ts @@ -0,0 +1,18 @@ +import { readFileSync } from 'node:fs'; +import { describe, expect, it } from 'vitest'; + +function readSource(relativePath: string): string { + return readFileSync(new URL(relativePath, import.meta.url), 'utf8'); +} + +describe('modelService discovery architecture boundaries', () => { + it('keeps platform-specific oauth discovery HTTP logic in a dedicated discovery registry', () => { + const source = readSource('./modelService.ts'); + + expect(source).toContain("from './platformDiscoveryRegistry.js'"); + expect(source).not.toContain('function discoverCodexModelsFromCloud'); + expect(source).not.toContain('function discoverClaudeModelsFromCloud'); + expect(source).not.toContain('function validateGeminiCliOauthConnection'); + expect(source).not.toContain('function discoverAntigravityModelsFromCloud'); + }); +}); diff --git a/src/server/services/modelService.discovery.test.ts b/src/server/services/modelService.discovery.test.ts index 72fff1e4..8bda03b0 100644 --- a/src/server/services/modelService.discovery.test.ts +++ b/src/server/services/modelService.discovery.test.ts @@ -6,6 +6,20 @@ import { eq } from 'drizzle-orm'; const getApiTokenMock = vi.fn(); const getModelsMock = vi.fn(); +const undiciFetchMock = vi.fn(); +const proxyAgentCtorMock = vi.fn(); +const refreshOauthAccessTokenSingleflightMock = vi.fn(); + +class MockProxyAgent { + readonly proxyUrl: string; + + constructor(proxyUrl: string) { + this.proxyUrl = proxyUrl; + proxyAgentCtorMock(proxyUrl); + } +} + +class MockAgent {} vi.mock('./platforms/index.js', () => ({ getAdapter: () => ({ @@ -14,6 +28,16 @@ vi.mock('./platforms/index.js', () => ({ }), })); +vi.mock('undici', () => ({ + fetch: (...args: unknown[]) => undiciFetchMock(...args), + ProxyAgent: MockProxyAgent, + Agent: MockAgent, +})); + +vi.mock('./oauth/refreshSingleflight.js', () => ({ + refreshOauthAccessTokenSingleflight: (...args: unknown[]) => refreshOauthAccessTokenSingleflightMock(...args), +})); + type DbModule = typeof import('../db/index.js'); type ModelServiceModule = typeof import('./modelService.js'); @@ -21,6 +45,7 @@ describe('refreshModelsForAccount credential discovery', () => { let db: DbModule['db']; let schema: DbModule['schema']; let refreshModelsForAccount: ModelServiceModule['refreshModelsForAccount']; + let refreshModelsAndRebuildRoutes: ModelServiceModule['refreshModelsAndRebuildRoutes']; let dataDir = ''; beforeAll(async () => { @@ -34,11 +59,15 @@ describe('refreshModelsForAccount credential discovery', () => { db = dbModule.db; schema = dbModule.schema; refreshModelsForAccount = modelService.refreshModelsForAccount; + refreshModelsAndRebuildRoutes = modelService.refreshModelsAndRebuildRoutes; }); beforeEach(async () => { getApiTokenMock.mockReset(); getModelsMock.mockReset(); + undiciFetchMock.mockReset(); + proxyAgentCtorMock.mockReset(); + refreshOauthAccessTokenSingleflightMock.mockReset(); await db.delete(schema.routeChannels).run(); await db.delete(schema.tokenRoutes).run(); @@ -79,7 +108,11 @@ describe('refreshModelsForAccount credential discovery', () => { expect(result).toMatchObject({ accountId: account.id, refreshed: true, + status: 'success', + errorCode: null, + errorMessage: '', modelCount: 2, + modelsPreview: ['claude-sonnet-4-5-20250929', 'claude-opus-4-6'], tokenScanned: 0, discoveredByCredential: true, }); @@ -95,4 +128,1171 @@ describe('refreshModelsForAccount credential discovery', () => { const tokenRows = await db.select().from(schema.tokenModelAvailability).all(); expect(tokenRows).toHaveLength(0); }); + + it('deduplicates discovered model names before writing availability rows', async () => { + getApiTokenMock.mockResolvedValue(null); + getModelsMock.mockResolvedValue(['? ', '?', 'GPT-4.1', 'gpt-4.1']); + + const site = await db.insert(schema.sites).values({ + name: 'site-dedupe', + url: 'https://site-dedupe.example.com', + platform: 'new-api', + status: 'active', + }).returning().get(); + + const account = await db.insert(schema.accounts).values({ + siteId: site.id, + username: 'dedupe-user', + accessToken: 'session-token', + apiToken: null, + status: 'active', + }).returning().get(); + + const result = await refreshModelsForAccount(account.id); + + expect(result).toMatchObject({ + accountId: account.id, + refreshed: true, + status: 'success', + modelCount: 2, + modelsPreview: ['?', 'GPT-4.1'], + }); + + const rows = await db.select().from(schema.modelAvailability) + .where(eq(schema.modelAvailability.accountId, account.id)) + .all(); + + expect(rows.map((row) => row.modelName).sort()).toEqual(['?', 'GPT-4.1']); + }); + + it('reuses one in-flight full refresh when concurrent callers request a rebuild', async () => { + getApiTokenMock.mockResolvedValue(null); + + let releaseGate: (() => void) | null = null; + const gate = new Promise((resolve) => { + releaseGate = resolve; + }); + + getModelsMock.mockImplementation(async () => { + await gate; + return ['gpt-5-nano']; + }); + + const site = await db.insert(schema.sites).values({ + name: 'site-concurrent-refresh', + url: 'https://site-concurrent-refresh.example.com', + platform: 'new-api', + status: 'active', + }).returning().get(); + + const account = await db.insert(schema.accounts).values({ + siteId: site.id, + username: 'concurrent-refresh-user', + accessToken: 'shared-credential', + apiToken: 'shared-credential', + status: 'active', + extraConfig: JSON.stringify({ credentialMode: 'session' }), + }).returning().get(); + + await db.insert(schema.accountTokens).values({ + accountId: account.id, + name: 'default', + token: 'shared-credential', + source: 'manual', + enabled: true, + isDefault: true, + }).run(); + + const firstRefresh = refreshModelsAndRebuildRoutes(); + const secondRefresh = refreshModelsAndRebuildRoutes(); + await Promise.resolve(); + await Promise.resolve(); + releaseGate?.(); + + const results = await Promise.allSettled([firstRefresh, secondRefresh]); + expect(results.every((item) => item.status === 'fulfilled')).toBe(true); + expect(getModelsMock).toHaveBeenCalledTimes(2); + + const modelRows = await db.select().from(schema.modelAvailability) + .where(eq(schema.modelAvailability.accountId, account.id)) + .all(); + expect(modelRows.map((row) => row.modelName)).toEqual(['gpt-5-nano']); + + const token = await db.select().from(schema.accountTokens) + .where(eq(schema.accountTokens.accountId, account.id)) + .get(); + const tokenRows = await db.select().from(schema.tokenModelAvailability) + .where(eq(schema.tokenModelAvailability.tokenId, token!.id)) + .all(); + expect(tokenRows.map((row) => row.modelName)).toEqual(['gpt-5-nano']); + }); + + it('marks runtime health unhealthy when model discovery fails', async () => { + getApiTokenMock.mockResolvedValue(null); + getModelsMock.mockRejectedValue(new Error('HTTP 401: invalid token')); + + const site = await db.insert(schema.sites).values({ + name: 'site-fail', + url: 'https://site-fail.example.com', + platform: 'new-api', + status: 'active', + }).returning().get(); + + const account = await db.insert(schema.accounts).values({ + siteId: site.id, + username: 'fail-user', + accessToken: '', + apiToken: 'sk-invalid', + status: 'active', + extraConfig: JSON.stringify({ credentialMode: 'apikey' }), + }).returning().get(); + + const result = await refreshModelsForAccount(account.id); + + expect(result).toMatchObject({ + accountId: account.id, + refreshed: true, + modelCount: 0, + modelsPreview: [], + tokenScanned: 0, + status: 'failed', + errorCode: 'unauthorized', + }); + + const latest = await db.select().from(schema.accounts) + .where(eq(schema.accounts.id, account.id)) + .get(); + const parsed = JSON.parse(latest!.extraConfig || '{}'); + expect(parsed.runtimeHealth?.state).toBe('unhealthy'); + expect(parsed.runtimeHealth?.source).toBe('model-discovery'); + expect(parsed.runtimeHealth?.reason).toBe('模型获取失败,API Key 已无效'); + expect(parsed.runtimeHealth?.checkedAt).toMatch(/\d{4}-\d{2}-\d{2}T/); + }); + + it('does not scan hidden managed tokens for direct apikey connections', async () => { + getApiTokenMock.mockResolvedValue(null); + getModelsMock.mockImplementation(async (_baseUrl: string, token: string) => ( + token === 'sk-direct-credential' ? ['gpt-4.1'] : ['legacy-should-not-be-used'] + )); + + const site = await db.insert(schema.sites).values({ + name: 'apikey-direct-site', + url: 'https://apikey-direct.example.com', + platform: 'new-api', + status: 'active', + }).returning().get(); + + const account = await db.insert(schema.accounts).values({ + siteId: site.id, + username: 'apikey-direct-user', + accessToken: '', + apiToken: 'sk-direct-credential', + status: 'active', + extraConfig: JSON.stringify({ credentialMode: 'apikey' }), + }).returning().get(); + + const hiddenToken = await db.insert(schema.accountTokens).values({ + accountId: account.id, + name: 'legacy-hidden', + token: 'sk-legacy-hidden', + source: 'legacy', + enabled: true, + isDefault: true, + }).returning().get(); + + const result = await refreshModelsForAccount(account.id); + + expect(result).toMatchObject({ + accountId: account.id, + refreshed: true, + status: 'success', + modelCount: 1, + modelsPreview: ['gpt-4.1'], + tokenScanned: 0, + discoveredByCredential: true, + }); + + const tokenRows = await db.select().from(schema.tokenModelAvailability) + .where(eq(schema.tokenModelAvailability.tokenId, hiddenToken.id)) + .all(); + expect(tokenRows).toHaveLength(0); + }); + + it('returns structured result when account missing', async () => { + const result = await refreshModelsForAccount(9999); + + expect(result).toMatchObject({ + accountId: 9999, + refreshed: false, + status: 'failed', + errorCode: 'account_not_found', + errorMessage: '账号不存在', + modelCount: 0, + modelsPreview: [], + reason: 'account_not_found', + }); + }); + + it('returns structured result when site disabled', async () => { + const site = await db.insert(schema.sites).values({ + name: 'site-disabled', + url: 'https://site-disabled.example.com', + platform: 'new-api', + status: 'disabled', + }).returning().get(); + + const account = await db.insert(schema.accounts).values({ + siteId: site.id, + username: 'disabled-user', + accessToken: 'session-token', + apiToken: null, + status: 'active', + }).returning().get(); + + const result = await refreshModelsForAccount(account.id); + + expect(result).toMatchObject({ + accountId: account.id, + refreshed: false, + status: 'skipped', + errorCode: 'site_disabled', + errorMessage: '站点已禁用', + modelCount: 0, + modelsPreview: [], + reason: 'site_disabled', + }); + }); + + it('returns structured result when account inactive', async () => { + const site = await db.insert(schema.sites).values({ + name: 'site-inactive', + url: 'https://site-inactive.example.com', + platform: 'new-api', + status: 'active', + }).returning().get(); + + const account = await db.insert(schema.accounts).values({ + siteId: site.id, + username: 'inactive-user', + accessToken: 'session-token', + apiToken: null, + status: 'disabled', + }).returning().get(); + + const result = await refreshModelsForAccount(account.id); + + expect(result).toMatchObject({ + accountId: account.id, + refreshed: false, + status: 'skipped', + errorCode: 'adapter_or_status', + errorMessage: '平台不可用或账号未激活', + modelCount: 0, + modelsPreview: [], + reason: 'adapter_or_status', + }); + }); + + it('preserves existing availability when allowInactive refresh fails', async () => { + getApiTokenMock.mockResolvedValue(null); + getModelsMock.mockRejectedValue(new Error('upstream unavailable')); + + const site = await db.insert(schema.sites).values({ + name: 'site-rebind-refresh', + url: 'https://site-rebind-refresh.example.com', + platform: 'new-api', + status: 'active', + }).returning().get(); + + const account = await db.insert(schema.accounts).values({ + siteId: site.id, + username: 'rebind-user', + accessToken: 'session-token', + apiToken: null, + status: 'disabled', + extraConfig: JSON.stringify({ credentialMode: 'session' }), + }).returning().get(); + + const token = await db.insert(schema.accountTokens).values({ + accountId: account.id, + name: 'default', + token: 'sk-stored-token', + source: 'manual', + enabled: true, + isDefault: true, + valueStatus: 'ready' as any, + }).returning().get(); + + await db.insert(schema.modelAvailability).values({ + accountId: account.id, + modelName: 'gpt-4.1', + available: true, + latencyMs: 120, + checkedAt: '2026-03-21T11:30:00.000Z', + }).run(); + + await db.insert(schema.tokenModelAvailability).values({ + tokenId: token.id, + modelName: 'gpt-4.1', + available: true, + latencyMs: 90, + checkedAt: '2026-03-21T11:30:00.000Z', + }).run(); + + const result = await refreshModelsForAccount(account.id, { allowInactive: true }); + + expect(result).toMatchObject({ + accountId: account.id, + refreshed: true, + status: 'failed', + modelCount: 0, + discoveredByCredential: false, + }); + + const modelRows = await db.select().from(schema.modelAvailability) + .where(eq(schema.modelAvailability.accountId, account.id)) + .all(); + expect(modelRows).toHaveLength(1); + expect(modelRows[0]).toMatchObject({ + accountId: account.id, + modelName: 'gpt-4.1', + available: true, + }); + + const tokenRows = await db.select().from(schema.tokenModelAvailability) + .where(eq(schema.tokenModelAvailability.tokenId, token.id)) + .all(); + expect(tokenRows).toHaveLength(1); + expect(tokenRows[0]).toMatchObject({ + tokenId: token.id, + modelName: 'gpt-4.1', + available: true, + }); + }); + + it('does not scan masked_pending placeholders as token credentials', async () => { + getApiTokenMock.mockResolvedValue(null); + getModelsMock.mockImplementation(async (_baseUrl: string, token: string) => ( + token === 'sk-mask***tail' ? ['gpt-5.2-codex'] : [] + )); + + const site = await db.insert(schema.sites).values({ + name: 'site-placeholder', + url: 'https://site-placeholder.example.com', + platform: 'new-api', + status: 'active', + }).returning().get(); + + const account = await db.insert(schema.accounts).values({ + siteId: site.id, + username: 'placeholder-user', + accessToken: '', + apiToken: null, + status: 'active', + extraConfig: JSON.stringify({ credentialMode: 'session' }), + }).returning().get(); + + const placeholder = await db.insert(schema.accountTokens).values({ + accountId: account.id, + name: 'masked-token', + token: 'sk-mask***tail', + source: 'sync', + enabled: true, + isDefault: false, + valueStatus: 'masked_pending' as any, + }).returning().get(); + + const result = await refreshModelsForAccount(account.id); + + expect(result).toMatchObject({ + accountId: account.id, + refreshed: true, + status: 'failed', + tokenScanned: 0, + }); + + const placeholderModels = await db.select().from(schema.tokenModelAvailability) + .where(eq(schema.tokenModelAvailability.tokenId, placeholder.id)) + .all(); + expect(placeholderModels).toEqual([]); + expect(getModelsMock).not.toHaveBeenCalledWith(site.url, 'sk-mask***tail', account.username); + }); + + it('discovers codex models from upstream cloud endpoint without adapter model fetch', async () => { + getApiTokenMock.mockResolvedValue(null); + getModelsMock.mockRejectedValue(new Error('codex plan discovery should not call adapter.getModels')); + undiciFetchMock.mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ + models: [ + { id: 'gpt-5.4' }, + { id: 'gpt-5.3-codex' }, + { id: 'gpt-5.2-codex' }, + { id: 'gpt-5.2' }, + { id: 'gpt-5.1-codex-max' }, + { id: 'gpt-5.1-codex' }, + { id: 'gpt-5.1' }, + { id: 'gpt-5-codex' }, + { id: 'gpt-5' }, + { id: 'gpt-5.1-codex-mini' }, + { id: 'gpt-5-codex-mini' }, + ], + }), + text: async () => JSON.stringify({ ok: true }), + }); + + const site = await db.insert(schema.sites).values({ + name: 'codex-site', + url: 'https://chatgpt.com/backend-api/codex', + platform: 'codex', + status: 'active', + }).returning().get(); + + const account = await db.insert(schema.accounts).values({ + siteId: site.id, + username: 'codex-user@example.com', + accessToken: 'oauth-access-token', + apiToken: null, + status: 'active', + extraConfig: JSON.stringify({ + credentialMode: 'session', + oauth: { + provider: 'codex', + accountId: 'chatgpt-account-123', + email: 'codex-user@example.com', + planType: 'plus', + }, + }), + }).returning().get(); + + const result = await refreshModelsForAccount(account.id); + + expect(result).toMatchObject({ + accountId: account.id, + refreshed: true, + status: 'success', + errorCode: null, + tokenScanned: 0, + discoveredByCredential: true, + modelCount: 11, + }); + expect(result.modelsPreview).toEqual([ + 'gpt-5.4', + 'gpt-5.3-codex', + 'gpt-5.2-codex', + 'gpt-5.2', + 'gpt-5.1-codex-max', + 'gpt-5.1-codex', + 'gpt-5.1', + 'gpt-5-codex', + 'gpt-5', + 'gpt-5.1-codex-mini', + ]); + expect(getModelsMock).not.toHaveBeenCalled(); + expect(undiciFetchMock).toHaveBeenCalledTimes(1); + expect(String(undiciFetchMock.mock.calls[0]?.[0] || '')).toBe('https://chatgpt.com/backend-api/codex/models?client_version=1.0.0'); + expect(undiciFetchMock.mock.calls[0]?.[1]).toMatchObject({ + method: 'GET', + headers: expect.objectContaining({ + Authorization: 'Bearer oauth-access-token', + 'Chatgpt-Account-Id': 'chatgpt-account-123', + Originator: 'codex_cli_rs', + }), + }); + + const rows = await db.select().from(schema.modelAvailability) + .where(eq(schema.modelAvailability.accountId, account.id)) + .all(); + const modelNames = rows.map((row) => row.modelName); + expect(modelNames.sort()).toEqual([ + 'gpt-5', + 'gpt-5-codex', + 'gpt-5-codex-mini', + 'gpt-5.1', + 'gpt-5.1-codex', + 'gpt-5.1-codex-max', + 'gpt-5.1-codex-mini', + 'gpt-5.2', + 'gpt-5.2-codex', + 'gpt-5.3-codex', + 'gpt-5.4', + ]); + }); + + it('discovers Claude OAuth models from the upstream /v1/models response', async () => { + getApiTokenMock.mockResolvedValue(null); + getModelsMock.mockRejectedValue(new Error('claude oauth discovery should not call adapter.getModels')); + undiciFetchMock.mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ + data: [ + { id: 'claude-3-7-sonnet-latest' }, + { id: 'claude-opus-4-1-20250805' }, + ], + }), + text: async () => JSON.stringify({ ok: true }), + }); + + const site = await db.insert(schema.sites).values({ + name: 'claude-site', + url: 'https://api.anthropic.com', + platform: 'claude', + status: 'active', + }).returning().get(); + + const account = await db.insert(schema.accounts).values({ + siteId: site.id, + username: 'claude-user@example.com', + accessToken: 'claude-oauth-token', + apiToken: null, + status: 'active', + extraConfig: JSON.stringify({ + credentialMode: 'session', + oauth: { + provider: 'claude', + email: 'claude-user@example.com', + accountKey: 'claude-user@example.com', + }, + }), + }).returning().get(); + + const result = await refreshModelsForAccount(account.id); + + expect(result).toMatchObject({ + accountId: account.id, + refreshed: true, + status: 'success', + errorCode: null, + tokenScanned: 0, + discoveredByCredential: true, + discoveredApiToken: false, + modelCount: 2, + modelsPreview: ['claude-3-7-sonnet-latest', 'claude-opus-4-1-20250805'], + }); + expect(getModelsMock).not.toHaveBeenCalled(); + expect(undiciFetchMock).toHaveBeenCalledTimes(1); + expect(String(undiciFetchMock.mock.calls[0]?.[0] || '')).toBe('https://api.anthropic.com/v1/models'); + expect(undiciFetchMock.mock.calls[0]?.[1]).toMatchObject({ + method: 'GET', + headers: expect.objectContaining({ + Authorization: 'Bearer claude-oauth-token', + Accept: 'application/json', + 'anthropic-version': '2023-06-01', + }), + }); + + const rows = await db.select().from(schema.modelAvailability) + .where(eq(schema.modelAvailability.accountId, account.id)) + .all(); + expect(rows.map((row) => row.modelName).sort()).toEqual([ + 'claude-3-7-sonnet-latest', + 'claude-opus-4-1-20250805', + ]); + }); + + it('marks codex oauth account abnormal when upstream cloud discovery fails', async () => { + getApiTokenMock.mockResolvedValue(null); + getModelsMock.mockRejectedValue(new Error('codex plan discovery should not call adapter.getModels')); + undiciFetchMock.mockResolvedValue({ + ok: false, + status: 403, + json: async () => ({ error: 'forbidden' }), + text: async () => 'forbidden', + }); + + const site = await db.insert(schema.sites).values({ + name: 'codex-team-site', + url: 'https://chatgpt.com/backend-api/codex', + platform: 'codex', + status: 'active', + }).returning().get(); + + const account = await db.insert(schema.accounts).values({ + siteId: site.id, + username: 'team-user@example.com', + accessToken: 'oauth-access-token', + apiToken: null, + status: 'active', + extraConfig: JSON.stringify({ + credentialMode: 'session', + oauth: { + provider: 'codex', + accountId: 'chatgpt-account-team', + email: 'team-user@example.com', + planType: 'team', + }, + }), + }).returning().get(); + + await db.insert(schema.modelAvailability).values({ + accountId: account.id, + modelName: 'gpt-5.2-codex', + available: true, + checkedAt: '2026-03-16T12:00:00.000Z', + }).run(); + + const result = await refreshModelsForAccount(account.id); + + expect(result).toMatchObject({ + accountId: account.id, + refreshed: true, + status: 'failed', + errorCode: 'unauthorized', + tokenScanned: 0, + discoveredByCredential: false, + modelCount: 0, + }); + expect(getModelsMock).not.toHaveBeenCalled(); + expect(undiciFetchMock).toHaveBeenCalledTimes(1); + + const rows = await db.select().from(schema.modelAvailability) + .where(eq(schema.modelAvailability.accountId, account.id)) + .all(); + expect(rows).toEqual([]); + + const latest = await db.select().from(schema.accounts) + .where(eq(schema.accounts.id, account.id)) + .get(); + const parsed = JSON.parse(latest!.extraConfig || '{}'); + expect(parsed.oauth).toMatchObject({ + modelDiscoveryStatus: 'abnormal', + }); + expect(parsed.oauth).not.toHaveProperty('provider'); + expect(parsed.oauth.lastModelSyncError).toContain('HTTP 403'); + expect(parsed.oauth.lastModelSyncAt).toMatch(/\d{4}-\d{2}-\d{2}T/); + expect(parsed.runtimeHealth?.state).toBe('unhealthy'); + }); + + it('applies account proxy override to codex oauth cloud discovery requests', async () => { + getApiTokenMock.mockResolvedValue(null); + getModelsMock.mockRejectedValue(new Error('codex oauth discovery should not call adapter.getModels')); + undiciFetchMock.mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ + models: [ + { id: 'gpt-5.4' }, + ], + }), + text: async () => JSON.stringify({ ok: true }), + }); + + const site = await db.insert(schema.sites).values({ + name: 'codex-account-proxy-site', + url: 'https://chatgpt.com/backend-api/codex', + platform: 'codex', + status: 'active', + }).returning().get(); + + const account = await db.insert(schema.accounts).values({ + siteId: site.id, + username: 'codex-proxy-user@example.com', + accessToken: 'oauth-access-token', + apiToken: null, + status: 'active', + extraConfig: JSON.stringify({ + credentialMode: 'session', + proxyUrl: 'http://127.0.0.1:7890', + oauth: { + provider: 'codex', + accountId: 'chatgpt-account-proxy', + email: 'codex-proxy-user@example.com', + planType: 'plus', + }, + }), + }).returning().get(); + + const result = await refreshModelsForAccount(account.id); + + expect(result).toMatchObject({ + accountId: account.id, + refreshed: true, + status: 'success', + modelCount: 1, + modelsPreview: ['gpt-5.4'], + }); + expect(undiciFetchMock).toHaveBeenCalledTimes(1); + expect(proxyAgentCtorMock).toHaveBeenCalledWith('http://127.0.0.1:7890'); + expect(undiciFetchMock.mock.calls[0]?.[1]).toMatchObject({ + method: 'GET', + dispatcher: expect.any(MockProxyAgent), + }); + }); + + it('applies account proxy override to gemini oauth validation requests', async () => { + getApiTokenMock.mockResolvedValue(null); + getModelsMock.mockRejectedValue(new Error('gemini oauth validation should not call adapter.getModels')); + undiciFetchMock.mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ + state: 'ENABLED', + }), + text: async () => JSON.stringify({ state: 'ENABLED' }), + }); + + const site = await db.insert(schema.sites).values({ + name: 'gemini-account-proxy-site', + url: 'https://gemini.example.com', + platform: 'gemini', + status: 'active', + }).returning().get(); + + const account = await db.insert(schema.accounts).values({ + siteId: site.id, + username: 'gemini-proxy-user@example.com', + accessToken: 'gemini-access-token', + apiToken: null, + status: 'active', + extraConfig: JSON.stringify({ + credentialMode: 'session', + proxyUrl: 'http://127.0.0.1:1080', + oauth: { + provider: 'gemini-cli', + projectId: 'project-proxy-demo', + email: 'gemini-proxy-user@example.com', + }, + }), + }).returning().get(); + + const result = await refreshModelsForAccount(account.id); + + expect(result).toMatchObject({ + accountId: account.id, + refreshed: true, + status: 'success', + modelCount: expect.any(Number), + }); + expect(undiciFetchMock).toHaveBeenCalledTimes(1); + expect(proxyAgentCtorMock).toHaveBeenCalledWith('http://127.0.0.1:1080'); + expect(String(undiciFetchMock.mock.calls[0]?.[0] || '')).toContain('/projects/project-proxy-demo/services/'); + expect(undiciFetchMock.mock.calls[0]?.[1]).toMatchObject({ + method: 'GET', + dispatcher: expect.any(MockProxyAgent), + }); + }); + + it('refreshes gemini oauth access token during validation through singleflight and reuses the refreshed account state', async () => { + getApiTokenMock.mockResolvedValue(null); + getModelsMock.mockRejectedValue(new Error('gemini oauth validation should not call adapter.getModels')); + refreshOauthAccessTokenSingleflightMock.mockImplementation(async (accountId: number) => { + const refreshedExtraConfig = JSON.stringify({ + credentialMode: 'session', + oauth: { + provider: 'gemini-cli', + email: 'gemini-refreshed-user@example.com', + accountId: 'gemini-refreshed-user@example.com', + accountKey: 'gemini-refreshed-user@example.com', + projectId: 'project-refresh-demo', + refreshToken: 'gemini-refresh-token-next', + }, + }); + + await db.update(schema.accounts).set({ + accessToken: 'gemini-access-token-refreshed', + oauthProvider: 'gemini-cli', + oauthAccountKey: 'gemini-refreshed-user@example.com', + oauthProjectId: 'project-refresh-demo', + extraConfig: refreshedExtraConfig, + status: 'active', + updatedAt: '2026-03-21T00:00:00.000Z', + }).where(eq(schema.accounts.id, accountId)).run(); + + return { + accountId, + accessToken: 'gemini-access-token-refreshed', + accountKey: 'gemini-refreshed-user@example.com', + extraConfig: refreshedExtraConfig, + }; + }); + undiciFetchMock + .mockResolvedValueOnce({ + ok: false, + status: 401, + json: async () => ({ error: 'expired' }), + text: async () => 'expired', + }) + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ state: 'ENABLED' }), + text: async () => JSON.stringify({ state: 'ENABLED' }), + }); + + const site = await db.insert(schema.sites).values({ + name: 'gemini-refresh-site', + url: 'https://gemini.example.com', + platform: 'gemini', + status: 'active', + }).returning().get(); + + const account = await db.insert(schema.accounts).values({ + siteId: site.id, + username: 'gemini-user@example.com', + accessToken: 'gemini-access-token-expired', + apiToken: null, + status: 'active', + oauthProvider: 'gemini-cli', + oauthAccountKey: 'gemini-user@example.com', + oauthProjectId: 'project-refresh-demo', + extraConfig: JSON.stringify({ + credentialMode: 'session', + oauth: { + provider: 'gemini-cli', + email: 'gemini-user@example.com', + accountId: 'gemini-user@example.com', + accountKey: 'gemini-user@example.com', + projectId: 'project-refresh-demo', + refreshToken: 'gemini-refresh-token', + }, + }), + }).returning().get(); + + const result = await refreshModelsForAccount(account.id); + + expect(result).toMatchObject({ + accountId: account.id, + refreshed: true, + status: 'success', + modelCount: expect.any(Number), + discoveredByCredential: true, + }); + expect(refreshOauthAccessTokenSingleflightMock).toHaveBeenCalledWith(account.id); + expect(undiciFetchMock).toHaveBeenCalledTimes(2); + expect(String(undiciFetchMock.mock.calls[0]?.[0] || '')).toContain('/projects/project-refresh-demo/services/'); + expect(undiciFetchMock.mock.calls[0]?.[1]).toMatchObject({ + method: 'GET', + headers: expect.objectContaining({ + Authorization: 'Bearer gemini-access-token-expired', + }), + }); + expect( + undiciFetchMock.mock.calls.some(([url]) => String(url || '').includes('oauth2.googleapis.com/token')), + ).toBe(false); + expect(String(undiciFetchMock.mock.calls[1]?.[0] || '')).toContain('/projects/project-refresh-demo/services/'); + expect(undiciFetchMock.mock.calls[1]?.[1]).toMatchObject({ + method: 'GET', + headers: expect.objectContaining({ + Authorization: 'Bearer gemini-access-token-refreshed', + }), + }); + + const latest = await db.select().from(schema.accounts) + .where(eq(schema.accounts.id, account.id)) + .get(); + expect(latest).toMatchObject({ + accessToken: 'gemini-access-token-refreshed', + oauthProvider: 'gemini-cli', + oauthAccountKey: 'gemini-refreshed-user@example.com', + oauthProjectId: 'project-refresh-demo', + }); + const parsed = JSON.parse(latest!.extraConfig || '{}'); + expect(parsed.oauth).toMatchObject({ + email: 'gemini-refreshed-user@example.com', + refreshToken: 'gemini-refresh-token-next', + modelDiscoveryStatus: 'healthy', + }); + expect(parsed.oauth).not.toHaveProperty('provider'); + expect(parsed.oauth).not.toHaveProperty('projectId'); + }); + + it('discovers antigravity oauth models via fetchAvailableModels fallback using the oauth project id', async () => { + getApiTokenMock.mockResolvedValue(null); + getModelsMock.mockRejectedValue(new Error('antigravity oauth discovery should not call adapter.getModels')); + undiciFetchMock + .mockResolvedValueOnce({ + ok: false, + status: 503, + json: async () => ({ error: 'unavailable' }), + text: async () => 'unavailable', + }) + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + models: { + 'gemini-3-pro-preview': { displayName: 'Gemini 3 Pro Preview' }, + 'claude-sonnet-4-5-20250929': { displayName: 'Claude Sonnet 4.5' }, + }, + }), + text: async () => JSON.stringify({ ok: true }), + }); + + const site = await db.insert(schema.sites).values({ + name: 'antigravity-site', + url: 'https://cloudcode-pa.googleapis.com', + platform: 'antigravity', + status: 'active', + }).returning().get(); + + const account = await db.insert(schema.accounts).values({ + siteId: site.id, + username: 'antigravity-user@example.com', + accessToken: 'antigravity-access-token', + apiToken: null, + status: 'active', + extraConfig: JSON.stringify({ + credentialMode: 'session', + oauth: { + provider: 'antigravity', + email: 'antigravity-user@example.com', + projectId: 'project-demo', + }, + }), + }).returning().get(); + + const result = await refreshModelsForAccount(account.id); + + expect(result).toMatchObject({ + accountId: account.id, + refreshed: true, + status: 'success', + errorCode: null, + tokenScanned: 0, + discoveredByCredential: true, + discoveredApiToken: false, + modelCount: 2, + modelsPreview: ['gemini-3-pro-preview', 'claude-sonnet-4-5-20250929'], + }); + expect(getModelsMock).not.toHaveBeenCalled(); + expect(undiciFetchMock).toHaveBeenCalledTimes(2); + expect(String(undiciFetchMock.mock.calls[0]?.[0] || '')).toBe('https://cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels'); + expect(String(undiciFetchMock.mock.calls[1]?.[0] || '')).toBe('https://daily-cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels'); + expect(undiciFetchMock.mock.calls[0]?.[1]).toMatchObject({ + method: 'POST', + headers: expect.objectContaining({ + Authorization: 'Bearer antigravity-access-token', + Accept: 'application/json', + 'Content-Type': 'application/json', + 'User-Agent': 'antigravity/1.19.6 darwin/arm64', + }), + }); + const discoveryHeaders = undiciFetchMock.mock.calls[0]?.[1]?.headers as Record; + expect(discoveryHeaders).not.toHaveProperty('X-Goog-Api-Client'); + expect(discoveryHeaders).not.toHaveProperty('Client-Metadata'); + expect(JSON.parse(String(undiciFetchMock.mock.calls[0]?.[1]?.body || '{}'))).toEqual({ + project: 'project-demo', + }); + + const rows = await db.select().from(schema.modelAvailability) + .where(eq(schema.modelAvailability.accountId, account.id)) + .all(); + expect(rows.map((row) => row.modelName).sort()).toEqual([ + 'claude-sonnet-4-5-20250929', + 'gemini-3-pro-preview', + ]); + }); + + it('continues antigravity discovery after fetch errors and trims the oauth project id before posting', async () => { + getApiTokenMock.mockResolvedValue(null); + getModelsMock.mockRejectedValue(new Error('antigravity oauth discovery should not call adapter.getModels')); + undiciFetchMock + .mockRejectedValueOnce(new Error('network boom')) + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + models: { + 'gemini-3-pro-preview': { displayName: 'Gemini 3 Pro Preview' }, + }, + }), + text: async () => JSON.stringify({ ok: true }), + }); + + const site = await db.insert(schema.sites).values({ + name: 'antigravity-site-trimmed', + url: 'https://cloudcode-pa.googleapis.com', + platform: 'antigravity', + status: 'active', + }).returning().get(); + + const account = await db.insert(schema.accounts).values({ + siteId: site.id, + username: 'antigravity-trimmed@example.com', + accessToken: 'antigravity-access-token', + apiToken: null, + status: 'active', + oauthProvider: 'antigravity', + oauthAccountKey: 'antigravity-trimmed@example.com', + oauthProjectId: ' project-demo ', + extraConfig: JSON.stringify({ + credentialMode: 'session', + oauth: { + provider: 'antigravity', + email: 'antigravity-trimmed@example.com', + }, + }), + }).returning().get(); + + const result = await refreshModelsForAccount(account.id); + + expect(result).toMatchObject({ + accountId: account.id, + refreshed: true, + status: 'success', + errorCode: null, + tokenScanned: 0, + discoveredByCredential: true, + discoveredApiToken: false, + modelCount: 1, + modelsPreview: ['gemini-3-pro-preview'], + }); + expect(undiciFetchMock).toHaveBeenCalledTimes(2); + expect(String(undiciFetchMock.mock.calls[1]?.[0] || '')).toBe('https://daily-cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels'); + expect(JSON.parse(String(undiciFetchMock.mock.calls[1]?.[1]?.body || '{}'))).toEqual({ + project: 'project-demo', + }); + }); + + it('preserves manual models after successful model refresh', async () => { + getApiTokenMock.mockResolvedValue(null); + getModelsMock.mockResolvedValue(['gpt-4.1', 'claude-opus-4-6']); + + const site = await db.insert(schema.sites).values({ + name: 'site-manual', + url: 'https://site-manual.example.com', + platform: 'new-api', + status: 'active', + }).returning().get(); + + const account = await db.insert(schema.accounts).values({ + siteId: site.id, + username: 'manual-user', + accessToken: 'session-token', + apiToken: null, + status: 'active', + }).returning().get(); + + // Add a manual model before refresh + await db.insert(schema.modelAvailability).values({ + accountId: account.id, + modelName: 'my-custom-model', + available: true, + isManual: true, + }).run(); + + const result = await refreshModelsForAccount(account.id); + + expect(result).toMatchObject({ + accountId: account.id, + refreshed: true, + status: 'success', + }); + + const rows = await db.select().from(schema.modelAvailability) + .where(eq(schema.modelAvailability.accountId, account.id)) + .all(); + + const modelNames = rows.map((r) => r.modelName).sort(); + expect(modelNames).toContain('my-custom-model'); + expect(modelNames).toContain('gpt-4.1'); + expect(modelNames).toContain('claude-opus-4-6'); + + const manualRow = rows.find((r) => r.modelName === 'my-custom-model'); + expect(manualRow?.isManual).toBe(true); + }); + + it('preserves manual models even when discovered models overlap', async () => { + getApiTokenMock.mockResolvedValue(null); + getModelsMock.mockResolvedValue(['gpt-4.1', 'my-custom-model']); + + const site = await db.insert(schema.sites).values({ + name: 'site-overlap', + url: 'https://site-overlap.example.com', + platform: 'new-api', + status: 'active', + }).returning().get(); + + const account = await db.insert(schema.accounts).values({ + siteId: site.id, + username: 'overlap-user', + accessToken: 'session-token', + apiToken: null, + status: 'active', + }).returning().get(); + + // Manual model that also exists upstream + await db.insert(schema.modelAvailability).values({ + accountId: account.id, + modelName: 'my-custom-model', + available: true, + isManual: true, + }).run(); + + const result = await refreshModelsForAccount(account.id); + + expect(result).toMatchObject({ + accountId: account.id, + refreshed: true, + status: 'success', + }); + + const rows = await db.select().from(schema.modelAvailability) + .where(eq(schema.modelAvailability.accountId, account.id)) + .all(); + + // Should have gpt-4.1 (discovered) and my-custom-model (manual, kept as-is) + const modelNames = rows.map((r) => r.modelName).sort(); + expect(modelNames).toEqual(['gpt-4.1', 'my-custom-model']); + + // The manual model should still have isManual=true (not overwritten by discovery) + const manualRow = rows.find((r) => r.modelName === 'my-custom-model'); + expect(manualRow?.isManual).toBe(true); + }); + + it('preserves manual models when refresh fails and restores previous availability', async () => { + getApiTokenMock.mockResolvedValue(null); + getModelsMock.mockResolvedValue([]); + + const site = await db.insert(schema.sites).values({ + name: 'site-fail', + url: 'https://site-fail.example.com', + platform: 'new-api', + status: 'active', + }).returning().get(); + + const account = await db.insert(schema.accounts).values({ + siteId: site.id, + username: 'fail-user', + accessToken: 'session-token', + apiToken: null, + status: 'active', + }).returning().get(); + + // Existing synced model + await db.insert(schema.modelAvailability).values({ + accountId: account.id, + modelName: 'gpt-4.1', + available: true, + isManual: false, + }).run(); + + // Manual model + await db.insert(schema.modelAvailability).values({ + accountId: account.id, + modelName: 'my-custom-model', + available: true, + isManual: true, + }).run(); + + const result = await refreshModelsForAccount(account.id, { allowInactive: true }); + + expect(result).toMatchObject({ + status: 'failed', + }); + + const rows = await db.select().from(schema.modelAvailability) + .where(eq(schema.modelAvailability.accountId, account.id)) + .all(); + + // Both manual model and restored synced model should exist + const modelNames = rows.map((r) => r.modelName).sort(); + expect(modelNames).toContain('my-custom-model'); + expect(modelNames).toContain('gpt-4.1'); + + const manualRow = rows.find((r) => r.modelName === 'my-custom-model'); + expect(manualRow?.isManual).toBe(true); + }); }); diff --git a/src/server/services/modelService.siteDisable.test.ts b/src/server/services/modelService.siteDisable.test.ts new file mode 100644 index 00000000..02ffe670 --- /dev/null +++ b/src/server/services/modelService.siteDisable.test.ts @@ -0,0 +1,177 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; +import { mkdtempSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { and, eq } from 'drizzle-orm'; + +type DbModule = typeof import('../db/index.js'); +type ModelServiceModule = typeof import('./modelService.js'); + +describe('rebuildTokenRoutesFromAvailability with site disabled models', () => { + let db: DbModule['db']; + let schema: DbModule['schema']; + let rebuildTokenRoutesFromAvailability: ModelServiceModule['rebuildTokenRoutesFromAvailability']; + let dataDir = ''; + + beforeAll(async () => { + dataDir = mkdtempSync(join(tmpdir(), 'metapi-site-disabled-models-')); + process.env.DATA_DIR = dataDir; + + await import('../db/migrate.js'); + const dbModule = await import('../db/index.js'); + const modelService = await import('./modelService.js'); + + db = dbModule.db; + schema = dbModule.schema; + rebuildTokenRoutesFromAvailability = modelService.rebuildTokenRoutesFromAvailability; + }); + + beforeEach(async () => { + await db.delete(schema.routeChannels).run(); + await db.delete(schema.tokenRoutes).run(); + await db.delete(schema.tokenModelAvailability).run(); + await db.delete(schema.modelAvailability).run(); + await db.delete(schema.siteDisabledModels).run(); + await db.delete(schema.accountTokens).run(); + await db.delete(schema.accounts).run(); + await db.delete(schema.sites).run(); + }); + + afterAll(() => { + delete process.env.DATA_DIR; + }); + + it('does not create route/channel for a model disabled on its site', async () => { + const site = await db.insert(schema.sites).values({ + name: 'site-a', + url: 'https://site-a.example.com', + platform: 'new-api', + }).returning().get(); + + const account = await db.insert(schema.accounts).values({ + siteId: site.id, + username: 'user-a', + accessToken: '', + apiToken: 'sk-test-disabled', + status: 'active', + extraConfig: JSON.stringify({ credentialMode: 'apikey' }), + }).returning().get(); + + await db.insert(schema.modelAvailability).values({ + accountId: account.id, + modelName: 'gpt-4o', + available: true, + latencyMs: 500, + checkedAt: '2026-03-12T00:00:00.000Z', + }).run(); + + // Disable gpt-4o for this site + await db.insert(schema.siteDisabledModels).values({ + siteId: site.id, + modelName: 'gpt-4o', + }).run(); + + const rebuild = await rebuildTokenRoutesFromAvailability(); + + expect(rebuild.models).toBe(0); + + const routes = await db.select().from(schema.tokenRoutes) + .where(eq(schema.tokenRoutes.modelPattern, 'gpt-4o')) + .all(); + expect(routes).toHaveLength(0); + }); + + it('only blocks the disabled site, not other sites providing the same model', async () => { + const siteA = await db.insert(schema.sites).values({ + name: 'site-a', + url: 'https://site-a.example.com', + platform: 'new-api', + }).returning().get(); + + const siteB = await db.insert(schema.sites).values({ + name: 'site-b', + url: 'https://site-b.example.com', + platform: 'new-api', + }).returning().get(); + + const accountA = await db.insert(schema.accounts).values({ + siteId: siteA.id, + username: 'user-a', + accessToken: '', + apiToken: 'sk-site-a', + status: 'active', + extraConfig: JSON.stringify({ credentialMode: 'apikey' }), + }).returning().get(); + + const accountB = await db.insert(schema.accounts).values({ + siteId: siteB.id, + username: 'user-b', + accessToken: '', + apiToken: 'sk-site-b', + status: 'active', + extraConfig: JSON.stringify({ credentialMode: 'apikey' }), + }).returning().get(); + + // Both sites have the same model + await db.insert(schema.modelAvailability).values([ + { accountId: accountA.id, modelName: 'claude-sonnet-4-5-20250929', available: true, latencyMs: 300 }, + { accountId: accountB.id, modelName: 'claude-sonnet-4-5-20250929', available: true, latencyMs: 400 }, + ]).run(); + + // Disable the model only on site A + await db.insert(schema.siteDisabledModels).values({ + siteId: siteA.id, + modelName: 'claude-sonnet-4-5-20250929', + }).run(); + + const rebuild = await rebuildTokenRoutesFromAvailability(); + + expect(rebuild.models).toBe(1); + + const route = await db.select().from(schema.tokenRoutes) + .where(eq(schema.tokenRoutes.modelPattern, 'claude-sonnet-4-5-20250929')) + .get(); + expect(route).toBeDefined(); + + const channels = await db.select().from(schema.routeChannels) + .where(eq(schema.routeChannels.routeId, route!.id)) + .all(); + + // Only site B's channel should exist + expect(channels).toHaveLength(1); + expect(channels[0]?.accountId).toBe(accountB.id); + }); + + it('allows model when no disabled models are configured', async () => { + const site = await db.insert(schema.sites).values({ + name: 'normal-site', + url: 'https://normal.example.com', + platform: 'new-api', + }).returning().get(); + + const account = await db.insert(schema.accounts).values({ + siteId: site.id, + username: 'normal-user', + accessToken: '', + apiToken: 'sk-normal', + status: 'active', + extraConfig: JSON.stringify({ credentialMode: 'apikey' }), + }).returning().get(); + + await db.insert(schema.modelAvailability).values({ + accountId: account.id, + modelName: 'gpt-5', + available: true, + latencyMs: 200, + }).run(); + + const rebuild = await rebuildTokenRoutesFromAvailability(); + + expect(rebuild.models).toBe(1); + + const route = await db.select().from(schema.tokenRoutes) + .where(eq(schema.tokenRoutes.modelPattern, 'gpt-5')) + .get(); + expect(route).toBeDefined(); + }); +}); diff --git a/src/server/services/modelService.test.ts b/src/server/services/modelService.test.ts index 61d00c0f..e88d15e5 100644 --- a/src/server/services/modelService.test.ts +++ b/src/server/services/modelService.test.ts @@ -85,6 +85,120 @@ describe('rebuildTokenRoutesFromAvailability', () => { expect(channels[0]?.manualOverride).toBe(false); }); + it('ignores hidden account_tokens for direct apikey connections when rebuilding routes', async () => { + const site = await db.insert(schema.sites).values({ + name: 'apikey-legacy-site', + url: 'https://apikey-legacy.example.com', + platform: 'new-api', + }).returning().get(); + + const account = await db.insert(schema.accounts).values({ + siteId: site.id, + username: 'apikey-legacy-user', + accessToken: '', + apiToken: 'sk-direct-credential', + status: 'active', + extraConfig: JSON.stringify({ credentialMode: 'apikey' }), + }).returning().get(); + + const hiddenToken = await db.insert(schema.accountTokens).values({ + accountId: account.id, + name: 'legacy-hidden', + token: 'sk-hidden-legacy-token', + source: 'legacy', + enabled: true, + isDefault: true, + }).returning().get(); + + await db.insert(schema.modelAvailability).values({ + accountId: account.id, + modelName: 'gpt-4.1', + available: true, + latencyMs: 200, + checkedAt: '2026-03-20T08:00:00.000Z', + }).run(); + + await db.insert(schema.tokenModelAvailability).values({ + tokenId: hiddenToken.id, + modelName: 'gpt-4.1', + available: true, + latencyMs: 180, + checkedAt: '2026-03-20T08:00:00.000Z', + }).run(); + + const rebuild = await rebuildTokenRoutesFromAvailability(); + + expect(rebuild.models).toBe(1); + + const route = await db.select().from(schema.tokenRoutes) + .where(eq(schema.tokenRoutes.modelPattern, 'gpt-4.1')) + .get(); + expect(route).toBeDefined(); + + const channels = await db.select().from(schema.routeChannels) + .where(and( + eq(schema.routeChannels.routeId, route!.id), + eq(schema.routeChannels.accountId, account.id), + )) + .all(); + + expect(channels).toHaveLength(1); + expect(channels[0]?.tokenId ?? null).toBeNull(); + }); + + it('creates an exact route with an account-direct channel for oauth model availability', async () => { + const site = await db.insert(schema.sites).values({ + name: 'codex-site', + url: 'https://chatgpt.com/backend-api/codex', + platform: 'codex', + }).returning().get(); + + const account = await db.insert(schema.accounts).values({ + siteId: site.id, + username: 'codex-user@example.com', + accessToken: 'oauth-access-token', + apiToken: null, + status: 'active', + extraConfig: JSON.stringify({ + credentialMode: 'session', + oauth: { + provider: 'codex', + accountId: 'chatgpt-account-123', + email: 'codex-user@example.com', + planType: 'team', + }, + }), + }).returning().get(); + + await db.insert(schema.modelAvailability).values({ + accountId: account.id, + modelName: 'gpt-5.2-codex', + available: true, + latencyMs: 320, + checkedAt: '2026-03-17T00:00:00.000Z', + }).run(); + + const rebuild = await rebuildTokenRoutesFromAvailability(); + + expect(rebuild.models).toBe(1); + + const route = await db.select().from(schema.tokenRoutes) + .where(eq(schema.tokenRoutes.modelPattern, 'gpt-5.2-codex')) + .get(); + expect(route).toBeDefined(); + + const channels = await db.select().from(schema.routeChannels) + .where(and( + eq(schema.routeChannels.routeId, route!.id), + eq(schema.routeChannels.accountId, account.id), + )) + .all(); + + expect(channels).toHaveLength(1); + expect(channels[0]?.tokenId ?? null).toBeNull(); + expect(channels[0]?.manualOverride).toBe(false); + }); + it('removes stale exact routes and keeps wildcard routes on rebuild', async () => { const site = await db.insert(schema.sites).values({ name: 'site-1', diff --git a/src/server/services/modelService.ts b/src/server/services/modelService.ts index 1c251901..28d021e4 100644 --- a/src/server/services/modelService.ts +++ b/src/server/services/modelService.ts @@ -1,269 +1,932 @@ import { and, eq } from 'drizzle-orm'; import { db, schema } from '../db/index.js'; import { getAdapter } from './platforms/index.js'; -import { ensureDefaultTokenForAccount, getPreferredAccountToken } from './accountTokenService.js'; -import { getCredentialModeFromExtraConfig, resolvePlatformUserId } from './accountExtraConfig.js'; +import { + ACCOUNT_TOKEN_VALUE_STATUS_READY, + ensureDefaultTokenForAccount, + getPreferredAccountToken, + isMaskedTokenValue, + isUsableAccountToken, +} from './accountTokenService.js'; +import { + getCredentialModeFromExtraConfig, + getProxyUrlFromExtraConfig, + mergeAccountExtraConfig, + requiresManagedAccountTokens, + resolvePlatformUserId, + supportsDirectAccountRoutingConnection, +} from './accountExtraConfig.js'; import { invalidateTokenRouterCache } from './tokenRouter.js'; +import { getBlockedBrandRules, isModelBlockedByBrand } from './brandMatcher.js'; +import { config } from '../config.js'; import { setAccountRuntimeHealth } from './accountHealthService.js'; import { clearAllRouteDecisionSnapshots } from './routeDecisionSnapshotStore.js'; - -const API_TOKEN_DISCOVERY_TIMEOUT_MS = 8_000; -const MODEL_DISCOVERY_TIMEOUT_MS = 12_000; -const MODEL_REFRESH_BATCH_SIZE = 3; - +import { withAccountProxyOverride } from './siteProxy.js'; +import { isCodexPlatform } from './oauth/codexAccount.js'; +import { buildStoredOauthStateFromAccount, getOauthInfoFromAccount } from './oauth/oauthAccount.js'; +import { refreshOauthAccessTokenSingleflight } from './oauth/refreshSingleflight.js'; +import { + discoverAntigravityModelsFromCloud, + discoverClaudeModelsFromCloud, + discoverCodexModelsFromCloud, + validateGeminiCliOauthConnection, +} from './platformDiscoveryRegistry.js'; + +const API_TOKEN_DISCOVERY_TIMEOUT_MS = 8_000; +const MODEL_DISCOVERY_TIMEOUT_MS = 12_000; +const MODEL_REFRESH_BATCH_SIZE = 3; +const GEMINI_CLI_STATIC_MODELS = [ + 'gemini-2.5-pro', + 'gemini-2.5-flash', + 'gemini-2.5-flash-lite', + 'gemini-3-pro-preview', + 'gemini-3.1-pro-preview', + 'gemini-3-flash-preview', + 'gemini-3.1-flash-lite-preview', +]; +let inFlightRefreshModelsAndRebuildRoutes: Promise<{ + refresh: ModelRefreshResult[]; + rebuild: Awaited>; +}> | null = null; + +type ModelRefreshErrorCode = 'timeout' | 'unauthorized' | 'empty_models' | 'unknown'; +type ModelRefreshSkipCode = 'site_disabled' | 'adapter_or_status'; + +export type ModelRefreshAccountNotFoundResult = { + accountId: number; + refreshed: false; + status: 'failed'; + errorCode: 'account_not_found'; + errorMessage: '账号不存在'; + modelCount: 0; + modelsPreview: string[]; + reason: 'account_not_found'; +}; + +export type ModelRefreshSkippedResult = { + accountId: number; + refreshed: false; + status: 'skipped'; + errorCode: ModelRefreshSkipCode; + errorMessage: string; + modelCount: 0; + modelsPreview: string[]; + reason: ModelRefreshSkipCode; +}; + +export type ModelRefreshFailureResult = { + accountId: number; + refreshed: true; + status: 'failed'; + errorCode: ModelRefreshErrorCode; + errorMessage: string; + modelCount: 0; + modelsPreview: string[]; + tokenScanned: number; + discoveredByCredential: boolean; + discoveredApiToken: boolean; +}; + +export type ModelRefreshSuccessResult = { + accountId: number; + refreshed: true; + status: 'success'; + errorCode: null; + errorMessage: ''; + modelCount: number; + modelsPreview: string[]; + tokenScanned: number; + discoveredByCredential: boolean; + discoveredApiToken: boolean; +}; + +export type ModelRefreshResult = + | ModelRefreshAccountNotFoundResult + | ModelRefreshSkippedResult + | ModelRefreshFailureResult + | ModelRefreshSuccessResult; + +function classifyModelDiscoveryError(message: string): ModelRefreshErrorCode { + const lowered = message.toLowerCase(); + if (lowered.includes('timeout') || lowered.includes('timed out') || lowered.includes('请求超时')) return 'timeout'; + if (lowered.includes('http 401') || lowered.includes('http 403') + || lowered.includes('unauthorized') || lowered.includes('invalid') + || lowered.includes('无权') || lowered.includes('未提供令牌')) return 'unauthorized'; + return 'unknown'; +} + +function buildModelFailureMessage(code: ModelRefreshErrorCode, fallback?: string) { + if (code === 'timeout') return '模型获取失败(请求超时)'; + if (code === 'unauthorized') return '模型获取失败,API Key 已无效'; + if (code === 'empty_models') return '模型获取失败:未获取到可用模型'; + return fallback || '模型获取失败'; +} + function isSiteDisabled(status?: string | null): boolean { return (status || 'active') === 'disabled'; } -function isApiKeyConnection(account: typeof schema.accounts.$inferSelect): boolean { - const explicit = getCredentialModeFromExtraConfig(account.extraConfig); - if (explicit && explicit !== 'auto') return explicit === 'apikey'; - return !(account.accessToken || '').trim(); +function normalizeModels(models: string[]): string[] { + const normalizedModels: string[] = []; + const seen = new Set(); + + for (const rawModel of models) { + if (typeof rawModel !== 'string') continue; + const modelName = rawModel.trim(); + if (!modelName) continue; + + // Keep app/database behavior stable across SQLite/MySQL by deduping with a + // case-insensitive key after trimming whitespace. + const dedupeKey = modelName.toLowerCase(); + if (seen.has(dedupeKey)) continue; + seen.add(dedupeKey); + normalizedModels.push(modelName); + } + + return normalizedModels; } - -function normalizeModels(models: string[]): string[] { - return Array.from(new Set(models.filter((model) => typeof model === 'string' && model.trim().length > 0))); -} - -function isExactModelPattern(modelPattern: string): boolean { - const normalized = modelPattern.trim(); - if (!normalized) return false; - if (normalized.toLowerCase().startsWith('re:')) return false; - return !/[\*\?\[]/.test(normalized); -} - -async function withTimeout(fn: () => Promise, timeoutMs: number, timeoutMessage: string): Promise { - let timer: ReturnType | null = null; - try { - return await Promise.race([ - fn(), - new Promise((_, reject) => { - timer = setTimeout(() => reject(new Error(timeoutMessage)), timeoutMs); - }), - ]); - } finally { - if (timer) clearTimeout(timer); - } -} - -export async function refreshModelsForAccount(accountId: number) { - const row = await db.select().from(schema.accounts) - .innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id)) - .where(eq(schema.accounts.id, accountId)) - .get(); - - if (!row) { - return { accountId, refreshed: false, modelCount: 0, reason: 'account_not_found' }; - } - - const account = row.accounts; - const site = row.sites; - const adapter = getAdapter(site.platform); - - const accountTokens = await db.select() - .from(schema.accountTokens) - .where(eq(schema.accountTokens.accountId, accountId)) - .all(); - - await db.delete(schema.modelAvailability) - .where(eq(schema.modelAvailability.accountId, accountId)) - .run(); - - for (const token of accountTokens) { - await db.delete(schema.tokenModelAvailability) - .where(eq(schema.tokenModelAvailability.tokenId, token.id)) - .run(); - } - - if (isSiteDisabled(site.status)) { - return { accountId, refreshed: false, modelCount: 0, reason: 'site_disabled' }; - } - - if (!adapter || account.status !== 'active') { - return { accountId, refreshed: false, modelCount: 0, reason: 'adapter_or_status' }; - } - - const platformUserId = resolvePlatformUserId(account.extraConfig, account.username); - let discoveredApiToken: string | null = null; - - if (!account.apiToken && account.accessToken) { - try { - discoveredApiToken = await withTimeout( - () => adapter.getApiToken(site.url, account.accessToken, platformUserId), - API_TOKEN_DISCOVERY_TIMEOUT_MS, - `api token discovery timeout (${Math.round(API_TOKEN_DISCOVERY_TIMEOUT_MS / 1000)}s)`, - ); - if (discoveredApiToken) { - ensureDefaultTokenForAccount(account.id, discoveredApiToken, { name: 'default', source: 'sync' }); - await db.update(schema.accounts).set({ - apiToken: discoveredApiToken, - updatedAt: new Date().toISOString(), - }).where(eq(schema.accounts.id, account.id)).run(); - } - } catch {} - } - - let enabledTokens = await db.select() - .from(schema.accountTokens) - .where(and(eq(schema.accountTokens.accountId, account.id), eq(schema.accountTokens.enabled, true))) - .all(); - - // Last fallback: if still no managed token but account has a legacy apiToken, mirror it into token table. - if (!isApiKeyConnection(account) && enabledTokens.length === 0) { - const fallback = discoveredApiToken || account.apiToken || null; - if (fallback) { - ensureDefaultTokenForAccount(account.id, fallback, { name: 'default', source: 'legacy' }); - enabledTokens = await db.select() - .from(schema.accountTokens) - .where(and(eq(schema.accountTokens.accountId, account.id), eq(schema.accountTokens.enabled, true))) - .all(); - } - } - - const accountModels = new Set(); - const modelLatency = new Map(); - let scannedTokenCount = 0; - let discoveredByCredential = false; - const attemptedCredentials = new Set(); - - const mergeDiscoveredModels = (models: string[], latencyMs: number | null) => { - for (const modelName of models) { - accountModels.add(modelName); - const prev = modelLatency.get(modelName); - if (prev === undefined || prev === null) { - modelLatency.set(modelName, latencyMs); - continue; - } - if (latencyMs === null) continue; - if (latencyMs < prev) modelLatency.set(modelName, latencyMs); - } - }; - - const discoverModelsWithCredential = async (credentialRaw: string | null | undefined) => { - const credential = (credentialRaw || '').trim(); - if (!credential) return; - if (attemptedCredentials.has(credential)) return; - attemptedCredentials.add(credential); - - const startedAt = Date.now(); - let models: string[] = []; - try { - models = normalizeModels( - await withTimeout( - () => adapter.getModels(site.url, credential, platformUserId), - MODEL_DISCOVERY_TIMEOUT_MS, - `model discovery timeout (${Math.round(MODEL_DISCOVERY_TIMEOUT_MS / 1000)}s)`, - ), - ); - } catch { - models = []; - } - if (models.length === 0) return; - discoveredByCredential = true; - const latencyMs = Date.now() - startedAt; - mergeDiscoveredModels(models, latencyMs); - }; - - // Prefer account-level credential discovery so model availability does not rely on managed tokens. - await discoverModelsWithCredential(account.apiToken); - await discoverModelsWithCredential(discoveredApiToken); - await discoverModelsWithCredential(account.accessToken); - - for (const token of enabledTokens) { - const startedAt = Date.now(); - let models: string[] = []; - - try { - models = normalizeModels( - await withTimeout( - () => adapter.getModels(site.url, token.token, platformUserId), - MODEL_DISCOVERY_TIMEOUT_MS, - `model discovery timeout (${Math.round(MODEL_DISCOVERY_TIMEOUT_MS / 1000)}s)`, - ), - ); - } catch { - models = []; - } - - if (models.length === 0) continue; - - const latencyMs = Date.now() - startedAt; - const checkedAt = new Date().toISOString(); - - await db.insert(schema.tokenModelAvailability).values( - models.map((modelName) => ({ - tokenId: token.id, - modelName, - available: true, - latencyMs, - checkedAt, - })), - ).run(); - - scannedTokenCount++; - mergeDiscoveredModels(models, latencyMs); - } - - if (accountModels.size > 0) { + +async function updateOauthModelDiscoveryState(input: { + account: typeof schema.accounts.$inferSelect; + checkedAt: string; + status: 'healthy' | 'abnormal'; + lastModelSyncError?: string; + lastDiscoveredModels?: string[]; +}) { + const oauth = getOauthInfoFromAccount(input.account); + if (!oauth) return input.account.extraConfig || null; + const extraConfig = mergeAccountExtraConfig(input.account.extraConfig, { + oauth: buildStoredOauthStateFromAccount(input.account, { + provider: oauth.provider, + modelDiscoveryStatus: input.status, + lastModelSyncAt: input.checkedAt, + lastModelSyncError: input.lastModelSyncError, + lastDiscoveredModels: input.lastDiscoveredModels ?? [], + }), + }); + await db.update(schema.accounts).set({ + extraConfig, + updatedAt: input.checkedAt, + }).where(eq(schema.accounts.id, input.account.id)).run(); + return extraConfig; +} + +function isExactModelPattern(modelPattern: string): boolean { + const normalized = modelPattern.trim(); + if (!normalized) return false; + if (normalized.toLowerCase().startsWith('re:')) return false; + return !/[\*\?]/.test(normalized); +} + +async function withTimeout(fn: () => Promise, timeoutMs: number, timeoutMessage: string): Promise { + let timer: ReturnType | null = null; + try { + return await Promise.race([ + fn(), + new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error(timeoutMessage)), timeoutMs); + }), + ]); + } finally { + if (timer) clearTimeout(timer); + } +} + +function buildAccountNotFoundRefreshResult(accountId: number): ModelRefreshAccountNotFoundResult { + return { + accountId, + refreshed: false, + status: 'failed', + errorCode: 'account_not_found', + errorMessage: '账号不存在', + modelCount: 0, + modelsPreview: [], + reason: 'account_not_found', + }; +} + +function buildSkippedRefreshResult( + accountId: number, + code: ModelRefreshSkipCode, + errorMessage: string, +): ModelRefreshSkippedResult { + return { + accountId, + refreshed: false, + status: 'skipped', + errorCode: code, + errorMessage, + modelCount: 0, + modelsPreview: [], + reason: code, + }; +} + +function buildFailedRefreshResult(input: { + accountId: number; + errorCode: ModelRefreshErrorCode; + errorMessage: string; + tokenScanned: number; + discoveredByCredential: boolean; + discoveredApiToken: boolean; +}): ModelRefreshFailureResult { + return { + accountId: input.accountId, + refreshed: true, + status: 'failed', + errorCode: input.errorCode, + errorMessage: input.errorMessage, + modelCount: 0, + modelsPreview: [], + tokenScanned: input.tokenScanned, + discoveredByCredential: input.discoveredByCredential, + discoveredApiToken: input.discoveredApiToken, + }; +} + +function buildSuccessfulRefreshResult(input: { + accountId: number; + modelCount: number; + modelsPreview: string[]; + tokenScanned: number; + discoveredByCredential: boolean; + discoveredApiToken: boolean; +}): ModelRefreshSuccessResult { + return { + accountId: input.accountId, + refreshed: true, + status: 'success', + errorCode: null, + errorMessage: '', + modelCount: input.modelCount, + modelsPreview: input.modelsPreview, + tokenScanned: input.tokenScanned, + discoveredByCredential: input.discoveredByCredential, + discoveredApiToken: input.discoveredApiToken, + }; +} + +function shouldRetryModelDiscoveryWithOauthRefresh(error: unknown): boolean { + const message = ((error as { message?: string })?.message || '').toLowerCase(); + return message.includes('http 401') + || message.includes('unauthorized') + || message.includes('unauthenticated'); +} + +export async function refreshModelsForAccount( + accountId: number, + options?: { allowInactive?: boolean }, +): Promise { + const row = await db.select().from(schema.accounts) + .innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id)) + .where(eq(schema.accounts.id, accountId)) + .get(); + + if (!row) { + return buildAccountNotFoundRefreshResult(accountId); + } + + const account = row.accounts; + const site = row.sites; + const oauth = getOauthInfoFromAccount(account); + const adapter = getAdapter(site.platform); + const accountProxyUrl = getProxyUrlFromExtraConfig(account.extraConfig); + + const restoreAvailabilityOnFailure = options?.allowInactive === true; + const previousAccountTokens = restoreAvailabilityOnFailure + ? await db.select() + .from(schema.accountTokens) + .where(eq(schema.accountTokens.accountId, accountId)) + .all() + : []; + const previousModelAvailability = restoreAvailabilityOnFailure + ? await db.select() + .from(schema.modelAvailability) + .where(and( + eq(schema.modelAvailability.accountId, accountId), + eq(schema.modelAvailability.isManual, false), + )) + .all() + : []; + const previousTokenModelAvailability = restoreAvailabilityOnFailure + ? (await Promise.all(previousAccountTokens.map(async (token) => db.select() + .from(schema.tokenModelAvailability) + .where(eq(schema.tokenModelAvailability.tokenId, token.id)) + .all()))).flat() + : []; + + const clearExistingAvailability = async () => { + await db.delete(schema.modelAvailability) + .where(and( + eq(schema.modelAvailability.accountId, accountId), + eq(schema.modelAvailability.isManual, false), + )) + .run(); + + const currentAccountTokens = await db.select({ id: schema.accountTokens.id }) + .from(schema.accountTokens) + .where(eq(schema.accountTokens.accountId, accountId)) + .all(); + + for (const token of currentAccountTokens) { + await db.delete(schema.tokenModelAvailability) + .where(eq(schema.tokenModelAvailability.tokenId, token.id)) + .run(); + } + }; + + const restorePreviousAvailability = async () => { + if (!restoreAvailabilityOnFailure) return; + await clearExistingAvailability(); + if (previousModelAvailability.length > 0) { + await db.insert(schema.modelAvailability).values( + previousModelAvailability.map(({ id: _id, ...row }) => row), + ).run(); + } + if (previousTokenModelAvailability.length > 0) { + await db.insert(schema.tokenModelAvailability).values( + previousTokenModelAvailability.map(({ id: _id, ...row }) => row), + ).run(); + } + }; + + await clearExistingAvailability(); + + // Collect manual model names so discovered models that collide are skipped (unique index). + const manualModelNames = new Set( + (await db.select({ modelName: schema.modelAvailability.modelName }) + .from(schema.modelAvailability) + .where(and( + eq(schema.modelAvailability.accountId, accountId), + eq(schema.modelAvailability.isManual, true), + )) + .all() + ).map((r) => r.modelName.toLowerCase()), + ); + + if (isSiteDisabled(site.status)) { + return buildSkippedRefreshResult(accountId, 'site_disabled', '站点已禁用'); + } + + if (account.status !== 'active' && !options?.allowInactive) { + return buildSkippedRefreshResult(accountId, 'adapter_or_status', '平台不可用或账号未激活'); + } + + if (oauth?.provider === 'codex') { const checkedAt = new Date().toISOString(); - await db.insert(schema.modelAvailability).values( - Array.from(accountModels).map((modelName) => ({ - accountId: account.id, - modelName, - available: true, - latencyMs: modelLatency.get(modelName) ?? null, - checkedAt, - })), - ).run(); + const startedAt = Date.now(); + try { + const codexModels = await withTimeout( + () => withAccountProxyOverride(accountProxyUrl, + () => discoverCodexModelsFromCloud({ site, account })), + MODEL_DISCOVERY_TIMEOUT_MS, + `codex model discovery timeout (${Math.round(MODEL_DISCOVERY_TIMEOUT_MS / 1000)}s)`, + ); + if (codexModels.length === 0) { + throw new Error('未获取到可用模型'); + } - if (isApiKeyConnection(account)) { + const newCodexModels = codexModels.filter((m) => !manualModelNames.has(m.toLowerCase())); + if (newCodexModels.length > 0) { + await db.insert(schema.modelAvailability).values( + newCodexModels.map((modelName) => ({ + accountId, + modelName, + available: true, + latencyMs: Date.now() - startedAt, + checkedAt, + })), + ).run(); + } + await updateOauthModelDiscoveryState({ + account, + checkedAt, + status: 'healthy', + lastDiscoveredModels: codexModels, + }); + await setAccountRuntimeHealth(accountId, { + state: 'healthy', + reason: 'Codex 云端模型探测成功', + source: 'model-discovery', + checkedAt, + }); + return buildSuccessfulRefreshResult({ + accountId, + modelCount: codexModels.length, + modelsPreview: codexModels.slice(0, 10), + tokenScanned: 0, + discoveredByCredential: true, + discoveredApiToken: false, + }); + } catch (err) { + const rawMessage = (err as { message?: string })?.message || 'codex model discovery failed'; + const errorCode = classifyModelDiscoveryError(rawMessage); + const errorMessage = `Codex 模型获取失败(${rawMessage})`; + await updateOauthModelDiscoveryState({ + account, + checkedAt, + status: 'abnormal', + lastModelSyncError: errorMessage, + lastDiscoveredModels: [], + }); await setAccountRuntimeHealth(account.id, { + state: 'unhealthy', + reason: errorMessage, + source: 'model-discovery', + checkedAt, + }); + await restorePreviousAvailability(); + return buildFailedRefreshResult({ + accountId, + errorCode, + errorMessage, + tokenScanned: 0, + discoveredByCredential: false, + discoveredApiToken: false, + }); + } + } + + if (oauth?.provider === 'claude') { + const checkedAt = new Date().toISOString(); + const startedAt = Date.now(); + try { + const claudeModels = await withTimeout( + () => withAccountProxyOverride(accountProxyUrl, + () => discoverClaudeModelsFromCloud({ site, account })), + MODEL_DISCOVERY_TIMEOUT_MS, + `claude oauth model discovery timeout (${Math.round(MODEL_DISCOVERY_TIMEOUT_MS / 1000)}s)`, + ); + if (claudeModels.length === 0) { + throw new Error('未获取到可用模型'); + } + const newClaudeModels = claudeModels.filter((m) => !manualModelNames.has(m.toLowerCase())); + if (newClaudeModels.length > 0) { + await db.insert(schema.modelAvailability).values( + newClaudeModels.map((modelName) => ({ + accountId, + modelName, + available: true, + latencyMs: Date.now() - startedAt, + checkedAt, + })), + ).run(); + } + await updateOauthModelDiscoveryState({ + account, + checkedAt, + status: 'healthy', + lastDiscoveredModels: claudeModels, + }); + await setAccountRuntimeHealth(accountId, { state: 'healthy', - reason: '模型探测成功', + reason: 'Claude OAuth 模型探测成功', + source: 'model-discovery', + checkedAt, + }); + return buildSuccessfulRefreshResult({ + accountId, + modelCount: claudeModels.length, + modelsPreview: claudeModels.slice(0, 10), + tokenScanned: 0, + discoveredByCredential: true, + discoveredApiToken: false, + }); + } catch (err) { + const rawMessage = (err as { message?: string })?.message || 'claude oauth model discovery failed'; + const errorCode = classifyModelDiscoveryError(rawMessage); + const errorMessage = `Claude OAuth 模型获取失败(${rawMessage})`; + await updateOauthModelDiscoveryState({ + account, + checkedAt, + status: 'abnormal', + lastModelSyncError: errorMessage, + lastDiscoveredModels: [], + }); + await setAccountRuntimeHealth(account.id, { + state: 'unhealthy', + reason: errorMessage, source: 'model-discovery', checkedAt, }); + await restorePreviousAvailability(); + return buildFailedRefreshResult({ + accountId, + errorCode, + errorMessage, + tokenScanned: 0, + discoveredByCredential: false, + discoveredApiToken: false, + }); } } - - return { - accountId, - refreshed: true, - modelCount: accountModels.size, - tokenScanned: scannedTokenCount, - discoveredByCredential, - discoveredApiToken: !!discoveredApiToken, - }; -} - -async function refreshModelsForAllActiveAccounts() { - const accounts = await db.select({ id: schema.accounts.id }).from(schema.accounts) - .where(eq(schema.accounts.status, 'active')) - .all(); - - const results: any[] = []; - for (let offset = 0; offset < accounts.length; offset += MODEL_REFRESH_BATCH_SIZE) { - const batch = accounts.slice(offset, offset + MODEL_REFRESH_BATCH_SIZE); - const batchResults = await Promise.all(batch.map(async (account) => refreshModelsForAccount(account.id))); - results.push(...batchResults); - } - return results; -} - + + if (oauth?.provider === 'gemini-cli') { + const checkedAt = new Date().toISOString(); + const startedAt = Date.now(); + let discoveryAccount = account; + try { + try { + await withTimeout( + () => withAccountProxyOverride(accountProxyUrl, + () => validateGeminiCliOauthConnection({ account: discoveryAccount })), + MODEL_DISCOVERY_TIMEOUT_MS, + `gemini cli oauth validation timeout (${Math.round(MODEL_DISCOVERY_TIMEOUT_MS / 1000)}s)`, + ); + } catch (error) { + if (!shouldRetryModelDiscoveryWithOauthRefresh(error)) { + throw error; + } + const refreshed = await refreshOauthAccessTokenSingleflight(discoveryAccount.id); + if (!refreshed?.extraConfig) { + throw error; + } + discoveryAccount = { + ...discoveryAccount, + accessToken: refreshed.accessToken, + extraConfig: refreshed.extraConfig, + }; + await withTimeout( + () => withAccountProxyOverride(accountProxyUrl, + () => validateGeminiCliOauthConnection({ account: discoveryAccount })), + MODEL_DISCOVERY_TIMEOUT_MS, + `gemini cli oauth validation timeout (${Math.round(MODEL_DISCOVERY_TIMEOUT_MS / 1000)}s)`, + ); + } + const newGeminiModels = GEMINI_CLI_STATIC_MODELS.filter((m) => !manualModelNames.has(m.toLowerCase())); + if (newGeminiModels.length > 0) { + await db.insert(schema.modelAvailability).values( + newGeminiModels.map((modelName) => ({ + accountId, + modelName, + available: true, + latencyMs: Date.now() - startedAt, + checkedAt, + })), + ).run(); + } + await updateOauthModelDiscoveryState({ + account: discoveryAccount, + checkedAt, + status: 'healthy', + lastDiscoveredModels: GEMINI_CLI_STATIC_MODELS, + }); + await setAccountRuntimeHealth(accountId, { + state: 'healthy', + reason: 'Gemini CLI OAuth 健康探测成功', + source: 'model-discovery', + checkedAt, + }); + return buildSuccessfulRefreshResult({ + accountId, + modelCount: GEMINI_CLI_STATIC_MODELS.length, + modelsPreview: GEMINI_CLI_STATIC_MODELS.slice(0, 10), + tokenScanned: 0, + discoveredByCredential: true, + discoveredApiToken: false, + }); + } catch (err) { + const rawMessage = (err as { message?: string })?.message || 'gemini cli oauth validation failed'; + const errorCode = classifyModelDiscoveryError(rawMessage); + const errorMessage = `Gemini CLI 模型获取失败(${rawMessage})`; + await updateOauthModelDiscoveryState({ + account: discoveryAccount, + checkedAt, + status: 'abnormal', + lastModelSyncError: errorMessage, + lastDiscoveredModels: [], + }); + await setAccountRuntimeHealth(account.id, { + state: 'unhealthy', + reason: errorMessage, + source: 'model-discovery', + checkedAt, + }); + await restorePreviousAvailability(); + return buildFailedRefreshResult({ + accountId, + errorCode, + errorMessage, + tokenScanned: 0, + discoveredByCredential: false, + discoveredApiToken: false, + }); + } + } + + if (oauth?.provider === 'antigravity') { + const checkedAt = new Date().toISOString(); + const startedAt = Date.now(); + try { + const antigravityModels = await withTimeout( + () => withAccountProxyOverride(accountProxyUrl, + () => discoverAntigravityModelsFromCloud({ site, account })), + MODEL_DISCOVERY_TIMEOUT_MS, + `antigravity model discovery timeout (${Math.round(MODEL_DISCOVERY_TIMEOUT_MS / 1000)}s)`, + ); + if (antigravityModels.length === 0) { + throw new Error('未获取到可用模型'); + } + + const newAntigravityModels = antigravityModels.filter((m) => !manualModelNames.has(m.toLowerCase())); + if (newAntigravityModels.length > 0) { + await db.insert(schema.modelAvailability).values( + newAntigravityModels.map((modelName) => ({ + accountId, + modelName, + available: true, + latencyMs: Date.now() - startedAt, + checkedAt, + })), + ).run(); + } + await updateOauthModelDiscoveryState({ + account, + checkedAt, + status: 'healthy', + lastDiscoveredModels: antigravityModels, + }); + await setAccountRuntimeHealth(accountId, { + state: 'healthy', + reason: 'Antigravity OAuth 健康探测成功', + source: 'model-discovery', + checkedAt, + }); + return buildSuccessfulRefreshResult({ + accountId, + modelCount: antigravityModels.length, + modelsPreview: antigravityModels.slice(0, 10), + tokenScanned: 0, + discoveredByCredential: true, + discoveredApiToken: false, + }); + } catch (err) { + const rawMessage = (err as { message?: string })?.message || 'antigravity model discovery failed'; + const errorCode = classifyModelDiscoveryError(rawMessage); + const errorMessage = `Antigravity 模型获取失败(${rawMessage})`; + await updateOauthModelDiscoveryState({ + account, + checkedAt, + status: 'abnormal', + lastModelSyncError: errorMessage, + lastDiscoveredModels: [], + }); + await setAccountRuntimeHealth(account.id, { + state: 'unhealthy', + reason: errorMessage, + source: 'model-discovery', + checkedAt, + }); + await restorePreviousAvailability(); + return buildFailedRefreshResult({ + accountId, + errorCode, + errorMessage, + tokenScanned: 0, + discoveredByCredential: false, + discoveredApiToken: false, + }); + } + } + + if (!adapter) { + return buildSkippedRefreshResult(accountId, 'adapter_or_status', '平台不可用或账号未激活'); + } + + const platformUserId = resolvePlatformUserId(account.extraConfig, account.username); + let discoveredApiToken: string | null = null; + + if (!account.apiToken && account.accessToken) { + try { + discoveredApiToken = await withTimeout( + () => withAccountProxyOverride(accountProxyUrl, + () => adapter.getApiToken(site.url, account.accessToken, platformUserId)), + API_TOKEN_DISCOVERY_TIMEOUT_MS, + `api token discovery timeout (${Math.round(API_TOKEN_DISCOVERY_TIMEOUT_MS / 1000)}s)`, + ); + if (discoveredApiToken && !isMaskedTokenValue(discoveredApiToken)) { + ensureDefaultTokenForAccount(account.id, discoveredApiToken, { name: 'default', source: 'sync' }); + await db.update(schema.accounts).set({ + apiToken: discoveredApiToken, + updatedAt: new Date().toISOString(), + }).where(eq(schema.accounts.id, account.id)).run(); + } else { + discoveredApiToken = null; + } + } catch { } + } + + const usesManagedTokens = requiresManagedAccountTokens(account); + let enabledTokens = usesManagedTokens + ? await db.select() + .from(schema.accountTokens) + .where(and( + eq(schema.accountTokens.accountId, account.id), + eq(schema.accountTokens.enabled, true), + eq(schema.accountTokens.valueStatus, ACCOUNT_TOKEN_VALUE_STATUS_READY), + )) + .all() + : []; + enabledTokens = enabledTokens.filter(isUsableAccountToken); + + // Last fallback: if still no managed token but account has a legacy apiToken, mirror it into token table. + if (usesManagedTokens && enabledTokens.length === 0) { + const fallback = discoveredApiToken || account.apiToken || null; + if (fallback) { + ensureDefaultTokenForAccount(account.id, fallback, { name: 'default', source: 'legacy' }); + enabledTokens = await db.select() + .from(schema.accountTokens) + .where(and( + eq(schema.accountTokens.accountId, account.id), + eq(schema.accountTokens.enabled, true), + eq(schema.accountTokens.valueStatus, ACCOUNT_TOKEN_VALUE_STATUS_READY), + )) + .all(); + enabledTokens = enabledTokens.filter(isUsableAccountToken); + } + } + + const accountModels = new Map(); // lowercase key → original name (first-wins) + const modelLatency = new Map(); + let scannedTokenCount = 0; + let discoveredByCredential = false; + const attemptedCredentials = new Set(); + const failureMessages: string[] = []; + const recordFailure = (err: unknown) => { + const message = (err as { message?: string })?.message || String(err || ''); + if (message) failureMessages.push(message); + }; + + const mergeDiscoveredModels = (models: string[], latencyMs: number | null) => { + for (const modelName of models) { + const key = modelName.toLowerCase(); + if (!accountModels.has(key)) accountModels.set(key, modelName); + const prev = modelLatency.get(key); + if (prev === undefined || prev === null) { + modelLatency.set(key, latencyMs); + continue; + } + if (latencyMs === null) continue; + if (latencyMs < prev) modelLatency.set(key, latencyMs); + } + }; + + const discoverModelsWithCredential = async (credentialRaw: string | null | undefined) => { + const credential = (credentialRaw || '').trim(); + if (!credential) return; + if (isMaskedTokenValue(credential)) return; + if (attemptedCredentials.has(credential)) return; + attemptedCredentials.add(credential); + + const startedAt = Date.now(); + let models: string[] = []; + try { + models = normalizeModels( + await withTimeout( + () => withAccountProxyOverride(accountProxyUrl, + () => adapter.getModels(site.url, credential, platformUserId)), + MODEL_DISCOVERY_TIMEOUT_MS, + `model discovery timeout (${Math.round(MODEL_DISCOVERY_TIMEOUT_MS / 1000)}s)`, + ), + ); + } catch (err) { + recordFailure(err); + models = []; + } + if (models.length === 0) return; + discoveredByCredential = true; + const latencyMs = Date.now() - startedAt; + mergeDiscoveredModels(models, latencyMs); + }; + + // Prefer account-level credential discovery so model availability does not rely on managed tokens. + await discoverModelsWithCredential(account.apiToken); + await discoverModelsWithCredential(discoveredApiToken); + await discoverModelsWithCredential(account.accessToken); + + for (const token of enabledTokens) { + const startedAt = Date.now(); + let models: string[] = []; + + try { + models = normalizeModels( + await withTimeout( + () => withAccountProxyOverride(accountProxyUrl, + () => adapter.getModels(site.url, token.token, platformUserId)), + MODEL_DISCOVERY_TIMEOUT_MS, + `model discovery timeout (${Math.round(MODEL_DISCOVERY_TIMEOUT_MS / 1000)}s)`, + ), + ); + } catch (err) { + recordFailure(err); + models = []; + } + + if (models.length === 0) continue; + + const latencyMs = Date.now() - startedAt; + const checkedAt = new Date().toISOString(); + + await db.insert(schema.tokenModelAvailability).values( + models.map((modelName) => ({ + tokenId: token.id, + modelName, + available: true, + latencyMs, + checkedAt, + })), + ).run(); + + scannedTokenCount++; + mergeDiscoveredModels(models, latencyMs); + } + + if (accountModels.size === 0) { + const firstMessage = failureMessages[0] || ''; + const errorCode = firstMessage ? classifyModelDiscoveryError(firstMessage) : 'empty_models'; + const errorMessage = buildModelFailureMessage(errorCode, firstMessage); + await setAccountRuntimeHealth(account.id, { + state: 'unhealthy', + reason: errorMessage, + source: 'model-discovery', + checkedAt: new Date().toISOString(), + }); + await restorePreviousAvailability(); + return buildFailedRefreshResult({ + accountId, + errorCode, + errorMessage, + tokenScanned: scannedTokenCount, + discoveredByCredential, + discoveredApiToken: !!discoveredApiToken, + }); + } + + const checkedAt = new Date().toISOString(); + const newAccountModels = Array.from(accountModels.values()).filter((m) => !manualModelNames.has(m.toLowerCase())); + if (newAccountModels.length > 0) { + await db.insert(schema.modelAvailability).values( + newAccountModels.map((modelName) => ({ + accountId: account.id, + modelName, + available: true, + latencyMs: modelLatency.get(modelName.toLowerCase()) ?? null, + checkedAt, + })), + ).run(); + } + + await setAccountRuntimeHealth(account.id, { + state: 'healthy', + reason: '模型探测成功', + source: 'model-discovery', + checkedAt, + }); + + const modelsPreview = Array.from(accountModels.values()).slice(0, 10); + return buildSuccessfulRefreshResult({ + accountId, + modelCount: accountModels.size, + modelsPreview, + tokenScanned: scannedTokenCount, + discoveredByCredential, + discoveredApiToken: !!discoveredApiToken, + }); +} + +async function refreshModelsForAllActiveAccounts(): Promise { + const accounts = await db.select({ id: schema.accounts.id }).from(schema.accounts) + .where(eq(schema.accounts.status, 'active')) + .all(); + + const results: ModelRefreshResult[] = []; + for (let offset = 0; offset < accounts.length; offset += MODEL_REFRESH_BATCH_SIZE) { + const batch = accounts.slice(offset, offset + MODEL_REFRESH_BATCH_SIZE); + const batchResults = await Promise.all(batch.map(async (account) => refreshModelsForAccount(account.id))); + results.push(...batchResults); + } + return results; +} + export async function rebuildTokenRoutesFromAvailability() { const tokenRows = await db.select().from(schema.tokenModelAvailability) - .innerJoin(schema.accountTokens, eq(schema.tokenModelAvailability.tokenId, schema.accountTokens.id)) - .innerJoin(schema.accounts, eq(schema.accountTokens.accountId, schema.accounts.id)) - .innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id)) - .where( - and( - eq(schema.tokenModelAvailability.available, true), - eq(schema.accountTokens.enabled, true), - eq(schema.accounts.status, 'active'), - eq(schema.sites.status, 'active'), - ), + .innerJoin(schema.accountTokens, eq(schema.tokenModelAvailability.tokenId, schema.accountTokens.id)) + .innerJoin(schema.accounts, eq(schema.accountTokens.accountId, schema.accounts.id)) + .innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id)) + .where( + and( + eq(schema.tokenModelAvailability.available, true), + eq(schema.accountTokens.enabled, true), + eq(schema.accountTokens.valueStatus, ACCOUNT_TOKEN_VALUE_STATUS_READY), + eq(schema.accounts.status, 'active'), + eq(schema.sites.status, 'active'), + ), ) .all(); + const usableTokenRows = tokenRows.filter((row) => ( + isUsableAccountToken(row.account_tokens) + && requiresManagedAccountTokens(row.accounts) + )); const accountRows = await db.select().from(schema.modelAvailability) .innerJoin(schema.accounts, eq(schema.modelAvailability.accountId, schema.accounts.id)) @@ -277,35 +940,65 @@ export async function rebuildTokenRoutesFromAvailability() { ) .all(); + // Load site-level disabled models + const disabledModelRows = await db.select().from(schema.siteDisabledModels).all(); + const disabledModelsBySite = new Map>(); + for (const row of disabledModelRows) { + if (!disabledModelsBySite.has(row.siteId)) disabledModelsBySite.set(row.siteId, new Set()); + disabledModelsBySite.get(row.siteId)!.add(row.modelName.toLowerCase()); + } + + function isModelDisabledForSite(siteId: number, modelName: string): boolean { + const disabled = disabledModelsBySite.get(siteId); + return !!disabled && disabled.has(modelName.toLowerCase()); + } + + // Load global brand filter + const blockedBrandRules = getBlockedBrandRules(config.globalBlockedBrands); + + // Load global allowed models whitelist + const globalAllowedModels = new Set( + config.globalAllowedModels.map((m) => m.toLowerCase().trim()).filter(Boolean), + ); + + function isModelAllowedByWhitelist(modelName: string): boolean { + // If whitelist is empty, allow all models (backward compatible) + if (globalAllowedModels.size === 0) return true; + // Check if model is in whitelist (case-insensitive) + return globalAllowedModels.has(modelName.toLowerCase().trim()); + } + const modelCandidates = new Map>(); - const addModelCandidate = (modelNameRaw: string | null | undefined, accountId: number, tokenId: number | null) => { + const addModelCandidate = (modelNameRaw: string | null | undefined, accountId: number, tokenId: number | null, siteId: number) => { const modelName = (modelNameRaw || '').trim(); if (!modelName) return; + if (!isModelAllowedByWhitelist(modelName)) return; + if (isModelDisabledForSite(siteId, modelName)) return; + if (blockedBrandRules.length > 0 && isModelBlockedByBrand(modelName, blockedBrandRules)) return; if (!modelCandidates.has(modelName)) modelCandidates.set(modelName, new Map()); const candidateKey = `${accountId}:${tokenId ?? 'account'}`; modelCandidates.get(modelName)!.set(candidateKey, { accountId, tokenId }); }; - for (const row of tokenRows) { - addModelCandidate(row.token_model_availability.modelName, row.accounts.id, row.account_tokens.id); + for (const row of usableTokenRows) { + addModelCandidate(row.token_model_availability.modelName, row.accounts.id, row.account_tokens.id, row.accounts.siteId); } for (const row of accountRows) { - if (!isApiKeyConnection(row.accounts)) continue; - if (!(row.accounts.apiToken || '').trim()) continue; - addModelCandidate(row.model_availability.modelName, row.accounts.id, null); + if (!supportsDirectAccountRoutingConnection(row.accounts)) continue; + addModelCandidate(row.model_availability.modelName, row.accounts.id, null, row.accounts.siteId); } const routes = await db.select().from(schema.tokenRoutes).all(); const channels = await db.select().from(schema.routeChannels).all(); - - let createdRoutes = 0; + + let createdRoutes = 0; let createdChannels = 0; let removedChannels = 0; let removedRoutes = 0; for (const [modelName, candidateMap] of modelCandidates.entries()) { - let route = routes.find((r) => r.modelPattern === modelName); + let route = routes.find((r) => (r.routeMode || 'pattern') !== 'explicit_group' && r.modelPattern === modelName); if (!route) { const inserted = await db.insert(schema.tokenRoutes).values({ modelPattern: modelName, @@ -362,31 +1055,34 @@ export async function rebuildTokenRoutesFromAvailability() { .where(eq(schema.routeChannels.id, channel.id)) .run(); continue; - } - } - - if (!channel.manualOverride) { - await db.delete(schema.routeChannels).where(eq(schema.routeChannels.id, channel.id)).run(); - removedChannels++; + } + } + + if (!channel.manualOverride) { + await db.delete(schema.routeChannels).where(eq(schema.routeChannels.id, channel.id)).run(); + removedChannels++; } } } const latestModelNames = new Set(Array.from(modelCandidates.keys())); for (const route of routes) { + if ((route.routeMode || 'pattern') === 'explicit_group') { + continue; + } const modelPattern = (route.modelPattern || '').trim(); if (!modelPattern || !isExactModelPattern(modelPattern) || latestModelNames.has(modelPattern)) { continue; - } - - const routeChannelCount = channels.filter((channel) => channel.routeId === route.id).length; - if (routeChannelCount > 0) { - removedChannels += routeChannelCount; - } - - const deleted = (await db.delete(schema.tokenRoutes).where(eq(schema.tokenRoutes.id, route.id)).run()).changes; - if (deleted > 0) { - removedRoutes += deleted; + } + + const routeChannelCount = channels.filter((channel) => channel.routeId === route.id).length; + if (routeChannelCount > 0) { + removedChannels += routeChannelCount; + } + + const deleted = (await db.delete(schema.tokenRoutes).where(eq(schema.tokenRoutes.id, route.id)).run()).changes; + if (deleted > 0) { + removedRoutes += deleted; } } @@ -395,7 +1091,7 @@ export async function rebuildTokenRoutesFromAvailability() { } invalidateTokenRouterCache(); - + return { models: modelCandidates.size, createdRoutes, @@ -403,10 +1099,26 @@ export async function rebuildTokenRoutesFromAvailability() { removedChannels, removedRoutes, }; -} - -export async function refreshModelsAndRebuildRoutes() { +} + +async function runRefreshModelsAndRebuildRoutes() { const refresh = await refreshModelsForAllActiveAccounts(); const rebuild = await rebuildTokenRoutesFromAvailability(); return { refresh, rebuild }; } + +export async function refreshModelsAndRebuildRoutes() { + if (inFlightRefreshModelsAndRebuildRoutes) { + return inFlightRefreshModelsAndRebuildRoutes; + } + + inFlightRefreshModelsAndRebuildRoutes = (async () => { + try { + return await runRefreshModelsAndRebuildRoutes(); + } finally { + inFlightRefreshModelsAndRebuildRoutes = null; + } + })(); + + return inFlightRefreshModelsAndRebuildRoutes; +} diff --git a/src/server/services/notifyService.test.ts b/src/server/services/notifyService.test.ts index 326d9fe7..6e56dfb5 100644 --- a/src/server/services/notifyService.test.ts +++ b/src/server/services/notifyService.test.ts @@ -17,12 +17,24 @@ vi.mock('undici', () => ({ fetch: (...args: unknown[]) => fetchMock(...args), })); +const withExplicitProxyRequestInitMock = vi.fn( + (_proxyUrl: unknown, options?: Record) => { + if (_proxyUrl) return { ...(options || {}), dispatcher: 'mock-proxy-dispatcher' }; + return options ?? {}; + }, +); + +vi.mock('./siteProxy.js', () => ({ + withExplicitProxyRequestInit: (...args: unknown[]) => withExplicitProxyRequestInitMock(...args), +})); + describe('notifyService', () => { beforeEach(async () => { vi.resetModules(); sendMailMock.mockReset(); createTransportMock.mockClear(); fetchMock.mockReset(); + withExplicitProxyRequestInitMock.mockClear(); const { config } = await import('../config.js'); config.notifyCooldownSec = 300; @@ -35,6 +47,9 @@ describe('notifyService', () => { (config as any).telegramEnabled = false; (config as any).telegramBotToken = ''; (config as any).telegramChatId = ''; + (config as any).telegramUseSystemProxy = false; + config.systemProxyUrl = ''; + (config as any).telegramMessageThreadId = ''; config.smtpEnabled = true; config.smtpHost = 'smtp.example.com'; config.smtpPort = 465; @@ -167,11 +182,12 @@ describe('notifyService', () => { expect(payload?.text || '').toContain('UTC Time:'); }); - it('sends telegram message when telegram channel is enabled', async () => { + it('sends telegram message without topic when telegram thread id is empty', async () => { const { config } = await import('../config.js'); (config as any).telegramEnabled = true; (config as any).telegramBotToken = '123456:telegram-token'; (config as any).telegramChatId = '-1001234567890'; + (config as any).telegramMessageThreadId = ''; config.smtpEnabled = false; fetchMock.mockResolvedValue({ @@ -192,10 +208,173 @@ describe('notifyService', () => { ); const rawBody = fetchMock.mock.calls[0]?.[1] as { body?: string }; - const payload = JSON.parse(rawBody?.body || '{}') as { chat_id?: string; text?: string }; + const payload = JSON.parse(rawBody?.body || '{}') as { chat_id?: string; text?: string; message_thread_id?: number }; expect(payload.chat_id).toBe('-1001234567890'); + expect(payload.message_thread_id).toBeUndefined(); expect(payload.text || '').toContain('Level: warning'); expect(payload.text || '').toContain('Local Time:'); expect(payload.text || '').toContain('UTC Time:'); }); + + it('sends telegram topic id when telegram thread id is configured', async () => { + const { config } = await import('../config.js'); + (config as any).telegramEnabled = true; + (config as any).telegramBotToken = '123456:telegram-token'; + (config as any).telegramChatId = '-1001234567890'; + (config as any).telegramMessageThreadId = '77'; + config.smtpEnabled = false; + + fetchMock.mockResolvedValue({ + ok: true, + status: 200, + }); + + const { sendNotification } = await import('./notifyService.js'); + await sendNotification('测试通知', 'message', 'warning', { bypassThrottle: true, throwOnFailure: true }); + + const rawBody = fetchMock.mock.calls[0]?.[1] as { body?: string }; + const payload = JSON.parse(rawBody?.body || '{}') as { message_thread_id?: number }; + expect(payload.message_thread_id).toBe(77); + }); + + it('uses TELEGRAM_API_BASE_URL when configured', async () => { + const { config } = await import('../config.js'); + (config as any).telegramEnabled = true; + (config as any).telegramBotToken = '123456:telegram-token'; + (config as any).telegramChatId = '-1001234567890'; + (config as any).telegramApiBaseUrl = 'https://tg-proxy.example.com/custom/'; + config.smtpEnabled = false; + + fetchMock.mockResolvedValue({ + ok: true, + status: 200, + }); + + const { sendNotification } = await import('./notifyService.js'); + await sendNotification('测试通知', 'message', 'warning', { bypassThrottle: true, throwOnFailure: true }); + + expect(fetchMock).toHaveBeenCalledWith( + 'https://tg-proxy.example.com/custom/bot123456:telegram-token/sendMessage', + expect.objectContaining({ + method: 'POST', + }), + ); + }); + + it('applies system proxy dispatcher when telegramUseSystemProxy is enabled', async () => { + const { config } = await import('../config.js'); + (config as any).telegramEnabled = true; + (config as any).telegramBotToken = '123456:telegram-token'; + (config as any).telegramChatId = '-1001234567890'; + (config as any).telegramUseSystemProxy = true; + config.systemProxyUrl = 'http://127.0.0.1:7890'; + config.smtpEnabled = false; + + fetchMock.mockResolvedValue({ ok: true, status: 200 }); + + const { sendNotification } = await import('./notifyService.js'); + await sendNotification('测试通知', 'proxy-test', 'info', { bypassThrottle: true, throwOnFailure: true }); + + expect(withExplicitProxyRequestInitMock).toHaveBeenCalledWith( + 'http://127.0.0.1:7890', + expect.objectContaining({ method: 'POST' }), + ); + expect(fetchMock).toHaveBeenCalledWith( + expect.stringContaining('/sendMessage'), + expect.objectContaining({ dispatcher: 'mock-proxy-dispatcher' }), + ); + }); + + it('does not apply proxy dispatcher when telegramUseSystemProxy is disabled', async () => { + const { config } = await import('../config.js'); + (config as any).telegramEnabled = true; + (config as any).telegramBotToken = '123456:telegram-token'; + (config as any).telegramChatId = '-1001234567890'; + (config as any).telegramUseSystemProxy = false; + config.systemProxyUrl = 'http://127.0.0.1:7890'; + config.smtpEnabled = false; + + fetchMock.mockResolvedValue({ ok: true, status: 200 }); + + const { sendNotification } = await import('./notifyService.js'); + await sendNotification('测试通知', 'no-proxy-test', 'info', { bypassThrottle: true, throwOnFailure: true }); + + expect(withExplicitProxyRequestInitMock).toHaveBeenCalledWith( + null, + expect.objectContaining({ method: 'POST' }), + ); + const fetchOptions = fetchMock.mock.calls[0]?.[1] as Record; + expect(fetchOptions.dispatcher).toBeUndefined(); + }); + + it('sends feishu webhook payload with msg_type text format', async () => { + const { config } = await import('../config.js'); + config.webhookEnabled = true; + config.webhookUrl = 'https://open.feishu.cn/open-apis/bot/v2/hook/demo-token'; + config.smtpEnabled = false; + + fetchMock.mockResolvedValue({ + ok: true, + status: 200, + json: vi.fn().mockResolvedValue({ code: 0, msg: 'success' }), + }); + + const { sendNotification } = await import('./notifyService.js'); + await sendNotification('测试通知', 'feishu message', 'info', { bypassThrottle: true, throwOnFailure: true }); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const call = fetchMock.mock.calls[0] as [string, { body?: string }]; + expect(call[0]).toContain('open.feishu.cn/open-apis/bot/v2/hook/'); + + const payload = JSON.parse(call[1]?.body || '{}') as { msg_type?: string; content?: { text?: string } }; + expect(payload.msg_type).toBe('text'); + expect(payload.content?.text || '').toContain('[metapi][INFO] 测试通知'); + expect(payload.content?.text || '').toContain('feishu message'); + }); + + it('fails when feishu webhook returns non-zero code', async () => { + const { config } = await import('../config.js'); + config.webhookEnabled = true; + config.webhookUrl = 'https://open.feishu.cn/open-apis/bot/v2/hook/demo-token'; + config.smtpEnabled = false; + + fetchMock.mockResolvedValue({ + ok: true, + status: 200, + json: vi.fn().mockResolvedValue({ code: 19001, msg: 'param invalid' }), + }); + + const { sendNotification } = await import('./notifyService.js'); + await expect( + sendNotification('测试通知', 'message', 'info', { + bypassThrottle: true, + throwOnFailure: true, + }), + ).rejects.toThrow(/飞书|19001|param invalid/); + }); + + it('sends feishu webhook payload for larksuite.com domain', async () => { + const { config } = await import('../config.js'); + config.webhookEnabled = true; + config.webhookUrl = 'https://open.larksuite.com/open-apis/bot/v2/hook/demo-token'; + config.smtpEnabled = false; + + fetchMock.mockResolvedValue({ + ok: true, + status: 200, + json: vi.fn().mockResolvedValue({ code: 0, msg: 'success' }), + }); + + const { sendNotification } = await import('./notifyService.js'); + await sendNotification('测试通知', 'lark message', 'warning', { bypassThrottle: true, throwOnFailure: true }); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const call = fetchMock.mock.calls[0] as [string, { body?: string }]; + expect(call[0]).toContain('open.larksuite.com/open-apis/bot/v2/hook/'); + + const payload = JSON.parse(call[1]?.body || '{}') as { msg_type?: string; content?: { text?: string } }; + expect(payload.msg_type).toBe('text'); + expect(payload.content?.text || '').toContain('[metapi][WARNING] 测试通知'); + expect(payload.content?.text || '').toContain('lark message'); + }); }); diff --git a/src/server/services/notifyService.ts b/src/server/services/notifyService.ts index 166e588f..8f7ec108 100644 --- a/src/server/services/notifyService.ts +++ b/src/server/services/notifyService.ts @@ -1,5 +1,6 @@ import { fetch } from 'undici'; import { config } from '../config.js'; +import { withExplicitProxyRequestInit } from './siteProxy.js'; import nodemailer, { type Transporter } from 'nodemailer'; import { createNotificationSignature, @@ -103,6 +104,30 @@ function buildWeComText( return `${raw.slice(0, maxLength)}\n...(truncated)`; } +function isFeishuBotWebhook(url: string): boolean { + try { + const parsed = new URL(url); + return ( + (parsed.hostname === 'open.feishu.cn' || parsed.hostname === 'open.larksuite.com') + && parsed.pathname.includes('/open-apis/bot/v2/hook/') + ); + } catch { + return false; + } +} + +function buildFeishuText( + title: string, + message: string, + level: 'info' | 'warning' | 'error', + timeFootnote: string, +): string { + const maxLength = 3900; + const raw = `[metapi][${level.toUpperCase()}] ${title}\n\n${message}\n\n${timeFootnote}`; + if (raw.length <= maxLength) return raw; + return `${raw.slice(0, maxLength)}\n...(truncated)`; +} + export async function sendNotification( title: string, message: string, @@ -141,26 +166,36 @@ export async function sendNotification( channel: 'webhook', run: async () => { const isWeComWebhook = isWeComBotWebhook(config.webhookUrl); + const isFeishuWebhook = isFeishuBotWebhook(config.webhookUrl); + let body: string; + if (isWeComWebhook) { + body = JSON.stringify({ + msgtype: 'text', + text: { + content: buildWeComText(title, resolvedMessage, level, timeFootnote), + }, + }); + } else if (isFeishuWebhook) { + body = JSON.stringify({ + msg_type: 'text', + content: { + text: buildFeishuText(title, resolvedMessage, level, timeFootnote), + }, + }); + } else { + body = JSON.stringify({ + title, + message: resolvedMessage, + level, + timestamp: now.toISOString(), + localTime: formatLocalDateTime(now), + timeZone: getResolvedTimeZone(), + }); + } const response = await fetch(config.webhookUrl, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify( - isWeComWebhook - ? { - msgtype: 'text', - text: { - content: buildWeComText(title, resolvedMessage, level, timeFootnote), - }, - } - : { - title, - message: resolvedMessage, - level, - timestamp: now.toISOString(), - localTime: formatLocalDateTime(now), - timeZone: getResolvedTimeZone(), - }, - ), + body, }); if (!response.ok) { throw new Error(`Webhook 响应状态 ${response.status}`); @@ -176,6 +211,17 @@ export async function sendNotification( throw new Error(`企业微信 Webhook 返回错误 ${payload.errcode}: ${payload.errmsg || 'unknown error'}`); } } + if (isFeishuWebhook) { + let payload: { code?: number; msg?: string } | null = null; + try { + payload = await response.json() as { code?: number; msg?: string }; + } catch { + throw new Error('飞书 Webhook 返回了无效 JSON'); + } + if (typeof payload?.code === 'number' && payload.code !== 0) { + throw new Error(`飞书 Webhook 返回错误 ${payload.code}: ${payload.msg || 'unknown error'}`); + } + } }, }, ); @@ -218,20 +264,29 @@ export async function sendNotification( } if (config.telegramEnabled && config.telegramBotToken && config.telegramChatId) { - const telegramApiUrl = `https://api.telegram.org/bot${config.telegramBotToken}/sendMessage`; + const telegramApiBaseUrl = String(config.telegramApiBaseUrl || 'https://api.telegram.org').replace(/\/+$/, ''); + const telegramApiUrl = `${telegramApiBaseUrl}/bot${config.telegramBotToken}/sendMessage`; const text = buildTelegramText(title, resolvedMessage, level, timeFootnote); + const telegramMessageThreadId = Number.parseInt(String(config.telegramMessageThreadId || '').trim(), 10); tasks.push({ channel: 'telegram', run: async () => { - const response = await fetch(telegramApiUrl, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - chat_id: config.telegramChatId, - text, - disable_web_page_preview: true, - }), - }); + const telegramRequestInit = withExplicitProxyRequestInit( + config.telegramUseSystemProxy ? config.systemProxyUrl : null, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + chat_id: config.telegramChatId, + ...(Number.isFinite(telegramMessageThreadId) && telegramMessageThreadId > 0 + ? { message_thread_id: telegramMessageThreadId } + : {}), + text, + disable_web_page_preview: true, + }), + }, + ); + const response = await fetch(telegramApiUrl, telegramRequestInit); if (!response.ok) { throw new Error(`Telegram 响应状态 ${response.status}`); } diff --git a/src/server/services/oauth/antigravityProvider.ts b/src/server/services/oauth/antigravityProvider.ts new file mode 100644 index 00000000..32a88341 --- /dev/null +++ b/src/server/services/oauth/antigravityProvider.ts @@ -0,0 +1,304 @@ +import { fetch } from 'undici'; +import { withExplicitProxyRequestInit } from '../siteProxy.js'; +import type { OAuthProviderDefinition } from './providers.js'; + +export const ANTIGRAVITY_OAUTH_PROVIDER = 'antigravity'; +export const ANTIGRAVITY_AUTH_URL = 'https://accounts.google.com/o/oauth2/v2/auth'; +export const ANTIGRAVITY_TOKEN_URL = 'https://oauth2.googleapis.com/token'; +export const ANTIGRAVITY_USERINFO_URL = 'https://www.googleapis.com/oauth2/v1/userinfo?alt=json'; +export const ANTIGRAVITY_CLIENT_ID = '1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com'; +export const ANTIGRAVITY_CLIENT_SECRET = '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'; +export const ANTIGRAVITY_LOOPBACK_CALLBACK_PORT = 51121; +export const ANTIGRAVITY_LOOPBACK_CALLBACK_PATH = '/oauth-callback'; +export const ANTIGRAVITY_LOOPBACK_REDIRECT_URI = `http://localhost:${ANTIGRAVITY_LOOPBACK_CALLBACK_PORT}${ANTIGRAVITY_LOOPBACK_CALLBACK_PATH}`; +export const ANTIGRAVITY_UPSTREAM_BASE_URL = 'https://cloudcode-pa.googleapis.com'; +export const ANTIGRAVITY_DAILY_UPSTREAM_BASE_URL = 'https://daily-cloudcode-pa.googleapis.com'; +export const ANTIGRAVITY_SANDBOX_DAILY_UPSTREAM_BASE_URL = 'https://daily-cloudcode-pa.sandbox.googleapis.com'; +export const ANTIGRAVITY_GOOGLE_API_CLIENT = 'google-cloud-sdk vscode_cloudshelleditor/0.1'; +export const ANTIGRAVITY_USER_AGENT = 'google-api-nodejs-client/9.15.1'; +export const ANTIGRAVITY_MODELS_USER_AGENT = 'antigravity/1.19.6 darwin/arm64'; +export const ANTIGRAVITY_CLIENT_METADATA = '{"ideType":"IDE_UNSPECIFIED","platform":"PLATFORM_UNSPECIFIED","pluginType":"GEMINI"}'; +export const ANTIGRAVITY_INTERNAL_API_VERSION = 'v1internal'; +export const ANTIGRAVITY_ONBOARD_POLL_INTERVAL_MS = 2_000; +export const ANTIGRAVITY_ONBOARD_MAX_ATTEMPTS = 5; + +const ANTIGRAVITY_SCOPES = [ + 'https://www.googleapis.com/auth/cloud-platform', + 'https://www.googleapis.com/auth/userinfo.email', + 'https://www.googleapis.com/auth/userinfo.profile', + 'https://www.googleapis.com/auth/cclog', + 'https://www.googleapis.com/auth/experimentsandconfigs', +]; + +type AntigravityOAuthTokenPayload = { + access_token?: unknown; + refresh_token?: unknown; + token_type?: unknown; + expires_in?: unknown; + scope?: unknown; + expiry?: unknown; +}; + +type AntigravityLoadCodeAssistPayload = { + cloudaicompanionProject?: unknown; + allowedTiers?: unknown; +}; + +type AntigravityOnboardUserPayload = { + done?: unknown; + response?: unknown; +}; + +function asTrimmedString(value: unknown): string | undefined { + if (typeof value !== 'string') return undefined; + const trimmed = value.trim(); + return trimmed || undefined; +} + +function parseExpiresAt(payload: AntigravityOAuthTokenPayload): number | undefined { + if (typeof payload.expires_in === 'number' && Number.isFinite(payload.expires_in) && payload.expires_in > 0) { + return Date.now() + Math.trunc(payload.expires_in) * 1000; + } + if (typeof payload.expires_in === 'string') { + const parsed = Number.parseInt(payload.expires_in.trim(), 10); + if (Number.isFinite(parsed) && parsed > 0) { + return Date.now() + parsed * 1000; + } + } + if (typeof payload.expiry === 'string') { + const parsed = Date.parse(payload.expiry); + if (!Number.isNaN(parsed)) return parsed; + } + return undefined; +} + +function buildAntigravityMetadata() { + return { + ideType: 'ANTIGRAVITY', + platform: 'PLATFORM_UNSPECIFIED', + pluginType: 'GEMINI', + }; +} + +function extractAntigravityProjectId(value: unknown): string | undefined { + if (typeof value === 'string') { + return asTrimmedString(value); + } + if (value && typeof value === 'object' && !Array.isArray(value)) { + return asTrimmedString((value as { id?: unknown }).id); + } + return undefined; +} + +function extractAntigravityDefaultTierId(payload: AntigravityLoadCodeAssistPayload): string { + const allowedTiers = Array.isArray(payload.allowedTiers) ? payload.allowedTiers : []; + for (const rawTier of allowedTiers) { + if (!rawTier || typeof rawTier !== 'object' || Array.isArray(rawTier)) continue; + const tier = rawTier as { id?: unknown; isDefault?: unknown }; + if (tier.isDefault === true) { + const tierId = asTrimmedString(tier.id); + if (tierId) return tierId; + } + } + return 'legacy-tier'; +} + +async function sleep(ms: number): Promise { + await new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function callAntigravityInternalApi( + accessToken: string, + method: 'loadCodeAssist' | 'onboardUser', + body: Record, + proxyUrl?: string | null, +): Promise { + const response = await fetch( + `${ANTIGRAVITY_UPSTREAM_BASE_URL}/${ANTIGRAVITY_INTERNAL_API_VERSION}:${method}`, + withExplicitProxyRequestInit(proxyUrl, { + method: 'POST', + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: 'application/json', + 'Content-Type': 'application/json', + 'User-Agent': ANTIGRAVITY_USER_AGENT, + 'X-Goog-Api-Client': ANTIGRAVITY_GOOGLE_API_CLIENT, + 'Client-Metadata': ANTIGRAVITY_CLIENT_METADATA, + }, + body: JSON.stringify(body), + }), + ); + if (!response.ok) return undefined; + return response.json() as Promise; +} + +async function postAntigravityToken( + body: URLSearchParams, + proxyUrl?: string | null, +) { + const response = await fetch(ANTIGRAVITY_TOKEN_URL, withExplicitProxyRequestInit(proxyUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + Accept: 'application/json', + }, + body: body.toString(), + })); + if (!response.ok) { + const text = await response.text().catch(() => ''); + throw new Error(text || `antigravity token exchange failed with status ${response.status}`); + } + const payload = await response.json() as AntigravityOAuthTokenPayload; + const accessToken = asTrimmedString(payload.access_token); + if (!accessToken) { + throw new Error('antigravity token exchange response missing access token'); + } + return { + accessToken, + refreshToken: asTrimmedString(payload.refresh_token), + tokenExpiresAt: parseExpiresAt(payload), + providerData: { + tokenType: asTrimmedString(payload.token_type), + scope: asTrimmedString(payload.scope), + }, + }; +} + +async function fetchAntigravityUserEmail( + accessToken: string, + proxyUrl?: string | null, +): Promise { + const response = await fetch(ANTIGRAVITY_USERINFO_URL, withExplicitProxyRequestInit(proxyUrl, { + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: 'application/json', + }, + })); + if (!response.ok) return undefined; + const payload = await response.json() as { email?: unknown }; + return asTrimmedString(payload.email); +} + +async function fetchAntigravityProjectId( + accessToken: string, + proxyUrl?: string | null, +): Promise { + const metadata = buildAntigravityMetadata(); + const payload = await callAntigravityInternalApi( + accessToken, + 'loadCodeAssist', + { metadata }, + proxyUrl, + ); + if (!payload) return undefined; + + const discoveredFromLoad = extractAntigravityProjectId(payload.cloudaicompanionProject); + if (discoveredFromLoad) { + return discoveredFromLoad; + } + + const tierId = extractAntigravityDefaultTierId(payload); + for (let attempt = 0; attempt < ANTIGRAVITY_ONBOARD_MAX_ATTEMPTS; attempt += 1) { + const onboardPayload = await callAntigravityInternalApi( + accessToken, + 'onboardUser', + { + tierId, + metadata, + }, + proxyUrl, + ); + if (!onboardPayload) return undefined; + if (onboardPayload.done === true) { + const response = ( + onboardPayload.response + && typeof onboardPayload.response === 'object' + && !Array.isArray(onboardPayload.response) + ) + ? onboardPayload.response as { cloudaicompanionProject?: unknown } + : undefined; + return extractAntigravityProjectId(response?.cloudaicompanionProject); + } + if ((attempt + 1) < ANTIGRAVITY_ONBOARD_MAX_ATTEMPTS) { + await sleep(ANTIGRAVITY_ONBOARD_POLL_INTERVAL_MS); + } + } + + return undefined; +} + +export const antigravityOauthProvider: OAuthProviderDefinition = { + metadata: { + provider: ANTIGRAVITY_OAUTH_PROVIDER, + label: 'Antigravity', + platform: 'antigravity', + enabled: true, + loginType: 'oauth', + requiresProjectId: false, + supportsDirectAccountRouting: true, + supportsCloudValidation: true, + supportsNativeProxy: false, + }, + site: { + name: 'Google Antigravity OAuth', + url: ANTIGRAVITY_UPSTREAM_BASE_URL, + platform: 'antigravity', + }, + loopback: { + host: '127.0.0.1', + port: ANTIGRAVITY_LOOPBACK_CALLBACK_PORT, + path: ANTIGRAVITY_LOOPBACK_CALLBACK_PATH, + redirectUri: ANTIGRAVITY_LOOPBACK_REDIRECT_URI, + }, + buildAuthorizationUrl: async ({ state, redirectUri }) => { + const params = new URLSearchParams({ + client_id: ANTIGRAVITY_CLIENT_ID, + redirect_uri: redirectUri, + response_type: 'code', + access_type: 'offline', + prompt: 'consent', + scope: ANTIGRAVITY_SCOPES.join(' '), + state, + }); + return `${ANTIGRAVITY_AUTH_URL}?${params.toString()}`; + }, + exchangeAuthorizationCode: async ({ code, redirectUri, proxyUrl }) => { + const token = await postAntigravityToken(new URLSearchParams({ + code, + client_id: ANTIGRAVITY_CLIENT_ID, + client_secret: ANTIGRAVITY_CLIENT_SECRET, + redirect_uri: redirectUri, + grant_type: 'authorization_code', + }), proxyUrl); + const email = await fetchAntigravityUserEmail(token.accessToken, proxyUrl); + const projectId = await fetchAntigravityProjectId(token.accessToken, proxyUrl); + return { + ...token, + email, + accountKey: email, + accountId: email, + projectId, + }; + }, + refreshAccessToken: async ({ refreshToken, oauth, proxyUrl }) => { + const token = await postAntigravityToken(new URLSearchParams({ + client_id: ANTIGRAVITY_CLIENT_ID, + client_secret: ANTIGRAVITY_CLIENT_SECRET, + grant_type: 'refresh_token', + refresh_token: refreshToken, + }), proxyUrl); + const email = await fetchAntigravityUserEmail(token.accessToken, proxyUrl); + return { + ...token, + email, + accountKey: email, + accountId: email, + projectId: oauth?.projectId ?? await fetchAntigravityProjectId(token.accessToken, proxyUrl), + }; + }, + buildProxyHeaders: () => ({ + 'User-Agent': ANTIGRAVITY_USER_AGENT, + 'X-Goog-Api-Client': ANTIGRAVITY_GOOGLE_API_CLIENT, + 'Client-Metadata': ANTIGRAVITY_CLIENT_METADATA, + }), +}; diff --git a/src/server/services/oauth/claudeProvider.ts b/src/server/services/oauth/claudeProvider.ts new file mode 100644 index 00000000..c2cbdf26 --- /dev/null +++ b/src/server/services/oauth/claudeProvider.ts @@ -0,0 +1,156 @@ +import { fetch } from 'undici'; +import { config } from '../../config.js'; +import { withExplicitProxyRequestInit } from '../siteProxy.js'; +import { createPkceChallenge } from './sessionStore.js'; +import type { OAuthProviderDefinition } from './providers.js'; + +export const CLAUDE_OAUTH_PROVIDER = 'claude'; +export const CLAUDE_AUTH_URL = 'https://claude.ai/oauth/authorize'; +export const CLAUDE_TOKEN_URL = 'https://api.anthropic.com/v1/oauth/token'; +export const CLAUDE_CLIENT_ID = config.claudeClientId; +export const CLAUDE_LOOPBACK_CALLBACK_PORT = 54545; +export const CLAUDE_LOOPBACK_CALLBACK_PATH = '/callback'; +export const CLAUDE_LOOPBACK_REDIRECT_URI = `http://localhost:${CLAUDE_LOOPBACK_CALLBACK_PORT}${CLAUDE_LOOPBACK_CALLBACK_PATH}`; +export const CLAUDE_UPSTREAM_BASE_URL = 'https://api.anthropic.com'; +export const CLAUDE_DEFAULT_ANTHROPIC_VERSION = '2023-06-01'; + +function requireClaudeClientId(): string { + if (!CLAUDE_CLIENT_ID) { + throw new Error('CLAUDE_CLIENT_ID is not configured'); + } + return CLAUDE_CLIENT_ID; +} + +type ClaudeTokenResponse = { + access_token?: unknown; + refresh_token?: unknown; + token_type?: unknown; + expires_in?: unknown; + organization?: { + uuid?: unknown; + name?: unknown; + }; + account?: { + uuid?: unknown; + email_address?: unknown; + }; +}; + +function asTrimmedString(value: unknown): string | undefined { + if (typeof value !== 'string') return undefined; + const trimmed = value.trim(); + return trimmed || undefined; +} + +function parseExpiresAt(value: unknown): number | undefined { + if (typeof value === 'number' && Number.isFinite(value) && value > 0) { + return Date.now() + Math.trunc(value) * 1000; + } + if (typeof value === 'string') { + const parsed = Number.parseInt(value.trim(), 10); + if (Number.isFinite(parsed) && parsed > 0) { + return Date.now() + parsed * 1000; + } + } + return undefined; +} + +function parseClaudeTokenPayload(payload: unknown) { + if (!payload || typeof payload !== 'object' || Array.isArray(payload)) { + throw new Error('claude token exchange returned invalid payload'); + } + const body = payload as ClaudeTokenResponse; + const accessToken = asTrimmedString(body.access_token); + if (!accessToken) { + throw new Error('claude token exchange response missing access token'); + } + return { + accessToken, + refreshToken: asTrimmedString(body.refresh_token), + tokenExpiresAt: parseExpiresAt(body.expires_in), + email: asTrimmedString(body.account?.email_address), + accountId: asTrimmedString(body.account?.uuid), + accountKey: asTrimmedString(body.account?.uuid) || asTrimmedString(body.account?.email_address), + providerData: { + organizationId: asTrimmedString(body.organization?.uuid), + organizationName: asTrimmedString(body.organization?.name), + }, + }; +} + +async function postClaudeToken( + body: Record, + proxyUrl?: string | null, +) { + const response = await fetch(CLAUDE_TOKEN_URL, withExplicitProxyRequestInit(proxyUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + body: JSON.stringify(body), + })); + if (!response.ok) { + const text = await response.text().catch(() => ''); + throw new Error(text || `claude token exchange failed with status ${response.status}`); + } + return parseClaudeTokenPayload(await response.json()); +} + +export const claudeOauthProvider: OAuthProviderDefinition = { + metadata: { + provider: CLAUDE_OAUTH_PROVIDER, + label: 'Claude', + platform: 'claude', + enabled: true, + loginType: 'oauth', + requiresProjectId: false, + supportsDirectAccountRouting: true, + supportsCloudValidation: true, + supportsNativeProxy: true, + }, + site: { + name: 'Anthropic Claude OAuth', + url: CLAUDE_UPSTREAM_BASE_URL, + platform: 'claude', + }, + loopback: { + host: '127.0.0.1', + port: CLAUDE_LOOPBACK_CALLBACK_PORT, + path: CLAUDE_LOOPBACK_CALLBACK_PATH, + redirectUri: CLAUDE_LOOPBACK_REDIRECT_URI, + }, + buildAuthorizationUrl: async ({ state, redirectUri, codeVerifier }) => { + const params = new URLSearchParams({ + code: 'true', + client_id: requireClaudeClientId(), + response_type: 'code', + redirect_uri: redirectUri, + scope: 'org:create_api_key user:profile user:inference', + code_challenge: await createPkceChallenge(codeVerifier), + code_challenge_method: 'S256', + state, + }); + return `${CLAUDE_AUTH_URL}?${params.toString()}`; + }, + exchangeAuthorizationCode: async ({ code, state, redirectUri, codeVerifier, proxyUrl }) => { + return postClaudeToken({ + code, + state, + grant_type: 'authorization_code', + client_id: requireClaudeClientId(), + redirect_uri: redirectUri, + code_verifier: codeVerifier, + }, proxyUrl); + }, + refreshAccessToken: async ({ refreshToken, proxyUrl }) => { + return postClaudeToken({ + client_id: requireClaudeClientId(), + grant_type: 'refresh_token', + refresh_token: refreshToken, + }, proxyUrl); + }, + buildProxyHeaders: () => ({ + 'anthropic-version': CLAUDE_DEFAULT_ANTHROPIC_VERSION, + }), +}; diff --git a/src/server/services/oauth/codexAccount.test.ts b/src/server/services/oauth/codexAccount.test.ts new file mode 100644 index 00000000..0545b247 --- /dev/null +++ b/src/server/services/oauth/codexAccount.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from 'vitest'; +import { + buildCodexOauthInfo, + getCodexOauthInfoFromExtraConfig, + isCodexPlatform, +} from './codexAccount.js'; + +describe('codexAccount', () => { + it('reads codex oauth info from parsed extra config objects', () => { + const extraConfig = { + oauth: { + provider: 'codex', + accountKey: 'chatgpt-account-123', + refreshToken: 'refresh-token', + }, + }; + + expect(getCodexOauthInfoFromExtraConfig(extraConfig)).toEqual(expect.objectContaining({ + provider: 'codex', + accountId: 'chatgpt-account-123', + accountKey: 'chatgpt-account-123', + refreshToken: 'refresh-token', + })); + }); + + it('recognizes parsed codex extra config objects as codex platform accounts', () => { + expect(isCodexPlatform({ + oauth: { + provider: 'codex', + accountKey: 'chatgpt-account-123', + }, + })).toBe(true); + }); + + it('builds codex oauth info from parsed extra config objects', () => { + const oauth = buildCodexOauthInfo({ + oauth: { + provider: 'codex', + accountKey: 'chatgpt-account-123', + refreshToken: 'refresh-token', + }, + }); + + expect(oauth).toEqual(expect.objectContaining({ + provider: 'codex', + accountId: 'chatgpt-account-123', + accountKey: 'chatgpt-account-123', + refreshToken: 'refresh-token', + })); + }); +}); diff --git a/src/server/services/oauth/codexAccount.ts b/src/server/services/oauth/codexAccount.ts new file mode 100644 index 00000000..f87ec8d8 --- /dev/null +++ b/src/server/services/oauth/codexAccount.ts @@ -0,0 +1,56 @@ +import { schema } from '../../db/index.js'; +import { + buildOauthInfo, + getOauthInfoFromExtraConfig, + isOauthProvider, + type OauthInfo, +} from './oauthAccount.js'; + +export type CodexOauthInfo = OauthInfo & { + provider: 'codex'; +}; + +type OauthExtraConfigInput = Parameters[0]; +type BuildOauthInfoInput = Parameters[0]; +type OauthIdentityCarrierLike = Pick< + typeof schema.accounts.$inferSelect, + 'extraConfig' | 'oauthProvider' | 'oauthAccountKey' | 'oauthProjectId' +>; + +function isOauthIdentityCarrierLike(value: unknown): value is OauthIdentityCarrierLike { + if (!value || typeof value !== 'object' || Array.isArray(value)) return false; + return 'extraConfig' in value + || 'oauthProvider' in value + || 'oauthAccountKey' in value + || 'oauthProjectId' in value; +} + +export function getCodexOauthInfoFromExtraConfig(extraConfig?: OauthExtraConfigInput): CodexOauthInfo | null { + const oauth = getOauthInfoFromExtraConfig(extraConfig); + if (!oauth || oauth.provider !== 'codex') return null; + return oauth as CodexOauthInfo; +} + +export function isCodexPlatform( + account: OauthIdentityCarrierLike | OauthExtraConfigInput, +): boolean { + if (!account || typeof account === 'string') { + return isOauthProvider(account, 'codex'); + } + if (isOauthIdentityCarrierLike(account)) { + return isOauthProvider(account, 'codex'); + } + return getCodexOauthInfoFromExtraConfig(account) !== null; +} + +export function buildCodexOauthInfo( + extraConfig?: BuildOauthInfoInput, + patch: Partial = {}, +): CodexOauthInfo { + return buildOauthInfo(extraConfig, { provider: 'codex', ...patch }) as CodexOauthInfo; +} + +export type { + OauthExtraConfigInput, + OauthIdentityCarrierLike, +}; diff --git a/src/server/services/oauth/codexProvider.ts b/src/server/services/oauth/codexProvider.ts new file mode 100644 index 00000000..a135044f --- /dev/null +++ b/src/server/services/oauth/codexProvider.ts @@ -0,0 +1,246 @@ +import { fetch } from 'undici'; +import { config } from '../../config.js'; +import { withExplicitProxyRequestInit } from '../siteProxy.js'; +import { createPkceChallenge } from './sessionStore.js'; +import type { OAuthProviderDefinition } from './providers.js'; + +export const CODEX_OAUTH_PROVIDER = 'codex'; +export const CODEX_AUTH_URL = 'https://auth.openai.com/oauth/authorize'; +export const CODEX_TOKEN_URL = 'https://auth.openai.com/oauth/token'; +export const CODEX_CLIENT_ID = config.codexClientId; +export const CODEX_CALLBACK_PATH = '/api/oauth/callback/codex'; +export const CODEX_LOOPBACK_CALLBACK_PATH = '/auth/callback'; +export const CODEX_LOOPBACK_CALLBACK_PORT = 1455; +export const CODEX_LOOPBACK_REDIRECT_URI = `http://localhost:${CODEX_LOOPBACK_CALLBACK_PORT}${CODEX_LOOPBACK_CALLBACK_PATH}`; +export const CODEX_UPSTREAM_BASE_URL = 'https://chatgpt.com/backend-api/codex'; + +function requireCodexClientId(): string { + if (!CODEX_CLIENT_ID) { + throw new Error('CODEX_CLIENT_ID is not configured'); + } + return CODEX_CLIENT_ID; +} + +type CodexJwtClaims = { + email?: unknown; + 'https://api.openai.com/auth'?: { + chatgpt_account_id?: unknown; + chatgpt_plan_type?: unknown; + }; +}; + +export type CodexTokenExchangeResult = { + accessToken: string; + refreshToken: string; + idToken: string; + accountId?: string; + email?: string; + planType?: string; + tokenExpiresAt: number; +}; + +function asTrimmedString(value: unknown): string | undefined { + if (typeof value !== 'string') return undefined; + const trimmed = value.trim(); + return trimmed || undefined; +} + +function parseJwtClaims(token: string): CodexJwtClaims | null { + const parts = token.split('.'); + if (parts.length !== 3) return null; + try { + const payload = JSON.parse(Buffer.from(parts[1] || '', 'base64url').toString('utf8')) as unknown; + if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return null; + return payload as CodexJwtClaims; + } catch { + return null; + } +} + +export async function buildCodexAuthorizationUrl(input: { + state: string; + redirectUri: string; + codeVerifier: string; +}): Promise { + const params = new URLSearchParams({ + client_id: requireCodexClientId(), + response_type: 'code', + redirect_uri: input.redirectUri, + scope: 'openid email profile offline_access', + state: input.state, + code_challenge: await createPkceChallenge(input.codeVerifier), + code_challenge_method: 'S256', + prompt: 'login', + id_token_add_organizations: 'true', + codex_cli_simplified_flow: 'true', + }); + return `${CODEX_AUTH_URL}?${params.toString()}`; +} + +function parseTokenResponsePayload(payload: unknown): CodexTokenExchangeResult { + if (!payload || typeof payload !== 'object' || Array.isArray(payload)) { + throw new Error('codex token exchange returned invalid payload'); + } + const body = payload as Record; + const accessToken = asTrimmedString(body.access_token); + const refreshToken = asTrimmedString(body.refresh_token); + const idToken = asTrimmedString(body.id_token); + const expiresIn = typeof body.expires_in === 'number' && Number.isFinite(body.expires_in) + ? Math.trunc(body.expires_in) + : (typeof body.expires_in === 'string' ? Number.parseInt(body.expires_in.trim(), 10) : NaN); + if (!accessToken || !refreshToken || !idToken || !Number.isFinite(expiresIn) || expiresIn <= 0) { + throw new Error('codex token exchange response missing required fields'); + } + const claims = parseJwtClaims(idToken); + const accountId = asTrimmedString(claims?.['https://api.openai.com/auth']?.chatgpt_account_id); + if (!accountId) { + throw new Error('codex token exchange response missing chatgpt_account_id'); + } + return { + accessToken, + refreshToken, + idToken, + accountId, + email: asTrimmedString(claims?.email), + planType: asTrimmedString(claims?.['https://api.openai.com/auth']?.chatgpt_plan_type), + tokenExpiresAt: Date.now() + expiresIn * 1000, + }; +} + +async function exchangeCodexToken( + form: URLSearchParams, + proxyUrl?: string | null, +): Promise { + const response = await fetch(CODEX_TOKEN_URL, withExplicitProxyRequestInit(proxyUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + Accept: 'application/json', + }, + body: form.toString(), + })); + if (!response.ok) { + const text = await response.text().catch(() => ''); + throw new Error(text || `codex token exchange failed with status ${response.status}`); + } + return parseTokenResponsePayload(await response.json()); +} + +export async function exchangeCodexAuthorizationCode(input: { + code: string; + codeVerifier: string; + redirectUri: string; + proxyUrl?: string | null; +}): Promise { + return exchangeCodexToken(new URLSearchParams({ + grant_type: 'authorization_code', + client_id: requireCodexClientId(), + code: input.code, + redirect_uri: input.redirectUri, + code_verifier: input.codeVerifier, + }), input.proxyUrl); +} + +export async function refreshCodexTokens( + refreshToken: string, + proxyUrl?: string | null, +): Promise { + return exchangeCodexToken(new URLSearchParams({ + client_id: requireCodexClientId(), + grant_type: 'refresh_token', + refresh_token: refreshToken, + scope: 'openid profile email', + }), proxyUrl); +} + +function getHeaderValue(headers: Record | undefined, key: string): string | undefined { + if (!headers) return undefined; + const loweredKey = key.toLowerCase(); + for (const [rawKey, rawValue] of Object.entries(headers)) { + if (rawKey.toLowerCase() !== loweredKey) continue; + if (typeof rawValue === 'string') { + const trimmed = rawValue.trim(); + if (trimmed) return trimmed; + } + if (Array.isArray(rawValue)) { + for (const item of rawValue) { + if (typeof item !== 'string') continue; + const trimmed = item.trim(); + if (trimmed) return trimmed; + } + } + } + return undefined; +} + +export const codexOauthProvider: OAuthProviderDefinition = { + metadata: { + provider: CODEX_OAUTH_PROVIDER, + label: 'Codex', + platform: 'codex', + enabled: true, + loginType: 'oauth', + requiresProjectId: false, + supportsDirectAccountRouting: true, + supportsCloudValidation: true, + supportsNativeProxy: true, + }, + site: { + name: 'ChatGPT Codex OAuth', + url: CODEX_UPSTREAM_BASE_URL, + platform: 'codex', + }, + loopback: { + host: '127.0.0.1', + port: CODEX_LOOPBACK_CALLBACK_PORT, + path: CODEX_LOOPBACK_CALLBACK_PATH, + redirectUri: CODEX_LOOPBACK_REDIRECT_URI, + }, + buildAuthorizationUrl: ({ state, redirectUri, codeVerifier }) => buildCodexAuthorizationUrl({ + state, + redirectUri, + codeVerifier, + }), + exchangeAuthorizationCode: async ({ code, redirectUri, codeVerifier, proxyUrl }) => { + const exchange = await exchangeCodexAuthorizationCode({ + code, + redirectUri, + codeVerifier, + proxyUrl, + }); + return { + accessToken: exchange.accessToken, + refreshToken: exchange.refreshToken, + tokenExpiresAt: exchange.tokenExpiresAt, + email: exchange.email, + accountId: exchange.accountId, + accountKey: exchange.accountId, + planType: exchange.planType, + idToken: exchange.idToken, + }; + }, + refreshAccessToken: async ({ refreshToken, proxyUrl }) => { + const refreshed = await refreshCodexTokens(refreshToken, proxyUrl); + return { + accessToken: refreshed.accessToken, + refreshToken: refreshed.refreshToken, + tokenExpiresAt: refreshed.tokenExpiresAt, + email: refreshed.email, + accountId: refreshed.accountId, + accountKey: refreshed.accountId, + planType: refreshed.planType, + idToken: refreshed.idToken, + }; + }, + buildProxyHeaders: ({ oauth, downstreamHeaders }) => { + const accountId = oauth.accountId || oauth.accountKey; + const originator = getHeaderValue(downstreamHeaders, 'originator') || 'codex_cli_rs'; + const headers: Record = { + Originator: originator, + }; + if (accountId) { + headers['Chatgpt-Account-Id'] = accountId; + } + return headers; + }, +}; diff --git a/src/server/services/oauth/geminiCliProvider.ts b/src/server/services/oauth/geminiCliProvider.ts new file mode 100644 index 00000000..5a335cac --- /dev/null +++ b/src/server/services/oauth/geminiCliProvider.ts @@ -0,0 +1,487 @@ +import { fetch } from 'undici'; +import { config } from '../../config.js'; +import { withExplicitProxyRequestInit } from '../siteProxy.js'; +import type { OAuthProviderDefinition } from './providers.js'; + +export const GEMINI_CLI_OAUTH_PROVIDER = 'gemini-cli'; +export const GEMINI_CLI_AUTH_URL = 'https://accounts.google.com/o/oauth2/v2/auth'; +export const GEMINI_CLI_TOKEN_URL = 'https://oauth2.googleapis.com/token'; +export const GEMINI_CLI_USERINFO_URL = 'https://www.googleapis.com/oauth2/v1/userinfo?alt=json'; +export const GEMINI_CLI_PROJECTS_URL = 'https://cloudresourcemanager.googleapis.com/v1/projects'; +export const GEMINI_CLI_SERVICE_USAGE_URL = 'https://serviceusage.googleapis.com/v1'; +export const GEMINI_CLI_CLIENT_ID = config.geminiCliClientId; +export const GEMINI_CLI_CLIENT_SECRET = config.geminiCliClientSecret; +export const GEMINI_CLI_LOOPBACK_CALLBACK_PORT = 8085; +export const GEMINI_CLI_LOOPBACK_CALLBACK_PATH = '/oauth2callback'; +export const GEMINI_CLI_LOOPBACK_REDIRECT_URI = `http://localhost:${GEMINI_CLI_LOOPBACK_CALLBACK_PORT}${GEMINI_CLI_LOOPBACK_CALLBACK_PATH}`; +export const GEMINI_CLI_UPSTREAM_BASE_URL = 'https://cloudcode-pa.googleapis.com'; +export const GEMINI_CLI_GOOGLE_API_CLIENT = 'google-genai-sdk/1.41.0 gl-node/v22.19.0'; +export const GEMINI_CLI_USER_AGENT = 'GeminiCLI/0.31.0/unknown (win32; x64)'; +export const GEMINI_CLI_REQUIRED_SERVICE = 'cloudaicompanion.googleapis.com'; +export const GEMINI_CLI_INTERNAL_API_VERSION = 'v1internal'; +export const GEMINI_CLI_AUTO_ONBOARD_POLL_INTERVAL_MS = 2_000; +export const GEMINI_CLI_AUTO_ONBOARD_MAX_ATTEMPTS = 15; +export const GEMINI_CLI_ONBOARD_POLL_INTERVAL_MS = 5_000; +export const GEMINI_CLI_ONBOARD_MAX_ATTEMPTS = 6; + +function requireGeminiCliOAuthConfig() { + if (!GEMINI_CLI_CLIENT_ID) { + throw new Error('GEMINI_CLI_CLIENT_ID is not configured'); + } + if (!GEMINI_CLI_CLIENT_SECRET) { + throw new Error('GEMINI_CLI_CLIENT_SECRET is not configured'); + } + return { + clientId: GEMINI_CLI_CLIENT_ID, + clientSecret: GEMINI_CLI_CLIENT_SECRET, + }; +} + +const GEMINI_CLI_SCOPES = [ + 'https://www.googleapis.com/auth/cloud-platform', + 'https://www.googleapis.com/auth/userinfo.email', + 'https://www.googleapis.com/auth/userinfo.profile', +]; + +type GeminiOAuthTokenPayload = { + access_token?: unknown; + refresh_token?: unknown; + token_type?: unknown; + expires_in?: unknown; + scope?: unknown; + expiry?: unknown; +}; + +type GeminiLoadCodeAssistPayload = { + cloudaicompanionProject?: unknown; + allowedTiers?: unknown; +}; + +type GeminiOnboardUserPayload = { + done?: unknown; + response?: unknown; +}; + +function asTrimmedString(value: unknown): string | undefined { + if (typeof value !== 'string') return undefined; + const trimmed = value.trim(); + return trimmed || undefined; +} + +function parseExpiresAt(payload: GeminiOAuthTokenPayload): number | undefined { + if (typeof payload.expires_in === 'number' && Number.isFinite(payload.expires_in) && payload.expires_in > 0) { + return Date.now() + Math.trunc(payload.expires_in) * 1000; + } + if (typeof payload.expires_in === 'string') { + const parsed = Number.parseInt(payload.expires_in.trim(), 10); + if (Number.isFinite(parsed) && parsed > 0) { + return Date.now() + parsed * 1000; + } + } + if (typeof payload.expiry === 'string') { + const parsed = Date.parse(payload.expiry); + if (!Number.isNaN(parsed)) return parsed; + } + return undefined; +} + +function buildGeminiCliMetadata() { + return { + ideType: 'IDE_UNSPECIFIED', + platform: 'PLATFORM_UNSPECIFIED', + pluginType: 'GEMINI', + }; +} + +function extractGeminiProjectId(value: unknown): string | undefined { + if (typeof value === 'string') { + return asTrimmedString(value); + } + if (value && typeof value === 'object' && !Array.isArray(value)) { + return asTrimmedString((value as { id?: unknown }).id); + } + return undefined; +} + +function extractGeminiDefaultTierId(payload: GeminiLoadCodeAssistPayload): string { + const allowedTiers = Array.isArray(payload.allowedTiers) ? payload.allowedTiers : []; + for (const rawTier of allowedTiers) { + if (!rawTier || typeof rawTier !== 'object' || Array.isArray(rawTier)) continue; + const tier = rawTier as { id?: unknown; isDefault?: unknown }; + if (tier.isDefault === true) { + const tierId = asTrimmedString(tier.id); + if (tierId) return tierId; + } + } + return 'legacy-tier'; +} + +function isGeminiFreeUserProject(input: { + requestedProjectId?: string; + tierId: string; +}) { + const projectId = (input.requestedProjectId || '').trim(); + const tierId = input.tierId.trim(); + return projectId.startsWith('gen-lang-client-') + || tierId.toUpperCase() === 'FREE' + || tierId.toUpperCase() === 'LEGACY'; +} + +function isSameGeminiProjectId(a: string, b: string): boolean { + return a.trim().toLowerCase() === b.trim().toLowerCase(); +} + +function extractGeminiServiceErrorMessage(raw: string): string { + const trimmed = raw.trim(); + if (!trimmed) return ''; + try { + const parsed = JSON.parse(trimmed) as { + error?: { message?: unknown }; + }; + return asTrimmedString(parsed.error?.message) || trimmed; + } catch { + return trimmed; + } +} + +async function sleep(ms: number): Promise { + await new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function postGeminiToken( + body: URLSearchParams, + proxyUrl?: string | null, +) { + const response = await fetch(GEMINI_CLI_TOKEN_URL, withExplicitProxyRequestInit(proxyUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + Accept: 'application/json', + }, + body: body.toString(), + })); + if (!response.ok) { + const text = await response.text().catch(() => ''); + throw new Error(text || `gemini token exchange failed with status ${response.status}`); + } + const payload = await response.json() as GeminiOAuthTokenPayload; + const accessToken = asTrimmedString(payload.access_token); + if (!accessToken) { + throw new Error('gemini token exchange response missing access token'); + } + return { + accessToken, + refreshToken: asTrimmedString(payload.refresh_token), + tokenExpiresAt: parseExpiresAt(payload), + providerData: { + tokenType: asTrimmedString(payload.token_type), + scope: asTrimmedString(payload.scope), + }, + }; +} + +async function fetchGeminiUserEmail( + accessToken: string, + proxyUrl?: string | null, +): Promise { + const response = await fetch(GEMINI_CLI_USERINFO_URL, withExplicitProxyRequestInit(proxyUrl, { + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: 'application/json', + }, + })); + if (!response.ok) return undefined; + const payload = await response.json() as { email?: unknown }; + return asTrimmedString(payload.email); +} + +async function callGeminiCliInternalApi( + accessToken: string, + method: 'loadCodeAssist' | 'onboardUser', + body: Record, + proxyUrl?: string | null, +): Promise { + const response = await fetch( + `${GEMINI_CLI_UPSTREAM_BASE_URL}/${GEMINI_CLI_INTERNAL_API_VERSION}:${method}`, + withExplicitProxyRequestInit(proxyUrl, { + method: 'POST', + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: 'application/json', + 'Content-Type': 'application/json', + 'User-Agent': GEMINI_CLI_USER_AGENT, + 'X-Goog-Api-Client': GEMINI_CLI_GOOGLE_API_CLIENT, + }, + body: JSON.stringify(body), + }), + ); + if (!response.ok) { + const text = await response.text().catch(() => ''); + throw new Error(text || `api request failed with status ${response.status}`); + } + return response.json() as Promise; +} + +async function fetchGcpProjects(accessToken: string, proxyUrl?: string | null): Promise { + const response = await fetch(GEMINI_CLI_PROJECTS_URL, withExplicitProxyRequestInit(proxyUrl, { + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: 'application/json', + }, + })); + if (!response.ok) { + const text = await response.text().catch(() => ''); + throw new Error(text || `project list request failed with status ${response.status}`); + } + const payload = await response.json() as { projects?: Array<{ projectId?: unknown }> }; + return (payload.projects || []) + .map((project) => asTrimmedString(project.projectId)) + .filter((projectId): projectId is string => !!projectId); +} + +async function checkCloudAIAPIEnabled( + accessToken: string, + projectId: string, + proxyUrl?: string | null, +): Promise { + const checkResponse = await fetch( + `${GEMINI_CLI_SERVICE_USAGE_URL}/projects/${encodeURIComponent(projectId)}/services/${encodeURIComponent(GEMINI_CLI_REQUIRED_SERVICE)}`, + withExplicitProxyRequestInit(proxyUrl, { + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: 'application/json', + 'User-Agent': GEMINI_CLI_USER_AGENT, + 'X-Goog-Api-Client': GEMINI_CLI_GOOGLE_API_CLIENT, + }, + }), + ); + if (checkResponse.ok) { + const payload = await checkResponse.json() as { state?: unknown }; + const state = asTrimmedString(payload.state); + if ((state || '').toUpperCase() === 'ENABLED') { + return; + } + } + + const response = await fetch( + `${GEMINI_CLI_SERVICE_USAGE_URL}/projects/${encodeURIComponent(projectId)}/services/${encodeURIComponent(GEMINI_CLI_REQUIRED_SERVICE)}:enable`, + withExplicitProxyRequestInit(proxyUrl, { + method: 'POST', + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: 'application/json', + 'Content-Type': 'application/json', + 'User-Agent': GEMINI_CLI_USER_AGENT, + 'X-Goog-Api-Client': GEMINI_CLI_GOOGLE_API_CLIENT, + }, + body: '{}', + }), + ); + const text = await response.text().catch(() => ''); + if (response.ok) { + return; + } + if (response.status === 400 && extractGeminiServiceErrorMessage(text).toLowerCase().includes('already enabled')) { + return; + } + throw new Error(`project activation required: ${extractGeminiServiceErrorMessage(text) || `HTTP ${response.status}`}`); +} + +async function performGeminiCliSetup( + accessToken: string, + requestedProjectId: string, + proxyUrl?: string | null, +): Promise { + const trimmedRequest = requestedProjectId.trim(); + const explicitProject = !!trimmedRequest; + const metadata = buildGeminiCliMetadata(); + const loadResponse = await callGeminiCliInternalApi( + accessToken, + 'loadCodeAssist', + explicitProject + ? { + metadata, + cloudaicompanionProject: trimmedRequest, + } + : { metadata }, + proxyUrl, + ); + + const tierId = extractGeminiDefaultTierId(loadResponse); + let projectId = trimmedRequest || extractGeminiProjectId(loadResponse.cloudaicompanionProject) || ''; + + if (!projectId) { + for (let attempt = 0; attempt < GEMINI_CLI_AUTO_ONBOARD_MAX_ATTEMPTS; attempt += 1) { + const onboardResponse = await callGeminiCliInternalApi( + accessToken, + 'onboardUser', + { + tierId, + metadata, + }, + proxyUrl, + ); + if (onboardResponse.done === true) { + const response = ( + onboardResponse.response + && typeof onboardResponse.response === 'object' + && !Array.isArray(onboardResponse.response) + ) + ? onboardResponse.response as { cloudaicompanionProject?: unknown } + : undefined; + projectId = extractGeminiProjectId(response?.cloudaicompanionProject) || ''; + break; + } + if ((attempt + 1) < GEMINI_CLI_AUTO_ONBOARD_MAX_ATTEMPTS) { + await sleep(GEMINI_CLI_AUTO_ONBOARD_POLL_INTERVAL_MS); + } + } + } + + if (!projectId) { + throw new Error('gemini cli: project selection required'); + } + + let finalProjectId = projectId; + for (let attempt = 0; attempt < GEMINI_CLI_ONBOARD_MAX_ATTEMPTS; attempt += 1) { + const onboardResponse = await callGeminiCliInternalApi( + accessToken, + 'onboardUser', + { + tierId, + metadata, + cloudaicompanionProject: projectId, + }, + proxyUrl, + ); + if (onboardResponse.done === true) { + const response = ( + onboardResponse.response + && typeof onboardResponse.response === 'object' + && !Array.isArray(onboardResponse.response) + ) + ? onboardResponse.response as { cloudaicompanionProject?: unknown } + : undefined; + const responseProjectId = extractGeminiProjectId(response?.cloudaicompanionProject) || ''; + if (responseProjectId) { + if (explicitProject && !isSameGeminiProjectId(responseProjectId, projectId)) { + finalProjectId = isGeminiFreeUserProject({ requestedProjectId: projectId, tierId }) + ? responseProjectId + : projectId; + } else { + finalProjectId = responseProjectId; + } + } + return finalProjectId || projectId; + } + if ((attempt + 1) < GEMINI_CLI_ONBOARD_MAX_ATTEMPTS) { + await sleep(GEMINI_CLI_ONBOARD_POLL_INTERVAL_MS); + } + } + + if (finalProjectId) { + return finalProjectId; + } + throw new Error('gemini cli: onboarding timed out'); +} + +async function ensureGeminiProjectAndOnboard( + accessToken: string, + requestedProjectId?: string, + proxyUrl?: string | null, +): Promise { + const explicitProject = asTrimmedString(requestedProjectId); + if (explicitProject) { + return performGeminiCliSetup(accessToken, explicitProject, proxyUrl); + } + + const projects = await fetchGcpProjects(accessToken, proxyUrl); + const firstProject = projects[0]; + if (!firstProject) { + throw new Error('no Google Cloud projects available for this account'); + } + return performGeminiCliSetup(accessToken, firstProject, proxyUrl); +} + +export const geminiCliOauthProvider: OAuthProviderDefinition = { + metadata: { + provider: GEMINI_CLI_OAUTH_PROVIDER, + label: 'Gemini CLI', + platform: 'gemini-cli', + enabled: true, + loginType: 'oauth', + requiresProjectId: true, + supportsDirectAccountRouting: true, + supportsCloudValidation: true, + supportsNativeProxy: true, + }, + site: { + name: 'Google Gemini CLI OAuth', + url: GEMINI_CLI_UPSTREAM_BASE_URL, + platform: 'gemini-cli', + }, + loopback: { + host: '127.0.0.1', + port: GEMINI_CLI_LOOPBACK_CALLBACK_PORT, + path: GEMINI_CLI_LOOPBACK_CALLBACK_PATH, + redirectUri: GEMINI_CLI_LOOPBACK_REDIRECT_URI, + }, + buildAuthorizationUrl: async ({ state, redirectUri }) => { + const oauthConfig = requireGeminiCliOAuthConfig(); + const params = new URLSearchParams({ + client_id: oauthConfig.clientId, + redirect_uri: redirectUri, + response_type: 'code', + access_type: 'offline', + prompt: 'consent', + scope: GEMINI_CLI_SCOPES.join(' '), + state, + }); + return `${GEMINI_CLI_AUTH_URL}?${params.toString()}`; + }, + exchangeAuthorizationCode: async ({ code, redirectUri, projectId, proxyUrl }) => { + const oauthConfig = requireGeminiCliOAuthConfig(); + const token = await postGeminiToken(new URLSearchParams({ + code, + client_id: oauthConfig.clientId, + client_secret: oauthConfig.clientSecret, + redirect_uri: redirectUri, + grant_type: 'authorization_code', + }), proxyUrl); + const resolvedProjectId = await ensureGeminiProjectAndOnboard(token.accessToken, projectId, proxyUrl); + await checkCloudAIAPIEnabled(token.accessToken, resolvedProjectId, proxyUrl); + const email = await fetchGeminiUserEmail(token.accessToken, proxyUrl); + return { + ...token, + email, + accountKey: email, + accountId: email, + projectId: resolvedProjectId, + }; + }, + refreshAccessToken: async ({ refreshToken, oauth, proxyUrl }) => { + const oauthConfig = requireGeminiCliOAuthConfig(); + const token = await postGeminiToken(new URLSearchParams({ + client_id: oauthConfig.clientId, + client_secret: oauthConfig.clientSecret, + grant_type: 'refresh_token', + refresh_token: refreshToken, + }), proxyUrl); + const nextProjectId = oauth?.projectId + ? oauth.projectId + : await ensureGeminiProjectAndOnboard(token.accessToken, undefined, proxyUrl); + await checkCloudAIAPIEnabled(token.accessToken, nextProjectId, proxyUrl); + const email = await fetchGeminiUserEmail(token.accessToken, proxyUrl); + return { + ...token, + email, + accountKey: email, + accountId: email, + projectId: nextProjectId, + }; + }, + buildProxyHeaders: () => ({ + 'User-Agent': GEMINI_CLI_USER_AGENT, + 'X-Goog-Api-Client': GEMINI_CLI_GOOGLE_API_CLIENT, + }), +}; diff --git a/src/server/services/oauth/localCallbackServer.test.ts b/src/server/services/oauth/localCallbackServer.test.ts new file mode 100644 index 00000000..f81e3814 --- /dev/null +++ b/src/server/services/oauth/localCallbackServer.test.ts @@ -0,0 +1,48 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { + startOAuthLoopbackCallbackServer, + stopOAuthLoopbackCallbackServers, +} from './localCallbackServer.js'; + +describe('oauth loopback callback server', () => { + afterEach(async () => { + await stopOAuthLoopbackCallbackServers(); + }); + + it('accepts codex oauth callback requests and closes the popup on success', async () => { + const callbackHandler = vi.fn(async () => ({ accountId: 12, siteId: 34 })); + const started = await startOAuthLoopbackCallbackServer('codex', { + callbackHandler, + port: 0, + }); + + const response = await fetch(`${started.origin}/auth/callback?state=test-state&code=test-code`); + const body = await response.text(); + + expect(response.status).toBe(200); + expect(callbackHandler).toHaveBeenCalledWith({ + provider: 'codex', + state: 'test-state', + code: 'test-code', + error: undefined, + }); + expect(body).toContain('window.close()'); + }); + + it('renders a stable error page when oauth completion fails', async () => { + const callbackHandler = vi.fn(async () => { + throw new Error('oauth failed'); + }); + const started = await startOAuthLoopbackCallbackServer('claude', { + callbackHandler, + port: 0, + }); + + const response = await fetch(`${started.origin}/callback?state=test-state&code=test-code`); + const body = await response.text(); + + expect(response.status).toBe(500); + expect(body).toContain('OAuth authorization failed'); + expect(body).not.toContain('oauth failed'); + }); +}); diff --git a/src/server/services/oauth/localCallbackServer.ts b/src/server/services/oauth/localCallbackServer.ts new file mode 100644 index 00000000..8e96f094 --- /dev/null +++ b/src/server/services/oauth/localCallbackServer.ts @@ -0,0 +1,245 @@ +import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http'; +import { type AddressInfo } from 'node:net'; +import { getOAuthProviderDefinition, listOAuthProviderDefinitions } from './providers.js'; +import { handleOauthCallback } from './service.js'; + +type CallbackHandler = typeof handleOauthCallback; + +type StartOAuthLoopbackCallbackServerOptions = { + host?: string; + port?: number; + callbackHandler?: CallbackHandler; +}; + +export type OAuthLoopbackCallbackServerState = { + provider: string; + attempted: boolean; + ready: boolean; + host?: string; + port: number; + path: string; + origin: string; + redirectUri: string; + error?: string; +}; + +const servers = new Map(); +const states = new Map(); +const startPromises = new Map>(); + +function escapeHtml(value: string): string { + return value + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", '''); +} + +function renderCompletionPage(message: string): string { + const safeMessage = escapeHtml(message); + return ` + + + + OAuth Callback + + + + ${safeMessage} + +`; +} + +function respondHtml(response: ServerResponse, statusCode: number, message: string) { + response.writeHead(statusCode, { + 'Content-Type': 'text/html; charset=utf-8', + 'Cache-Control': 'no-store', + }); + response.end(renderCompletionPage(message)); +} + +function normalizeOrigin(host: string | undefined, port: number): string { + if (!host || host === '::' || host === '0.0.0.0') { + return `http://localhost:${port}`; + } + if (host.includes(':') && !host.startsWith('[')) { + return `http://[${host}]:${port}`; + } + return `http://${host}:${port}`; +} + +function createDefaultState(provider: string): OAuthLoopbackCallbackServerState { + const definition = getOAuthProviderDefinition(provider); + if (!definition) { + throw new Error(`unsupported oauth provider: ${provider}`); + } + return { + provider, + attempted: false, + ready: false, + host: definition.loopback.host, + port: definition.loopback.port, + path: definition.loopback.path, + origin: normalizeOrigin(definition.loopback.host, definition.loopback.port), + redirectUri: definition.loopback.redirectUri, + }; +} + +async function handleCallbackRequest( + provider: string, + request: IncomingMessage, + response: ServerResponse, + callbackHandler: CallbackHandler, +) { + const definition = getOAuthProviderDefinition(provider); + if (!definition) { + response.writeHead(404); + response.end('Not found'); + return; + } + + if (request.method !== 'GET') { + response.writeHead(405, { Allow: 'GET' }); + response.end('Method not allowed'); + return; + } + + const requestUrl = new URL(request.url || '/', 'http://localhost'); + if (requestUrl.pathname !== definition.loopback.path) { + response.writeHead(404); + response.end('Not found'); + return; + } + + try { + await callbackHandler({ + provider, + state: requestUrl.searchParams.get('state') || '', + code: requestUrl.searchParams.get('code') || undefined, + error: requestUrl.searchParams.get('error') || undefined, + }); + respondHtml(response, 200, 'OAuth authorization succeeded. You can close this window.'); + } catch { + respondHtml(response, 500, 'OAuth authorization failed. Return to metapi and review the server logs.'); + } +} + +export function getOAuthLoopbackCallbackServerState(provider: string): OAuthLoopbackCallbackServerState { + return { ...(states.get(provider) || createDefaultState(provider)) }; +} + +export function getOAuthLoopbackCallbackServerStates(): OAuthLoopbackCallbackServerState[] { + return listOAuthProviderDefinitions().map((provider) => getOAuthLoopbackCallbackServerState(provider.metadata.provider)); +} + +export async function startOAuthLoopbackCallbackServer( + provider: string, + options: StartOAuthLoopbackCallbackServerOptions = {}, +): Promise { + const definition = getOAuthProviderDefinition(provider); + if (!definition) { + throw new Error(`unsupported oauth provider: ${provider}`); + } + if (servers.has(provider)) { + return getOAuthLoopbackCallbackServerState(provider); + } + const existingStart = startPromises.get(provider); + if (existingStart) { + return existingStart; + } + + const callbackHandler = options.callbackHandler || handleOauthCallback; + const host = options.host || definition.loopback.host; + const port = options.port ?? definition.loopback.port; + const { path, redirectUri } = definition.loopback; + const startPromise = new Promise((resolve, reject) => { + const server = createServer((request, response) => { + void handleCallbackRequest(provider, request, response, callbackHandler); + }); + + const finalizeFailure = (error: Error) => { + const failedState: OAuthLoopbackCallbackServerState = { + provider, + attempted: true, + ready: false, + host, + port, + path, + origin: normalizeOrigin(host, port), + redirectUri, + error: error.message || `failed to start ${provider} oauth callback server`, + }; + states.set(provider, failedState); + servers.delete(provider); + reject(error); + }; + + const onStartupError = (error: Error) => { + finalizeFailure(error); + }; + + server.once('error', onStartupError); + server.listen(port, host, () => { + server.off('error', onStartupError); + servers.set(provider, server); + const address = server.address() as AddressInfo | null; + const listeningPort = address?.port || port; + const listeningHost = address?.address || host; + const nextState: OAuthLoopbackCallbackServerState = { + provider, + attempted: true, + ready: true, + host: listeningHost, + port: listeningPort, + path, + origin: normalizeOrigin(listeningHost, listeningPort), + redirectUri, + }; + states.set(provider, nextState); + resolve(getOAuthLoopbackCallbackServerState(provider)); + }); + }).finally(() => { + startPromises.delete(provider); + }); + + startPromises.set(provider, startPromise); + return startPromise; +} + +export async function startOAuthLoopbackCallbackServers( + options: StartOAuthLoopbackCallbackServerOptions = {}, +): Promise { + const results = await Promise.allSettled( + listOAuthProviderDefinitions().map((provider) => + startOAuthLoopbackCallbackServer(provider.metadata.provider, options)), + ); + return results.map((result, index) => { + if (result.status === 'fulfilled') return result.value; + return getOAuthLoopbackCallbackServerState(listOAuthProviderDefinitions()[index]!.metadata.provider); + }); +} + +export async function stopOAuthLoopbackCallbackServers(): Promise { + const activeServers = Array.from(servers.entries()); + servers.clear(); + states.clear(); + startPromises.clear(); + + await Promise.all(activeServers.map(async ([provider, server]) => { + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }).catch(() => undefined); + states.set(provider, createDefaultState(provider)); + })); +} + +export async function startCodexLoopbackCallbackServer( + options: StartOAuthLoopbackCallbackServerOptions = {}, +): Promise { + return startOAuthLoopbackCallbackServer('codex', options); +} + +export async function stopCodexLoopbackCallbackServer(): Promise { + await stopOAuthLoopbackCallbackServers(); +} diff --git a/src/server/services/oauth/oauthAccount.test.ts b/src/server/services/oauth/oauthAccount.test.ts new file mode 100644 index 00000000..c8b1fa0e --- /dev/null +++ b/src/server/services/oauth/oauthAccount.test.ts @@ -0,0 +1,247 @@ +import { describe, expect, it } from 'vitest'; +import { + buildOauthIdentityBackfillPatch, + buildOauthInfoFromAccount, + buildStoredOauthStateFromAccount, + getOauthInfoFromAccount, + isOauthProvider, +} from './oauthAccount.js'; + +describe('oauth account identity helpers', () => { + it('prefers structured oauth provider/accountKey/projectId columns while preserving a legacy accountId', () => { + const oauth = getOauthInfoFromAccount({ + oauthProvider: 'gemini-cli', + oauthAccountKey: 'structured-user@example.com', + oauthProjectId: 'structured-project', + extraConfig: JSON.stringify({ + oauth: { + provider: 'codex', + accountKey: 'json-user', + projectId: 'json-project', + refreshToken: 'refresh-token', + quota: { status: 'supported' }, + }, + }), + }); + + expect(oauth).toEqual(expect.objectContaining({ + provider: 'gemini-cli', + accountId: 'json-user', + accountKey: 'structured-user@example.com', + projectId: 'structured-project', + refreshToken: 'refresh-token', + quota: { status: 'supported' }, + })); + }); + + it('falls back to extraConfig metadata when structured columns are absent', () => { + const oauth = getOauthInfoFromAccount({ + oauthProvider: null, + oauthAccountKey: null, + oauthProjectId: null, + extraConfig: JSON.stringify({ + oauth: { + provider: 'codex', + accountKey: 'json-user', + projectId: 'json-project', + refreshToken: 'refresh-token', + }, + }), + }); + + expect(oauth).toEqual(expect.objectContaining({ + provider: 'codex', + accountId: 'json-user', + accountKey: 'json-user', + projectId: 'json-project', + refreshToken: 'refresh-token', + })); + }); + + it('reads oauth metadata when extraConfig is already a parsed object', () => { + const oauth = getOauthInfoFromAccount({ + oauthProvider: null, + oauthAccountKey: null, + oauthProjectId: null, + extraConfig: { + oauth: { + provider: 'codex', + accountKey: 'json-user', + projectId: 'json-project', + refreshToken: 'refresh-token', + }, + }, + }); + + expect(oauth).toEqual(expect.objectContaining({ + provider: 'codex', + accountId: 'json-user', + accountKey: 'json-user', + projectId: 'json-project', + refreshToken: 'refresh-token', + })); + }); + + it('reconstructs oauth runtime state from extraConfig even when identity fields are stripped', () => { + const oauth = getOauthInfoFromAccount({ + oauthProvider: 'gemini-cli', + oauthAccountKey: 'structured-user@example.com', + oauthProjectId: 'structured-project', + extraConfig: JSON.stringify({ + oauth: { + email: 'structured-user@example.com', + refreshToken: 'refresh-token', + modelDiscoveryStatus: 'healthy', + }, + }), + }); + + expect(oauth).toEqual(expect.objectContaining({ + provider: 'gemini-cli', + accountId: 'structured-user@example.com', + accountKey: 'structured-user@example.com', + projectId: 'structured-project', + email: 'structured-user@example.com', + refreshToken: 'refresh-token', + modelDiscoveryStatus: 'healthy', + })); + }); + + it('preserves legacy accountId when structured oauthAccountKey differs', () => { + const oauth = getOauthInfoFromAccount({ + oauthProvider: 'codex', + oauthAccountKey: 'structured-account-key', + oauthProjectId: null, + extraConfig: JSON.stringify({ + oauth: { + provider: 'codex', + accountId: 'legacy-account-id', + accountKey: 'legacy-account-key', + refreshToken: 'refresh-token', + }, + }), + }); + + expect(oauth).toEqual(expect.objectContaining({ + provider: 'codex', + accountId: 'legacy-account-id', + accountKey: 'structured-account-key', + refreshToken: 'refresh-token', + })); + }); + + it('builds patched oauth state from structured identity columns and current runtime state', () => { + const oauth = buildOauthInfoFromAccount({ + oauthProvider: 'codex', + oauthAccountKey: 'structured-account', + oauthProjectId: 'structured-project', + extraConfig: JSON.stringify({ + oauth: { + provider: 'codex', + refreshToken: 'refresh-token', + modelDiscoveryStatus: 'healthy', + }, + }), + }, { + quota: { status: 'supported' } as any, + }); + + expect(oauth).toEqual(expect.objectContaining({ + provider: 'codex', + accountId: 'structured-account', + accountKey: 'structured-account', + projectId: 'structured-project', + refreshToken: 'refresh-token', + modelDiscoveryStatus: 'healthy', + quota: { status: 'supported' }, + })); + }); + + it('strips oauth identity fields when preparing persisted extraConfig state', () => { + const oauth = buildStoredOauthStateFromAccount({ + oauthProvider: 'codex', + oauthAccountKey: 'structured-account', + oauthProjectId: 'structured-project', + extraConfig: JSON.stringify({ + oauth: { + provider: 'codex', + accountId: 'json-account', + accountKey: 'json-account', + projectId: 'json-project', + refreshToken: 'refresh-token', + modelDiscoveryStatus: 'healthy', + }, + }), + }, { + quota: { status: 'supported' } as any, + }); + + expect(oauth).toEqual({ + refreshToken: 'refresh-token', + modelDiscoveryStatus: 'healthy', + quota: { status: 'supported' }, + }); + }); + + it('still recognizes oauth providers from account rows after persisted state strips identity fields', () => { + const account = { + oauthProvider: 'codex', + oauthAccountKey: 'structured-account', + oauthProjectId: 'structured-project', + extraConfig: JSON.stringify({ + oauth: buildStoredOauthStateFromAccount({ + oauthProvider: 'codex', + oauthAccountKey: 'structured-account', + oauthProjectId: 'structured-project', + extraConfig: JSON.stringify({ + oauth: { + provider: 'codex', + accountId: 'legacy-account', + refreshToken: 'refresh-token', + }, + }), + }), + }), + }; + + expect(isOauthProvider(account)).toBe(true); + expect(isOauthProvider(account, 'codex')).toBe(true); + expect(isOauthProvider(account, 'gemini-cli')).toBe(false); + }); + + it('builds a structured identity backfill patch from legacy oauth metadata only for missing columns', () => { + expect(buildOauthIdentityBackfillPatch({ + oauthProvider: null, + oauthAccountKey: null, + oauthProjectId: null, + extraConfig: JSON.stringify({ + oauth: { + provider: 'codex', + accountKey: 'legacy-account', + projectId: 'legacy-project', + refreshToken: 'refresh-token', + }, + }), + })).toEqual({ + oauthProvider: 'codex', + oauthAccountKey: 'legacy-account', + oauthProjectId: 'legacy-project', + }); + + expect(buildOauthIdentityBackfillPatch({ + oauthProvider: 'codex', + oauthAccountKey: 'structured-account', + oauthProjectId: null, + extraConfig: JSON.stringify({ + oauth: { + provider: 'legacy-provider-ignored', + accountKey: 'legacy-account-ignored', + projectId: 'legacy-project', + refreshToken: 'refresh-token', + }, + }), + })).toEqual({ + oauthProjectId: 'legacy-project', + }); + }); +}); diff --git a/src/server/services/oauth/oauthAccount.ts b/src/server/services/oauth/oauthAccount.ts new file mode 100644 index 00000000..50bc3207 --- /dev/null +++ b/src/server/services/oauth/oauthAccount.ts @@ -0,0 +1,296 @@ +import { schema } from '../../db/index.js'; +import type { OauthQuotaSnapshot } from './quotaTypes.js'; + +type ParsedOauthInfo = { + provider?: unknown; + accountId?: unknown; + accountKey?: unknown; + email?: unknown; + planType?: unknown; + projectId?: unknown; + tokenExpiresAt?: unknown; + refreshToken?: unknown; + idToken?: unknown; + providerData?: unknown; + quota?: unknown; + modelDiscoveryStatus?: unknown; + lastModelSyncAt?: unknown; + lastModelSyncError?: unknown; + lastDiscoveredModels?: unknown; +}; + +type ParsedExtraConfig = { + oauth?: ParsedOauthInfo; +}; + +type ExtraConfigInput = string | Record | null | undefined; + +export type OauthModelDiscoveryStatus = 'healthy' | 'abnormal'; + +export type OauthInfo = { + provider: string; + accountId?: string; + accountKey?: string; + email?: string; + planType?: string; + projectId?: string; + tokenExpiresAt?: number; + refreshToken?: string; + idToken?: string; + providerData?: Record; + quota?: OauthQuotaSnapshot; + modelDiscoveryStatus?: OauthModelDiscoveryStatus; + lastModelSyncAt?: string; + lastModelSyncError?: string; + lastDiscoveredModels?: string[]; +}; + +export type StoredOauthState = Omit; + +type OauthIdentityCarrier = { + extraConfig?: ExtraConfigInput; + oauthProvider?: string | null; + oauthAccountKey?: string | null; + oauthProjectId?: string | null; +}; + +type StoredOauthIdentity = Pick; + +function isRecord(value: unknown): value is Record { + return !!value && typeof value === 'object' && !Array.isArray(value); +} + +function parseExtraConfig(extraConfig?: ExtraConfigInput): ParsedExtraConfig { + if (!extraConfig) return {}; + if (isRecord(extraConfig)) return extraConfig as ParsedExtraConfig; + if (typeof extraConfig !== 'string') return {}; + try { + const parsed = JSON.parse(extraConfig) as unknown; + if (!isRecord(parsed)) return {}; + return parsed as ParsedExtraConfig; + } catch { + return {}; + } +} + +function asTrimmedString(value: unknown): string | undefined { + if (typeof value !== 'string') return undefined; + const trimmed = value.trim(); + return trimmed || undefined; +} + +function asPositiveInteger(value: unknown): number | undefined { + if (typeof value === 'number' && Number.isFinite(value) && value > 0) { + return Math.trunc(value); + } + if (typeof value === 'string') { + const parsed = Number.parseInt(value.trim(), 10); + if (Number.isFinite(parsed) && parsed > 0) return parsed; + } + return undefined; +} + +function asIsoDateTime(value: unknown): string | undefined { + if (typeof value !== 'string') return undefined; + const trimmed = value.trim(); + if (!trimmed) return undefined; + const parsed = Date.parse(trimmed); + return Number.isNaN(parsed) ? undefined : new Date(parsed).toISOString(); +} + +function asStringArray(value: unknown): string[] | undefined { + if (!Array.isArray(value)) return undefined; + return value + .map((item) => asTrimmedString(item)) + .filter((item): item is string => !!item); +} + +function asRecord(value: unknown): Record | undefined { + if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined; + return value as Record; +} + +function asQuotaSnapshot(value: unknown): OauthQuotaSnapshot | undefined { + if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined; + return value as OauthQuotaSnapshot; +} + +function asModelDiscoveryStatus(value: unknown): OauthModelDiscoveryStatus | undefined { + if (typeof value !== 'string') return undefined; + const normalized = value.trim().toLowerCase(); + if (normalized === 'healthy') return 'healthy'; + if (normalized === 'abnormal') return 'abnormal'; + return undefined; +} + +function parseStoredOauthIdentity(extraConfig?: ExtraConfigInput): StoredOauthIdentity | null { + const parsed = parseExtraConfig(extraConfig).oauth; + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null; + const accountKey = asTrimmedString(parsed.accountKey) || asTrimmedString(parsed.accountId); + const provider = asTrimmedString(parsed.provider); + if (!provider) return null; + return { + provider, + accountId: asTrimmedString(parsed.accountId) || accountKey, + accountKey, + projectId: asTrimmedString(parsed.projectId), + }; +} + +function parseStoredOauthRuntimeState(extraConfig?: ExtraConfigInput): Partial | null { + const parsed = parseExtraConfig(extraConfig).oauth; + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null; + return { + email: asTrimmedString(parsed.email), + planType: asTrimmedString(parsed.planType), + tokenExpiresAt: asPositiveInteger(parsed.tokenExpiresAt), + refreshToken: asTrimmedString(parsed.refreshToken), + idToken: asTrimmedString(parsed.idToken), + providerData: asRecord(parsed.providerData), + quota: asQuotaSnapshot(parsed.quota), + modelDiscoveryStatus: asModelDiscoveryStatus(parsed.modelDiscoveryStatus), + lastModelSyncAt: asIsoDateTime(parsed.lastModelSyncAt), + lastModelSyncError: asTrimmedString(parsed.lastModelSyncError), + lastDiscoveredModels: asStringArray(parsed.lastDiscoveredModels), + }; +} + +export function getOauthInfoFromExtraConfig(extraConfig?: ExtraConfigInput): OauthInfo | null { + const identity = parseStoredOauthIdentity(extraConfig); + const runtime = parseStoredOauthRuntimeState(extraConfig); + const provider = identity?.provider; + if (!provider) return null; + return { + provider, + accountId: identity?.accountId || identity?.accountKey, + accountKey: identity?.accountKey, + projectId: identity?.projectId, + email: runtime?.email, + planType: runtime?.planType, + tokenExpiresAt: runtime?.tokenExpiresAt, + refreshToken: runtime?.refreshToken, + idToken: runtime?.idToken, + providerData: runtime?.providerData, + quota: runtime?.quota, + modelDiscoveryStatus: runtime?.modelDiscoveryStatus, + lastModelSyncAt: runtime?.lastModelSyncAt, + lastModelSyncError: runtime?.lastModelSyncError, + lastDiscoveredModels: runtime?.lastDiscoveredModels, + }; +} + +export function getOauthInfoFromAccount(account?: OauthIdentityCarrier | null): OauthInfo | null { + if (!account) return null; + const storedIdentity = parseStoredOauthIdentity(account.extraConfig); + const storedRuntime = parseStoredOauthRuntimeState(account.extraConfig); + const provider = asTrimmedString(account.oauthProvider) || storedIdentity?.provider; + if (!provider) return null; + const structuredAccountKey = asTrimmedString(account.oauthAccountKey); + const accountKey = structuredAccountKey || storedIdentity?.accountKey || storedIdentity?.accountId; + const accountId = storedIdentity?.accountId || structuredAccountKey || accountKey; + const projectId = asTrimmedString(account.oauthProjectId) || storedIdentity?.projectId; + return { + ...(storedRuntime || {}), + provider, + accountId, + accountKey, + projectId, + }; +} + +export function buildOauthIdentityBackfillPatch( + account?: OauthIdentityCarrier | null, +): Partial> | null { + if (!account) return null; + const legacyIdentity = parseStoredOauthIdentity(account.extraConfig); + if (!legacyIdentity?.provider) return null; + + const patch: Partial> = {}; + if (!asTrimmedString(account.oauthProvider)) { + patch.oauthProvider = legacyIdentity.provider; + } + if (!asTrimmedString(account.oauthAccountKey) && (legacyIdentity.accountKey || legacyIdentity.accountId)) { + patch.oauthAccountKey = legacyIdentity.accountKey || legacyIdentity.accountId; + } + if (!asTrimmedString(account.oauthProjectId) && legacyIdentity.projectId) { + patch.oauthProjectId = legacyIdentity.projectId; + } + + return Object.keys(patch).length > 0 ? patch : null; +} + +export function buildOauthInfo( + extraConfig?: ExtraConfigInput, + patch: Partial = {}, +): OauthInfo { + const provider = patch.provider || getOauthInfoFromExtraConfig(extraConfig)?.provider; + if (!provider) { + throw new Error('oauth provider is required'); + } + const current = getOauthInfoFromExtraConfig(extraConfig); + const next: OauthInfo = { + provider, + ...(current || {}), + ...patch, + }; + if (!next.accountKey && next.accountId) { + next.accountKey = next.accountId; + } + if (!next.accountId && next.accountKey) { + next.accountId = next.accountKey; + } + return next; +} + +export function buildOauthInfoFromAccount( + account?: OauthIdentityCarrier | null, + patch: Partial = {}, +): OauthInfo { + const provider = patch.provider || getOauthInfoFromAccount(account)?.provider; + if (!provider) { + throw new Error('oauth provider is required'); + } + const current = getOauthInfoFromAccount(account); + const next: OauthInfo = { + provider, + ...(current || {}), + ...patch, + }; + if (!next.accountKey && next.accountId) { + next.accountKey = next.accountId; + } + if (!next.accountId && next.accountKey) { + next.accountId = next.accountKey; + } + return next; +} + +export function buildStoredOauthState(oauth: OauthInfo): StoredOauthState { + const { + provider: _provider, + accountId: _accountId, + accountKey: _accountKey, + projectId: _projectId, + ...stored + } = oauth; + return stored; +} + +export function buildStoredOauthStateFromAccount( + account?: OauthIdentityCarrier | null, + patch: Partial = {}, +): StoredOauthState { + return buildStoredOauthState(buildOauthInfoFromAccount(account, patch)); +} + +export function isOauthProvider( + account: OauthIdentityCarrier | string | null | undefined, + provider?: string, +): boolean { + const oauth = typeof account === 'string' || account == null + ? getOauthInfoFromExtraConfig(account) + : getOauthInfoFromAccount(account); + if (!oauth) return false; + if (!provider) return true; + return oauth.provider === provider; +} diff --git a/src/server/services/oauth/oauthIdentityBackfill.ts b/src/server/services/oauth/oauthIdentityBackfill.ts new file mode 100644 index 00000000..9f5114e1 --- /dev/null +++ b/src/server/services/oauth/oauthIdentityBackfill.ts @@ -0,0 +1,43 @@ +import { eq, isNotNull, isNull, or } from 'drizzle-orm'; +import { db, schema } from '../../db/index.js'; +import { buildOauthIdentityBackfillPatch } from './oauthAccount.js'; + +let inFlightOauthIdentityBackfill: Promise | null = null; + +async function runOauthIdentityBackfill(): Promise { + const rows = await db.select().from(schema.accounts) + .where(or( + isNotNull(schema.accounts.extraConfig), + isNull(schema.accounts.oauthProvider), + )) + .all(); + + let updated = 0; + for (const row of rows) { + const patch = buildOauthIdentityBackfillPatch(row); + if (!patch) continue; + await db.update(schema.accounts).set({ + ...patch, + updatedAt: new Date().toISOString(), + }).where(eq(schema.accounts.id, row.id)).run(); + updated += 1; + } + + return updated; +} + +export async function ensureOauthIdentityBackfill(): Promise { + if (inFlightOauthIdentityBackfill) { + return inFlightOauthIdentityBackfill; + } + + inFlightOauthIdentityBackfill = (async () => { + try { + return await runOauthIdentityBackfill(); + } finally { + inFlightOauthIdentityBackfill = null; + } + })(); + + return inFlightOauthIdentityBackfill; +} diff --git a/src/server/services/oauth/oauthSiteRegistry.test.ts b/src/server/services/oauth/oauthSiteRegistry.test.ts new file mode 100644 index 00000000..35d0bf21 --- /dev/null +++ b/src/server/services/oauth/oauthSiteRegistry.test.ts @@ -0,0 +1,51 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +type DbModule = typeof import('../../db/index.js'); + +describe('oauth site registry', () => { + let db: DbModule['db']; + let schema: DbModule['schema']; + let dataDir = ''; + + beforeAll(async () => { + dataDir = mkdtempSync(join(tmpdir(), 'metapi-oauth-site-registry-')); + process.env.DATA_DIR = dataDir; + await import('../../db/migrate.js'); + const dbModule = await import('../../db/index.js'); + db = dbModule.db; + schema = dbModule.schema; + }); + + beforeEach(async () => { + await db.delete(schema.sites).run(); + }); + + afterAll(() => { + delete process.env.DATA_DIR; + if (dataDir) { + rmSync(dataDir, { recursive: true, force: true }); + } + }); + + it('creates missing oauth provider sites without duplicating existing rows', async () => { + await db.insert(schema.sites).values({ + name: 'Anthropic Claude OAuth', + url: 'https://api.anthropic.com', + platform: 'claude', + status: 'active', + useSystemProxy: true, + }).run(); + + const { ensureOauthProviderSitesExist } = await import('./oauthSiteRegistry.js'); + await ensureOauthProviderSitesExist(); + + const rows = await db.select().from(schema.sites).all(); + expect(rows.filter((row) => row.platform === 'codex')).toHaveLength(1); + expect(rows.filter((row) => row.platform === 'gemini-cli')).toHaveLength(1); + expect(rows.filter((row) => row.platform === 'antigravity')).toHaveLength(1); + expect(rows.filter((row) => row.platform === 'claude')).toHaveLength(1); + }); +}); diff --git a/src/server/services/oauth/oauthSiteRegistry.ts b/src/server/services/oauth/oauthSiteRegistry.ts new file mode 100644 index 00000000..5ab26c70 --- /dev/null +++ b/src/server/services/oauth/oauthSiteRegistry.ts @@ -0,0 +1,55 @@ +import { and, eq, sql } from 'drizzle-orm'; +import { db, schema } from '../../db/index.js'; +import { listOAuthProviderDefinitions, type OAuthProviderDefinition } from './providers.js'; + +function isUniqueConstraintError(error: unknown): boolean { + if (!error) return false; + const message = error instanceof Error ? error.message : String(error); + const normalized = message.toLowerCase(); + return normalized.includes('unique') + || normalized.includes('duplicate') + || normalized.includes('constraint failed'); +} + +async function getNextSiteSortOrder(): Promise { + const row = await db.select({ + maxSortOrder: sql`COALESCE(MAX(${schema.sites.sortOrder}), -1)`, + }).from(schema.sites).get(); + return (row?.maxSortOrder ?? -1) + 1; +} + +export async function ensureOauthProviderSite(definition: OAuthProviderDefinition) { + const existing = await db.select().from(schema.sites).where(and( + eq(schema.sites.platform, definition.site.platform), + eq(schema.sites.url, definition.site.url), + )).get(); + if (existing) return existing; + + try { + return await db.insert(schema.sites).values({ + name: definition.site.name, + url: definition.site.url, + platform: definition.site.platform, + status: 'active', + useSystemProxy: false, + isPinned: false, + globalWeight: 1, + sortOrder: await getNextSiteSortOrder(), + }).returning().get(); + } catch (error) { + if (!isUniqueConstraintError(error)) throw error; + const recovered = await db.select().from(schema.sites).where(and( + eq(schema.sites.platform, definition.site.platform), + eq(schema.sites.url, definition.site.url), + )).get(); + if (recovered) return recovered; + throw error; + } +} + +export async function ensureOauthProviderSitesExist(): Promise { + const definitions = listOAuthProviderDefinitions(); + for (const definition of definitions) { + await ensureOauthProviderSite(definition); + } +} diff --git a/src/server/services/oauth/providers.ts b/src/server/services/oauth/providers.ts new file mode 100644 index 00000000..895ccf4f --- /dev/null +++ b/src/server/services/oauth/providers.ts @@ -0,0 +1,102 @@ +import { codexOauthProvider } from './codexProvider.js'; +import { claudeOauthProvider } from './claudeProvider.js'; +import { geminiCliOauthProvider } from './geminiCliProvider.js'; +import { antigravityOauthProvider } from './antigravityProvider.js'; + +export type OAuthProviderId = 'codex' | 'claude' | 'gemini-cli' | 'antigravity'; + +export type OAuthProviderMetadata = { + provider: OAuthProviderId; + label: string; + platform: string; + enabled: boolean; + loginType: 'oauth'; + requiresProjectId: boolean; + supportsDirectAccountRouting: boolean; + supportsCloudValidation: boolean; + supportsNativeProxy: boolean; +}; + +export type OAuthProviderExchangeResult = { + accessToken: string; + refreshToken?: string; + tokenExpiresAt?: number; + email?: string; + accountKey?: string; + accountId?: string; + planType?: string; + projectId?: string; + idToken?: string; + providerData?: Record; +}; + +export type OAuthProviderRefreshResult = OAuthProviderExchangeResult; + +export type OAuthProviderProxyHeaderInput = { + oauth: { + provider: string; + accountKey?: string; + accountId?: string; + projectId?: string; + providerData?: Record; + }; + downstreamHeaders?: Record; +}; + +export interface OAuthProviderDefinition { + metadata: OAuthProviderMetadata; + site: { + name: string; + url: string; + platform: string; + }; + loopback: { + host: string; + port: number; + path: string; + redirectUri: string; + }; + buildAuthorizationUrl(input: { + state: string; + redirectUri: string; + codeVerifier: string; + projectId?: string; + }): Promise; + resolveRedirectUri?(input: { + requestOrigin?: string; + }): string; + exchangeAuthorizationCode(input: { + code: string; + state: string; + redirectUri: string; + codeVerifier: string; + projectId?: string; + proxyUrl?: string | null; + }): Promise; + refreshAccessToken(input: { + refreshToken: string; + oauth?: { + projectId?: string; + providerData?: Record; + }; + proxyUrl?: string | null; + }): Promise; + buildProxyHeaders?(input: OAuthProviderProxyHeaderInput): Record; +} + +const PROVIDERS: OAuthProviderDefinition[] = [ + codexOauthProvider, + claudeOauthProvider, + geminiCliOauthProvider, + antigravityOauthProvider, +]; + +const PROVIDER_BY_ID = new Map(PROVIDERS.map((provider) => [provider.metadata.provider, provider] as const)); + +export function listOAuthProviderDefinitions(): OAuthProviderDefinition[] { + return PROVIDERS.slice(); +} + +export function getOAuthProviderDefinition(provider: string): OAuthProviderDefinition | undefined { + return PROVIDER_BY_ID.get(provider as OAuthProviderId); +} diff --git a/src/server/services/oauth/quota.test.ts b/src/server/services/oauth/quota.test.ts new file mode 100644 index 00000000..0466ce73 --- /dev/null +++ b/src/server/services/oauth/quota.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it } from 'vitest'; +import { + buildQuotaSnapshotFromOauthInfo, + parseCodexQuotaResetHint, +} from './quota.js'; + +function buildJwt(payload: Record) { + const encode = (value: unknown) => Buffer.from(JSON.stringify(value)) + .toString('base64url'); + return `${encode({ alg: 'none', typ: 'JWT' })}.${encode(payload)}.signature`; +} + +describe('oauth quota snapshot helpers', () => { + it('builds a codex quota snapshot from stored id_token claims and reset hint', () => { + const snapshot = buildQuotaSnapshotFromOauthInfo({ + provider: 'codex', + planType: 'plus', + idToken: buildJwt({ + email: 'codex-user@example.com', + 'https://api.openai.com/auth': { + chatgpt_account_id: 'chatgpt-account-123', + chatgpt_plan_type: 'plus', + chatgpt_subscription_active_start: '2026-03-01T00:00:00.000Z', + chatgpt_subscription_active_until: '2026-04-01T00:00:00.000Z', + }, + }), + quota: { + status: 'supported', + source: 'reverse_engineered', + lastSyncAt: '2026-03-18T01:00:00.000Z', + lastLimitResetAt: '2026-03-18T05:00:00.000Z', + providerMessage: 'current codex oauth signals do not expose stable 5h/7d remaining values', + windows: { + fiveHour: { + supported: false, + message: 'official 5h quota window is not exposed by current codex oauth artifacts', + }, + sevenDay: { + supported: false, + message: 'official 7d quota window is not exposed by current codex oauth artifacts', + }, + }, + }, + }); + + expect(snapshot).toEqual({ + status: 'supported', + source: 'reverse_engineered', + lastSyncAt: '2026-03-18T01:00:00.000Z', + lastLimitResetAt: '2026-03-18T05:00:00.000Z', + providerMessage: 'current codex oauth signals do not expose stable 5h/7d remaining values', + subscription: { + planType: 'plus', + activeStart: '2026-03-01T00:00:00.000Z', + activeUntil: '2026-04-01T00:00:00.000Z', + }, + windows: { + fiveHour: { + supported: false, + message: 'official 5h quota window is not exposed by current codex oauth artifacts', + }, + sevenDay: { + supported: false, + message: 'official 7d quota window is not exposed by current codex oauth artifacts', + }, + }, + }); + }); + + it('returns unsupported snapshots for non-codex providers', () => { + const snapshot = buildQuotaSnapshotFromOauthInfo({ + provider: 'antigravity', + planType: 'pro', + }); + + expect(snapshot).toEqual({ + status: 'unsupported', + source: 'official', + providerMessage: 'official quota windows are not exposed for antigravity oauth', + windows: { + fiveHour: { + supported: false, + message: 'official 5h quota window is unavailable for this provider', + }, + sevenDay: { + supported: false, + message: 'official 7d quota window is unavailable for this provider', + }, + }, + }); + }); + + it('parses codex usage_limit_reached reset hints', () => { + const reset = parseCodexQuotaResetHint(429, JSON.stringify({ + error: { + type: 'usage_limit_reached', + resets_at: 1773800400, + }, + })); + + expect(reset).toEqual({ + resetAt: '2026-03-18T02:20:00.000Z', + message: 'codex usage_limit_reached reset hint observed from upstream', + }); + }); +}); diff --git a/src/server/services/oauth/quota.ts b/src/server/services/oauth/quota.ts new file mode 100644 index 00000000..3a79dcbe --- /dev/null +++ b/src/server/services/oauth/quota.ts @@ -0,0 +1,263 @@ +import { eq } from 'drizzle-orm'; +import { db, schema } from '../../db/index.js'; +import { mergeAccountExtraConfig } from '../accountExtraConfig.js'; +import { + buildStoredOauthStateFromAccount, + getOauthInfoFromAccount, + type OauthInfo, +} from './oauthAccount.js'; +import type { OauthQuotaSnapshot, OauthQuotaWindowSnapshot } from './quotaTypes.js'; + +type CodexJwtClaims = { + 'https://api.openai.com/auth'?: { + chatgpt_plan_type?: unknown; + chatgpt_subscription_active_start?: unknown; + chatgpt_subscription_active_until?: unknown; + }; +}; + +function asTrimmedString(value: unknown): string | undefined { + if (typeof value !== 'string') return undefined; + const trimmed = value.trim(); + return trimmed || undefined; +} + +function asIsoDateTime(value: unknown): string | undefined { + if (typeof value !== 'string') return undefined; + const trimmed = value.trim(); + if (!trimmed) return undefined; + const parsed = Date.parse(trimmed); + return Number.isNaN(parsed) ? undefined : new Date(parsed).toISOString(); +} + +function parseCodexJwtClaims(idToken?: string): CodexJwtClaims | null { + if (!idToken) return null; + const parts = idToken.split('.'); + if (parts.length !== 3) return null; + try { + const payload = JSON.parse(Buffer.from(parts[1] || '', 'base64url').toString('utf8')) as unknown; + if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return null; + return payload as CodexJwtClaims; + } catch { + return null; + } +} + +function buildUnsupportedWindow(message: string): OauthQuotaWindowSnapshot { + return { supported: false, message }; +} + +function buildCodexUnsupportedWindows(): OauthQuotaSnapshot['windows'] { + return { + fiveHour: buildUnsupportedWindow('official 5h quota window is not exposed by current codex oauth artifacts'), + sevenDay: buildUnsupportedWindow('official 7d quota window is not exposed by current codex oauth artifacts'), + }; +} + +function buildProviderUnsupportedSnapshot(provider: string): OauthQuotaSnapshot { + return { + status: 'unsupported', + source: 'official', + providerMessage: `official quota windows are not exposed for ${provider} oauth`, + windows: { + fiveHour: buildUnsupportedWindow('official 5h quota window is unavailable for this provider'), + sevenDay: buildUnsupportedWindow('official 7d quota window is unavailable for this provider'), + }, + }; +} + +function normalizeStoredWindow(value: unknown): OauthQuotaWindowSnapshot | undefined { + if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined; + const raw = value as Record; + const supported = typeof raw.supported === 'boolean' ? raw.supported : undefined; + if (supported === undefined) return undefined; + const pickNumber = (field: string) => { + const item = raw[field]; + return typeof item === 'number' && Number.isFinite(item) ? item : undefined; + }; + const normalized: OauthQuotaWindowSnapshot = { + supported, + }; + const limit = pickNumber('limit'); + const used = pickNumber('used'); + const remaining = pickNumber('remaining'); + const resetAt = asIsoDateTime(raw.resetAt); + const message = asTrimmedString(raw.message); + if (limit !== undefined) normalized.limit = limit; + if (used !== undefined) normalized.used = used; + if (remaining !== undefined) normalized.remaining = remaining; + if (resetAt) normalized.resetAt = resetAt; + if (message) normalized.message = message; + return normalized; +} + +function normalizeStoredQuotaSnapshot(value: unknown): OauthQuotaSnapshot | undefined { + if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined; + const raw = value as Record; + const status = raw.status === 'supported' || raw.status === 'unsupported' || raw.status === 'error' + ? raw.status + : undefined; + const source = raw.source === 'official' || raw.source === 'reverse_engineered' + ? raw.source + : undefined; + const windowsRaw = raw.windows; + if (!status || !source || !windowsRaw || typeof windowsRaw !== 'object' || Array.isArray(windowsRaw)) { + return undefined; + } + const windowsObject = windowsRaw as Record; + const fiveHour = normalizeStoredWindow(windowsObject.fiveHour); + const sevenDay = normalizeStoredWindow(windowsObject.sevenDay); + if (!fiveHour || !sevenDay) return undefined; + + const subscriptionRaw = raw.subscription; + const subscription = subscriptionRaw && typeof subscriptionRaw === 'object' && !Array.isArray(subscriptionRaw) + ? { + planType: asTrimmedString((subscriptionRaw as Record).planType), + activeStart: asIsoDateTime((subscriptionRaw as Record).activeStart), + activeUntil: asIsoDateTime((subscriptionRaw as Record).activeUntil), + } + : undefined; + + return { + status, + source, + ...(asIsoDateTime(raw.lastSyncAt) ? { lastSyncAt: asIsoDateTime(raw.lastSyncAt)! } : {}), + ...(asTrimmedString(raw.lastError) ? { lastError: asTrimmedString(raw.lastError)! } : {}), + ...(asTrimmedString(raw.providerMessage) ? { providerMessage: asTrimmedString(raw.providerMessage)! } : {}), + ...(subscription && (subscription.planType || subscription.activeStart || subscription.activeUntil) + ? { subscription } + : {}), + windows: { fiveHour, sevenDay }, + ...(asIsoDateTime(raw.lastLimitResetAt) ? { lastLimitResetAt: asIsoDateTime(raw.lastLimitResetAt)! } : {}), + }; +} + +function buildStoredCodexSnapshot(oauth: Pick): OauthQuotaSnapshot { + const claims = parseCodexJwtClaims(oauth.idToken); + const authClaims = claims?.['https://api.openai.com/auth']; + const storedQuota = normalizeStoredQuotaSnapshot(oauth.quota); + const subscription = { + planType: asTrimmedString(authClaims?.chatgpt_plan_type) || oauth.planType, + activeStart: asIsoDateTime(authClaims?.chatgpt_subscription_active_start), + activeUntil: asIsoDateTime(authClaims?.chatgpt_subscription_active_until), + }; + + return { + status: storedQuota?.status || 'supported', + source: storedQuota?.source || 'reverse_engineered', + ...(storedQuota?.lastSyncAt ? { lastSyncAt: storedQuota.lastSyncAt } : {}), + ...(storedQuota?.lastError ? { lastError: storedQuota.lastError } : {}), + providerMessage: storedQuota?.providerMessage || 'current codex oauth signals do not expose stable 5h/7d remaining values', + ...((subscription.planType || subscription.activeStart || subscription.activeUntil) ? { subscription } : {}), + windows: storedQuota?.windows || buildCodexUnsupportedWindows(), + ...(storedQuota?.lastLimitResetAt ? { lastLimitResetAt: storedQuota.lastLimitResetAt } : {}), + }; +} + +export function buildQuotaSnapshotFromOauthInfo(oauth: Pick): OauthQuotaSnapshot { + if (oauth.provider === 'codex') { + return buildStoredCodexSnapshot(oauth); + } + return buildProviderUnsupportedSnapshot(oauth.provider); +} + +export function parseCodexQuotaResetHint( + statusCode: number, + errorBody: string | null | undefined, + nowMs = Date.now(), +): { resetAt: string; message: string } | null { + if (statusCode !== 429 || !errorBody) return null; + try { + const parsed = JSON.parse(errorBody) as Record; + const error = parsed?.error; + if (!error || typeof error !== 'object' || error.type !== 'usage_limit_reached') { + return null; + } + if (typeof error.resets_at === 'number' && Number.isFinite(error.resets_at) && error.resets_at > 0) { + return { + resetAt: new Date(error.resets_at * 1000).toISOString(), + message: 'codex usage_limit_reached reset hint observed from upstream', + }; + } + if (typeof error.resets_in_seconds === 'number' && Number.isFinite(error.resets_in_seconds) && error.resets_in_seconds > 0) { + return { + resetAt: new Date(nowMs + error.resets_in_seconds * 1000).toISOString(), + message: 'codex usage_limit_reached reset hint observed from upstream', + }; + } + } catch { + return null; + } + return null; +} + +async function persistQuotaSnapshot(accountId: number, snapshot: OauthQuotaSnapshot) { + const account = await db.select().from(schema.accounts).where(eq(schema.accounts.id, accountId)).get(); + if (!account) { + throw new Error('oauth account not found'); + } + const oauth = getOauthInfoFromAccount(account); + if (!oauth) { + throw new Error('account is not managed by oauth'); + } + const nextExtraConfig = mergeAccountExtraConfig(account.extraConfig, { + oauth: buildStoredOauthStateFromAccount(account, { + quota: snapshot, + }), + }); + await db.update(schema.accounts).set({ + extraConfig: nextExtraConfig, + updatedAt: new Date().toISOString(), + }).where(eq(schema.accounts.id, accountId)).run(); + return snapshot; +} + +export async function refreshOauthQuotaSnapshot(accountId: number): Promise { + const account = await db.select().from(schema.accounts).where(eq(schema.accounts.id, accountId)).get(); + if (!account) { + throw new Error('oauth account not found'); + } + const oauth = getOauthInfoFromAccount(account); + if (!oauth) { + throw new Error('account is not managed by oauth'); + } + const baseSnapshot = buildQuotaSnapshotFromOauthInfo(oauth); + const snapshot: OauthQuotaSnapshot = { + ...baseSnapshot, + lastSyncAt: new Date().toISOString(), + ...(baseSnapshot.status === 'error' ? {} : { lastError: undefined }), + }; + return persistQuotaSnapshot(accountId, snapshot); +} + +export async function recordOauthQuotaResetHint(input: { + accountId: number; + statusCode: number; + errorText?: string | null; +}): Promise { + const resetHint = parseCodexQuotaResetHint(input.statusCode, input.errorText); + if (!resetHint) return null; + + const account = await db.select().from(schema.accounts).where(eq(schema.accounts.id, input.accountId)).get(); + if (!account) return null; + const oauth = getOauthInfoFromAccount(account); + if (!oauth || oauth.provider !== 'codex') return null; + + const baseSnapshot = buildQuotaSnapshotFromOauthInfo({ + ...oauth, + quota: { + ...normalizeStoredQuotaSnapshot(oauth.quota), + status: 'supported', + source: 'reverse_engineered', + lastLimitResetAt: resetHint.resetAt, + providerMessage: 'current codex oauth signals do not expose stable 5h/7d remaining values', + windows: normalizeStoredQuotaSnapshot(oauth.quota)?.windows || buildCodexUnsupportedWindows(), + }, + }); + + return persistQuotaSnapshot(input.accountId, { + ...baseSnapshot, + lastSyncAt: new Date().toISOString(), + lastLimitResetAt: resetHint.resetAt, + }); +} diff --git a/src/server/services/oauth/quotaTypes.ts b/src/server/services/oauth/quotaTypes.ts new file mode 100644 index 00000000..845a1e5e --- /dev/null +++ b/src/server/services/oauth/quotaTypes.ts @@ -0,0 +1,26 @@ +export type OauthQuotaWindowSnapshot = { + supported: boolean; + limit?: number | null; + used?: number | null; + remaining?: number | null; + resetAt?: string | null; + message?: string; +}; + +export type OauthQuotaSnapshot = { + status: 'supported' | 'unsupported' | 'error'; + source: 'official' | 'reverse_engineered'; + lastSyncAt?: string; + lastError?: string; + providerMessage?: string; + subscription?: { + planType?: string; + activeStart?: string; + activeUntil?: string; + }; + windows: { + fiveHour: OauthQuotaWindowSnapshot; + sevenDay: OauthQuotaWindowSnapshot; + }; + lastLimitResetAt?: string; +}; diff --git a/src/server/services/oauth/refreshSingleflight.ts b/src/server/services/oauth/refreshSingleflight.ts new file mode 100644 index 00000000..867c86f7 --- /dev/null +++ b/src/server/services/oauth/refreshSingleflight.ts @@ -0,0 +1,16 @@ +import { refreshOauthAccessToken } from './service.js'; + +const refreshInFlight = new Map>>>(); + +export async function refreshOauthAccessTokenSingleflight(accountId: number) { + const existing = refreshInFlight.get(accountId); + if (existing) { + return existing; + } + + const promise = refreshOauthAccessToken(accountId).finally(() => { + refreshInFlight.delete(accountId); + }); + refreshInFlight.set(accountId, promise); + return promise; +} diff --git a/src/server/services/oauth/requestProxy.ts b/src/server/services/oauth/requestProxy.ts new file mode 100644 index 00000000..9c7075be --- /dev/null +++ b/src/server/services/oauth/requestProxy.ts @@ -0,0 +1,19 @@ +import { eq } from 'drizzle-orm'; +import { db, schema } from '../../db/index.js'; +import { getOAuthProviderDefinition } from './providers.js'; +import { resolveSiteProxyUrlByRequestUrl } from '../siteProxy.js'; + +export async function resolveOauthProviderProxyUrl(provider: string): Promise { + const definition = getOAuthProviderDefinition(provider); + if (!definition) return null; + return resolveSiteProxyUrlByRequestUrl(definition.site.url); +} + +export async function resolveOauthAccountProxyUrl(siteId: number | null | undefined): Promise { + if (!siteId || siteId <= 0) return null; + const site = await db.select({ + url: schema.sites.url, + }).from(schema.sites).where(eq(schema.sites.id, siteId)).get(); + if (!site?.url) return null; + return resolveSiteProxyUrlByRequestUrl(site.url); +} diff --git a/src/server/services/oauth/service.ts b/src/server/services/oauth/service.ts new file mode 100644 index 00000000..529bce16 --- /dev/null +++ b/src/server/services/oauth/service.ts @@ -0,0 +1,684 @@ +import { and, desc, eq, inArray, isNull, or, sql } from 'drizzle-orm'; +import { db, schema } from '../../db/index.js'; +import { mergeAccountExtraConfig } from '../accountExtraConfig.js'; +import { refreshModelsForAccount } from '../modelService.js'; +import * as routeRefreshWorkflow from '../routeRefreshWorkflow.js'; +import { + createOauthSession, + getOauthSession, + markOauthSessionError, + markOauthSessionSuccess, +} from './sessionStore.js'; +import { getOAuthLoopbackCallbackServerState } from './localCallbackServer.js'; +import { + getOAuthProviderDefinition, + listOAuthProviderDefinitions, + type OAuthProviderDefinition, +} from './providers.js'; +import { ensureOauthProviderSite } from './oauthSiteRegistry.js'; +import { + buildOauthInfo, + buildOauthInfoFromAccount, + buildStoredOauthState, + buildStoredOauthStateFromAccount, + getOauthInfoFromAccount, + type OauthInfo, +} from './oauthAccount.js'; +import { + buildCodexOauthInfo, + type OauthExtraConfigInput, + type OauthIdentityCarrierLike, +} from './codexAccount.js'; +import { resolveOauthAccountProxyUrl, resolveOauthProviderProxyUrl } from './requestProxy.js'; +import { ensureOauthIdentityBackfill } from './oauthIdentityBackfill.js'; +import { buildQuotaSnapshotFromOauthInfo, refreshOauthQuotaSnapshot } from './quota.js'; + +type OAuthProviderMetadata = ReturnType[number]; +const MANUAL_CALLBACK_DELAY_MS = 15_000; +type OauthProviderHeaderAccountInput = OauthIdentityCarrierLike & { + extraConfig?: OauthExtraConfigInput; +}; + +type OAuthStartInstructions = { + redirectUri: string; + callbackPort: number; + callbackPath: string; + manualCallbackDelayMs: number; + sshTunnelCommand?: string; + sshTunnelKeyCommand?: string; +}; + +function isLoopbackHost(hostname: string): boolean { + const normalized = hostname.trim().toLowerCase(); + return normalized === 'localhost' + || normalized === '127.0.0.1' + || normalized === '::1' + || normalized === '[::1]'; +} + +function resolveSshTunnelHost(requestOrigin?: string): string | undefined { + if (!requestOrigin) return undefined; + try { + const parsed = new URL(requestOrigin); + if (!parsed.hostname || isLoopbackHost(parsed.hostname)) { + return undefined; + } + return parsed.hostname; + } catch { + return undefined; + } +} + +function buildLoopbackInstructions( + definition: OAuthProviderDefinition, + requestOrigin?: string, +): OAuthStartInstructions { + const sshHost = resolveSshTunnelHost(requestOrigin); + return { + redirectUri: definition.loopback.redirectUri, + callbackPort: definition.loopback.port, + callbackPath: definition.loopback.path, + manualCallbackDelayMs: MANUAL_CALLBACK_DELAY_MS, + sshTunnelCommand: sshHost + ? `ssh -L ${definition.loopback.port}:127.0.0.1:${definition.loopback.port} root@${sshHost} -p 22` + : undefined, + sshTunnelKeyCommand: sshHost + ? `ssh -i -L ${definition.loopback.port}:127.0.0.1:${definition.loopback.port} root@${sshHost} -p 22` + : undefined, + }; +} + +function parseManualCallbackUrl(input: { + callbackUrl: string; + provider: string; +}) { + const raw = asNonEmptyString(input.callbackUrl); + if (!raw) { + throw new Error('invalid oauth callback url'); + } + + let parsed: URL; + try { + parsed = new URL(raw); + } catch { + throw new Error('invalid oauth callback url'); + } + + const state = asNonEmptyString(parsed.searchParams.get('state')); + const code = asNonEmptyString(parsed.searchParams.get('code')); + const error = asNonEmptyString(parsed.searchParams.get('error')); + const errorDescription = asNonEmptyString(parsed.searchParams.get('error_description')); + if (!state || (!code && !error)) { + throw new Error('invalid oauth callback url'); + } + + return { + state, + code, + error: error + ? (errorDescription ? `${error}: ${errorDescription}` : error) + : undefined, + }; +} + +function asNonEmptyString(value: unknown): string | undefined { + if (typeof value !== 'string') return undefined; + const trimmed = value.trim(); + return trimmed || undefined; +} + +function buildUsername(input: { + email?: string; + accountKey?: string; + provider: string; +}) { + return input.email || input.accountKey || `${input.provider}-user`; +} + +async function getNextAccountSortOrder(): Promise { + const row = await db.select({ + maxSortOrder: sql`COALESCE(MAX(${schema.accounts.sortOrder}), -1)`, + }).from(schema.accounts).get(); + return (row?.maxSortOrder ?? -1) + 1; +} + +async function ensureOauthSite(definition: OAuthProviderDefinition) { + return ensureOauthProviderSite(definition); +} + +async function findExistingOauthAccount(input: { + provider: string; + accountKey?: string; + email?: string; + projectId?: string; + rebindAccountId?: number; +}) { + if (typeof input.rebindAccountId === 'number' && input.rebindAccountId > 0) { + return db.select().from(schema.accounts) + .where(eq(schema.accounts.id, input.rebindAccountId)) + .get(); + } + + const accountKey = asNonEmptyString(input.accountKey); + const email = asNonEmptyString(input.email); + const projectId = asNonEmptyString(input.projectId); + + if (accountKey) { + const byKey = await db.select().from(schema.accounts).where(and( + eq(schema.accounts.oauthProvider, input.provider), + eq(schema.accounts.oauthAccountKey, accountKey), + projectId + ? eq(schema.accounts.oauthProjectId, projectId) + : or(isNull(schema.accounts.oauthProjectId), eq(schema.accounts.oauthProjectId, '')), + )).get(); + if (byKey) return byKey; + } + + if (!accountKey && email && input.provider !== 'codex') { + const byEmail = await db.select().from(schema.accounts).where(and( + eq(schema.accounts.oauthProvider, input.provider), + eq(schema.accounts.username, email), + )).get(); + if (byEmail) return byEmail; + } + + return null; +} + +async function upsertOauthAccount(input: { + definition: OAuthProviderDefinition; + exchange: { + accessToken: string; + refreshToken?: string; + tokenExpiresAt?: number; + email?: string; + accountKey?: string; + accountId?: string; + planType?: string; + projectId?: string; + idToken?: string; + providerData?: Record; + }; + rebindAccountId?: number; +}) { + const site = await ensureOauthSite(input.definition); + const existing = await findExistingOauthAccount({ + provider: input.definition.metadata.provider, + accountKey: input.exchange.accountKey || input.exchange.accountId, + email: input.exchange.email, + projectId: input.exchange.projectId, + rebindAccountId: input.rebindAccountId, + }); + const username = buildUsername({ + email: input.exchange.email, + accountKey: input.exchange.accountKey || input.exchange.accountId, + provider: input.definition.metadata.provider, + }); + const oauth = buildOauthInfo(existing?.extraConfig, { + provider: input.definition.metadata.provider, + accountId: input.exchange.accountId || input.exchange.accountKey, + accountKey: input.exchange.accountKey || input.exchange.accountId, + email: input.exchange.email, + planType: input.exchange.planType, + projectId: input.exchange.projectId, + refreshToken: input.exchange.refreshToken, + tokenExpiresAt: input.exchange.tokenExpiresAt, + idToken: input.exchange.idToken, + providerData: input.exchange.providerData, + }); + const extraConfig = mergeAccountExtraConfig(existing?.extraConfig, { + credentialMode: 'session', + oauth: buildStoredOauthState(oauth), + }); + + if (existing) { + await db.update(schema.accounts).set({ + siteId: site.id, + username, + accessToken: input.exchange.accessToken, + apiToken: null, + checkinEnabled: false, + status: 'disabled', + oauthProvider: input.definition.metadata.provider, + oauthAccountKey: oauth.accountKey || oauth.accountId || null, + oauthProjectId: oauth.projectId || null, + extraConfig, + updatedAt: new Date().toISOString(), + }).where(eq(schema.accounts.id, existing.id)).run(); + return { + account: await db.select().from(schema.accounts).where(eq(schema.accounts.id, existing.id)).get(), + site, + created: false, + previousAccount: existing, + }; + } + + const created = await db.insert(schema.accounts).values({ + siteId: site.id, + username, + accessToken: input.exchange.accessToken, + apiToken: null, + checkinEnabled: false, + status: 'active', + oauthProvider: input.definition.metadata.provider, + oauthAccountKey: oauth.accountKey || oauth.accountId || null, + oauthProjectId: oauth.projectId || null, + extraConfig, + isPinned: false, + sortOrder: await getNextAccountSortOrder(), + }).returning().get(); + return { account: created, site, created: true, previousAccount: null }; +} + +export function listOauthProviders() { + return listOAuthProviderDefinitions().map((definition) => { + const state = getOAuthLoopbackCallbackServerState(definition.metadata.provider); + return { + ...definition.metadata, + enabled: state.ready || !state.attempted, + }; + }); +} + +export async function startOauthProviderFlow(input: { + provider: string; + rebindAccountId?: number; + projectId?: string; + requestOrigin?: string; +}) { + const definition = getOAuthProviderDefinition(input.provider); + if (!definition) { + throw new Error(`unsupported oauth provider: ${input.provider}`); + } + const redirectUri = definition.loopback.redirectUri; + const callbackServerState = getOAuthLoopbackCallbackServerState(input.provider); + if (callbackServerState.attempted && !callbackServerState.ready) { + throw new Error(`${input.provider} oauth callback listener is unavailable: ${callbackServerState.error || 'unknown error'}`); + } + const session = createOauthSession({ + provider: input.provider, + redirectUri, + rebindAccountId: input.rebindAccountId, + projectId: input.projectId, + }); + return { + provider: input.provider, + state: session.state, + authorizationUrl: await definition.buildAuthorizationUrl({ + state: session.state, + redirectUri: session.redirectUri, + codeVerifier: session.codeVerifier, + projectId: session.projectId, + }), + instructions: buildLoopbackInstructions(definition, input.requestOrigin), + }; +} + +export function getOauthSessionStatus(state: string) { + const session = getOauthSession(state); + if (!session) return null; + return { + provider: session.provider, + state: session.state, + status: session.status, + accountId: session.accountId, + siteId: session.siteId, + error: session.error, + createdAt: session.createdAt, + updatedAt: session.updatedAt, + }; +} + +export async function handleOauthCallback(input: { + provider: string; + state: string; + code?: string; + error?: string; +}) { + const session = getOauthSession(input.state); + if (!session || session.provider !== input.provider) { + throw new Error('oauth session not found or provider mismatch'); + } + const definition = getOAuthProviderDefinition(input.provider); + if (!definition) { + markOauthSessionError(input.state, `unsupported oauth provider: ${input.provider}`); + throw new Error(`unsupported oauth provider: ${input.provider}`); + } + if (input.error) { + markOauthSessionError(input.state, input.error); + throw new Error(input.error); + } + const code = asNonEmptyString(input.code); + if (!code) { + markOauthSessionError(input.state, 'missing oauth code'); + throw new Error('missing oauth code'); + } + + try { + const proxyUrl = await resolveOauthProviderProxyUrl(input.provider); + const exchange = await definition.exchangeAuthorizationCode({ + code, + state: input.state, + redirectUri: session.redirectUri, + codeVerifier: session.codeVerifier, + projectId: session.projectId, + proxyUrl, + }); + const { account, site, created, previousAccount } = await upsertOauthAccount({ + definition, + exchange, + rebindAccountId: session.rebindAccountId, + }); + if (!account) { + markOauthSessionError(input.state, 'failed to persist oauth account'); + throw new Error('failed to persist oauth account'); + } + + const refreshResult = await refreshModelsForAccount( + account.id, + previousAccount ? { allowInactive: true } : undefined, + ); + if (refreshResult.status !== 'success') { + if (created) { + await db.delete(schema.accounts).where(eq(schema.accounts.id, account.id)).run(); + } else if (previousAccount) { + await db.update(schema.accounts).set({ + siteId: previousAccount.siteId, + username: previousAccount.username, + accessToken: previousAccount.accessToken, + apiToken: previousAccount.apiToken, + checkinEnabled: previousAccount.checkinEnabled, + status: previousAccount.status, + oauthProvider: previousAccount.oauthProvider, + oauthAccountKey: previousAccount.oauthAccountKey, + oauthProjectId: previousAccount.oauthProjectId, + extraConfig: previousAccount.extraConfig, + updatedAt: previousAccount.updatedAt, + }).where(eq(schema.accounts.id, previousAccount.id)).run(); + } + await routeRefreshWorkflow.rebuildRoutesOnly(); + const errorMessage = refreshResult.errorMessage || `${input.provider} model discovery failed`; + markOauthSessionError(input.state, errorMessage); + throw new Error(errorMessage); + } + + if (previousAccount) { + await db.update(schema.accounts).set({ + status: 'active', + updatedAt: new Date().toISOString(), + }).where(eq(schema.accounts.id, account.id)).run(); + } + + await routeRefreshWorkflow.rebuildRoutesOnly(); + markOauthSessionSuccess(input.state, { + accountId: account.id, + siteId: site.id, + }); + return { accountId: account.id, siteId: site.id }; + } catch (error) { + const message = ( + error instanceof Error + ? (error.message || error.name) + : String(error || 'OAuth failed') + ).trim() || 'OAuth failed'; + markOauthSessionError(input.state, message); + throw error; + } +} + +export async function submitOauthManualCallback(input: { + state: string; + callbackUrl: string; +}) { + const session = getOauthSession(input.state); + if (!session) { + throw new Error('oauth session not found'); + } + const parsed = parseManualCallbackUrl({ + callbackUrl: input.callbackUrl, + provider: session.provider, + }); + if (parsed.state !== input.state) { + throw new Error('oauth callback state mismatch'); + } + + await handleOauthCallback({ + provider: session.provider, + state: parsed.state, + code: parsed.code, + error: parsed.error, + }); + + return { success: true }; +} + +export async function listOauthConnections(options: { + limit?: number; + offset?: number; +} = {}) { + await ensureOauthIdentityBackfill(); + const limit = Math.max(1, Math.min(200, Math.trunc(options.limit ?? 50))); + const offset = Math.max(0, Math.trunc(options.offset ?? 0)); + + const totalRow = await db.select({ + count: sql`COUNT(*)`, + }).from(schema.accounts) + .where(sql`${schema.accounts.oauthProvider} IS NOT NULL`) + .get(); + const total = totalRow?.count ?? 0; + + const rows = await db.select().from(schema.accounts) + .innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id)) + .where(sql`${schema.accounts.oauthProvider} IS NOT NULL`) + .orderBy(desc(schema.accounts.id)) + .limit(limit) + .offset(offset) + .all(); + + const accountIds = rows.map((row) => row.accounts.id); + if (accountIds.length <= 0) { + return { items: [], total, limit, offset }; + } + + const modelRows = await db.select({ + accountId: schema.modelAvailability.accountId, + modelName: schema.modelAvailability.modelName, + }).from(schema.modelAvailability) + .where(and( + inArray(schema.modelAvailability.accountId, accountIds), + eq(schema.modelAvailability.available, true), + )) + .all(); + const modelMap = new Map(); + for (const row of modelRows) { + if (typeof row.accountId !== 'number') continue; + const list = modelMap.get(row.accountId) || []; + list.push(row.modelName); + modelMap.set(row.accountId, list); + } + + const routeChannelRows = await db.select({ + accountId: schema.routeChannels.accountId, + count: sql`COUNT(*)`, + }).from(schema.routeChannels) + .where(inArray(schema.routeChannels.accountId, accountIds)) + .groupBy(schema.routeChannels.accountId) + .all(); + const routeChannelCountByAccount = new Map(); + for (const row of routeChannelRows) { + routeChannelCountByAccount.set(row.accountId, row.count ?? 0); + } + + const items = rows.flatMap((row) => { + const oauth = getOauthInfoFromAccount(row.accounts); + if (!oauth) return []; + const models = modelMap.get(row.accounts.id) || []; + const status = ( + oauth.modelDiscoveryStatus === 'abnormal' + || row.accounts.status !== 'active' + || row.sites.status !== 'active' + ) ? 'abnormal' : 'healthy'; + return [{ + accountId: row.accounts.id, + siteId: row.sites.id, + provider: oauth.provider, + username: row.accounts.username, + email: oauth.email, + accountKey: oauth.accountKey || oauth.accountId, + planType: oauth.planType, + projectId: oauth.projectId, + modelCount: models.length, + modelsPreview: models.slice(0, 10), + quota: buildQuotaSnapshotFromOauthInfo(oauth), + status, + routeChannelCount: routeChannelCountByAccount.get(row.accounts.id) || 0, + lastModelSyncAt: oauth.lastModelSyncAt, + lastModelSyncError: oauth.lastModelSyncError, + site: { + id: row.sites.id, + name: row.sites.name, + url: row.sites.url, + platform: row.sites.platform, + }, + }]; + }); + + return { items, total, limit, offset }; +} + +export async function deleteOauthConnection(accountId: number) { + const account = await db.select().from(schema.accounts) + .where(eq(schema.accounts.id, accountId)) + .get(); + if (!account) { + throw new Error('oauth account not found'); + } + const normalizedOauth = getOauthInfoFromAccount(account); + if (!normalizedOauth) { + throw new Error('account is not managed by oauth'); + } + await db.delete(schema.accounts).where(eq(schema.accounts.id, accountId)).run(); + await routeRefreshWorkflow.rebuildRoutesOnly(); + return { success: true }; +} + +export async function refreshOauthConnectionQuota(accountId: number) { + const quota = await refreshOauthQuotaSnapshot(accountId); + return { success: true, quota }; +} + +export async function startOauthRebindFlow(accountId: number, requestOrigin?: string) { + const account = await db.select().from(schema.accounts) + .where(eq(schema.accounts.id, accountId)) + .get(); + if (!account) { + throw new Error('oauth account not found'); + } + const oauth = getOauthInfoFromAccount(account); + if (!oauth) { + throw new Error('account is not managed by oauth'); + } + return startOauthProviderFlow({ + provider: oauth.provider, + rebindAccountId: accountId, + projectId: oauth.projectId, + requestOrigin, + }); +} + +export function buildOauthProviderHeaders(input: { + account?: OauthProviderHeaderAccountInput | null; + extraConfig?: OauthExtraConfigInput; + downstreamHeaders?: Record; +}) { + const oauth = getOauthInfoFromAccount(input.account || { + extraConfig: input.extraConfig, + }); + if (!oauth) return {}; + const definition = getOAuthProviderDefinition(oauth.provider); + if (!definition?.buildProxyHeaders) return {}; + return definition.buildProxyHeaders({ + oauth, + downstreamHeaders: input.downstreamHeaders, + }); +} + +export function buildCodexOauthProviderHeaders(input: { + extraConfig?: OauthExtraConfigInput; + downstreamHeaders?: Record; +}) { + const oauth = buildCodexOauthInfo(input.extraConfig); + const definition = getOAuthProviderDefinition('codex'); + return definition?.buildProxyHeaders?.({ + oauth, + downstreamHeaders: input.downstreamHeaders, + }) || {}; +} + +export async function refreshOauthAccessToken(accountId: number) { + const account = await db.select().from(schema.accounts) + .where(eq(schema.accounts.id, accountId)) + .get(); + if (!account) { + throw new Error('oauth account not found'); + } + const oauth = getOauthInfoFromAccount(account); + if (!oauth?.refreshToken) { + throw new Error('oauth refresh token missing'); + } + const definition = getOAuthProviderDefinition(oauth.provider); + if (!definition) { + throw new Error(`unsupported oauth provider: ${oauth.provider}`); + } + + const refreshed = await definition.refreshAccessToken({ + refreshToken: oauth.refreshToken, + oauth: { + projectId: oauth.projectId, + providerData: oauth.providerData, + }, + proxyUrl: await resolveOauthAccountProxyUrl(account.siteId), + }); + const nextOauth = buildOauthInfoFromAccount(account, { + provider: oauth.provider, + accountId: refreshed.accountId || oauth.accountId, + accountKey: refreshed.accountKey || oauth.accountKey || refreshed.accountId || oauth.accountId, + email: refreshed.email || oauth.email, + planType: refreshed.planType || oauth.planType, + projectId: refreshed.projectId || oauth.projectId, + refreshToken: refreshed.refreshToken || oauth.refreshToken, + tokenExpiresAt: refreshed.tokenExpiresAt || oauth.tokenExpiresAt, + idToken: refreshed.idToken || oauth.idToken, + providerData: { + ...(oauth.providerData || {}), + ...(refreshed.providerData || {}), + }, + }); + const extraConfig = mergeAccountExtraConfig(account.extraConfig, { + credentialMode: 'session', + oauth: buildStoredOauthStateFromAccount(account, nextOauth), + }); + + await db.update(schema.accounts).set({ + accessToken: refreshed.accessToken, + oauthProvider: oauth.provider, + oauthAccountKey: nextOauth.accountKey || nextOauth.accountId || null, + oauthProjectId: nextOauth.projectId || null, + extraConfig, + status: 'active', + updatedAt: new Date().toISOString(), + }).where(eq(schema.accounts.id, accountId)).run(); + + return { + accountId, + accessToken: refreshed.accessToken, + accountKey: nextOauth.accountKey || nextOauth.accountId, + extraConfig, + }; +} + +export async function refreshCodexOauthAccessToken(accountId: number) { + return refreshOauthAccessToken(accountId); +} + +export type { OAuthProviderMetadata }; diff --git a/src/server/services/oauth/sessionStore.ts b/src/server/services/oauth/sessionStore.ts new file mode 100644 index 00000000..6cd1257d --- /dev/null +++ b/src/server/services/oauth/sessionStore.ts @@ -0,0 +1,152 @@ +import { randomBytes, webcrypto } from 'node:crypto'; + +export type OAuthSessionStatus = 'pending' | 'success' | 'error'; + +export type OAuthSessionRecord = { + provider: string; + state: string; + status: OAuthSessionStatus; + codeVerifier: string; + redirectUri: string; + createdAt: string; + updatedAt: string; + expiresAt: string; + accountId?: number; + siteId?: number; + error?: string; + rebindAccountId?: number; + projectId?: string; +}; + +export interface OAuthSessionStore { + create(input: { + provider: string; + redirectUri: string; + rebindAccountId?: number; + projectId?: string; + }): OAuthSessionRecord; + get(state: string): OAuthSessionRecord | null; + markSuccess(state: string, patch: { accountId: number; siteId: number }): OAuthSessionRecord | null; + markError(state: string, error: string): OAuthSessionRecord | null; +} + +const SESSION_TTL_MS = 10 * 60 * 1000; + +function nowIso(): string { + return new Date().toISOString(); +} + +function toBase64Url(input: Buffer): string { + return input.toString('base64url'); +} + +function createPkceVerifier(): string { + return toBase64Url(randomBytes(48)); +} + +export async function createPkceChallenge(codeVerifier: string): Promise { + const digest = await webcrypto.subtle.digest('SHA-256', Buffer.from(codeVerifier, 'utf8')); + return Buffer.from(digest).toString('base64url'); +} + +class MemoryOAuthSessionStore implements OAuthSessionStore { + private readonly sessions = new Map(); + + private pruneExpiredSessions(nowMs = Date.now()) { + for (const [state, session] of this.sessions.entries()) { + if (Date.parse(session.expiresAt) <= nowMs) { + this.sessions.delete(state); + } + } + } + + create(input: { + provider: string; + redirectUri: string; + rebindAccountId?: number; + projectId?: string; + }): OAuthSessionRecord { + this.pruneExpiredSessions(); + const state = toBase64Url(randomBytes(24)); + const codeVerifier = createPkceVerifier(); + const createdAt = nowIso(); + const expiresAt = new Date(Date.now() + SESSION_TTL_MS).toISOString(); + const record: OAuthSessionRecord = { + provider: input.provider, + state, + status: 'pending', + codeVerifier, + redirectUri: input.redirectUri, + createdAt, + updatedAt: createdAt, + expiresAt, + rebindAccountId: input.rebindAccountId, + projectId: input.projectId, + }; + this.sessions.set(state, record); + return record; + } + + get(state: string): OAuthSessionRecord | null { + this.pruneExpiredSessions(); + return this.sessions.get(state) || null; + } + + markSuccess(state: string, patch: { accountId: number; siteId: number }): OAuthSessionRecord | null { + const existing = this.get(state); + if (!existing) return null; + const next: OAuthSessionRecord = { + ...existing, + status: 'success', + updatedAt: nowIso(), + accountId: patch.accountId, + siteId: patch.siteId, + error: undefined, + }; + this.sessions.set(state, next); + return next; + } + + markError(state: string, error: string): OAuthSessionRecord | null { + const existing = this.get(state); + if (!existing) return null; + const next: OAuthSessionRecord = { + ...existing, + status: 'error', + updatedAt: nowIso(), + error: error.trim() || 'OAuth failed', + }; + this.sessions.set(state, next); + return next; + } +} + +let oauthSessionStore: OAuthSessionStore = new MemoryOAuthSessionStore(); + +export function setOauthSessionStore(store: OAuthSessionStore) { + oauthSessionStore = store; +} + +export function createOauthSession(input: { + provider: string; + redirectUri: string; + rebindAccountId?: number; + projectId?: string; +}): OAuthSessionRecord { + return oauthSessionStore.create(input); +} + +export function getOauthSession(state: string): OAuthSessionRecord | null { + return oauthSessionStore.get(state); +} + +export function markOauthSessionSuccess( + state: string, + patch: { accountId: number; siteId: number }, +): OAuthSessionRecord | null { + return oauthSessionStore.markSuccess(state, patch); +} + +export function markOauthSessionError(state: string, error: string): OAuthSessionRecord | null { + return oauthSessionStore.markError(state, error); +} diff --git a/src/server/services/payloadRules.ts b/src/server/services/payloadRules.ts new file mode 100644 index 00000000..76f7bcba --- /dev/null +++ b/src/server/services/payloadRules.ts @@ -0,0 +1,312 @@ +import { minimatch } from 'minimatch'; + +export type PayloadRuleModel = { + name: string; + protocol?: string; +}; + +export type PayloadValueRule = { + models: PayloadRuleModel[]; + params: Record; +}; + +export type PayloadFilterRule = { + models: PayloadRuleModel[]; + params: string[]; +}; + +export type PayloadRulesConfig = { + default: PayloadValueRule[]; + defaultRaw: PayloadValueRule[]; + override: PayloadValueRule[]; + overrideRaw: PayloadValueRule[]; + filter: PayloadFilterRule[]; +}; + +function asTrimmedString(value: unknown): string { + return typeof value === 'string' ? value.trim() : ''; +} + +function isRecord(value: unknown): value is Record { + return !!value && typeof value === 'object' && !Array.isArray(value); +} + +function cloneJsonValue(value: T): T { + if (Array.isArray(value)) { + return value.map((item) => cloneJsonValue(item)) as T; + } + if (isRecord(value)) { + return Object.fromEntries( + Object.entries(value).map(([key, item]) => [key, cloneJsonValue(item)]), + ) as T; + } + return value; +} + +function toPathSegments(path: string): string[] { + const normalized = asTrimmedString(path).replace(/^\.+/, ''); + return normalized + .split('.') + .map((segment) => segment.trim()) + .filter((segment) => segment.length > 0); +} + +function parseIndexSegment(segment: string): number | null { + if (!/^\d+$/.test(segment)) return null; + const parsed = Number.parseInt(segment, 10); + return Number.isFinite(parsed) ? parsed : null; +} + +function hasPath(target: unknown, path: string): boolean { + const segments = toPathSegments(path); + if (segments.length <= 0) return false; + + let current: unknown = target; + for (const segment of segments) { + const index = parseIndexSegment(segment); + if (index !== null) { + if (!Array.isArray(current) || index >= current.length) return false; + current = current[index]; + continue; + } + if (!isRecord(current) || !Object.prototype.hasOwnProperty.call(current, segment)) return false; + current = current[segment]; + } + return true; +} + +function setPath(target: Record, path: string, value: unknown): void { + const segments = toPathSegments(path); + if (segments.length <= 0) return; + + let current: unknown = target; + for (let index = 0; index < segments.length; index += 1) { + const segment = segments[index]; + const nextSegment = segments[index + 1]; + const segmentIndex = parseIndexSegment(segment); + const isLast = index === segments.length - 1; + + if (segmentIndex !== null) { + if (!Array.isArray(current)) return; + while (current.length <= segmentIndex) current.push(undefined); + if (isLast) { + current[segmentIndex] = cloneJsonValue(value); + return; + } + if (!isRecord(current[segmentIndex]) && !Array.isArray(current[segmentIndex])) { + current[segmentIndex] = parseIndexSegment(nextSegment) !== null ? [] : {}; + } + current = current[segmentIndex]; + continue; + } + + if (!isRecord(current)) return; + if (isLast) { + current[segment] = cloneJsonValue(value); + return; + } + if (!isRecord(current[segment]) && !Array.isArray(current[segment])) { + current[segment] = parseIndexSegment(nextSegment) !== null ? [] : {}; + } + current = current[segment]; + } +} + +function deletePath(target: Record, path: string): void { + const segments = toPathSegments(path); + if (segments.length <= 0) return; + + let current: unknown = target; + for (let index = 0; index < segments.length - 1; index += 1) { + const segment = segments[index]; + const segmentIndex = parseIndexSegment(segment); + if (segmentIndex !== null) { + if (!Array.isArray(current) || segmentIndex >= current.length) return; + current = current[segmentIndex]; + continue; + } + if (!isRecord(current) || !Object.prototype.hasOwnProperty.call(current, segment)) return; + current = current[segment]; + } + + const lastSegment = segments[segments.length - 1]; + const lastIndex = parseIndexSegment(lastSegment); + if (lastIndex !== null) { + if (!Array.isArray(current) || lastIndex >= current.length) return; + current.splice(lastIndex, 1); + return; + } + if (!isRecord(current)) return; + delete current[lastSegment]; +} + +function normalizePayloadRuleModels(value: unknown): PayloadRuleModel[] { + if (!Array.isArray(value)) return []; + return value + .map((item) => { + if (!isRecord(item)) return null; + const name = asTrimmedString(item.name); + if (!name) return null; + const protocol = asTrimmedString(item.protocol); + return { + name, + ...(protocol ? { protocol } : {}), + }; + }) + .filter((item): item is PayloadRuleModel => !!item); +} + +function normalizePayloadValueRules(value: unknown): PayloadValueRule[] { + if (!Array.isArray(value)) return []; + return value + .map((item) => { + if (!isRecord(item)) return null; + const models = normalizePayloadRuleModels(item.models); + const params = isRecord(item.params) ? cloneJsonValue(item.params) : null; + if (models.length <= 0 || !params) return null; + return { models, params }; + }) + .filter((item): item is PayloadValueRule => !!item); +} + +function normalizePayloadFilterRules(value: unknown): PayloadFilterRule[] { + if (!Array.isArray(value)) return []; + return value + .map((item) => { + if (!isRecord(item)) return null; + const models = normalizePayloadRuleModels(item.models); + const params = Array.isArray(item.params) + ? item.params + .map((entry) => asTrimmedString(entry)) + .filter((entry) => entry.length > 0) + : []; + if (models.length <= 0 || params.length <= 0) return null; + return { models, params }; + }) + .filter((item): item is PayloadFilterRule => !!item); +} + +function modelRuleMatches(rule: PayloadRuleModel, protocol: string, candidates: string[]): boolean { + if (!rule.name || candidates.length <= 0) return false; + const ruleProtocol = asTrimmedString(rule.protocol).toLowerCase(); + if (ruleProtocol && protocol && ruleProtocol !== protocol) return false; + return candidates.some((candidate) => minimatch(candidate, rule.name, { nocase: true })); +} + +function rulesMatch(models: PayloadRuleModel[], protocol: string, candidates: string[]): boolean { + if (models.length <= 0 || candidates.length <= 0) return false; + return models.some((rule) => modelRuleMatches(rule, protocol, candidates)); +} + +function parseRawRuleValue(value: unknown): unknown { + if (typeof value !== 'string') return cloneJsonValue(value); + const trimmed = value.trim(); + if (!trimmed) return undefined; + try { + return JSON.parse(trimmed); + } catch { + return undefined; + } +} + +export function createEmptyPayloadRulesConfig(): PayloadRulesConfig { + return { + default: [], + defaultRaw: [], + override: [], + overrideRaw: [], + filter: [], + }; +} + +export function normalizePayloadRulesConfig(value: unknown): PayloadRulesConfig { + if (!isRecord(value)) return createEmptyPayloadRulesConfig(); + return { + default: normalizePayloadValueRules(value.default), + defaultRaw: normalizePayloadValueRules(value.defaultRaw ?? value['default-raw']), + override: normalizePayloadValueRules(value.override), + overrideRaw: normalizePayloadValueRules(value.overrideRaw ?? value['override-raw']), + filter: normalizePayloadFilterRules(value.filter), + }; +} + +export function applyPayloadRules(input: { + rules: PayloadRulesConfig; + payload: Record; + modelName?: string; + requestedModel?: string; + protocol?: string; +}): Record { + const candidates = Array.from(new Set( + [input.modelName, input.requestedModel] + .map((value) => asTrimmedString(value)) + .filter((value) => value.length > 0), + )); + if (candidates.length <= 0) return input.payload; + + const rules = input.rules; + const hasAnyRules = rules.default.length > 0 + || rules.defaultRaw.length > 0 + || rules.override.length > 0 + || rules.overrideRaw.length > 0 + || rules.filter.length > 0; + if (!hasAnyRules) return input.payload; + + const protocol = asTrimmedString(input.protocol).toLowerCase(); + const original = cloneJsonValue(input.payload); + const output = cloneJsonValue(input.payload); + const appliedDefaults = new Set(); + + for (const rule of rules.default) { + if (!rulesMatch(rule.models, protocol, candidates)) continue; + for (const [path, value] of Object.entries(rule.params)) { + const normalizedPath = asTrimmedString(path); + if (!normalizedPath || hasPath(original, normalizedPath) || appliedDefaults.has(normalizedPath)) continue; + setPath(output, normalizedPath, value); + appliedDefaults.add(normalizedPath); + } + } + + for (const rule of rules.defaultRaw) { + if (!rulesMatch(rule.models, protocol, candidates)) continue; + for (const [path, value] of Object.entries(rule.params)) { + const normalizedPath = asTrimmedString(path); + if (!normalizedPath || hasPath(original, normalizedPath) || appliedDefaults.has(normalizedPath)) continue; + const parsed = parseRawRuleValue(value); + if (parsed === undefined) continue; + setPath(output, normalizedPath, parsed); + appliedDefaults.add(normalizedPath); + } + } + + for (const rule of rules.override) { + if (!rulesMatch(rule.models, protocol, candidates)) continue; + for (const [path, value] of Object.entries(rule.params)) { + const normalizedPath = asTrimmedString(path); + if (!normalizedPath) continue; + setPath(output, normalizedPath, value); + } + } + + for (const rule of rules.overrideRaw) { + if (!rulesMatch(rule.models, protocol, candidates)) continue; + for (const [path, value] of Object.entries(rule.params)) { + const normalizedPath = asTrimmedString(path); + if (!normalizedPath) continue; + const parsed = parseRawRuleValue(value); + if (parsed === undefined) continue; + setPath(output, normalizedPath, parsed); + } + } + + for (const rule of rules.filter) { + if (!rulesMatch(rule.models, protocol, candidates)) continue; + for (const path of rule.params) { + const normalizedPath = asTrimmedString(path); + if (!normalizedPath) continue; + deletePath(output, normalizedPath); + } + } + + return output; +} diff --git a/src/server/services/platformDiscoveryRegistry.ts b/src/server/services/platformDiscoveryRegistry.ts new file mode 100644 index 00000000..3b1b1c05 --- /dev/null +++ b/src/server/services/platformDiscoveryRegistry.ts @@ -0,0 +1,257 @@ +import { fetch } from 'undici'; +import { schema } from '../db/index.js'; +import { getProxyUrlFromExtraConfig } from './accountExtraConfig.js'; +import { withExplicitProxyRequestInit, withSiteRecordProxyRequestInit } from './siteProxy.js'; +import { getOauthInfoFromAccount } from './oauth/oauthAccount.js'; +import { CLAUDE_DEFAULT_ANTHROPIC_VERSION } from './oauth/claudeProvider.js'; +import { + ANTIGRAVITY_DAILY_UPSTREAM_BASE_URL, + ANTIGRAVITY_MODELS_USER_AGENT, + ANTIGRAVITY_SANDBOX_DAILY_UPSTREAM_BASE_URL, + ANTIGRAVITY_UPSTREAM_BASE_URL, +} from './oauth/antigravityProvider.js'; +import { + GEMINI_CLI_GOOGLE_API_CLIENT, + GEMINI_CLI_REQUIRED_SERVICE, + GEMINI_CLI_USER_AGENT, +} from './oauth/geminiCliProvider.js'; + +type PlatformDiscoverySite = typeof schema.sites.$inferSelect; +type PlatformDiscoveryAccount = typeof schema.accounts.$inferSelect; + +function normalizeDiscoveredModels(models: string[]): string[] { + const normalizedModels: string[] = []; + const seen = new Set(); + + for (const rawModel of models) { + if (typeof rawModel !== 'string') continue; + const modelName = rawModel.trim(); + if (!modelName) continue; + + const dedupeKey = modelName.toLowerCase(); + if (seen.has(dedupeKey)) continue; + seen.add(dedupeKey); + normalizedModels.push(modelName); + } + + return normalizedModels; +} + +function normalizeBaseUrl(baseUrl: string): string { + return (baseUrl || '').replace(/\/+$/, ''); +} + +function buildCodexModelsEndpoint(baseUrl: string): string { + const normalized = normalizeBaseUrl(baseUrl); + return `${normalized}/models?client_version=${encodeURIComponent('1.0.0')}`; +} + +function extractCodexModelIds(payload: unknown): string[] { + const collection = (() => { + if (Array.isArray(payload)) return payload; + if (!payload || typeof payload !== 'object') return []; + const record = payload as Record; + if (Array.isArray(record.models)) return record.models; + if (Array.isArray(record.data)) return record.data; + if (Array.isArray(record.items)) return record.items; + return []; + })(); + + return collection.flatMap((item) => { + if (typeof item === 'string') return [item]; + if (!item || typeof item !== 'object' || Array.isArray(item)) return []; + const record = item as Record; + const id = typeof record.id === 'string' + ? record.id + : (typeof record.slug === 'string' ? record.slug : (typeof record.model === 'string' ? record.model : '')); + return id ? [id] : []; + }); +} + +function extractClaudeModelIds(payload: unknown): string[] { + if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return []; + const record = payload as { data?: unknown }; + if (!Array.isArray(record.data)) return []; + + return record.data.flatMap((item) => { + if (!item || typeof item !== 'object' || Array.isArray(item)) return []; + const value = item as { id?: unknown; name?: unknown }; + const id = typeof value.id === 'string' + ? value.id.trim() + : (typeof value.name === 'string' ? value.name.trim() : ''); + return id ? [id] : []; + }); +} + +function extractAntigravityModelIds(payload: unknown): string[] { + if (!payload || typeof payload !== 'object') return []; + const record = payload as { models?: unknown }; + if (record.models && typeof record.models === 'object' && !Array.isArray(record.models)) { + return Object.keys(record.models).map((name) => name.trim()).filter(Boolean); + } + if (!Array.isArray(record.models)) return []; + return record.models.flatMap((item) => { + if (typeof item === 'string') { + const trimmed = item.trim(); + return trimmed ? [trimmed] : []; + } + if (!item || typeof item !== 'object' || Array.isArray(item)) return []; + const value = item as { id?: unknown; name?: unknown }; + const id = typeof value.id === 'string' + ? value.id.trim() + : (typeof value.name === 'string' ? value.name.trim() : ''); + return id ? [id] : []; + }); +} + +function buildAntigravityDiscoveryBaseUrls(siteUrl: string): string[] { + const seen = new Set(); + return [ + siteUrl, + ANTIGRAVITY_UPSTREAM_BASE_URL, + ANTIGRAVITY_DAILY_UPSTREAM_BASE_URL, + ANTIGRAVITY_SANDBOX_DAILY_UPSTREAM_BASE_URL, + ].flatMap((candidate) => { + const normalized = normalizeBaseUrl(candidate); + if (!normalized || seen.has(normalized)) return []; + seen.add(normalized); + return [normalized]; + }); +} + +export async function discoverCodexModelsFromCloud(input: { + site: PlatformDiscoverySite; + account: PlatformDiscoveryAccount; +}): Promise { + const accessToken = (input.account.accessToken || '').trim(); + if (!accessToken) { + throw new Error('codex oauth access token missing'); + } + const oauth = getOauthInfoFromAccount(input.account); + const headers: Record = { + Authorization: `Bearer ${accessToken}`, + Accept: 'application/json', + Originator: 'codex_cli_rs', + }; + if (oauth?.accountId) { + headers['Chatgpt-Account-Id'] = oauth.accountId; + } + + const response = await fetch( + buildCodexModelsEndpoint(input.site.url), + withSiteRecordProxyRequestInit(input.site, { method: 'GET', headers }), + ); + if (!response.ok) { + const text = await response.text().catch(() => ''); + throw new Error(`HTTP ${response.status}: ${text || 'codex model discovery failed'}`); + } + return normalizeDiscoveredModels(extractCodexModelIds(await response.json())); +} + +export async function discoverClaudeModelsFromCloud(input: { + site: PlatformDiscoverySite; + account: PlatformDiscoveryAccount; +}): Promise { + const accessToken = (input.account.accessToken || '').trim(); + if (!accessToken) { + throw new Error('claude oauth access token missing'); + } + const response = await fetch( + `${input.site.url.replace(/\/+$/, '')}/v1/models`, + withSiteRecordProxyRequestInit(input.site, { + method: 'GET', + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: 'application/json', + 'anthropic-version': CLAUDE_DEFAULT_ANTHROPIC_VERSION, + }, + }), + ); + if (!response.ok) { + const text = await response.text().catch(() => ''); + throw new Error(`HTTP ${response.status}: ${text || 'claude oauth model discovery failed'}`); + } + return normalizeDiscoveredModels(extractClaudeModelIds(await response.json())); +} + +export async function validateGeminiCliOauthConnection(input: { + account: PlatformDiscoveryAccount; +}): Promise { + const accessToken = (input.account.accessToken || '').trim(); + if (!accessToken) { + throw new Error('gemini cli oauth access token missing'); + } + const oauth = getOauthInfoFromAccount(input.account); + const projectId = (oauth?.projectId || '').trim(); + if (!projectId) { + throw new Error('gemini cli oauth project id missing'); + } + const response = await fetch( + `https://serviceusage.googleapis.com/v1/projects/${encodeURIComponent(projectId)}/services/${encodeURIComponent(GEMINI_CLI_REQUIRED_SERVICE)}`, + withExplicitProxyRequestInit(getProxyUrlFromExtraConfig(input.account.extraConfig), { + method: 'GET', + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: 'application/json', + 'User-Agent': GEMINI_CLI_USER_AGENT, + 'X-Goog-Api-Client': GEMINI_CLI_GOOGLE_API_CLIENT, + }, + }, true), + ); + if (!response.ok) { + const text = await response.text().catch(() => ''); + throw new Error(`HTTP ${response.status}: ${text || 'gemini cli oauth validation failed'}`); + } + const payload = await response.json() as { state?: unknown }; + if (String(payload.state || '').trim().toUpperCase() !== 'ENABLED') { + throw new Error(`Cloud AI API not enabled for project ${projectId}`); + } +} + +export async function discoverAntigravityModelsFromCloud(input: { + site: PlatformDiscoverySite; + account: PlatformDiscoveryAccount; +}): Promise { + const accessToken = (input.account.accessToken || '').trim(); + if (!accessToken) { + throw new Error('antigravity oauth access token missing'); + } + + const oauth = getOauthInfoFromAccount(input.account); + const projectId = (oauth?.projectId || '').trim(); + const requestBody = projectId ? { project: projectId } : {}; + let lastError = ''; + + for (const baseUrl of buildAntigravityDiscoveryBaseUrls(input.site.url || ANTIGRAVITY_UPSTREAM_BASE_URL)) { + try { + const response = await fetch( + `${baseUrl}/v1internal:fetchAvailableModels`, + withSiteRecordProxyRequestInit(input.site, { + method: 'POST', + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: 'application/json', + 'Content-Type': 'application/json', + 'User-Agent': ANTIGRAVITY_MODELS_USER_AGENT, + }, + body: JSON.stringify(requestBody), + }), + ); + if (!response.ok) { + lastError = await response.text().catch(() => '') || `HTTP ${response.status}`; + continue; + } + + const payload = await response.json(); + const models = normalizeDiscoveredModels(extractAntigravityModelIds(payload)); + if (models.length > 0) { + return models; + } + lastError = '未获取到可用模型'; + } catch (error) { + lastError = error instanceof Error ? `${baseUrl}: ${error.message}` : String(error); + } + } + + throw new Error(lastError || '未获取到可用模型'); +} diff --git a/src/server/services/platforms/antigravity.ts b/src/server/services/platforms/antigravity.ts new file mode 100644 index 00000000..7198b3ee --- /dev/null +++ b/src/server/services/platforms/antigravity.ts @@ -0,0 +1,79 @@ +import { BasePlatformAdapter, type BalanceInfo, type CheckinResult, type UserInfo } from './base.js'; +import { + ANTIGRAVITY_CLIENT_METADATA, + ANTIGRAVITY_GOOGLE_API_CLIENT, + ANTIGRAVITY_UPSTREAM_BASE_URL, + ANTIGRAVITY_USER_AGENT, +} from '../oauth/antigravityProvider.js'; + +function normalizeBaseUrl(baseUrl: string): string { + return (baseUrl || '').replace(/\/+$/, ''); +} + +function extractAntigravityModelNames(payload: unknown): string[] { + if (!payload || typeof payload !== 'object') return []; + const record = payload as { models?: unknown }; + if (record.models && typeof record.models === 'object' && !Array.isArray(record.models)) { + return Object.keys(record.models).map((name) => name.trim()).filter(Boolean); + } + if (Array.isArray(record.models)) { + return record.models.flatMap((item) => { + if (typeof item === 'string') return item.trim() ? [item.trim()] : []; + if (!item || typeof item !== 'object') return []; + const id = typeof (item as { id?: unknown }).id === 'string' + ? (item as { id: string }).id.trim() + : (typeof (item as { name?: unknown }).name === 'string' + ? (item as { name: string }).name.trim() + : ''); + return id ? [id] : []; + }); + } + return []; +} + +export class AntigravityAdapter extends BasePlatformAdapter { + readonly platformName = 'antigravity'; + + async detect(url: string): Promise { + const normalized = (url || '').toLowerCase(); + return normalized.includes('antigravity'); + } + + override async login(_baseUrl: string, _username: string, _password: string) { + return { success: false, message: 'login endpoint not supported' }; + } + + override async getUserInfo(_baseUrl: string, _accessToken: string): Promise { + return null; + } + + async checkin(_baseUrl: string, _accessToken: string): Promise { + return { success: false, message: 'checkin endpoint not supported' }; + } + + async getBalance(_baseUrl: string, _accessToken: string): Promise { + return { balance: 0, used: 0, quota: 0 }; + } + + async getModels(baseUrl: string, accessToken: string): Promise { + try { + const payload = await this.fetchJson<{ models?: unknown }>( + `${normalizeBaseUrl(baseUrl || ANTIGRAVITY_UPSTREAM_BASE_URL)}/v1internal:fetchAvailableModels`, + { + method: 'POST', + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: 'application/json', + 'User-Agent': ANTIGRAVITY_USER_AGENT, + 'X-Goog-Api-Client': ANTIGRAVITY_GOOGLE_API_CLIENT, + 'Client-Metadata': ANTIGRAVITY_CLIENT_METADATA, + }, + body: JSON.stringify({}), + }, + ); + return extractAntigravityModelNames(payload); + } catch { + return []; + } + } +} diff --git a/src/server/services/platforms/base.ts b/src/server/services/platforms/base.ts index b0acbfbb..6b87351a 100644 --- a/src/server/services/platforms/base.ts +++ b/src/server/services/platforms/base.ts @@ -1,3 +1,4 @@ +import { createHash } from 'node:crypto'; import type { RequestInit as UndiciRequestInit } from 'undici'; import { withSiteProxyRequestInit } from '../siteProxy.js'; @@ -7,12 +8,33 @@ export interface CheckinResult { reward?: string; } +export interface SubscriptionPlanSummary { + id?: number; + groupId?: number; + groupName?: string; + status?: string; + expiresAt?: string; + dailyUsedUsd?: number; + dailyLimitUsd?: number; + weeklyUsedUsd?: number; + weeklyLimitUsd?: number; + monthlyUsedUsd?: number; + monthlyLimitUsd?: number; +} + +export interface SubscriptionSummary { + activeCount: number; + totalUsedUsd: number; + subscriptions: SubscriptionPlanSummary[]; +} + export interface BalanceInfo { balance: number; used: number; quota: number; todayIncome?: number; todayQuotaConsumption?: number; + subscriptionSummary?: SubscriptionSummary; } interface LoginResult { @@ -44,6 +66,19 @@ export interface ApiTokenInfo { tokenGroup?: string | null; } +export interface SiteAnnouncement { + sourceKey: string; + title: string; + content: string; + level: 'info' | 'warning' | 'error'; + sourceUrl?: string | null; + startsAt?: string | null; + endsAt?: string | null; + upstreamCreatedAt?: string | null; + upstreamUpdatedAt?: string | null; + rawPayload?: unknown; +} + export interface CreateApiTokenOptions { name?: string; group?: string; @@ -66,6 +101,7 @@ export interface PlatformAdapter { getModels(baseUrl: string, token: string, platformUserId?: number): Promise; getApiToken(baseUrl: string, accessToken: string, platformUserId?: number): Promise; getApiTokens(baseUrl: string, accessToken: string, platformUserId?: number): Promise; + getSiteAnnouncements(baseUrl: string, accessToken: string, platformUserId?: number): Promise; getUserGroups(baseUrl: string, accessToken: string, platformUserId?: number): Promise; createApiToken(baseUrl: string, accessToken: string, platformUserId?: number, options?: CreateApiTokenOptions): Promise; deleteApiToken(baseUrl: string, accessToken: string, tokenKey: string, platformUserId?: number): Promise; @@ -149,6 +185,14 @@ export abstract class BasePlatformAdapter implements PlatformAdapter { return [{ name: 'default', key: token, enabled: true, tokenGroup: 'default' }]; } + async getSiteAnnouncements( + _baseUrl: string, + _accessToken: string, + _platformUserId?: number, + ): Promise { + return []; + } + async createApiToken( _baseUrl: string, _accessToken: string, @@ -192,4 +236,9 @@ export abstract class BasePlatformAdapter implements PlatformAdapter { } return res.json() as Promise; } + + protected buildNoticeSourceKey(content: string): string { + const normalized = (content || '').trim(); + return `notice:${createHash('sha1').update(normalized).digest('hex')}`; + } } diff --git a/src/server/services/platforms/claude.ts b/src/server/services/platforms/claude.ts index c2e6ffb9..d56a5282 100644 --- a/src/server/services/platforms/claude.ts +++ b/src/server/services/platforms/claude.ts @@ -1,20 +1,7 @@ -import { BasePlatformAdapter, type BalanceInfo, type CheckinResult, type UserInfo } from './base.js'; +import { StandardApiProviderAdapterBase } from './standardApiProvider.js'; +import { CLAUDE_DEFAULT_ANTHROPIC_VERSION } from '../oauth/claudeProvider.js'; -const DEFAULT_ANTHROPIC_VERSION = '2023-06-01'; - -function normalizeBaseUrl(baseUrl: string): string { - return (baseUrl || '').replace(/\/+$/, ''); -} - -function resolveClaudeModelsUrl(baseUrl: string): string { - const normalized = normalizeBaseUrl(baseUrl); - if (/\/v\d+(\.\d+)?$/i.test(normalized)) { - return `${normalized}/models`; - } - return `${normalized}/v1/models`; -} - -export class ClaudeAdapter extends BasePlatformAdapter { +export class ClaudeAdapter extends StandardApiProviderAdapterBase { readonly platformName = 'claude'; async detect(url: string): Promise { @@ -22,34 +9,13 @@ export class ClaudeAdapter extends BasePlatformAdapter { return normalized.includes('api.anthropic.com') || normalized.includes('anthropic.com/v1'); } - override async login(_baseUrl: string, _username: string, _password: string) { - return { success: false, message: 'login endpoint not supported' }; - } - - override async getUserInfo(_baseUrl: string, _accessToken: string): Promise { - return null; - } - - async checkin(_baseUrl: string, _accessToken: string): Promise { - return { success: false, message: 'checkin endpoint not supported' }; - } - - async getBalance(_baseUrl: string, _accessToken: string): Promise { - // Official Anthropic API keys generally do not expose account balance via API. - return { balance: 0, used: 0, quota: 0 }; - } - async getModels(baseUrl: string, apiToken: string): Promise { - try { - const res = await this.fetchJson(resolveClaudeModelsUrl(baseUrl), { - headers: { - 'x-api-key': apiToken, - 'anthropic-version': DEFAULT_ANTHROPIC_VERSION, - }, - }); - return (res?.data || []).map((m: any) => m?.id).filter(Boolean); - } catch { - return []; - } + return this.fetchModelsFromStandardEndpoint({ + baseUrl, + headers: { + 'x-api-key': apiToken, + 'anthropic-version': CLAUDE_DEFAULT_ANTHROPIC_VERSION, + }, + }); } } diff --git a/src/server/services/platforms/cliproxyapi.ts b/src/server/services/platforms/cliproxyapi.ts index d1757044..dca644b1 100644 --- a/src/server/services/platforms/cliproxyapi.ts +++ b/src/server/services/platforms/cliproxyapi.ts @@ -1,30 +1,27 @@ -import { BasePlatformAdapter, type BalanceInfo, type CheckinResult, type UserInfo } from './base.js'; +import { + StandardApiProviderAdapterBase, + normalizePlatformBaseUrl, + resolveVersionedModelsUrl, +} from './standardApiProvider.js'; -function normalizeBaseUrl(baseUrl: string): string { - return (baseUrl || '').replace(/\/+$/, ''); -} - -export class CliProxyApiAdapter extends BasePlatformAdapter { +export class CliProxyApiAdapter extends StandardApiProviderAdapterBase { readonly platformName = 'cliproxyapi'; + protected override loginUnsupportedMessage = 'CLIProxyAPI does not support login'; + protected override checkinUnsupportedMessage = 'CLIProxyAPI does not support checkin'; async detect(url: string): Promise { const normalized = (url || '').toLowerCase(); - // Quick check: default CLIProxyAPI port if (/:8317(\/|$)/.test(normalized)) { return true; } - // Quick check: common hostname keyword if (normalized.includes('cliproxy')) { return true; } - // Probe management endpoint with strict signature checks. - // Do not trust bare 401/403 because many non-CLIProxy sites may return - // those statuses for unknown/protected paths. try { - const base = normalizeBaseUrl(url); + const base = normalizePlatformBaseUrl(url); const { fetch } = await import('undici'); const res = await fetch(`${base}/v0/management/openai-compatibility`, { method: 'GET', @@ -53,31 +50,11 @@ export class CliProxyApiAdapter extends BasePlatformAdapter { } } - override async login(_baseUrl: string, _username: string, _password: string) { - return { success: false as const, message: 'CLIProxyAPI does not support login' }; - } - - override async getUserInfo(_baseUrl: string, _accessToken: string): Promise { - return null; - } - - async checkin(_baseUrl: string, _accessToken: string): Promise { - return { success: false, message: 'CLIProxyAPI does not support checkin' }; - } - - async getBalance(_baseUrl: string, _accessToken: string): Promise { - return { balance: 0, used: 0, quota: 0 }; - } - async getModels(baseUrl: string, apiToken: string): Promise { - try { - const base = normalizeBaseUrl(baseUrl); - const res = await this.fetchJson(`${base}/v1/models`, { - headers: { Authorization: `Bearer ${apiToken}` }, - }); - return (res?.data || []).map((m: any) => m?.id).filter(Boolean); - } catch { - return []; - } + return this.fetchModelsFromStandardEndpoint({ + baseUrl, + headers: { Authorization: `Bearer ${apiToken}` }, + resolveUrl: resolveVersionedModelsUrl, + }); } } diff --git a/src/server/services/platforms/codex.ts b/src/server/services/platforms/codex.ts new file mode 100644 index 00000000..60bcc258 --- /dev/null +++ b/src/server/services/platforms/codex.ts @@ -0,0 +1,38 @@ +import { BasePlatformAdapter, type BalanceInfo, type CheckinResult, type UserInfo } from './base.js'; + +function normalizeBaseUrl(baseUrl: string): string { + let normalized = (baseUrl || '').trim(); + while (normalized.endsWith('/')) { + normalized = normalized.slice(0, -1); + } + return normalized; +} + +export class CodexAdapter extends BasePlatformAdapter { + readonly platformName = 'codex'; + + async detect(url: string): Promise { + const normalized = normalizeBaseUrl(url).toLowerCase(); + return normalized.includes('chatgpt.com/backend-api/codex'); + } + + override async login(_baseUrl: string, _username: string, _password: string) { + return { success: false as const, message: 'codex oauth login is managed via OAuth flow' }; + } + + override async getUserInfo(_baseUrl: string, _accessToken: string): Promise { + return null; + } + + async checkin(_baseUrl: string, _accessToken: string): Promise { + return { success: false, message: 'codex oauth connections do not support checkin' }; + } + + async getBalance(_baseUrl: string, _accessToken: string): Promise { + return { balance: 0, used: 0, quota: 0 }; + } + + async getModels(_baseUrl: string, _token: string): Promise { + return []; + } +} diff --git a/src/server/services/platforms/doneHub.test.ts b/src/server/services/platforms/doneHub.test.ts index 4ca539ba..3222aab5 100644 --- a/src/server/services/platforms/doneHub.test.ts +++ b/src/server/services/platforms/doneHub.test.ts @@ -1,6 +1,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'; import { AddressInfo } from 'node:net'; +import { createHash } from 'node:crypto'; import { DoneHubAdapter } from './doneHub.js'; describe('DoneHubAdapter', () => { @@ -48,6 +49,15 @@ describe('DoneHubAdapter', () => { return; } + if (req.url === '/api/notice') { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + success: true, + data: 'Scheduled maintenance tonight', + })); + return; + } + res.writeHead(404, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'not found' })); }); @@ -93,4 +103,20 @@ describe('DoneHubAdapter', () => { expect(balance.used).toBe(60); expect(balance.quota).toBe(100); }); + + it('normalizes the global site notice from /api/notice', async () => { + const adapter = new DoneHubAdapter(); + const rows = await adapter.getSiteAnnouncements(baseUrl, 'token'); + + expect(rows).toEqual([ + { + sourceKey: `notice:${createHash('sha1').update('Scheduled maintenance tonight').digest('hex')}`, + title: 'Site notice', + content: 'Scheduled maintenance tonight', + level: 'info', + sourceUrl: '/api/notice', + rawPayload: { success: true, data: 'Scheduled maintenance tonight' }, + }, + ]); + }); }); diff --git a/src/server/services/platforms/doneHub.ts b/src/server/services/platforms/doneHub.ts index 922b7c75..a7cb3dd7 100644 --- a/src/server/services/platforms/doneHub.ts +++ b/src/server/services/platforms/doneHub.ts @@ -1,5 +1,5 @@ import { OneHubAdapter } from './oneHub.js'; -import type { BalanceInfo, CheckinResult } from './base.js'; +import type { BalanceInfo, CheckinResult, SiteAnnouncement } from './base.js'; export class DoneHubAdapter extends OneHubAdapter { readonly platformName: string = 'done-hub'; @@ -32,6 +32,26 @@ export class DoneHubAdapter extends OneHubAdapter { return { balance: quotaRemaining, used, quota: total, todayIncome, todayQuotaConsumption }; } + override async getSiteAnnouncements(baseUrl: string, _accessToken: string): Promise { + try { + const payload = await this.fetchJson(`${baseUrl}/api/notice`); + const content = typeof payload?.data === 'string' + ? payload.data.trim() + : (typeof payload === 'string' ? payload.trim() : ''); + if (!content) return []; + return [{ + sourceKey: this.buildNoticeSourceKey(content), + title: 'Site notice', + content, + level: 'info', + sourceUrl: '/api/notice', + rawPayload: payload, + }]; + } catch { + return []; + } + } + // getModels is inherited from OneHubAdapter which already has /api/available_model fallback. // No need to override here — OneHub's implementation handles this correctly. } diff --git a/src/server/services/platforms/gemini.ts b/src/server/services/platforms/gemini.ts index 1a5e2834..381b35b7 100644 --- a/src/server/services/platforms/gemini.ts +++ b/src/server/services/platforms/gemini.ts @@ -1,8 +1,4 @@ -import { BasePlatformAdapter, type BalanceInfo, type CheckinResult, type UserInfo } from './base.js'; - -function normalizeBaseUrl(baseUrl: string): string { - return (baseUrl || '').replace(/\/+$/, ''); -} +import { StandardApiProviderAdapterBase, normalizePlatformBaseUrl } from './standardApiProvider.js'; function stripModelPrefix(name: string): string { const trimmed = name.trim(); @@ -24,13 +20,13 @@ function isOpenAiCompatGeminiBase(baseUrl: string): boolean { } function resolveGeminiOpenAiModelsUrl(baseUrl: string): string { - const normalized = normalizeBaseUrl(baseUrl); + const normalized = normalizePlatformBaseUrl(baseUrl); if (/\/models$/i.test(normalized)) return normalized; return `${normalized}/models`; } function resolveGeminiNativeModelsUrl(baseUrl: string, apiToken: string): string { - const normalized = normalizeBaseUrl(baseUrl); + const normalized = normalizePlatformBaseUrl(baseUrl); const withVersion = /\/v\d+(?:beta)?(?:\/|$)/i.test(normalized) ? normalized : `${normalized}/v1beta`; @@ -41,8 +37,8 @@ function resolveGeminiNativeModelsUrl(baseUrl: string, apiToken: string): string return `${listBase}${separator}key=${encodeURIComponent(apiToken)}`; } -export class GeminiAdapter extends BasePlatformAdapter { - readonly platformName = 'gemini'; +export class GeminiAdapter extends StandardApiProviderAdapterBase { + readonly platformName: string = 'gemini'; async detect(url: string): Promise { const normalized = (url || '').toLowerCase(); @@ -53,34 +49,16 @@ export class GeminiAdapter extends BasePlatformAdapter { ); } - override async login(_baseUrl: string, _username: string, _password: string) { - return { success: false, message: 'login endpoint not supported' }; - } - - override async getUserInfo(_baseUrl: string, _accessToken: string): Promise { - return null; - } - - async checkin(_baseUrl: string, _accessToken: string): Promise { - return { success: false, message: 'checkin endpoint not supported' }; - } - - async getBalance(_baseUrl: string, _accessToken: string): Promise { - // Gemini API keys generally do not expose account balance via API. - return { balance: 0, used: 0, quota: 0 }; - } - async getModels(baseUrl: string, apiToken: string): Promise { - const normalizedBase = normalizeBaseUrl(baseUrl); + const normalizedBase = normalizePlatformBaseUrl(baseUrl); if (isOpenAiCompatGeminiBase(normalizedBase)) { - try { - const res = await this.fetchJson(resolveGeminiOpenAiModelsUrl(normalizedBase), { - headers: { Authorization: `Bearer ${apiToken}` }, - }); - const openAiModels = (res?.data || []).map((m: any) => String(m?.id || '').trim()).filter(Boolean); - if (openAiModels.length > 0) return normalizeModelList(openAiModels); - } catch {} + const openAiModels = await this.fetchModelsFromStandardEndpoint({ + baseUrl: normalizedBase, + headers: { Authorization: `Bearer ${apiToken}` }, + resolveUrl: resolveGeminiOpenAiModelsUrl, + }); + if (openAiModels.length > 0) return normalizeModelList(openAiModels); } try { @@ -92,13 +70,11 @@ export class GeminiAdapter extends BasePlatformAdapter { } catch {} if (!isOpenAiCompatGeminiBase(normalizedBase)) { - try { - const res = await this.fetchJson(`${normalizedBase}/v1beta/openai/models`, { - headers: { Authorization: `Bearer ${apiToken}` }, - }); - const openAiModels = (res?.data || []).map((m: any) => String(m?.id || '').trim()).filter(Boolean); - if (openAiModels.length > 0) return normalizeModelList(openAiModels); - } catch {} + const openAiModels = await this.fetchModelsFromStandardEndpoint({ + baseUrl: `${normalizedBase}/v1beta/openai`, + headers: { Authorization: `Bearer ${apiToken}` }, + }); + if (openAiModels.length > 0) return normalizeModelList(openAiModels); } return []; diff --git a/src/server/services/platforms/geminiCli.ts b/src/server/services/platforms/geminiCli.ts new file mode 100644 index 00000000..e8c42248 --- /dev/null +++ b/src/server/services/platforms/geminiCli.ts @@ -0,0 +1,10 @@ +import { GeminiAdapter } from './gemini.js'; + +export class GeminiCliAdapter extends GeminiAdapter { + override readonly platformName = 'gemini-cli'; + + override async detect(url: string): Promise { + const normalized = (url || '').toLowerCase(); + return normalized.includes('cloudcode-pa.googleapis.com'); + } +} diff --git a/src/server/services/platforms/index.test.ts b/src/server/services/platforms/index.test.ts index 12b025b6..e726cd4a 100644 --- a/src/server/services/platforms/index.test.ts +++ b/src/server/services/platforms/index.test.ts @@ -7,19 +7,34 @@ async function withHttpServer( handler: (req: IncomingMessage, res: ServerResponse) => void, run: (baseUrl: string) => Promise, ) { - const server = createServer(handler); - await new Promise((resolve) => { - server.listen(0, '127.0.0.1', () => resolve()); - }); - const { port } = server.address() as AddressInfo; - const baseUrl = `http://127.0.0.1:${port}`; - try { - await run(baseUrl); - } finally { - await new Promise((resolve, reject) => { - server.close((err?: Error) => (err ? reject(err) : resolve())); + // Avoid flakiness: CPA uses port 8317 by convention, and our platform detection + // includes a fast-path for localhost:8317. Random ephemeral ports can collide. + for (let attempt = 0; attempt < 5; attempt += 1) { + const server = createServer(handler); + await new Promise((resolve) => { + server.listen(0, '127.0.0.1', () => resolve()); }); + + const { port } = server.address() as AddressInfo; + if (port === 8317) { + await new Promise((resolve, reject) => { + server.close((err?: Error) => (err ? reject(err) : resolve())); + }); + continue; + } + + const baseUrl = `http://127.0.0.1:${port}`; + try { + await run(baseUrl); + return; + } finally { + await new Promise((resolve, reject) => { + server.close((err?: Error) => (err ? reject(err) : resolve())); + }); + } } + + throw new Error('withHttpServer: unable to allocate a test port that avoids 8317'); } describe('getAdapter platform aliases', () => { @@ -43,6 +58,16 @@ describe('getAdapter platform aliases', () => { expect(getAdapter('gemini')?.platformName).toBe('gemini'); }); + it('supports antigravity adapter aliases', () => { + expect(getAdapter('antigravity')?.platformName).toBe('antigravity'); + expect(getAdapter('anti-gravity')?.platformName).toBe('antigravity'); + }); + + it('supports dedicated codex adapter and aliases', () => { + expect(getAdapter('codex')?.platformName).toBe('codex'); + expect(getAdapter('chatgpt-codex')?.platformName).toBe('codex'); + }); + it('detects anyrouter URL before generic new-api adapter', async () => { const adapter = await detectPlatform('https://anyrouter.top'); expect(adapter?.platformName).toBe('anyrouter'); diff --git a/src/server/services/platforms/index.ts b/src/server/services/platforms/index.ts index 4829dac8..aceb9116 100644 --- a/src/server/services/platforms/index.ts +++ b/src/server/services/platforms/index.ts @@ -7,16 +7,23 @@ import { OneHubAdapter } from './oneHub.js'; import { DoneHubAdapter } from './doneHub.js'; import { Sub2ApiAdapter } from './sub2api.js'; import { OpenAiAdapter } from './openai.js'; +import { CodexAdapter } from './codex.js'; import { ClaudeAdapter } from './claude.js'; import { GeminiAdapter } from './gemini.js'; +import { GeminiCliAdapter } from './geminiCli.js'; +import { AntigravityAdapter } from './antigravity.js'; import { CliProxyApiAdapter } from './cliproxyapi.js'; import { detectPlatformByTitle } from './titleHint.js'; +import { detectPlatformByUrlHint, normalizePlatformAlias } from '../../../shared/platformIdentity.js'; const adapters: PlatformAdapter[] = [ // Specific forks before generic adapters for better auto-detection. new OpenAiAdapter(), + new CodexAdapter(), new ClaudeAdapter(), new GeminiAdapter(), + new GeminiCliAdapter(), + new AntigravityAdapter(), new CliProxyApiAdapter(), new AnyRouterAdapter(), new DoneHubAdapter(), @@ -27,41 +34,8 @@ const adapters: PlatformAdapter[] = [ new OneApiAdapter(), ]; -const platformAliases: Record = { - // NewAPI family aliases - anyrouter: 'anyrouter', - 'wong-gongyi': 'new-api', - 'vo-api': 'new-api', - 'super-api': 'new-api', - 'rix-api': 'new-api', - 'neo-api': 'new-api', - newapi: 'new-api', - 'new api': 'new-api', - // OneAPI family aliases - oneapi: 'one-api', - 'one api': 'one-api', - // Keep canonical forms explicit for clarity - 'new-api': 'new-api', - 'one-api': 'one-api', - veloera: 'veloera', - 'one-hub': 'one-hub', - 'done-hub': 'done-hub', - sub2api: 'sub2api', - // Official upstream APIs - openai: 'openai', - anthropic: 'claude', - claude: 'claude', - gemini: 'gemini', - google: 'gemini', - // CLIProxyAPI aliases - cliproxyapi: 'cliproxyapi', - cpa: 'cliproxyapi', - 'cli-proxy-api': 'cliproxyapi', -}; - function normalizePlatform(platform: string): string { - const raw = (platform || '').trim().toLowerCase(); - return platformAliases[raw] ?? raw; + return normalizePlatformAlias(platform); } export function getAdapter(platform: string): PlatformAdapter | undefined { @@ -77,34 +51,6 @@ const titleFirstPlatforms = new Set([ 'sub2api', ]); -function detectPlatformByUrlHint(url: string): string | undefined { - const normalized = (url || '').trim().toLowerCase(); - if (!normalized) return undefined; - - // Official upstream endpoints. - if (normalized.includes('api.openai.com')) return 'openai'; - if (normalized.includes('api.anthropic.com') || normalized.includes('anthropic.com/v1')) return 'claude'; - if ( - normalized.includes('generativelanguage.googleapis.com') - || normalized.includes('googleapis.com/v1beta/openai') - || normalized.includes('gemini.google.com') - ) { - return 'gemini'; - } - - // NewAPI-family forks and common aliases. - if (normalized.includes('anyrouter')) return 'anyrouter'; - if (normalized.includes('donehub') || normalized.includes('done-hub')) return 'done-hub'; - if (normalized.includes('onehub') || normalized.includes('one-hub')) return 'one-hub'; - if (normalized.includes('veloera')) return 'veloera'; - if (normalized.includes('sub2api')) return 'sub2api'; - - // CLIProxyAPI default local endpoints. - if (normalized.includes('127.0.0.1:8317') || normalized.includes('localhost:8317')) return 'cliproxyapi'; - - return undefined; -} - export async function detectPlatform(url: string): Promise { const urlHint = detectPlatformByUrlHint(url); if (urlHint) { diff --git a/src/server/services/platforms/llmUpstream.test.ts b/src/server/services/platforms/llmUpstream.test.ts index ee4e176f..c98f215d 100644 --- a/src/server/services/platforms/llmUpstream.test.ts +++ b/src/server/services/platforms/llmUpstream.test.ts @@ -4,6 +4,7 @@ import { AddressInfo } from 'node:net'; import { OpenAiAdapter } from './openai.js'; import { ClaudeAdapter } from './claude.js'; import { GeminiAdapter } from './gemini.js'; +import { CliProxyApiAdapter } from './cliproxyapi.js'; interface RequestSnapshot { method: string; @@ -52,6 +53,20 @@ describe('official llm upstream adapters', () => { return; } + if (req.url === '/cliproxy/v1/models') { + if (req.headers.authorization !== 'Bearer sk-cpa') { + res.writeHead(401, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: { message: 'unauthorized' } })); + return; + } + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + object: 'list', + data: [{ id: 'gpt-5.4' }, { id: 'gpt-5.2-codex' }], + })); + return; + } + if (req.url?.startsWith('/gemini/v1beta/models')) { const url = new URL(req.url, 'http://127.0.0.1'); if (url.searchParams.get('key') !== 'gemini-key') { @@ -112,6 +127,12 @@ describe('official llm upstream adapters', () => { expect(models).toEqual(['claude-sonnet-4-5-20250929', 'claude-haiku-4-5-20251001']); }); + it('fetches models from cliproxy openai-compatible upstream', async () => { + const adapter = new CliProxyApiAdapter(); + const models = await adapter.getModels(`${baseUrl}/cliproxy`, 'sk-cpa'); + expect(models).toEqual(['gpt-5.4', 'gpt-5.2-codex']); + }); + it('fetches models from gemini native endpoint and normalizes model names', async () => { const adapter = new GeminiAdapter(); const models = await adapter.getModels(`${baseUrl}/gemini/v1beta`, 'gemini-key'); diff --git a/src/server/services/platforms/newApi.test.ts b/src/server/services/platforms/newApi.test.ts index fefb94f2..9f7baebb 100644 --- a/src/server/services/platforms/newApi.test.ts +++ b/src/server/services/platforms/newApi.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'; import { AddressInfo } from 'node:net'; +import { createHash } from 'node:crypto'; import { readFileSync } from 'node:fs'; import { NewApiAdapter } from './newApi.js'; @@ -16,6 +17,7 @@ const CHECKIN_ALREADY_TOKEN = 'checkin-already-token'; const CHECKIN_INVALID_URL_TOKEN = 'checkin-invalid-url-token'; const CHECKIN_CLOUDFLARE_530_TOKEN = 'checkin-cloudflare-530-token'; const BALANCE_FAIL_TOKEN = 'balance-fail-token'; +const GROUP_EXPIRED_TOKEN = 'group-expired-token'; const SHIELD_LOGIN_USERNAME = 'shield-user'; const SHIELD_LOGIN_PASSWORD = 'shield-pass'; const SHIELD_LOGIN_TOKEN = 'login-session-token'; @@ -141,6 +143,15 @@ describe('NewApiAdapter', () => { return; } + if (req.url === '/api/notice') { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + success: true, + data: 'Welcome to the site', + })); + return; + } + if (req.url?.startsWith('/api/token/')) { if (typeof req.headers.authorization === 'string' && req.headers.authorization === `Bearer ${COOKIE_SHIELDED_TOKEN}`) { res.writeHead(401, { 'Content-Type': 'application/json' }); @@ -366,6 +377,19 @@ describe('NewApiAdapter', () => { } } + if (req.url === '/api/user/self/groups') { + if (typeof req.headers.authorization === 'string' && req.headers.authorization === `Bearer ${GROUP_EXPIRED_TOKEN}`) { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ success: false, message: 'access token expired' })); + return; + } + if (typeof req.headers.authorization === 'string' && req.headers.authorization === 'Bearer session-token') { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ success: true, data: { default: true, gemini: true } })); + return; + } + } + if (req.url === '/api/user/sign_in') { if (typeof req.headers.cookie === 'string' && req.headers.cookie.includes(`session=${CHECKIN_ALREADY_TOKEN}`)) { res.writeHead(200, { 'Content-Type': 'application/json' }); @@ -556,6 +580,20 @@ describe('NewApiAdapter', () => { expect(result.message).toBe('今天已经签到过啦'); }); + it('returns clean groups from data object without envelope keys', async () => { + const adapter = new NewApiAdapter(); + const groups = await adapter.getUserGroups(baseUrl, 'session-token', 11494); + + expect(groups).toEqual(['default', 'gemini']); + expect(groups).not.toContain('success'); + expect(groups).not.toContain('message'); + }); + + it('throws expired-session error when group endpoint reports invalid access token', async () => { + const adapter = new NewApiAdapter(); + await expect(adapter.getUserGroups(baseUrl, GROUP_EXPIRED_TOKEN, 11494)).rejects.toThrow('账号会话可能已过期'); + }); + it('sends all compatibility user-id headers when userId is known', async () => { await new Promise((resolve, reject) => { server.close((err?: Error) => (err ? reject(err) : resolve())); @@ -588,4 +626,20 @@ describe('NewApiAdapter', () => { expect(receivedHeaders['rix-api-user']).toBe('42'); expect(receivedHeaders['neo-api-user']).toBe('42'); }); + + it('normalizes the global site notice from /api/notice', async () => { + const adapter = new NewApiAdapter(); + const rows = await adapter.getSiteAnnouncements(baseUrl, 'session-token'); + + expect(rows).toEqual([ + { + sourceKey: `notice:${createHash('sha1').update('Welcome to the site').digest('hex')}`, + title: 'Site notice', + content: 'Welcome to the site', + level: 'info', + sourceUrl: '/api/notice', + rawPayload: { success: true, data: 'Welcome to the site' }, + }, + ]); + }); }); diff --git a/src/server/services/platforms/newApi.ts b/src/server/services/platforms/newApi.ts index 31d6cfb8..f74ec609 100644 --- a/src/server/services/platforms/newApi.ts +++ b/src/server/services/platforms/newApi.ts @@ -1,4 +1,4 @@ -import { ApiTokenInfo, BasePlatformAdapter, CheckinResult, BalanceInfo, UserInfo, TokenVerifyResult, CreateApiTokenOptions } from './base.js'; +import { ApiTokenInfo, BasePlatformAdapter, CheckinResult, BalanceInfo, UserInfo, TokenVerifyResult, CreateApiTokenOptions, type SiteAnnouncement } from './base.js'; import type { RequestInit as UndiciRequestInit } from 'undici'; import { createContext, runInContext } from 'node:vm'; import { withSiteProxyRequestInit } from '../siteProxy.js'; @@ -15,6 +15,26 @@ export class NewApiAdapter extends BasePlatformAdapter { } } + override async getSiteAnnouncements(baseUrl: string, _accessToken: string): Promise { + try { + const payload = await this.fetchJson(`${baseUrl}/api/notice`); + const content = typeof payload?.data === 'string' + ? payload.data.trim() + : (typeof payload === 'string' ? payload.trim() : ''); + if (!content) return []; + return [{ + sourceKey: this.buildNoticeSourceKey(content), + title: 'Site notice', + content, + level: 'info', + sourceUrl: '/api/notice', + rawPayload: payload, + }]; + } catch { + return []; + } + } + private tryDecodeUserId(token: string): number | null { try { const parts = token.split('.'); @@ -229,12 +249,29 @@ export class NewApiAdapter extends BasePlatformAdapter { return []; } + private isTokenListResponse(payload: any): boolean { + if (!payload || typeof payload !== 'object') return false; + if (payload?.success === true) return true; + return ( + Array.isArray(payload?.data) + || Array.isArray(payload?.data?.items) + || Array.isArray(payload?.data?.data) + || Array.isArray(payload?.items) + || Array.isArray(payload?.list) + || Array.isArray(payload?.data?.list) + ); + } + private normalizeTokenKeyForCompare(value?: string | null): string { const trimmed = (value || '').trim(); return trimmed.startsWith('Bearer ') ? trimmed.slice(7).trim() : trimmed; } private parseGroupKeys(payload: any): string[] { + if (payload && typeof payload === 'object' && payload?.success === false) { + return []; + } + const source = payload?.data ?? payload; if (Array.isArray(source)) { return source @@ -245,12 +282,28 @@ export class NewApiAdapter extends BasePlatformAdapter { if (source && typeof source === 'object') { return Object.keys(source) .map((key) => key.trim()) + .filter((key) => !['success', 'message', 'code', 'data', 'error'].includes(key.toLowerCase())) .filter(Boolean); } return []; } + private resolveGroupFetchErrorMessage(payload: any): string { + const message = typeof payload?.message === 'string' ? payload.message.trim() : ''; + const normalized = message.toLowerCase(); + const indicatesExpired = normalized.includes('expired') + || normalized.includes('invalid token') + || normalized.includes('access token') + || normalized.includes('unauthorized') + || normalized.includes('forbidden') + || normalized.includes('未登录') + || normalized.includes('登录') + || normalized.includes('过期'); + if (indicatesExpired) return '账号会话可能已过期,请重新登录后再拉取分组'; + return message || '拉取分组失败'; + } + private normalizeTokenItems(items: any[]): ApiTokenInfo[] { const normalized: ApiTokenInfo[] = []; for (let index = 0; index < items.length; index += 1) { @@ -1128,11 +1181,15 @@ export class NewApiAdapter extends BasePlatformAdapter { async getUserGroups(baseUrl: string, accessToken: string, platformUserId?: number): Promise { const resolvedUserId = platformUserId || await this.discoverUserId(baseUrl, accessToken); const dedupe = (groups: string[]) => Array.from(new Set(groups.map((item) => item.trim()).filter(Boolean))); + let terminalError: string | null = null; try { const res = await this.fetchJson(`${baseUrl}/api/user/self/groups`, { headers: this.authHeaders(accessToken, resolvedUserId || undefined), }); + if (res?.success === false) { + terminalError = this.resolveGroupFetchErrorMessage(res); + } const parsed = dedupe(this.parseGroupKeys(res)); if (parsed.length > 0) return parsed; } catch {} @@ -1141,6 +1198,9 @@ export class NewApiAdapter extends BasePlatformAdapter { const res = await this.fetchJson(`${baseUrl}/api/user_group_map`, { headers: this.authHeaders(accessToken, resolvedUserId || undefined), }); + if (res?.success === false) { + terminalError = this.resolveGroupFetchErrorMessage(res); + } const parsed = dedupe(this.parseGroupKeys(res)); if (parsed.length > 0) return parsed; } catch {} @@ -1152,17 +1212,27 @@ export class NewApiAdapter extends BasePlatformAdapter { try { const res = await this.fetchJsonRaw(`${baseUrl}/api/user/self/groups`, { headers }); + if (res?.success === false) { + terminalError = this.resolveGroupFetchErrorMessage(res); + } const parsed = dedupe(this.parseGroupKeys(res)); if (parsed.length > 0) return parsed; } catch {} try { const res = await this.fetchJsonRaw(`${baseUrl}/api/user_group_map`, { headers }); + if (res?.success === false) { + terminalError = this.resolveGroupFetchErrorMessage(res); + } const parsed = dedupe(this.parseGroupKeys(res)); if (parsed.length > 0) return parsed; } catch {} } + if (terminalError) { + throw new Error(terminalError); + } + return ['default']; } @@ -1236,6 +1306,7 @@ export class NewApiAdapter extends BasePlatformAdapter { }); const normalized = this.normalizeTokenItems(this.parseTokenItems(res)); if (normalized.length > 0) return normalized; + if (this.isTokenListResponse(res)) return []; } catch {} const cookieTokens = await this.getApiTokensByCookie(baseUrl, accessToken, userId); diff --git a/src/server/services/platforms/oneApi.ts b/src/server/services/platforms/oneApi.ts index 4c83b13d..1b0851ac 100644 --- a/src/server/services/platforms/oneApi.ts +++ b/src/server/services/platforms/oneApi.ts @@ -129,24 +129,49 @@ export class OneApiAdapter extends BasePlatformAdapter { async getUserGroups(baseUrl: string, accessToken: string): Promise { const headers = { Authorization: `Bearer ${accessToken}` }; + const resolveGroupFetchErrorMessage = (payload: any): string => { + const message = typeof payload?.message === 'string' ? payload.message.trim() : ''; + const normalized = message.toLowerCase(); + const indicatesExpired = normalized.includes('expired') + || normalized.includes('invalid token') + || normalized.includes('access token') + || normalized.includes('unauthorized') + || normalized.includes('forbidden') + || normalized.includes('未登录') + || normalized.includes('登录') + || normalized.includes('过期'); + if (indicatesExpired) return '账号会话可能已过期,请重新登录后再拉取分组'; + return message || '拉取分组失败'; + }; const extractGroupKeys = (payload: any): string[] => { + if (payload && typeof payload === 'object' && payload?.success === false) return []; const source = payload?.data || payload; if (!source || typeof source !== 'object') return []; - return Object.keys(source).map((key) => key.trim()).filter(Boolean); + return Object.keys(source) + .map((key) => key.trim()) + .filter((key) => !['success', 'message', 'code', 'data', 'error'].includes(key.toLowerCase())) + .filter(Boolean); }; + let terminalError: string | null = null; try { const groupMap = await this.fetchJson(`${baseUrl}/api/user_group_map`, { headers }); + if (groupMap?.success === false) terminalError = resolveGroupFetchErrorMessage(groupMap); const keys = extractGroupKeys(groupMap); if (keys.length > 0) return Array.from(new Set(keys)); } catch {} try { const groups = await this.fetchJson(`${baseUrl}/api/user/self/groups`, { headers }); + if (groups?.success === false) terminalError = resolveGroupFetchErrorMessage(groups); const keys = extractGroupKeys(groups); if (keys.length > 0) return Array.from(new Set(keys)); } catch {} + if (terminalError) { + throw new Error(terminalError); + } + return ['default']; } diff --git a/src/server/services/platforms/openai.ts b/src/server/services/platforms/openai.ts index e391d19a..8af3a0c3 100644 --- a/src/server/services/platforms/openai.ts +++ b/src/server/services/platforms/openai.ts @@ -1,18 +1,6 @@ -import { BasePlatformAdapter, type BalanceInfo, type CheckinResult, type UserInfo } from './base.js'; +import { StandardApiProviderAdapterBase } from './standardApiProvider.js'; -function normalizeBaseUrl(baseUrl: string): string { - return (baseUrl || '').replace(/\/+$/, ''); -} - -function resolveOpenAiModelsUrl(baseUrl: string): string { - const normalized = normalizeBaseUrl(baseUrl); - if (/\/v\d+(\.\d+)?$/i.test(normalized)) { - return `${normalized}/models`; - } - return `${normalized}/v1/models`; -} - -export class OpenAiAdapter extends BasePlatformAdapter { +export class OpenAiAdapter extends StandardApiProviderAdapterBase { readonly platformName = 'openai'; async detect(url: string): Promise { @@ -20,31 +8,10 @@ export class OpenAiAdapter extends BasePlatformAdapter { return normalized.includes('api.openai.com'); } - override async login(_baseUrl: string, _username: string, _password: string) { - return { success: false, message: 'login endpoint not supported' }; - } - - override async getUserInfo(_baseUrl: string, _accessToken: string): Promise { - return null; - } - - async checkin(_baseUrl: string, _accessToken: string): Promise { - return { success: false, message: 'checkin endpoint not supported' }; - } - - async getBalance(_baseUrl: string, _accessToken: string): Promise { - // Official OpenAI API keys generally do not expose account balance via API. - return { balance: 0, used: 0, quota: 0 }; - } - async getModels(baseUrl: string, apiToken: string): Promise { - try { - const res = await this.fetchJson(resolveOpenAiModelsUrl(baseUrl), { - headers: { Authorization: `Bearer ${apiToken}` }, - }); - return (res?.data || []).map((m: any) => m?.id).filter(Boolean); - } catch { - return []; - } + return this.fetchModelsFromStandardEndpoint({ + baseUrl, + headers: { Authorization: `Bearer ${apiToken}` }, + }); } } diff --git a/src/server/services/platforms/siteAnnouncements.test.ts b/src/server/services/platforms/siteAnnouncements.test.ts new file mode 100644 index 00000000..dfa223ec --- /dev/null +++ b/src/server/services/platforms/siteAnnouncements.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it } from 'vitest'; +import { + BasePlatformAdapter, + type BalanceInfo, + type CheckinResult, + type PlatformAdapter, + type SiteAnnouncement, +} from './base.js'; + +class UnsupportedAnnouncementAdapter extends BasePlatformAdapter { + override readonly platformName = 'unsupported'; + + override async detect(): Promise { + return false; + } + + override async checkin(): Promise { + return { success: false, message: 'unsupported' }; + } + + override async getBalance(): Promise { + return { balance: 0, used: 0, quota: 0 }; + } + + override async getModels(): Promise { + return []; + } +} + +class SingleNoticeAdapter extends UnsupportedAnnouncementAdapter { + override readonly platformName = 'single-notice'; + + override async getSiteAnnouncements(): Promise { + return [{ + sourceKey: 'notice:welcome', + title: 'Site notice', + content: 'Welcome to the site', + level: 'info', + sourceUrl: '/api/notice', + rawPayload: { notice: 'Welcome to the site' }, + }]; + } +} + +class ListAnnouncementAdapter extends UnsupportedAnnouncementAdapter { + override readonly platformName = 'list-notice'; + + override async getSiteAnnouncements(): Promise { + return [ + { + sourceKey: 'announcement:1', + title: 'Maintenance', + content: 'Window starts at 10:00', + level: 'warning', + startsAt: '2026-03-20T10:00:00Z', + upstreamCreatedAt: '2026-03-20T09:00:00Z', + rawPayload: { id: 1 }, + }, + { + sourceKey: 'announcement:2', + title: 'New model online', + content: 'gpt-4.1 is available', + level: 'info', + endsAt: '2026-03-21T00:00:00Z', + upstreamUpdatedAt: '2026-03-20T12:00:00Z', + rawPayload: { id: 2 }, + }, + ]; + } +} + +function expectAnnouncementShape(row: SiteAnnouncement) { + expect(row.sourceKey).toBeTruthy(); + expect(row.title).toBeTruthy(); + expect(row.content).toBeTruthy(); + expect(['info', 'warning', 'error']).toContain(row.level); +} + +describe('site announcement platform contract', () => { + it('allows unsupported adapters to return an empty list', async () => { + const adapter: PlatformAdapter = new UnsupportedAnnouncementAdapter(); + + await expect(adapter.getSiteAnnouncements('https://example.com', 'token')).resolves.toEqual([]); + }); + + it('normalizes single-notice platforms into the shared announcement shape', async () => { + const adapter: PlatformAdapter = new SingleNoticeAdapter(); + + const rows = await adapter.getSiteAnnouncements('https://example.com', 'token'); + + expect(rows).toHaveLength(1); + expectAnnouncementShape(rows[0]); + }); + + it('normalizes list-style platforms into the shared announcement shape', async () => { + const adapter: PlatformAdapter = new ListAnnouncementAdapter(); + + const rows = await adapter.getSiteAnnouncements('https://example.com', 'token'); + + expect(rows).toHaveLength(2); + rows.forEach(expectAnnouncementShape); + }); +}); diff --git a/src/server/services/platforms/standardApiProvider.test.ts b/src/server/services/platforms/standardApiProvider.test.ts new file mode 100644 index 00000000..f3cfabab --- /dev/null +++ b/src/server/services/platforms/standardApiProvider.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from 'vitest'; +import { + StandardApiProviderAdapterBase, + normalizePlatformBaseUrl, + resolveVersionedModelsUrl, +} from './standardApiProvider.js'; + +class TestStandardApiProviderAdapter extends StandardApiProviderAdapterBase { + readonly platformName = 'test-standard'; + fetchJsonImpl = async () => ({ data: [] as Array<{ id: string }> }); + + async detect(_url: string): Promise { + return false; + } + + async getModels(_baseUrl: string, _token: string): Promise { + return []; + } + + protected override async fetchJson(url: string, options?: Parameters[1]): Promise { + return this.fetchJsonImpl(url, options) as Promise; + } + + async fetchModelsForTest(options: Parameters[0]) { + return this.fetchModelsFromStandardEndpoint(options); + } +} + +describe('standardApiProvider helpers', () => { + it('normalizes provider base urls and appends /v1/models when needed', () => { + expect(normalizePlatformBaseUrl('https://api.example.com///')).toBe('https://api.example.com'); + expect(resolveVersionedModelsUrl('https://api.example.com')).toBe('https://api.example.com/v1/models'); + expect(resolveVersionedModelsUrl('https://api.example.com/v1')).toBe('https://api.example.com/v1/models'); + expect(resolveVersionedModelsUrl('https://api.example.com/v1beta')).toBe('https://api.example.com/v1beta/models'); + }); + + it('provides shared unsupported login/checkin and zero-balance defaults', async () => { + const adapter = new TestStandardApiProviderAdapter(); + + await expect(adapter.login('https://api.example.com', 'user', 'pass')).resolves.toEqual({ + success: false, + message: 'login endpoint not supported', + }); + await expect(adapter.getUserInfo('https://api.example.com', 'token')).resolves.toBe(null); + await expect(adapter.checkin('https://api.example.com', 'token')).resolves.toEqual({ + success: false, + message: 'checkin endpoint not supported', + }); + await expect(adapter.getBalance('https://api.example.com', 'token')).resolves.toEqual({ + balance: 0, + used: 0, + quota: 0, + }); + }); + + it('does not swallow mapper bugs while still returning empty lists for network failures', async () => { + const adapter = new TestStandardApiProviderAdapter(); + adapter.fetchJsonImpl = async () => ({ data: [{ id: 'gpt-5' }] }); + + await expect(adapter.fetchModelsForTest({ + baseUrl: 'https://api.example.com', + mapResponse: () => { + throw new Error('mapper exploded'); + }, + })).rejects.toThrow('mapper exploded'); + + adapter.fetchJsonImpl = async () => { + throw new Error('network failed'); + }; + + await expect(adapter.fetchModelsForTest({ + baseUrl: 'https://api.example.com', + })).resolves.toEqual([]); + }); + + it('rejects invalid payload shapes instead of silently treating them as no models', async () => { + const adapter = new TestStandardApiProviderAdapter(); + adapter.fetchJsonImpl = async () => ({ data: 'not-an-array' }); + + await expect(adapter.fetchModelsForTest({ + baseUrl: 'https://api.example.com', + })).rejects.toThrow('invalid standard models payload'); + }); +}); diff --git a/src/server/services/platforms/standardApiProvider.ts b/src/server/services/platforms/standardApiProvider.ts new file mode 100644 index 00000000..76c516bf --- /dev/null +++ b/src/server/services/platforms/standardApiProvider.ts @@ -0,0 +1,86 @@ +import { + BasePlatformAdapter, + type BalanceInfo, + type CheckinResult, + type UserInfo, +} from './base.js'; + +type FetchModelsOptions = { + baseUrl: string; + headers?: Record; + resolveUrl?: (normalizedBaseUrl: string) => string; + mapResponse?: (payload: any) => unknown[]; +}; + +export function normalizePlatformBaseUrl(baseUrl: string): string { + let normalized = baseUrl || ''; + while (normalized.endsWith('/')) { + normalized = normalized.slice(0, -1); + } + return normalized; +} + +export function resolveVersionedModelsUrl(baseUrl: string): string { + const normalized = normalizePlatformBaseUrl(baseUrl); + if (/\/v\d+(?:\.\d+)?(?:beta)?$/i.test(normalized)) { + return `${normalized}/models`; + } + return `${normalized}/v1/models`; +} + +export abstract class StandardApiProviderAdapterBase extends BasePlatformAdapter { + protected loginUnsupportedMessage = 'login endpoint not supported'; + protected checkinUnsupportedMessage = 'checkin endpoint not supported'; + + override async login(_baseUrl: string, _username: string, _password: string) { + return { + success: false as const, + message: this.loginUnsupportedMessage, + }; + } + + override async getUserInfo(_baseUrl: string, _accessToken: string): Promise { + return null; + } + + override async checkin(_baseUrl: string, _accessToken: string): Promise { + return { + success: false, + message: this.checkinUnsupportedMessage, + }; + } + + override async getBalance(_baseUrl: string, _accessToken: string): Promise { + return { balance: 0, used: 0, quota: 0 }; + } + + protected async fetchModelsFromStandardEndpoint(options: FetchModelsOptions): Promise { + const normalizedBaseUrl = normalizePlatformBaseUrl(options.baseUrl); + const url = options.resolveUrl + ? options.resolveUrl(normalizedBaseUrl) + : resolveVersionedModelsUrl(normalizedBaseUrl); + + let payload: any; + try { + payload = await this.fetchJson(url, { + headers: options.headers, + }); + } catch { + return []; + } + + const rows = options.mapResponse + ? options.mapResponse(payload) + : Array.isArray(payload?.data) + ? payload.data.map((item: any) => item?.id) + : null; + + if (!Array.isArray(rows)) { + throw new Error('invalid standard models payload'); + } + + return rows + .map((item) => (typeof item === 'string' ? item.trim() : '')) + .filter((item) => item.length > 0); + } +} diff --git a/src/server/services/platforms/sub2api.subscription-summary.test.ts b/src/server/services/platforms/sub2api.subscription-summary.test.ts new file mode 100644 index 00000000..902cd0f0 --- /dev/null +++ b/src/server/services/platforms/sub2api.subscription-summary.test.ts @@ -0,0 +1,140 @@ +import { describe, expect, it } from 'vitest'; +import { Sub2ApiAdapter } from './sub2api.js'; + +class MockSub2ApiAdapter extends Sub2ApiAdapter { + constructor(private readonly responses: Record) { + super(); + } + + protected override async fetchJson(url: string): Promise { + const parsed = new URL(url); + const key = `${parsed.pathname}${parsed.search}`; + const value = this.responses[key]; + if (value instanceof Error) throw value; + if (value === undefined) throw new Error(`Unexpected request: ${key}`); + return value as T; + } +} + +describe('Sub2ApiAdapter subscription summary parsing', () => { + it('returns summary data from /api/v1/subscriptions/summary', async () => { + const adapter = new MockSub2ApiAdapter({ + '/api/v1/auth/me': { + code: 0, + message: 'success', + data: { id: 1, username: 'demo', email: 'demo@example.com', balance: 8.5 }, + }, + '/api/v1/subscriptions/summary': { + code: 0, + message: 'success', + data: { + active_count: 1, + total_used_usd: 3.2, + subscriptions: [ + { + id: 9, + group_name: 'Pro', + status: 'active', + expires_at: '2026-04-01T00:00:00Z', + monthly_used_usd: 3.2, + monthly_limit_usd: 20, + }, + ], + }, + }, + }); + + const balance = await adapter.getBalance('https://sub2api.example.com', 'jwt-token'); + expect(balance.subscriptionSummary).toEqual({ + activeCount: 1, + totalUsedUsd: 3.2, + subscriptions: [ + { + id: 9, + groupName: 'Pro', + status: 'active', + expiresAt: '2026-04-01T00:00:00.000Z', + monthlyUsedUsd: 3.2, + monthlyLimitUsd: 20, + }, + ], + }); + }); + + it('falls back to /api/v1/subscriptions/active when summary endpoint fails', async () => { + const adapter = new MockSub2ApiAdapter({ + '/api/v1/auth/me': { + code: 0, + message: 'success', + data: { id: 1, username: 'demo', email: 'demo@example.com', balance: 8.5 }, + }, + '/api/v1/subscriptions/summary': new Error('HTTP 404'), + '/api/v1/subscriptions/active': { + code: 0, + message: 'success', + data: [ + { + id: 10, + group_name: 'Fallback', + expires_at: '2026-05-01T00:00:00Z', + monthly_used_usd: 1.5, + monthly_limit_usd: 10, + }, + ], + }, + }); + + const balance = await adapter.getBalance('https://sub2api.example.com', 'jwt-token'); + expect(balance.subscriptionSummary).toEqual({ + activeCount: 1, + totalUsedUsd: 1.5, + subscriptions: [ + { + id: 10, + groupName: 'Fallback', + expiresAt: '2026-05-01T00:00:00.000Z', + monthlyUsedUsd: 1.5, + monthlyLimitUsd: 10, + }, + ], + }); + }); + + it('preserves explicit zero activeCount from summary payload', async () => { + const adapter = new MockSub2ApiAdapter({ + '/api/v1/auth/me': { + code: 0, + message: 'success', + data: { id: 1, username: 'demo', email: 'demo@example.com', balance: 8.5 }, + }, + '/api/v1/subscriptions/summary': { + code: 0, + message: 'success', + data: { + active_count: 0, + total_used_usd: 0, + subscriptions: [ + { + id: 11, + group_name: 'Expired', + status: 'expired', + }, + ], + }, + }, + }); + + const balance = await adapter.getBalance('https://sub2api.example.com', 'jwt-token'); + expect(balance.subscriptionSummary).toEqual({ + activeCount: 0, + totalUsedUsd: 0, + subscriptions: [ + { + id: 11, + groupName: 'Expired', + status: 'expired', + }, + ], + }); + }); +}); diff --git a/src/server/services/platforms/sub2api.test.ts b/src/server/services/platforms/sub2api.test.ts index 762115d8..94e07d0c 100644 --- a/src/server/services/platforms/sub2api.test.ts +++ b/src/server/services/platforms/sub2api.test.ts @@ -101,6 +101,111 @@ describe('Sub2ApiAdapter', () => { expect(balance.used).toBe(0); }); + it('includes subscription summary from /api/v1/subscriptions/summary when available', async () => { + await startServer((req, res) => { + if (req.url === '/api/v1/auth/me') { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + code: 0, + message: 'success', + data: { id: 1, username: 'testuser', email: 'test@example.com', balance: 12.5 }, + })); + return; + } + if (req.url === '/api/v1/subscriptions/summary') { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + code: 0, + message: 'success', + data: { + active_count: 1, + total_used_usd: 3.75, + subscriptions: [ + { + id: 3, + group_name: 'Pro', + status: 'active', + expires_at: '2026-04-01T00:00:00Z', + monthly_used_usd: 3.75, + monthly_limit_usd: 20, + }, + ], + }, + })); + return; + } + res.writeHead(404).end(); + }); + + const balance = await adapter.getBalance(baseUrl, 'jwt-token'); + expect(balance.subscriptionSummary).toEqual({ + activeCount: 1, + totalUsedUsd: 3.75, + subscriptions: [ + { + id: 3, + groupName: 'Pro', + status: 'active', + expiresAt: '2026-04-01T00:00:00.000Z', + monthlyUsedUsd: 3.75, + monthlyLimitUsd: 20, + }, + ], + }); + }); + + it('falls back to active subscriptions when summary endpoint is unavailable', async () => { + await startServer((req, res) => { + if (req.url === '/api/v1/auth/me') { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + code: 0, + message: 'success', + data: { id: 1, username: 'testuser', email: 'test@example.com', balance: 12.5 }, + })); + return; + } + if (req.url === '/api/v1/subscriptions/summary') { + res.writeHead(404, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ code: 404, message: 'not found' })); + return; + } + if (req.url === '/api/v1/subscriptions/active') { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + code: 0, + message: 'success', + data: [ + { + id: 9, + group_name: 'Fallback', + expires_at: '2026-05-01T00:00:00Z', + monthly_used_usd: 2.5, + monthly_limit_usd: 15, + }, + ], + })); + return; + } + res.writeHead(404).end(); + }); + + const balance = await adapter.getBalance(baseUrl, 'jwt-token'); + expect(balance.subscriptionSummary).toEqual({ + activeCount: 1, + totalUsedUsd: 2.5, + subscriptions: [ + { + id: 9, + groupName: 'Fallback', + expiresAt: '2026-05-01T00:00:00.000Z', + monthlyUsedUsd: 2.5, + monthlyLimitUsd: 15, + }, + ], + }); + }); + it('fetches user info from /api/v1/auth/me', async () => { await startServer((req, res) => { if (req.url === '/api/v1/auth/me') { @@ -438,4 +543,79 @@ describe('Sub2ApiAdapter', () => { const deleted = await adapter.deleteApiToken(baseUrl, 'jwt-token', 'sk-delete-me'); expect(deleted).toBe(true); }); + + it('normalizes announcements from /api/v1/announcements', async () => { + await startServer((req, res) => { + if (req.url === '/api/v1/announcements?page=1&page_size=100') { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + code: 0, + message: 'success', + data: { + items: [ + { + id: 11, + title: 'Maintenance', + content: 'Window starts at 10:00', + starts_at: '2026-03-20T10:00:00Z', + ends_at: '2026-03-20T12:00:00Z', + created_at: '2026-03-20T09:00:00Z', + updated_at: '2026-03-20T09:30:00Z', + }, + { + id: 12, + title: 'New model online', + content: 'gpt-4.1 is available', + read_at: '2026-03-20T12:05:00Z', + created_at: '2026-03-20T12:00:00Z', + updated_at: '2026-03-20T12:01:00Z', + }, + ], + }, + })); + return; + } + res.writeHead(404).end(); + }); + + const rows = await adapter.getSiteAnnouncements(baseUrl, 'jwt-token'); + + expect(rows).toEqual([ + { + sourceKey: 'announcement:11', + title: 'Maintenance', + content: 'Window starts at 10:00', + level: 'info', + startsAt: '2026-03-20T10:00:00Z', + endsAt: '2026-03-20T12:00:00Z', + upstreamCreatedAt: '2026-03-20T09:00:00Z', + upstreamUpdatedAt: '2026-03-20T09:30:00Z', + rawPayload: { + id: 11, + title: 'Maintenance', + content: 'Window starts at 10:00', + starts_at: '2026-03-20T10:00:00Z', + ends_at: '2026-03-20T12:00:00Z', + created_at: '2026-03-20T09:00:00Z', + updated_at: '2026-03-20T09:30:00Z', + }, + }, + { + sourceKey: 'announcement:12', + title: 'New model online', + content: 'gpt-4.1 is available', + level: 'info', + upstreamCreatedAt: '2026-03-20T12:00:00Z', + upstreamUpdatedAt: '2026-03-20T12:01:00Z', + rawPayload: { + id: 12, + title: 'New model online', + content: 'gpt-4.1 is available', + read_at: '2026-03-20T12:05:00Z', + created_at: '2026-03-20T12:00:00Z', + updated_at: '2026-03-20T12:01:00Z', + }, + }, + ]); + }); }); diff --git a/src/server/services/platforms/sub2api.ts b/src/server/services/platforms/sub2api.ts index 34a58814..8ae4e804 100644 --- a/src/server/services/platforms/sub2api.ts +++ b/src/server/services/platforms/sub2api.ts @@ -4,6 +4,9 @@ import { CheckinResult, BalanceInfo, CreateApiTokenOptions, + SubscriptionPlanSummary, + SubscriptionSummary, + type SiteAnnouncement, UserInfo, } from './base.js'; @@ -21,6 +24,202 @@ function normalizeBaseUrl(baseUrl: string): string { export class Sub2ApiAdapter extends BasePlatformAdapter { readonly platformName = 'sub2api'; + private roundCurrency(value: number): number { + return Math.round(value * 1_000_000) / 1_000_000; + } + + private parsePositiveInteger(raw: unknown): number | undefined { + if (typeof raw === 'number' && Number.isFinite(raw) && raw > 0) return Math.trunc(raw); + if (typeof raw === 'string') { + const parsed = Number.parseInt(raw.trim(), 10); + if (!Number.isNaN(parsed) && parsed > 0) return parsed; + } + return undefined; + } + + private parseNonNegativeInteger(raw: unknown): number | undefined { + if (typeof raw === 'number' && Number.isFinite(raw) && raw >= 0) return Math.trunc(raw); + if (typeof raw === 'string') { + const parsed = Number.parseInt(raw.trim(), 10); + if (!Number.isNaN(parsed) && parsed >= 0) return parsed; + } + return undefined; + } + + private parseNonNegativeNumber(raw: unknown): number | undefined { + if (typeof raw === 'number' && Number.isFinite(raw) && raw >= 0) { + return this.roundCurrency(raw); + } + if (typeof raw === 'string') { + const parsed = Number(raw.trim()); + if (Number.isFinite(parsed) && parsed >= 0) { + return this.roundCurrency(parsed); + } + } + return undefined; + } + + private parseDateTime(raw: unknown): string | undefined { + if (typeof raw === 'number' && Number.isFinite(raw) && raw > 0) { + const ms = raw > 10_000_000_000 ? raw : raw * 1000; + return new Date(ms).toISOString(); + } + if (typeof raw !== 'string') return undefined; + const trimmed = raw.trim(); + if (!trimmed) return undefined; + + const numeric = Number(trimmed); + if (Number.isFinite(numeric) && numeric > 0) { + const ms = numeric > 10_000_000_000 ? numeric : numeric * 1000; + return new Date(ms).toISOString(); + } + + const parsed = Date.parse(trimmed); + if (Number.isFinite(parsed)) return new Date(parsed).toISOString(); + return undefined; + } + + private parseSubscriptionItem(raw: unknown): SubscriptionPlanSummary | null { + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null; + + const item = raw as Record; + const group = item.group && typeof item.group === 'object' && !Array.isArray(item.group) + ? item.group as Record + : null; + + const normalized: SubscriptionPlanSummary = {}; + + const id = this.parsePositiveInteger(item.id); + if (id) normalized.id = id; + + const groupId = this.parsePositiveInteger(item.group_id ?? item.groupId ?? group?.id); + if (groupId) normalized.groupId = groupId; + + const groupNameCandidates = [ + item.group_name, + item.groupName, + item.name, + item.title, + group?.name, + group?.title, + ]; + for (const candidate of groupNameCandidates) { + if (typeof candidate !== 'string') continue; + const trimmed = candidate.trim(); + if (!trimmed) continue; + normalized.groupName = trimmed; + break; + } + + if (typeof item.status === 'string' && item.status.trim()) { + normalized.status = item.status.trim(); + } + + const expiresAt = this.parseDateTime( + item.expires_at + ?? item.expiresAt + ?? item.expired_at + ?? item.expiredAt + ?? item.end_at + ?? item.endAt + ?? item.end_time + ?? item.endTime + ?? item.current_period_end + ?? item.currentPeriodEnd, + ); + if (expiresAt) normalized.expiresAt = expiresAt; + + const dailyUsedUsd = this.parseNonNegativeNumber(item.daily_used_usd ?? item.dailyUsedUsd); + if (dailyUsedUsd !== undefined) normalized.dailyUsedUsd = dailyUsedUsd; + + const dailyLimitUsd = this.parseNonNegativeNumber(item.daily_limit_usd ?? item.dailyLimitUsd); + if (dailyLimitUsd !== undefined) normalized.dailyLimitUsd = dailyLimitUsd; + + const weeklyUsedUsd = this.parseNonNegativeNumber(item.weekly_used_usd ?? item.weeklyUsedUsd); + if (weeklyUsedUsd !== undefined) normalized.weeklyUsedUsd = weeklyUsedUsd; + + const weeklyLimitUsd = this.parseNonNegativeNumber(item.weekly_limit_usd ?? item.weeklyLimitUsd); + if (weeklyLimitUsd !== undefined) normalized.weeklyLimitUsd = weeklyLimitUsd; + + const monthlyUsedUsd = this.parseNonNegativeNumber( + item.monthly_used_usd + ?? item.monthlyUsedUsd + ?? item.used_usd + ?? item.usedUsd + ?? item.total_used_usd + ?? item.totalUsedUsd, + ); + if (monthlyUsedUsd !== undefined) normalized.monthlyUsedUsd = monthlyUsedUsd; + + const monthlyLimitUsd = this.parseNonNegativeNumber( + item.monthly_limit_usd + ?? item.monthlyLimitUsd + ?? item.limit_usd + ?? item.limitUsd + ?? item.total_limit_usd + ?? item.totalLimitUsd, + ); + if (monthlyLimitUsd !== undefined) normalized.monthlyLimitUsd = monthlyLimitUsd; + + return Object.keys(normalized).length > 0 ? normalized : null; + } + + private parseSubscriptionItems(raw: unknown): SubscriptionPlanSummary[] { + const rawItems = (() => { + if (Array.isArray(raw)) return raw; + if (raw && typeof raw === 'object') { + const body = raw as Record; + if (Array.isArray(body.subscriptions)) return body.subscriptions; + if (Array.isArray(body.items)) return body.items; + if (Array.isArray(body.list)) return body.list; + if (Array.isArray(body.data)) return body.data; + } + return []; + })(); + + return rawItems + .map((item) => this.parseSubscriptionItem(item)) + .filter((item): item is SubscriptionPlanSummary => !!item); + } + + private buildSubscriptionSummary(payload: unknown): SubscriptionSummary { + const body = payload && typeof payload === 'object' && !Array.isArray(payload) + ? payload as Record + : {}; + const subscriptions = this.parseSubscriptionItems(payload); + const activeCount = this.parseNonNegativeInteger(body.active_count ?? body.activeCount); + const totalUsedUsd = this.parseNonNegativeNumber(body.total_used_usd ?? body.totalUsedUsd); + const inferredUsedUsd = subscriptions.reduce((sum, item) => sum + (item.monthlyUsedUsd || 0), 0); + + return { + activeCount: activeCount ?? subscriptions.length, + totalUsedUsd: totalUsedUsd ?? this.roundCurrency(inferredUsedUsd), + subscriptions, + }; + } + + private async fetchSubscriptionSummary(baseUrl: string, accessToken: string): Promise { + const headers = this.buildAuthHeader(accessToken); + const summaryEndpoint = '/api/v1/subscriptions/summary'; + + try { + const res = await this.fetchJson(`${baseUrl}${summaryEndpoint}`, { headers }); + const data = this.parseSub2ApiEnvelope(res, summaryEndpoint); + return this.buildSubscriptionSummary(data); + } catch {} + + const fallbackEndpoints = ['/api/v1/subscriptions/active']; + for (const endpoint of fallbackEndpoints) { + try { + const res = await this.fetchJson(`${baseUrl}${endpoint}`, { headers }); + const data = this.parseSub2ApiEnvelope(res, endpoint); + return this.buildSubscriptionSummary(data); + } catch {} + } + + return undefined; + } + private stripBearerPrefix(value?: string | null): string { const trimmed = (value || '').trim(); if (!trimmed) return ''; @@ -461,13 +660,18 @@ export class Sub2ApiAdapter extends BasePlatformAdapter { // --- Balance --- async getBalance(baseUrl: string, accessToken: string): Promise { - const user = await this.fetchAuthMe(baseUrl, accessToken); + const normalizedBase = normalizeBaseUrl(baseUrl); + const [user, subscriptionSummary] = await Promise.all([ + this.fetchAuthMe(normalizedBase, accessToken), + this.fetchSubscriptionSummary(normalizedBase, accessToken), + ]); const quotaValue = this.usdToQuota(user.balance); // Sub2API only provides current balance, no usage breakdown return { balance: quotaValue / 500000, used: 0, quota: quotaValue / 500000, + subscriptionSummary, }; } @@ -486,6 +690,41 @@ export class Sub2ApiAdapter extends BasePlatformAdapter { return this.fetchModelsByToken(normalizedBase, discoveredApiToken); } + override async getSiteAnnouncements(baseUrl: string, accessToken: string): Promise { + try { + const endpoint = '/api/v1/announcements?page=1&page_size=100'; + const res = await this.fetchJson(`${normalizeBaseUrl(baseUrl)}${endpoint}`, { + headers: this.buildAuthHeader(accessToken), + }); + const data = this.parseSub2ApiEnvelope(res, endpoint); + const rawItems = Array.isArray(data) + ? data + : (Array.isArray(data?.items) ? data.items : []); + const rows: SiteAnnouncement[] = []; + for (const item of rawItems) { + const id = Number.parseInt(String(item?.id), 10); + if (!Number.isFinite(id) || id <= 0) continue; + const title = typeof item?.title === 'string' ? item.title.trim() : ''; + const content = typeof item?.content === 'string' ? item.content.trim() : ''; + if (!title && !content) continue; + rows.push({ + sourceKey: `announcement:${id}`, + title: title || `Announcement ${id}`, + content: content || title, + level: 'info', + startsAt: typeof item?.starts_at === 'string' ? item.starts_at : undefined, + endsAt: typeof item?.ends_at === 'string' ? item.ends_at : undefined, + upstreamCreatedAt: typeof item?.created_at === 'string' ? item.created_at : undefined, + upstreamUpdatedAt: typeof item?.updated_at === 'string' ? item.updated_at : undefined, + rawPayload: item, + }); + } + return rows; + } catch { + return []; + } + } + override async getApiTokens(baseUrl: string, accessToken: string): Promise { try { const keys = await this.listApiKeys(normalizeBaseUrl(baseUrl), accessToken); diff --git a/src/server/services/platforms/titleHint.ts b/src/server/services/platforms/titleHint.ts index 57fe3def..35ee0239 100644 --- a/src/server/services/platforms/titleHint.ts +++ b/src/server/services/platforms/titleHint.ts @@ -44,10 +44,7 @@ function extractHtmlTitle(html: string): string { return match[1].replace(/\s+/g, ' ').trim(); } -export async function detectPlatformByTitle(url: string): Promise { - const base = normalizeBaseUrl(url); - if (!base) return undefined; - +async function detectPlatformByTitleOnce(base: string): Promise { try { const { fetch } = await import('undici'); const res = await fetch(`${base}/`, { @@ -71,3 +68,16 @@ export async function detectPlatformByTitle(url: string): Promise { + const base = normalizeBaseUrl(url); + if (!base) return undefined; + + const first = await detectPlatformByTitleOnce(base); + if (first) return first; + + // Under heavy parallel test load, local title probes can occasionally race + // with just-started ephemeral HTTP servers. Retry once before giving up. + await new Promise((resolve) => setTimeout(resolve, 50)); + return detectPlatformByTitleOnce(base); +} diff --git a/src/server/services/proxyChannelCoordinator.test.ts b/src/server/services/proxyChannelCoordinator.test.ts new file mode 100644 index 00000000..0ef47207 --- /dev/null +++ b/src/server/services/proxyChannelCoordinator.test.ts @@ -0,0 +1,149 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { config } from '../config.js'; +import { + proxyChannelCoordinator, + resetProxyChannelCoordinatorState, +} from './proxyChannelCoordinator.js'; + +describe('proxyChannelCoordinator', () => { + const originalStickyEnabled = config.proxyStickySessionEnabled; + const originalStickyTtlMs = config.proxyStickySessionTtlMs; + const originalConcurrencyLimit = config.proxySessionChannelConcurrencyLimit; + const originalQueueWaitMs = config.proxySessionChannelQueueWaitMs; + const originalLeaseTtlMs = config.proxySessionChannelLeaseTtlMs; + const originalLeaseKeepaliveMs = config.proxySessionChannelLeaseKeepaliveMs; + + beforeEach(() => { + vi.useFakeTimers(); + config.proxyStickySessionEnabled = true; + config.proxyStickySessionTtlMs = 31_000; + config.proxySessionChannelConcurrencyLimit = 1; + config.proxySessionChannelQueueWaitMs = 200; + config.proxySessionChannelLeaseTtlMs = 100; + config.proxySessionChannelLeaseKeepaliveMs = 30; + resetProxyChannelCoordinatorState(); + }); + + afterEach(() => { + config.proxyStickySessionEnabled = originalStickyEnabled; + config.proxyStickySessionTtlMs = originalStickyTtlMs; + config.proxySessionChannelConcurrencyLimit = originalConcurrencyLimit; + config.proxySessionChannelQueueWaitMs = originalQueueWaitMs; + config.proxySessionChannelLeaseTtlMs = originalLeaseTtlMs; + config.proxySessionChannelLeaseKeepaliveMs = originalLeaseKeepaliveMs; + resetProxyChannelCoordinatorState(); + vi.useRealTimers(); + }); + + it('stores sticky bindings for session-scoped channels and expires them by ttl', async () => { + const key = proxyChannelCoordinator.buildStickySessionKey({ + clientKind: 'codex', + sessionId: 'turn-123', + requestedModel: 'gpt-5.2', + downstreamPath: '/v1/responses', + downstreamApiKeyId: 9, + }); + + proxyChannelCoordinator.bindStickyChannel(key, 42, JSON.stringify({ credentialMode: 'session' })); + expect(proxyChannelCoordinator.getStickyChannelId(key)).toBe(42); + + await vi.advanceTimersByTimeAsync(31_100); + expect(proxyChannelCoordinator.getStickyChannelId(key)).toBeNull(); + }); + + it('does not store sticky bindings for apikey-only channels', () => { + const key = proxyChannelCoordinator.buildStickySessionKey({ + clientKind: 'codex', + sessionId: 'turn-456', + requestedModel: 'gpt-5.2', + downstreamPath: '/v1/responses', + downstreamApiKeyId: 9, + }); + + proxyChannelCoordinator.bindStickyChannel(key, 42, JSON.stringify({ credentialMode: 'apikey' })); + expect(proxyChannelCoordinator.getStickyChannelId(key)).toBeNull(); + }); + + it('queues requests behind the active lease and grants the next waiter after release', async () => { + const first = await proxyChannelCoordinator.acquireChannelLease({ + channelId: 11, + accountExtraConfig: JSON.stringify({ credentialMode: 'session' }), + }); + expect(first.status).toBe('acquired'); + if (first.status !== 'acquired') return; + + let secondSettled = false; + const secondPromise = proxyChannelCoordinator.acquireChannelLease({ + channelId: 11, + accountExtraConfig: JSON.stringify({ credentialMode: 'session' }), + }).then((result) => { + secondSettled = true; + return result; + }); + + await vi.advanceTimersByTimeAsync(50); + expect(secondSettled).toBe(false); + + first.lease.release(); + await vi.advanceTimersByTimeAsync(0); + + const second = await secondPromise; + expect(second.status).toBe('acquired'); + if (second.status === 'acquired') { + second.lease.release(); + } + }); + + it('times out queued requests when no slot becomes available', async () => { + const first = await proxyChannelCoordinator.acquireChannelLease({ + channelId: 11, + accountExtraConfig: JSON.stringify({ credentialMode: 'session' }), + }); + expect(first.status).toBe('acquired'); + if (first.status !== 'acquired') return; + + const secondPromise = proxyChannelCoordinator.acquireChannelLease({ + channelId: 11, + accountExtraConfig: JSON.stringify({ credentialMode: 'session' }), + }); + + await vi.advanceTimersByTimeAsync(250); + await expect(secondPromise).resolves.toEqual({ + status: 'timeout', + waitMs: 200, + }); + + first.lease.release(); + }); + + it('keeps active leases alive until they are explicitly released', async () => { + const first = await proxyChannelCoordinator.acquireChannelLease({ + channelId: 11, + accountExtraConfig: JSON.stringify({ credentialMode: 'session' }), + }); + expect(first.status).toBe('acquired'); + if (first.status !== 'acquired') return; + + let secondSettled = false; + const secondPromise = proxyChannelCoordinator.acquireChannelLease({ + channelId: 11, + accountExtraConfig: JSON.stringify({ credentialMode: 'session' }), + }).then((result) => { + secondSettled = true; + return result; + }); + + await vi.advanceTimersByTimeAsync(180); + expect(first.lease.isActive()).toBe(true); + expect(secondSettled).toBe(false); + + first.lease.release(); + await vi.advanceTimersByTimeAsync(0); + + const second = await secondPromise; + expect(second.status).toBe('acquired'); + if (second.status === 'acquired') { + second.lease.release(); + } + }); +}); diff --git a/src/server/services/proxyChannelCoordinator.ts b/src/server/services/proxyChannelCoordinator.ts new file mode 100644 index 00000000..debab793 --- /dev/null +++ b/src/server/services/proxyChannelCoordinator.ts @@ -0,0 +1,295 @@ +import { config } from '../config.js'; +import { + getCredentialModeFromExtraConfig, + hasOauthProvider, +} from './accountExtraConfig.js'; + +type StickyEntry = { + channelId: number; + expiresAtMs: number; +}; + +type ActiveLeaseState = { + release: () => void; +}; + +type ChannelWaiter = { + cancelled: boolean; + resolve: (result: AcquireProxyChannelLeaseResult) => void; + timer: ReturnType | null; +}; + +type ChannelRuntimeState = { + activeLeaseIds: Set; + queue: ChannelWaiter[]; +}; + +export type ProxyChannelLease = { + channelId: number; + isActive(): boolean; + release(): void; + touch(): void; +}; + +export type AcquireProxyChannelLeaseResult = + | { status: 'acquired'; lease: ProxyChannelLease } + | { status: 'timeout'; waitMs: number }; + +const stickySessionBindings = new Map(); +const channelRuntimeStates = new Map(); +let nextLeaseId = 1; + +function shouldUnrefTimer(timer: ReturnType | ReturnType) { + if (typeof (timer as { unref?: () => void }).unref === 'function') { + (timer as { unref: () => void }).unref(); + } +} + +function cleanupExpiredStickyBindings(nowMs = Date.now()): void { + for (const [key, entry] of stickySessionBindings.entries()) { + if (entry.expiresAtMs <= nowMs) { + stickySessionBindings.delete(key); + } + } +} + +function isSessionScopedChannel(extraConfig?: string | null): boolean { + return getCredentialModeFromExtraConfig(extraConfig) === 'session' + || hasOauthProvider(extraConfig); +} + +function getStickySessionTtlMs(): number { + return Math.max(30_000, Math.trunc(config.proxyStickySessionTtlMs || 0)); +} + +function getChannelLeaseTtlMs(): number { + return Math.max(5_000, Math.trunc(config.proxySessionChannelLeaseTtlMs || 0)); +} + +function getChannelLeaseKeepaliveMs(): number { + return Math.max(1_000, Math.trunc(config.proxySessionChannelLeaseKeepaliveMs || 0)); +} + +function getChannelQueueWaitMs(): number { + return Math.max(0, Math.trunc(config.proxySessionChannelQueueWaitMs || 0)); +} + +function getChannelConcurrencyLimit(extraConfig?: string | null): number { + if (!isSessionScopedChannel(extraConfig)) return 0; + return Math.max(0, Math.trunc(config.proxySessionChannelConcurrencyLimit || 0)); +} + +function getOrCreateChannelRuntimeState(channelId: number): ChannelRuntimeState { + let state = channelRuntimeStates.get(channelId); + if (!state) { + state = { + activeLeaseIds: new Set(), + queue: [], + }; + channelRuntimeStates.set(channelId, state); + } + return state; +} + +function maybeDeleteChannelRuntimeState(channelId: number): void { + const state = channelRuntimeStates.get(channelId); + if (!state) return; + if (state.activeLeaseIds.size <= 0 && state.queue.every((waiter) => waiter.cancelled)) { + channelRuntimeStates.delete(channelId); + } +} + +function createNoopLease(channelId: number): ProxyChannelLease { + return { + channelId, + isActive: () => false, + release: () => {}, + touch: () => {}, + }; +} + +class ProxyChannelCoordinator { + buildStickySessionKey(input: { + clientKind?: string | null; + sessionId?: string | null; + requestedModel: string; + downstreamPath: string; + downstreamApiKeyId?: number | null; + }): string | null { + if (!config.proxyStickySessionEnabled) return null; + const sessionId = String(input.sessionId || '').trim(); + if (!sessionId) return null; + const requestedModel = String(input.requestedModel || '').trim().toLowerCase(); + if (!requestedModel) return null; + const downstreamPath = String(input.downstreamPath || '').trim().toLowerCase() || 'unknown'; + const clientKind = String(input.clientKind || 'generic').trim().toLowerCase() || 'generic'; + const owner = typeof input.downstreamApiKeyId === 'number' && Number.isFinite(input.downstreamApiKeyId) + ? `key:${Math.trunc(input.downstreamApiKeyId)}` + : 'key:anonymous'; + return [owner, clientKind, downstreamPath, requestedModel, sessionId].join('|'); + } + + getStickyChannelId(stickySessionKey?: string | null, nowMs = Date.now()): number | null { + cleanupExpiredStickyBindings(nowMs); + const normalizedKey = String(stickySessionKey || '').trim(); + if (!normalizedKey) return null; + const entry = stickySessionBindings.get(normalizedKey); + if (!entry || entry.expiresAtMs <= nowMs) { + stickySessionBindings.delete(normalizedKey); + return null; + } + return entry.channelId; + } + + bindStickyChannel(stickySessionKey: string | null | undefined, channelId: number, extraConfig?: string | null): void { + if (!config.proxyStickySessionEnabled) return; + if (!isSessionScopedChannel(extraConfig)) return; + const normalizedKey = String(stickySessionKey || '').trim(); + if (!normalizedKey || !Number.isFinite(channelId) || channelId <= 0) return; + cleanupExpiredStickyBindings(); + stickySessionBindings.set(normalizedKey, { + channelId: Math.trunc(channelId), + expiresAtMs: Date.now() + getStickySessionTtlMs(), + }); + } + + clearStickyChannel(stickySessionKey?: string | null, channelId?: number | null): void { + const normalizedKey = String(stickySessionKey || '').trim(); + if (!normalizedKey) return; + const existing = stickySessionBindings.get(normalizedKey); + if (!existing) return; + if (typeof channelId === 'number' && Number.isFinite(channelId) && existing.channelId !== Math.trunc(channelId)) { + return; + } + stickySessionBindings.delete(normalizedKey); + } + + async acquireChannelLease(input: { + channelId: number; + accountExtraConfig?: string | null; + }): Promise { + const channelId = Math.trunc(input.channelId || 0); + if (channelId <= 0) { + return { + status: 'acquired', + lease: createNoopLease(0), + }; + } + + const concurrencyLimit = getChannelConcurrencyLimit(input.accountExtraConfig); + if (concurrencyLimit <= 0) { + return { + status: 'acquired', + lease: createNoopLease(channelId), + }; + } + + const state = getOrCreateChannelRuntimeState(channelId); + if (state.activeLeaseIds.size < concurrencyLimit) { + return { + status: 'acquired', + lease: this.createTrackedLease(channelId, state), + }; + } + + const waitMs = getChannelQueueWaitMs(); + if (waitMs <= 0) { + return { + status: 'timeout', + waitMs: 0, + }; + } + + return await new Promise((resolve) => { + const waiter: ChannelWaiter = { + cancelled: false, + resolve, + timer: null, + }; + waiter.timer = setTimeout(() => { + waiter.cancelled = true; + waiter.timer = null; + maybeDeleteChannelRuntimeState(channelId); + resolve({ + status: 'timeout', + waitMs, + }); + }, waitMs); + shouldUnrefTimer(waiter.timer); + state.queue.push(waiter); + }); + } + + private createTrackedLease(channelId: number, state: ChannelRuntimeState): ProxyChannelLease { + const leaseId = nextLeaseId++; + state.activeLeaseIds.add(leaseId); + + let released = false; + let expiryTimer: ReturnType | null = null; + let keepaliveTimer: ReturnType | null = null; + + const release = () => { + if (released) return; + released = true; + if (expiryTimer) clearTimeout(expiryTimer); + if (keepaliveTimer) clearInterval(keepaliveTimer); + state.activeLeaseIds.delete(leaseId); + this.drainQueue(channelId); + maybeDeleteChannelRuntimeState(channelId); + }; + + const touch = () => { + if (released) return; + if (expiryTimer) clearTimeout(expiryTimer); + expiryTimer = setTimeout(() => { + release(); + }, getChannelLeaseTtlMs()); + shouldUnrefTimer(expiryTimer); + }; + + touch(); + + const keepaliveMs = getChannelLeaseKeepaliveMs(); + if (keepaliveMs > 0) { + keepaliveTimer = setInterval(() => { + touch(); + }, keepaliveMs); + shouldUnrefTimer(keepaliveTimer); + } + + return { + channelId, + isActive: () => !released, + release, + touch, + }; + } + + private drainQueue(channelId: number): void { + const state = channelRuntimeStates.get(channelId); + if (!state) return; + const concurrencyLimit = Math.max(0, Math.trunc(config.proxySessionChannelConcurrencyLimit || 0)); + while (state.activeLeaseIds.size < concurrencyLimit && state.queue.length > 0) { + const waiter = state.queue.shift(); + if (!waiter || waiter.cancelled) continue; + if (waiter.timer) clearTimeout(waiter.timer); + waiter.timer = null; + waiter.resolve({ + status: 'acquired', + lease: this.createTrackedLease(channelId, state), + }); + } + } +} + +export function resetProxyChannelCoordinatorState(): void { + stickySessionBindings.clear(); + channelRuntimeStates.clear(); + nextLeaseId = 1; +} + +export function isProxyChannelSessionScoped(extraConfig?: string | null): boolean { + return isSessionScopedChannel(extraConfig); +} + +export const proxyChannelCoordinator = new ProxyChannelCoordinator(); diff --git a/src/server/services/proxyChannelRetry.test.ts b/src/server/services/proxyChannelRetry.test.ts new file mode 100644 index 00000000..1381eace --- /dev/null +++ b/src/server/services/proxyChannelRetry.test.ts @@ -0,0 +1,37 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { buildConfig, config } from '../config.js'; +import { + canRetryProxyChannel, + getProxyMaxChannelAttempts, + getProxyMaxChannelRetries, +} from './proxyChannelRetry.js'; + +const originalProxyMaxChannelAttempts = config.proxyMaxChannelAttempts; + +afterEach(() => { + config.proxyMaxChannelAttempts = originalProxyMaxChannelAttempts; +}); + +describe('proxyChannelRetry', () => { + it('parses proxy max channel attempts from config with a safer default', () => { + expect(buildConfig({} as NodeJS.ProcessEnv).proxyMaxChannelAttempts).toBe(3); + expect(buildConfig({ PROXY_MAX_CHANNEL_ATTEMPTS: '3' } as NodeJS.ProcessEnv).proxyMaxChannelAttempts).toBe(3); + }); + + it('derives retry budget from total channel attempts', () => { + config.proxyMaxChannelAttempts = 5; + + expect(getProxyMaxChannelAttempts()).toBe(5); + expect(getProxyMaxChannelRetries()).toBe(4); + expect(canRetryProxyChannel(3)).toBe(true); + expect(canRetryProxyChannel(4)).toBe(false); + }); + + it('clamps invalid runtime config to at least one channel attempt', () => { + config.proxyMaxChannelAttempts = 0; + + expect(getProxyMaxChannelAttempts()).toBe(1); + expect(getProxyMaxChannelRetries()).toBe(0); + expect(canRetryProxyChannel(0)).toBe(false); + }); +}); diff --git a/src/server/services/proxyChannelRetry.ts b/src/server/services/proxyChannelRetry.ts new file mode 100644 index 00000000..42fafa63 --- /dev/null +++ b/src/server/services/proxyChannelRetry.ts @@ -0,0 +1,14 @@ +import { config } from '../config.js'; + +export function getProxyMaxChannelAttempts(): number { + const attempts = Math.trunc(config.proxyMaxChannelAttempts || 0); + return attempts > 0 ? attempts : 1; +} + +export function getProxyMaxChannelRetries(): number { + return Math.max(0, getProxyMaxChannelAttempts() - 1); +} + +export function canRetryProxyChannel(retryCount: number): boolean { + return retryCount < getProxyMaxChannelRetries(); +} diff --git a/src/server/services/proxyFileRetentionService.test.ts b/src/server/services/proxyFileRetentionService.test.ts new file mode 100644 index 00000000..c139573c --- /dev/null +++ b/src/server/services/proxyFileRetentionService.test.ts @@ -0,0 +1,112 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; +import { mkdtempSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +type DbModule = typeof import('../db/index.js'); +type StoreModule = typeof import('./proxyFileStore.js'); +type RetentionModule = typeof import('./proxyFileRetentionService.js'); +type ConfigModule = typeof import('../config.js'); + +describe('proxyFileRetentionService', () => { + let db: DbModule['db']; + let schema: DbModule['schema']; + let store: StoreModule; + let retention: RetentionModule; + let config: ConfigModule['config']; + let dataDir = ''; + let originalRetentionDays = 0; + let originalPruneIntervalMinutes = 0; + + beforeAll(async () => { + dataDir = mkdtempSync(join(tmpdir(), 'metapi-proxy-file-retention-')); + process.env.DATA_DIR = dataDir; + + await import('../db/migrate.js'); + const dbModule = await import('../db/index.js'); + const storeModule = await import('./proxyFileStore.js'); + const retentionModule = await import('./proxyFileRetentionService.js'); + const configModule = await import('../config.js'); + db = dbModule.db; + schema = dbModule.schema; + store = storeModule; + retention = retentionModule; + config = configModule.config; + originalRetentionDays = config.proxyFileRetentionDays; + originalPruneIntervalMinutes = config.proxyFileRetentionPruneIntervalMinutes; + }); + + beforeEach(async () => { + config.proxyFileRetentionDays = originalRetentionDays; + config.proxyFileRetentionPruneIntervalMinutes = originalPruneIntervalMinutes; + await db.delete(schema.proxyFiles).run(); + }); + + afterAll(async () => { + config.proxyFileRetentionDays = originalRetentionDays; + config.proxyFileRetentionPruneIntervalMinutes = originalPruneIntervalMinutes; + const dbModule = await import('../db/index.js'); + await dbModule.closeDbConnections(); + delete process.env.DATA_DIR; + }); + + it('returns disabled when proxy file retention is turned off', async () => { + config.proxyFileRetentionDays = 0; + + const result = await retention.cleanupExpiredProxyFiles(Date.parse('2026-03-20T00:00:00Z')); + + expect(result).toEqual({ + enabled: false, + retentionDays: 0, + cutoffUtc: null, + deleted: 0, + }); + }); + + it('purges files older than the configured retention window', async () => { + config.proxyFileRetentionDays = 7; + + await db.insert(schema.proxyFiles).values([ + { + publicId: 'file-metapi-old', + ownerType: 'global_proxy_token', + ownerId: 'global', + filename: 'old.txt', + mimeType: 'text/plain', + purpose: 'assistants', + byteSize: 3, + sha256: 'old-hash', + contentBase64: Buffer.from('old').toString('base64'), + createdAt: '2026-03-10 00:00:00', + updatedAt: '2026-03-10 00:00:00', + deletedAt: null, + }, + { + publicId: 'file-metapi-new', + ownerType: 'global_proxy_token', + ownerId: 'global', + filename: 'new.txt', + mimeType: 'text/plain', + purpose: 'assistants', + byteSize: 3, + sha256: 'new-hash', + contentBase64: Buffer.from('new').toString('base64'), + createdAt: '2026-03-18 00:00:00', + updatedAt: '2026-03-18 00:00:00', + deletedAt: null, + }, + ]).run(); + + const result = await retention.cleanupExpiredProxyFiles(Date.parse('2026-03-20T00:00:00Z')); + + expect(result).toEqual({ + enabled: true, + retentionDays: 7, + cutoffUtc: '2026-03-13 00:00:00', + deleted: 1, + }); + + const remaining = await store.listProxyFilesByOwner({ ownerType: 'global_proxy_token', ownerId: 'global' }); + expect(remaining.map((item) => item.publicId)).toEqual(['file-metapi-new']); + }); +}); diff --git a/src/server/services/proxyFileRetentionService.ts b/src/server/services/proxyFileRetentionService.ts new file mode 100644 index 00000000..8b5cd62a --- /dev/null +++ b/src/server/services/proxyFileRetentionService.ts @@ -0,0 +1,63 @@ +import { config } from '../config.js'; +import { getLogCleanupCutoffUtc } from './logCleanupService.js'; +import { purgeExpiredProxyFiles } from './proxyFileStore.js'; + +let retentionTimer: ReturnType | null = null; + +export function getProxyFileRetentionCutoffUtc(nowMs = Date.now()): string | null { + const days = Math.max(0, Math.trunc(config.proxyFileRetentionDays)); + if (days <= 0) return null; + return getLogCleanupCutoffUtc(days, nowMs); +} + +export async function cleanupExpiredProxyFiles(nowMs = Date.now()): Promise<{ + enabled: boolean; + retentionDays: number; + cutoffUtc: string | null; + deleted: number; +}> { + const retentionDays = Math.max(0, Math.trunc(config.proxyFileRetentionDays)); + const cutoffUtc = getProxyFileRetentionCutoffUtc(nowMs); + if (!cutoffUtc) { + return { + enabled: false, + retentionDays, + cutoffUtc: null, + deleted: 0, + }; + } + + const deleted = await purgeExpiredProxyFiles(cutoffUtc); + return { + enabled: true, + retentionDays, + cutoffUtc, + deleted, + }; +} + +export function startProxyFileRetentionService(): void { + if (retentionTimer) return; + + const intervalMinutes = Math.max(1, Math.trunc(config.proxyFileRetentionPruneIntervalMinutes)); + const intervalMs = intervalMinutes * 60 * 1000; + const runCleanup = async () => { + try { + const result = await cleanupExpiredProxyFiles(); + if (!result.enabled || result.deleted <= 0) return; + console.info(`[proxy-file-retention] deleted ${result.deleted} files before ${result.cutoffUtc}`); + } catch (error) { + console.warn('[proxy-file-retention] cleanup failed', error); + } + }; + + void runCleanup(); + retentionTimer = setInterval(() => { void runCleanup(); }, intervalMs); + retentionTimer.unref?.(); +} + +export function stopProxyFileRetentionService(): void { + if (!retentionTimer) return; + clearInterval(retentionTimer); + retentionTimer = null; +} diff --git a/src/server/services/proxyFileStore.test.ts b/src/server/services/proxyFileStore.test.ts index 8f7431d4..05e0a7bb 100644 --- a/src/server/services/proxyFileStore.test.ts +++ b/src/server/services/proxyFileStore.test.ts @@ -112,4 +112,45 @@ describe('proxyFileStore', () => { }); expect(loaded).toBeNull(); }); + + it('purges expired proxy files before a cutoff to control database growth', async () => { + await db.insert(schema.proxyFiles).values([ + { + publicId: 'file-metapi-old', + ownerType: 'global_proxy_token', + ownerId: 'global', + filename: 'old.txt', + mimeType: 'text/plain', + purpose: 'assistants', + byteSize: 3, + sha256: 'old-hash', + contentBase64: Buffer.from('old').toString('base64'), + createdAt: '2026-03-01 00:00:00', + updatedAt: '2026-03-01 00:00:00', + deletedAt: null, + }, + { + publicId: 'file-metapi-new', + ownerType: 'global_proxy_token', + ownerId: 'global', + filename: 'new.txt', + mimeType: 'text/plain', + purpose: 'assistants', + byteSize: 3, + sha256: 'new-hash', + contentBase64: Buffer.from('new').toString('base64'), + createdAt: '2026-03-19 00:00:00', + updatedAt: '2026-03-19 00:00:00', + deletedAt: null, + }, + ]).run(); + + const purgeExpiredProxyFiles = (store as Record).purgeExpiredProxyFiles; + const deleted = await purgeExpiredProxyFiles?.('2026-03-10 00:00:00'); + + expect(deleted).toBe(1); + + const remainingRows = await db.select().from(schema.proxyFiles).all(); + expect(remainingRows.map((row) => row.publicId)).toEqual(['file-metapi-new']); + }); }); diff --git a/src/server/services/proxyFileStore.ts b/src/server/services/proxyFileStore.ts index 13241693..63ca312c 100644 --- a/src/server/services/proxyFileStore.ts +++ b/src/server/services/proxyFileStore.ts @@ -1,4 +1,4 @@ -import { and, desc, eq, isNull, or } from 'drizzle-orm'; +import { and, desc, eq, isNull, lt, or } from 'drizzle-orm'; import { createHash } from 'node:crypto'; import { db, schema } from '../db/index.js'; import type { ProxyResourceOwner } from '../middleware/auth.js'; @@ -204,3 +204,17 @@ export async function softDeleteProxyFile(publicId: string): Promise { .run(); return Number(result?.changes || 0) > 0; } + +export async function purgeExpiredProxyFiles(cutoffUtc: string): Promise { + const normalizedCutoff = cutoffUtc.trim(); + if (!normalizedCutoff) return 0; + + const result = await db.delete(schema.proxyFiles) + .where(or( + lt(schema.proxyFiles.createdAt, normalizedCutoff), + and(isNull(schema.proxyFiles.createdAt), lt(schema.proxyFiles.updatedAt, normalizedCutoff)), + )) + .run(); + + return Number(result?.changes || 0); +} diff --git a/src/server/services/proxyInputFileResolver.test.ts b/src/server/services/proxyInputFileResolver.test.ts index 5c1339be..9b68bc9e 100644 --- a/src/server/services/proxyInputFileResolver.test.ts +++ b/src/server/services/proxyInputFileResolver.test.ts @@ -2,10 +2,13 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; const getProxyFileByPublicIdForOwnerMock = vi.fn(); -vi.mock('./proxyFileStore.js', () => ({ - getProxyFileByPublicIdForOwner: (...args: unknown[]) => getProxyFileByPublicIdForOwnerMock(...args), - LOCAL_PROXY_FILE_ID_PREFIX: 'file-metapi-', -})); +vi.mock('./proxyFileStore.js', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + getProxyFileByPublicIdForOwner: (...args: unknown[]) => getProxyFileByPublicIdForOwnerMock(...args), + }; +}); describe('proxyInputFileResolver', () => { beforeEach(() => { @@ -37,7 +40,7 @@ describe('proxyInputFileResolver', () => { expect(getProxyFileByPublicIdForOwnerMock).not.toHaveBeenCalled(); }); - it('resolves object-form responses input payloads with local file ids', async () => { + it('resolves object-form responses input payloads with local file ids into inline-only uploads', async () => { getProxyFileByPublicIdForOwnerMock.mockResolvedValue({ publicId: 'file-metapi-123', filename: 'brief.pdf', @@ -69,7 +72,6 @@ describe('proxyInputFileResolver', () => { content: [ { type: 'input_file', - file_id: 'file-metapi-123', filename: 'brief.pdf', file_data: `data:application/pdf;base64,${Buffer.from('%PDF-local').toString('base64')}`, }, @@ -77,4 +79,93 @@ describe('proxyInputFileResolver', () => { }, }); }); + + it('exports generic inline local file resolution for route-level callers', async () => { + getProxyFileByPublicIdForOwnerMock.mockResolvedValue({ + publicId: 'file-metapi-abc', + filename: 'notes.md', + mimeType: 'text/markdown', + contentBase64: Buffer.from('# hello').toString('base64'), + }); + + const { inlineLocalInputFileReferences } = await import('./proxyInputFileResolver.js'); + await expect(inlineLocalInputFileReferences( + { + model: 'gpt-5', + messages: [ + { + role: 'user', + content: [ + { + type: 'file', + file: { + file_id: 'file-metapi-abc', + }, + }, + ], + }, + ], + }, + { ownerType: 'managed_key', ownerId: '7' }, + )).resolves.toEqual({ + model: 'gpt-5', + messages: [ + { + role: 'user', + content: [ + { + type: 'file', + file: { + file_data: Buffer.from('# hello').toString('base64'), + filename: 'notes.md', + mime_type: 'text/markdown', + }, + }, + ], + }, + ], + }); + }); + + it('falls back from application/octet-stream to filename-based mime inference', async () => { + const { inlineLocalInputFileReferences } = await import('./proxyInputFileResolver.js'); + await expect(inlineLocalInputFileReferences( + { + model: 'gpt-5', + messages: [ + { + role: 'user', + content: [ + { + type: 'file', + file: { + filename: 'paper.pdf', + mime_type: 'application/octet-stream', + file_data: Buffer.from('%PDF-octet').toString('base64'), + }, + }, + ], + }, + ], + }, + { ownerType: 'managed_key', ownerId: '7' }, + )).resolves.toEqual({ + model: 'gpt-5', + messages: [ + { + role: 'user', + content: [ + { + type: 'file', + file: { + file_data: Buffer.from('%PDF-octet').toString('base64'), + filename: 'paper.pdf', + mime_type: 'application/pdf', + }, + }, + ], + }, + ], + }); + }); }); diff --git a/src/server/services/proxyInputFileResolver.ts b/src/server/services/proxyInputFileResolver.ts index b610e8b5..80df2cf7 100644 --- a/src/server/services/proxyInputFileResolver.ts +++ b/src/server/services/proxyInputFileResolver.ts @@ -1,6 +1,11 @@ import type { ProxyResourceOwner } from '../middleware/auth.js'; import { getProxyFileByPublicIdForOwner, LOCAL_PROXY_FILE_ID_PREFIX } from './proxyFileStore.js'; import { ensureBase64DataUrl } from '../transformers/shared/inputFile.js'; +import { summarizeConversationFileInputsInOpenAiBody } from '../proxy-core/capabilities/conversationFileCapabilities.js'; +import { + isSupportedConversationFileMimeType, + resolveConversationFileMimeType, +} from '../../shared/conversationFileTypes.js'; function isRecord(value: unknown): value is Record { return !!value && typeof value === 'object' && !Array.isArray(value); @@ -22,31 +27,8 @@ function cloneJsonValue(value: T): T { return value; } -function inferMimeTypeFromFilename(filename: string): string { - const normalized = filename.toLowerCase(); - if (normalized.endsWith('.pdf')) return 'application/pdf'; - if (normalized.endsWith('.txt')) return 'text/plain'; - if (normalized.endsWith('.md') || normalized.endsWith('.markdown')) return 'text/markdown'; - if (normalized.endsWith('.json')) return 'application/json'; - if (normalized.endsWith('.png')) return 'image/png'; - if (normalized.endsWith('.jpg') || normalized.endsWith('.jpeg')) return 'image/jpeg'; - if (normalized.endsWith('.gif')) return 'image/gif'; - if (normalized.endsWith('.webp')) return 'image/webp'; - if (normalized.endsWith('.wav')) return 'audio/wav'; - if (normalized.endsWith('.mp3')) return 'audio/mpeg'; - if (normalized.endsWith('.m4a')) return 'audio/mp4'; - if (normalized.endsWith('.ogg')) return 'audio/ogg'; - if (normalized.endsWith('.webm')) return 'audio/webm'; - return 'application/octet-stream'; -} - function isSupportedMimeType(mimeType: string): boolean { - return mimeType === 'application/pdf' - || mimeType === 'text/plain' - || mimeType === 'text/markdown' - || mimeType === 'application/json' - || mimeType.startsWith('image/') - || mimeType.startsWith('audio/'); + return isSupportedConversationFileMimeType(mimeType); } function audioFormatFromMimeType(mimeType: string, filename: string): string { @@ -117,7 +99,7 @@ async function resolveInputFileLike(fileLike: InputFileLike, owner: ProxyResourc }> { if (fileLike.fileData) { const filename = fileLike.filename || 'upload.bin'; - const mimeType = fileLike.mimeType || inferMimeTypeFromFilename(filename); + const mimeType = resolveConversationFileMimeType(fileLike.mimeType, filename); if (!isSupportedMimeType(mimeType)) { throw new ProxyInputFileResolutionError(400, `unsupported file mime type: ${mimeType}`); } @@ -134,12 +116,14 @@ async function resolveInputFileLike(fileLike: InputFileLike, owner: ProxyResourc if (!stored) { throw new ProxyInputFileResolutionError(404, `file not found: ${fileLike.fileId}`, 'not_found_error'); } - const mimeType = fileLike.mimeType || stored.mimeType || inferMimeTypeFromFilename(stored.filename); + const mimeType = resolveConversationFileMimeType( + fileLike.mimeType || stored.mimeType, + stored.filename, + ); if (!isSupportedMimeType(mimeType)) { throw new ProxyInputFileResolutionError(400, `unsupported file mime type: ${mimeType}`); } return { - fileId: fileLike.fileId, filename: fileLike.filename || stored.filename, fileData: stored.contentBase64, mimeType, @@ -147,7 +131,7 @@ async function resolveInputFileLike(fileLike: InputFileLike, owner: ProxyResourc } const filename = fileLike.filename || 'upload.bin'; - const mimeType = fileLike.mimeType || inferMimeTypeFromFilename(filename); + const mimeType = resolveConversationFileMimeType(fileLike.mimeType, filename); if (!fileLike.fileData) { throw new ProxyInputFileResolutionError(400, `file_data is required for inline file block: ${filename}`); } @@ -212,7 +196,7 @@ function toResponsesResolvedBlock(file: { fileId?: string; filename: string; fil } return { type: 'input_file', - ...(file.fileId ? { file_id: file.fileId } : {}), + ...(!file.fileData && file.fileId ? { file_id: file.fileId } : {}), filename: file.filename, file_data: ensureBase64DataUrl(file.fileData, file.mimeType), }; @@ -238,6 +222,36 @@ async function resolveResponsesMessageContent(content: unknown, owner: ProxyReso })); } +export async function inlineLocalInputFileReferences( + value: unknown, + owner: ProxyResourceOwner, +): Promise { + if (Array.isArray(value)) { + return Promise.all(value.map((item) => inlineLocalInputFileReferences(item, owner))); + } + + if (!isRecord(value)) return cloneJsonValue(value); + + const fileLike = normalizeInputFileLike(value); + if (fileLike && shouldResolveInlineFileLike(fileLike)) { + const resolved = await resolveInputFileLike(fileLike, owner); + const type = asTrimmedString(value.type).toLowerCase(); + if (type === 'file') { + return toOpenAiResolvedBlock(resolved); + } + if (type === 'input_file') { + return toResponsesResolvedBlock(resolved); + } + } + + const entries = await Promise.all( + Object.entries(value).map(async ([key, entryValue]) => ( + [key, await inlineLocalInputFileReferences(entryValue, owner)] as const + )), + ); + return Object.fromEntries(entries); +} + export async function resolveOpenAiBodyInputFiles( body: Record, owner: ProxyResourceOwner, @@ -306,15 +320,5 @@ export async function resolveResponsesBodyInputFiles( } export function hasNonImageFileInputInOpenAiBody(body: Record): boolean { - const messages = Array.isArray(body.messages) ? body.messages : []; - return messages.some((message) => { - if (!isRecord(message)) return false; - const content = message.content; - if (isRecord(content)) { - const type = asTrimmedString(content.type).toLowerCase(); - return type === 'file' || type === 'input_file'; - } - if (!Array.isArray(content)) return false; - return content.some((item) => isRecord(item) && ['file', 'input_file'].includes(asTrimmedString(item.type).toLowerCase())); - }); + return summarizeConversationFileInputsInOpenAiBody(body).hasDocument; } diff --git a/src/server/services/proxyLogRetentionService.ts b/src/server/services/proxyLogRetentionService.ts index 259a4546..ce6da66f 100644 --- a/src/server/services/proxyLogRetentionService.ts +++ b/src/server/services/proxyLogRetentionService.ts @@ -1,16 +1,12 @@ -import { lt } from 'drizzle-orm'; import { config } from '../config.js'; -import { db, schema } from '../db/index.js'; -import { formatUtcSqlDateTime } from './localTimeService.js'; - -const DAY_MS = 24 * 60 * 60 * 1000; +import { cleanupUsageLogs, getLogCleanupCutoffUtc } from './logCleanupService.js'; let retentionTimer: ReturnType | null = null; export function getProxyLogRetentionCutoffUtc(nowMs = Date.now()): string | null { const days = Math.max(0, Math.trunc(config.proxyLogRetentionDays)); if (days <= 0) return null; - return formatUtcSqlDateTime(new Date(nowMs - days * DAY_MS)); + return getLogCleanupCutoffUtc(days, nowMs); } export async function cleanupExpiredProxyLogs(nowMs = Date.now()): Promise<{ @@ -30,10 +26,7 @@ export async function cleanupExpiredProxyLogs(nowMs = Date.now()): Promise<{ }; } - const deleted = (await db.delete(schema.proxyLogs) - .where(lt(schema.proxyLogs.createdAt, cutoffUtc)) - .run()) - .changes; + const { deleted } = await cleanupUsageLogs(retentionDays, nowMs); return { enabled: true, @@ -68,3 +61,11 @@ export function stopProxyLogRetentionService(): void { clearInterval(retentionTimer); retentionTimer = null; } + +export function setLegacyProxyLogRetentionFallbackEnabled(enabled: boolean): void { + if (enabled) { + startProxyLogRetentionService(); + return; + } + stopProxyLogRetentionService(); +} diff --git a/src/server/services/proxyLogStore.test.ts b/src/server/services/proxyLogStore.test.ts index 0f57c6c1..358a39d6 100644 --- a/src/server/services/proxyLogStore.test.ts +++ b/src/server/services/proxyLogStore.test.ts @@ -2,12 +2,16 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; const { hasProxyLogBillingDetailsColumnMock, + hasProxyLogClientColumnsMock, + hasProxyLogDownstreamApiKeyIdColumnMock, dbInsertMock, dbInsertValuesMock, dbInsertRunMock, proxyLogsSchema, } = vi.hoisted(() => ({ hasProxyLogBillingDetailsColumnMock: vi.fn(), + hasProxyLogClientColumnsMock: vi.fn(), + hasProxyLogDownstreamApiKeyIdColumnMock: vi.fn(), dbInsertMock: vi.fn(), dbInsertValuesMock: vi.fn(), dbInsertRunMock: vi.fn(), @@ -26,6 +30,10 @@ const { totalTokens: 'total_tokens', estimatedCost: 'estimated_cost', billingDetails: 'billing_details', + clientFamily: 'client_family', + clientAppId: 'client_app_id', + clientAppName: 'client_app_name', + clientConfidence: 'client_confidence', errorMessage: 'error_message', retryCount: 'retry_count', createdAt: 'created_at', @@ -40,16 +48,23 @@ vi.mock('../db/index.js', () => ({ proxyLogs: proxyLogsSchema, }, hasProxyLogBillingDetailsColumn: (...args: unknown[]) => hasProxyLogBillingDetailsColumnMock(...args), + hasProxyLogClientColumns: (...args: unknown[]) => hasProxyLogClientColumnsMock(...args), + hasProxyLogDownstreamApiKeyIdColumn: (...args: unknown[]) => hasProxyLogDownstreamApiKeyIdColumnMock(...args), })); -import { insertProxyLog, withProxyLogSelectFields } from './proxyLogStore.js'; +import { insertProxyLog, parseProxyLogBillingDetails, withProxyLogSelectFields } from './proxyLogStore.js'; describe('proxyLogStore', () => { beforeEach(() => { hasProxyLogBillingDetailsColumnMock.mockReset(); + hasProxyLogClientColumnsMock.mockReset(); + hasProxyLogDownstreamApiKeyIdColumnMock.mockReset(); dbInsertMock.mockReset(); dbInsertValuesMock.mockReset(); dbInsertRunMock.mockReset(); + hasProxyLogBillingDetailsColumnMock.mockResolvedValue(false); + hasProxyLogClientColumnsMock.mockResolvedValue(false); + hasProxyLogDownstreamApiKeyIdColumnMock.mockResolvedValue(false); dbInsertMock.mockReturnValue({ values: (...args: unknown[]) => dbInsertValuesMock(...args), @@ -74,6 +89,16 @@ describe('proxyLogStore', () => { expect(runner.mock.calls[1][0].fields.billingDetails).toBeUndefined(); }); + it('accepts parsed billing details objects for helper-level callers', () => { + expect(parseProxyLogBillingDetails({ + source: 'pricing', + usd: 1.25, + })).toEqual({ + source: 'pricing', + usd: 1.25, + }); + }); + it('retries proxy log inserts without billing details when the column is missing', async () => { hasProxyLogBillingDetailsColumnMock.mockResolvedValue(true); dbInsertRunMock @@ -95,4 +120,87 @@ describe('proxyLogStore', () => { }); expect(dbInsertValuesMock.mock.calls[1][0].billingDetails).toBeUndefined(); }); + + it('falls back to base values when both billing details and downstream key columns are missing', async () => { + hasProxyLogBillingDetailsColumnMock.mockResolvedValue(true); + hasProxyLogDownstreamApiKeyIdColumnMock.mockResolvedValue(true); + dbInsertRunMock + .mockRejectedValueOnce(new Error('column proxy_logs.billing_details does not exist')) + .mockRejectedValueOnce(new Error('column proxy_logs.downstream_api_key_id does not exist')) + .mockResolvedValueOnce(undefined); + + await insertProxyLog({ + modelRequested: 'gpt-5', + billingDetails: { total: 1 }, + downstreamApiKeyId: 12, + }); + + expect(dbInsertValuesMock).toHaveBeenCalledTimes(3); + expect(dbInsertValuesMock.mock.calls[0][0]).toMatchObject({ + modelRequested: 'gpt-5', + billingDetails: JSON.stringify({ total: 1 }), + downstreamApiKeyId: 12, + }); + expect(dbInsertValuesMock.mock.calls[1][0]).toMatchObject({ + modelRequested: 'gpt-5', + downstreamApiKeyId: 12, + }); + expect(dbInsertValuesMock.mock.calls[2][0]).toMatchObject({ + modelRequested: 'gpt-5', + }); + expect(dbInsertValuesMock.mock.calls[2][0].billingDetails).toBeUndefined(); + expect(dbInsertValuesMock.mock.calls[2][0].downstreamApiKeyId).toBeUndefined(); + }); + + it('writes structured client fields when the schema supports them', async () => { + hasProxyLogClientColumnsMock.mockResolvedValue(true); + + await insertProxyLog({ + modelRequested: 'gpt-5', + clientFamily: 'codex', + clientAppId: 'cherry_studio', + clientAppName: 'Cherry Studio', + clientConfidence: 'exact', + }); + + expect(dbInsertValuesMock).toHaveBeenCalledTimes(1); + expect(dbInsertValuesMock.mock.calls[0][0]).toMatchObject({ + modelRequested: 'gpt-5', + clientFamily: 'codex', + clientAppId: 'cherry_studio', + clientAppName: 'Cherry Studio', + clientConfidence: 'exact', + }); + }); + + it('retries proxy log inserts without structured client fields when those columns are missing', async () => { + hasProxyLogClientColumnsMock.mockResolvedValue(true); + dbInsertRunMock + .mockRejectedValueOnce(new Error('column proxy_logs.client_app_id does not exist')) + .mockResolvedValueOnce(undefined); + + await insertProxyLog({ + modelRequested: 'gpt-5', + clientFamily: 'codex', + clientAppId: 'cherry_studio', + clientAppName: 'Cherry Studio', + clientConfidence: 'exact', + }); + + expect(dbInsertValuesMock).toHaveBeenCalledTimes(2); + expect(dbInsertValuesMock.mock.calls[0][0]).toMatchObject({ + modelRequested: 'gpt-5', + clientFamily: 'codex', + clientAppId: 'cherry_studio', + clientAppName: 'Cherry Studio', + clientConfidence: 'exact', + }); + expect(dbInsertValuesMock.mock.calls[1][0]).toMatchObject({ + modelRequested: 'gpt-5', + }); + expect(dbInsertValuesMock.mock.calls[1][0].clientFamily).toBeUndefined(); + expect(dbInsertValuesMock.mock.calls[1][0].clientAppId).toBeUndefined(); + expect(dbInsertValuesMock.mock.calls[1][0].clientAppName).toBeUndefined(); + expect(dbInsertValuesMock.mock.calls[1][0].clientConfidence).toBeUndefined(); + }); }); diff --git a/src/server/services/proxyLogStore.ts b/src/server/services/proxyLogStore.ts index 1e3b4cad..19a72d28 100644 --- a/src/server/services/proxyLogStore.ts +++ b/src/server/services/proxyLogStore.ts @@ -1,9 +1,16 @@ -import { db, schema, hasProxyLogBillingDetailsColumn } from '../db/index.js'; +import { + db, + schema, + hasProxyLogBillingDetailsColumn, + hasProxyLogClientColumns, + hasProxyLogDownstreamApiKeyIdColumn, +} from '../db/index.js'; export type ProxyLogInsertInput = { routeId?: number | null; channelId?: number | null; accountId?: number | null; + downstreamApiKeyId?: number | null; modelRequested?: string | null; modelActual?: string | null; status?: string | null; @@ -14,17 +21,22 @@ export type ProxyLogInsertInput = { totalTokens?: number | null; estimatedCost?: number | null; billingDetails?: unknown; + clientFamily?: string | null; + clientAppId?: string | null; + clientAppName?: string | null; + clientConfidence?: string | null; errorMessage?: string | null; retryCount?: number | null; createdAt?: string | null; }; -function buildProxyLogBaseSelectFields() { +function buildProxyLogCoreSelectFields() { return { id: schema.proxyLogs.id, routeId: schema.proxyLogs.routeId, channelId: schema.proxyLogs.channelId, accountId: schema.proxyLogs.accountId, + downstreamApiKeyId: schema.proxyLogs.downstreamApiKeyId, modelRequested: schema.proxyLogs.modelRequested, modelActual: schema.proxyLogs.modelActual, status: schema.proxyLogs.status, @@ -40,65 +52,120 @@ function buildProxyLogBaseSelectFields() { }; } +function buildProxyLogClientSelectFields() { + return { + clientFamily: schema.proxyLogs.clientFamily, + clientAppId: schema.proxyLogs.clientAppId, + clientAppName: schema.proxyLogs.clientAppName, + clientConfidence: schema.proxyLogs.clientConfidence, + }; +} + +function buildProxyLogSelectFields(options?: { + includeBillingDetails?: boolean; + includeClientFields?: boolean; +}) { + return { + ...buildProxyLogCoreSelectFields(), + ...(options?.includeClientFields ? buildProxyLogClientSelectFields() : {}), + ...(options?.includeBillingDetails ? { billingDetails: schema.proxyLogs.billingDetails } : {}), + }; +} + export function getProxyLogBaseSelectFields() { - return buildProxyLogBaseSelectFields(); + return buildProxyLogCoreSelectFields(); } -export type ProxyLogSelectFields = ReturnType & { - billingDetails?: typeof schema.proxyLogs.billingDetails; -}; +export type ProxyLogSelectFields = ReturnType; export type ResolvedProxyLogSelectFields = { includeBillingDetails: boolean; + includeClientFields: boolean; fields: ProxyLogSelectFields; }; -export async function resolveProxyLogSelectFields(options?: { includeBillingDetails?: boolean }) { +export async function resolveProxyLogSelectFields(options?: { + includeBillingDetails?: boolean; + includeClientFields?: boolean; +}) { const includeBillingDetails = options?.includeBillingDetails === true && await hasProxyLogBillingDetailsColumn(); + const includeClientFields = options?.includeClientFields !== false + && await hasProxyLogClientColumns(); return { includeBillingDetails, - fields: includeBillingDetails - ? { ...buildProxyLogBaseSelectFields(), billingDetails: schema.proxyLogs.billingDetails } - : buildProxyLogBaseSelectFields(), + includeClientFields, + fields: buildProxyLogSelectFields({ + includeBillingDetails, + includeClientFields, + }), }; } export async function withProxyLogSelectFields( runner: (selection: ResolvedProxyLogSelectFields) => Promise, - options?: { includeBillingDetails?: boolean }, + options?: { includeBillingDetails?: boolean; includeClientFields?: boolean }, ): Promise { - const selection = await resolveProxyLogSelectFields(options); + let selection = await resolveProxyLogSelectFields(options); - try { - return await runner(selection); - } catch (error) { - if (selection.includeBillingDetails && isMissingBillingDetailsColumnError(error)) { - return await runner({ - includeBillingDetails: false, - fields: buildProxyLogBaseSelectFields(), - }); + while (true) { + try { + return await runner(selection); + } catch (error) { + if (selection.includeBillingDetails && isMissingBillingDetailsColumnError(error)) { + selection = { + includeBillingDetails: false, + includeClientFields: selection.includeClientFields, + fields: buildProxyLogSelectFields({ + includeBillingDetails: false, + includeClientFields: selection.includeClientFields, + }), + }; + continue; + } + + if (selection.includeClientFields && isMissingProxyLogClientColumnsError(error)) { + selection = { + includeBillingDetails: selection.includeBillingDetails, + includeClientFields: false, + fields: buildProxyLogSelectFields({ + includeBillingDetails: selection.includeBillingDetails, + includeClientFields: false, + }), + }; + continue; + } + + throw error; } - throw error; } } export function parseProxyLogBillingDetails(value: unknown): Record | null { + if (value && typeof value === 'object' && !Array.isArray(value)) { + return value as Record; + } if (typeof value !== 'string' || value.trim().length === 0) return null; try { const parsed = JSON.parse(value); - return parsed && typeof parsed === 'object' ? parsed as Record : null; + return parsed && typeof parsed === 'object' && !Array.isArray(parsed) + ? parsed as Record + : null; } catch { return null; } } -export function isMissingBillingDetailsColumnError(error: unknown): boolean { +function normalizeProxyLogStoreErrorMessage(error: unknown): string { const message = typeof error === 'object' && error && 'message' in error ? String((error as { message?: unknown }).message || '') : String(error || ''); - const lowered = message.toLowerCase(); + return message.toLowerCase(); +} + +export function isMissingBillingDetailsColumnError(error: unknown): boolean { + const lowered = normalizeProxyLogStoreErrorMessage(error); return lowered.includes('billing_details') && ( lowered.includes('does not exist') @@ -108,6 +175,35 @@ export function isMissingBillingDetailsColumnError(error: unknown): boolean { ); } +export function isMissingDownstreamApiKeyIdColumnError(error: unknown): boolean { + const lowered = normalizeProxyLogStoreErrorMessage(error); + return lowered.includes('downstream_api_key_id') + && ( + lowered.includes('does not exist') + || lowered.includes('unknown column') + || lowered.includes('no such column') + || lowered.includes('has no column named') + ); +} + +export function isMissingProxyLogClientColumnsError(error: unknown): boolean { + const lowered = normalizeProxyLogStoreErrorMessage(error); + const hasClientColumnReference = [ + 'client_family', + 'client_app_id', + 'client_app_name', + 'client_confidence', + ].some((columnName) => lowered.includes(columnName)); + + return hasClientColumnReference + && ( + lowered.includes('does not exist') + || lowered.includes('unknown column') + || lowered.includes('no such column') + || lowered.includes('has no column named') + ); +} + export async function insertProxyLog(input: ProxyLogInsertInput): Promise { const baseValues = { routeId: input.routeId ?? null, @@ -131,18 +227,56 @@ export async function insertProxyLog(input: ProxyLogInsertInput): Promise : JSON.stringify(input.billingDetails); const includeBillingDetails = serializedBillingDetails !== null && await hasProxyLogBillingDetailsColumn(); + const includeDownstreamApiKeyId = input.downstreamApiKeyId != null + && await hasProxyLogDownstreamApiKeyIdColumn(); + const requestedClientFields = [ + input.clientFamily, + input.clientAppId, + input.clientAppName, + input.clientConfidence, + ].some((value) => value != null && String(value).trim().length > 0); + const includeClientFields = requestedClientFields + && await hasProxyLogClientColumns(); - try { - await db.insert(schema.proxyLogs).values( - includeBillingDetails - ? { ...baseValues, billingDetails: serializedBillingDetails } - : baseValues, - ).run(); - } catch (error) { - if (includeBillingDetails && isMissingBillingDetailsColumnError(error)) { - await db.insert(schema.proxyLogs).values(baseValues).run(); + let allowBillingDetails = includeBillingDetails; + let allowDownstreamApiKeyId = includeDownstreamApiKeyId; + let allowClientFields = includeClientFields; + + while (true) { + const values = { + ...baseValues, + ...(allowBillingDetails ? { billingDetails: serializedBillingDetails } : {}), + ...(allowDownstreamApiKeyId ? { downstreamApiKeyId: input.downstreamApiKeyId } : {}), + ...(allowClientFields + ? { + clientFamily: input.clientFamily ?? null, + clientAppId: input.clientAppId ?? null, + clientAppName: input.clientAppName ?? null, + clientConfidence: input.clientConfidence ?? null, + } + : {}), + }; + + try { + await db.insert(schema.proxyLogs).values(values).run(); return; + } catch (error) { + if (allowBillingDetails && isMissingBillingDetailsColumnError(error)) { + allowBillingDetails = false; + continue; + } + + if (allowDownstreamApiKeyId && isMissingDownstreamApiKeyIdColumnError(error)) { + allowDownstreamApiKeyId = false; + continue; + } + + if (allowClientFields && isMissingProxyLogClientColumnsError(error)) { + allowClientFields = false; + continue; + } + + throw error; } - throw error; } } diff --git a/src/server/services/proxyRetryPolicy.test.ts b/src/server/services/proxyRetryPolicy.test.ts index 23e82551..6318bdb2 100644 --- a/src/server/services/proxyRetryPolicy.test.ts +++ b/src/server/services/proxyRetryPolicy.test.ts @@ -20,10 +20,19 @@ describe('proxyRetryPolicy', () => { ).toBe(true); }); - it('retries on generic client request errors for cross-channel fallback', () => { + it('does not retry obvious request-shape errors that will fail on every channel', () => { expect( shouldRetryProxyRequest(400, '{"error":{"message":"invalid request body"}}'), - ).toBe(true); + ).toBe(false); + expect( + shouldRetryProxyRequest(422, '{"error":{"message":"unprocessable"}}'), + ).toBe(false); + expect( + shouldRetryProxyRequest(404, '{"error":{"message":"not found"}}'), + ).toBe(false); + }); + + it('keeps retrying channel-local compatibility and auth failures', () => { expect( shouldRetryProxyRequest(401, '{"error":{"message":"invalid access token"}}'), ).toBe(true); @@ -31,10 +40,16 @@ describe('proxyRetryPolicy', () => { shouldRetryProxyRequest(403, '{"error":{"message":"forbidden"}}'), ).toBe(true); expect( - shouldRetryProxyRequest(404, '{"error":{"message":"not found"}}'), + shouldRetryProxyRequest(400, 'Unsupported legacy protocol: /v1/chat/completions is not supported. Please use /v1/responses.'), ).toBe(true); + }); + + it('does not retry client-side timeout validation errors', () => { expect( - shouldRetryProxyRequest(422, '{"error":{"message":"unprocessable"}}'), - ).toBe(true); + shouldRetryProxyRequest(400, '{"error":{"message":"timeout must be <= 60"}}'), + ).toBe(false); + expect( + shouldRetryProxyRequest(400, '{"error":{"message":"invalid timeout parameter"}}'), + ).toBe(false); }); }); diff --git a/src/server/services/proxyRetryPolicy.ts b/src/server/services/proxyRetryPolicy.ts index f9b0366d..6ecf33b3 100644 --- a/src/server/services/proxyRetryPolicy.ts +++ b/src/server/services/proxyRetryPolicy.ts @@ -14,14 +14,65 @@ const MODEL_UNSUPPORTED_PATTERNS: RegExp[] = [ /you\s+do\s+not\s+have\s+access\s+to\s+the\s+model/i, ]; +export const RETRYABLE_TIMEOUT_PATTERNS: RegExp[] = [ + /(request timed out|connection timed out|read timeout|\btimed out\b)/i, +]; + +const RETRYABLE_CHANNEL_LOCAL_PATTERNS: RegExp[] = [ + /unsupported\s+legacy\s+protocol/i, + /please\s+use\s+\/v1\/responses/i, + /please\s+use\s+\/v1\/messages/i, + /please\s+use\s+\/v1\/chat\/completions/i, + /does\s+not\s+allow\s+\/v1\/[a-z0-9/_:-]+\s+dispatch/i, + /unsupported\s+endpoint/i, + /unsupported\s+path/i, + /unknown\s+endpoint/i, + /unrecognized\s+request\s+url/i, + /no\s+route\s+matched/i, + /invalid\s+api\s+key/i, + /invalid\s+access\s+token/i, + /forbidden/i, + /rate\s+limit/i, + /quota/i, + /bad\s+gateway/i, + /gateway\s+time-?out/i, + /service\s+unavailable/i, + /cpu\s+overloaded/i, + ...RETRYABLE_TIMEOUT_PATTERNS, +]; + +const NON_RETRYABLE_REQUEST_PATTERNS: RegExp[] = [ + /invalid\s+request\s+body/i, + /validation/i, + /missing\s+required/i, + /required\s+parameter/i, + /unknown\s+parameter/i, + /unrecognized\s+(field|key|parameter)/i, + /malformed/i, + /invalid\s+json/i, + /cannot\s+parse/i, + /unsupported\s+media\s+type/i, +]; + function isModelUnsupportedErrorMessage(rawMessage?: string | null): boolean { const text = (rawMessage || '').trim(); if (!text) return false; return MODEL_UNSUPPORTED_PATTERNS.some((pattern) => pattern.test(text)); } +function matchesAnyPattern(patterns: RegExp[], rawMessage?: string | null): boolean { + const text = (rawMessage || '').trim(); + if (!text) return false; + return patterns.some((pattern) => pattern.test(text)); +} + export function shouldRetryProxyRequest(status: number, upstreamErrorText?: string | null): boolean { - if (status >= 400) return true; - if (!upstreamErrorText) return false; - return isModelUnsupportedErrorMessage(upstreamErrorText); + if (status >= 500) return true; + if (status === 408 || status === 409 || status === 425 || status === 429) return true; + if (status === 401 || status === 403) return true; + if (isModelUnsupportedErrorMessage(upstreamErrorText)) return true; + if (matchesAnyPattern(NON_RETRYABLE_REQUEST_PATTERNS, upstreamErrorText)) return false; + if (matchesAnyPattern(RETRYABLE_CHANNEL_LOCAL_PATTERNS, upstreamErrorText)) return true; + if (status === 400 || status === 404 || status === 422) return false; + return false; } diff --git a/src/server/services/proxyVideoTaskStore.test.ts b/src/server/services/proxyVideoTaskStore.test.ts new file mode 100644 index 00000000..3405d70e --- /dev/null +++ b/src/server/services/proxyVideoTaskStore.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from 'vitest'; +import { __proxyVideoTaskStoreTestUtils } from './proxyVideoTaskStore.js'; + +describe('proxyVideoTaskStore', () => { + it('accepts parsed object input for JSON column helpers', () => { + expect(__proxyVideoTaskStoreTestUtils.parseJsonColumn({ + status: 'done', + id: 'video-1', + })).toEqual({ + status: 'done', + id: 'video-1', + }); + }); +}); diff --git a/src/server/services/proxyVideoTaskStore.ts b/src/server/services/proxyVideoTaskStore.ts index dc34b3c2..f1739c6b 100644 --- a/src/server/services/proxyVideoTaskStore.ts +++ b/src/server/services/proxyVideoTaskStore.ts @@ -117,7 +117,8 @@ export async function refreshProxyVideoTaskSnapshot( .run(); } -function parseJsonColumn(value: string | null | undefined): unknown | null { +function parseJsonColumn(value: unknown): unknown | null { + if (value && typeof value === 'object') return value; if (typeof value !== 'string' || value.trim().length === 0) return null; try { return JSON.parse(value); @@ -125,3 +126,7 @@ function parseJsonColumn(value: string | null | undefined): unknown | null { return value; } } + +export const __proxyVideoTaskStoreTestUtils = { + parseJsonColumn, +}; diff --git a/src/server/services/routeDecisionSnapshotStore.test.ts b/src/server/services/routeDecisionSnapshotStore.test.ts new file mode 100644 index 00000000..ba5f4056 --- /dev/null +++ b/src/server/services/routeDecisionSnapshotStore.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from 'vitest'; +import { parseRouteDecisionSnapshot } from './routeDecisionSnapshotStore.js'; + +describe('routeDecisionSnapshotStore', () => { + it('accepts parsed decision snapshot objects', () => { + expect(parseRouteDecisionSnapshot({ + matched: true, + candidates: [{ routeId: 1 }], + })).toEqual({ + matched: true, + candidates: [{ routeId: 1 }], + }); + }); +}); diff --git a/src/server/services/routeDecisionSnapshotStore.ts b/src/server/services/routeDecisionSnapshotStore.ts index b8e0b1ad..8811072f 100644 --- a/src/server/services/routeDecisionSnapshotStore.ts +++ b/src/server/services/routeDecisionSnapshotStore.ts @@ -7,7 +7,8 @@ function serializeSnapshot(snapshot: unknown): string | null { return JSON.stringify(snapshot); } -export function parseRouteDecisionSnapshot(value: string | null | undefined): unknown | null { +export function parseRouteDecisionSnapshot(value: unknown): unknown | null { + if (value && typeof value === 'object') return value; if (typeof value !== 'string' || value.trim().length === 0) return null; try { return JSON.parse(value); diff --git a/src/server/services/routeRefreshWorkflow.ts b/src/server/services/routeRefreshWorkflow.ts new file mode 100644 index 00000000..32c9bd65 --- /dev/null +++ b/src/server/services/routeRefreshWorkflow.ts @@ -0,0 +1,43 @@ +import { startBackgroundTask } from './backgroundTaskService.js'; +import { + rebuildTokenRoutesFromAvailability, + refreshModelsAndRebuildRoutes as refreshModelsAndRebuildRoutesViaModelService, +} from './modelService.js'; + +export async function rebuildRoutesOnly() { + return rebuildTokenRoutesFromAvailability(); +} + +export async function rebuildRoutesBestEffort() { + try { + await rebuildRoutesOnly(); + return true; + } catch { + return false; + } +} + +export async function refreshModelsAndRebuildRoutes() { + return refreshModelsAndRebuildRoutesViaModelService(); +} + +export function queueRefreshModelsAndRebuildRoutesTask(input: { + type: string; + title: string; + dedupeKey?: string; + notifyOnFailure?: boolean; + successMessage: (currentTask: { result?: unknown }) => string; + failureMessage: (currentTask: { error?: string | null }) => string; +}) { + return startBackgroundTask( + { + type: input.type, + title: input.title, + dedupeKey: input.dedupeKey || 'refresh-models-and-rebuild-routes', + notifyOnFailure: input.notifyOnFailure ?? true, + successMessage: input.successMessage, + failureMessage: input.failureMessage, + }, + async () => refreshModelsAndRebuildRoutes(), + ); +} diff --git a/src/server/services/routeRoutingStrategy.ts b/src/server/services/routeRoutingStrategy.ts new file mode 100644 index 00000000..e1390dc4 --- /dev/null +++ b/src/server/services/routeRoutingStrategy.ts @@ -0,0 +1,14 @@ +export type RouteRoutingStrategy = 'weighted' | 'round_robin' | 'stable_first'; + +export const DEFAULT_ROUTE_ROUTING_STRATEGY: RouteRoutingStrategy = 'weighted'; + +export function normalizeRouteRoutingStrategy(value: unknown): RouteRoutingStrategy { + const normalized = String(value || '').trim().toLowerCase(); + if (normalized === 'round_robin') return 'round_robin'; + if (normalized === 'stable_first') return 'stable_first'; + return DEFAULT_ROUTE_ROUTING_STRATEGY; +} + +export function isRoundRobinRouteRoutingStrategy(value: unknown): boolean { + return normalizeRouteRoutingStrategy(value) === 'round_robin'; +} diff --git a/src/server/services/siteAnnouncementPollingService.test.ts b/src/server/services/siteAnnouncementPollingService.test.ts new file mode 100644 index 00000000..1977a207 --- /dev/null +++ b/src/server/services/siteAnnouncementPollingService.test.ts @@ -0,0 +1,32 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const syncSiteAnnouncementsMock = vi.fn(); + +vi.mock('./siteAnnouncementService.js', () => ({ + syncSiteAnnouncements: (...args: unknown[]) => syncSiteAnnouncementsMock(...args), +})); + +describe('siteAnnouncementPollingService', () => { + beforeEach(() => { + vi.useFakeTimers(); + syncSiteAnnouncementsMock.mockReset(); + }); + + afterEach(async () => { + const module = await import('./siteAnnouncementPollingService.js'); + module.stopSiteAnnouncementPolling(); + vi.useRealTimers(); + }); + + it('runs one immediate sync and then continues on the configured interval', async () => { + const module = await import('./siteAnnouncementPollingService.js'); + syncSiteAnnouncementsMock.mockResolvedValue(undefined); + + module.startSiteAnnouncementPolling(60_000); + await vi.advanceTimersByTimeAsync(0); + expect(syncSiteAnnouncementsMock).toHaveBeenCalledTimes(1); + + await vi.advanceTimersByTimeAsync(60_000); + expect(syncSiteAnnouncementsMock).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/server/services/siteAnnouncementPollingService.ts b/src/server/services/siteAnnouncementPollingService.ts new file mode 100644 index 00000000..fa157af9 --- /dev/null +++ b/src/server/services/siteAnnouncementPollingService.ts @@ -0,0 +1,35 @@ +import { syncSiteAnnouncements } from './siteAnnouncementService.js'; + +const DEFAULT_SITE_ANNOUNCEMENT_INTERVAL_MS = 15 * 60 * 1000; + +let pollingTimer: ReturnType | null = null; +let syncRunning = false; + +async function runSyncOnce() { + if (syncRunning) return; + syncRunning = true; + try { + await syncSiteAnnouncements(); + } catch (error) { + console.error('[SiteAnnouncementPolling] Sync failed:', error); + } finally { + syncRunning = false; + } +} + +export function startSiteAnnouncementPolling(intervalMs = DEFAULT_SITE_ANNOUNCEMENT_INTERVAL_MS) { + stopSiteAnnouncementPolling(); + pollingTimer = setInterval(() => { + void runSyncOnce(); + }, Math.max(10_000, intervalMs)); + pollingTimer.unref?.(); + void runSyncOnce(); + return { intervalMs: Math.max(10_000, intervalMs) }; +} + +export function stopSiteAnnouncementPolling() { + if (pollingTimer) { + clearInterval(pollingTimer); + pollingTimer = null; + } +} diff --git a/src/server/services/siteAnnouncementService.test.ts b/src/server/services/siteAnnouncementService.test.ts new file mode 100644 index 00000000..7230d045 --- /dev/null +++ b/src/server/services/siteAnnouncementService.test.ts @@ -0,0 +1,192 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +const getAdapterMock = vi.fn(); +const sendNotificationMock = vi.fn(); + +vi.mock('./platforms/index.js', () => ({ + getAdapter: (...args: unknown[]) => getAdapterMock(...args), +})); + +vi.mock('./notifyService.js', () => ({ + sendNotification: (...args: unknown[]) => sendNotificationMock(...args), +})); + +type DbModule = typeof import('../db/index.js'); +type ServiceModule = typeof import('./siteAnnouncementService.js'); + +describe('siteAnnouncementService', () => { + let dataDir = ''; + let db: DbModule['db']; + let schema: DbModule['schema']; + let closeDbConnections: DbModule['closeDbConnections']; + let syncSiteAnnouncements: ServiceModule['syncSiteAnnouncements']; + + beforeAll(async () => { + dataDir = mkdtempSync(join(tmpdir(), 'metapi-site-announcements-service-')); + process.env.DATA_DIR = dataDir; + + await import('../db/migrate.js'); + const dbModule = await import('../db/index.js'); + const serviceModule = await import('./siteAnnouncementService.js'); + db = dbModule.db; + schema = dbModule.schema; + closeDbConnections = dbModule.closeDbConnections; + syncSiteAnnouncements = serviceModule.syncSiteAnnouncements; + }); + + beforeEach(async () => { + vi.useFakeTimers(); + getAdapterMock.mockReset(); + sendNotificationMock.mockReset(); + + await db.delete(schema.siteAnnouncements).run(); + await db.delete(schema.events).run(); + await db.delete(schema.accounts).run(); + await db.delete(schema.sites).run(); + }); + + afterAll(async () => { + vi.useRealTimers(); + if (typeof closeDbConnections === 'function') { + await closeDbConnections(); + } + if (dataDir) { + try { + rmSync(dataDir, { recursive: true, force: true }); + } catch {} + } + delete process.env.DATA_DIR; + }); + + it('stores first-seen announcements, creates one event, and sends one notification', async () => { + vi.setSystemTime(new Date('2026-03-20T10:00:00Z')); + + const site = await db.insert(schema.sites).values({ + name: 'Sub Site', + url: 'https://sub.example.com', + platform: 'sub2api', + status: 'active', + }).returning().get(); + await db.insert(schema.accounts).values({ + siteId: site.id, + username: 'demo-user', + accessToken: 'jwt-token', + status: 'active', + }).run(); + await db.insert(schema.sites).values({ + name: 'Unsupported Site', + url: 'https://unsupported.example.com', + platform: 'openai', + status: 'active', + }).run(); + + getAdapterMock.mockImplementation((platform: string) => { + if (platform === 'sub2api') { + return { + getSiteAnnouncements: vi.fn(async (_baseUrl: string, accessToken: string) => { + expect(accessToken).toBe('jwt-token'); + return [{ + sourceKey: 'announcement:11', + title: 'Maintenance', + content: 'Window starts at 10:00', + level: 'info', + rawPayload: { id: 11, title: 'Maintenance' }, + }]; + }), + }; + } + return { + getSiteAnnouncements: undefined, + }; + }); + + const result = await syncSiteAnnouncements(); + + expect(result).toMatchObject({ + scannedSites: 2, + inserted: 1, + updated: 0, + unsupported: 1, + notifications: 1, + events: 1, + failed: 0, + }); + + const announcementRows = await db.select().from(schema.siteAnnouncements).all(); + expect(announcementRows).toHaveLength(1); + expect(announcementRows[0]).toMatchObject({ + siteId: site.id, + platform: 'sub2api', + sourceKey: 'announcement:11', + title: 'Maintenance', + content: 'Window starts at 10:00', + level: 'info', + }); + + const eventRows = await db.select().from(schema.events).all(); + expect(eventRows).toHaveLength(1); + expect(eventRows[0]).toMatchObject({ + type: 'site_notice', + relatedType: 'site_announcement', + }); + expect(Number(eventRows[0]?.relatedId)).toBe(Number(announcementRows[0]?.id)); + + expect(sendNotificationMock).toHaveBeenCalledTimes(1); + expect(sendNotificationMock.mock.calls[0]?.[0]).toContain('Sub Site'); + expect(sendNotificationMock.mock.calls[0]?.[1]).toContain('Window starts at 10:00'); + expect(sendNotificationMock.mock.calls[0]?.[2]).toBe('info'); + }); + + it('updates existing announcements without duplicating events or notifications', async () => { + const site = await db.insert(schema.sites).values({ + name: 'Sub Site', + url: 'https://sub.example.com', + platform: 'sub2api', + status: 'active', + }).returning().get(); + await db.insert(schema.accounts).values({ + siteId: site.id, + username: 'demo-user', + accessToken: 'jwt-token', + status: 'active', + }).run(); + + getAdapterMock.mockReturnValue({ + getSiteAnnouncements: vi.fn(async () => [{ + sourceKey: 'announcement:11', + title: 'Maintenance', + content: 'Window starts at 10:00', + level: 'info', + rawPayload: { id: 11, title: 'Maintenance' }, + }]), + }); + + vi.setSystemTime(new Date('2026-03-20T10:00:00Z')); + await syncSiteAnnouncements({ siteId: site.id }); + const firstRow = await db.select().from(schema.siteAnnouncements).get(); + + vi.setSystemTime(new Date('2026-03-20T11:00:00Z')); + const result = await syncSiteAnnouncements({ siteId: site.id }); + + expect(result).toMatchObject({ + scannedSites: 1, + inserted: 0, + updated: 1, + notifications: 0, + events: 0, + failed: 0, + }); + + const announcementRows = await db.select().from(schema.siteAnnouncements).all(); + expect(announcementRows).toHaveLength(1); + expect(announcementRows[0]?.firstSeenAt).toBe(firstRow?.firstSeenAt); + expect(announcementRows[0]?.lastSeenAt).not.toBe(firstRow?.lastSeenAt); + + const eventRows = await db.select().from(schema.events).all(); + expect(eventRows).toHaveLength(1); + expect(sendNotificationMock).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/server/services/siteAnnouncementService.ts b/src/server/services/siteAnnouncementService.ts new file mode 100644 index 00000000..73c53a41 --- /dev/null +++ b/src/server/services/siteAnnouncementService.ts @@ -0,0 +1,163 @@ +import { and, asc, eq } from 'drizzle-orm'; +import { db, schema } from '../db/index.js'; +import { getAdapter } from './platforms/index.js'; +import { sendNotification } from './notifyService.js'; +import { formatUtcSqlDateTime } from './localTimeService.js'; +import type { SiteAnnouncement } from './platforms/base.js'; + +export type SiteAnnouncementSyncResult = { + scannedSites: number; + inserted: number; + updated: number; + unsupported: number; + notifications: number; + events: number; + failed: number; + failedSites: Array<{ siteId: number; siteName: string; message: string }>; +}; + +function toStoredPayload(rawPayload: unknown): string | null { + if (rawPayload === undefined) return null; + try { + return JSON.stringify(rawPayload); + } catch { + return null; + } +} + +function buildAnnouncementMessage(row: SiteAnnouncement): string { + const title = String(row.title || '').trim(); + const content = String(row.content || '').trim(); + if (title && content && title !== content && title.toLowerCase() !== 'site notice') { + return `${title}\n${content}`; + } + return content || title; +} + +async function resolveSiteAccessToken(siteId: number, siteApiKey?: string | null): Promise { + const direct = String(siteApiKey || '').trim(); + if (direct) return direct; + + const account = await db.select() + .from(schema.accounts) + .where(and( + eq(schema.accounts.siteId, siteId), + eq(schema.accounts.status, 'active'), + )) + .orderBy(asc(schema.accounts.id)) + .limit(1) + .get(); + + return String(account?.accessToken || '').trim(); +} + +async function listTargetSites(siteId?: number | null) { + if (siteId && Number.isFinite(siteId) && siteId > 0) { + return await db.select() + .from(schema.sites) + .where(eq(schema.sites.id, siteId)) + .all(); + } + + return await db.select() + .from(schema.sites) + .where(eq(schema.sites.status, 'active')) + .all(); +} + +export async function syncSiteAnnouncements(options?: { siteId?: number | null }): Promise { + const result: SiteAnnouncementSyncResult = { + scannedSites: 0, + inserted: 0, + updated: 0, + unsupported: 0, + notifications: 0, + events: 0, + failed: 0, + failedSites: [], + }; + + const sites = await listTargetSites(options?.siteId ?? null); + + for (const site of sites) { + result.scannedSites += 1; + const adapter = getAdapter(String(site.platform || '')); + if (!adapter || typeof adapter.getSiteAnnouncements !== 'function') { + result.unsupported += 1; + continue; + } + + try { + const accessToken = await resolveSiteAccessToken(site.id, site.apiKey); + const announcements = await adapter.getSiteAnnouncements(site.url, accessToken); + const seenAt = formatUtcSqlDateTime(new Date()); + + for (const announcement of announcements) { + const existing = await db.select() + .from(schema.siteAnnouncements) + .where(and( + eq(schema.siteAnnouncements.siteId, site.id), + eq(schema.siteAnnouncements.sourceKey, announcement.sourceKey), + )) + .limit(1) + .get(); + + const patch = { + platform: String(site.platform || '').trim(), + title: announcement.title, + content: announcement.content, + level: announcement.level, + sourceUrl: announcement.sourceUrl ?? null, + startsAt: announcement.startsAt ?? null, + endsAt: announcement.endsAt ?? null, + upstreamCreatedAt: announcement.upstreamCreatedAt ?? null, + upstreamUpdatedAt: announcement.upstreamUpdatedAt ?? null, + lastSeenAt: seenAt, + rawPayload: toStoredPayload(announcement.rawPayload), + }; + + if (existing) { + await db.update(schema.siteAnnouncements) + .set(patch) + .where(eq(schema.siteAnnouncements.id, existing.id)) + .run(); + result.updated += 1; + continue; + } + + const inserted = await db.insert(schema.siteAnnouncements).values({ + siteId: site.id, + sourceKey: announcement.sourceKey, + firstSeenAt: seenAt, + ...patch, + }).returning().get(); + result.inserted += 1; + + const title = `站点公告:${site.name}`; + const message = buildAnnouncementMessage(announcement); + await db.insert(schema.events).values({ + type: 'site_notice', + title, + message, + level: announcement.level, + relatedId: inserted.id, + relatedType: 'site_announcement', + createdAt: seenAt, + }).run(); + result.events += 1; + + await sendNotification(title, message, announcement.level); + result.notifications += 1; + } + } catch (error) { + result.failed += 1; + result.failedSites.push({ + siteId: site.id, + siteName: site.name, + message: error instanceof Error && error.message ? error.message : 'unknown error', + }); + } + } + + return result; +} diff --git a/src/server/services/siteCustomHeaders.ts b/src/server/services/siteCustomHeaders.ts new file mode 100644 index 00000000..a1c2538d --- /dev/null +++ b/src/server/services/siteCustomHeaders.ts @@ -0,0 +1,122 @@ +import { Headers, type HeadersInit } from 'undici'; + +export type SiteCustomHeadersRecord = Record; + +export type ParsedSiteCustomHeadersInput = { + present: boolean; + valid: boolean; + customHeaders: string | null; + headers: SiteCustomHeadersRecord | null; + error?: string; +}; + +function isPlainObject(value: unknown): value is Record { + return Object.prototype.toString.call(value) === '[object Object]'; +} + +function normalizeSiteCustomHeadersRecord(input: Record): SiteCustomHeadersRecord | null { + const normalized = new Headers(); + + for (const [rawKey, rawValue] of Object.entries(input)) { + const key = rawKey.trim(); + if (!key) { + throw new Error('Header name cannot be empty.'); + } + if (typeof rawValue !== 'string') { + throw new Error(`Header "${key}" must use a string value.`); + } + normalized.set(key, rawValue); + } + + const entries = Array.from(normalized.entries()).sort(([left], [right]) => left.localeCompare(right)); + if (entries.length === 0) { + return null; + } + + return Object.fromEntries(entries); +} + +export function parseSiteCustomHeadersInput(input: unknown): ParsedSiteCustomHeadersInput { + if (input === undefined) { + return { present: false, valid: true, customHeaders: null, headers: null }; + } + if (input === null) { + return { present: true, valid: true, customHeaders: null, headers: null }; + } + + let parsedInput: unknown = input; + if (typeof input === 'string') { + const trimmed = input.trim(); + if (!trimmed) { + return { present: true, valid: true, customHeaders: null, headers: null }; + } + try { + parsedInput = JSON.parse(trimmed); + } catch { + return { + present: true, + valid: false, + customHeaders: null, + headers: null, + error: 'Invalid customHeaders. Expected a JSON object like {"x-header":"value"}.', + }; + } + } + + if (!isPlainObject(parsedInput)) { + return { + present: true, + valid: false, + customHeaders: null, + headers: null, + error: 'Invalid customHeaders. Expected a JSON object like {"x-header":"value"}.', + }; + } + + try { + const headers = normalizeSiteCustomHeadersRecord(parsedInput); + return { + present: true, + valid: true, + customHeaders: headers ? JSON.stringify(headers) : null, + headers, + }; + } catch (error) { + return { + present: true, + valid: false, + customHeaders: null, + headers: null, + error: error instanceof Error + ? `Invalid customHeaders. ${error.message}` + : 'Invalid customHeaders. Expected a JSON object like {"x-header":"value"}.', + }; + } +} + +export function readSiteCustomHeaders(input: unknown): SiteCustomHeadersRecord | null { + const parsed = parseSiteCustomHeadersInput(input); + if (!parsed.valid) { + return null; + } + return parsed.headers; +} + +export function mergeHeadersWithSiteCustomHeaders( + siteCustomHeaders: unknown, + requestHeaders?: HeadersInit, +): HeadersInit | undefined { + const normalizedSiteHeaders = readSiteCustomHeaders(siteCustomHeaders); + if (!normalizedSiteHeaders) { + return requestHeaders; + } + + const merged = new Headers(normalizedSiteHeaders); + if (requestHeaders) { + const explicitHeaders = new Headers(requestHeaders); + explicitHeaders.forEach((value, key) => { + merged.set(key, value); + }); + } + return merged; +} diff --git a/src/server/services/siteProxy.test.ts b/src/server/services/siteProxy.test.ts index 028ef357..3bd3ccd9 100644 --- a/src/server/services/siteProxy.test.ts +++ b/src/server/services/siteProxy.test.ts @@ -1,8 +1,13 @@ -import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { once } from 'node:events'; import { mkdtempSync } from 'node:fs'; +import { createServer } from 'node:http'; +import { connect as connectSocket } from 'node:net'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { sql } from 'drizzle-orm'; +import { SocksClient } from 'socks'; +import { Headers, fetch } from 'undici'; type DbModule = typeof import('../db/index.js'); @@ -52,6 +57,22 @@ describe('siteProxy', () => { .toBeNull(); }); + it('prefers site-specific proxy url over the shared system proxy', async () => { + await db.insert(schema.settings).values({ + key: 'system_proxy_url', + value: JSON.stringify('http://127.0.0.1:7890'), + }).run(); + + await db.run(sql` + INSERT INTO sites (name, url, platform, proxy_url, use_system_proxy) + VALUES ('proxy-site', 'https://proxy-site.example.com', 'new-api', 'socks5://127.0.0.1:1080', 1) + `); + + const { resolveSiteProxyUrlByRequestUrl } = await import('./siteProxy.js'); + expect(await resolveSiteProxyUrlByRequestUrl('https://proxy-site.example.com/v1/models')) + .toBe('socks5://127.0.0.1:1080'); + }); + it('injects dispatcher when a site opts into the configured system proxy', async () => { await db.insert(schema.settings).values({ key: 'system_proxy_url', @@ -69,4 +90,224 @@ describe('siteProxy', () => { expect('dispatcher' in requestInit).toBe(true); }); + + it('injects dispatcher when a site defines its own proxy url', async () => { + await db.run(sql` + INSERT INTO sites (name, url, platform, proxy_url, use_system_proxy) + VALUES ('proxy-site', 'https://proxy-site.example.com', 'new-api', 'http://127.0.0.1:7890', 0) + `); + + const { withSiteProxyRequestInit } = await import('./siteProxy.js'); + const requestInit = await withSiteProxyRequestInit('https://proxy-site.example.com/v1/chat/completions', { + method: 'POST', + }); + + expect('dispatcher' in requestInit).toBe(true); + }); + + it('injects a working dispatcher for socks5 system proxies', async () => { + const upstreamServer = createServer((_request, response) => { + response.writeHead(200, { 'content-type': 'application/json' }); + response.end(JSON.stringify({ ok: true })); + }); + upstreamServer.listen(0, '127.0.0.1'); + await once(upstreamServer, 'listening'); + const upstreamAddress = upstreamServer.address(); + if (!upstreamAddress || typeof upstreamAddress === 'string') { + throw new Error('Failed to determine upstream server address'); + } + const requestUrl = `http://proxy-site.example.com:${upstreamAddress.port}/v1/chat/completions`; + + await db.insert(schema.settings).values({ + key: 'system_proxy_url', + value: JSON.stringify('socks5h://127.0.0.1:1080'), + }).run(); + await db.run(sql` + INSERT INTO sites (name, url, platform, use_system_proxy) + VALUES ('proxy-site', ${`http://proxy-site.example.com:${upstreamAddress.port}`}, 'new-api', 1) + `); + + const createConnectionSpy = vi.spyOn(SocksClient, 'createConnection').mockImplementation(async () => { + const socket = connectSocket(upstreamAddress.port, '127.0.0.1'); + await once(socket, 'connect'); + return { socket } as Awaited>; + }); + + try { + const { withSiteProxyRequestInit } = await import('./siteProxy.js'); + const requestInit = await withSiteProxyRequestInit(requestUrl, { + method: 'GET', + }); + + expect('dispatcher' in requestInit).toBe(true); + + const response = await fetch(requestUrl, requestInit); + + expect(response.status).toBe(200); + expect(createConnectionSpy).toHaveBeenCalledTimes(1); + expect(createConnectionSpy).toHaveBeenCalledWith(expect.objectContaining({ + command: 'connect', + proxy: expect.objectContaining({ + host: '127.0.0.1', + port: 1080, + type: 5, + }), + destination: expect.objectContaining({ + host: 'proxy-site.example.com', + port: upstreamAddress.port, + }), + })); + } finally { + createConnectionSpy.mockRestore(); + await new Promise((resolve, reject) => { + upstreamServer.close((error) => { + if (error) { + reject(error); + return; + } + resolve(); + }); + }); + } + }); + + it('merges site custom headers by matched request url and keeps explicit headers authoritative', async () => { + await db.insert(schema.sites).values({ + name: 'headers-site', + url: 'https://headers-site.example.com', + platform: 'new-api', + customHeaders: JSON.stringify({ + 'cf-access-client-id': 'site-client', + authorization: 'Bearer site-default', + }), + }).run(); + + const { withSiteProxyRequestInit } = await import('./siteProxy.js'); + const requestInit = await withSiteProxyRequestInit('https://headers-site.example.com/v1/models', { + method: 'GET', + headers: { + Authorization: 'Bearer request-token', + 'X-Trace-Id': 'trace-1', + }, + }); + const headers = new Headers(requestInit.headers); + + expect(headers.get('cf-access-client-id')).toBe('site-client'); + expect(headers.get('authorization')).toBe('Bearer request-token'); + expect(headers.get('x-trace-id')).toBe('trace-1'); + }); + + it('merges site custom headers from site records even without cache lookup', async () => { + const { withSiteRecordProxyRequestInit } = await import('./siteProxy.js'); + const requestInit = withSiteRecordProxyRequestInit({ + proxyUrl: 'http://127.0.0.1:7890', + useSystemProxy: false, + customHeaders: JSON.stringify({ + 'x-site-scope': 'site-level', + }), + }, { + method: 'POST', + headers: { + 'X-Request-Id': 'req-1', + }, + }); + const headers = new Headers(requestInit.headers); + + expect(headers.get('x-site-scope')).toBe('site-level'); + expect(headers.get('x-request-id')).toBe('req-1'); + expect('dispatcher' in requestInit).toBe(true); + }); + + it('merges parsed-object site custom headers from site records', async () => { + const { withSiteRecordProxyRequestInit } = await import('./siteProxy.js'); + const requestInit = withSiteRecordProxyRequestInit({ + proxyUrl: 'http://127.0.0.1:7890', + useSystemProxy: false, + customHeaders: { + 'x-site-scope': 'site-level', + }, + }, { + method: 'POST', + headers: { + 'X-Request-Id': 'req-1', + }, + }); + const headers = new Headers(requestInit.headers); + + expect(headers.get('x-site-scope')).toBe('site-level'); + expect(headers.get('x-request-id')).toBe('req-1'); + }); + + it('resolveChannelProxyUrl prefers account proxy over site proxy', async () => { + const { resolveChannelProxyUrl } = await import('./siteProxy.js'); + + const accountConfig = JSON.stringify({ proxyUrl: 'http://account-proxy:8080' }); + const siteWithProxy = { useSystemProxy: true }; + + expect(resolveChannelProxyUrl(siteWithProxy, accountConfig)).toBe('http://account-proxy:8080'); + expect(resolveChannelProxyUrl(siteWithProxy, null)).toBeNull(); + expect(resolveChannelProxyUrl(siteWithProxy, JSON.stringify({}))).toBeNull(); + }); + + it('withSiteRecordProxyRequestInit uses account proxy when provided', async () => { + const { withSiteRecordProxyRequestInit } = await import('./siteProxy.js'); + const result = withSiteRecordProxyRequestInit( + { useSystemProxy: false }, + { method: 'POST' }, + 'http://account-proxy:8080', + ); + expect('dispatcher' in result).toBe(true); + }); + + it('withSiteRecordProxyRequestInit ignores invalid account proxy', async () => { + const { withSiteRecordProxyRequestInit } = await import('./siteProxy.js'); + const result = withSiteRecordProxyRequestInit( + { useSystemProxy: false }, + { method: 'POST' }, + 'not-a-url', + ); + expect('dispatcher' in result).toBe(false); + }); + + it('withAccountProxyOverride sets ALS context for nested proxy calls', async () => { + const { withAccountProxyOverride, withSiteProxyRequestInit } = await import('./siteProxy.js'); + + await db.insert(schema.sites).values({ + name: 'als-site', + url: 'https://als-site.example.com', + platform: 'new-api', + }).run(); + + const result = await withAccountProxyOverride( + 'http://account-als-proxy:9090', + async () => { + return withSiteProxyRequestInit('https://als-site.example.com/v1/models', { + method: 'GET', + }); + }, + ); + + expect('dispatcher' in result).toBe(true); + }); + + it('withAccountProxyOverride skips ALS when proxy is null', async () => { + const { withAccountProxyOverride, withSiteProxyRequestInit } = await import('./siteProxy.js'); + + await db.insert(schema.sites).values({ + name: 'als-null-site', + url: 'https://als-null-site.example.com', + platform: 'new-api', + }).run(); + + const result = await withAccountProxyOverride( + null, + async () => { + return withSiteProxyRequestInit('https://als-null-site.example.com/v1/models', { + method: 'GET', + }); + }, + ); + + expect('dispatcher' in result).toBe(false); + }); }); diff --git a/src/server/services/siteProxy.ts b/src/server/services/siteProxy.ts index 2b3eff4e..a8e27a39 100644 --- a/src/server/services/siteProxy.ts +++ b/src/server/services/siteProxy.ts @@ -1,8 +1,15 @@ +import { AsyncLocalStorage } from 'node:async_hooks'; import { db, schema } from '../db/index.js'; import { eq } from 'drizzle-orm'; import { config } from '../config.js'; +import { lookup as dnsLookup } from 'node:dns'; +import { isIP, type Socket } from 'node:net'; +import { connect as tlsConnect, type TLSSocket } from 'node:tls'; +import { SocksClient } from 'socks'; import type { Dispatcher, RequestInit as UndiciRequestInit } from 'undici'; -import { ProxyAgent } from 'undici'; +import { Agent as UndiciAgent, ProxyAgent } from 'undici'; +import { mergeHeadersWithSiteCustomHeaders } from './siteCustomHeaders.js'; +import { getProxyUrlFromExtraConfig } from './accountExtraConfig.js'; const SITE_PROXY_CACHE_TTL_MS = 3_000; const SUPPORTED_PROXY_PROTOCOLS = new Set([ @@ -14,10 +21,21 @@ const SUPPORTED_PROXY_PROTOCOLS = new Set([ 'socks5:', 'socks5h:', ]); +const SOCKS_PROXY_PROTOCOLS = new Set([ + 'socks:', + 'socks4:', + 'socks4a:', + 'socks5:', + 'socks5h:', +]); +const DEFAULT_PROXY_CONNECT_TIMEOUT_MS = 10_000; +const DEFAULT_PROXY_KEEPALIVE_INITIAL_DELAY_MS = 60_000; type SiteProxyRow = { siteUrl: string; + proxyUrl: string | null; useSystemProxy: boolean; + customHeaders: unknown; }; type ParsedSiteProxyInput = { @@ -26,8 +44,10 @@ type ParsedSiteProxyInput = { proxyUrl: string | null; }; -type SiteProxyConfigLike = { +export type SiteProxyConfigLike = { + proxyUrl?: string | null; useSystemProxy?: boolean | null; + customHeaders?: unknown; }; let siteProxyCache: { @@ -42,6 +62,38 @@ let siteProxyCache: { const dispatcherCache = new Map(); +const accountProxyOverride = new AsyncLocalStorage(); + +export function withAccountProxyOverride( + proxyUrl: string | null | undefined, + fn: () => Promise, +): Promise { + const normalized = normalizeSiteProxyUrl(proxyUrl); + if (!normalized) return fn(); + return accountProxyOverride.run(normalized, fn); +} + +type ParsedSocksProxyConfig = { + shouldLookup: boolean; + proxy: { + host: string; + port: number; + type: 4 | 5; + userId?: string; + password?: string; + }; +}; + +type UndiciConnectOptions = { + hostname: string; + host?: string; + protocol: string; + port: string; + servername?: string; + localAddress?: string | null; + httpSocket?: Socket; +}; + function normalizeSiteUrl(value: string): string { const trimmed = (value || '').trim(); if (!trimmed) return ''; @@ -66,7 +118,9 @@ async function getCachedSiteProxyRows(nowMs = Date.now()): Promise ({ siteUrl: normalizeSiteUrl(row.siteUrl), + proxyUrl: normalizeSiteProxyUrl(row.proxyUrl), useSystemProxy: !!row.useSystemProxy, + customHeaders: row.customHeaders ?? null, })), systemProxyUrl: parsedSystemProxyUrl, }; @@ -102,22 +158,154 @@ async function getCachedSiteProxyRows(nowMs = Date.now()): Promise { + return new Promise((resolve, reject) => { + dnsLookup(hostname, {}, (error, address) => { + if (error) { + reject(error); + return; + } + resolve(address); + }); + }); +} + +async function createSocksSocket( + connectOptions: UndiciConnectOptions, + socksProxy: ParsedSocksProxyConfig, +): Promise { + if (!connectOptions.hostname) { + throw new Error('Missing hostname for SOCKS proxy request'); + } + + const destinationHost = socksProxy.shouldLookup + ? await resolveSocksDestinationHost(connectOptions.hostname) + : connectOptions.hostname; + const destinationPort = Number.parseInt(connectOptions.port, 10) + || (connectOptions.protocol === 'https:' ? 443 : 80); + + const { socket } = await SocksClient.createConnection({ + proxy: socksProxy.proxy, + destination: { + host: destinationHost, + port: destinationPort, + }, + command: 'connect', + timeout: DEFAULT_PROXY_CONNECT_TIMEOUT_MS, + socket_options: connectOptions.localAddress + ? { localAddress: connectOptions.localAddress } as any + : undefined, + }); + applySocketDefaults(socket); + + if (connectOptions.protocol !== 'https:') { + return socket; + } + + return await new Promise((resolve, reject) => { + const tlsSocket = tlsConnect({ + socket, + host: connectOptions.hostname, + servername: connectOptions.servername || (!isIP(connectOptions.hostname) ? connectOptions.hostname : undefined), + ALPNProtocols: ['http/1.1'], + }); + + const cleanup = (error: Error) => { + socket.destroy(); + tlsSocket.destroy(); + reject(error); + }; + + tlsSocket.once('secureConnect', () => { + tlsSocket.off('error', cleanup); + applySocketDefaults(tlsSocket); + resolve(tlsSocket); + }); + tlsSocket.once('error', cleanup); + }); +} + +function createSocksDispatcher(proxyUrl: URL): Dispatcher { + const socksProxy = parseSocksProxyUrl(proxyUrl); + return new UndiciAgent({ + connect: (connectOptions, callback) => { + void createSocksSocket(connectOptions, socksProxy) + .then((socket) => callback(null, socket)) + .catch((error) => { + callback(error instanceof Error ? error : new Error(String(error)), null as any); + }); + }, + }); +} + export function normalizeSiteProxyUrl(input: unknown): string | null { if (typeof input !== 'string') return null; const trimmed = input.trim(); @@ -167,18 +355,11 @@ export function invalidateSiteProxyCache(): void { siteProxyCache = { loadedAt: 0, rows: [], systemProxyUrl: null }; } -export async function resolveSiteProxyUrlByRequestUrl(requestUrl: string): Promise { - const normalizedRequestUrl = normalizeSiteUrl(requestUrl); - if (!normalizedRequestUrl) return null; - - const rows = await getCachedSiteProxyRows(); - const systemProxyUrl = siteProxyCache.systemProxyUrl; - if (!systemProxyUrl) return null; - let bestMatch: string | null = null; +function findBestMatchingSiteRow(rows: SiteProxyRow[], normalizedRequestUrl: string): SiteProxyRow | null { + let bestMatch: SiteProxyRow | null = null; let bestMatchLength = -1; for (const row of rows) { - if (!row.useSystemProxy) continue; if (!row.siteUrl) continue; const isPrefixMatch = ( @@ -189,7 +370,7 @@ export async function resolveSiteProxyUrlByRequestUrl(requestUrl: string): Promi if (!isPrefixMatch) continue; if (row.siteUrl.length > bestMatchLength) { - bestMatch = systemProxyUrl; + bestMatch = row; bestMatchLength = row.siteUrl.length; } } @@ -197,18 +378,57 @@ export async function resolveSiteProxyUrlByRequestUrl(requestUrl: string): Promi return bestMatch; } +async function resolveSiteRequestConfigByRequestUrl(requestUrl: string): Promise<{ + proxyUrl: string | null; + customHeaders: unknown; +}> { + const normalizedRequestUrl = normalizeSiteUrl(requestUrl); + if (!normalizedRequestUrl) { + return { proxyUrl: null, customHeaders: null }; + } + + const rows = await getCachedSiteProxyRows(); + const matchedRow = findBestMatchingSiteRow(rows, normalizedRequestUrl); + const proxyUrl = matchedRow?.proxyUrl + || (matchedRow?.useSystemProxy ? siteProxyCache.systemProxyUrl : null); + return { + proxyUrl: proxyUrl || null, + customHeaders: matchedRow?.customHeaders ?? null, + }; +} + +export async function resolveSiteProxyUrlByRequestUrl(requestUrl: string): Promise { + const resolved = await resolveSiteRequestConfigByRequestUrl(requestUrl); + return resolved.proxyUrl; +} + export async function withSiteProxyRequestInit( requestUrl: string, options?: UndiciRequestInit, ): Promise { - const proxyUrl = await resolveSiteProxyUrlByRequestUrl(requestUrl); - if (!proxyUrl) return options ?? {}; + const resolved = await resolveSiteRequestConfigByRequestUrl(requestUrl); + const nextOptions: UndiciRequestInit = { + ...(options || {}), + }; + const mergedHeaders = mergeHeadersWithSiteCustomHeaders(resolved.customHeaders, options?.headers); + if (mergedHeaders) { + nextOptions.headers = mergedHeaders; + } - const dispatcher = getDispatcherByProxyUrl(proxyUrl); - if (!dispatcher) return options ?? {}; + const alsOverride = accountProxyOverride.getStore(); + const proxyUrl = alsOverride ?? resolved.proxyUrl; + + if (!proxyUrl) { + return nextOptions; + } + + const dispatcher = getDispatcherByProxyUrl(proxyUrl, alsOverride != null); + if (!dispatcher) { + return nextOptions; + } return { - ...(options || {}), + ...nextOptions, dispatcher, }; } @@ -216,11 +436,12 @@ export async function withSiteProxyRequestInit( export function withExplicitProxyRequestInit( proxyUrl: string | null | undefined, options?: UndiciRequestInit, + skipCache = false, ): UndiciRequestInit { const normalized = normalizeSiteProxyUrl(proxyUrl); if (!normalized) return options ?? {}; - const dispatcher = getDispatcherByProxyUrl(normalized); + const dispatcher = getDispatcherByProxyUrl(normalized, skipCache); if (!dispatcher) return options ?? {}; return { @@ -230,6 +451,8 @@ export function withExplicitProxyRequestInit( } export function resolveProxyUrlForSite(site: SiteProxyConfigLike | null | undefined): string | null { + const explicitProxyUrl = normalizeSiteProxyUrl(site?.proxyUrl); + if (explicitProxyUrl) return explicitProxyUrl; if (!site?.useSystemProxy) return null; return normalizeSiteProxyUrl(config.systemProxyUrl); } @@ -237,6 +460,29 @@ export function resolveProxyUrlForSite(site: SiteProxyConfigLike | null | undefi export function withSiteRecordProxyRequestInit( site: SiteProxyConfigLike | null | undefined, options?: UndiciRequestInit, + accountProxyUrl?: string | null, ): UndiciRequestInit { - return withExplicitProxyRequestInit(resolveProxyUrlForSite(site), options); + const nextOptions: UndiciRequestInit = { + ...(options || {}), + }; + const mergedHeaders = mergeHeadersWithSiteCustomHeaders(site?.customHeaders, options?.headers); + if (mergedHeaders) { + nextOptions.headers = mergedHeaders; + } + const accountNormalized = normalizeSiteProxyUrl(accountProxyUrl) ?? accountProxyOverride.getStore(); + const siteProxyUrl = resolveProxyUrlForSite(site); + const proxyUrl = accountNormalized || siteProxyUrl; + const isAccountOverride = !!accountNormalized && accountNormalized !== siteProxyUrl; + return withExplicitProxyRequestInit(proxyUrl, nextOptions, isAccountOverride); +} + +export function resolveChannelProxyUrl( + site: SiteProxyConfigLike | null | undefined, + accountExtraConfig?: string | null, +): string | null { + if (accountExtraConfig) { + const normalized = normalizeSiteProxyUrl(getProxyUrlFromExtraConfig(accountExtraConfig)); + if (normalized) return normalized; + } + return resolveProxyUrlForSite(site); } diff --git a/src/server/services/todayIncomeRewardService.test.ts b/src/server/services/todayIncomeRewardService.test.ts index 61478832..da79c84e 100644 --- a/src/server/services/todayIncomeRewardService.test.ts +++ b/src/server/services/todayIncomeRewardService.test.ts @@ -62,4 +62,27 @@ describe('today income reward service', () => { }); expect(preferParsed).toBe(1.5); }); + + it('reads today income snapshots from parsed extraConfig objects', () => { + const day = formatLocalDate(new Date('2026-02-25T08:00:00.000Z')); + const extraConfig = { + todayIncomeSnapshot: { + day, + baseline: 8, + latest: 10.2, + updatedAt: '2026-02-25T12:00:00.000Z', + }, + }; + + expect(getTodayIncomeValue(extraConfig, day)).toBeCloseTo(10.2, 6); + expect(getTodayIncomeDelta(extraConfig, day)).toBeCloseTo(2.2, 6); + }); + + it('preserves missing extraConfig when income is invalid', () => { + expect(updateTodayIncomeSnapshot(null, Number.NaN)).toBeNull(); + expect(updateTodayIncomeSnapshot(undefined, -1)).toBeNull(); + expect(updateTodayIncomeSnapshot('', Number.NaN)).toBeNull(); + expect(updateTodayIncomeSnapshot('{"demo":true}', Number.NaN)).toBe('{"demo":true}'); + expect(updateTodayIncomeSnapshot({ demo: true }, Number.NaN)).toBe('{"demo":true}'); + }); }); diff --git a/src/server/services/todayIncomeRewardService.ts b/src/server/services/todayIncomeRewardService.ts index f57e8194..3e7e70a6 100644 --- a/src/server/services/todayIncomeRewardService.ts +++ b/src/server/services/todayIncomeRewardService.ts @@ -13,15 +13,20 @@ type EstimateRewardInput = { successCount: number; parsedRewardCount: number; rewardSum: number; - extraConfig?: string | null; + extraConfig?: string | Record | null; }; -function parseObject(value: string | null | undefined): Record { +function isRecord(value: unknown): value is Record { + return !!value && typeof value === 'object' && !Array.isArray(value); +} + +function parseObject(value: string | Record | null | undefined): Record { if (!value) return {}; + if (isRecord(value)) return value; + if (typeof value !== 'string') return {}; try { const parsed = JSON.parse(value); - if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {}; - return parsed as Record; + return isRecord(parsed) ? parsed : {}; } catch { return {}; } @@ -52,12 +57,15 @@ function normalizeSnapshot(raw: unknown): TodayIncomeSnapshot | null { }; } -function extractTodayIncomeSnapshot(extraConfig?: string | null): TodayIncomeSnapshot | null { +function extractTodayIncomeSnapshot(extraConfig?: string | Record | null): TodayIncomeSnapshot | null { const parsed = parseObject(extraConfig); return normalizeSnapshot(parsed.todayIncomeSnapshot); } -export function getTodayIncomeDelta(extraConfig: string | null | undefined, day: string): number { +export function getTodayIncomeDelta( + extraConfig: string | Record | null | undefined, + day: string, +): number { if (!day) return 0; const snapshot = extractTodayIncomeSnapshot(extraConfig); if (!snapshot || snapshot.day !== day) return 0; @@ -68,7 +76,10 @@ export function getTodayIncomeDelta(extraConfig: string | null | undefined, day: return delta; } -export function getTodayIncomeValue(extraConfig: string | null | undefined, day: string): number { +export function getTodayIncomeValue( + extraConfig: string | Record | null | undefined, + day: string, +): number { if (!day) return 0; const snapshot = extractTodayIncomeSnapshot(extraConfig); if (!snapshot || snapshot.day !== day) return 0; @@ -76,12 +87,16 @@ export function getTodayIncomeValue(extraConfig: string | null | undefined, day: } export function updateTodayIncomeSnapshot( - extraConfig: string | null | undefined, + extraConfig: string | Record | null | undefined, todayIncome: number, now = new Date(), -): string { +): string | null { const income = toNonNegativeNumber(todayIncome); - if (income == null) return extraConfig || '{}'; + if (income == null) { + if (typeof extraConfig === 'string') return extraConfig || null; + if (extraConfig == null) return null; + return JSON.stringify(parseObject(extraConfig)); + } const day = formatLocalDate(now); const existing = extractTodayIncomeSnapshot(extraConfig); diff --git a/src/server/services/tokenRouter.cache.test.ts b/src/server/services/tokenRouter.cache.test.ts index 738d1561..255555e6 100644 --- a/src/server/services/tokenRouter.cache.test.ts +++ b/src/server/services/tokenRouter.cache.test.ts @@ -13,6 +13,7 @@ describe('TokenRouter runtime cache', () => { let schema: DbModule['schema']; let TokenRouter: TokenRouterModule['TokenRouter']; let invalidateTokenRouterCache: TokenRouterModule['invalidateTokenRouterCache']; + let resetSiteRuntimeHealthState: TokenRouterModule['resetSiteRuntimeHealthState']; let config: ConfigModule['config']; let dataDir = ''; let originalCacheTtlMs = 0; @@ -29,6 +30,7 @@ describe('TokenRouter runtime cache', () => { schema = dbModule.schema; TokenRouter = tokenRouterModule.TokenRouter; invalidateTokenRouterCache = tokenRouterModule.invalidateTokenRouterCache; + resetSiteRuntimeHealthState = tokenRouterModule.resetSiteRuntimeHealthState; config = configModule.config; originalCacheTtlMs = config.tokenRouterCacheTtlMs; }); @@ -36,16 +38,19 @@ describe('TokenRouter runtime cache', () => { beforeEach(async () => { await db.delete(schema.routeChannels).run(); await db.delete(schema.tokenRoutes).run(); + await db.delete(schema.settings).run(); await db.delete(schema.accountTokens).run(); await db.delete(schema.accounts).run(); await db.delete(schema.sites).run(); config.tokenRouterCacheTtlMs = 60_000; invalidateTokenRouterCache(); + resetSiteRuntimeHealthState(); }); afterAll(() => { config.tokenRouterCacheTtlMs = originalCacheTtlMs; invalidateTokenRouterCache(); + resetSiteRuntimeHealthState(); delete process.env.DATA_DIR; }); @@ -100,4 +105,223 @@ describe('TokenRouter runtime cache', () => { const refreshedSelection = await router.selectChannel('gpt-4o-mini'); expect(refreshedSelection).toBeNull(); }); + + it('uses fibonacci-style cooldown across repeated failures', async () => { + const site = await db.insert(schema.sites).values({ + name: 'cooldown-site', + url: 'https://cooldown-site.example.com', + platform: 'new-api', + status: 'active', + }).returning().get(); + + const account = await db.insert(schema.accounts).values({ + siteId: site.id, + username: 'cooldown-user', + accessToken: 'cooldown-access-token', + apiToken: 'cooldown-api-token', + status: 'active', + }).returning().get(); + + const token = await db.insert(schema.accountTokens).values({ + accountId: account.id, + name: 'cooldown-token', + token: 'sk-cooldown-token', + enabled: true, + isDefault: true, + }).returning().get(); + + const route = await db.insert(schema.tokenRoutes).values({ + modelPattern: 'gpt-4o-mini', + routingStrategy: 'weighted', + enabled: true, + }).returning().get(); + + const channel = await db.insert(schema.routeChannels).values({ + routeId: route.id, + accountId: account.id, + tokenId: token.id, + priority: 0, + weight: 10, + enabled: true, + }).returning().get(); + + const router = new TokenRouter(); + + const firstStartedAt = Date.now(); + await router.recordFailure(channel.id); + const firstRecord = await db.select().from(schema.routeChannels) + .where(eq(schema.routeChannels.id, channel.id)) + .get(); + const firstCooldownMs = Date.parse(String(firstRecord?.cooldownUntil || '')) - firstStartedAt; + expect(firstCooldownMs).toBeGreaterThanOrEqual(10_000); + expect(firstCooldownMs).toBeLessThanOrEqual(20_000); + + const secondStartedAt = Date.now(); + await router.recordFailure(channel.id); + const secondRecord = await db.select().from(schema.routeChannels) + .where(eq(schema.routeChannels.id, channel.id)) + .get(); + const secondCooldownMs = Date.parse(String(secondRecord?.cooldownUntil || '')) - secondStartedAt; + expect(secondCooldownMs).toBeGreaterThanOrEqual(10_000); + expect(secondCooldownMs).toBeLessThanOrEqual(20_000); + + const thirdStartedAt = Date.now(); + await router.recordFailure(channel.id); + const thirdRecord = await db.select().from(schema.routeChannels) + .where(eq(schema.routeChannels.id, channel.id)) + .get(); + const thirdCooldownMs = Date.parse(String(thirdRecord?.cooldownUntil || '')) - thirdStartedAt; + expect(thirdCooldownMs).toBeGreaterThanOrEqual(25_000); + expect(thirdCooldownMs).toBeLessThanOrEqual(35_000); + }); + + it('round robins across all available channels regardless of priority', async () => { + const site = await db.insert(schema.sites).values({ + name: 'round-robin-site', + url: 'https://round-robin-site.example.com', + platform: 'new-api', + status: 'active', + }).returning().get(); + + const account = await db.insert(schema.accounts).values({ + siteId: site.id, + username: 'round-robin-user', + accessToken: 'round-robin-access-token', + apiToken: 'round-robin-api-token', + status: 'active', + }).returning().get(); + + const token = await db.insert(schema.accountTokens).values({ + accountId: account.id, + name: 'round-robin-token', + token: 'sk-round-robin-token', + enabled: true, + isDefault: true, + }).returning().get(); + + const route = await db.insert(schema.tokenRoutes).values({ + modelPattern: 'gpt-4o-mini', + routingStrategy: 'round_robin', + enabled: true, + }).returning().get(); + + const channels = await db.insert(schema.routeChannels).values([ + { routeId: route.id, accountId: account.id, tokenId: token.id, priority: 0, weight: 10, enabled: true }, + { routeId: route.id, accountId: account.id, tokenId: token.id, priority: 3, weight: 10, enabled: true }, + { routeId: route.id, accountId: account.id, tokenId: token.id, priority: 9, weight: 10, enabled: true }, + ]).returning().all(); + + const router = new TokenRouter(); + + const first = await router.selectChannel('gpt-4o-mini'); + const second = await router.selectChannel('gpt-4o-mini'); + const third = await router.selectChannel('gpt-4o-mini'); + const fourth = await router.selectChannel('gpt-4o-mini'); + + expect(first?.channel.id).toBe(channels[0].id); + expect(second?.channel.id).toBe(channels[1].id); + expect(third?.channel.id).toBe(channels[2].id); + expect(fourth?.channel.id).toBe(channels[0].id); + }); + + it('applies staged cooldowns for round robin after every three consecutive failures', async () => { + const site = await db.insert(schema.sites).values({ + name: 'round-robin-cooldown-site', + url: 'https://round-robin-cooldown-site.example.com', + platform: 'new-api', + status: 'active', + }).returning().get(); + + const account = await db.insert(schema.accounts).values({ + siteId: site.id, + username: 'round-robin-cooldown-user', + accessToken: 'round-robin-cooldown-access-token', + apiToken: 'round-robin-cooldown-api-token', + status: 'active', + }).returning().get(); + + const token = await db.insert(schema.accountTokens).values({ + accountId: account.id, + name: 'round-robin-cooldown-token', + token: 'sk-round-robin-cooldown-token', + enabled: true, + isDefault: true, + }).returning().get(); + + const route = await db.insert(schema.tokenRoutes).values({ + modelPattern: 'gpt-4o-mini', + routingStrategy: 'round_robin', + enabled: true, + }).returning().get(); + + const channel = await db.insert(schema.routeChannels).values({ + routeId: route.id, + accountId: account.id, + tokenId: token.id, + priority: 0, + weight: 10, + enabled: true, + }).returning().get(); + + const router = new TokenRouter(); + + for (let index = 0; index < 2; index += 1) { + await router.recordFailure(channel.id); + } + let current = await db.select().from(schema.routeChannels) + .where(eq(schema.routeChannels.id, channel.id)) + .get(); + expect(current?.cooldownUntil).toBeNull(); + expect(current?.consecutiveFailCount).toBe(2); + expect(current?.cooldownLevel).toBe(0); + + let startedAt = Date.now(); + await router.recordFailure(channel.id); + current = await db.select().from(schema.routeChannels) + .where(eq(schema.routeChannels.id, channel.id)) + .get(); + let cooldownMs = Date.parse(String(current?.cooldownUntil || '')) - startedAt; + expect(current?.consecutiveFailCount).toBe(0); + expect(current?.cooldownLevel).toBe(1); + expect(cooldownMs).toBeGreaterThanOrEqual(9 * 60 * 1000); + expect(cooldownMs).toBeLessThanOrEqual(11 * 60 * 1000); + + await db.update(schema.routeChannels).set({ cooldownUntil: null }).where(eq(schema.routeChannels.id, channel.id)).run(); + + for (let index = 0; index < 2; index += 1) { + await router.recordFailure(channel.id); + } + startedAt = Date.now(); + await router.recordFailure(channel.id); + current = await db.select().from(schema.routeChannels) + .where(eq(schema.routeChannels.id, channel.id)) + .get(); + cooldownMs = Date.parse(String(current?.cooldownUntil || '')) - startedAt; + expect(current?.cooldownLevel).toBe(2); + expect(cooldownMs).toBeGreaterThanOrEqual(59 * 60 * 1000); + expect(cooldownMs).toBeLessThanOrEqual(61 * 60 * 1000); + + await db.update(schema.routeChannels).set({ cooldownUntil: null }).where(eq(schema.routeChannels.id, channel.id)).run(); + + for (let index = 0; index < 2; index += 1) { + await router.recordFailure(channel.id); + } + startedAt = Date.now(); + await router.recordFailure(channel.id); + current = await db.select().from(schema.routeChannels) + .where(eq(schema.routeChannels.id, channel.id)) + .get(); + cooldownMs = Date.parse(String(current?.cooldownUntil || '')) - startedAt; + expect(current?.cooldownLevel).toBe(3); + expect(cooldownMs).toBeGreaterThanOrEqual(23 * 60 * 60 * 1000); + expect(cooldownMs).toBeLessThanOrEqual(25 * 60 * 60 * 1000); + + await router.recordSuccess(channel.id, 320, 0.12); + current = await db.select().from(schema.routeChannels) + .where(eq(schema.routeChannels.id, channel.id)) + .get(); + expect(current?.consecutiveFailCount).toBe(0); + expect(current?.cooldownLevel).toBe(0); + expect(current?.cooldownUntil).toBeNull(); + }); }); diff --git a/src/server/services/tokenRouter.downstream-policy.test.ts b/src/server/services/tokenRouter.downstream-policy.test.ts index f8a3c97a..c394a5fd 100644 --- a/src/server/services/tokenRouter.downstream-policy.test.ts +++ b/src/server/services/tokenRouter.downstream-policy.test.ts @@ -102,6 +102,47 @@ describe('TokenRouter downstream policy', () => { expect(blockedPick).toBeNull(); }); + it('rejects route selection when both supportedModels and allowedRouteIds are empty', async () => { + const site = await db.insert(schema.sites).values({ + name: 'site-deny-all', + url: 'https://deny-all.example.com', + platform: 'new-api', + status: 'active', + }).returning().get(); + + const account = await db.insert(schema.accounts).values({ + siteId: site.id, + username: 'user-deny-all', + accessToken: 'access-deny-all', + apiToken: 'sk-deny-all', + status: 'active', + }).returning().get(); + + const route = await db.insert(schema.tokenRoutes).values({ + modelPattern: 'gpt-4o-mini', + enabled: true, + }).returning().get(); + + await db.insert(schema.routeChannels).values({ + routeId: route.id, + accountId: account.id, + tokenId: null, + priority: 0, + weight: 10, + enabled: true, + }).run(); + + const router = new TokenRouter(); + const deniedPick = await router.selectChannel('gpt-4o-mini', { + allowedRouteIds: [], + supportedModels: [], + siteWeightMultipliers: {}, + denyAllWhenEmpty: true, + }); + + expect(deniedPick).toBeNull(); + }); + it('applies site weight multipliers to probability explanation', async () => { const siteHigh = await db.insert(schema.sites).values({ name: 'high-site', diff --git a/src/server/services/tokenRouter.patterns.test.ts b/src/server/services/tokenRouter.patterns.test.ts index e5f1e2c7..f764b24a 100644 --- a/src/server/services/tokenRouter.patterns.test.ts +++ b/src/server/services/tokenRouter.patterns.test.ts @@ -11,6 +11,7 @@ describe('TokenRouter patterns and model mapping', () => { let schema: DbModule['schema']; let TokenRouter: TokenRouterModule['TokenRouter']; let invalidateTokenRouterCache: TokenRouterModule['invalidateTokenRouterCache']; + let tokenRouterTestUtils: TokenRouterModule['__tokenRouterTestUtils']; let dataDir = ''; let idSeed = 0; @@ -30,6 +31,7 @@ describe('TokenRouter patterns and model mapping', () => { schema = dbModule.schema; TokenRouter = tokenRouterModule.TokenRouter; invalidateTokenRouterCache = tokenRouterModule.invalidateTokenRouterCache; + tokenRouterTestUtils = tokenRouterModule.__tokenRouterTestUtils; }); beforeEach(async () => { @@ -93,6 +95,27 @@ describe('TokenRouter patterns and model mapping', () => { return { route, channel }; } + async function createExplicitGroupRoute( + displayName: string, + sourceRouteIds: number[], + ) { + const route = await db.insert(schema.tokenRoutes).values({ + modelPattern: displayName, + displayName, + routeMode: 'explicit_group', + enabled: true, + }).returning().get(); + + await db.insert(schema.routeGroupSources).values( + sourceRouteIds.map((sourceRouteId) => ({ + groupRouteId: route.id, + sourceRouteId, + })), + ).run(); + + return route; + } + it('matches routes with re: regex patterns', async () => { await createRouteWithSingleChannel('re:^claude-(opus|sonnet)-4-6$'); const router = new TokenRouter(); @@ -134,6 +157,17 @@ describe('TokenRouter patterns and model mapping', () => { expect(regex?.actualModel).toBe('target-regex'); }); + it('resolves mapped models from parsed object input for helper-level callers', () => { + expect(tokenRouterTestUtils.resolveMappedModel('claude-sonnet-4-6', { + 'claude-sonnet-4-6': 'target-exact', + 'claude-sonnet-*': 'target-glob', + })).toBe('target-exact'); + expect(tokenRouterTestUtils.resolveMappedModel('claude-sonnet-4-7', { + 'claude-sonnet-4-6': 'target-exact', + 'claude-sonnet-*': 'target-glob', + })).toBe('target-glob'); + }); + it('matches a route by display name alias as an exposed model', async () => { await createRouteWithSingleChannel( 're:^claude-(opus|sonnet)-4-5$', @@ -154,4 +188,46 @@ describe('TokenRouter patterns and model mapping', () => { expect(decision.actualModel).toBe('claude-opus-4-5'); expect(exposedModels).toContain('claude-opus-4-6'); }); + + it('prefers an exact route over a colliding group display-name alias', async () => { + await createRouteWithSingleChannel( + 're:^claude-(opus|sonnet)-4-5$', + undefined, + { + displayName: 'claude-opus-4-6', + sourceModel: 'claude-opus-4-5', + }, + ); + const exact = await createRouteWithSingleChannel( + 'claude-opus-4-6', + undefined, + { + sourceModel: 'claude-opus-4-6', + }, + ); + const router = new TokenRouter(); + + const selected = await router.selectChannel('claude-opus-4-6'); + const decision = await router.explainSelection('claude-opus-4-6'); + + expect(selected).toBeTruthy(); + expect(selected?.channel.id).toBe(exact.channel.id); + expect(selected?.actualModel).toBe('claude-opus-4-6'); + expect(decision.actualModel).toBe('claude-opus-4-6'); + }); + + it('falls back to the source exact-route model when explicit-group channels omit sourceModel', async () => { + const source = await createRouteWithSingleChannel('claude-opus-4-5'); + await createExplicitGroupRoute('claude-test-4.6-sonnet', [source.route.id]); + const router = new TokenRouter(); + + const selected = await router.selectChannel('claude-test-4.6-sonnet'); + const decision = await router.explainSelection('claude-test-4.6-sonnet'); + + expect(selected).toBeTruthy(); + expect(selected?.actualModel).toBe('claude-opus-4-5'); + expect(decision.actualModel).toBe('claude-opus-4-5'); + expect(decision.summary).toContain('按显示名命中:claude-test-4.6-sonnet'); + expect(decision.summary).toContain('实际转发模型:claude-opus-4-5'); + }); }); diff --git a/src/server/services/tokenRouter.selection.test.ts b/src/server/services/tokenRouter.selection.test.ts index 73764a66..9909e84d 100644 --- a/src/server/services/tokenRouter.selection.test.ts +++ b/src/server/services/tokenRouter.selection.test.ts @@ -2,6 +2,7 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vites import { mkdtempSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; +import { eq } from 'drizzle-orm'; type DbModule = typeof import('../db/index.js'); type TokenRouterModule = typeof import('./tokenRouter.js'); @@ -24,6 +25,8 @@ describe('TokenRouter selection scoring', () => { let schema: DbModule['schema']; let TokenRouter: TokenRouterModule['TokenRouter']; let invalidateTokenRouterCache: TokenRouterModule['invalidateTokenRouterCache']; + let resetSiteRuntimeHealthState: TokenRouterModule['resetSiteRuntimeHealthState']; + let flushSiteRuntimeHealthPersistence: TokenRouterModule['flushSiteRuntimeHealthPersistence']; let config: ConfigModule['config']; let dataDir = ''; let idSeed = 0; @@ -47,6 +50,8 @@ describe('TokenRouter selection scoring', () => { schema = dbModule.schema; TokenRouter = tokenRouterModule.TokenRouter; invalidateTokenRouterCache = tokenRouterModule.invalidateTokenRouterCache; + resetSiteRuntimeHealthState = tokenRouterModule.resetSiteRuntimeHealthState; + flushSiteRuntimeHealthPersistence = tokenRouterModule.flushSiteRuntimeHealthPersistence; config = configModule.config; originalRoutingWeights = { ...config.routingWeights }; originalRoutingFallbackUnitCost = config.routingFallbackUnitCost; @@ -58,16 +63,19 @@ describe('TokenRouter selection scoring', () => { mockedCatalogRoutingCost.mockReturnValue(null); await db.delete(schema.routeChannels).run(); await db.delete(schema.tokenRoutes).run(); + await db.delete(schema.settings).run(); await db.delete(schema.accountTokens).run(); await db.delete(schema.accounts).run(); await db.delete(schema.sites).run(); invalidateTokenRouterCache(); + resetSiteRuntimeHealthState(); }); afterAll(() => { config.routingWeights = { ...originalRoutingWeights }; config.routingFallbackUnitCost = originalRoutingFallbackUnitCost; invalidateTokenRouterCache(); + resetSiteRuntimeHealthState(); delete process.env.DATA_DIR; }); @@ -109,6 +117,54 @@ describe('TokenRouter selection scoring', () => { }).returning().get(); } + it('reuses a preferred channel only while it remains healthy', async () => { + config.routingWeights = { + baseWeightFactor: 1, + valueScoreFactor: 0, + costWeight: 0, + balanceWeight: 0, + usageWeight: 0, + }; + + const route = await createRoute('gpt-5.2'); + const site = await createSite('sticky-site'); + const account = await createAccount(site.id, 'sticky-user'); + const tokenA = await createToken(account.id, 'sticky-a'); + const tokenB = await createToken(account.id, 'sticky-b'); + + const preferredChannel = await db.insert(schema.routeChannels).values({ + routeId: route.id, + accountId: account.id, + tokenId: tokenA.id, + priority: 0, + weight: 10, + enabled: true, + failCount: 0, + }).returning().get(); + + await db.insert(schema.routeChannels).values({ + routeId: route.id, + accountId: account.id, + tokenId: tokenB.id, + priority: 0, + weight: 10, + enabled: true, + failCount: 0, + }).run(); + + const router = new TokenRouter(); + const selected = await router.selectPreferredChannel('gpt-5.2', preferredChannel.id); + expect(selected?.channel.id).toBe(preferredChannel.id); + + await db.update(schema.routeChannels).set({ + failCount: 4, + lastFailAt: new Date().toISOString(), + }).where(eq(schema.routeChannels.id, preferredChannel.id)).run(); + invalidateTokenRouterCache(); + + await expect(router.selectPreferredChannel('gpt-5.2', preferredChannel.id)).resolves.toBeNull(); + }); + it('normalizes probability across channels on the same site', async () => { config.routingWeights = { baseWeightFactor: 1, @@ -398,4 +454,461 @@ describe('TokenRouter selection scoring', () => { expect(catalogCandidate?.reason || '').toContain('成本=目录:0.200000'); expect(fallbackCandidate?.reason || '').toContain('成本=默认:100.000000'); }); + + it('downweights a site after transient failures and restores it quickly after success', async () => { + config.routingWeights = { + baseWeightFactor: 1, + valueScoreFactor: 0, + costWeight: 0, + balanceWeight: 0, + usageWeight: 0, + }; + + const route = await createRoute('gpt-5.4'); + + const siteA = await createSite('runtime-a'); + const accountA = await createAccount(siteA.id, 'runtime-user-a'); + const tokenA = await createToken(accountA.id, 'runtime-token-a'); + const channelA = await db.insert(schema.routeChannels).values({ + routeId: route.id, + accountId: accountA.id, + tokenId: tokenA.id, + priority: 0, + weight: 10, + enabled: true, + }).returning().get(); + + const siteB = await createSite('runtime-b'); + const accountB = await createAccount(siteB.id, 'runtime-user-b'); + const tokenB = await createToken(accountB.id, 'runtime-token-b'); + const channelB = await db.insert(schema.routeChannels).values({ + routeId: route.id, + accountId: accountB.id, + tokenId: tokenB.id, + priority: 0, + weight: 10, + enabled: true, + }).returning().get(); + + const router = new TokenRouter(); + let decision = await router.explainSelection('gpt-5.4'); + let candidateA = decision.candidates.find((candidate) => candidate.channelId === channelA.id); + let candidateB = decision.candidates.find((candidate) => candidate.channelId === channelB.id); + expect(candidateA?.probability).toBeCloseTo(50, 1); + expect(candidateB?.probability).toBeCloseTo(50, 1); + + await router.recordFailure(channelA.id, { + status: 502, + errorText: 'Bad gateway', + }); + await db.update(schema.routeChannels).set({ + cooldownUntil: null, + lastFailAt: null, + failCount: 0, + }).where(eq(schema.routeChannels.id, channelA.id)).run(); + invalidateTokenRouterCache(); + + decision = await router.explainSelection('gpt-5.4'); + candidateA = decision.candidates.find((candidate) => candidate.channelId === channelA.id); + candidateB = decision.candidates.find((candidate) => candidate.channelId === channelB.id); + expect(candidateA).toBeTruthy(); + expect(candidateB).toBeTruthy(); + expect((candidateA?.probability || 0)).toBeLessThan(30); + expect(candidateA?.reason || '').toContain('运行时健康='); + expect((candidateB?.probability || 0)).toBeGreaterThan(70); + + await router.recordSuccess(channelA.id, 800, 0); + invalidateTokenRouterCache(); + + decision = await router.explainSelection('gpt-5.4'); + candidateA = decision.candidates.find((candidate) => candidate.channelId === channelA.id); + candidateB = decision.candidates.find((candidate) => candidate.channelId === channelB.id); + expect((candidateA?.probability || 0)).toBeGreaterThan(40); + expect((candidateB?.probability || 0)).toBeLessThan(60); + }); + + it('opens a site breaker after repeated transient failures and closes it after recovery', async () => { + config.routingWeights = { + baseWeightFactor: 1, + valueScoreFactor: 0, + costWeight: 0, + balanceWeight: 0, + usageWeight: 0, + }; + + const route = await createRoute('gpt-5.3'); + + const siteA = await createSite('breaker-a'); + const accountA = await createAccount(siteA.id, 'breaker-user-a'); + const tokenA = await createToken(accountA.id, 'breaker-token-a'); + const channelA = await db.insert(schema.routeChannels).values({ + routeId: route.id, + accountId: accountA.id, + tokenId: tokenA.id, + priority: 0, + weight: 10, + enabled: true, + }).returning().get(); + + const siteB = await createSite('breaker-b'); + const accountB = await createAccount(siteB.id, 'breaker-user-b'); + const tokenB = await createToken(accountB.id, 'breaker-token-b'); + const channelB = await db.insert(schema.routeChannels).values({ + routeId: route.id, + accountId: accountB.id, + tokenId: tokenB.id, + priority: 0, + weight: 10, + enabled: true, + }).returning().get(); + + const router = new TokenRouter(); + for (let index = 0; index < 3; index += 1) { + await router.recordFailure(channelA.id, { + status: 502, + errorText: 'Gateway timeout', + }); + } + await db.update(schema.routeChannels).set({ + cooldownUntil: null, + lastFailAt: null, + failCount: 0, + }).where(eq(schema.routeChannels.id, channelA.id)).run(); + invalidateTokenRouterCache(); + + let decision = await router.explainSelection('gpt-5.3'); + const breakerCandidateA = decision.candidates.find((candidate) => candidate.channelId === channelA.id); + const breakerCandidateB = decision.candidates.find((candidate) => candidate.channelId === channelB.id); + expect(breakerCandidateA?.reason || '').toContain('站点熔断'); + expect((breakerCandidateA?.probability || 0)).toBe(0); + expect((breakerCandidateB?.probability || 0)).toBe(100); + expect(decision.summary.join(' ')).toContain('站点熔断避让'); + + await router.recordSuccess(channelA.id, 600, 0); + invalidateTokenRouterCache(); + + decision = await router.explainSelection('gpt-5.3'); + const recoveredCandidateA = decision.candidates.find((candidate) => candidate.channelId === channelA.id); + const recoveredCandidateB = decision.candidates.find((candidate) => candidate.channelId === channelB.id); + expect((recoveredCandidateA?.probability || 0)).toBeGreaterThan(30); + expect((recoveredCandidateB?.probability || 0)).toBeLessThan(70); + }); + + it('does not open a site breaker for repeated timeout validation errors', async () => { + config.routingWeights = { + baseWeightFactor: 1, + valueScoreFactor: 0, + costWeight: 0, + balanceWeight: 0, + usageWeight: 0, + }; + + const route = await createRoute('gpt-5.4'); + + const siteA = await createSite('timeout-validation-a'); + const accountA = await createAccount(siteA.id, 'timeout-validation-user-a'); + const tokenA = await createToken(accountA.id, 'timeout-validation-token-a'); + const channelA = await db.insert(schema.routeChannels).values({ + routeId: route.id, + accountId: accountA.id, + tokenId: tokenA.id, + priority: 0, + weight: 10, + enabled: true, + }).returning().get(); + + const siteB = await createSite('timeout-validation-b'); + const accountB = await createAccount(siteB.id, 'timeout-validation-user-b'); + const tokenB = await createToken(accountB.id, 'timeout-validation-token-b'); + const channelB = await db.insert(schema.routeChannels).values({ + routeId: route.id, + accountId: accountB.id, + tokenId: tokenB.id, + priority: 0, + weight: 10, + enabled: true, + }).returning().get(); + + const router = new TokenRouter(); + for (let index = 0; index < 3; index += 1) { + await router.recordFailure(channelA.id, { + status: 400, + errorText: 'invalid timeout parameter', + }); + } + await db.update(schema.routeChannels).set({ + cooldownUntil: null, + lastFailAt: null, + failCount: 0, + }).where(eq(schema.routeChannels.id, channelA.id)).run(); + invalidateTokenRouterCache(); + + const decision = await router.explainSelection('gpt-5.4'); + const candidateA = decision.candidates.find((candidate) => candidate.channelId === channelA.id); + const candidateB = decision.candidates.find((candidate) => candidate.channelId === channelB.id); + + expect(candidateA).toBeTruthy(); + expect(candidateB).toBeTruthy(); + expect(candidateA?.reason || '').not.toContain('站点熔断'); + expect(candidateB?.reason || '').not.toContain('站点熔断'); + expect(decision.summary.join(' ')).not.toContain('站点熔断避让'); + expect((candidateA?.probability || 0)).toBeGreaterThan(0); + }); + + it('uses persisted site success and latency history to prefer historically healthier sites', async () => { + config.routingWeights = { + baseWeightFactor: 1, + valueScoreFactor: 0, + costWeight: 0, + balanceWeight: 0, + usageWeight: 0, + }; + + const route = await createRoute('claude-4-sonnet'); + + const siteStable = await createSite('history-stable'); + const accountStable = await createAccount(siteStable.id, 'history-user-stable'); + const tokenStable = await createToken(accountStable.id, 'history-token-stable'); + await db.insert(schema.routeChannels).values({ + routeId: route.id, + accountId: accountStable.id, + tokenId: tokenStable.id, + priority: 0, + weight: 10, + enabled: true, + successCount: 90, + failCount: 10, + totalLatencyMs: 90 * 240, + }).run(); + + const siteWeak = await createSite('history-weak'); + const accountWeak = await createAccount(siteWeak.id, 'history-user-weak'); + const tokenWeak = await createToken(accountWeak.id, 'history-token-weak'); + await db.insert(schema.routeChannels).values({ + routeId: route.id, + accountId: accountWeak.id, + tokenId: tokenWeak.id, + priority: 0, + weight: 10, + enabled: true, + successCount: 20, + failCount: 30, + totalLatencyMs: 20 * 5200, + }).run(); + + const decision = await new TokenRouter().explainSelection('claude-4-sonnet'); + const stableCandidate = decision.candidates.find((candidate) => candidate.siteName.startsWith('history-stable')); + const weakCandidate = decision.candidates.find((candidate) => candidate.siteName.startsWith('history-weak')); + + expect(stableCandidate).toBeTruthy(); + expect(weakCandidate).toBeTruthy(); + expect((stableCandidate?.probability || 0)).toBeGreaterThan(weakCandidate?.probability || 0); + expect(stableCandidate?.reason || '').toContain('历史健康='); + expect(stableCandidate?.reason || '').toContain('成功率=90.0%'); + expect(weakCandidate?.reason || '').toContain('成功率=40.0%'); + }); + + it('reloads persisted runtime health after in-memory reset', async () => { + config.routingWeights = { + baseWeightFactor: 1, + valueScoreFactor: 0, + costWeight: 0, + balanceWeight: 0, + usageWeight: 0, + }; + + const route = await createRoute('gpt-4o-mini'); + + const siteA = await createSite('persist-a'); + const accountA = await createAccount(siteA.id, 'persist-user-a'); + const tokenA = await createToken(accountA.id, 'persist-token-a'); + const channelA = await db.insert(schema.routeChannels).values({ + routeId: route.id, + accountId: accountA.id, + tokenId: tokenA.id, + priority: 0, + weight: 10, + enabled: true, + }).returning().get(); + + const siteB = await createSite('persist-b'); + const accountB = await createAccount(siteB.id, 'persist-user-b'); + const tokenB = await createToken(accountB.id, 'persist-token-b'); + const channelB = await db.insert(schema.routeChannels).values({ + routeId: route.id, + accountId: accountB.id, + tokenId: tokenB.id, + priority: 0, + weight: 10, + enabled: true, + }).returning().get(); + + const router = new TokenRouter(); + await router.recordFailure(channelA.id, { + status: 502, + errorText: 'Gateway timeout', + modelName: 'gpt-4o-mini', + }); + await db.update(schema.routeChannels).set({ + cooldownUntil: null, + lastFailAt: null, + failCount: 0, + }).where(eq(schema.routeChannels.id, channelA.id)).run(); + await flushSiteRuntimeHealthPersistence(); + + const persisted = await db.select().from(schema.settings) + .where(eq(schema.settings.key, 'token_router_site_runtime_health_v1')) + .get(); + expect(persisted?.value).toBeTruthy(); + + resetSiteRuntimeHealthState(); + invalidateTokenRouterCache(); + + const decision = await new TokenRouter().explainSelection('gpt-4o-mini'); + const candidateA = decision.candidates.find((candidate) => candidate.channelId === channelA.id); + const candidateB = decision.candidates.find((candidate) => candidate.channelId === channelB.id); + + expect(candidateA).toBeTruthy(); + expect(candidateB).toBeTruthy(); + expect((candidateA?.probability || 0)).toBeLessThan((candidateB?.probability || 0)); + expect(candidateA?.reason || '').toContain('运行时健康='); + }); + + it('penalizes the failed model more than unrelated models on the same site', async () => { + config.routingWeights = { + baseWeightFactor: 1, + valueScoreFactor: 0, + costWeight: 0, + balanceWeight: 0, + usageWeight: 0, + }; + + const gptRoute = await createRoute('gpt-5.4'); + const claudeRoute = await createRoute('claude-sonnet-4-6'); + + const siteA = await createSite('model-aware-a'); + const accountA = await createAccount(siteA.id, 'model-aware-user-a'); + const tokenA = await createToken(accountA.id, 'model-aware-token-a'); + const gptChannelA = await db.insert(schema.routeChannels).values({ + routeId: gptRoute.id, + accountId: accountA.id, + tokenId: tokenA.id, + priority: 0, + weight: 10, + enabled: true, + }).returning().get(); + await db.insert(schema.routeChannels).values({ + routeId: claudeRoute.id, + accountId: accountA.id, + tokenId: tokenA.id, + priority: 0, + weight: 10, + enabled: true, + }).run(); + + const siteB = await createSite('model-aware-b'); + const accountB = await createAccount(siteB.id, 'model-aware-user-b'); + const tokenB = await createToken(accountB.id, 'model-aware-token-b'); + await db.insert(schema.routeChannels).values([ + { + routeId: gptRoute.id, + accountId: accountB.id, + tokenId: tokenB.id, + priority: 0, + weight: 10, + enabled: true, + }, + { + routeId: claudeRoute.id, + accountId: accountB.id, + tokenId: tokenB.id, + priority: 0, + weight: 10, + enabled: true, + }, + ]).run(); + + const router = new TokenRouter(); + await router.recordFailure(gptChannelA.id, { + status: 502, + errorText: 'Bad gateway', + modelName: 'gpt-5.4', + }); + await db.update(schema.routeChannels).set({ + cooldownUntil: null, + lastFailAt: null, + failCount: 0, + }).where(eq(schema.routeChannels.id, gptChannelA.id)).run(); + invalidateTokenRouterCache(); + + const gptDecision = await router.explainSelection('gpt-5.4'); + const claudeDecision = await router.explainSelection('claude-sonnet-4-6'); + const gptCandidateA = gptDecision.candidates.find((candidate) => candidate.siteName.startsWith('model-aware-a')); + const claudeCandidateA = claudeDecision.candidates.find((candidate) => candidate.siteName.startsWith('model-aware-a')); + + expect(gptCandidateA).toBeTruthy(); + expect(claudeCandidateA).toBeTruthy(); + expect((gptCandidateA?.probability || 0)).toBeLessThan((claudeCandidateA?.probability || 0)); + expect(gptCandidateA?.reason || '').toContain('模型='); + }); + + it('stable_first deterministically chooses the healthiest candidate', async () => { + config.routingWeights = { + baseWeightFactor: 1, + valueScoreFactor: 0, + costWeight: 0, + balanceWeight: 0, + usageWeight: 0, + }; + + const route = await db.insert(schema.tokenRoutes).values({ + modelPattern: 'gpt-5.1', + routingStrategy: 'stable_first', + enabled: true, + }).returning().get(); + + const siteA = await createSite('stable-first-a'); + const accountA = await createAccount(siteA.id, 'stable-first-user-a'); + const tokenA = await createToken(accountA.id, 'stable-first-token-a'); + const channelA = await db.insert(schema.routeChannels).values({ + routeId: route.id, + accountId: accountA.id, + tokenId: tokenA.id, + priority: 0, + weight: 10, + enabled: true, + }).returning().get(); + + const siteB = await createSite('stable-first-b'); + const accountB = await createAccount(siteB.id, 'stable-first-user-b'); + const tokenB = await createToken(accountB.id, 'stable-first-token-b'); + const channelB = await db.insert(schema.routeChannels).values({ + routeId: route.id, + accountId: accountB.id, + tokenId: tokenB.id, + priority: 0, + weight: 10, + enabled: true, + }).returning().get(); + + const router = new TokenRouter(); + await router.recordFailure(channelA.id, { + status: 502, + errorText: 'Gateway timeout', + modelName: 'gpt-5.1', + }); + await db.update(schema.routeChannels).set({ + cooldownUntil: null, + lastFailAt: null, + failCount: 0, + }).where(eq(schema.routeChannels.id, channelA.id)).run(); + invalidateTokenRouterCache(); + + const preview = await router.previewSelectedChannel('gpt-5.1'); + const decision = await router.explainSelection('gpt-5.1'); + + expect(preview?.channel.id).toBe(channelB.id); + expect(decision.summary.join(' ')).toContain('稳定优先'); + expect(decision.selectedChannelId).toBe(channelB.id); + }); }); diff --git a/src/server/services/tokenRouter.siteStatus.test.ts b/src/server/services/tokenRouter.siteStatus.test.ts index 3b075061..6a77e4a3 100644 --- a/src/server/services/tokenRouter.siteStatus.test.ts +++ b/src/server/services/tokenRouter.siteStatus.test.ts @@ -119,4 +119,138 @@ describe('TokenRouter site status guard', () => { const selected = await new TokenRouter().selectChannel('gpt-4.1-mini'); expect(selected).toBeNull(); }); + + it('uses codex oauth access token when no api token is present', async () => { + const site = await db.insert(schema.sites).values({ + name: 'codex-site', + url: 'https://chatgpt.com/backend-api/codex', + platform: 'codex', + status: 'active', + }).returning().get(); + + const account = await db.insert(schema.accounts).values({ + siteId: site.id, + username: 'codex-user@example.com', + accessToken: 'oauth-access-token', + apiToken: null, + status: 'active', + extraConfig: JSON.stringify({ + credentialMode: 'session', + oauth: { + provider: 'codex', + accountId: 'chatgpt-account-123', + email: 'codex-user@example.com', + planType: 'plus', + }, + }), + }).returning().get(); + + const route = await db.insert(schema.tokenRoutes).values({ + modelPattern: 'gpt-5.2-codex', + enabled: true, + }).returning().get(); + + await db.insert(schema.routeChannels).values({ + routeId: route.id, + accountId: account.id, + tokenId: null, + priority: 0, + weight: 10, + enabled: true, + manualOverride: false, + }).run(); + + const selected = await new TokenRouter().selectChannel('gpt-5.2-codex'); + expect(selected).not.toBeNull(); + expect(selected?.tokenValue).toBe('oauth-access-token'); + }); + + it('prefers codex oauth access token over a stale legacy api token on oauth-managed rows', async () => { + const site = await db.insert(schema.sites).values({ + name: 'codex-site', + url: 'https://chatgpt.com/backend-api/codex', + platform: 'codex', + status: 'active', + }).returning().get(); + + const account = await db.insert(schema.accounts).values({ + siteId: site.id, + username: 'codex-user@example.com', + accessToken: 'oauth-access-token', + apiToken: 'stale-direct-token', + status: 'active', + extraConfig: JSON.stringify({ + credentialMode: 'session', + oauth: { + provider: 'codex', + accountId: 'chatgpt-account-123', + email: 'codex-user@example.com', + planType: 'plus', + }, + }), + }).returning().get(); + + const route = await db.insert(schema.tokenRoutes).values({ + modelPattern: 'gpt-5.2-codex', + enabled: true, + }).returning().get(); + + await db.insert(schema.routeChannels).values({ + routeId: route.id, + accountId: account.id, + tokenId: null, + priority: 0, + weight: 10, + enabled: true, + manualOverride: false, + }).run(); + + const selected = await new TokenRouter().selectChannel('gpt-5.2-codex'); + expect(selected).not.toBeNull(); + expect(selected?.tokenValue).toBe('oauth-access-token'); + }); + + it('does not fall back to a stale legacy api token when an oauth-managed row has no access token', async () => { + const site = await db.insert(schema.sites).values({ + name: 'codex-site', + url: 'https://chatgpt.com/backend-api/codex', + platform: 'codex', + status: 'active', + }).returning().get(); + + const account = await db.insert(schema.accounts).values({ + siteId: site.id, + username: 'codex-user@example.com', + accessToken: '', + apiToken: 'stale-direct-token', + status: 'active', + extraConfig: JSON.stringify({ + credentialMode: 'session', + oauth: { + provider: 'codex', + accountId: 'chatgpt-account-123', + email: 'codex-user@example.com', + planType: 'plus', + }, + }), + }).returning().get(); + + const route = await db.insert(schema.tokenRoutes).values({ + modelPattern: 'gpt-5.2-codex', + enabled: true, + }).returning().get(); + + await db.insert(schema.routeChannels).values({ + routeId: route.id, + accountId: account.id, + tokenId: null, + priority: 0, + weight: 10, + enabled: true, + manualOverride: false, + }).run(); + + const selected = await new TokenRouter().selectChannel('gpt-5.2-codex'); + expect(selected).toBeNull(); + }); }); diff --git a/src/server/services/tokenRouter.test.ts b/src/server/services/tokenRouter.test.ts index 0493f813..087630d6 100644 --- a/src/server/services/tokenRouter.test.ts +++ b/src/server/services/tokenRouter.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { filterRecentlyFailedCandidates } from './tokenRouter.js'; +import { filterRecentlyFailedCandidates, isChannelRecentlyFailed, matchesModelPattern, parseRegexModelPattern } from './tokenRouter.js'; type Candidate = { channel: { @@ -10,6 +10,31 @@ type Candidate = { }; describe('filterRecentlyFailedCandidates', () => { + it('rejects unsafe nested-quantifier regex route patterns', () => { + expect(parseRegexModelPattern('re:(?=claude)')).toBeNull(); + expect(matchesModelPattern('claude-sonnet-4-6', 're:(?=claude)')).toBe(false); + }); + + it('uses a short default recent-failure window', () => { + const nowMs = Date.now(); + expect(isChannelRecentlyFailed({ + failCount: 1, + lastFailAt: new Date(nowMs - 20 * 1000).toISOString(), + }, nowMs)).toBe(false); + }); + + it('expands the avoidance window with fibonacci-style backoff', () => { + const nowMs = Date.now(); + expect(isChannelRecentlyFailed({ + failCount: 4, + lastFailAt: new Date(nowMs - 40 * 1000).toISOString(), + }, nowMs)).toBe(true); + expect(isChannelRecentlyFailed({ + failCount: 4, + lastFailAt: new Date(nowMs - 50 * 1000).toISOString(), + }, nowMs)).toBe(false); + }); + it('prefers healthy channels when at least one healthy channel exists', () => { const nowMs = Date.now(); const candidates: Candidate[] = [ @@ -56,6 +81,29 @@ describe('filterRecentlyFailedCandidates', () => { expect(result.map((c) => c.id).sort()).toEqual(['a', 'b']); }); + it('keeps candidates unchanged when avoidSec is omitted', () => { + const nowMs = Date.now(); + const candidates: Candidate[] = [ + { + id: 'recent-failure', + channel: { + failCount: 3, + lastFailAt: new Date(nowMs - 5 * 1000).toISOString(), + }, + }, + { + id: 'healthy', + channel: { + failCount: 0, + lastFailAt: null, + }, + }, + ]; + + const result = filterRecentlyFailedCandidates(candidates, nowMs); + expect(result).toEqual(candidates); + }); + it('does not penalize stale failures outside the avoidance window', () => { const nowMs = Date.now(); const candidates: Candidate[] = [ diff --git a/src/server/services/tokenRouter.ts b/src/server/services/tokenRouter.ts index efb8ad89..c301b2a3 100644 --- a/src/server/services/tokenRouter.ts +++ b/src/server/services/tokenRouter.ts @@ -1,13 +1,32 @@ -import { eq } from 'drizzle-orm'; -import { minimatch } from 'minimatch'; -import { db, schema } from '../db/index.js'; -import { config } from '../config.js'; +import { eq, inArray } from 'drizzle-orm'; +import { db, schema } from '../db/index.js'; +import { upsertSetting } from '../db/upsertSetting.js'; +import { config } from '../config.js'; import { getCachedModelRoutingReferenceCost, refreshModelPricingCatalog } from './modelPricingService.js'; -import { type DownstreamRoutingPolicy, EMPTY_DOWNSTREAM_ROUTING_POLICY } from './downstreamPolicyTypes.js'; - -interface RouteMatch { - route: typeof schema.tokenRoutes.$inferSelect; - channels: Array<{ +import { RETRYABLE_TIMEOUT_PATTERNS } from './proxyRetryPolicy.js'; +import { + normalizeRouteRoutingStrategy, + type RouteRoutingStrategy, +} from './routeRoutingStrategy.js'; +import { type DownstreamRoutingPolicy, EMPTY_DOWNSTREAM_ROUTING_POLICY } from './downstreamPolicyTypes.js'; +import { isUsableAccountToken } from './accountTokenService.js'; +import { getOauthInfoFromAccount } from './oauth/oauthAccount.js'; +import { + isExactTokenRouteModelPattern, + isTokenRouteRegexPattern, + matchesTokenRouteModelPattern, + parseTokenRouteRegexPattern, +} from '../../shared/tokenRoutePatterns.js'; +import { + normalizeTokenRouteMode, + type RouteDecision, + type RouteDecisionCandidate, + type RouteMode, +} from '../../shared/tokenRouteContract.js'; + +interface RouteMatch { + route: RouteRow; + channels: Array<{ channel: typeof schema.routeChannels.$inferSelect; account: typeof schema.accounts.$inferSelect; site: typeof schema.sites.$inferSelect; @@ -27,16 +46,654 @@ interface SelectedChannel { actualModel: string; } -type FailureAwareChannel = { - failCount?: number | null; - lastFailAt?: string | null; -}; - -const RECENT_FAILURE_AVOID_SEC = 10 * 60; -const MIN_EFFECTIVE_UNIT_COST = 1e-6; +type FailureAwareChannel = { + failCount?: number | null; + lastFailAt?: string | null; +}; + +type SiteRuntimeFailureContext = { + status?: number | null; + errorText?: string | null; + modelName?: string | null; +}; + +type SiteRuntimeHealthState = { + penaltyScore: number; + latencyEmaMs: number | null; + transientFailureStreak: number; + lastTransientFailureAtMs: number | null; + breakerLevel: number; + breakerUntilMs: number | null; + lastUpdatedAtMs: number; + lastFailureAtMs: number | null; + lastSuccessAtMs: number | null; +}; + +const FAILURE_BACKOFF_BASE_SEC = 15; +const MIN_EFFECTIVE_UNIT_COST = 1e-6; +const ROUND_ROBIN_FAILURE_THRESHOLD = 3; +const ROUND_ROBIN_COOLDOWN_LEVELS_SEC = [0, 10 * 60, 60 * 60, 24 * 60 * 60] as const; +const SITE_RUNTIME_HEALTH_DECAY_HALF_LIFE_MS = 10 * 60 * 1000; +const SITE_RUNTIME_MIN_MULTIPLIER = 0.08; +const SITE_RUNTIME_LATENCY_BASELINE_MS = 2_500; +const SITE_RUNTIME_LATENCY_WINDOW_MS = 30_000; +const SITE_RUNTIME_MAX_LATENCY_PENALTY = 0.35; +const SITE_RUNTIME_LATENCY_EMA_ALPHA = 0.3; +const SITE_RUNTIME_BREAKER_STREAK_THRESHOLD = 3; +const SITE_RUNTIME_BREAKER_LEVELS_MS = [0, 60_000, 5 * 60_000, 30 * 60 * 1000] as const; +const SITE_TRANSIENT_STREAK_WINDOW_MS = 5 * 60 * 1000; +const SITE_HISTORICAL_HEALTH_MIN_MULTIPLIER = 0.45; +const SITE_HISTORICAL_HEALTH_MAX_SAMPLE = 24; +const SITE_HISTORICAL_LATENCY_BASELINE_MS = 2_000; +const SITE_HISTORICAL_LATENCY_WINDOW_MS = 20_000; +const SITE_HISTORICAL_MAX_LATENCY_PENALTY = 0.18; +const SITE_RUNTIME_HEALTH_SETTING_KEY = 'token_router_site_runtime_health_v1'; +const SITE_RUNTIME_HEALTH_PERSIST_DEBOUNCE_MS = 500; +const SITE_RUNTIME_HEALTH_PERSIST_STALE_TTL_MS = 7 * 24 * 60 * 60 * 1000; +const SITE_RUNTIME_HEALTH_PERSIST_IDLE_TTL_MS = 12 * 60 * 60 * 1000; +const SITE_RUNTIME_HEALTH_PERSIST_MIN_PENALTY = 0.02; + +const SITE_PROTOCOL_FAILURE_PATTERNS: RegExp[] = [ + /unsupported\s+legacy\s+protocol/i, + /please\s+use\s+\/v1\/responses/i, + /please\s+use\s+\/v1\/messages/i, + /please\s+use\s+\/v1\/chat\/completions/i, + /does\s+not\s+allow\s+\/v1\/[a-z0-9/_:-]+\s+dispatch/i, + /unsupported\s+endpoint/i, + /unsupported\s+path/i, + /unknown\s+endpoint/i, + /unrecognized\s+request\s+url/i, + /no\s+route\s+matched/i, +]; + +const SITE_MODEL_FAILURE_PATTERNS: RegExp[] = [ + /unsupported\s+model/i, + /model\s+not\s+supported/i, + /does\s+not\s+support(?:\s+the)?\s+model/i, + /no\s+such\s+model/i, + /unknown\s+model/i, + /invalid\s+model/i, + /model.*does\s+not\s+exist/i, + /当前\s*api\s*不支持所选模型/i, + /不支持所选模型/i, +]; + +const SITE_VALIDATION_FAILURE_PATTERNS: RegExp[] = [ + /invalid\s+request\s+body/i, + /validation/i, + /missing\s+required/i, + /required\s+parameter/i, + /unknown\s+parameter/i, + /unrecognized\s+(field|key|parameter)/i, + /malformed/i, + /invalid\s+json/i, + /cannot\s+parse/i, + /unsupported\s+media\s+type/i, +]; + +const SITE_TRANSIENT_FAILURE_PATTERNS: RegExp[] = [ + /bad\s+gateway/i, + /gateway\s+time-?out/i, + ...RETRYABLE_TIMEOUT_PATTERNS, + /service\s+unavailable/i, + /temporar(?:y|ily)\s+unavailable/i, + /cpu\s+overloaded/i, + /overloaded/i, + /connection\s+reset/i, + /connection\s+refused/i, + /econnreset/i, + /econnrefused/i, +]; + +type SiteRuntimeHealthPersistencePayload = { + version: 1; + savedAtMs: number; + globalBySiteId: Record; + modelBySiteId: Record>; +}; + +type SiteRuntimeHealthDetails = { + globalMultiplier: number; + modelMultiplier: number; + combinedMultiplier: number; + globalBreakerOpen: boolean; + modelBreakerOpen: boolean; + modelKey: string; +}; + +type WeightedSelectionMode = 'weighted' | 'stable_first'; + +const siteRuntimeHealthStates = new Map(); +const siteModelRuntimeHealthStates = new Map>(); +let siteRuntimeHealthLoaded = false; +let siteRuntimeHealthLoadPromise: Promise | null = null; +let siteRuntimeHealthSaveTimer: ReturnType | null = null; +let siteRuntimeHealthPersistInFlight: Promise | null = null; + +function fibonacciNumber(index: number): number { + if (index <= 2) return 1; + let prev = 1; + let current = 1; + for (let i = 3; i <= index; i += 1) { + const next = prev + current; + prev = current; + current = next; + } + return current; +} + +function resolveFailureBackoffSec(failCount?: number | null): number { + const normalizedFailCount = Math.max(1, Math.trunc(failCount ?? 0)); + return FAILURE_BACKOFF_BASE_SEC * fibonacciNumber(normalizedFailCount); +} + +function resolveRoundRobinCooldownSec(level: number): number { + const normalizedLevel = Math.max(0, Math.min(ROUND_ROBIN_COOLDOWN_LEVELS_SEC.length - 1, Math.trunc(level))); + return ROUND_ROBIN_COOLDOWN_LEVELS_SEC[normalizedLevel] ?? 0; +} + +function resolveSiteRuntimeBreakerMs(level: number): number { + const normalizedLevel = Math.max(0, Math.min(SITE_RUNTIME_BREAKER_LEVELS_MS.length - 1, Math.trunc(level))); + return SITE_RUNTIME_BREAKER_LEVELS_MS[normalizedLevel] ?? 0; +} + +function matchesAnyPattern(patterns: RegExp[], input?: string | null): boolean { + const text = (input || '').trim(); + if (!text) return false; + return patterns.some((pattern) => pattern.test(text)); +} + +function clampNumber(value: number, min: number, max: number): number { + return Math.max(min, Math.min(max, value)); +} + +function isRecord(value: unknown): value is Record { + return !!value && typeof value === 'object' && !Array.isArray(value); +} + +function readFiniteNumber(value: unknown): number | null { + return typeof value === 'number' && Number.isFinite(value) ? value : null; +} + +function readFiniteInteger(value: unknown): number | null { + const normalized = readFiniteNumber(value); + return normalized == null ? null : Math.trunc(normalized); +} + +function readNullableTimestamp(value: unknown): number | null { + const normalized = readFiniteInteger(value); + if (normalized == null || normalized <= 0) return null; + return normalized; +} + +function resolveSiteRuntimeFailurePenalty(context: SiteRuntimeFailureContext = {}): number { + const status = typeof context.status === 'number' ? context.status : 0; + const errorText = (context.errorText || '').trim(); + + if (status >= 500 || matchesAnyPattern(SITE_TRANSIENT_FAILURE_PATTERNS, errorText)) { + return 2.5; + } + + if (status === 429) { + return 2.2; + } + + if (status === 401 || status === 403) { + return 1.8; + } + + if (matchesAnyPattern(SITE_PROTOCOL_FAILURE_PATTERNS, errorText)) { + return 0.6; + } + + if (matchesAnyPattern(SITE_MODEL_FAILURE_PATTERNS, errorText)) { + return 0.9; + } + + if (matchesAnyPattern(SITE_VALIDATION_FAILURE_PATTERNS, errorText)) { + return 0.25; + } + + if (status >= 400 && status < 500) { + return 0.9; + } + + return 1.2; +} + +function isTransientSiteRuntimeFailure(context: SiteRuntimeFailureContext = {}): boolean { + const status = typeof context.status === 'number' ? context.status : 0; + const errorText = (context.errorText || '').trim(); + return status >= 500 || status === 429 || matchesAnyPattern(SITE_TRANSIENT_FAILURE_PATTERNS, errorText); +} + +function getDecayedSiteRuntimePenalty(state: SiteRuntimeHealthState, nowMs: number): number { + if (!Number.isFinite(state.penaltyScore) || state.penaltyScore <= 0) return 0; + const elapsedMs = Math.max(0, nowMs - state.lastUpdatedAtMs); + if (elapsedMs <= 0) return state.penaltyScore; + const decayFactor = Math.pow(0.5, elapsedMs / SITE_RUNTIME_HEALTH_DECAY_HALF_LIFE_MS); + return state.penaltyScore * decayFactor; +} + +function hydrateSiteRuntimeHealthState(raw: unknown): SiteRuntimeHealthState | null { + if (!isRecord(raw)) return null; + + const lastUpdatedAtMs = readFiniteInteger(raw.lastUpdatedAtMs) ?? Date.now(); + return { + penaltyScore: Math.max(0, readFiniteNumber(raw.penaltyScore) ?? 0), + latencyEmaMs: readFiniteNumber(raw.latencyEmaMs), + transientFailureStreak: Math.max(0, readFiniteInteger(raw.transientFailureStreak) ?? 0), + lastTransientFailureAtMs: readNullableTimestamp(raw.lastTransientFailureAtMs), + breakerLevel: Math.max(0, readFiniteInteger(raw.breakerLevel) ?? 0), + breakerUntilMs: readNullableTimestamp(raw.breakerUntilMs), + lastUpdatedAtMs: Math.max(0, lastUpdatedAtMs), + lastFailureAtMs: readNullableTimestamp(raw.lastFailureAtMs), + lastSuccessAtMs: readNullableTimestamp(raw.lastSuccessAtMs), + }; +} + +function cloneSiteRuntimeHealthState(state: SiteRuntimeHealthState): SiteRuntimeHealthState { + return { + penaltyScore: state.penaltyScore, + latencyEmaMs: state.latencyEmaMs, + transientFailureStreak: state.transientFailureStreak, + lastTransientFailureAtMs: state.lastTransientFailureAtMs, + breakerLevel: state.breakerLevel, + breakerUntilMs: state.breakerUntilMs, + lastUpdatedAtMs: state.lastUpdatedAtMs, + lastFailureAtMs: state.lastFailureAtMs, + lastSuccessAtMs: state.lastSuccessAtMs, + }; +} + +function getOrCreateRuntimeHealthState(states: Map, key: K, nowMs = Date.now()): SiteRuntimeHealthState { + const existing = states.get(key); + if (!existing) { + const initial: SiteRuntimeHealthState = { + penaltyScore: 0, + latencyEmaMs: null, + transientFailureStreak: 0, + lastTransientFailureAtMs: null, + breakerLevel: 0, + breakerUntilMs: null, + lastUpdatedAtMs: nowMs, + lastFailureAtMs: null, + lastSuccessAtMs: null, + }; + states.set(key, initial); + return initial; + } + + const nextPenalty = getDecayedSiteRuntimePenalty(existing, nowMs); + if (nextPenalty !== existing.penaltyScore || existing.lastUpdatedAtMs !== nowMs) { + existing.penaltyScore = nextPenalty; + existing.lastUpdatedAtMs = nowMs; + } + return existing; +} + +function getOrCreateSiteRuntimeHealthState(siteId: number, nowMs = Date.now()): SiteRuntimeHealthState { + return getOrCreateRuntimeHealthState(siteRuntimeHealthStates, siteId, nowMs); +} + +function getSiteModelRuntimeHealthState(siteId: number, modelName?: string | null): SiteRuntimeHealthState | null { + const modelKey = normalizeModelAlias(modelName || ''); + if (!modelKey) return null; + return siteModelRuntimeHealthStates.get(siteId)?.get(modelKey) ?? null; +} + +function getOrCreateSiteModelRuntimeHealthState( + siteId: number, + modelName?: string | null, + nowMs = Date.now(), +): SiteRuntimeHealthState | null { + const modelKey = normalizeModelAlias(modelName || ''); + if (!modelKey) return null; + let modelStates = siteModelRuntimeHealthStates.get(siteId); + if (!modelStates) { + modelStates = new Map(); + siteModelRuntimeHealthStates.set(siteId, modelStates); + } + return getOrCreateRuntimeHealthState(modelStates, modelKey, nowMs); +} + +function isRuntimeHealthBreakerOpen(state: SiteRuntimeHealthState | null | undefined, nowMs = Date.now()): boolean { + if (!state) return false; + return typeof state.breakerUntilMs === 'number' && state.breakerUntilMs > nowMs; +} + +function getRuntimeHealthMultiplier(state: SiteRuntimeHealthState | null | undefined, nowMs = Date.now()): number { + if (!state) return 1; + if (isRuntimeHealthBreakerOpen(state, nowMs)) { + return SITE_RUNTIME_MIN_MULTIPLIER; + } + const penaltyScore = getDecayedSiteRuntimePenalty(state, nowMs); + const failurePenaltyFactor = 1 / (1 + penaltyScore); + const latencyPenaltyRatio = state.latencyEmaMs == null + ? 0 + : clampNumber( + (state.latencyEmaMs - SITE_RUNTIME_LATENCY_BASELINE_MS) / SITE_RUNTIME_LATENCY_WINDOW_MS, + 0, + 1, + ); + const latencyFactor = 1 - (latencyPenaltyRatio * SITE_RUNTIME_MAX_LATENCY_PENALTY); + return clampNumber(failurePenaltyFactor * latencyFactor, SITE_RUNTIME_MIN_MULTIPLIER, 1); +} + +function getSiteRuntimeHealthDetails(siteId: number, modelName?: string | null, nowMs = Date.now()): SiteRuntimeHealthDetails { + const modelKey = normalizeModelAlias(modelName || ''); + const globalState = siteRuntimeHealthStates.get(siteId); + const modelState = modelKey ? getSiteModelRuntimeHealthState(siteId, modelKey) : null; + const globalMultiplier = getRuntimeHealthMultiplier(globalState, nowMs); + const modelMultiplier = modelState ? getRuntimeHealthMultiplier(modelState, nowMs) : 1; + return { + globalMultiplier, + modelMultiplier, + combinedMultiplier: clampNumber( + globalMultiplier * modelMultiplier, + SITE_RUNTIME_MIN_MULTIPLIER * SITE_RUNTIME_MIN_MULTIPLIER, + 1, + ), + globalBreakerOpen: isRuntimeHealthBreakerOpen(globalState, nowMs), + modelBreakerOpen: isRuntimeHealthBreakerOpen(modelState, nowMs), + modelKey, + }; +} + +function applyRuntimeHealthFailure(state: SiteRuntimeHealthState, context: SiteRuntimeFailureContext = {}, nowMs = Date.now()): void { + state.penaltyScore += resolveSiteRuntimeFailurePenalty(context); + if (isTransientSiteRuntimeFailure(context)) { + const lastTransientFailureAtMs = state.lastTransientFailureAtMs; + const shouldContinueStreak = ( + typeof lastTransientFailureAtMs === 'number' + && (nowMs - lastTransientFailureAtMs) <= SITE_TRANSIENT_STREAK_WINDOW_MS + ); + state.transientFailureStreak = shouldContinueStreak + ? state.transientFailureStreak + 1 + : 1; + state.lastTransientFailureAtMs = nowMs; + if (state.transientFailureStreak >= SITE_RUNTIME_BREAKER_STREAK_THRESHOLD) { + state.breakerLevel = Math.min(state.breakerLevel + 1, SITE_RUNTIME_BREAKER_LEVELS_MS.length - 1); + const breakerMs = resolveSiteRuntimeBreakerMs(state.breakerLevel); + state.breakerUntilMs = breakerMs > 0 ? nowMs + breakerMs : null; + state.transientFailureStreak = 0; + } + } else { + state.transientFailureStreak = 0; + state.lastTransientFailureAtMs = null; + } + state.lastFailureAtMs = nowMs; +} + +function applyRuntimeHealthSuccess(state: SiteRuntimeHealthState, latencyMs: number, nowMs = Date.now()): void { + state.penaltyScore = Math.max(0, state.penaltyScore * 0.2 - 0.3); + state.transientFailureStreak = 0; + state.lastTransientFailureAtMs = null; + state.breakerLevel = 0; + state.breakerUntilMs = null; + state.lastSuccessAtMs = nowMs; + const normalizedLatencyMs = Math.max(0, Math.trunc(latencyMs)); + state.latencyEmaMs = state.latencyEmaMs == null + ? normalizedLatencyMs + : (state.latencyEmaMs * (1 - SITE_RUNTIME_LATENCY_EMA_ALPHA)) + + (normalizedLatencyMs * SITE_RUNTIME_LATENCY_EMA_ALPHA); +} + +function shouldPersistSiteRuntimeHealthState(state: SiteRuntimeHealthState, nowMs = Date.now()): boolean { + const lastTouchedAtMs = Math.max( + state.lastUpdatedAtMs, + state.lastFailureAtMs ?? 0, + state.lastSuccessAtMs ?? 0, + state.lastTransientFailureAtMs ?? 0, + ); + if ((nowMs - lastTouchedAtMs) > SITE_RUNTIME_HEALTH_PERSIST_STALE_TTL_MS) { + return false; + } + + if (isRuntimeHealthBreakerOpen(state, nowMs)) return true; + if (getDecayedSiteRuntimePenalty(state, nowMs) >= SITE_RUNTIME_HEALTH_PERSIST_MIN_PENALTY) return true; + if ((state.latencyEmaMs ?? 0) > 0) return true; + return (nowMs - lastTouchedAtMs) <= SITE_RUNTIME_HEALTH_PERSIST_IDLE_TTL_MS; +} + +function buildSiteRuntimeHealthPersistencePayload(nowMs = Date.now()): SiteRuntimeHealthPersistencePayload { + const globalBySiteId: Record = {}; + const modelBySiteId: Record> = {}; + + for (const [siteId, state] of siteRuntimeHealthStates.entries()) { + if (!shouldPersistSiteRuntimeHealthState(state, nowMs)) continue; + globalBySiteId[String(siteId)] = cloneSiteRuntimeHealthState(state); + } + + for (const [siteId, modelStates] of siteModelRuntimeHealthStates.entries()) { + const persistedModels: Record = {}; + for (const [modelKey, state] of modelStates.entries()) { + if (!shouldPersistSiteRuntimeHealthState(state, nowMs)) continue; + persistedModels[modelKey] = cloneSiteRuntimeHealthState(state); + } + if (Object.keys(persistedModels).length > 0) { + modelBySiteId[String(siteId)] = persistedModels; + } + } + + return { + version: 1, + savedAtMs: nowMs, + globalBySiteId, + modelBySiteId, + }; +} + +async function persistSiteRuntimeHealthState(): Promise { + if (siteRuntimeHealthPersistInFlight) { + await siteRuntimeHealthPersistInFlight; + return; + } + const persistTask = (async () => { + const payload = buildSiteRuntimeHealthPersistencePayload(); + await upsertSetting(SITE_RUNTIME_HEALTH_SETTING_KEY, payload); + })(); + siteRuntimeHealthPersistInFlight = persistTask.finally(() => { + if (siteRuntimeHealthPersistInFlight === persistTask) { + siteRuntimeHealthPersistInFlight = null; + } + }); + await siteRuntimeHealthPersistInFlight; +} + +function scheduleSiteRuntimeHealthPersistence(): void { + if (siteRuntimeHealthSaveTimer) return; + siteRuntimeHealthSaveTimer = setTimeout(() => { + siteRuntimeHealthSaveTimer = null; + void persistSiteRuntimeHealthState().catch((error) => { + console.error('Failed to persist site runtime health state', error); + }); + }, SITE_RUNTIME_HEALTH_PERSIST_DEBOUNCE_MS); +} + +async function loadSiteRuntimeHealthStateFromSettings(): Promise { + siteRuntimeHealthStates.clear(); + siteModelRuntimeHealthStates.clear(); + + const row = await db.select({ value: schema.settings.value }) + .from(schema.settings) + .where(eq(schema.settings.key, SITE_RUNTIME_HEALTH_SETTING_KEY)) + .get(); + if (!row?.value) return; + + let parsed: unknown; + try { + parsed = JSON.parse(row.value); + } catch { + return; + } + if (!isRecord(parsed)) return; + + const globalBySiteId = isRecord(parsed.globalBySiteId) ? parsed.globalBySiteId : {}; + for (const [siteIdKey, stateRaw] of Object.entries(globalBySiteId)) { + const siteId = Number(siteIdKey); + if (!Number.isFinite(siteId) || siteId <= 0) continue; + const state = hydrateSiteRuntimeHealthState(stateRaw); + if (!state) continue; + siteRuntimeHealthStates.set(siteId, state); + } + + const modelBySiteId = isRecord(parsed.modelBySiteId) ? parsed.modelBySiteId : {}; + for (const [siteIdKey, modelStatesRaw] of Object.entries(modelBySiteId)) { + const siteId = Number(siteIdKey); + if (!Number.isFinite(siteId) || siteId <= 0 || !isRecord(modelStatesRaw)) continue; + const hydratedModelStates = new Map(); + for (const [rawModelKey, stateRaw] of Object.entries(modelStatesRaw)) { + const modelKey = normalizeModelAlias(rawModelKey); + if (!modelKey) continue; + const state = hydrateSiteRuntimeHealthState(stateRaw); + if (!state) continue; + hydratedModelStates.set(modelKey, state); + } + if (hydratedModelStates.size > 0) { + siteModelRuntimeHealthStates.set(siteId, hydratedModelStates); + } + } +} + +async function ensureSiteRuntimeHealthStateLoaded(): Promise { + if (siteRuntimeHealthLoaded) return; + if (!siteRuntimeHealthLoadPromise) { + siteRuntimeHealthLoadPromise = (async () => { + try { + await loadSiteRuntimeHealthStateFromSettings(); + siteRuntimeHealthLoaded = true; + } catch (error) { + console.warn('Failed to restore site runtime health state from settings', error); + siteRuntimeHealthLoadPromise = null; + siteRuntimeHealthLoaded = false; + } + })(); + } + await siteRuntimeHealthLoadPromise; +} + +function recordSiteRuntimeFailure(siteId: number, context: SiteRuntimeFailureContext = {}, nowMs = Date.now()): void { + applyRuntimeHealthFailure(getOrCreateSiteRuntimeHealthState(siteId, nowMs), context, nowMs); + const modelState = getOrCreateSiteModelRuntimeHealthState(siteId, context.modelName, nowMs); + if (modelState) { + applyRuntimeHealthFailure(modelState, context, nowMs); + } + scheduleSiteRuntimeHealthPersistence(); +} + +function recordSiteRuntimeSuccess(siteId: number, latencyMs: number, modelName?: string | null, nowMs = Date.now()): void { + applyRuntimeHealthSuccess(getOrCreateSiteRuntimeHealthState(siteId, nowMs), latencyMs, nowMs); + const modelState = getOrCreateSiteModelRuntimeHealthState(siteId, modelName, nowMs); + if (modelState) { + applyRuntimeHealthSuccess(modelState, latencyMs, nowMs); + } + scheduleSiteRuntimeHealthPersistence(); +} + +export function resetSiteRuntimeHealthState(): void { + siteRuntimeHealthStates.clear(); + siteModelRuntimeHealthStates.clear(); + siteRuntimeHealthLoaded = false; + siteRuntimeHealthLoadPromise = null; + if (siteRuntimeHealthSaveTimer) { + clearTimeout(siteRuntimeHealthSaveTimer); + siteRuntimeHealthSaveTimer = null; + } + siteRuntimeHealthPersistInFlight = null; +} + +export async function flushSiteRuntimeHealthPersistence(): Promise { + if (siteRuntimeHealthSaveTimer) { + clearTimeout(siteRuntimeHealthSaveTimer); + siteRuntimeHealthSaveTimer = null; + await persistSiteRuntimeHealthState(); + return; + } + if (siteRuntimeHealthPersistInFlight) { + await siteRuntimeHealthPersistInFlight; + } +} + +export function getSiteRuntimeHealthMultiplier(siteId: number, nowMs = Date.now()): number { + const state = siteRuntimeHealthStates.get(siteId); + return getRuntimeHealthMultiplier(state, nowMs); +} + +export function isSiteRuntimeBreakerOpen(siteId: number, nowMs = Date.now()): boolean { + const state = siteRuntimeHealthStates.get(siteId); + return isRuntimeHealthBreakerOpen(state, nowMs); +} + +export function filterSiteRuntimeBrokenCandidates( + candidates: T[], + nowMs = Date.now(), +): T[] { + if (candidates.length <= 1) return candidates; + const healthy = candidates.filter((candidate) => !isSiteRuntimeBreakerOpen(candidate.site.id, nowMs)); + return healthy.length > 0 ? healthy : candidates; +} + +function buildRuntimeBreakerReason(details: SiteRuntimeHealthDetails): string { + if (details.globalBreakerOpen && details.modelBreakerOpen) { + return '站点熔断中,模型熔断中,优先避让'; + } + if (details.globalBreakerOpen) { + return '站点熔断中,优先避让'; + } + if (details.modelBreakerOpen) { + return '模型熔断中,优先避让'; + } + return '运行时熔断中,优先避让'; +} + +function filterSiteRuntimeBrokenCandidatesByModel( + candidates: RouteChannelCandidate[], + modelName: string | ((candidate: RouteChannelCandidate) => string), + nowMs = Date.now(), +): { + candidates: RouteChannelCandidate[]; + avoided: Array<{ candidate: RouteChannelCandidate; reason: string }>; +} { + if (candidates.length <= 1) { + return { + candidates, + avoided: [], + }; + } + + const resolveModelName = typeof modelName === 'function' + ? modelName + : (() => modelName); + const avoided: Array<{ candidate: RouteChannelCandidate; reason: string }> = []; + const healthy = candidates.filter((candidate) => { + const details = getSiteRuntimeHealthDetails(candidate.site.id, resolveModelName(candidate), nowMs); + const blocked = details.globalBreakerOpen || details.modelBreakerOpen; + if (blocked) { + avoided.push({ + candidate, + reason: buildRuntimeBreakerReason(details), + }); + } + return !blocked; + }); + + return healthy.length > 0 + ? { + candidates: healthy, + avoided, + } + : { + candidates, + avoided: [], + }; +} -type RouteRow = typeof schema.tokenRoutes.$inferSelect; -type ChannelRow = typeof schema.routeChannels.$inferSelect; +type RouteRow = typeof schema.tokenRoutes.$inferSelect & { + routeMode: RouteMode; + sourceRouteIds: number[]; +}; +type ChannelRow = typeof schema.routeChannels.$inferSelect; type RouteCacheSnapshot = { loadedAt: number; @@ -65,41 +722,88 @@ function isCacheFresh(loadedAt: number, nowMs: number): boolean { } async function loadEnabledRoutes(nowMs = Date.now()): Promise { - if (isCacheFresh(routeCacheSnapshot.loadedAt, nowMs)) { - return routeCacheSnapshot.routes; - } - - const routes = await db.select().from(schema.tokenRoutes) + if (isCacheFresh(routeCacheSnapshot.loadedAt, nowMs)) { + return routeCacheSnapshot.routes; + } + + const rawRoutes = await db.select().from(schema.tokenRoutes) .where(eq(schema.tokenRoutes.enabled, true)) .all(); - routeCacheSnapshot = { - loadedAt: nowMs, - routes, + const explicitGroupRouteIds = rawRoutes + .filter((route) => normalizeRouteMode(route.routeMode) === 'explicit_group') + .map((route) => route.id); + const sourceRows = explicitGroupRouteIds.length > 0 + ? await db.select().from(schema.routeGroupSources) + .where(inArray(schema.routeGroupSources.groupRouteId, explicitGroupRouteIds)) + .all() + : []; + const sourceIdsByRouteId = new Map(); + for (const row of sourceRows) { + if (!sourceIdsByRouteId.has(row.groupRouteId)) { + sourceIdsByRouteId.set(row.groupRouteId, []); + } + sourceIdsByRouteId.get(row.groupRouteId)!.push(row.sourceRouteId); + } + const routes = rawRoutes.map((route) => ({ + ...route, + routeMode: normalizeRouteMode(route.routeMode), + sourceRouteIds: Array.from(new Set(sourceIdsByRouteId.get(route.id) ?? [])), + })); + routeCacheSnapshot = { + loadedAt: nowMs, + routes, }; return routes; } async function loadRouteMatch(route: RouteRow, nowMs = Date.now()): Promise { - const cached = routeMatchCache.get(route.id); - if (cached && isCacheFresh(cached.loadedAt, nowMs)) { - return cached.match; - } - - const channels = await db - .select() - .from(schema.routeChannels) - .innerJoin(schema.accounts, eq(schema.routeChannels.accountId, schema.accounts.id)) - .innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id)) - .leftJoin(schema.accountTokens, eq(schema.routeChannels.tokenId, schema.accountTokens.id)) - .where(eq(schema.routeChannels.routeId, route.id)) - .all(); - - const mapped = channels.map((row) => ({ - channel: row.route_channels, - account: row.accounts, - site: row.sites, - token: row.account_tokens, - })); + const cached = routeMatchCache.get(route.id); + if (cached && isCacheFresh(cached.loadedAt, nowMs)) { + return cached.match; + } + + const enabledRoutes = await loadEnabledRoutes(nowMs); + const routeIds = (() => { + if (!isExplicitGroupRoute(route)) { + return [route.id]; + } + return Array.from(new Set(route.sourceRouteIds.filter((routeId) => Number.isFinite(routeId) && routeId > 0))); + })(); + const enabledSourceRoutes = isExplicitGroupRoute(route) + ? enabledRoutes.filter((item) => ( + routeIds.includes(item.id) + && !isExplicitGroupRoute(item) + && isExactRouteModelPattern(item.modelPattern) + )) + : enabledRoutes.filter((item) => routeIds.includes(item.id)); + const enabledSourceRouteIds = enabledSourceRoutes.map((item) => item.id); + const fallbackSourceModelByRouteId = new Map( + enabledSourceRoutes + .filter((item) => isExactRouteModelPattern(item.modelPattern)) + .map((item) => [item.id, (item.modelPattern || '').trim()]), + ); + const channels = enabledSourceRouteIds.length > 0 + ? await db + .select() + .from(schema.routeChannels) + .innerJoin(schema.accounts, eq(schema.routeChannels.accountId, schema.accounts.id)) + .innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id)) + .leftJoin(schema.accountTokens, eq(schema.routeChannels.tokenId, schema.accountTokens.id)) + .where(inArray(schema.routeChannels.routeId, enabledSourceRouteIds)) + .all() + : []; + + const mapped = channels.map((row) => ({ + channel: { + ...row.route_channels, + sourceModel: normalizeChannelSourceModel(row.route_channels.sourceModel) + || fallbackSourceModelByRouteId.get(row.route_channels.routeId) + || null, + }, + account: row.accounts, + site: row.sites, + token: row.account_tokens, + })); const match = { route, channels: mapped }; routeMatchCache.set(route.id, { @@ -130,11 +834,11 @@ function isSiteDisabled(status?: string | null): boolean { return (status || 'active') === 'disabled'; } -export function isChannelRecentlyFailed( - channel: FailureAwareChannel, - nowMs = Date.now(), - avoidSec = RECENT_FAILURE_AVOID_SEC, -): boolean { +export function isChannelRecentlyFailed( + channel: FailureAwareChannel, + nowMs = Date.now(), + avoidSec = resolveFailureBackoffSec(channel.failCount), +): boolean { if (avoidSec <= 0) return false; if ((channel.failCount ?? 0) <= 0) return false; if (!channel.lastFailAt) return false; @@ -145,46 +849,24 @@ export function isChannelRecentlyFailed( return nowMs - failTs < avoidSec * 1000; } -export function filterRecentlyFailedCandidates( - candidates: T[], - nowMs = Date.now(), - avoidSec = RECENT_FAILURE_AVOID_SEC, -): T[] { - if (candidates.length <= 1) return candidates; - if (avoidSec <= 0) return candidates; - - const healthy = candidates.filter((candidate) => !isChannelRecentlyFailed(candidate.channel, nowMs, avoidSec)); - // If all channels failed recently, keep them all and let weight/random decide. - return healthy.length > 0 ? healthy : candidates; -} - -export interface RouteDecisionCandidate { - channelId: number; - accountId: number; - username: string; - siteName: string; - tokenName: string; - priority: number; - weight: number; - eligible: boolean; - recentlyFailed: boolean; - avoidedByRecentFailure: boolean; - probability: number; - reason: string; -} +export function filterRecentlyFailedCandidates( + candidates: T[], + nowMs = Date.now(), + avoidSec?: number, +): T[] { + if (candidates.length <= 1) return candidates; + if (avoidSec == null || avoidSec <= 0) return candidates; + + const healthy = candidates.filter((candidate) => !isChannelRecentlyFailed(candidate.channel, nowMs, avoidSec)); + // If all channels failed recently, keep them all and let weight/random decide. + return healthy.length > 0 ? healthy : candidates; +} -export interface RouteDecisionExplanation { - requestedModel: string; - actualModel: string; - matched: boolean; - routeId?: number; - modelPattern?: string; - selectedChannelId?: number; - selectedAccountId?: number; - selectedLabel?: string; - summary: string[]; - candidates: RouteDecisionCandidate[]; -} +export type RouteDecisionExplanation = RouteDecision & { + routeId?: number; + modelPattern?: string; + selectedAccountId?: number; +}; const DEFAULT_DOWNSTREAM_POLICY: DownstreamRoutingPolicy = EMPTY_DOWNSTREAM_ROUTING_POLICY; @@ -213,51 +895,94 @@ type CostSignal = { source: 'observed' | 'configured' | 'catalog' | 'fallback'; }; -export function isRegexModelPattern(pattern: string): boolean { - return pattern.trim().toLowerCase().startsWith('re:'); -} - -export function parseRegexModelPattern(pattern: string): RegExp | null { - if (!isRegexModelPattern(pattern)) return null; - const body = pattern.trim().slice(3).trim(); - if (!body) return null; - try { - return new RegExp(body); - } catch { - return null; - } -} - -export function matchesModelPattern(model: string, pattern: string): boolean { - const normalizedPattern = (pattern || '').trim(); - if (!normalizedPattern) return false; - - if (normalizedPattern === model) return true; - - if (isRegexModelPattern(normalizedPattern)) { - const re = parseRegexModelPattern(normalizedPattern); - return !!re && re.test(model); - } - - return minimatch(model, normalizedPattern); -} - -function normalizeRouteDisplayName(displayName: string | null | undefined): string { - return (displayName || '').trim(); -} - -function isRouteDisplayNameMatch(model: string, displayName: string | null | undefined): boolean { - const alias = normalizeRouteDisplayName(displayName); - return !!alias && alias === model; -} - -function matchesRouteRequestModel(model: string, route: typeof schema.tokenRoutes.$inferSelect): boolean { - return matchesModelPattern(model, route.modelPattern) || isRouteDisplayNameMatch(model, route.displayName); -} +export function isRegexModelPattern(pattern: string): boolean { + return isTokenRouteRegexPattern(pattern); +} + +export function parseRegexModelPattern(pattern: string): { test(value: string): boolean } | null { + return parseTokenRouteRegexPattern(pattern).regex; +} + +export function matchesModelPattern(model: string, pattern: string): boolean { + return matchesTokenRouteModelPattern(model, pattern); +} + +function isExactRouteModelPattern(pattern: string): boolean { + return isExactTokenRouteModelPattern(pattern); +} + +function normalizeRouteMode(routeMode: string | null | undefined): RouteMode { + return normalizeTokenRouteMode(routeMode); +} + +function isExplicitGroupRoute(route: Pick | Pick): boolean { + return normalizeRouteMode(route.routeMode) === 'explicit_group'; +} + +function normalizeRouteDisplayName(displayName: string | null | undefined): string { + return (displayName || '').trim(); +} -function getExposedModelNameForRoute(route: typeof schema.tokenRoutes.$inferSelect): string { - return normalizeRouteDisplayName(route.displayName) || route.modelPattern; -} +function isRouteDisplayNameMatch(model: string, displayName: string | null | undefined): boolean { + const alias = normalizeRouteDisplayName(displayName); + return !!alias && alias === model; +} + +function matchesRouteRequestModel(model: string, route: RouteRow): boolean { + if (isExplicitGroupRoute(route)) { + return isRouteDisplayNameMatch(model, route.displayName); + } + return matchesModelPattern(model, route.modelPattern) || isRouteDisplayNameMatch(model, route.displayName); +} + +function getExposedModelNameForRoute(route: RouteRow): string { + return normalizeRouteDisplayName(route.displayName) || route.modelPattern; +} + +function hasCustomDisplayName(route: Pick): boolean { + const displayName = normalizeRouteDisplayName(route.displayName); + const modelPattern = (route.modelPattern || '').trim(); + return !!displayName && displayName !== modelPattern; +} + +function buildVisibleEnabledRoutes(routes: RouteRow[]): RouteRow[] { + const exactModelNames = new Set( + routes + .filter((route) => !isExplicitGroupRoute(route) && isExactRouteModelPattern(route.modelPattern)) + .map((route) => (route.modelPattern || '').trim()) + .filter(Boolean), + ); + const coveringGroups = routes.filter((route) => ( + route.enabled + && ( + (isExplicitGroupRoute(route) && normalizeRouteDisplayName(route.displayName).length > 0 && route.sourceRouteIds.length > 0) + || (!isExplicitGroupRoute(route) && !isExactRouteModelPattern(route.modelPattern) && hasCustomDisplayName(route)) + ) + )); + + if (coveringGroups.length === 0) return routes; + + return routes.filter((route) => { + if (isExplicitGroupRoute(route)) { + return normalizeRouteDisplayName(route.displayName).length > 0; + } + if (!isExactRouteModelPattern(route.modelPattern)) return true; + if (hasCustomDisplayName(route)) return true; + + const exactModel = (route.modelPattern || '').trim(); + if (!exactModel) return true; + + return !coveringGroups.some((groupRoute) => { + if (groupRoute.id === route.id) return false; + const groupDisplayName = normalizeRouteDisplayName(groupRoute.displayName); + if (!groupDisplayName || exactModelNames.has(groupDisplayName)) return false; + if (isExplicitGroupRoute(groupRoute)) { + return groupRoute.sourceRouteIds.includes(route.id); + } + return matchesModelPattern(exactModel, groupRoute.modelPattern); + }); + }); +} function normalizeModelAlias(modelName: string): string { const normalized = (modelName || '').trim().toLowerCase(); @@ -284,32 +1009,40 @@ function channelSupportsRequestedModel(channelSourceModel: string | null | undef return false; } -function isModelAllowedByDownstreamPolicy(requestedModel: string, policy: DownstreamRoutingPolicy): boolean { - const supportedPatterns = Array.isArray(policy.supportedModels) - ? policy.supportedModels - : []; - const matchedSupportedPattern = supportedPatterns.some((pattern) => matchesModelPattern(requestedModel, pattern)); - if (matchedSupportedPattern) return true; - if (policy.allowedRouteIds.length > 0) return true; - return supportedPatterns.length === 0; -} - -function resolveMappedModel(requestedModel: string, modelMapping?: string | null): string { - if (!modelMapping) return requestedModel; - - let parsed: unknown; - try { - parsed = JSON.parse(modelMapping); - } catch { - return requestedModel; - } - - if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { - return requestedModel; - } +function isModelAllowedByDownstreamPolicy(requestedModel: string, policy: DownstreamRoutingPolicy): boolean { + const supportedPatterns = Array.isArray(policy.supportedModels) + ? policy.supportedModels + : []; + const hasSupportedPatterns = supportedPatterns.length > 0; + const hasAllowedRoutes = policy.allowedRouteIds.length > 0; + if (!hasSupportedPatterns && !hasAllowedRoutes) return policy.denyAllWhenEmpty === true ? false : true; + const matchedSupportedPattern = supportedPatterns.some((pattern) => matchesModelPattern(requestedModel, pattern)); + if (matchedSupportedPattern) return true; + if (hasAllowedRoutes) return true; + return false; +} - const entries = Object.entries(parsed as Record) - .filter(([, value]) => typeof value === 'string' && value.trim().length > 0) as Array<[string, string]>; +function parseModelMappingRecord(modelMapping?: string | Record | null): Record | null { + if (!modelMapping) return null; + if (typeof modelMapping === 'object' && !Array.isArray(modelMapping)) { + return modelMapping as Record; + } + if (typeof modelMapping !== 'string') return null; + try { + const parsed = JSON.parse(modelMapping); + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null; + return parsed as Record; + } catch { + return null; + } +} + +function resolveMappedModel(requestedModel: string, modelMapping?: string | Record | null): string { + const parsed = parseModelMappingRecord(modelMapping); + if (!parsed) return requestedModel; + + const entries = Object.entries(parsed) + .filter(([, value]) => typeof value === 'string' && value.trim().length > 0) as Array<[string, string]>; const exact = entries.find(([pattern]) => pattern === requestedModel); if (exact) return exact[1].trim(); @@ -329,18 +1062,37 @@ function normalizeChannelSourceModel(channelSourceModel: string | null | undefin function resolveActualModelForSelectedChannel( requestedModel: string, - route: typeof schema.tokenRoutes.$inferSelect, + route: RouteRow, mappedModel: string, - channelSourceModel: string | null | undefined, -): string { + channelSourceModel: string | null | undefined, +): string { const sourceModel = normalizeChannelSourceModel(channelSourceModel); if (isRouteDisplayNameMatch(requestedModel, route.displayName) && sourceModel) { return sourceModel; - } - return mappedModel; -} + } + return mappedModel; +} + +function resolveRouteStrategy(route: RouteRow): RouteRoutingStrategy { + return normalizeRouteRoutingStrategy(route.routingStrategy); +} + +function parseIsoTimeMs(value?: string | null): number | null { + if (!value) return null; + const parsed = Date.parse(value); + return Number.isNaN(parsed) ? null : parsed; +} + +function compareNullableTimeAsc(left?: string | null, right?: string | null): number { + const leftMs = parseIsoTimeMs(left); + const rightMs = parseIsoTimeMs(right); + if (leftMs == null && rightMs == null) return 0; + if (leftMs == null) return -1; + if (rightMs == null) return 1; + return leftMs - rightMs; +} -function resolveEffectiveUnitCost(candidate: RouteChannelCandidate, modelName: string): CostSignal { +function resolveEffectiveUnitCost(candidate: RouteChannelCandidate, modelName: string): CostSignal { const successCount = Math.max(0, candidate.channel.successCount ?? 0); const totalCost = Math.max(0, candidate.channel.totalCost ?? 0); const configured = candidate.account.unitCost ?? null; @@ -377,162 +1129,159 @@ function resolveEffectiveUnitCost(candidate: RouteChannelCandidate, modelName: s }; } +type SiteHistoricalHealthMetrics = { + multiplier: number; + totalCalls: number; + successRate: number | null; + avgLatencyMs: number | null; +}; + +function buildSiteHistoricalHealthMetrics(candidates: RouteChannelCandidate[]): Map { + const totals = new Map(); + + for (const candidate of candidates) { + const siteId = candidate.site.id; + if (!totals.has(siteId)) { + totals.set(siteId, { + totalCalls: 0, + successCount: 0, + failCount: 0, + totalLatencyMs: 0, + latencySamples: 0, + }); + } + const target = totals.get(siteId)!; + const successCount = Math.max(0, candidate.channel.successCount ?? 0); + const failCount = Math.max(0, candidate.channel.failCount ?? 0); + target.successCount += successCount; + target.failCount += failCount; + target.totalCalls += successCount + failCount; + if (successCount > 0) { + target.totalLatencyMs += Math.max(0, candidate.channel.totalLatencyMs ?? 0); + target.latencySamples += successCount; + } + } + + const metrics = new Map(); + for (const [siteId, total] of totals.entries()) { + if (total.totalCalls <= 0) { + metrics.set(siteId, { + multiplier: 1, + totalCalls: 0, + successRate: null, + avgLatencyMs: null, + }); + continue; + } + + const sampleFactor = clampNumber(total.totalCalls / SITE_HISTORICAL_HEALTH_MAX_SAMPLE, 0, 1); + const successRate = total.successCount / total.totalCalls; + const successPenaltyFactor = 1 - ((1 - successRate) * 0.55 * sampleFactor); + const avgLatencyMs = total.latencySamples > 0 + ? Math.round(total.totalLatencyMs / total.latencySamples) + : null; + const latencyPenaltyRatio = avgLatencyMs == null + ? 0 + : clampNumber( + (avgLatencyMs - SITE_HISTORICAL_LATENCY_BASELINE_MS) / SITE_HISTORICAL_LATENCY_WINDOW_MS, + 0, + 1, + ) * sampleFactor; + const latencyFactor = 1 - (latencyPenaltyRatio * SITE_HISTORICAL_MAX_LATENCY_PENALTY); + metrics.set(siteId, { + multiplier: clampNumber( + successPenaltyFactor * latencyFactor, + SITE_HISTORICAL_HEALTH_MIN_MULTIPLIER, + 1, + ), + totalCalls: total.totalCalls, + successRate, + avgLatencyMs, + }); + } + + return metrics; +} + function isExplicitTokenChannel(candidate: RouteChannelCandidate): boolean { return typeof candidate.channel.tokenId === 'number' && candidate.channel.tokenId > 0; } export class TokenRouter { - /** - * Find matching route and select a channel for the given model. - * Returns null if no route/channel available. - */ + /** + * Find matching route and select a channel for the given model. + * Returns null if no route/channel available. + */ async selectChannel(requestedModel: string, downstreamPolicy: DownstreamRoutingPolicy = DEFAULT_DOWNSTREAM_POLICY): Promise { - if (!isModelAllowedByDownstreamPolicy(requestedModel, downstreamPolicy)) return null; - + if (!isModelAllowedByDownstreamPolicy(requestedModel, downstreamPolicy)) return null; + await ensureSiteRuntimeHealthStateLoaded(); + const match = await this.findRoute(requestedModel, downstreamPolicy); - if (!match) return null; - - const mappedModel = resolveMappedModel(requestedModel, match.route.modelMapping); - const requestedByDisplayName = isRouteDisplayNameMatch(requestedModel, match.route.displayName); - const bypassSourceModelCheck = requestedByDisplayName; + if (!match) return null; + return await this.selectFromMatch(match, requestedModel, downstreamPolicy); + } - const nowIso = new Date().toISOString(); - const nowMs = Date.now(); - const available = match.channels.filter((candidate) => ( - this.getCandidateEligibilityReasons(candidate, { - requestedModel, - bypassSourceModelCheck, - nowIso, - }).length === 0 - )); - - if (available.length === 0) return null; - - // Group by priority - const layers = new Map(); - for (const c of available) { - const p = c.channel.priority ?? 0; - if (!layers.has(p)) layers.set(p, []); - layers.get(p)!.push(c); - } - - // Sort layers by priority (ascending = higher priority first) - const sortedPriorities = Array.from(layers.keys()).sort((a, b) => a - b); - - // Try each priority layer - for (const priority of sortedPriorities) { - const candidates = filterRecentlyFailedCandidates( - layers.get(priority)!, - nowMs, - RECENT_FAILURE_AVOID_SEC, - ); - const selected = this.weightedRandomSelect( - candidates, - requestedByDisplayName - ? (candidate) => normalizeChannelSourceModel(candidate.channel.sourceModel) || mappedModel - : mappedModel, - downstreamPolicy, - ); - if (selected) { - const tokenValue = this.resolveChannelTokenValue(selected); - if (!tokenValue) continue; - const actualModel = resolveActualModelForSelectedChannel( - requestedModel, - match.route, - mappedModel, - selected.channel.sourceModel, - ); - - return { - ...selected, - tokenValue, - tokenName: selected.token?.name || 'default', - actualModel, - }; - } - } - - return null; - } + async previewSelectedChannel( + requestedModel: string, + downstreamPolicy: DownstreamRoutingPolicy = DEFAULT_DOWNSTREAM_POLICY, + ): Promise { + if (!isModelAllowedByDownstreamPolicy(requestedModel, downstreamPolicy)) return null; + await ensureSiteRuntimeHealthStateLoaded(); + + const match = await this.findRoute(requestedModel, downstreamPolicy); + if (!match) return null; + return await this.selectFromMatch(match, requestedModel, downstreamPolicy, [], false); + } /** * Select next channel for failover (exclude already-tried channels). */ async selectNextChannel( - requestedModel: string, - excludeChannelIds: number[], - downstreamPolicy: DownstreamRoutingPolicy = DEFAULT_DOWNSTREAM_POLICY, + requestedModel: string, + excludeChannelIds: number[], + downstreamPolicy: DownstreamRoutingPolicy = DEFAULT_DOWNSTREAM_POLICY, ): Promise { - if (!isModelAllowedByDownstreamPolicy(requestedModel, downstreamPolicy)) return null; - + if (!isModelAllowedByDownstreamPolicy(requestedModel, downstreamPolicy)) return null; + await ensureSiteRuntimeHealthStateLoaded(); + const match = await this.findRoute(requestedModel, downstreamPolicy); - if (!match) return null; - - const mappedModel = resolveMappedModel(requestedModel, match.route.modelMapping); - const requestedByDisplayName = isRouteDisplayNameMatch(requestedModel, match.route.displayName); - const bypassSourceModelCheck = requestedByDisplayName; - - const nowIso = new Date().toISOString(); - const nowMs = Date.now(); - const available = match.channels.filter((candidate) => ( - this.getCandidateEligibilityReasons(candidate, { - requestedModel, - bypassSourceModelCheck, - excludeChannelIds, - nowIso, - }).length === 0 - )); - - if (available.length === 0) return null; - - const layers = new Map(); - for (const c of available) { - const p = c.channel.priority ?? 0; - if (!layers.has(p)) layers.set(p, []); - layers.get(p)!.push(c); - } - - const sortedPriorities = Array.from(layers.keys()).sort((a, b) => a - b); - for (const priority of sortedPriorities) { - const candidates = filterRecentlyFailedCandidates( - layers.get(priority)!, - nowMs, - RECENT_FAILURE_AVOID_SEC, - ); - const selected = this.weightedRandomSelect( - candidates, - requestedByDisplayName - ? (candidate) => normalizeChannelSourceModel(candidate.channel.sourceModel) || mappedModel - : mappedModel, - downstreamPolicy, - ); - if (!selected) continue; - - const tokenValue = this.resolveChannelTokenValue(selected); - if (!tokenValue) continue; - const actualModel = resolveActualModelForSelectedChannel( - requestedModel, - match.route, - mappedModel, - selected.channel.sourceModel, - ); - - return { - ...selected, - tokenValue, - tokenName: selected.token?.name || 'default', - actualModel, - }; - } - - return null; - } + if (!match) return null; + return await this.selectFromMatch(match, requestedModel, downstreamPolicy, excludeChannelIds); + } + + async selectPreferredChannel( + requestedModel: string, + preferredChannelId: number, + downstreamPolicy: DownstreamRoutingPolicy = DEFAULT_DOWNSTREAM_POLICY, + excludeChannelIds: number[] = [], + ): Promise { + if (!isModelAllowedByDownstreamPolicy(requestedModel, downstreamPolicy)) return null; + const normalizedPreferredChannelId = Math.trunc(preferredChannelId || 0); + if (normalizedPreferredChannelId <= 0) return null; + await ensureSiteRuntimeHealthStateLoaded(); + + const match = await this.findRoute(requestedModel, downstreamPolicy); + if (!match) return null; + return await this.selectPreferredFromMatch( + match, + requestedModel, + normalizedPreferredChannelId, + excludeChannelIds, + ); + } async explainSelection( requestedModel: string, excludeChannelIds: number[] = [], downstreamPolicy: DownstreamRoutingPolicy = DEFAULT_DOWNSTREAM_POLICY, ): Promise { + await ensureSiteRuntimeHealthStateLoaded(); const match = await this.findRoute(requestedModel, downstreamPolicy); return this.explainSelectionFromMatch(match, requestedModel, { excludeChannelIds, downstreamPolicy }); } @@ -543,11 +1292,13 @@ export class TokenRouter { excludeChannelIds: number[] = [], downstreamPolicy: DownstreamRoutingPolicy = DEFAULT_DOWNSTREAM_POLICY, ): Promise { + await ensureSiteRuntimeHealthStateLoaded(); const match = await this.findRouteById(routeId, downstreamPolicy); return this.explainSelectionFromMatch(match, requestedModel, { excludeChannelIds, downstreamPolicy }); } async explainSelectionRouteWide(routeId: number, downstreamPolicy: DownstreamRoutingPolicy = DEFAULT_DOWNSTREAM_POLICY): Promise { + await ensureSiteRuntimeHealthStateLoaded(); const match = await this.findRouteById(routeId, downstreamPolicy); const fallbackRequestedModel = match?.route.modelPattern || `route:${routeId}`; return this.explainSelectionFromMatch(match, fallbackRequestedModel, { @@ -593,7 +1344,7 @@ export class TokenRouter { match: RouteMatch | null, requestedModel: string, options: ExplainSelectionOptions = {}, - ): RouteDecisionExplanation { + ): RouteDecisionExplanation { const excludeChannelIds = options.excludeChannelIds ?? []; const downstreamPolicy = options.downstreamPolicy ?? DEFAULT_DOWNSTREAM_POLICY; @@ -606,20 +1357,29 @@ export class TokenRouter { candidates: [], }; } - - const requestedByDisplayName = isRouteDisplayNameMatch(requestedModel, match.route.displayName); - const bypassSourceModelCheck = (options.bypassSourceModelCheck ?? false) || requestedByDisplayName; - const useChannelSourceModelForCost = (options.useChannelSourceModelForCost ?? false) || requestedByDisplayName; - const mappedModel = resolveMappedModel(requestedModel, match.route.modelMapping); - - const nowIso = new Date().toISOString(); - const nowMs = Date.now(); - const summary: string[] = [`命中路由:${match.route.modelPattern}`]; - if (requestedByDisplayName) { - summary.push(`按显示名命中:${normalizeRouteDisplayName(match.route.displayName)}`); - summary.push('显示名仅用于聚合展示,实际转发模型按选中通道来源模型决定'); - } - const availableByPriority = new Map(); + + const requestedByDisplayName = isRouteDisplayNameMatch(requestedModel, match.route.displayName); + const bypassSourceModelCheck = (options.bypassSourceModelCheck ?? false) || requestedByDisplayName; + const useChannelSourceModelForCost = (options.useChannelSourceModelForCost ?? false) || requestedByDisplayName; + const mappedModel = resolveMappedModel(requestedModel, match.route.modelMapping); + const routeStrategy = resolveRouteStrategy(match.route); + const runtimeModelResolver = requestedByDisplayName + ? ((candidate: RouteChannelCandidate) => normalizeChannelSourceModel(candidate.channel.sourceModel) || mappedModel) + : mappedModel; + + const nowIso = new Date().toISOString(); + const nowMs = Date.now(); + const summary: string[] = [ + `命中路由:${match.route.modelPattern}`, + routeStrategy === 'round_robin' + ? '路由策略:轮询' + : (routeStrategy === 'stable_first' ? '路由策略:稳定优先' : '路由策略:按权重随机'), + ]; + if (requestedByDisplayName) { + summary.push(`按显示名命中:${normalizeRouteDisplayName(match.route.displayName)}`); + summary.push('显示名仅用于聚合展示,实际转发模型按选中通道来源模型决定'); + } + const availableByPriority = new Map(); const candidates: RouteDecisionCandidate[] = []; const candidateMap = new Map(); @@ -631,12 +1391,14 @@ export class TokenRouter { nowIso, }); - const recentlyFailed = isChannelRecentlyFailed(row.channel, nowMs, RECENT_FAILURE_AVOID_SEC); - const eligible = reasonParts.length === 0; - const candidate: RouteDecisionCandidate = { - channelId: row.channel.id, - accountId: row.account.id, - username: row.account.username || `account-${row.account.id}`, + const recentlyFailed = routeStrategy !== 'round_robin' + ? isChannelRecentlyFailed(row.channel, nowMs) + : false; + const eligible = reasonParts.length === 0; + const candidate: RouteDecisionCandidate = { + channelId: row.channel.id, + accountId: row.account.id, + username: row.account.username || `account-${row.account.id}`, siteName: row.site.name || 'unknown', tokenName: row.token?.name || 'default', priority: row.channel.priority ?? 0, @@ -657,64 +1419,154 @@ export class TokenRouter { } } - if (availableByPriority.size === 0) { - summary.push('没有可用通道(全部被禁用、站点不可用、冷却或令牌不可用)'); - return { - requestedModel, - actualModel: mappedModel, + if (availableByPriority.size === 0) { + summary.push('没有可用通道(全部被禁用、站点不可用、冷却或令牌不可用)'); + return { + requestedModel, + actualModel: mappedModel, matched: true, routeId: match.route.id, modelPattern: match.route.modelPattern, summary, - candidates, - }; - } - - const sortedPriorities = Array.from(availableByPriority.keys()).sort((a, b) => a - b); - let selected: RouteChannelCandidate | null = null; - let selectedPriority = 0; - - for (const priority of sortedPriorities) { - const rawLayer = availableByPriority.get(priority) ?? []; - if (rawLayer.length === 0) continue; - - const filteredLayer = filterRecentlyFailedCandidates(rawLayer, nowMs, RECENT_FAILURE_AVOID_SEC); - const avoided = rawLayer.filter((row) => !filteredLayer.some((item) => item.channel.id === row.channel.id)); - if (avoided.length > 0) { - for (const row of avoided) { - const target = candidateMap.get(row.channel.id); - if (!target) continue; - target.avoidedByRecentFailure = true; - target.reason = `最近失败,优先避让(${RECENT_FAILURE_AVOID_SEC / 60} 分钟窗口)`; - } - } - - const weighted = this.calculateWeightedSelection( - filteredLayer, - useChannelSourceModelForCost - ? (candidate) => (normalizeChannelSourceModel(candidate.channel.sourceModel) || mappedModel) - : mappedModel, - downstreamPolicy, - ); - for (const detail of weighted.details) { - const target = candidateMap.get(detail.candidate.channel.id); - if (!target) continue; - target.probability = Number((detail.probability * 100).toFixed(2)); - if (target.eligible && !target.avoidedByRecentFailure) { - target.reason = detail.reason; - } - } - - if (!weighted.selected) continue; - selected = weighted.selected; - selectedPriority = priority; - summary.push( - avoided.length > 0 - ? `优先级 P${priority}:可用 ${rawLayer.length},因最近失败避让 ${avoided.length}` - : `优先级 P${priority}:可用 ${rawLayer.length}`, - ); - break; - } + candidates, + }; + } + + if (routeStrategy === 'round_robin') { + const rawOrdered = this.getRoundRobinCandidates(match.channels.filter((row) => { + const target = candidateMap.get(row.channel.id); + return !!target?.eligible; + })); + const breakerFiltered = filterSiteRuntimeBrokenCandidatesByModel(rawOrdered, runtimeModelResolver, nowMs); + if (breakerFiltered.avoided.length > 0) { + for (const item of breakerFiltered.avoided) { + const target = candidateMap.get(item.candidate.channel.id); + if (!target) continue; + target.reason = item.reason; + } + const breakerSummaryLabel = breakerFiltered.avoided.some((item) => item.reason.includes('模型熔断')) + ? '运行时熔断避让' + : '站点熔断避让'; + summary.push(`${breakerSummaryLabel} ${breakerFiltered.avoided.length}`); + } + const ordered = breakerFiltered.candidates; + let selected: RouteChannelCandidate | null = null; + + for (let index = 0; index < ordered.length; index += 1) { + const target = candidateMap.get(ordered[index].channel.id); + if (!target || !target.eligible) continue; + target.probability = index === 0 ? 100 : 0; + target.reason = index === 0 + ? `轮询命中(全局第 1 / ${ordered.length} 位,忽略优先级)` + : `轮询排队中(全局第 ${index + 1} / ${ordered.length} 位,忽略优先级)`; + if (index === 0) { + selected = ordered[index]; + } + } + + if (!selected) { + summary.push('本次未选出通道'); + return { + requestedModel, + actualModel: mappedModel, + matched: true, + routeId: match.route.id, + modelPattern: match.route.modelPattern, + summary, + candidates, + }; + } + + const selectedChannel = candidateMap.get(selected.channel.id); + const selectedLabel = selectedChannel + ? `${selectedChannel.username} @ ${selectedChannel.siteName} / ${selectedChannel.tokenName}` + : `channel-${selected.channel.id}`; + const actualModel = resolveActualModelForSelectedChannel( + requestedModel, + match.route, + mappedModel, + selected.channel.sourceModel, + ); + summary.push(`全局轮询:可用 ${ordered.length},忽略优先级`); + summary.push(`最终选择:${selectedLabel}`); + if (actualModel !== mappedModel) { + summary.push(`实际转发模型:${actualModel}`); + } + + return { + requestedModel, + actualModel, + matched: true, + routeId: match.route.id, + modelPattern: match.route.modelPattern, + selectedChannelId: selected.channel.id, + selectedAccountId: selected.account.id, + selectedLabel, + summary, + candidates, + }; + } + + const sortedPriorities = Array.from(availableByPriority.keys()).sort((a, b) => a - b); + let selected: RouteChannelCandidate | null = null; + let selectedPriority = 0; + + for (const priority of sortedPriorities) { + const rawLayer = availableByPriority.get(priority) ?? []; + if (rawLayer.length === 0) continue; + + const breakerFiltered = filterSiteRuntimeBrokenCandidatesByModel(rawLayer, runtimeModelResolver, nowMs); + if (breakerFiltered.avoided.length > 0) { + for (const item of breakerFiltered.avoided) { + const target = candidateMap.get(item.candidate.channel.id); + if (!target) continue; + target.reason = item.reason; + } + } + + const filteredLayer = filterRecentlyFailedCandidates(breakerFiltered.candidates, nowMs); + const avoided = breakerFiltered.candidates.filter((row) => !filteredLayer.some((item) => item.channel.id === row.channel.id)); + if (avoided.length > 0) { + for (const row of avoided) { + const target = candidateMap.get(row.channel.id); + if (!target) continue; + target.avoidedByRecentFailure = true; + target.reason = `最近失败,优先避让(${resolveFailureBackoffSec(row.channel.failCount)} 秒窗口)`; + } + } + + const weighted = this.calculateWeightedSelection( + filteredLayer, + useChannelSourceModelForCost ? runtimeModelResolver : mappedModel, + downstreamPolicy, + nowMs, + routeStrategy === 'stable_first' ? 'stable_first' : 'weighted', + ); + for (const detail of weighted.details) { + const target = candidateMap.get(detail.candidate.channel.id); + if (!target) continue; + target.probability = Number((detail.probability * 100).toFixed(2)); + if (target.eligible && !target.avoidedByRecentFailure) { + target.reason = detail.reason; + } + } + + if (!weighted.selected) continue; + selected = weighted.selected; + selectedPriority = priority; + const layerSummaryParts = [`优先级 P${priority}:可用 ${rawLayer.length}`]; + if (breakerFiltered.avoided.length > 0) { + const breakerSummaryLabel = breakerFiltered.avoided.some((item) => item.reason.includes('模型熔断')) + ? '运行时熔断避让' + : '站点熔断避让'; + layerSummaryParts.push(`${breakerSummaryLabel} ${breakerFiltered.avoided.length}`); + } + if (avoided.length > 0) { + layerSummaryParts.push(`最近失败避让 ${avoided.length}`); + } + summary.push(layerSummaryParts.join(',')); + break; + } if (!selected) { summary.push('本次未选出通道'); @@ -800,69 +1652,282 @@ export class TokenRouter { /** * Record success for a channel. */ - async recordSuccess(channelId: number, latencyMs: number, cost: number) { - const ch = await db.select().from(schema.routeChannels).where(eq(schema.routeChannels.id, channelId)).get(); - if (!ch) return; - const nowIso = new Date().toISOString(); - const nextSuccessCount = (ch.successCount ?? 0) + 1; - const nextTotalLatencyMs = (ch.totalLatencyMs ?? 0) + latencyMs; - const nextTotalCost = (ch.totalCost ?? 0) + cost; + async recordSuccess(channelId: number, latencyMs: number, cost: number, modelName?: string | null) { + await ensureSiteRuntimeHealthStateLoaded(); + const row = await db.select() + .from(schema.routeChannels) + .innerJoin(schema.accounts, eq(schema.routeChannels.accountId, schema.accounts.id)) + .where(eq(schema.routeChannels.id, channelId)) + .get(); + if (!row) return; + const ch = row.route_channels; + const account = row.accounts; + const nowIso = new Date().toISOString(); + const nextSuccessCount = (ch.successCount ?? 0) + 1; + const nextTotalLatencyMs = (ch.totalLatencyMs ?? 0) + latencyMs; + const nextTotalCost = (ch.totalCost ?? 0) + cost; await db.update(schema.routeChannels).set({ successCount: nextSuccessCount, - totalLatencyMs: nextTotalLatencyMs, - totalCost: nextTotalCost, - lastUsedAt: nowIso, - cooldownUntil: null, - lastFailAt: null, - }).where(eq(schema.routeChannels.id, channelId)).run(); - - patchCachedChannel(channelId, (channel) => { - channel.successCount = nextSuccessCount; - channel.totalLatencyMs = nextTotalLatencyMs; - channel.totalCost = nextTotalCost; - channel.lastUsedAt = nowIso; - channel.cooldownUntil = null; - channel.lastFailAt = null; - }); - } - - /** - * Record failure and set cooldown. - */ - async recordFailure(channelId: number) { - const ch = await db.select().from(schema.routeChannels).where(eq(schema.routeChannels.id, channelId)).get(); - if (!ch) return; - const failCount = (ch.failCount ?? 0) + 1; - // Exponential backoff cooldown: 30s, 60s, 120s, 240s, max 5min - const cooldownSec = Math.min(30 * Math.pow(2, failCount - 1), 300); - const cooldownUntil = new Date(Date.now() + cooldownSec * 1000).toISOString(); - const nowIso = new Date().toISOString(); - await db.update(schema.routeChannels).set({ - failCount, - lastFailAt: nowIso, - cooldownUntil, - }).where(eq(schema.routeChannels.id, channelId)).run(); - - patchCachedChannel(channelId, (channel) => { - channel.failCount = failCount; - channel.lastFailAt = nowIso; - channel.cooldownUntil = cooldownUntil; - }); - } + totalLatencyMs: nextTotalLatencyMs, + totalCost: nextTotalCost, + lastUsedAt: nowIso, + cooldownUntil: null, + lastFailAt: null, + consecutiveFailCount: 0, + cooldownLevel: 0, + }).where(eq(schema.routeChannels.id, channelId)).run(); + + patchCachedChannel(channelId, (channel) => { + channel.successCount = nextSuccessCount; + channel.totalLatencyMs = nextTotalLatencyMs; + channel.totalCost = nextTotalCost; + channel.lastUsedAt = nowIso; + channel.cooldownUntil = null; + channel.lastFailAt = null; + channel.consecutiveFailCount = 0; + channel.cooldownLevel = 0; + }); + + recordSiteRuntimeSuccess(account.siteId, latencyMs, modelName); + } + + /** + * Record failure and set cooldown. + */ + async recordFailure(channelId: number, context: SiteRuntimeFailureContext | string | null = {}) { + await ensureSiteRuntimeHealthStateLoaded(); + const row = await db.select() + .from(schema.routeChannels) + .innerJoin(schema.accounts, eq(schema.routeChannels.accountId, schema.accounts.id)) + .innerJoin(schema.tokenRoutes, eq(schema.routeChannels.routeId, schema.tokenRoutes.id)) + .where(eq(schema.routeChannels.id, channelId)) + .get(); + if (!row) return; + + const ch = row.route_channels; + const account = row.accounts; + const route = row.token_routes; + const nowMs = Date.now(); + const nowIso = new Date(nowMs).toISOString(); + const failCount = (ch.failCount ?? 0) + 1; + const normalizedContext: SiteRuntimeFailureContext = typeof context === 'string' + ? { modelName: context } + : (context ?? {}); + const routeStrategy = resolveRouteStrategy(route); + let cooldownUntil: string | null = null; + let consecutiveFailCount = Math.max(0, ch.consecutiveFailCount ?? 0) + 1; + let cooldownLevel = Math.max(0, ch.cooldownLevel ?? 0); + + if (routeStrategy === 'round_robin') { + if (consecutiveFailCount >= ROUND_ROBIN_FAILURE_THRESHOLD) { + cooldownLevel = Math.min(cooldownLevel + 1, ROUND_ROBIN_COOLDOWN_LEVELS_SEC.length - 1); + const cooldownSec = resolveRoundRobinCooldownSec(cooldownLevel); + cooldownUntil = cooldownSec > 0 ? new Date(nowMs + cooldownSec * 1000).toISOString() : null; + consecutiveFailCount = 0; + } + } else { + const cooldownSec = resolveFailureBackoffSec(failCount); + cooldownUntil = new Date(nowMs + cooldownSec * 1000).toISOString(); + consecutiveFailCount = 0; + cooldownLevel = 0; + } + + await db.update(schema.routeChannels).set({ + failCount, + lastFailAt: nowIso, + consecutiveFailCount, + cooldownLevel, + cooldownUntil, + }).where(eq(schema.routeChannels.id, channelId)).run(); + + patchCachedChannel(channelId, (channel) => { + channel.failCount = failCount; + channel.lastFailAt = nowIso; + channel.cooldownUntil = cooldownUntil; + channel.consecutiveFailCount = consecutiveFailCount; + channel.cooldownLevel = cooldownLevel; + }); + + recordSiteRuntimeFailure(account.siteId, normalizedContext, nowMs); + } /** * Get all available models (aggregated from all routes). */ async getAvailableModels(): Promise { const routes = await loadEnabledRoutes(); - const exposed = routes + const exposed = buildVisibleEnabledRoutes(routes) .map((route) => getExposedModelNameForRoute(route).trim()) .filter((name) => name.length > 0); - return Array.from(new Set(exposed)); - } - - // --- Private methods --- - + return Array.from(new Set(exposed)); + } + + // --- Private methods --- + + private async selectFromMatch( + match: RouteMatch, + requestedModel: string, + downstreamPolicy: DownstreamRoutingPolicy, + excludeChannelIds: number[] = [], + recordSelection = true, + ): Promise { + const mappedModel = resolveMappedModel(requestedModel, match.route.modelMapping); + const requestedByDisplayName = isRouteDisplayNameMatch(requestedModel, match.route.displayName); + const bypassSourceModelCheck = requestedByDisplayName; + const routeStrategy = resolveRouteStrategy(match.route); + const runtimeModelResolver = requestedByDisplayName + ? ((candidate: RouteChannelCandidate) => normalizeChannelSourceModel(candidate.channel.sourceModel) || mappedModel) + : mappedModel; + + const nowIso = new Date().toISOString(); + const nowMs = Date.now(); + const available = match.channels.filter((candidate) => ( + this.getCandidateEligibilityReasons(candidate, { + requestedModel, + bypassSourceModelCheck, + excludeChannelIds, + nowIso, + }).length === 0 + )); + + if (available.length === 0) return null; + + if (routeStrategy === 'round_robin') { + const breakerFiltered = filterSiteRuntimeBrokenCandidatesByModel(available, runtimeModelResolver, nowMs); + const selected = this.selectRoundRobinCandidate(breakerFiltered.candidates); + if (!selected) return null; + + const tokenValue = this.resolveChannelTokenValue(selected); + if (!tokenValue) return null; + if (recordSelection) { + await this.recordChannelSelection(selected.channel.id); + } + + const actualModel = resolveActualModelForSelectedChannel( + requestedModel, + match.route, + mappedModel, + selected.channel.sourceModel, + ); + + return { + ...selected, + tokenValue, + tokenName: selected.token?.name || 'default', + actualModel, + }; + } + + const layers = new Map(); + for (const candidate of available) { + const priority = candidate.channel.priority ?? 0; + if (!layers.has(priority)) layers.set(priority, []); + layers.get(priority)!.push(candidate); + } + + const sortedPriorities = Array.from(layers.keys()).sort((a, b) => a - b); + for (const priority of sortedPriorities) { + const rawLayer = layers.get(priority) ?? []; + const breakerFiltered = filterSiteRuntimeBrokenCandidatesByModel(rawLayer, runtimeModelResolver, nowMs); + const candidates = filterRecentlyFailedCandidates(breakerFiltered.candidates, nowMs); + const selected = routeStrategy === 'stable_first' + ? this.stableFirstSelect( + candidates, + requestedByDisplayName ? runtimeModelResolver : mappedModel, + downstreamPolicy, + nowMs, + ) + : this.weightedRandomSelect( + candidates, + requestedByDisplayName ? runtimeModelResolver : mappedModel, + downstreamPolicy, + nowMs, + ); + if (!selected) continue; + + const tokenValue = this.resolveChannelTokenValue(selected); + if (!tokenValue) continue; + if (routeStrategy === 'stable_first' && recordSelection) { + await this.recordChannelSelection(selected.channel.id); + } + + const actualModel = resolveActualModelForSelectedChannel( + requestedModel, + match.route, + mappedModel, + selected.channel.sourceModel, + ); + + return { + ...selected, + tokenValue, + tokenName: selected.token?.name || 'default', + actualModel, + }; + } + + return null; + } + + private async selectPreferredFromMatch( + match: RouteMatch, + requestedModel: string, + preferredChannelId: number, + excludeChannelIds: number[] = [], + recordSelection = true, + ): Promise { + const mappedModel = resolveMappedModel(requestedModel, match.route.modelMapping); + const requestedByDisplayName = isRouteDisplayNameMatch(requestedModel, match.route.displayName); + const bypassSourceModelCheck = requestedByDisplayName; + const routeStrategy = resolveRouteStrategy(match.route); + const runtimeModelResolver = requestedByDisplayName + ? ((candidate: RouteChannelCandidate) => normalizeChannelSourceModel(candidate.channel.sourceModel) || mappedModel) + : mappedModel; + + const nowIso = new Date().toISOString(); + const nowMs = Date.now(); + const available = match.channels.filter((candidate) => ( + this.getCandidateEligibilityReasons(candidate, { + requestedModel, + bypassSourceModelCheck, + excludeChannelIds, + nowIso, + }).length === 0 + )); + + const preferred = available.find((candidate) => candidate.channel.id === preferredChannelId); + if (!preferred) return null; + + const breakerFiltered = filterSiteRuntimeBrokenCandidatesByModel([preferred], runtimeModelResolver, nowMs); + if (breakerFiltered.candidates.length <= 0) return null; + + const selected = breakerFiltered.candidates.find((candidate) => candidate.channel.id === preferredChannelId); + if (!selected) return null; + if (routeStrategy !== 'round_robin' && isChannelRecentlyFailed(selected.channel, nowMs)) { + return null; + } + + const tokenValue = this.resolveChannelTokenValue(selected); + if (!tokenValue) return null; + if (recordSelection && (routeStrategy === 'round_robin' || routeStrategy === 'stable_first')) { + await this.recordChannelSelection(selected.channel.id); + } + + const actualModel = resolveActualModelForSelectedChannel( + requestedModel, + match.route, + mappedModel, + selected.channel.sourceModel, + ); + + return { + ...selected, + tokenValue, + tokenName: selected.token?.name || 'default', + actualModel, + }; + } + private async findRoute(model: string, downstreamPolicy: DownstreamRoutingPolicy): Promise { let routes = await loadEnabledRoutes(); @@ -876,12 +1941,16 @@ export class TokenRouter { routes = routes.filter((route) => allowSet.has(route.id)); } - // Find matching route by model pattern or display alias. - const matchedRoute = routes.find((r) => { - return matchesRouteRequestModel(model, r); - }); - - if (!matchedRoute) return null; + const matchedRoute = routes.find((route) => ( + !isExplicitGroupRoute(route) + && isExactRouteModelPattern(route.modelPattern) + && (route.modelPattern || '').trim() === model + )) + || routes.find((route) => isExplicitGroupRoute(route) && isRouteDisplayNameMatch(model, route.displayName)) + || routes.find((route) => !isExplicitGroupRoute(route) && isRouteDisplayNameMatch(model, route.displayName)) + || routes.find((route) => !isExplicitGroupRoute(route) && matchesModelPattern(model, route.modelPattern)); + + if (!matchedRoute) return null; return await this.loadRouteMatch(matchedRoute); } @@ -897,7 +1966,7 @@ export class TokenRouter { return await this.loadRouteMatch(route); } - private async loadRouteMatch(route: typeof schema.tokenRoutes.$inferSelect): Promise { + private async loadRouteMatch(route: RouteRow): Promise { return await loadRouteMatch(route); } @@ -906,16 +1975,24 @@ export class TokenRouter { account: typeof schema.accounts.$inferSelect; site?: typeof schema.sites.$inferSelect | null; token: typeof schema.accountTokens.$inferSelect | null; - }): string | null { + }): string | null { if (candidate.channel.tokenId) { if (!candidate.token) return null; - if (!candidate.token.enabled) return null; + if (!isUsableAccountToken(candidate.token)) return null; const token = candidate.token.token?.trim(); return token ? token : null; } + if (getOauthInfoFromAccount(candidate.account)) { + const accessToken = candidate.account.accessToken?.trim(); + if (accessToken) return accessToken; + return null; + } + const fallback = candidate.account.apiToken?.trim(); - return fallback || null; + if (fallback) return fallback; + + return null; } private getCandidateEligibilityReasons( @@ -959,47 +2036,105 @@ export class TokenRouter { return reasonParts; } + private getRoundRobinCandidates(candidates: RouteChannelCandidate[]): RouteChannelCandidate[] { + return [...candidates].sort((left, right) => { + const selectionOrder = compareNullableTimeAsc( + left.channel.lastSelectedAt || left.channel.lastUsedAt, + right.channel.lastSelectedAt || right.channel.lastUsedAt, + ); + if (selectionOrder !== 0) return selectionOrder; + + const usedOrder = compareNullableTimeAsc(left.channel.lastUsedAt, right.channel.lastUsedAt); + if (usedOrder !== 0) return usedOrder; + + return (left.channel.id ?? 0) - (right.channel.id ?? 0); + }); + } + + private selectRoundRobinCandidate(candidates: RouteChannelCandidate[]): RouteChannelCandidate | null { + return this.getRoundRobinCandidates(candidates)[0] ?? null; + } + + private compareStableFirstCandidates(left: RouteChannelCandidate, right: RouteChannelCandidate): number { + const selectionOrder = compareNullableTimeAsc( + left.channel.lastSelectedAt || left.channel.lastUsedAt, + right.channel.lastSelectedAt || right.channel.lastUsedAt, + ); + if (selectionOrder !== 0) return selectionOrder; + + const usedOrder = compareNullableTimeAsc(left.channel.lastUsedAt, right.channel.lastUsedAt); + if (usedOrder !== 0) return usedOrder; + + return (left.channel.id ?? 0) - (right.channel.id ?? 0); + } + + private async recordChannelSelection(channelId: number): Promise { + const nowIso = new Date().toISOString(); + await db.update(schema.routeChannels).set({ + lastSelectedAt: nowIso, + }).where(eq(schema.routeChannels.id, channelId)).run(); + + patchCachedChannel(channelId, (channel) => { + channel.lastSelectedAt = nowIso; + }); + } + private weightedRandomSelect( candidates: RouteChannelCandidate[], - modelName: string | ((candidate: RouteChannelCandidate) => string), - downstreamPolicy: DownstreamRoutingPolicy, - ) { - return this.calculateWeightedSelection(candidates, modelName, downstreamPolicy).selected; - } - - private calculateWeightedSelection( - candidates: RouteChannelCandidate[], - modelName: string | ((candidate: RouteChannelCandidate) => string), - downstreamPolicy: DownstreamRoutingPolicy, - ) { - if (candidates.length === 0) { - return { - selected: null as RouteChannelCandidate | null, - details: [] as Array<{ candidate: RouteChannelCandidate; probability: number; reason: string }>, + modelName: string | ((candidate: RouteChannelCandidate) => string), + downstreamPolicy: DownstreamRoutingPolicy, + nowMs = Date.now(), + ) { + return this.calculateWeightedSelection(candidates, modelName, downstreamPolicy, nowMs, 'weighted').selected; + } + + private stableFirstSelect( + candidates: RouteChannelCandidate[], + modelName: string | ((candidate: RouteChannelCandidate) => string), + downstreamPolicy: DownstreamRoutingPolicy, + nowMs = Date.now(), + ) { + return this.calculateWeightedSelection(candidates, modelName, downstreamPolicy, nowMs, 'stable_first').selected; + } + + private calculateWeightedSelection( + candidates: RouteChannelCandidate[], + modelName: string | ((candidate: RouteChannelCandidate) => string), + downstreamPolicy: DownstreamRoutingPolicy, + nowMs = Date.now(), + selectionMode: WeightedSelectionMode = 'weighted', + ) { + if (candidates.length === 0) { + return { + selected: null as RouteChannelCandidate | null, + details: [] as Array<{ candidate: RouteChannelCandidate; probability: number; reason: string }>, }; } - if (candidates.length === 1) { - return { - selected: candidates[0], - details: [{ - candidate: candidates[0], - probability: 1, - reason: '唯一可用候选', - }], - }; - } + if (candidates.length === 1) { + return { + selected: candidates[0], + details: [{ + candidate: candidates[0], + probability: 1, + reason: selectionMode === 'stable_first' ? '稳定优先(唯一可用候选)' : '唯一可用候选', + }], + }; + } const { baseWeightFactor, valueScoreFactor, costWeight, balanceWeight, usageWeight } = config.routingWeights; - const resolveModelName = typeof modelName === 'function' - ? modelName - : (() => modelName); - const effectiveCosts = candidates.map((candidate) => resolveEffectiveUnitCost(candidate, resolveModelName(candidate))); - - const valueScores = candidates.map((c, i) => { - const unitCost = effectiveCosts[i]?.unitCost || 1; - const balance = c.account.balance || 0; - const totalUsed = (c.channel.successCount ?? 0) + (c.channel.failCount ?? 0); + const resolveModelName = typeof modelName === 'function' + ? modelName + : (() => modelName); + const effectiveCosts = candidates.map((candidate) => resolveEffectiveUnitCost(candidate, resolveModelName(candidate))); + const runtimeHealthDetails = candidates.map((candidate) => ( + getSiteRuntimeHealthDetails(candidate.site.id, resolveModelName(candidate), nowMs) + )); + + const valueScores = candidates.map((c, i) => { + const unitCost = effectiveCosts[i]?.unitCost || 1; + const balance = c.account.balance || 0; + const totalUsed = (c.channel.successCount ?? 0) + (c.channel.failCount ?? 0); const recentUsage = Math.max(totalUsed, 1); return costWeight * (1 / unitCost) + balanceWeight * balance + usageWeight * (1 / recentUsage); }); @@ -1016,14 +2151,15 @@ export class TokenRouter { // Avoid over-favoring a site that has many tokens/channels for the same route. // Site-level total contribution remains comparable, then split across its channels. - const siteChannelCounts = new Map(); - for (const candidate of candidates) { - siteChannelCounts.set(candidate.site.id, (siteChannelCounts.get(candidate.site.id) || 0) + 1); - } - - const contributions = candidates.map((candidate, i) => { - const siteChannels = Math.max(1, siteChannelCounts.get(candidate.site.id) || 1); - let contribution = baseContributions[i] / siteChannels; + const siteChannelCounts = new Map(); + for (const candidate of candidates) { + siteChannelCounts.set(candidate.site.id, (siteChannelCounts.get(candidate.site.id) || 0) + 1); + } + const siteHistoricalHealthMetrics = buildSiteHistoricalHealthMetrics(candidates); + + const contributions = candidates.map((candidate, i) => { + const siteChannels = Math.max(1, siteChannelCounts.get(candidate.site.id) || 1); + let contribution = baseContributions[i] / siteChannels; const downstreamSiteMultiplier = downstreamPolicy.siteWeightMultipliers[candidate.site.id] ?? 1; const normalizedDownstreamSiteMultiplier = (Number.isFinite(downstreamSiteMultiplier) && downstreamSiteMultiplier > 0) @@ -1033,25 +2169,40 @@ export class TokenRouter { (Number.isFinite(candidate.site.globalWeight) && (candidate.site.globalWeight || 0) > 0) ? (candidate.site.globalWeight as number) : 1; - const combinedSiteWeight = siteGlobalWeight * normalizedDownstreamSiteMultiplier; - if (combinedSiteWeight > 0 && Number.isFinite(combinedSiteWeight)) { - contribution *= combinedSiteWeight; - } - - // If upstream price is unknown and we are using fallback unit cost, - // apply an explicit penalty so raising fallback cost meaningfully lowers probability. - if (effectiveCosts[i]?.source === 'fallback') { - contribution *= 1 / Math.max(1, effectiveCosts[i]?.unitCost || 1); + const combinedSiteWeight = siteGlobalWeight * normalizedDownstreamSiteMultiplier; + if (combinedSiteWeight > 0 && Number.isFinite(combinedSiteWeight)) { + contribution *= combinedSiteWeight; + } + + contribution *= runtimeHealthDetails[i]?.combinedMultiplier ?? 1; + contribution *= siteHistoricalHealthMetrics.get(candidate.site.id)?.multiplier ?? 1; + + // If upstream price is unknown and we are using fallback unit cost, + // apply an explicit penalty so raising fallback cost meaningfully lowers probability. + if (effectiveCosts[i]?.source === 'fallback') { + contribution *= 1 / Math.max(1, effectiveCosts[i]?.unitCost || 1); } return contribution; }); - - const totalContribution = contributions.reduce((a, b) => a + b, 0); - const details = candidates.map((candidate, i) => { - const probability = totalContribution > 0 ? contributions[i] / totalContribution : 0; - const weight = candidate.channel.weight ?? 10; - const cost = effectiveCosts[i]; + + const totalContribution = contributions.reduce((a, b) => a + b, 0); + const rankedIndices = candidates.map((_, index) => index) + .sort((leftIndex, rightIndex) => { + const contributionDiff = contributions[rightIndex] - contributions[leftIndex]; + if (Math.abs(contributionDiff) > 1e-9) { + return contributionDiff > 0 ? 1 : -1; + } + return this.compareStableFirstCandidates(candidates[leftIndex], candidates[rightIndex]); + }); + const rankByIndex = new Map(); + rankedIndices.forEach((candidateIndex, rank) => { + rankByIndex.set(candidateIndex, rank + 1); + }); + const details = candidates.map((candidate, i) => { + const probability = totalContribution > 0 ? contributions[i] / totalContribution : 0; + const weight = candidate.channel.weight ?? 10; + const cost = effectiveCosts[i]; const costSourceText = cost?.source === 'observed' ? '实测' : (cost?.source === 'configured' ? '配置' : (cost?.source === 'catalog' ? '目录' : '默认')); @@ -1061,31 +2212,55 @@ export class TokenRouter { (Number.isFinite(downstreamSiteMultiplier) && downstreamSiteMultiplier > 0) ? downstreamSiteMultiplier : 1; - const siteGlobalWeight = - (Number.isFinite(candidate.site.globalWeight) && (candidate.site.globalWeight || 0) > 0) - ? (candidate.site.globalWeight as number) - : 1; - const combinedSiteWeight = siteGlobalWeight * normalizedDownstreamSiteMultiplier; - return { - candidate, - probability, - reason: `按权重随机(W=${weight},成本=${costSourceText}:${(cost?.unitCost || 1).toFixed(6)},站点权重=${siteGlobalWeight.toFixed(2)}x下游倍率=${normalizedDownstreamSiteMultiplier.toFixed(2)}=${combinedSiteWeight.toFixed(2)},同站点通道=${siteChannels},概率≈${(probability * 100).toFixed(1)}%)`, - }; - }); - - let rand = Math.random() * totalContribution; - let selected = candidates[candidates.length - 1]; - for (let i = 0; i < candidates.length; i++) { - rand -= contributions[i]; - if (rand <= 0) { - selected = candidates[i]; - break; - } - } - - return { selected, details }; - } + const siteGlobalWeight = + (Number.isFinite(candidate.site.globalWeight) && (candidate.site.globalWeight || 0) > 0) + ? (candidate.site.globalWeight as number) + : 1; + const combinedSiteWeight = siteGlobalWeight * normalizedDownstreamSiteMultiplier; + const siteRuntimeDetail = runtimeHealthDetails[i]; + const siteHistoricalHealth = siteHistoricalHealthMetrics.get(candidate.site.id); + const siteHistoricalMultiplier = siteHistoricalHealth?.multiplier ?? 1; + const historicalSuccessRateText = siteHistoricalHealth?.successRate == null + ? '—' + : `${(siteHistoricalHealth.successRate * 100).toFixed(1)}%`; + const historicalLatencyText = siteHistoricalHealth?.avgLatencyMs == null + ? '—' + : `${siteHistoricalHealth.avgLatencyMs}ms`; + const runtimeHealthText = siteRuntimeDetail.modelKey + ? `${siteRuntimeDetail.combinedMultiplier.toFixed(2)}(站点=${siteRuntimeDetail.globalMultiplier.toFixed(2)},模型=${siteRuntimeDetail.modelMultiplier.toFixed(2)})` + : `${siteRuntimeDetail.globalMultiplier.toFixed(2)}`; + const reasonPrefix = selectionMode === 'stable_first' + ? `稳定优先(综合评分第 ${rankByIndex.get(i) ?? 1} / ${candidates.length}` + : '按权重随机'; + return { + candidate, + probability, + reason: selectionMode === 'stable_first' + ? `${reasonPrefix},W=${weight},成本=${costSourceText}:${(cost?.unitCost || 1).toFixed(6)},站点权重=${siteGlobalWeight.toFixed(2)}x下游倍率=${normalizedDownstreamSiteMultiplier.toFixed(2)}=${combinedSiteWeight.toFixed(2)},运行时健康=${runtimeHealthText},历史健康=${siteHistoricalMultiplier.toFixed(2)}(成功率=${historicalSuccessRateText},均延迟=${historicalLatencyText},样本=${siteHistoricalHealth?.totalCalls ?? 0}),同站点通道=${siteChannels},评分占比≈${(probability * 100).toFixed(1)}%)` + : `按权重随机(W=${weight},成本=${costSourceText}:${(cost?.unitCost || 1).toFixed(6)},站点权重=${siteGlobalWeight.toFixed(2)}x下游倍率=${normalizedDownstreamSiteMultiplier.toFixed(2)}=${combinedSiteWeight.toFixed(2)},运行时健康=${runtimeHealthText},历史健康=${siteHistoricalMultiplier.toFixed(2)}(成功率=${historicalSuccessRateText},均延迟=${historicalLatencyText},样本=${siteHistoricalHealth?.totalCalls ?? 0}),同站点通道=${siteChannels},概率≈${(probability * 100).toFixed(1)}%)`, + }; + }); + + let selected = candidates[rankedIndices[0] ?? 0]; + if (selectionMode === 'weighted') { + let rand = Math.random() * totalContribution; + selected = candidates[candidates.length - 1]; + for (let i = 0; i < candidates.length; i++) { + rand -= contributions[i]; + if (rand <= 0) { + selected = candidates[i]; + break; + } + } + } + + return { selected, details }; + } } -export const tokenRouter = new TokenRouter(); +export const tokenRouter = new TokenRouter(); + +export const __tokenRouterTestUtils = { + resolveMappedModel, +}; diff --git a/src/server/test-fixtures/protocol/chat-inline-think.sse b/src/server/test-fixtures/protocol/chat-inline-think.sse new file mode 100644 index 00000000..24a537d6 --- /dev/null +++ b/src/server/test-fixtures/protocol/chat-inline-think.sse @@ -0,0 +1,9 @@ +data: {"id":"chatcmpl-think","model":"upstream-gpt","choices":[{"delta":{"role":"assistant"},"finish_reason":null}]} + +data: {"id":"chatcmpl-think","model":"upstream-gpt","choices":[{"delta":{"content":"plan quietly"},"finish_reason":null}]} + +data: {"id":"chatcmpl-think","model":"upstream-gpt","choices":[{"delta":{"content":"visible answer"},"finish_reason":null}]} + +data: {"id":"chatcmpl-think","model":"upstream-gpt","choices":[{"delta":{},"finish_reason":"stop"}]} + +data: [DONE] diff --git a/src/server/test-fixtures/protocol/chat-missing-finish.sse b/src/server/test-fixtures/protocol/chat-missing-finish.sse new file mode 100644 index 00000000..e8d55f50 --- /dev/null +++ b/src/server/test-fixtures/protocol/chat-missing-finish.sse @@ -0,0 +1,3 @@ +data: {"id":"chatcmpl-eof","model":"upstream-gpt","choices":[{"delta":{"role":"assistant"},"finish_reason":null}]} + +data: {"id":"chatcmpl-eof","model":"upstream-gpt","choices":[{"delta":{"content":"tail before eof"},"finish_reason":null}]} diff --git a/src/server/test-fixtures/protocol/messages-cumulative-text.sse b/src/server/test-fixtures/protocol/messages-cumulative-text.sse new file mode 100644 index 00000000..c3815765 --- /dev/null +++ b/src/server/test-fixtures/protocol/messages-cumulative-text.sse @@ -0,0 +1,16 @@ +event: message_start +data: {"type":"message_start","message":{"id":"msg_dup_1","model":"upstream-gpt"}} + +event: content_block_delta +data: {"type":"content_block_delta","delta":{"type":"text_delta","text":"I'm Claude, an AI assistant made by Anthropic."}} + +event: content_block_delta +data: {"type":"content_block_delta","delta":{"type":"text_delta","text":"I'm Claude, an AI assistant made by Anthropic."}} + +event: content_block_delta +data: {"type":"content_block_delta","delta":{"type":"text_delta","text":"I'm Claude, an AI assistant made by Anthropic."}} + +event: message_stop +data: {"type":"message_stop"} + +data: [DONE] diff --git a/src/server/test-fixtures/protocol/responses-failed-sparse.sse b/src/server/test-fixtures/protocol/responses-failed-sparse.sse new file mode 100644 index 00000000..abe7a3b7 --- /dev/null +++ b/src/server/test-fixtures/protocol/responses-failed-sparse.sse @@ -0,0 +1,10 @@ +event: response.created +data: {"type":"response.created","response":{"id":"resp_fail_1","model":"upstream-gpt","created_at":1706000000,"status":"in_progress","output":[]}} + +event: response.output_item.added +data: {"type":"response.output_item.added","output_index":0,"item":{"id":"msg_0","type":"message","role":"assistant","status":"in_progress","content":[]}} + +event: response.failed +data: {"type":"response.failed","response":{"id":"resp_fail_1","model":"upstream-gpt","status":"failed","error":{"message":"upstream stream failed"}}} + +data: [DONE] diff --git a/src/server/test-fixtures/protocol/responses-native-sparse.sse b/src/server/test-fixtures/protocol/responses-native-sparse.sse new file mode 100644 index 00000000..5cb5ebd5 --- /dev/null +++ b/src/server/test-fixtures/protocol/responses-native-sparse.sse @@ -0,0 +1,13 @@ +event: response.created +data: {"type":"response.created","response":{"id":"resp_sparse_1","model":"upstream-gpt","created_at":1706000000,"status":"in_progress","output":[]}} + +event: response.output_item.added +data: {"type":"response.output_item.added","output_index":0,"item":{"id":"msg_0","type":"message","role":"assistant","status":"in_progress","content":[]}} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","output_index":0,"item_id":"msg_0","delta":"hello world"} + +event: response.completed +data: {"type":"response.completed","response":{"id":"resp_sparse_1","model":"upstream-gpt","status":"completed","usage":{"input_tokens":2,"output_tokens":4,"total_tokens":6}}} + +data: [DONE] diff --git a/src/server/transformers/anthropic/messages/conversion.test.ts b/src/server/transformers/anthropic/messages/conversion.test.ts index c765dbba..215f3aa5 100644 --- a/src/server/transformers/anthropic/messages/conversion.test.ts +++ b/src/server/transformers/anthropic/messages/conversion.test.ts @@ -524,6 +524,91 @@ describe('convertOpenAiBodyToAnthropicMessagesBody', () => { ]); }); + it('preserves tool_reference blocks inside tool_result content arrays', () => { + const result = sanitizeAnthropicMessagesBody({ + model: 'claude-opus-4-6', + messages: [ + { + role: 'user', + content: [ + { + type: 'tool_result', + tool_use_id: 'tool_1', + content: [ + { type: 'tool_reference', tool_name: 'lookup' }, + { type: 'text', text: 'follow-up' }, + ], + }, + ], + }, + ], + }); + + expect(result.messages).toEqual([ + { + role: 'user', + content: [ + { + type: 'tool_result', + tool_use_id: 'tool_1', + content: [ + { type: 'tool_reference', tool_name: 'lookup' }, + { type: 'text', text: 'follow-up' }, + ], + cache_control: { type: 'ephemeral' }, + }, + ], + }, + ]); + }); + + it('preserves image blocks inside tool_result content arrays', () => { + const result = sanitizeAnthropicMessagesBody({ + model: 'claude-opus-4-6', + messages: [ + { + role: 'user', + content: [ + { + type: 'tool_result', + tool_use_id: 'tool_1', + content: [ + { type: 'text', text: 'found 1 image' }, + { + type: 'image_url', + image_url: 'https://example.com/cat.png', + }, + ], + }, + ], + }, + ], + }); + + expect(result.messages).toEqual([ + { + role: 'user', + content: [ + { + type: 'tool_result', + tool_use_id: 'tool_1', + content: [ + { type: 'text', text: 'found 1 image' }, + { + type: 'image', + source: { + type: 'url', + url: 'https://example.com/cat.png', + }, + }, + ], + cache_control: { type: 'ephemeral' }, + }, + ], + }, + ]); + }); + it('adds a second message cache anchor around the 20-block window for long prompts', () => { const longContent = Array.from({ length: 25 }, (_, index) => ({ type: 'text', @@ -565,6 +650,57 @@ describe('convertOpenAiBodyToAnthropicMessagesBody', () => { expect(body.output_config).toEqual({ effort: 'high' }); }); + it('copies metadata.user_id into the anthropic payload', () => { + const metadata = { user_id: 'anthropic-user-1', trace_id: 'trace-xyz' }; + const body = convertOpenAiBodyToAnthropicMessagesBody( + { + model: 'gpt-5', + metadata, + messages: [{ role: 'user', content: 'hello' }], + }, + 'claude-opus-4-6', + true, + ); + + expect(body.metadata).toEqual(metadata); + }); + + it('marks tool_choice.disable_parallel_tool_use when parallel_tool_calls is false', () => { + const body = convertOpenAiBodyToAnthropicMessagesBody( + { + model: 'gpt-5', + parallel_tool_calls: false, + messages: [{ role: 'user', content: 'hello' }], + }, + 'claude-opus-4-6', + true, + ); + + expect(body.tool_choice).toMatchObject({ + type: 'auto', + disable_parallel_tool_use: true, + }); + }); + + it('augments explicit tool_choice with disable_parallel_tool_use when parallel_tool_calls is false', () => { + const body = convertOpenAiBodyToAnthropicMessagesBody( + { + model: 'gpt-5', + parallel_tool_calls: false, + tool_choice: { type: 'tool', name: 'search' }, + messages: [{ role: 'user', content: 'hello' }], + }, + 'claude-opus-4-6', + true, + ); + + expect(body.tool_choice).toMatchObject({ + type: 'tool', + name: 'search', + disable_parallel_tool_use: true, + }); + }); + it('keeps inbound claude bodies thin instead of rebuilding cache anchors', () => { const result = anthropicMessagesInbound.parse( { @@ -586,7 +722,7 @@ describe('convertOpenAiBodyToAnthropicMessagesBody', () => { ); expect(result.error).toBeUndefined(); - expect(result.value?.claudeOriginalBody).toEqual({ + expect(result.value?.parsed.claudeOriginalBody).toEqual({ model: 'gpt-5', max_tokens: 512, tools: [ @@ -623,7 +759,7 @@ describe('convertOpenAiToolChoiceToAnthropic', () => { max_tokens: 512, messages: [{ role: 'user', content: 'hello' }], tool_choice: { type: 'tool', tool: { name: 'lookup' } }, - }).value?.claudeOriginalBody?.tool_choice).toEqual({ + }).value?.parsed.claudeOriginalBody?.tool_choice).toEqual({ type: 'tool', name: 'lookup', }); @@ -633,7 +769,7 @@ describe('convertOpenAiToolChoiceToAnthropic', () => { max_tokens: 512, messages: [{ role: 'user', content: 'hello' }], tool_choice: { type: 'none', name: 'should-disappear' }, - }).value?.claudeOriginalBody?.tool_choice).toEqual({ + }).value?.parsed.claudeOriginalBody?.tool_choice).toEqual({ type: 'none', }); }); diff --git a/src/server/transformers/anthropic/messages/conversion.ts b/src/server/transformers/anthropic/messages/conversion.ts index c240e2ea..89dbaded 100644 --- a/src/server/transformers/anthropic/messages/conversion.ts +++ b/src/server/transformers/anthropic/messages/conversion.ts @@ -29,6 +29,14 @@ function safeJsonStringify(value: unknown): string { } } +function cloneJsonValue(value: T): T | undefined { + try { + return JSON.parse(JSON.stringify(value)) as T; + } catch { + return undefined; + } +} + function parseJsonString(raw: string): unknown { const trimmed = raw.trim(); if (!trimmed) return {}; @@ -187,12 +195,56 @@ function normalizeTextCandidate(value: unknown): string { return asTrimmedString(value.text ?? value.content ?? value.output_text); } -function normalizeToolMessageContent(raw: unknown): string { +function ensureAnthropicToolChoiceRecord(value: unknown): Record | undefined { + if (isRecord(value)) return value; + if (typeof value === 'string') { + const normalized = value.trim().toLowerCase(); + if (!normalized) return undefined; + return { type: normalized }; + } + return undefined; +} + +function sanitizeToolReferenceBlock(item: Record): Record | null { + const toolName = asTrimmedString(item.tool_name ?? item.toolName ?? item.name); + if (!toolName) return null; + const next: Record = { + ...item, + type: 'tool_reference', + tool_name: toolName, + }; + delete next.toolName; + if ('name' in next) delete next.name; + return next; +} + +function normalizeToolMessageContent(raw: unknown): string | Array> { if (raw === undefined || raw === null) return ''; if (typeof raw === 'string') return raw; if (typeof raw === 'number' || typeof raw === 'boolean') return String(raw); if (Array.isArray(raw)) { + const structuredBlocks = raw + .map((item) => { + if (typeof item === 'string') return toAnthropicTextBlock(item); + if (!isRecord(item)) return null; + const type = asTrimmedString(item.type).toLowerCase(); + if (type === 'tool_reference') return sanitizeToolReferenceBlock(item); + if (type === 'text' || type === 'input_text' || type === 'output_text') { + return toAnthropicTextBlock(normalizeTextCandidate(item)); + } + if (type === 'image_url' || type === 'input_image') { + return toAnthropicImageBlock(item); + } + if (type === 'file' || type === 'input_file') { + const fileBlock = normalizeInputFileBlock(item); + return fileBlock ? toAnthropicDocumentBlock(fileBlock) : null; + } + return null; + }) + .filter((item): item is Record => item !== null); + if (structuredBlocks.length > 0) return structuredBlocks; + const textParts = raw .map((item) => { if (typeof item === 'string') return item; @@ -205,6 +257,17 @@ function normalizeToolMessageContent(raw: unknown): string { } if (isRecord(raw)) { + const type = asTrimmedString(raw.type).toLowerCase(); + if (type === 'image_url' || type === 'input_image') { + const imageBlock = toAnthropicImageBlock(raw); + return imageBlock ? [imageBlock] : ''; + } + if (type === 'file' || type === 'input_file') { + const fileBlock = normalizeInputFileBlock(raw); + if (!fileBlock) return ''; + const documentBlock = toAnthropicDocumentBlock(fileBlock); + return documentBlock ? [documentBlock] : ''; + } const text = normalizeTextCandidate(raw); return text || safeJsonStringify(raw); } @@ -329,7 +392,7 @@ function sanitizeAnthropicContentBlock(item: Record): Record = { type: 'tool_result', tool_use_id: toolUseId, @@ -355,6 +418,10 @@ function sanitizeAnthropicContentBlock(item: Record): Record 0 + : toolResultContent.length > 0; + if (toolUseId && hasToolResultContent) { toolResultBlocks.push({ type: 'tool_result', tool_use_id: toolUseId, @@ -906,6 +976,14 @@ export function convertOpenAiBodyToAnthropicMessagesBody( max_tokens: toFiniteNumber(openaiBody.max_tokens) ?? 4096, }; + const openAiMetadata = openaiBody.metadata; + if (isRecord(openAiMetadata)) { + const clonedMetadata = cloneJsonValue(openAiMetadata); + if (isRecord(clonedMetadata) && 'user_id' in clonedMetadata) { + body.metadata = clonedMetadata; + } + } + if (systemContents.length > 0) { body.system = systemContents.join('\n\n'); } @@ -922,8 +1000,17 @@ export function convertOpenAiBodyToAnthropicMessagesBody( if (openaiBody.tools !== undefined) body.tools = convertOpenAiToolsToAnthropic(openaiBody.tools); - const anthropicToolChoice = convertOpenAiToolChoiceToAnthropic(openaiBody.tool_choice); - if (anthropicToolChoice !== undefined) body.tool_choice = anthropicToolChoice; + const parallelToolCalls = openaiBody.parallel_tool_calls; + let anthropicToolChoice = convertOpenAiToolChoiceToAnthropic(openaiBody.tool_choice); + if (parallelToolCalls === false) { + const choiceRecord = ensureAnthropicToolChoiceRecord(anthropicToolChoice) ?? { type: 'auto' }; + choiceRecord.disable_parallel_tool_use = true; + anthropicToolChoice = choiceRecord; + } + + if (anthropicToolChoice !== undefined) { + body.tool_choice = anthropicToolChoice; + } const reasoningSettings = resolveOpenAiReasoningSettings(openaiBody); if (reasoningSettings.thinking) body.thinking = reasoningSettings.thinking; diff --git a/src/server/transformers/anthropic/messages/inbound.test.ts b/src/server/transformers/anthropic/messages/inbound.test.ts index 1c70f6d4..fa2ba0a8 100644 --- a/src/server/transformers/anthropic/messages/inbound.test.ts +++ b/src/server/transformers/anthropic/messages/inbound.test.ts @@ -54,7 +54,12 @@ describe('anthropicMessagesInbound', () => { }); expect(result.error).toBeUndefined(); - expect(result.value?.claudeOriginalBody).toMatchObject({ + expect(result.value).toMatchObject({ + protocol: 'anthropic/messages', + model: 'claude-opus-4-6', + stream: false, + }); + expect(result.value?.parsed.claudeOriginalBody).toMatchObject({ thinking: { type: 'adaptive' }, output_config: { effort: 'high', preserve: true }, tool_choice: { type: 'tool', name: 'lookup' }, @@ -89,7 +94,7 @@ describe('anthropicMessagesInbound', () => { }); expect(result.error).toBeUndefined(); - expect(result.value?.claudeOriginalBody).toEqual({ + expect(result.value?.parsed.claudeOriginalBody).toEqual({ model: 'claude-opus-4-6', max_tokens: 512, system: [ @@ -151,6 +156,6 @@ describe('anthropicMessagesInbound', () => { const result = anthropicMessagesInbound.parse(nativeBody); expect(result.error).toBeUndefined(); - expect(result.value?.claudeOriginalBody).toEqual(nativeBody); + expect(result.value?.parsed.claudeOriginalBody).toEqual(nativeBody); }); }); diff --git a/src/server/transformers/anthropic/messages/inbound.ts b/src/server/transformers/anthropic/messages/inbound.ts index 29b53d77..c6bb4928 100644 --- a/src/server/transformers/anthropic/messages/inbound.ts +++ b/src/server/transformers/anthropic/messages/inbound.ts @@ -1,4 +1,5 @@ import { parseDownstreamChatRequest, type ParsedDownstreamChatRequest } from '../../shared/normalized.js'; +import { createProtocolRequestEnvelope, type ProtocolRequestEnvelope } from '../../shared/protocolModel.js'; import { validateAnthropicMessagesBody } from './conversion.js'; function isRecord(value: unknown): value is Record { @@ -53,61 +54,6 @@ function validateSystemPrompts(body: Record): { statusCode: num return undefined; } -function validateAdaptiveEffort(body: Record): { statusCode: number; payload: unknown } | undefined { - const thinking = isRecord(body.thinking) ? body.thinking : null; - const thinkingType = asTrimmedString(thinking?.type).toLowerCase(); - const outputConfig = isRecord(body.output_config) - ? body.output_config - : (isRecord(body.outputConfig) ? body.outputConfig : null); - - if (!outputConfig || !('effort' in outputConfig)) return undefined; - - const effort = asTrimmedString(outputConfig.effort).toLowerCase(); - if (!effort || thinkingType !== 'adaptive') return undefined; - - if (!['low', 'medium', 'high', 'max'].includes(effort)) { - return invalidRequest('output_config.effort must be one of: low, medium, high, max'); - } - - return undefined; -} - -function validateToolChoice(body: Record): { statusCode: number; payload: unknown } | undefined { - const rawToolChoice = body.tool_choice ?? body.toolChoice; - if (rawToolChoice === undefined) return undefined; - - if (typeof rawToolChoice === 'string') { - const type = asTrimmedString(rawToolChoice).toLowerCase(); - if (!type) return undefined; - if (type === 'required' || type === 'auto' || type === 'none' || type === 'any') return undefined; - if (type === 'tool') { - return invalidRequest('tool_choice.name is required when type is tool'); - } - return invalidRequest('tool_choice.type must be one of: auto, none, any, tool'); - } - - if (!isRecord(rawToolChoice)) { - return invalidRequest('tool_choice must be an object or string'); - } - - const type = asTrimmedString(rawToolChoice.type).toLowerCase(); - if (!['auto', 'none', 'any', 'tool'].includes(type)) { - return invalidRequest('tool_choice.type must be one of: auto, none, any, tool'); - } - - if (type !== 'tool') return undefined; - - const name = asTrimmedString( - rawToolChoice.name - ?? (isRecord(rawToolChoice.tool) ? rawToolChoice.tool.name : undefined), - ); - if (!name) { - return invalidRequest('tool_choice.name is required when type is tool'); - } - - return undefined; -} - function sanitizeAnthropicInboundBody( body: Record, ): { sanitizedBody?: Record; error?: { statusCode: number; payload: unknown } } { @@ -117,12 +63,6 @@ function sanitizeAnthropicInboundBody( const systemError = validateSystemPrompts(body); if (systemError) return { error: systemError }; - const adaptiveEffortError = validateAdaptiveEffort(body); - if (adaptiveEffortError) return { error: adaptiveEffortError }; - - const toolChoiceError = validateToolChoice(body); - if (toolChoiceError) return { error: toolChoiceError }; - const validation = validateAnthropicMessagesBody(body, { autoOptimizeCacheControls: false, }); @@ -136,7 +76,10 @@ function sanitizeAnthropicInboundBody( } export const anthropicMessagesInbound = { - parse(body: unknown): { value?: ParsedDownstreamChatRequest; error?: { statusCode: number; payload: unknown } } { + parse(body: unknown): { + value?: ProtocolRequestEnvelope<'anthropic/messages', ParsedDownstreamChatRequest>; + error?: { statusCode: number; payload: unknown }; + } { const rawBody = isRecord(body) ? body : null; const inboundValidation = rawBody ? sanitizeAnthropicInboundBody(rawBody) : null; if (inboundValidation?.error) { @@ -145,12 +88,25 @@ export const anthropicMessagesInbound = { const effectiveBody = inboundValidation?.sanitizedBody ?? body; const parsed = parseDownstreamChatRequest(effectiveBody, 'claude'); - if (parsed.error || !parsed.value) return parsed; + if (parsed.error) { + return { error: parsed.error }; + } + if (!parsed.value) { + return { error: invalidRequest('invalid messages request') }; + } if (inboundValidation?.sanitizedBody) { parsed.value.claudeOriginalBody = inboundValidation.sanitizedBody; } - return parsed; + return { + value: createProtocolRequestEnvelope({ + protocol: 'anthropic/messages', + model: parsed.value.requestedModel, + stream: parsed.value.isStream, + rawBody: body, + parsed: parsed.value, + }), + }; }, }; diff --git a/src/server/transformers/anthropic/messages/index.test.ts b/src/server/transformers/anthropic/messages/index.test.ts new file mode 100644 index 00000000..6fdd37f5 --- /dev/null +++ b/src/server/transformers/anthropic/messages/index.test.ts @@ -0,0 +1,121 @@ +import { describe, expect, it } from 'vitest'; + +import { anthropicMessagesTransformer } from './index.js'; + +describe('anthropicMessagesTransformer protocol contract', () => { + it('parses native messages requests into canonical envelopes', () => { + const result = anthropicMessagesTransformer.parseRequest({ + model: 'claude-sonnet-4-5', + max_tokens: 256, + messages: [ + { + role: 'user', + content: [{ type: 'text', text: 'hello' }], + }, + ], + metadata: { + user_id: 'user_0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef_account__session_11111111-2222-3333-4444-555555555555', + }, + }); + + expect(result.error).toBeUndefined(); + expect(result.value).toMatchObject({ + operation: 'generate', + surface: 'anthropic-messages', + cliProfile: 'generic', + requestedModel: 'claude-sonnet-4-5', + stream: false, + messages: [ + { + role: 'user', + parts: [{ type: 'text', text: 'hello' }], + }, + ], + }); + }); + + it('preserves native image and document blocks when parsing into canonical envelopes', () => { + const result = anthropicMessagesTransformer.parseRequest({ + model: 'claude-sonnet-4-5', + max_tokens: 256, + messages: [ + { + role: 'user', + content: [ + { type: 'text', text: 'inspect both attachments' }, + { + type: 'image', + source: { + type: 'base64', + media_type: 'image/png', + data: 'QUFBQQ==', + }, + }, + { + type: 'document', + source: { + type: 'base64', + media_type: 'application/pdf', + data: 'JVBERi0x', + }, + title: 'brief.pdf', + }, + ], + }, + ], + }); + + expect(result.error).toBeUndefined(); + expect(result.value).toMatchObject({ + messages: [ + { + role: 'user', + parts: [ + { type: 'text', text: 'inspect both attachments' }, + { type: 'image', url: 'data:image/png;base64,QUFBQQ==' }, + { + type: 'file', + fileData: 'JVBERi0x', + filename: 'brief.pdf', + mimeType: 'application/pdf', + }, + ], + }, + ], + }); + }); + + it('builds native messages requests from canonical envelopes', () => { + const body = anthropicMessagesTransformer.buildProtocolRequest({ + operation: 'count_tokens', + surface: 'anthropic-messages', + cliProfile: 'claude_code', + requestedModel: 'claude-sonnet-4-5', + stream: false, + messages: [ + { + role: 'user', + parts: [{ type: 'text', text: 'count these tokens' }], + }, + ], + tools: [{ name: 'lookup', inputSchema: { type: 'object' } }], + }); + + expect(body).toMatchObject({ + model: 'claude-sonnet-4-5', + max_tokens: 4096, + messages: [ + { + role: 'user', + content: 'count these tokens', + }, + ], + tools: [ + { + name: 'lookup', + input_schema: { type: 'object' }, + }, + ], + }); + }); +}); diff --git a/src/server/transformers/anthropic/messages/index.ts b/src/server/transformers/anthropic/messages/index.ts index 01eafaca..9154eb7b 100644 --- a/src/server/transformers/anthropic/messages/index.ts +++ b/src/server/transformers/anthropic/messages/index.ts @@ -1,10 +1,18 @@ +import { canonicalRequestFromOpenAiBody, canonicalRequestToOpenAiChatBody } from '../../canonical/request.js'; +import type { CanonicalRequestEnvelope } from '../../canonical/types.js'; +import type { ProtocolBuildContext, ProtocolParseContext } from '../../contracts.js'; import { type NormalizedFinalResponse, type NormalizedStreamEvent, type ParsedDownstreamChatRequest, type StreamTransformContext, type ClaudeDownstreamContext } from '../../shared/normalized.js'; +import { createChatEndpointStrategy } from '../../shared/chatEndpointStrategy.js'; import { anthropicMessagesInbound } from './inbound.js'; +import { convertOpenAiBodyToAnthropicMessagesBody } from './conversion.js'; import { anthropicMessagesOutbound } from './outbound.js'; import { anthropicMessagesStream, consumeAnthropicSseEvent } from './stream.js'; import { anthropicMessagesUsage } from './usage.js'; import { createAnthropicMessagesAggregateState } from './aggregator.js'; -import { isMessagesRequiredError, shouldRetryNormalizedMessagesBody } from './compatibility.js'; +import { + isMessagesRequiredError, + shouldRetryNormalizedMessagesBody, +} from './compatibility.js'; export { ANTHROPIC_RAW_SSE_EVENT_NAMES, consumeAnthropicSseEvent, @@ -22,12 +30,54 @@ export const anthropicMessagesTransformer = { stream: anthropicMessagesStream, usage: anthropicMessagesUsage, compatibility: { + createEndpointStrategy: createChatEndpointStrategy, shouldRetryNormalizedBody: shouldRetryNormalizedMessagesBody, isMessagesRequiredError, }, aggregator: { createState: createAnthropicMessagesAggregateState, }, + parseRequest( + body: unknown, + ctx?: ProtocolParseContext, + ): { value?: CanonicalRequestEnvelope; error?: { statusCode: number; payload: unknown } } { + const parsed = anthropicMessagesInbound.parse(body); + if (parsed.error) { + return { error: parsed.error }; + } + if (!parsed.value) { + return { + error: { + statusCode: 400, + payload: { + error: { + message: 'invalid messages request', + type: 'invalid_request_error', + }, + }, + }, + }; + } + + return { + value: canonicalRequestFromOpenAiBody({ + body: parsed.value.parsed.upstreamBody, + surface: 'anthropic-messages', + cliProfile: ctx?.cliProfile, + operation: ctx?.operation, + metadata: ctx?.metadata, + passthrough: ctx?.passthrough, + continuation: ctx?.continuation, + }), + }; + }, + buildProtocolRequest( + request: CanonicalRequestEnvelope, + _ctx?: ProtocolBuildContext, + ): Record { + const openAiBody = canonicalRequestToOpenAiChatBody(request); + return convertOpenAiBodyToAnthropicMessagesBody(openAiBody, request.requestedModel, request.stream); + }, transformRequest(body: unknown): ReturnType { return anthropicMessagesInbound.parse(body); }, diff --git a/src/server/transformers/anthropic/messages/outbound.test.ts b/src/server/transformers/anthropic/messages/outbound.test.ts index 436dcd4a..6ba6672d 100644 --- a/src/server/transformers/anthropic/messages/outbound.test.ts +++ b/src/server/transformers/anthropic/messages/outbound.test.ts @@ -149,4 +149,94 @@ describe('anthropicMessagesOutbound.serializeFinal', () => { }, }); }); + + it('cleans tagged signatures and preserves redacted_thinking for generic normalized finals', () => { + const payload = anthropicMessagesOutbound.serializeFinal({ + id: 'chatcmpl-redacted-1', + model: 'claude-test', + created: 456, + content: 'visible text', + reasoningContent: 'plan quietly', + reasoningSignature: 'metapi:anthropic-signature:sig-clean', + redactedReasoningContent: 'ciphertext', + finishReason: 'stop', + toolCalls: [], + } as any, { + promptTokens: 9, + completionTokens: 3, + totalTokens: 12, + }); + + expect(payload.content).toEqual([ + { + type: 'thinking', + thinking: 'plan quietly', + signature: 'sig-clean', + }, + { + type: 'redacted_thinking', + data: 'ciphertext', + }, + { + type: 'text', + text: 'visible text', + }, + ]); + }); + + it('cleans tagged native thinking signatures while preserving native anthropic block order', () => { + const sourcePayload = { + id: 'msg_native-tagged-1', + type: 'message', + role: 'assistant', + model: 'claude-test', + content: [ + { + type: 'thinking', + thinking: 'plan first', + signature: 'metapi:anthropic-signature:sig-native', + }, + { + type: 'tool_use', + id: 'toolu_lookup', + name: 'lookup_weather', + input: { city: 'Paris' }, + }, + { + type: 'text', + text: 'done', + }, + ], + stop_reason: 'tool_use', + stop_sequence: null, + usage: { + input_tokens: 100, + output_tokens: 30, + }, + }; + + const normalized = anthropicMessagesOutbound.normalizeFinal(sourcePayload, 'claude-test'); + const payload = anthropicMessagesOutbound.serializeFinal( + normalized, + anthropicMessagesUsage.fromPayload(sourcePayload), + ); + + expect(payload.content).toEqual([ + { + type: 'thinking', + thinking: 'plan first', + signature: 'sig-native', + }, + { + type: 'tool_use', + id: 'toolu_lookup', + name: 'lookup_weather', + input: { city: 'Paris' }, + }, + { + type: 'text', + text: 'done', + }, + ]); + }); }); diff --git a/src/server/transformers/anthropic/messages/outbound.ts b/src/server/transformers/anthropic/messages/outbound.ts index df714120..d0524107 100644 --- a/src/server/transformers/anthropic/messages/outbound.ts +++ b/src/server/transformers/anthropic/messages/outbound.ts @@ -1,4 +1,5 @@ import { normalizeUpstreamFinalResponse, toClaudeStopReason, type NormalizedFinalResponse } from '../../shared/normalized.js'; +import { decodeAnthropicReasoningSignature } from '../../shared/reasoningTransport.js'; import { toAnthropicUsagePayload } from './usage.js'; type AnthropicRecord = Record; @@ -25,6 +26,10 @@ function cloneJsonValue(value: T): T { return value; } +function asTrimmedString(value: unknown): string { + return typeof value === 'string' ? value.trim() : ''; +} + function parseJsonLike(raw: string): unknown { const trimmed = raw.trim(); if (!trimmed) return {}; @@ -41,17 +46,46 @@ function buildClaudeMessageId(sourceId: string): string { return `msg_${sanitized || Date.now()}`; } +function cleanAnthropicReasoningSignature(value: unknown): string | null { + const raw = typeof value === 'string' ? value.trim() : ''; + if (!raw) return null; + const decoded = decodeAnthropicReasoningSignature(raw); + if (decoded) return decoded; + if (raw.startsWith('metapi:')) return null; + return raw; +} + function buildAnthropicContent(normalized: NormalizedFinalResponse): Array> { const anthropicNormalized = normalized as AnthropicMessagesNormalizedFinalResponse; if (Array.isArray(anthropicNormalized.nativeContent) && anthropicNormalized.nativeContent.length > 0) { - return anthropicNormalized.nativeContent.map((block) => cloneJsonValue(block)); + return anthropicNormalized.nativeContent.map((block) => { + const cloned = cloneJsonValue(block); + const blockType = asTrimmedString(cloned.type).toLowerCase(); + if (blockType === 'thinking') { + const signature = cleanAnthropicReasoningSignature(cloned.signature); + if (signature) cloned.signature = signature; + else delete cloned.signature; + } + return cloned; + }); } const contentBlocks: Array> = []; - if (normalized.reasoningContent) { - contentBlocks.push({ + const cleanSignature = cleanAnthropicReasoningSignature(normalized.reasoningSignature); + if (normalized.reasoningContent || cleanSignature) { + const thinkingBlock: Record = { type: 'thinking', - thinking: normalized.reasoningContent, + thinking: normalized.reasoningContent || '', + }; + if (cleanSignature) { + thinkingBlock.signature = cleanSignature; + } + contentBlocks.push(thinkingBlock); + } + if (normalized.redactedReasoningContent) { + contentBlocks.push({ + type: 'redacted_thinking', + data: normalized.redactedReasoningContent, }); } if (normalized.content) { diff --git a/src/server/transformers/anthropic/messages/stream.test.ts b/src/server/transformers/anthropic/messages/stream.test.ts index 80ffe040..615bc69e 100644 --- a/src/server/transformers/anthropic/messages/stream.test.ts +++ b/src/server/transformers/anthropic/messages/stream.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { anthropicMessagesTransformer } from './index.js'; +import { anthropicMessagesTransformer, serializeAnthropicFinalAsStream } from './index.js'; type SerializedAnthropicEvent = { event: string; @@ -304,3 +304,142 @@ describe('anthropicMessagesStream.serializeEvent', () => { expect(stopEvents[0]?.payload.index).toBe(1); }); }); + +describe('serializeAnthropicFinalAsStream', () => { + it('emits thinking signature and tool_use blocks for normalized final payloads', () => { + const streamContext = anthropicMessagesTransformer.createStreamContext('claude-opus-4-6'); + const downstreamContext = anthropicMessagesTransformer.createDownstreamContext(); + + const serialized = serializeAnthropicFinalAsStream({ + id: 'resp_final_stream', + model: 'claude-opus-4-6', + created: 1700000000, + content: '', + reasoningContent: 'plan quietly', + reasoningSignature: 'sig-final', + finishReason: 'tool_calls', + toolCalls: [{ + id: 'call_1', + name: 'lookup_city', + arguments: '{"city":"Paris"}', + }], + }, streamContext, downstreamContext).join(''); + + const events = anthropicMessagesTransformer.pullSseEvents(serialized).events.map((item) => ({ + event: item.event, + payload: JSON.parse(item.data), + })); + + expect(events.some((item) => + item.payload.type === 'content_block_delta' + && item.payload.delta?.type === 'signature_delta' + && item.payload.delta?.signature === 'sig-final', + )).toBe(true); + expect(events.some((item) => + item.payload.type === 'content_block_start' + && item.payload.content_block?.type === 'tool_use' + && item.payload.content_block?.id === 'call_1' + && item.payload.content_block?.name === 'lookup_city', + )).toBe(true); + expect(events.some((item) => + item.payload.type === 'content_block_delta' + && item.payload.delta?.type === 'input_json_delta' + && item.payload.delta?.partial_json === '{"city":"Paris"}', + )).toBe(true); + }); + + it('preserves native anthropic block order and cleans tagged signatures when serializing fallback streams', () => { + const streamContext = anthropicMessagesTransformer.createStreamContext('claude-opus-4-6'); + const downstreamContext = anthropicMessagesTransformer.createDownstreamContext(); + + const serialized = serializeAnthropicFinalAsStream({ + id: 'resp_native_order_1', + model: 'claude-opus-4-6', + created: 1700000000, + content: '', + reasoningContent: '', + finishReason: 'tool_calls', + toolCalls: [], + nativeContent: [ + { + type: 'thinking', + thinking: 'plan first', + signature: 'metapi:anthropic-signature:sig-native', + }, + { + type: 'tool_use', + id: 'toolu_lookup', + name: 'lookup_city', + input: { city: 'Paris' }, + }, + { + type: 'text', + text: 'done', + }, + ], + } as any, streamContext, downstreamContext).join(''); + + const events = anthropicMessagesTransformer.pullSseEvents(serialized).events.map((item) => ({ + event: item.event, + payload: JSON.parse(item.data), + })); + + expect(events + .filter((item) => item.payload.type === 'content_block_start') + .map((item) => item.payload.content_block?.type)).toEqual([ + 'thinking', + 'tool_use', + 'text', + ]); + expect(events.some((item) => + item.payload.type === 'content_block_delta' + && item.payload.delta?.type === 'signature_delta' + && item.payload.delta?.signature === 'sig-native', + )).toBe(true); + expect(events.some((item) => + item.payload.type === 'content_block_delta' + && item.payload.delta?.type === 'input_json_delta' + && item.payload.delta?.partial_json === '{"city":"Paris"}', + )).toBe(true); + }); + + it('preserves redacted_thinking in generic normalized final fallback streams', () => { + const streamContext = anthropicMessagesTransformer.createStreamContext('claude-opus-4-6'); + const downstreamContext = anthropicMessagesTransformer.createDownstreamContext(); + + const serialized = serializeAnthropicFinalAsStream({ + id: 'resp_redacted_1', + model: 'claude-opus-4-6', + created: 1700000000, + content: 'visible text', + reasoningContent: 'plan quietly', + reasoningSignature: 'metapi:anthropic-signature:sig-redacted', + redactedReasoningContent: 'ciphertext', + finishReason: 'stop', + toolCalls: [], + } as any, streamContext, downstreamContext).join(''); + + const events = anthropicMessagesTransformer.pullSseEvents(serialized).events.map((item) => ({ + event: item.event, + payload: JSON.parse(item.data), + })); + + expect(events + .filter((item) => item.payload.type === 'content_block_start') + .map((item) => item.payload.content_block?.type)).toEqual([ + 'thinking', + 'redacted_thinking', + 'text', + ]); + expect(events.some((item) => + item.payload.type === 'content_block_start' + && item.payload.content_block?.type === 'redacted_thinking' + && item.payload.content_block?.data === 'ciphertext', + )).toBe(true); + expect(events.some((item) => + item.payload.type === 'content_block_delta' + && item.payload.delta?.type === 'signature_delta' + && item.payload.delta?.signature === 'sig-redacted', + )).toBe(true); + }); +}); diff --git a/src/server/transformers/anthropic/messages/stream.ts b/src/server/transformers/anthropic/messages/stream.ts index 700e9e39..579b88d0 100644 --- a/src/server/transformers/anthropic/messages/stream.ts +++ b/src/server/transformers/anthropic/messages/stream.ts @@ -9,9 +9,13 @@ import { type NormalizedStreamEvent, type StreamTransformContext, } from '../../shared/normalized.js'; +import { decodeAnthropicReasoningSignature } from '../../shared/reasoningTransport.js'; import { type AnthropicExtendedStreamEvent } from './aggregator.js'; type AnthropicStreamPayload = Record; +type AnthropicMessagesNormalizedFinalResponse = NormalizedFinalResponse & { + nativeContent?: AnthropicStreamPayload[]; +}; type AnthropicBlockKind = 'thinking' | 'text' | 'tool_use' | 'redacted_thinking'; @@ -57,6 +61,106 @@ function serializeSse(event: string, payload: Record): string { return `event: ${event}\ndata: ${JSON.stringify(payload)}\n\n`; } +function cloneJsonValue(value: T): T { + if (Array.isArray(value)) { + return value.map((item) => cloneJsonValue(item)) as T; + } + if (isRecord(value)) { + return Object.fromEntries( + Object.entries(value).map(([key, item]) => [key, cloneJsonValue(item)]), + ) as T; + } + return value; +} + +function cleanAnthropicReasoningSignature(value: unknown): string | null { + const raw = typeof value === 'string' ? value.trim() : ''; + if (!raw) return null; + const decoded = decodeAnthropicReasoningSignature(raw); + if (decoded) return decoded; + if (raw.startsWith('metapi:')) return null; + return raw; +} + +function buildAnthropicFinalContentBlocks( + normalizedFinal: NormalizedFinalResponse, +): AnthropicStreamPayload[] { + const anthropicNormalized = normalizedFinal as AnthropicMessagesNormalizedFinalResponse; + if (Array.isArray(anthropicNormalized.nativeContent) && anthropicNormalized.nativeContent.length > 0) { + return anthropicNormalized.nativeContent + .filter((block): block is AnthropicStreamPayload => isRecord(block)) + .map((block) => { + const cloned = cloneJsonValue(block); + const blockType = asTrimmedString(cloned.type).toLowerCase(); + if (blockType === 'thinking') { + const signature = cleanAnthropicReasoningSignature(cloned.signature); + if (signature) cloned.signature = signature; + else delete cloned.signature; + } + return cloned; + }); + } + + const contentBlocks: AnthropicStreamPayload[] = []; + const cleanSignature = cleanAnthropicReasoningSignature(normalizedFinal.reasoningSignature); + if (normalizedFinal.reasoningContent || cleanSignature) { + const thinkingBlock: AnthropicStreamPayload = { + type: 'thinking', + thinking: normalizedFinal.reasoningContent || '', + }; + if (cleanSignature) thinkingBlock.signature = cleanSignature; + contentBlocks.push(thinkingBlock); + } + if (normalizedFinal.redactedReasoningContent) { + contentBlocks.push({ + type: 'redacted_thinking', + data: normalizedFinal.redactedReasoningContent, + }); + } + if (normalizedFinal.content) { + contentBlocks.push({ + type: 'text', + text: normalizedFinal.content, + }); + } + if (Array.isArray(normalizedFinal.toolCalls)) { + for (let index = 0; index < normalizedFinal.toolCalls.length; index += 1) { + const toolCall = normalizedFinal.toolCalls[index]; + contentBlocks.push({ + type: 'tool_use', + id: toolCall.id || `toolu_${index}`, + name: toolCall.name || `tool_${index}`, + input: (() => { + const rawArguments = toolCall.arguments || ''; + try { + return rawArguments ? JSON.parse(rawArguments) : {}; + } catch { + return { value: rawArguments }; + } + })(), + }); + } + } + + if (contentBlocks.length <= 0) { + contentBlocks.push({ + type: 'text', + text: '', + }); + } + return contentBlocks; +} + +function serializeToolInputDelta(input: unknown): string | null { + if (input === undefined) return null; + if (typeof input === 'string') return input; + try { + return JSON.stringify(input); + } catch { + return JSON.stringify({}); + } +} + export function isAnthropicRawSseEventName(value: unknown): value is string { return typeof value === 'string' && ANTHROPIC_RAW_SSE_EVENT_NAMES.has(value); } @@ -493,25 +597,126 @@ export function serializeAnthropicFinalAsStream( const lines = [ ...anthropicMessagesStream.serializeEvent({ role: 'assistant' }, streamContext, downstreamContext), ]; + const serializeAnthropicEvent = (event: AnthropicExtendedStreamEvent) => anthropicMessagesStream.serializeEvent( + event, + streamContext, + downstreamContext, + ); + const contentBlocks = buildAnthropicFinalContentBlocks(normalizedFinal); + + for (let index = 0; index < contentBlocks.length; index += 1) { + const block = contentBlocks[index]; + const blockType = asTrimmedString(block.type).toLowerCase(); - if (normalizedFinal.reasoningContent) { - lines.push( - ...anthropicMessagesStream.serializeEvent( - { reasoningDelta: normalizedFinal.reasoningContent }, + if (blockType === 'thinking') { + lines.push(...serializeAnthropicEvent( + { + anthropic: { + startBlock: { + kind: 'thinking', + index, + }, + }, + }, + )); + const thinkingText = asTrimmedString(block.thinking); + if (thinkingText) { + lines.push(...anthropicMessagesStream.serializeEvent( + { reasoningDelta: thinkingText }, + streamContext, + downstreamContext, + )); + } + const cleanSignature = cleanAnthropicReasoningSignature(block.signature); + if (cleanSignature) { + lines.push(...serializeAnthropicEvent( + { + anthropic: { + signatureDelta: cleanSignature, + }, + }, + )); + } + lines.push(...serializeAnthropicEvent( + { + anthropic: { + stopBlockIndex: index, + }, + }, + )); + continue; + } + + if (blockType === 'redacted_thinking') { + lines.push(...serializeAnthropicEvent( + { + anthropic: { + startBlock: { + kind: 'redacted_thinking', + index, + }, + redactedThinkingData: asTrimmedString(block.data), + }, + }, + )); + lines.push(...serializeAnthropicEvent( + { + anthropic: { + stopBlockIndex: index, + }, + }, + )); + continue; + } + + if (blockType === 'tool_use') { + const argumentsDelta = serializeToolInputDelta(block.input); + lines.push(...anthropicMessagesStream.serializeEvent( + { + toolCallDeltas: [{ + index, + id: asTrimmedString(block.id) || undefined, + name: asTrimmedString(block.name) || undefined, + ...(argumentsDelta !== null ? { argumentsDelta } : {}), + }], + }, streamContext, downstreamContext, - ), - ); - } + )); + lines.push(...serializeAnthropicEvent( + { + anthropic: { + stopBlockIndex: index, + }, + }, + )); + continue; + } - if (normalizedFinal.content) { - lines.push( - ...anthropicMessagesStream.serializeEvent( - { contentDelta: normalizedFinal.content }, + lines.push(...serializeAnthropicEvent( + { + anthropic: { + startBlock: { + kind: 'text', + index, + }, + }, + }, + )); + if (typeof block.text === 'string') { + lines.push(...anthropicMessagesStream.serializeEvent( + { contentDelta: block.text }, streamContext, downstreamContext, - ), - ); + )); + } + lines.push(...serializeAnthropicEvent( + { + anthropic: { + stopBlockIndex: index, + }, + }, + )); } lines.push( @@ -766,8 +971,11 @@ export const anthropicMessagesStream = { events.push(...handleExplicitBlockStart(startBlock.kind, normalizedStartIndex, context)); } - if (anthropicEvent.anthropic?.signatureDelta) { - bufferPendingSignature(context, anthropicEvent.anthropic.signatureDelta); + const signatureDelta = anthropicEvent.anthropic?.signatureDelta + ?? (typeof event.reasoningSignature === 'string' ? event.reasoningSignature : undefined); + const cleanSignatureDelta = cleanAnthropicReasoningSignature(signatureDelta); + if (cleanSignatureDelta) { + bufferPendingSignature(context, cleanSignatureDelta); } if (anthropicEvent.anthropic?.redactedThinkingData) { diff --git a/src/server/transformers/canonical/attachments.test.ts b/src/server/transformers/canonical/attachments.test.ts new file mode 100644 index 00000000..3354d0af --- /dev/null +++ b/src/server/transformers/canonical/attachments.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest'; + +import { + canonicalAttachmentFromInputFileBlock, + canonicalAttachmentToNormalizedInputFile, +} from './attachments.js'; + +describe('canonical attachment helpers', () => { + it('normalizes input_file blocks and prefers file_data over file_url and file_id', () => { + const attachment = canonicalAttachmentFromInputFileBlock({ + type: 'input_file', + file_data: 'data:application/pdf;base64,QUJD', + file_url: 'https://example.com/brief.pdf', + file_id: 'file_ignored', + filename: 'brief.pdf', + }); + + expect(attachment).toEqual({ + kind: 'file', + sourceType: 'input_file', + fileData: 'data:application/pdf;base64,QUJD', + filename: 'brief.pdf', + mimeType: 'application/pdf', + }); + + expect(canonicalAttachmentToNormalizedInputFile(attachment!)).toEqual({ + sourceType: 'input_file', + fileData: 'data:application/pdf;base64,QUJD', + filename: 'brief.pdf', + mimeType: 'application/pdf', + hadDataUrl: true, + }); + }); + + it('preserves file_url when no file_data exists', () => { + const attachment = canonicalAttachmentFromInputFileBlock({ + type: 'file', + file: { + file_url: 'https://example.com/report.json', + filename: 'report.json', + mime_type: 'application/json', + }, + }); + + expect(attachment).toEqual({ + kind: 'file', + sourceType: 'file', + fileUrl: 'https://example.com/report.json', + filename: 'report.json', + mimeType: 'application/json', + }); + }); +}); diff --git a/src/server/transformers/canonical/attachments.ts b/src/server/transformers/canonical/attachments.ts new file mode 100644 index 00000000..fa36cb0d --- /dev/null +++ b/src/server/transformers/canonical/attachments.ts @@ -0,0 +1,57 @@ +import { + normalizeInputFileBlock, + type NormalizedInputFile, +} from '../shared/inputFile.js'; + +export type CanonicalAttachment = { + kind: 'file'; + sourceType?: 'file' | 'input_file'; + fileId?: string; + fileUrl?: string; + fileData?: string; + filename?: string; + mimeType?: string | null; +}; + +function cloneOptionalString(value: string | null | undefined): string | undefined { + return typeof value === 'string' && value.length > 0 ? value : undefined; +} + +export function canonicalAttachmentFromNormalizedInputFile( + file: NormalizedInputFile, +): CanonicalAttachment { + return { + kind: 'file', + sourceType: file.sourceType, + ...(cloneOptionalString(file.fileId) ? { fileId: file.fileId } : {}), + ...(cloneOptionalString(file.fileUrl) ? { fileUrl: file.fileUrl } : {}), + ...(cloneOptionalString(file.fileData) ? { fileData: file.fileData } : {}), + ...(cloneOptionalString(file.filename) ? { filename: file.filename } : {}), + ...(file.mimeType !== undefined ? { mimeType: file.mimeType } : {}), + }; +} + +export function canonicalAttachmentFromInputFileBlock( + item: Record, +): CanonicalAttachment | null { + const normalized = normalizeInputFileBlock(item); + if (!normalized) return null; + return canonicalAttachmentFromNormalizedInputFile(normalized); +} + +export function canonicalAttachmentToNormalizedInputFile( + attachment: CanonicalAttachment, +): NormalizedInputFile { + const hadDataUrl = typeof attachment.fileData === 'string' + ? /^data:[^;,]+;base64,/i.test(attachment.fileData) + : undefined; + return { + ...(attachment.sourceType ? { sourceType: attachment.sourceType } : {}), + ...(cloneOptionalString(attachment.fileId) ? { fileId: attachment.fileId } : {}), + ...(cloneOptionalString(attachment.fileUrl) ? { fileUrl: attachment.fileUrl } : {}), + ...(cloneOptionalString(attachment.fileData) ? { fileData: attachment.fileData } : {}), + ...(cloneOptionalString(attachment.filename) ? { filename: attachment.filename } : {}), + ...(attachment.mimeType !== undefined ? { mimeType: attachment.mimeType } : {}), + ...(hadDataUrl !== undefined ? { hadDataUrl } : {}), + }; +} diff --git a/src/server/transformers/canonical/reasoning.test.ts b/src/server/transformers/canonical/reasoning.test.ts new file mode 100644 index 00000000..f35af872 --- /dev/null +++ b/src/server/transformers/canonical/reasoning.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest'; + +import { normalizeCanonicalReasoningRequest } from './reasoning.js'; + +describe('canonical reasoning helpers', () => { + it('normalizes reasoning config and keeps non-reasoning include entries in metadata', () => { + const normalized = normalizeCanonicalReasoningRequest({ + include: [' reasoning.encrypted_content ', '', 'message.input_image.image_url'], + reasoning: { + effort: 'high', + }, + reasoning_budget: '2048', + reasoning_summary: 'detailed', + }); + + expect(normalized.reasoning).toEqual({ + effort: 'high', + budgetTokens: 2048, + summary: 'detailed', + includeEncryptedContent: true, + }); + expect(normalized.metadata).toEqual({ + include: ['message.input_image.image_url'], + }); + }); + + it('returns undefined when no reasoning hints are present', () => { + expect(normalizeCanonicalReasoningRequest({})).toEqual({}); + }); +}); diff --git a/src/server/transformers/canonical/reasoning.ts b/src/server/transformers/canonical/reasoning.ts new file mode 100644 index 00000000..4d7cb176 --- /dev/null +++ b/src/server/transformers/canonical/reasoning.ts @@ -0,0 +1,98 @@ +import type { TransformerMetadata } from '../shared/normalized.js'; +import type { + CanonicalReasoningEffort, + CanonicalReasoningRequest, +} from './types.js'; + +type CanonicalReasoningNormalizationInput = { + include?: unknown; + reasoning?: unknown; + reasoning_effort?: unknown; + reasoning_budget?: unknown; + reasoning_summary?: unknown; +}; + +function isRecord(value: unknown): value is Record { + return !!value && typeof value === 'object' && !Array.isArray(value); +} + +function asTrimmedString(value: unknown): string { + return typeof value === 'string' ? value.trim() : ''; +} + +function toFiniteInteger(value: unknown): number | undefined { + if (typeof value === 'number' && Number.isFinite(value)) { + return Math.trunc(value); + } + if (typeof value === 'string') { + const trimmed = value.trim(); + if (!trimmed) return undefined; + const parsed = Number(trimmed); + if (Number.isFinite(parsed)) return Math.trunc(parsed); + } + return undefined; +} + +function normalizeReasoningEffort(value: unknown): CanonicalReasoningEffort | undefined { + const effort = asTrimmedString(value).toLowerCase(); + switch (effort) { + case 'none': + case 'low': + case 'medium': + case 'high': + case 'max': + return effort; + default: + return undefined; + } +} + +function normalizeIncludeEntries(value: unknown): string[] { + if (typeof value === 'string') { + const trimmed = value.trim(); + return trimmed ? [trimmed] : []; + } + if (!Array.isArray(value)) return []; + return value + .map((item) => (typeof item === 'string' ? item.trim() : '')) + .filter((item) => item.length > 0); +} + +export function normalizeCanonicalReasoningRequest( + input: CanonicalReasoningNormalizationInput, +): { + reasoning?: CanonicalReasoningRequest; + metadata?: Pick; +} { + const rawReasoning = isRecord(input.reasoning) ? input.reasoning : {}; + const include = normalizeIncludeEntries(input.include); + const filteredInclude = include.filter((item) => item !== 'reasoning.encrypted_content'); + + const effort = normalizeReasoningEffort(rawReasoning.effort ?? input.reasoning_effort); + const budgetTokens = toFiniteInteger(rawReasoning.budget_tokens ?? rawReasoning.budgetTokens ?? input.reasoning_budget); + const summary = asTrimmedString(rawReasoning.summary ?? input.reasoning_summary) || undefined; + const includeEncryptedContent = include.includes('reasoning.encrypted_content') || undefined; + + const reasoning: CanonicalReasoningRequest | undefined = ( + effort + || budgetTokens !== undefined + || summary + || includeEncryptedContent + ) + ? { + ...(effort ? { effort } : {}), + ...(budgetTokens !== undefined ? { budgetTokens } : {}), + ...(summary ? { summary } : {}), + ...(includeEncryptedContent ? { includeEncryptedContent } : {}), + } + : undefined; + + const metadata = filteredInclude.length > 0 + ? { include: filteredInclude } + : undefined; + + return { + ...(reasoning ? { reasoning } : {}), + ...(metadata ? { metadata } : {}), + }; +} diff --git a/src/server/transformers/canonical/request.test.ts b/src/server/transformers/canonical/request.test.ts new file mode 100644 index 00000000..0d810fb0 --- /dev/null +++ b/src/server/transformers/canonical/request.test.ts @@ -0,0 +1,375 @@ +import { describe, expect, it } from 'vitest'; + +import { + canonicalRequestFromOpenAiBody, + canonicalRequestToOpenAiChatBody, + createCanonicalRequestEnvelope, +} from './request.js'; + +describe('canonical request helpers', () => { + it('normalizes a count_tokens request without provider-owned fields', () => { + const request = createCanonicalRequestEnvelope({ + operation: 'count_tokens', + surface: 'anthropic-messages', + cliProfile: 'claude_code', + requestedModel: ' claude-sonnet-4-5 ', + stream: false, + continuation: { + sessionId: ' session-1 ', + promptCacheKey: ' cache-1 ', + }, + }); + + expect(request).toEqual({ + operation: 'count_tokens', + surface: 'anthropic-messages', + cliProfile: 'claude_code', + requestedModel: 'claude-sonnet-4-5', + stream: false, + messages: [], + continuation: { + sessionId: 'session-1', + promptCacheKey: 'cache-1', + }, + }); + }); + + it('defaults generate requests to generic profile and empty collections', () => { + const request = createCanonicalRequestEnvelope({ + requestedModel: 'gpt-5.2-codex', + surface: 'openai-responses', + }); + + expect(request).toEqual({ + operation: 'generate', + surface: 'openai-responses', + cliProfile: 'generic', + requestedModel: 'gpt-5.2-codex', + stream: false, + messages: [], + }); + }); + + it('parses metadata and explicit function tool choice from OpenAI-compatible bodies', () => { + const request = canonicalRequestFromOpenAiBody({ + body: { + model: 'gpt-5', + stream: true, + metadata: { user_id: 'user-1' }, + tools: [{ + type: 'function', + function: { + name: 'Glob', + description: 'Search files', + strict: true, + parameters: { + type: 'object', + properties: { + pattern: { type: 'string' }, + }, + }, + }, + }], + tool_choice: { + type: 'function', + function: { + name: 'Glob', + }, + }, + messages: [{ role: 'user', content: 'hello' }], + }, + surface: 'openai-chat', + }); + + expect(request).toMatchObject({ + requestedModel: 'gpt-5', + stream: true, + metadata: { user_id: 'user-1' }, + tools: [{ + name: 'Glob', + description: 'Search files', + strict: true, + inputSchema: { + type: 'object', + properties: { + pattern: { type: 'string' }, + }, + }, + }], + toolChoice: { + type: 'tool', + name: 'Glob', + }, + }); + }); + + it('parses anthropic-shaped tools from compatibility bodies', () => { + const request = canonicalRequestFromOpenAiBody({ + body: { + model: 'gpt-5', + tools: [{ + name: 'Glob', + description: 'Search files', + input_schema: { + type: 'object', + properties: { + pattern: { type: 'string' }, + }, + }, + }], + tool_choice: { + type: 'tool', + name: 'Glob', + }, + messages: [{ role: 'user', content: 'hello' }], + }, + surface: 'openai-chat', + }); + + expect(request.tools).toEqual([{ + name: 'Glob', + description: 'Search files', + inputSchema: { + type: 'object', + properties: { + pattern: { type: 'string' }, + }, + }, + }]); + expect(request.toolChoice).toEqual({ + type: 'tool', + name: 'Glob', + }); + }); + + it('builds metadata back into OpenAI chat requests', () => { + const body = canonicalRequestToOpenAiChatBody({ + operation: 'generate', + surface: 'openai-chat', + cliProfile: 'generic', + requestedModel: 'gpt-5', + stream: false, + messages: [{ role: 'user', parts: [{ type: 'text', text: 'hello' }] }], + metadata: { user_id: 'user-1' }, + toolChoice: { + type: 'tool', + name: 'Glob', + }, + tools: [{ + name: 'Glob', + strict: true, + inputSchema: { + type: 'object', + properties: { + pattern: { type: 'string' }, + }, + }, + }], + }); + + expect(body).toMatchObject({ + model: 'gpt-5', + metadata: { user_id: 'user-1' }, + tool_choice: { + type: 'function', + function: { + name: 'Glob', + }, + }, + tools: [{ + type: 'function', + function: { + name: 'Glob', + strict: true, + parameters: { + type: 'object', + properties: { + pattern: { type: 'string' }, + }, + }, + }, + }], + }); + }); + + it('round-trips include continuity metadata back into OpenAI-compatible bodies', () => { + const request = canonicalRequestFromOpenAiBody({ + body: { + model: 'gpt-5', + stream: true, + include: ['reasoning.encrypted_content', 'message.input_image.image_url'], + reasoning: { + effort: 'high', + }, + messages: [{ role: 'user', content: 'hello' }], + }, + surface: 'openai-responses', + }); + + const body = canonicalRequestToOpenAiChatBody(request); + + expect(body).toMatchObject({ + model: 'gpt-5', + reasoning_effort: 'high', + include: ['reasoning.encrypted_content', 'message.input_image.image_url'], + }); + }); + + it('preserves extra fields on tool-shaped raw tool_choice objects', () => { + const request = canonicalRequestFromOpenAiBody({ + body: { + model: 'gpt-5', + tool_choice: { + type: 'tool', + name: 'browser', + mode: 'required', + disable_parallel_tool_use: true, + }, + messages: [{ role: 'user', content: 'hello' }], + }, + surface: 'openai-responses', + }); + + const body = canonicalRequestToOpenAiChatBody(request); + + expect(body.tool_choice).toEqual({ + type: 'tool', + name: 'browser', + mode: 'required', + disable_parallel_tool_use: true, + }); + }); + + it('preserves structured tool outputs and top-level attachments through canonical round-trips', () => { + const request = createCanonicalRequestEnvelope({ + requestedModel: 'gpt-5', + surface: 'openai-chat', + attachments: [{ + kind: 'file', + fileId: 'file-top-level', + }], + messages: [{ + role: 'tool', + parts: [{ + type: 'tool_result', + toolCallId: 'call_1', + resultContent: [ + { type: 'text', text: 'tool result' }, + { type: 'image_url', image_url: { url: 'https://example.com/tool.png' } }, + ], + } as any], + }], + }); + + const body = canonicalRequestToOpenAiChatBody(request); + + expect(body.attachments).toEqual([{ + kind: 'file', + fileId: 'file-top-level', + }]); + expect(body.messages).toEqual([{ + role: 'tool', + tool_call_id: 'call_1', + content: [ + { type: 'text', text: 'tool result' }, + { type: 'image_url', image_url: { url: 'https://example.com/tool.png' } }, + ], + }]); + }); + + it('preserves richer Responses tools, raw tool_choice, assistant phase, and reasoning signatures through canonical round-trips', () => { + const request = canonicalRequestFromOpenAiBody({ + body: { + model: 'gpt-5', + parallel_tool_calls: false, + tools: [ + { + type: 'custom', + name: 'browser', + description: 'browse the web', + format: { type: 'text' }, + }, + { + type: 'image_generation', + background: 'transparent', + }, + ], + tool_choice: { + type: 'allowed_tools', + mode: 'auto', + tools: [{ type: 'custom', name: 'browser' }], + }, + messages: [ + { + role: 'assistant', + phase: 'analysis', + reasoning_signature: 'sig_123', + content: 'thinking', + }, + { + role: 'user', + content: 'hello', + }, + ], + }, + surface: 'openai-responses', + }); + + const body = canonicalRequestToOpenAiChatBody(request); + + expect(body.parallel_tool_calls).toBe(false); + expect(body.tools).toEqual([ + { + type: 'custom', + name: 'browser', + description: 'browse the web', + format: { type: 'text' }, + }, + { + type: 'image_generation', + background: 'transparent', + }, + ]); + expect(body.tool_choice).toEqual({ + type: 'allowed_tools', + mode: 'auto', + tools: [{ type: 'custom', name: 'browser' }], + }); + expect(body.messages).toMatchObject([ + { + role: 'assistant', + phase: 'analysis', + reasoning_signature: 'sig_123', + content: 'thinking', + }, + { + role: 'user', + content: 'hello', + }, + ]); + }); + + it('writes raw canonical tool types back into OpenAI-compatible bodies when the raw payload omits the discriminator', () => { + const request = createCanonicalRequestEnvelope({ + requestedModel: 'gpt-5', + surface: 'openai-responses', + tools: [{ + type: 'custom', + raw: { + name: 'browser', + description: 'browse the web', + format: { type: 'text' }, + }, + }], + }); + + const body = canonicalRequestToOpenAiChatBody(request); + + expect(body.tools).toEqual([{ + type: 'custom', + name: 'browser', + description: 'browse the web', + format: { type: 'text' }, + }]); + }); +}); diff --git a/src/server/transformers/canonical/request.ts b/src/server/transformers/canonical/request.ts new file mode 100644 index 00000000..701a6676 --- /dev/null +++ b/src/server/transformers/canonical/request.ts @@ -0,0 +1,602 @@ +import { + canonicalAttachmentFromInputFileBlock, + canonicalAttachmentToNormalizedInputFile, + type CanonicalAttachment, +} from './attachments.js'; +import { normalizeCanonicalReasoningRequest } from './reasoning.js'; +import type { CanonicalTool, CanonicalToolChoice } from './tools.js'; +import type { + CanonicalContentPart, + CanonicalCliProfile, + CanonicalContinuation, + CanonicalMessage, + CanonicalMessageRole, + CanonicalOperation, + CanonicalReasoningRequest, + CanonicalRequestEnvelope, + CanonicalSurface, +} from './types.js'; +import { toOpenAiChatFileBlock } from '../shared/inputFile.js'; + +export type CreateCanonicalRequestEnvelopeInput = { + operation?: CanonicalOperation; + surface: CanonicalSurface; + cliProfile?: CanonicalCliProfile; + requestedModel: string; + stream?: boolean; + messages?: CanonicalMessage[]; + reasoning?: CanonicalReasoningRequest; + tools?: CanonicalTool[]; + toolChoice?: CanonicalToolChoice; + continuation?: CanonicalContinuation; + metadata?: Record; + passthrough?: Record; + attachments?: CanonicalAttachment[]; +}; + +function asTrimmedString(value: unknown): string { + return typeof value === 'string' ? value.trim() : ''; +} + +function isRecord(value: unknown): value is Record { + return !!value && typeof value === 'object' && !Array.isArray(value); +} + +function cloneJsonValue(value: T): T { + if (Array.isArray(value)) { + return value.map((item) => cloneJsonValue(item)) as T; + } + if (isRecord(value)) { + return Object.fromEntries( + Object.entries(value).map(([key, item]) => [key, cloneJsonValue(item)]), + ) as T; + } + return value; +} + +function safeJsonStringify(value: unknown): string { + try { + return JSON.stringify(value); + } catch { + return ''; + } +} + +function normalizeCanonicalContinuation( + continuation: CanonicalContinuation | undefined, +): CanonicalContinuation | undefined { + if (!continuation) return undefined; + + const normalized: CanonicalContinuation = { + ...(asTrimmedString(continuation.sessionId) ? { sessionId: asTrimmedString(continuation.sessionId) } : {}), + ...(asTrimmedString(continuation.previousResponseId) + ? { previousResponseId: asTrimmedString(continuation.previousResponseId) } + : {}), + ...(asTrimmedString(continuation.promptCacheKey) + ? { promptCacheKey: asTrimmedString(continuation.promptCacheKey) } + : {}), + ...(asTrimmedString(continuation.turnState) ? { turnState: asTrimmedString(continuation.turnState) } : {}), + }; + + return Object.keys(normalized).length > 0 ? normalized : undefined; +} + +export function createCanonicalRequestEnvelope( + input: CreateCanonicalRequestEnvelopeInput, +): CanonicalRequestEnvelope { + const requestedModel = asTrimmedString(input.requestedModel); + if (!requestedModel) { + throw new Error('canonical request requires requestedModel'); + } + + return { + operation: input.operation ?? 'generate', + surface: input.surface, + cliProfile: input.cliProfile ?? 'generic', + requestedModel, + stream: input.stream === true, + messages: Array.isArray(input.messages) ? input.messages : [], + ...(input.reasoning ? { reasoning: input.reasoning } : {}), + ...(Array.isArray(input.tools) && input.tools.length > 0 ? { tools: input.tools } : {}), + ...(input.toolChoice !== undefined ? { toolChoice: input.toolChoice } : {}), + ...(normalizeCanonicalContinuation(input.continuation) + ? { continuation: normalizeCanonicalContinuation(input.continuation) } + : {}), + ...(input.metadata ? { metadata: input.metadata } : {}), + ...(input.passthrough ? { passthrough: input.passthrough } : {}), + ...(Array.isArray(input.attachments) && input.attachments.length > 0 + ? { attachments: cloneJsonValue(input.attachments) as CanonicalAttachment[] } + : {}), + }; +} + +type CanonicalRequestFromOpenAiBodyInput = { + body: Record; + surface: CanonicalSurface; + cliProfile?: CanonicalCliProfile; + operation?: CanonicalOperation; + metadata?: Record; + passthrough?: Record; + continuation?: CanonicalContinuation; +}; + +function normalizeRole(value: unknown): CanonicalMessageRole { + const role = asTrimmedString(value).toLowerCase(); + switch (role) { + case 'system': + case 'developer': + case 'assistant': + case 'tool': + return role; + default: + return 'user'; + } +} + +function openAiContentToCanonicalParts(content: unknown): CanonicalContentPart[] { + if (typeof content === 'string') { + return content ? [{ type: 'text', text: content }] : []; + } + + if (!Array.isArray(content)) return []; + + const parts: CanonicalContentPart[] = []; + for (const item of content) { + if (typeof item === 'string') { + if (item) parts.push({ type: 'text', text: item }); + continue; + } + if (!isRecord(item)) continue; + + const type = asTrimmedString(item.type).toLowerCase(); + if (type === 'text' || type === 'input_text' || type === 'output_text') { + const text = asTrimmedString(item.text); + if (text) parts.push({ type: 'text', text }); + continue; + } + if (type === 'reasoning' || type === 'thinking' || type === 'redacted_reasoning') { + const text = asTrimmedString(item.text ?? item.reasoning ?? item.thinking); + if (text) parts.push({ type: 'text', text, thought: true }); + continue; + } + if (type === 'image_url' && isRecord(item.image_url)) { + const url = asTrimmedString(item.image_url.url); + if (url) parts.push({ type: 'image', url }); + continue; + } + if (type === 'input_image' && isRecord(item.image_url)) { + const url = asTrimmedString(item.image_url.url); + if (url) parts.push({ type: 'image', url }); + continue; + } + if (type === 'input_file' || type === 'file') { + const attachment = canonicalAttachmentFromInputFileBlock(item); + if (attachment) { + parts.push({ + type: 'file', + ...(attachment.fileId ? { fileId: attachment.fileId } : {}), + ...(attachment.fileUrl ? { fileUrl: attachment.fileUrl } : {}), + ...(attachment.fileData ? { fileData: attachment.fileData } : {}), + ...(attachment.mimeType !== undefined ? { mimeType: attachment.mimeType } : {}), + ...(attachment.filename ? { filename: attachment.filename } : {}), + }); + } + continue; + } + } + + return parts; +} + +function parseToolChoice(rawToolChoice: unknown): CanonicalToolChoice | undefined { + if (typeof rawToolChoice === 'string') { + const normalized = rawToolChoice.trim().toLowerCase(); + if (normalized === 'auto' || normalized === 'none' || normalized === 'required') return normalized; + if (normalized === 'any') return 'required'; + return rawToolChoice.trim() ? { type: 'raw', value: rawToolChoice } : undefined; + } + + if (!isRecord(rawToolChoice)) return undefined; + const type = asTrimmedString(rawToolChoice.type).toLowerCase(); + if (type === 'auto' || type === 'none') return type; + if (type === 'any' || type === 'required') return 'required'; + if (type === 'function') { + const name = asTrimmedString( + (isRecord(rawToolChoice.function) ? rawToolChoice.function.name : undefined) + ?? rawToolChoice.name, + ); + return name ? { type: 'tool', name } : undefined; + } + if (type && type !== 'tool') { + return { type: 'raw', value: cloneJsonValue(rawToolChoice) as Record }; + } + + const name = asTrimmedString( + rawToolChoice.name + ?? (isRecord(rawToolChoice.tool) ? rawToolChoice.tool.name : undefined), + ); + const toolChoiceKeys = Object.keys(rawToolChoice); + const hasExtraToolFields = toolChoiceKeys.some((key) => key !== 'type' && key !== 'name' && key !== 'tool'); + if (hasExtraToolFields) { + return { type: 'raw', value: cloneJsonValue(rawToolChoice) as Record }; + } + if (name) return { type: 'tool', name }; + return { type: 'raw', value: cloneJsonValue(rawToolChoice) as Record }; +} + +function parseTools(rawTools: unknown): CanonicalTool[] | undefined { + if (!Array.isArray(rawTools)) return undefined; + + const tools: CanonicalTool[] = rawTools + .flatMap((item): CanonicalTool[] => { + if (!isRecord(item)) return []; + const itemType = asTrimmedString(item.type).toLowerCase(); + + if (itemType === 'function' && isRecord(item.function)) { + const name = asTrimmedString(item.function.name); + if (!name) return []; + return [{ + name, + ...(asTrimmedString(item.function.description) + ? { description: asTrimmedString(item.function.description) } + : {}), + ...(typeof item.function.strict === 'boolean' ? { strict: item.function.strict } : {}), + ...(isRecord(item.function.parameters) ? { inputSchema: cloneJsonValue(item.function.parameters) } : {}), + }]; + } + + if ((itemType === '' || itemType === 'tool') && asTrimmedString(item.name)) { + return [{ + name: asTrimmedString(item.name), + ...(asTrimmedString(item.description) + ? { description: asTrimmedString(item.description) } + : {}), + ...(typeof item.strict === 'boolean' ? { strict: item.strict } : {}), + ...(isRecord(item.input_schema) + ? { inputSchema: cloneJsonValue(item.input_schema) } + : (isRecord(item.inputSchema) ? { inputSchema: cloneJsonValue(item.inputSchema) } : {})), + }]; + } + + if (Array.isArray(item.functionDeclarations)) { + return item.functionDeclarations.flatMap((declaration) => { + if (!isRecord(declaration)) return []; + const name = asTrimmedString(declaration.name); + if (!name) return []; + return [{ + name, + ...(asTrimmedString(declaration.description) + ? { description: asTrimmedString(declaration.description) } + : {}), + ...(isRecord(declaration.parametersJsonSchema) + ? { inputSchema: cloneJsonValue(declaration.parametersJsonSchema) } + : (isRecord(declaration.parameters) ? { inputSchema: cloneJsonValue(declaration.parameters) } : {})), + }]; + }); + } + + if (itemType) { + return [{ + type: itemType, + raw: cloneJsonValue(item) as Record, + }]; + } + + return []; + }); + + return tools.length > 0 ? tools : undefined; +} + +export function canonicalRequestFromOpenAiBody( + input: CanonicalRequestFromOpenAiBodyInput, +): CanonicalRequestEnvelope { + const body = input.body; + const metadata = isRecord(input.metadata) + ? input.metadata + : (isRecord(body.metadata) ? cloneJsonValue(body.metadata) : undefined); + const attachments = Array.isArray(body.attachments) + ? cloneJsonValue(body.attachments) as CanonicalAttachment[] + : undefined; + const messages: CanonicalMessage[] = []; + const rawMessages = Array.isArray(body.messages) ? body.messages : []; + + for (const rawMessage of rawMessages) { + if (!isRecord(rawMessage)) continue; + const role = normalizeRole(rawMessage.role); + + if (role === 'tool') { + const toolCallId = asTrimmedString(rawMessage.tool_call_id ?? rawMessage.id); + const rawContent = rawMessage.content; + const resultText = typeof rawContent === 'string' + ? rawContent + : (!Array.isArray(rawContent) && !isRecord(rawContent) ? safeJsonStringify(rawContent ?? '') : ''); + messages.push({ + role: 'tool', + parts: [{ + type: 'tool_result', + toolCallId: toolCallId || 'tool', + ...(resultText ? { resultText } : {}), + ...(Array.isArray(rawContent) + ? { resultContent: cloneJsonValue(rawContent) as Array> } + : (isRecord(rawContent) + ? { resultContent: [cloneJsonValue(rawContent) as Record] } + : {})), + }], + }); + continue; + } + + const parts = openAiContentToCanonicalParts(rawMessage.content); + const toolCalls = Array.isArray(rawMessage.tool_calls) ? rawMessage.tool_calls : []; + for (const toolCall of toolCalls) { + if (!isRecord(toolCall)) continue; + const fn = isRecord(toolCall.function) ? toolCall.function : {}; + const id = asTrimmedString(toolCall.id); + const name = asTrimmedString(toolCall.name ?? fn.name); + const argumentsJson = typeof fn.arguments === 'string' + ? fn.arguments + : safeJsonStringify(fn.arguments ?? toolCall.arguments ?? {}); + if (!name) continue; + parts.push({ + type: 'tool_call', + id: id || `tool_${parts.length}`, + name, + argumentsJson, + }); + } + + messages.push({ + role, + parts, + ...(asTrimmedString(rawMessage.phase) ? { phase: asTrimmedString(rawMessage.phase) } : {}), + ...(asTrimmedString(rawMessage.reasoning_signature) + ? { reasoningSignature: asTrimmedString(rawMessage.reasoning_signature) } + : {}), + }); + } + + const reasoningResult = normalizeCanonicalReasoningRequest({ + include: body.include, + reasoning: body.reasoning, + reasoning_effort: body.reasoning_effort, + reasoning_budget: body.reasoning_budget, + reasoning_summary: body.reasoning_summary, + }); + + const continuation: CanonicalContinuation = { + ...(normalizeCanonicalContinuation(input.continuation) ?? {}), + ...(asTrimmedString(body.previous_response_id) + ? { previousResponseId: asTrimmedString(body.previous_response_id) } + : {}), + ...(asTrimmedString(body.prompt_cache_key) + ? { promptCacheKey: asTrimmedString(body.prompt_cache_key) } + : {}), + }; + + const passthrough = { + ...(input.passthrough ?? {}), + ...(typeof body.parallel_tool_calls === 'boolean' + ? { parallel_tool_calls: body.parallel_tool_calls } + : {}), + ...(reasoningResult.metadata ? { transformerMetadata: reasoningResult.metadata } : {}), + }; + + return createCanonicalRequestEnvelope({ + operation: input.operation ?? 'generate', + surface: input.surface, + cliProfile: input.cliProfile ?? 'generic', + requestedModel: asTrimmedString(body.model), + stream: body.stream === true, + messages, + ...(reasoningResult.reasoning ? { reasoning: reasoningResult.reasoning } : {}), + ...(parseTools(body.tools) ? { tools: parseTools(body.tools) } : {}), + ...(parseToolChoice(body.tool_choice) !== undefined ? { toolChoice: parseToolChoice(body.tool_choice) } : {}), + ...(Object.keys(continuation).length > 0 ? { continuation } : {}), + ...(metadata ? { metadata } : {}), + ...(attachments ? { attachments } : {}), + ...(Object.keys(passthrough).length > 0 ? { passthrough } : {}), + }); +} + +function canonicalPartsToOpenAiContent( + role: CanonicalMessageRole, + parts: CanonicalContentPart[], +): { content: string | Array>; reasoning?: string; toolCalls?: Array> } { + const contentBlocks: Array> = []; + const toolCalls: Array> = []; + const visibleText: string[] = []; + const reasoningText: string[] = []; + + for (const part of parts) { + if (part.type === 'text') { + if (part.thought === true) { + reasoningText.push(part.text); + } else { + visibleText.push(part.text); + } + continue; + } + if (part.type === 'image') { + const url = asTrimmedString(part.url ?? part.dataUrl); + if (url) { + contentBlocks.push({ + type: 'image_url', + image_url: { url }, + }); + } + continue; + } + if (part.type === 'file') { + const normalizedFile = canonicalAttachmentToNormalizedInputFile({ + kind: 'file', + ...(part.fileId ? { fileId: part.fileId } : {}), + ...(part.fileUrl ? { fileUrl: part.fileUrl } : {}), + ...(part.fileData ? { fileData: part.fileData } : {}), + ...(part.filename ? { filename: part.filename } : {}), + ...(part.mimeType !== undefined ? { mimeType: part.mimeType } : {}), + }); + contentBlocks.push(toOpenAiChatFileBlock(normalizedFile)); + continue; + } + if (part.type === 'tool_call') { + toolCalls.push({ + id: part.id, + type: 'function', + function: { + name: part.name, + arguments: part.argumentsJson, + }, + }); + continue; + } + if (part.type === 'tool_result' && role !== 'tool') { + const text = part.resultText + ?? (typeof part.resultContent === 'string' + ? part.resultContent + : safeJsonStringify(part.resultJson ?? part.resultContent ?? '')); + if (text) { + visibleText.push(text); + } + } + } + + if (contentBlocks.length <= 0) { + return { + content: visibleText.join(''), + ...(reasoningText.length > 0 ? { reasoning: reasoningText.join('') } : {}), + ...(toolCalls.length > 0 ? { toolCalls } : {}), + }; + } + + if (visibleText.length > 0) { + contentBlocks.unshift({ + type: 'text', + text: visibleText.join(''), + }); + } + + return { + content: contentBlocks, + ...(reasoningText.length > 0 ? { reasoning: reasoningText.join('') } : {}), + ...(toolCalls.length > 0 ? { toolCalls } : {}), + }; +} + +function canonicalToolChoiceToOpenAi(toolChoice: CanonicalToolChoice | undefined): unknown { + if (!toolChoice) return undefined; + if (toolChoice === 'auto' || toolChoice === 'none') return toolChoice; + if (toolChoice === 'required') return 'required'; + if (toolChoice.type === 'raw') return cloneJsonValue(toolChoice.value); + return { + type: 'function', + function: { + name: toolChoice.name, + }, + }; +} + +export function canonicalRequestToOpenAiChatBody( + request: CanonicalRequestEnvelope, +): Record { + const messages: Array> = []; + + for (const message of request.messages) { + if (message.role === 'tool') { + for (const part of message.parts) { + if (part.type !== 'tool_result') continue; + messages.push({ + role: 'tool', + tool_call_id: part.toolCallId, + content: part.resultContent + ?? part.resultText + ?? safeJsonStringify(part.resultJson ?? ''), + }); + } + continue; + } + + const converted = canonicalPartsToOpenAiContent(message.role, message.parts); + const nextMessage: Record = { + role: message.role, + content: converted.content, + }; + if (message.role === 'assistant' && converted.reasoning) { + nextMessage.reasoning_content = converted.reasoning; + } + if (message.phase) nextMessage.phase = message.phase; + if (message.reasoningSignature) nextMessage.reasoning_signature = message.reasoningSignature; + if (message.role === 'assistant' && converted.toolCalls && converted.toolCalls.length > 0) { + nextMessage.tool_calls = converted.toolCalls; + if (typeof nextMessage.content !== 'string' && (nextMessage.content as Array).length <= 0) { + nextMessage.content = ''; + } + } + messages.push(nextMessage); + } + + const body: Record = { + model: request.requestedModel, + stream: request.stream, + messages, + }; + + if (request.reasoning?.effort) body.reasoning_effort = request.reasoning.effort; + if (request.reasoning?.budgetTokens !== undefined) body.reasoning_budget = request.reasoning.budgetTokens; + if (request.reasoning?.summary) body.reasoning_summary = request.reasoning.summary; + const transformerMetadata = isRecord(request.passthrough?.transformerMetadata) + ? request.passthrough.transformerMetadata as Record + : null; + const passthroughInclude = Array.isArray(transformerMetadata?.include) + ? (transformerMetadata.include as unknown[]) + .filter((item): item is string => typeof item === 'string' && item.trim().length > 0) + .map((item) => item.trim()) + : []; + const mergedInclude = [ + ...(request.reasoning?.includeEncryptedContent ? ['reasoning.encrypted_content'] : []), + ...passthroughInclude, + ].filter((item, index, all) => all.indexOf(item) === index); + if (mergedInclude.length > 0) body.include = mergedInclude; + if (request.metadata !== undefined) body.metadata = cloneJsonValue(request.metadata); + if (Array.isArray(request.attachments) && request.attachments.length > 0) { + body.attachments = cloneJsonValue(request.attachments); + } + if (request.continuation?.promptCacheKey) body.prompt_cache_key = request.continuation.promptCacheKey; + if (request.continuation?.previousResponseId) body.previous_response_id = request.continuation.previousResponseId; + if (Array.isArray(request.tools) && request.tools.length > 0) { + body.tools = request.tools.map((tool) => { + if ('raw' in tool) { + const raw = cloneJsonValue(tool.raw) as Record; + if (typeof raw.type !== 'string' || raw.type.trim().length === 0) { + raw.type = tool.type; + } + return raw; + } + return { + type: 'function', + function: { + name: tool.name, + ...(tool.description ? { description: tool.description } : {}), + ...(typeof tool.strict === 'boolean' ? { strict: tool.strict } : {}), + parameters: cloneJsonValue(tool.inputSchema ?? { type: 'object' }), + }, + }; + }); + } + const toolChoice = canonicalToolChoiceToOpenAi(request.toolChoice); + if (toolChoice !== undefined) body.tool_choice = toolChoice; + + if (isRecord(request.passthrough)) { + for (const [key, value] of Object.entries(request.passthrough)) { + if (key === 'transformerMetadata' || body[key] !== undefined) continue; + body[key] = cloneJsonValue(value); + } + } + + return body; +} + +export * from './attachments.js'; +export * from './reasoning.js'; +export * from './tools.js'; +export * from './types.js'; diff --git a/src/server/transformers/canonical/tools.ts b/src/server/transformers/canonical/tools.ts new file mode 100644 index 00000000..4c85efc6 --- /dev/null +++ b/src/server/transformers/canonical/tools.ts @@ -0,0 +1,38 @@ +export type CanonicalFunctionTool = { + name: string; + description?: string; + strict?: boolean; + inputSchema?: Record | null; +}; + +export type CanonicalRawTool = { + type: string; + raw: Record; +}; + +export type CanonicalTool = + | CanonicalFunctionTool + | CanonicalRawTool; + +export type CanonicalToolChoice = + | 'auto' + | 'none' + | 'required' + | { + type: 'tool'; + name: string; + } + | { + type: 'raw'; + value: string | Record; + }; + +export function isCanonicalFunctionTool(tool: CanonicalTool): tool is CanonicalFunctionTool { + return 'name' in tool; +} + +export function isCanonicalNamedToolChoice( + toolChoice: CanonicalToolChoice | undefined, +): toolChoice is { type: 'tool'; name: string } { + return !!toolChoice && typeof toolChoice === 'object' && toolChoice.type === 'tool'; +} diff --git a/src/server/transformers/canonical/types.ts b/src/server/transformers/canonical/types.ts new file mode 100644 index 00000000..0611d83f --- /dev/null +++ b/src/server/transformers/canonical/types.ts @@ -0,0 +1,113 @@ +import type { CanonicalAttachment } from './attachments.js'; +import type { CanonicalTool, CanonicalToolChoice } from './tools.js'; + +export type CanonicalOperation = + | 'generate' + | 'count_tokens'; + +export type CanonicalSurface = + | 'openai-chat' + | 'openai-responses' + | 'anthropic-messages' + | 'gemini-generate-content'; + +export type CanonicalCliProfile = + | 'generic' + | 'codex' + | 'claude_code' + | 'gemini_cli'; + +export type CanonicalMessageRole = + | 'system' + | 'developer' + | 'user' + | 'assistant' + | 'tool'; + +export type CanonicalTextPart = { + type: 'text'; + text: string; + thought?: boolean; +}; + +export type CanonicalImagePart = { + type: 'image'; + dataUrl?: string; + url?: string; + mimeType?: string | null; +}; + +export type CanonicalFilePart = { + type: 'file'; + fileId?: string; + fileUrl?: string; + fileData?: string; + mimeType?: string | null; + filename?: string; +}; + +export type CanonicalToolCallPart = { + type: 'tool_call'; + id: string; + name: string; + argumentsJson: string; +}; + +export type CanonicalToolResultPart = { + type: 'tool_result'; + toolCallId: string; + resultText?: string; + resultJson?: unknown; + resultContent?: string | Array>; +}; + +export type CanonicalContentPart = + | CanonicalTextPart + | CanonicalImagePart + | CanonicalFilePart + | CanonicalToolCallPart + | CanonicalToolResultPart; + +export type CanonicalMessage = { + role: CanonicalMessageRole; + parts: CanonicalContentPart[]; + phase?: string; + reasoningSignature?: string; +}; + +export type CanonicalReasoningEffort = + | 'none' + | 'low' + | 'medium' + | 'high' + | 'max'; + +export type CanonicalReasoningRequest = { + effort?: CanonicalReasoningEffort; + budgetTokens?: number; + summary?: string; + includeEncryptedContent?: boolean; +}; + +export type CanonicalContinuation = { + sessionId?: string; + previousResponseId?: string; + promptCacheKey?: string; + turnState?: string; +}; + +export type CanonicalRequestEnvelope = { + operation: CanonicalOperation; + surface: CanonicalSurface; + cliProfile: CanonicalCliProfile; + requestedModel: string; + stream: boolean; + messages: CanonicalMessage[]; + reasoning?: CanonicalReasoningRequest; + tools?: CanonicalTool[]; + toolChoice?: CanonicalToolChoice; + continuation?: CanonicalContinuation; + metadata?: Record; + passthrough?: Record; + attachments?: CanonicalAttachment[]; +}; diff --git a/src/server/transformers/contracts.ts b/src/server/transformers/contracts.ts new file mode 100644 index 00000000..1ce6a823 --- /dev/null +++ b/src/server/transformers/contracts.ts @@ -0,0 +1,78 @@ +import type { + CanonicalCliProfile, + CanonicalContinuation, + CanonicalOperation, + CanonicalRequestEnvelope, +} from './canonical/types.js'; +import type { + ClaudeDownstreamContext, + NormalizedFinalResponse, + NormalizedStreamEvent, + StreamTransformContext, +} from './shared/normalized.js'; + +export type ProtocolParseContext = { + cliProfile?: CanonicalCliProfile; + operation?: CanonicalOperation; + continuation?: CanonicalContinuation; + metadata?: Record; + passthrough?: Record; + defaultEncryptedReasoningInclude?: boolean; +}; + +export type ProtocolBuildContext = { + cliProfile?: CanonicalCliProfile; +}; + +export type ProtocolResponseContext = { + modelName: string; + fallbackText?: string; +}; + +export type ProtocolStreamContext = { + modelName: string; + streamContext?: StreamTransformContext; +}; + +export type ProtocolSerializeContext = { + modelName: string; + usage?: { + promptTokens?: number | null; + completionTokens?: number | null; + totalTokens?: number | null; + }; + streamContext?: StreamTransformContext; + claudeContext?: ClaudeDownstreamContext; +}; + +export interface ProtocolTransformer { + parseRequest( + body: unknown, + ctx?: ProtocolParseContext, + ): { value?: CanonicalRequestEnvelope; error?: { statusCode: number; payload: unknown } }; + + buildProtocolRequest( + request: CanonicalRequestEnvelope, + ctx?: ProtocolBuildContext, + ): Record; + + normalizeFinal( + payload: unknown, + ctx: ProtocolResponseContext, + ): NormalizedFinalResponse; + + normalizeStreamEvent( + payload: unknown, + ctx: ProtocolStreamContext, + ): NormalizedStreamEvent; + + serializeFinal( + normalized: NormalizedFinalResponse, + ctx: ProtocolSerializeContext, + ): unknown; + + serializeStreamEvent( + normalized: NormalizedStreamEvent, + ctx: ProtocolSerializeContext, + ): string[] | unknown[]; +} diff --git a/src/server/transformers/final-hard-cut.architecture.test.ts b/src/server/transformers/final-hard-cut.architecture.test.ts index bd84ba3b..0c68e207 100644 --- a/src/server/transformers/final-hard-cut.architecture.test.ts +++ b/src/server/transformers/final-hard-cut.architecture.test.ts @@ -1,11 +1,28 @@ import { describe, expect, it } from 'vitest'; -import { readFileSync } from 'node:fs'; +import { readFileSync, readdirSync, statSync } from 'node:fs'; import path from 'node:path'; function readWorkspaceFile(relativePath: string): string { return readFileSync(path.resolve(process.cwd(), relativePath), 'utf8'); } +function listTransformerFiles(directory: string): string[] { + const entries = readdirSync(directory); + const files: string[] = []; + for (const entry of entries) { + const fullPath = path.join(directory, entry); + const stat = statSync(fullPath); + if (stat.isDirectory()) { + files.push(...listTransformerFiles(fullPath)); + continue; + } + if (entry.endsWith('.ts') && !entry.endsWith('.test.ts')) { + files.push(fullPath); + } + } + return files; +} + describe('final transformer hard-cut architecture', () => { it('keeps shared normalized helpers independent from route chatFormats', () => { const sharedNormalized = readWorkspaceFile('src/server/transformers/shared/normalized.ts'); @@ -37,4 +54,18 @@ describe('final transformer hard-cut architecture', () => { expect(geminiStream).not.toContain('passthrough'); expect(geminiAggregator).not.toContain('parts: unknown[]'); }); + + it('forbids transformer imports from routes, oauth, token router, runtime executor, and fastify', () => { + const transformerRoot = path.resolve(process.cwd(), 'src/server/transformers'); + const files = listTransformerFiles(transformerRoot); + + for (const file of files) { + const source = readFileSync(file, 'utf8'); + expect(source).not.toMatch(/(?:^|\n)\s*(?:import|export)\s+(?:type\s+)?(?:[^'"\n]*?\s+from\s+)?['"][^'"]*routes\/proxy\//m); + expect(source).not.toMatch(/(?:^|\n)\s*(?:import|export)\s+(?:type\s+)?(?:[^'"\n]*?\s+from\s+)?['"][^'"]*services\/oauth\//m); + expect(source).not.toMatch(/(?:^|\n)\s*(?:import|export)\s+(?:type\s+)?(?:[^'"\n]*?\s+from\s+)?['"][^'"]*services\/tokenRouter\.js['"]/m); + expect(source).not.toMatch(/(?:^|\n)\s*(?:import|export)\s+(?:type\s+)?(?:[^'"\n]*?\s+from\s+)?['"][^'"]*routes\/proxy\/runtimeExecutor\.js['"]/m); + expect(source).not.toContain("from 'fastify'"); + } + }); }); diff --git a/src/server/transformers/gemini/generate-content/compatibility.ts b/src/server/transformers/gemini/generate-content/compatibility.ts new file mode 100644 index 00000000..ab1b0d93 --- /dev/null +++ b/src/server/transformers/gemini/generate-content/compatibility.ts @@ -0,0 +1,370 @@ +import { toOpenAiChatFileBlock } from '../../shared/inputFile.js'; +import type { NormalizedFinalResponse } from '../../shared/normalized.js'; +import { extractReasoningMetadataFromGeminiRequest } from './convert.js'; + +type GeminiRecord = Record; + +function isRecord(value: unknown): value is GeminiRecord { + return !!value && typeof value === 'object' && !Array.isArray(value); +} + +function asTrimmedString(value: unknown): string { + return typeof value === 'string' ? value.trim() : ''; +} + +function isImageMimeType(mimeType: string): boolean { + return mimeType.toLowerCase().startsWith('image/'); +} + +function parseJsonIfPossible(value: string): unknown { + const trimmed = value.trim(); + if (!trimmed) return {}; + try { + return JSON.parse(trimmed); + } catch { + return { raw: value }; + } +} + +function buildDataUrl(part: GeminiRecord): string | null { + const inlineData = isRecord(part.inlineData) ? part.inlineData : null; + if (!inlineData) return null; + const mimeType = asTrimmedString(inlineData.mime_type ?? inlineData.mimeType) || 'application/octet-stream'; + const data = asTrimmedString(inlineData.data); + if (!data) return null; + return `data:${mimeType};base64,${data}`; +} + +function buildInlineData( + part: GeminiRecord, +): { mimeType: string; data: string } | null { + const inlineData = isRecord(part.inlineData) ? part.inlineData : null; + if (!inlineData) return null; + const mimeType = asTrimmedString(inlineData.mime_type ?? inlineData.mimeType) || 'application/octet-stream'; + const data = asTrimmedString(inlineData.data); + if (!data) return null; + return { mimeType, data }; +} + +function buildFileDataSource( + part: GeminiRecord, +): { fileUri: string; mimeType: string | null } | null { + const fileData = isRecord(part.fileData) ? part.fileData : null; + if (!fileData) return null; + const fileUri = asTrimmedString(fileData.fileUri ?? fileData.file_uri); + if (!fileUri) return null; + const mimeType = asTrimmedString(fileData.mimeType ?? fileData.mime_type) || null; + return { fileUri, mimeType }; +} + +function toOpenAiBlockFromGeminiPart(part: GeminiRecord): Record | null { + const inlineData = buildInlineData(part); + if (inlineData) { + const normalizedMimeType = inlineData.mimeType.toLowerCase(); + if (normalizedMimeType.startsWith('image/')) { + return { + type: 'image_url', + image_url: { url: buildDataUrl(part)! }, + }; + } + + return toOpenAiChatFileBlock({ + fileData: inlineData.data, + mimeType: inlineData.mimeType, + }); + } + + const fileData = buildFileDataSource(part); + if (!fileData) return null; + if (fileData.mimeType?.toLowerCase().startsWith('image/')) { + return { + type: 'image_url', + image_url: { url: fileData.fileUri }, + }; + } + + return toOpenAiChatFileBlock({ + fileUrl: fileData.fileUri, + mimeType: fileData.mimeType, + }); +} + +function toOpenAiContent(contentParts: GeminiRecord[]): string | Array> { + const blocks: Array> = []; + let textContent = ''; + + for (const part of contentParts) { + if (typeof part.text === 'string' && part.text.length > 0) { + if (part.thought === true) continue; + textContent += part.text; + continue; + } + + const block = toOpenAiBlockFromGeminiPart(part); + if (block) { + blocks.push(block); + } + } + + if (blocks.length <= 0) { + return textContent; + } + + if (textContent) { + blocks.unshift({ + type: 'text', + text: textContent, + }); + } + return blocks; +} + +function extractToolDeclarations(tools: unknown): Array> | undefined { + if (!Array.isArray(tools)) return undefined; + const next = tools.flatMap((tool) => { + if (!isRecord(tool) || !Array.isArray(tool.functionDeclarations)) return []; + return tool.functionDeclarations.flatMap((declaration) => { + if (!isRecord(declaration)) return []; + const name = asTrimmedString(declaration.name); + if (!name) return []; + return [{ + type: 'function', + function: { + name, + ...(asTrimmedString(declaration.description) + ? { description: asTrimmedString(declaration.description) } + : {}), + parameters: isRecord(declaration.parametersJsonSchema) + ? declaration.parametersJsonSchema + : (isRecord(declaration.parameters) ? declaration.parameters : { type: 'object', properties: {} }), + }, + }]; + }); + }); + return next.length > 0 ? next : undefined; +} + +function extractToolChoice(toolConfig: unknown): string | undefined { + const functionCallingConfig = ( + isRecord(toolConfig) && isRecord(toolConfig.functionCallingConfig) + ? toolConfig.functionCallingConfig + : null + ); + const mode = asTrimmedString(functionCallingConfig?.mode).toUpperCase(); + if (mode === 'NONE') return 'none'; + if (mode === 'ANY' || mode === 'VALIDATED') return 'required'; + if (mode === 'AUTO') return 'auto'; + return undefined; +} + +function buildGeminiMessages(body: GeminiRecord): Array> { + const messages: Array> = []; + + if (isRecord(body.systemInstruction) && Array.isArray(body.systemInstruction.parts)) { + const content = toOpenAiContent( + body.systemInstruction.parts.filter((part): part is GeminiRecord => isRecord(part)), + ); + const hasContent = typeof content === 'string' ? content.length > 0 : content.length > 0; + if (hasContent) { + messages.push({ + role: 'system', + content, + }); + } + } + + const contents = Array.isArray(body.contents) ? body.contents : []; + for (const contentItem of contents) { + if (!isRecord(contentItem)) continue; + const role = asTrimmedString(contentItem.role) === 'model' ? 'assistant' : 'user'; + const parts = Array.isArray(contentItem.parts) + ? contentItem.parts.filter((part): part is GeminiRecord => isRecord(part)) + : []; + + const toolCalls = parts + .map((part, index) => { + const functionCall = isRecord(part.functionCall) ? part.functionCall : null; + const name = asTrimmedString(functionCall?.name); + if (!functionCall || !name) return null; + return { + id: asTrimmedString(functionCall.id) || `call_${index}`, + type: 'function', + function: { + name, + arguments: JSON.stringify(functionCall.args ?? {}), + }, + }; + }) + .filter((item): item is NonNullable => !!item); + + const functionResponses = parts + .map((part) => (isRecord(part.functionResponse) ? part.functionResponse : null)) + .filter((item): item is GeminiRecord => !!item); + + if (functionResponses.length > 0) { + for (let index = 0; index < functionResponses.length; index += 1) { + const functionResponse = functionResponses[index] as GeminiRecord; + const toolName = asTrimmedString(functionResponse.name) || `tool_${index}`; + const toolResponse = functionResponse.response; + messages.push({ + role: 'tool', + tool_call_id: toolName, + content: JSON.stringify(toolResponse ?? {}), + }); + } + continue; + } + + const openAiParts = parts.filter((part) => !part.functionCall && !part.functionResponse); + const content = toOpenAiContent(openAiParts); + const hasContent = typeof content === 'string' ? content.length > 0 : content.length > 0; + if (!hasContent && toolCalls.length <= 0) continue; + + const message: Record = { + role, + }; + if (hasContent) { + message.content = content; + } + if (toolCalls.length > 0) { + message.tool_calls = toolCalls; + if (!hasContent) message.content = ''; + } + messages.push(message); + } + + return messages; +} + +export function buildOpenAiBodyFromGeminiRequest(input: { + body: GeminiRecord; + modelName: string; + stream: boolean; +}): Record { + const openAiBody: Record = { + model: input.modelName, + stream: input.stream, + messages: buildGeminiMessages(input.body), + }; + + const generationConfig = isRecord(input.body.generationConfig) ? input.body.generationConfig : null; + const maxTokens = Number( + generationConfig?.maxOutputTokens + ?? input.body.max_output_tokens + ?? input.body.max_completion_tokens + ?? input.body.max_tokens + ?? 0, + ); + if (Number.isFinite(maxTokens) && maxTokens > 0) { + openAiBody.max_tokens = Math.trunc(maxTokens); + } + const temperature = Number(generationConfig?.temperature); + if (Number.isFinite(temperature)) openAiBody.temperature = temperature; + const topP = Number(generationConfig?.topP); + if (Number.isFinite(topP)) openAiBody.top_p = topP; + if (Array.isArray(generationConfig?.stopSequences) && generationConfig!.stopSequences.length > 0) { + openAiBody.stop = generationConfig!.stopSequences; + } + + const tools = extractToolDeclarations(input.body.tools); + if (tools) { + openAiBody.tools = tools; + } + const toolChoice = extractToolChoice(input.body.toolConfig); + if (toolChoice) { + openAiBody.tool_choice = toolChoice; + } + + const reasoning = extractReasoningMetadataFromGeminiRequest(input.body); + if (reasoning) { + openAiBody.reasoning = { + effort: reasoning.reasoningEffort, + budget_tokens: reasoning.reasoningBudget, + }; + openAiBody.reasoning_effort = reasoning.reasoningEffort; + } + + return openAiBody; +} + +function mapFinishReason(finishReason: string): string { + const normalized = finishReason.trim().toLowerCase(); + if (!normalized) return 'STOP'; + if (normalized === 'stop' || normalized === 'completed' || normalized === 'tool_calls') return 'STOP'; + if (normalized === 'length' || normalized === 'max_tokens' || normalized === 'max_output_tokens') return 'MAX_TOKENS'; + if (normalized === 'content_filter' || normalized === 'safety') return 'SAFETY'; + return normalized.toUpperCase().replace(/[^A-Z0-9_]+/g, '_'); +} + +function buildUsageMetadata(usage?: { + promptTokens?: number | null; + completionTokens?: number | null; + totalTokens?: number | null; +}): Record | undefined { + if (!usage) return undefined; + const metadata: Record = {}; + if (typeof usage.promptTokens === 'number' && Number.isFinite(usage.promptTokens)) { + metadata.promptTokenCount = usage.promptTokens; + } + if (typeof usage.completionTokens === 'number' && Number.isFinite(usage.completionTokens)) { + metadata.candidatesTokenCount = usage.completionTokens; + } + if (typeof usage.totalTokens === 'number' && Number.isFinite(usage.totalTokens)) { + metadata.totalTokenCount = usage.totalTokens; + } + return Object.keys(metadata).length > 0 ? metadata : undefined; +} + +export function serializeNormalizedFinalToGemini(input: { + normalized: NormalizedFinalResponse; + usage?: { + promptTokens?: number | null; + completionTokens?: number | null; + totalTokens?: number | null; + }; +}): Record { + const parts: Array> = []; + + if (input.normalized.reasoningContent) { + parts.push({ + text: input.normalized.reasoningContent, + thought: true, + }); + } + if (input.normalized.content) { + parts.push({ + text: input.normalized.content, + }); + } + for (const toolCall of input.normalized.toolCalls) { + parts.push({ + functionCall: { + ...(toolCall.id ? { id: toolCall.id } : {}), + name: toolCall.name, + args: parseJsonIfPossible(toolCall.arguments), + }, + }); + } + + const response: Record = { + responseId: input.normalized.id || '', + modelVersion: input.normalized.model || '', + candidates: [ + { + index: 0, + content: { + role: 'model', + parts, + }, + finishReason: mapFinishReason(input.normalized.finishReason), + }, + ], + }; + + const usageMetadata = buildUsageMetadata(input.usage); + if (usageMetadata) { + response.usageMetadata = usageMetadata; + } + + return response; +} diff --git a/src/server/transformers/gemini/generate-content/conversion.test.ts b/src/server/transformers/gemini/generate-content/conversion.test.ts new file mode 100644 index 00000000..b5741e7f --- /dev/null +++ b/src/server/transformers/gemini/generate-content/conversion.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from 'vitest'; + +import { convertOpenAiBodyToGeminiGenerateContentRequest } from './conversion.js'; + +describe('convertOpenAiBodyToGeminiGenerateContentRequest', () => { + it('maps OpenAI generic file blocks to Gemini inlineData parts', () => { + const request = convertOpenAiBodyToGeminiGenerateContentRequest({ + modelName: 'gemini-2.5-pro', + body: { + messages: [ + { + role: 'user', + content: [ + { type: 'text', text: 'summarize this file' }, + { + type: 'file', + file: { + filename: 'brief.pdf', + mime_type: 'application/pdf', + file_data: 'JVBERi0xLjc=', + }, + }, + ], + }, + ], + }, + }); + + expect(request.contents).toEqual([ + { + role: 'user', + parts: [ + { text: 'summarize this file' }, + { + inlineData: { + mime_type: 'application/pdf', + data: 'JVBERi0xLjc=', + }, + }, + ], + }, + ]); + }); +}); diff --git a/src/server/transformers/gemini/generate-content/conversion.ts b/src/server/transformers/gemini/generate-content/conversion.ts new file mode 100644 index 00000000..f8c44ad7 --- /dev/null +++ b/src/server/transformers/gemini/generate-content/conversion.ts @@ -0,0 +1,327 @@ +import { normalizeInputFileBlock } from '../../shared/inputFile.js'; +import { resolveGeminiThinkingConfigFromRequest } from './convert.js'; + +const DUMMY_THOUGHT_SIGNATURE = 'c2tpcF90aG91Z2h0X3NpZ25hdHVyZV92YWxpZGF0b3I='; + +function isRecord(value: unknown): value is Record { + return !!value && typeof value === 'object' && !Array.isArray(value); +} + +function asTrimmedString(value: unknown): string { + return typeof value === 'string' ? value.trim() : ''; +} + +function parseDataUrl(url: string): { mimeType: string; data: string } | null { + if (!url.startsWith('data:')) return null; + const [, rest] = url.split('data:', 2); + const [meta, data] = rest.split(',', 2); + if (!meta || !data) return null; + const [mimeType] = meta.split(';', 1); + return { + mimeType: mimeType || 'application/octet-stream', + data, + }; +} + +function normalizeFunctionResponseResult(value: unknown): unknown { + if (typeof value !== 'string') return value; + const trimmed = value.trim(); + if (!trimmed) return value; + try { + return JSON.parse(trimmed); + } catch { + return value; + } +} + +function toGeminiInlineDataPart(input: { + mimeType: string; + data: string; +}): Record { + return { + inlineData: { + mime_type: input.mimeType, + data: input.data, + }, + }; +} + +function convertContentToGeminiParts(content: unknown): Array> { + if (typeof content === 'string') { + const trimmed = content.trim(); + return trimmed ? [{ text: trimmed }] : []; + } + + if (isRecord(content)) { + if (typeof content.text === 'string') { + const trimmed = content.text.trim(); + return trimmed ? [{ text: trimmed }] : []; + } + return []; + } + + if (!Array.isArray(content)) return []; + + const parts: Array> = []; + for (const item of content) { + if (!isRecord(item)) continue; + const type = asTrimmedString(item.type).toLowerCase(); + if (type === 'text') { + const text = asTrimmedString(item.text); + if (text) parts.push({ text }); + continue; + } + if (type === 'image_url' || type === 'input_image') { + const imageUrl = asTrimmedString(item.image_url && isRecord(item.image_url) ? item.image_url.url : item.image_url ?? item.url); + const parsed = imageUrl ? parseDataUrl(imageUrl) : null; + if (parsed) { + parts.push(toGeminiInlineDataPart(parsed)); + continue; + } + if (imageUrl) { + parts.push({ + fileData: { + fileUri: imageUrl, + }, + }); + } + continue; + } + if (type === 'input_audio') { + const audio = isRecord(item.input_audio) ? item.input_audio : item; + const data = asTrimmedString(audio.data); + if (data) { + parts.push(toGeminiInlineDataPart({ + mimeType: asTrimmedString(audio.mime_type ?? audio.mimeType) || 'audio/wav', + data, + })); + } + continue; + } + + const normalizedFile = normalizeInputFileBlock(item); + if (normalizedFile) { + if (normalizedFile.fileData) { + const parsed = parseDataUrl(normalizedFile.fileData); + parts.push(toGeminiInlineDataPart({ + mimeType: normalizedFile.mimeType || parsed?.mimeType || 'application/octet-stream', + data: parsed?.data || normalizedFile.fileData, + })); + continue; + } + + const fileUri = normalizedFile.fileUrl || normalizedFile.fileId; + if (fileUri) { + parts.push({ + fileData: { + fileUri, + ...(normalizedFile.mimeType ? { mimeType: normalizedFile.mimeType } : {}), + }, + }); + } + } + } + return parts; +} + +function buildGeminiTools(tools: unknown): Array> | undefined { + if (!Array.isArray(tools)) return undefined; + const declarations = tools + .filter((item) => isRecord(item)) + .flatMap((item) => { + if (asTrimmedString(item.type) !== 'function' || !isRecord(item.function)) return []; + const fn = item.function as Record; + const name = asTrimmedString(fn.name); + if (!name) return []; + return [{ + name, + ...(asTrimmedString(fn.description) ? { description: asTrimmedString(fn.description) } : {}), + parametersJsonSchema: isRecord(fn.parameters) ? fn.parameters : { type: 'object', properties: {} }, + }]; + }); + + if (declarations.length <= 0) return undefined; + return [{ functionDeclarations: declarations }]; +} + +function buildGeminiToolConfig(toolChoice: unknown): Record | undefined { + if (typeof toolChoice === 'string') { + const normalized = toolChoice.trim().toLowerCase(); + if (normalized === 'none') { + return { functionCallingConfig: { mode: 'NONE' } }; + } + if (normalized === 'required') { + return { functionCallingConfig: { mode: 'ANY' } }; + } + return { functionCallingConfig: { mode: 'AUTO' } }; + } + return undefined; +} + +export function convertOpenAiBodyToGeminiGenerateContentRequest(input: { + body: Record; + modelName: string; + instructions?: string; +}) { + const request: Record = { + contents: [], + }; + + const messages = Array.isArray(input.body.messages) ? input.body.messages : []; + const toolNameById = new Map(); + for (const message of messages) { + if (!isRecord(message) || asTrimmedString(message.role) !== 'assistant') continue; + const toolCalls = Array.isArray(message.tool_calls) ? message.tool_calls : []; + for (const toolCall of toolCalls) { + if (!isRecord(toolCall) || !isRecord(toolCall.function)) continue; + const id = asTrimmedString(toolCall.id); + const name = asTrimmedString(toolCall.function.name); + if (id && name) { + toolNameById.set(id, name); + } + } + } + + const hasThinkingEnabled = !!resolveGeminiThinkingConfigFromRequest(input.modelName, input.body); + const thoughtSignatureById = new Map(); + for (const message of messages) { + if (!isRecord(message) || asTrimmedString(message.role) !== 'assistant') continue; + const toolCalls = Array.isArray(message.tool_calls) ? message.tool_calls : []; + for (const toolCall of toolCalls) { + if (!isRecord(toolCall)) continue; + const id = asTrimmedString(toolCall.id); + if (!id) continue; + const providerFields = isRecord(toolCall.provider_specific_fields) ? toolCall.provider_specific_fields : null; + if (providerFields && typeof providerFields.thought_signature === 'string') { + thoughtSignatureById.set(id, providerFields.thought_signature); + } + } + } + + const systemParts: Array> = []; + if (typeof input.instructions === 'string' && input.instructions.trim()) { + systemParts.push({ text: input.instructions.trim() }); + } + + for (const message of messages) { + if (!isRecord(message)) continue; + const role = asTrimmedString(message.role).toLowerCase(); + if (role === 'system' || role === 'developer') { + systemParts.push(...convertContentToGeminiParts(message.content)); + continue; + } + if (role === 'tool') { + const toolCallId = asTrimmedString(message.tool_call_id); + const name = toolNameById.get(toolCallId) || 'unknown'; + const result = normalizeFunctionResponseResult(message.content); + request.contents = [ + ...(Array.isArray(request.contents) ? request.contents : []), + { + role: 'user', + parts: [{ + functionResponse: { + name, + response: { + result, + }, + }, + }], + }, + ]; + continue; + } + + const textParts = convertContentToGeminiParts(message.content); + const functionCallParts: Array> = []; + const toolCalls = Array.isArray(message.tool_calls) ? message.tool_calls : []; + for (const toolCall of toolCalls) { + if (!isRecord(toolCall) || !isRecord(toolCall.function)) continue; + const name = asTrimmedString(toolCall.function.name); + if (!name) continue; + const rawArguments = toolCall.function.arguments; + let args: unknown = {}; + if (typeof rawArguments === 'string' && rawArguments.trim()) { + try { + args = JSON.parse(rawArguments); + } catch { + args = { raw: rawArguments }; + } + } else if (isRecord(rawArguments)) { + args = rawArguments; + } + const functionCallPart: Record = { + functionCall: { name, args }, + }; + const id = asTrimmedString(toolCall.id); + const signature = thoughtSignatureById.get(id); + if (signature) { + functionCallPart.thoughtSignature = signature; + } else if (hasThinkingEnabled) { + functionCallPart.thoughtSignature = DUMMY_THOUGHT_SIGNATURE; + } + functionCallParts.push(functionCallPart); + } + + const geminiRole = role === 'assistant' ? 'model' : 'user'; + const hasSigned = functionCallParts.some((part) => 'thoughtSignature' in part); + if (hasSigned && textParts.length > 0 && functionCallParts.length > 0) { + request.contents = [ + ...(Array.isArray(request.contents) ? request.contents : []), + { role: geminiRole, parts: textParts }, + { role: geminiRole, parts: functionCallParts }, + ]; + } else { + const allParts = [...textParts, ...functionCallParts]; + if (allParts.length <= 0) continue; + request.contents = [ + ...(Array.isArray(request.contents) ? request.contents : []), + { role: geminiRole, parts: allParts }, + ]; + } + } + + if (systemParts.length > 0) { + request.systemInstruction = { + role: 'user', + parts: systemParts, + }; + } + + const generationConfig: Record = {}; + const maxOutputTokens = Number( + input.body.max_output_tokens + ?? input.body.max_completion_tokens + ?? input.body.max_tokens + ?? 0, + ); + if (Number.isFinite(maxOutputTokens) && maxOutputTokens > 0) { + generationConfig.maxOutputTokens = Math.trunc(maxOutputTokens); + } + const temperature = Number(input.body.temperature); + if (Number.isFinite(temperature)) generationConfig.temperature = temperature; + const topP = Number(input.body.top_p); + if (Number.isFinite(topP)) generationConfig.topP = topP; + const topK = Number(input.body.top_k); + if (Number.isFinite(topK)) generationConfig.topK = topK; + if (Array.isArray(input.body.stop) && input.body.stop.length > 0) { + generationConfig.stopSequences = input.body.stop.filter((item): item is string => typeof item === 'string' && item.trim().length > 0); + } + const thinkingConfig = resolveGeminiThinkingConfigFromRequest(input.modelName, input.body); + if (thinkingConfig) { + generationConfig.thinkingConfig = thinkingConfig; + } + if (Object.keys(generationConfig).length > 0) { + request.generationConfig = generationConfig; + } + + const geminiTools = buildGeminiTools(input.body.tools); + if (geminiTools) { + request.tools = geminiTools; + } + const toolConfig = buildGeminiToolConfig(input.body.tool_choice); + if (toolConfig) { + request.toolConfig = toolConfig; + } + + return request; +} diff --git a/src/server/transformers/gemini/generate-content/inbound.ts b/src/server/transformers/gemini/generate-content/inbound.ts index 6a906331..e62dfb68 100644 --- a/src/server/transformers/gemini/generate-content/inbound.ts +++ b/src/server/transformers/gemini/generate-content/inbound.ts @@ -194,14 +194,22 @@ export const geminiGenerateContentInbound = { next.generationConfig = generationConfig; } - const strippedKeys = new Set([ - 'reasoning', - 'reasoning_effort', - 'reasoning_budget', + // Only forward fields that Gemini API supports. Unknown fields + // (e.g. requestId, frequency_penalty) cause upstream 400 errors. + const allowedPassthroughKeys = new Set([ + 'contents', + 'systemInstruction', + 'cachedContent', + 'safetySettings', + 'generationConfig', + 'tools', + 'toolConfig', + 'labels', + 'model', ]); for (const [key, value] of Object.entries(body)) { - if (strippedKeys.has(key)) continue; + if (!allowedPassthroughKeys.has(key)) continue; if (next[key] !== undefined) continue; next[key] = cloneJsonValue(value); } diff --git a/src/server/transformers/gemini/generate-content/index.test.ts b/src/server/transformers/gemini/generate-content/index.test.ts index a9c7a49b..83e2b77a 100644 --- a/src/server/transformers/gemini/generate-content/index.test.ts +++ b/src/server/transformers/gemini/generate-content/index.test.ts @@ -2,11 +2,14 @@ import { describe, expect, it } from 'vitest'; import { geminiGenerateContentTransformer, + resolveGeminiGenerateContentUrl, + resolveGeminiModelsUrl, + resolveGeminiNativeBaseUrl, reasoningEffortToGeminiThinkingConfig, geminiThinkingConfigToReasoning, } from './index.js'; import { extractGeminiUsage } from './usage.js'; -import { serializeGeminiAggregateResponse, extractResponseMetadata } from './outbound.js'; +import { serializeGeminiAggregateResponse, extractResponseMetadata, geminiGenerateContentOutbound } from './outbound.js'; import { resolveGeminiThinkingConfigFromRequest } from './convert.js'; import { parseGeminiStreamPayload, @@ -15,6 +18,191 @@ import { } from './stream.js'; describe('geminiGenerateContentTransformer.inbound', () => { + it('reuses the same gemini url resolver helpers across transformer layers', () => { + expect(geminiGenerateContentTransformer.resolveBaseUrl).toBe(resolveGeminiNativeBaseUrl); + expect(geminiGenerateContentTransformer.resolveModelsUrl).toBe(resolveGeminiModelsUrl); + expect(geminiGenerateContentTransformer.resolveActionUrl).toBe(resolveGeminiGenerateContentUrl); + expect(geminiGenerateContentOutbound.resolveBaseUrl).toBe(resolveGeminiNativeBaseUrl); + expect(geminiGenerateContentOutbound.resolveModelsUrl).toBe(resolveGeminiModelsUrl); + expect(geminiGenerateContentOutbound.resolveActionUrl).toBe(resolveGeminiGenerateContentUrl); + }); + + it('preserves base-url query params when resolving Gemini endpoints', () => { + expect( + resolveGeminiNativeBaseUrl('https://example.com/native?alt=sse', 'v1beta'), + ).toBe('https://example.com/native/v1beta?alt=sse'); + expect( + resolveGeminiModelsUrl('https://example.com/native?alt=sse', 'v1beta', 'api-key'), + ).toBe('https://example.com/native/v1beta/models?alt=sse&key=api-key'); + expect( + resolveGeminiGenerateContentUrl( + 'https://example.com/native?alt=sse', + 'v1beta', + '/models/gemini-2.5-pro:generateContent', + 'api-key', + '?trace=1', + ), + ).toBe('https://example.com/native/v1beta/models/gemini-2.5-pro:generateContent?alt=sse&trace=1&key=api-key'); + }); + + it('parses native Gemini requests into canonical envelopes', () => { + const result = geminiGenerateContentTransformer.parseRequest({ + model: 'gemini-2.5-pro', + contents: [ + { + role: 'user', + parts: [{ text: 'hello' }], + }, + ], + generationConfig: { + thinkingConfig: { + thinkingBudget: 512, + }, + }, + }); + + expect(result.error).toBeUndefined(); + expect(result.value).toMatchObject({ + operation: 'generate', + surface: 'gemini-generate-content', + cliProfile: 'generic', + requestedModel: 'gemini-2.5-pro', + stream: false, + messages: [ + { + role: 'user', + parts: [{ type: 'text', text: 'hello' }], + }, + ], + reasoning: { + budgetTokens: 512, + }, + }); + }); + + it('parses non-image inlineData parts into canonical file parts', () => { + const result = geminiGenerateContentTransformer.parseRequest({ + model: 'gemini-2.5-pro', + contents: [ + { + role: 'user', + parts: [ + { text: 'summarize this pdf' }, + { + inlineData: { + mimeType: 'application/pdf', + data: 'JVBERi0xLjQK', + }, + }, + ], + }, + ], + }); + + expect(result.error).toBeUndefined(); + expect(result.value?.messages).toEqual([ + { + role: 'user', + parts: [ + { type: 'text', text: 'summarize this pdf' }, + { + type: 'file', + fileData: 'JVBERi0xLjQK', + mimeType: 'application/pdf', + }, + ], + }, + ]); + }); + + it('builds native Gemini requests from canonical envelopes', () => { + const body = geminiGenerateContentTransformer.buildProtocolRequest({ + operation: 'generate', + surface: 'gemini-generate-content', + cliProfile: 'gemini_cli', + requestedModel: 'gemini-2.5-pro', + stream: false, + messages: [{ role: 'user', parts: [{ type: 'text', text: 'hello' }] }], + reasoning: { + budgetTokens: 512, + }, + tools: [{ name: 'lookup', inputSchema: { type: 'object' } }], + toolChoice: 'required', + }); + + expect(body).toMatchObject({ + contents: [ + { + role: 'user', + parts: [{ text: 'hello' }], + }, + ], + tools: [ + { + functionDeclarations: [ + { + name: 'lookup', + parameters: { type: 'object' }, + }, + ], + }, + ], + toolConfig: { + functionCallingConfig: { + mode: 'ANY', + }, + }, + generationConfig: { + thinkingConfig: { + thinkingBudget: 512, + }, + }, + }); + }); + + it('compatibility preserves inline document parts as OpenAI file blocks', () => { + const body = geminiGenerateContentTransformer.compatibility.buildOpenAiBodyFromGeminiRequest({ + modelName: 'gpt-5.4', + stream: false, + body: { + contents: [ + { + role: 'user', + parts: [ + { text: 'summarize this file' }, + { + inlineData: { + mimeType: 'application/pdf', + data: 'JVBERi0x', + }, + }, + ], + }, + ], + }, + }); + + expect(body).toEqual({ + model: 'gpt-5.4', + stream: false, + messages: [ + { + role: 'user', + content: [ + { type: 'text', text: 'summarize this file' }, + { + type: 'file', + file: { + file_data: 'JVBERi0x', + mime_type: 'application/pdf', + }, + }, + ], + }, + ], + }); + }); + it('preserves native Gemini request fields through normalization', () => { const body = geminiGenerateContentTransformer.inbound.normalizeRequest({ contents: [ diff --git a/src/server/transformers/gemini/generate-content/index.ts b/src/server/transformers/gemini/generate-content/index.ts index 13dcf91f..92ef4f5a 100644 --- a/src/server/transformers/gemini/generate-content/index.ts +++ b/src/server/transformers/gemini/generate-content/index.ts @@ -1,44 +1,27 @@ -function normalizeBaseUrl(baseUrl: string): string { - return (baseUrl || '').replace(/\/+$/, ''); -} - -function baseIncludesVersion(baseUrl: string): boolean { - return /\/v\d+(?:beta)?(?:\/|$)/i.test(baseUrl); -} +import { + canonicalRequestFromOpenAiBody, + isCanonicalFunctionTool, + isCanonicalNamedToolChoice, +} from '../../canonical/request.js'; +import type { CanonicalContentPart, CanonicalRequestEnvelope } from '../../canonical/types.js'; +import type { ProtocolBuildContext, ProtocolParseContext } from '../../contracts.js'; +import { + resolveGeminiGenerateContentUrl, + resolveGeminiModelsUrl, + resolveGeminiNativeBaseUrl, +} from './urlResolver.js'; +export { + resolveGeminiGenerateContentUrl, + resolveGeminiModelsUrl, + resolveGeminiNativeBaseUrl, +} from './urlResolver.js'; function asTrimmedString(value: unknown): string { return typeof value === 'string' ? value.trim() : ''; } -export function resolveGeminiNativeBaseUrl(baseUrl: string, apiVersion: string): string { - const normalized = normalizeBaseUrl(baseUrl); - if (baseIncludesVersion(normalized)) return normalized; - return `${normalized}/${apiVersion}`; -} - -export function resolveGeminiModelsUrl( - baseUrl: string, - apiVersion: string, - apiKey: string, -): string { - const base = resolveGeminiNativeBaseUrl(baseUrl, apiVersion); - const separator = base.includes('?') ? '&' : '?'; - return `${base}/models${separator}key=${encodeURIComponent(apiKey)}`; -} - -export function resolveGeminiGenerateContentUrl( - baseUrl: string, - apiVersion: string, - modelActionPath: string, - apiKey: string, - search: string, -): string { - const base = resolveGeminiNativeBaseUrl(baseUrl, apiVersion); - const normalizedAction = modelActionPath.replace(/^\/+/, ''); - const params = new URLSearchParams(search.startsWith('?') ? search.slice(1) : search); - params.set('key', apiKey); - const query = params.toString(); - return `${base}/${normalizedAction}${query ? `?${query}` : ''}`; +function isRecord(value: unknown): value is Record { + return !!value && typeof value === 'object' && !Array.isArray(value); } export function resolveGeminiProxyApiVersion(params: { geminiApiVersion?: unknown } | null | undefined): string { @@ -83,6 +66,193 @@ import { geminiGenerateContentStream } from './stream.js'; import { createGeminiGenerateContentAggregateState, applyGeminiGenerateContentAggregate } from './aggregator.js'; import { geminiGenerateContentUsage } from './usage.js'; import { reasoningEffortToGeminiThinkingConfig, geminiThinkingConfigToReasoning } from './convert.js'; +import { buildOpenAiBodyFromGeminiRequest, serializeNormalizedFinalToGemini } from './compatibility.js'; + +function parseJsonString(raw: string): unknown { + const trimmed = raw.trim(); + if (!trimmed) return {}; + try { + return JSON.parse(trimmed); + } catch { + return { raw }; + } +} + +function parseDataUrl(value: string): { mimeType: string; data: string } | null { + const match = /^data:([^;,]+);base64,(.+)$/i.exec(value.trim()); + if (!match) return null; + return { + mimeType: match[1], + data: match[2], + }; +} + +function canonicalPartToGeminiPart(part: CanonicalContentPart): Record | null { + if (part.type === 'text') { + return { + text: part.text, + ...(part.thought === true ? { thought: true } : {}), + }; + } + + if (part.type === 'image') { + const source = typeof part.dataUrl === 'string' && part.dataUrl.trim() + ? part.dataUrl + : (typeof part.url === 'string' ? part.url : ''); + if (!source) return null; + + const dataUrl = parseDataUrl(source); + if (dataUrl) { + return { + inlineData: { + mimeType: dataUrl.mimeType, + data: dataUrl.data, + }, + }; + } + + return { + fileData: { + fileUri: source, + ...(part.mimeType ? { mimeType: part.mimeType } : {}), + }, + }; + } + + if (part.type === 'file') { + if (part.fileData) { + return { + inlineData: { + mimeType: part.mimeType || 'application/octet-stream', + data: part.fileData, + }, + }; + } + + const fileUri = part.fileUrl || part.fileId; + if (!fileUri) return null; + return { + fileData: { + fileUri, + ...(part.mimeType ? { mimeType: part.mimeType } : {}), + }, + }; + } + + if (part.type === 'tool_call') { + return { + functionCall: { + id: part.id, + name: part.name, + args: parseJsonString(part.argumentsJson), + }, + }; + } + + if (part.type === 'tool_result') { + return { + functionResponse: { + name: part.toolCallId, + response: part.resultJson ?? parseJsonString(part.resultText ?? ''), + }, + }; + } + + return null; +} + +function buildGeminiRequestFromCanonical(request: CanonicalRequestEnvelope): Record { + const contents: Array> = []; + const systemParts: Array> = []; + + for (const message of request.messages) { + if (message.role === 'system' || message.role === 'developer') { + systemParts.push( + ...message.parts + .map((part) => canonicalPartToGeminiPart(part)) + .filter((part): part is Record => !!part), + ); + continue; + } + + const parts = message.parts + .map((part) => canonicalPartToGeminiPart(part)) + .filter((part): part is Record => !!part); + + if (parts.length <= 0) continue; + + if (message.role === 'tool') { + contents.push({ + role: 'user', + parts, + }); + continue; + } + + contents.push({ + role: message.role === 'assistant' ? 'model' : 'user', + parts, + }); + } + + const payload: Record = { + contents, + }; + + if (systemParts.length > 0) { + payload.systemInstruction = { + role: 'user', + parts: systemParts, + }; + } + + const generationConfig: Record = {}; + if (request.reasoning?.budgetTokens !== undefined) { + generationConfig.thinkingConfig = { + thinkingBudget: request.reasoning.budgetTokens, + }; + } else if (request.reasoning?.effort) { + generationConfig.thinkingConfig = reasoningEffortToGeminiThinkingConfig( + request.requestedModel, + request.reasoning.effort, + ); + } + if (Object.keys(generationConfig).length > 0) { + payload.generationConfig = generationConfig; + } + + if (Array.isArray(request.tools) && request.tools.length > 0) { + const functionTools = request.tools.filter(isCanonicalFunctionTool); + if (functionTools.length > 0) { + payload.tools = [{ + functionDeclarations: functionTools.map((tool) => ({ + name: tool.name, + ...(tool.description ? { description: tool.description } : {}), + ...(tool.inputSchema ? { parameters: tool.inputSchema } : {}), + })), + }]; + } + } + + if (request.toolChoice) { + if (request.toolChoice === 'none') { + payload.toolConfig = { functionCallingConfig: { mode: 'NONE' } }; + } else if (request.toolChoice === 'auto') { + payload.toolConfig = { functionCallingConfig: { mode: 'AUTO' } }; + } else if (request.toolChoice === 'required') { + payload.toolConfig = { functionCallingConfig: { mode: 'ANY' } }; + } else if (isCanonicalNamedToolChoice(request.toolChoice)) { + payload.toolConfig = { + functionCallingConfig: { + mode: 'ANY', + allowedFunctionNames: [request.toolChoice.name], + }, + }; + } + } + + return payload; +} export const geminiGenerateContentTransformer = { protocol: 'gemini/generate-content' as const, @@ -98,11 +268,61 @@ export const geminiGenerateContentTransformer = { reasoningEffortToGeminiThinkingConfig, geminiThinkingConfigToReasoning, }, + compatibility: { + buildOpenAiBodyFromGeminiRequest, + serializeNormalizedFinalToGemini, + }, parseProxyRequestPath: parseGeminiProxyRequestPath, resolveProxyApiVersion: resolveGeminiProxyApiVersion, resolveBaseUrl: resolveGeminiNativeBaseUrl, resolveModelsUrl: resolveGeminiModelsUrl, resolveActionUrl: resolveGeminiGenerateContentUrl, + parseRequest( + body: unknown, + ctx?: ProtocolParseContext, + ): { value?: CanonicalRequestEnvelope; error?: { statusCode: number; payload: unknown } } { + const rawBody = isRecord(body) ? body : {}; + const requestedModel = asTrimmedString(rawBody.model ?? ctx?.metadata?.requestedModel); + if (!requestedModel) { + return { + error: { + statusCode: 400, + payload: { + error: { + message: 'model is required', + type: 'invalid_request_error', + }, + }, + }, + }; + } + + const stream = rawBody.stream === true || ctx?.metadata?.stream === true; + const normalizedBody = geminiGenerateContentInbound.normalizeRequest(rawBody, requestedModel); + const openAiBody = buildOpenAiBodyFromGeminiRequest({ + body: normalizedBody, + modelName: requestedModel, + stream, + }); + + return { + value: canonicalRequestFromOpenAiBody({ + body: openAiBody, + surface: 'gemini-generate-content', + cliProfile: ctx?.cliProfile, + operation: ctx?.operation, + metadata: ctx?.metadata, + passthrough: ctx?.passthrough, + continuation: ctx?.continuation, + }), + }; + }, + buildProtocolRequest( + request: CanonicalRequestEnvelope, + _ctx?: ProtocolBuildContext, + ): Record { + return buildGeminiRequestFromCanonical(request); + }, }; export { @@ -114,4 +334,6 @@ export { geminiGenerateContentUsage, reasoningEffortToGeminiThinkingConfig, geminiThinkingConfigToReasoning, + buildOpenAiBodyFromGeminiRequest, + serializeNormalizedFinalToGemini, }; diff --git a/src/server/transformers/gemini/generate-content/outbound.ts b/src/server/transformers/gemini/generate-content/outbound.ts index 2a9da9f2..f692ab84 100644 --- a/src/server/transformers/gemini/generate-content/outbound.ts +++ b/src/server/transformers/gemini/generate-content/outbound.ts @@ -7,45 +7,11 @@ import { toTransformerMetadataRecord, type TransformerMetadata, } from '../../shared/normalized.js'; - -function normalizeBaseUrl(baseUrl: string): string { - return (baseUrl || '').replace(/\/+$/, ''); -} - -function baseIncludesVersion(baseUrl: string): boolean { - return /\/v\d+(?:beta)?(?:\/|$)/i.test(baseUrl); -} - -function resolveBaseUrl(baseUrl: string, apiVersion: string): string { - const normalized = normalizeBaseUrl(baseUrl); - if (baseIncludesVersion(normalized)) return normalized; - return `${normalized}/${apiVersion}`; -} - -function resolveModelsUrl( - baseUrl: string, - apiVersion: string, - apiKey: string, -): string { - const resolvedBaseUrl = resolveBaseUrl(baseUrl, apiVersion); - const separator = resolvedBaseUrl.includes('?') ? '&' : '?'; - return `${resolvedBaseUrl}/models${separator}key=${encodeURIComponent(apiKey)}`; -} - -function resolveActionUrl( - baseUrl: string, - apiVersion: string, - modelActionPath: string, - apiKey: string, - search: string, -): string { - const resolvedBaseUrl = resolveBaseUrl(baseUrl, apiVersion); - const normalizedAction = modelActionPath.replace(/^\/+/, ''); - const params = new URLSearchParams(search.startsWith('?') ? search.slice(1) : search); - params.set('key', apiKey); - const query = params.toString(); - return `${resolvedBaseUrl}/${normalizedAction}${query ? `?${query}` : ''}`; -} +import { + resolveGeminiGenerateContentUrl, + resolveGeminiModelsUrl, + resolveGeminiNativeBaseUrl, +} from './urlResolver.js'; type GeminiRecord = Record; @@ -304,9 +270,9 @@ export function extractResponseMetadata(payload: unknown, requestPayload?: unkno } export const geminiGenerateContentOutbound = { - resolveBaseUrl, - resolveModelsUrl, - resolveActionUrl, + resolveBaseUrl: resolveGeminiNativeBaseUrl, + resolveModelsUrl: resolveGeminiModelsUrl, + resolveActionUrl: resolveGeminiGenerateContentUrl, extractTransformerMetadata, extractResponseMetadata, serializeAggregateResponse: serializeGeminiAggregateResponse, diff --git a/src/server/transformers/gemini/generate-content/stream.test.ts b/src/server/transformers/gemini/generate-content/stream.test.ts index f171c027..2b8e987e 100644 --- a/src/server/transformers/gemini/generate-content/stream.test.ts +++ b/src/server/transformers/gemini/generate-content/stream.test.ts @@ -5,8 +5,10 @@ import { extractResponseMetadata, serializeGeminiAggregateResponse } from './out import { applyJsonPayloadToAggregate, applySsePayloadsToAggregate, + consumeUpstreamSseBuffer, geminiGenerateContentStream, parseGeminiStreamPayload, + serializeUpstreamJsonPayload, serializeAggregateSsePayload, } from './stream.js'; import { extractGeminiUsage } from './usage.js'; @@ -169,6 +171,56 @@ describe('geminiGenerateContentStream', () => { }); }); + it('returns raw upstream sse blocks while aggregating tool-calling state', () => { + const state = createGeminiGenerateContentAggregateState(); + const firstBlock = 'data: {"promptFeedback":{"blockReason":"BLOCK_REASON_UNSPECIFIED"},"candidates":[{"content":{"parts":[{"functionCall":{"id":"tool-1","name":"lookup","args":{"q":"cat"}},"thoughtSignature":"sig-tool-1"}]}}]}\r\n\r\n'; + const secondBlock = 'data: {"candidates":[{"content":{"parts":[{"text":"answer"}]},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":10,"candidatesTokenCount":4,"totalTokenCount":14}}\r\n\r\n'; + const doneBlock = 'data: [DONE]\r\n\r\n'; + const result = consumeUpstreamSseBuffer( + state, + `${firstBlock}${secondBlock}${doneBlock}data: {"responseId":"partial"`, + ); + + expect(result.lines).toEqual([firstBlock, secondBlock, doneBlock]); + expect(result.events).toEqual([ + { + promptFeedback: { blockReason: 'BLOCK_REASON_UNSPECIFIED' }, + candidates: [ + { + content: { + parts: [{ functionCall: { id: 'tool-1', name: 'lookup', args: { q: 'cat' } }, thoughtSignature: 'sig-tool-1' }], + }, + }, + ], + }, + { + candidates: [ + { + content: { + parts: [{ text: 'answer' }], + }, + finishReason: 'STOP', + }, + ], + usageMetadata: { + promptTokenCount: 10, + candidatesTokenCount: 4, + totalTokenCount: 14, + }, + }, + ]); + expect(result.rest).toBe('data: {"responseId":"partial"'); + expect(extractGeminiUsage(result.state)).toEqual({ + promptTokens: 10, + completionTokens: 4, + totalTokens: 14, + cachedTokens: 0, + cacheReadTokens: 0, + cacheCreationTokens: 0, + reasoningTokens: 0, + }); + }); + it('normalizes object payloads through the JSON-array path', () => { const state = createGeminiGenerateContentAggregateState(); @@ -203,4 +255,47 @@ describe('geminiGenerateContentStream', () => { ], }); }); + + it('keeps json stream chunks raw while still aggregating usage', () => { + const state = createGeminiGenerateContentAggregateState(); + const payload = [ + { + promptFeedback: { blockReason: 'BLOCK_REASON_UNSPECIFIED' }, + candidates: [ + { + content: { + parts: [{ functionCall: { id: 'tool-1', name: 'lookup', args: { q: 'cat' } } }], + }, + }, + ], + }, + { + serverContent: { modelTurn: { parts: [{ text: 'tool result received' }] } }, + candidates: [ + { + content: { + parts: [{ text: 'final answer' }], + }, + finishReason: 'STOP', + }, + ], + usageMetadata: { + promptTokenCount: 8, + candidatesTokenCount: 5, + totalTokenCount: 13, + }, + }, + ]; + + expect(serializeUpstreamJsonPayload(state, payload, true)).toEqual(payload); + expect(extractGeminiUsage(state)).toEqual({ + promptTokens: 8, + completionTokens: 5, + totalTokens: 13, + cachedTokens: 0, + cacheReadTokens: 0, + cacheCreationTokens: 0, + reasoningTokens: 0, + }); + }); }); diff --git a/src/server/transformers/gemini/generate-content/stream.ts b/src/server/transformers/gemini/generate-content/stream.ts index 1393597f..010750f0 100644 --- a/src/server/transformers/gemini/generate-content/stream.ts +++ b/src/server/transformers/gemini/generate-content/stream.ts @@ -7,6 +7,7 @@ import { serializeGeminiAggregateResponse } from './outbound.js'; type ParsedSsePayloads = { events: unknown[]; + lines: string[]; rest: string; }; @@ -15,10 +16,11 @@ type GeminiGenerateContentStreamFormat = 'sse' | 'json'; type ParsedGeminiStreamPayload = { format: GeminiGenerateContentStreamFormat; events: unknown[]; + lines?: string[]; rest: string; }; -type AppliedGeminiStreamPayloads = ParsedSsePayloads & { +type AppliedGeminiStreamPayloads = ParsedGeminiStreamPayload & { state: GeminiGenerateContentAggregateState; }; @@ -27,19 +29,24 @@ function serializeSsePayload(payload: unknown): string { } function parseSsePayloads(buffer: string): ParsedSsePayloads { - const normalized = buffer.replace(/\r\n/g, '\n'); const events: unknown[] = []; - let rest = normalized; + const lines: string[] = []; + let cursor = 0; - while (true) { - const boundary = rest.indexOf('\n\n'); - if (boundary < 0) break; + while (cursor < buffer.length) { + const boundaryMatch = /\r?\n\r?\n/.exec(buffer.slice(cursor)); + if (!boundaryMatch || typeof boundaryMatch.index !== 'number') break; + + const boundary = cursor + boundaryMatch.index; + const block = buffer.slice(cursor, boundary); + const rawBlock = buffer.slice(cursor, boundary + boundaryMatch[0].length); + cursor = boundary + boundaryMatch[0].length; - const block = rest.slice(0, boundary); - rest = rest.slice(boundary + 2); if (!block.trim()) continue; + lines.push(rawBlock); const data = block + .replace(/\r\n/g, '\n') .split('\n') .filter((line) => line.startsWith('data:')) .map((line) => line.slice(5).trimStart()) @@ -55,7 +62,11 @@ function parseSsePayloads(buffer: string): ParsedSsePayloads { } } - return { events, rest }; + return { + events, + lines, + rest: buffer.slice(cursor), + }; } function isRecord(value: unknown): value is Record { @@ -77,6 +88,7 @@ function parseGeminiStreamPayload( return { format: 'sse', events: parsed.events, + lines: parsed.lines, rest: parsed.rest, }; } @@ -123,8 +135,7 @@ function applyParsedPayloadToAggregate( } return { - events: parsed.events, - rest: parsed.rest, + ...parsed, state, }; } @@ -151,7 +162,7 @@ function consumeUpstreamSseBuffer( const applied = applySsePayloadsToAggregate(state, buffer); return { ...applied, - lines: applied.events.map((event) => serializeAggregateSsePayload(event)), + lines: applied.lines ?? [], }; } @@ -182,10 +193,11 @@ function serializeUpstreamJsonPayload( streamAction = false, ): unknown { if (streamAction) { - return parseJsonArrayPayload(payload).map((event) => { + const events = parseJsonArrayPayload(payload); + for (const event of events) { applyGeminiGenerateContentAggregate(state, event); - return serializeAggregateJsonPayload(event); - }); + } + return payload; } applyJsonPayloadToAggregate(state, payload); diff --git a/src/server/transformers/gemini/generate-content/urlResolver.ts b/src/server/transformers/gemini/generate-content/urlResolver.ts new file mode 100644 index 00000000..9dca8905 --- /dev/null +++ b/src/server/transformers/gemini/generate-content/urlResolver.ts @@ -0,0 +1,63 @@ +function normalizePathname(pathname: string): string { + let normalized = pathname || ''; + while (normalized.endsWith('/')) { + normalized = normalized.slice(0, -1); + } + return normalized; +} + +function splitBaseUrl(baseUrl: string): { path: string; query: string } { + const raw = baseUrl || ''; + const queryIndex = raw.indexOf('?'); + if (queryIndex < 0) { + return { path: normalizePathname(raw), query: '' }; + } + return { + path: normalizePathname(raw.slice(0, queryIndex)), + query: raw.slice(queryIndex + 1), + }; +} + +function baseIncludesVersion(path: string): boolean { + return /\/v\d+(?:beta)?(?:\/|$)/i.test(path); +} + +export function resolveGeminiNativeBaseUrl(baseUrl: string, apiVersion: string): string { + const { path, query } = splitBaseUrl(baseUrl); + const normalizedPath = baseIncludesVersion(path) + ? path + : `${path}/${apiVersion.replace(/^\/+/, '')}`; + return `${normalizedPath}${query ? `?${query}` : ''}`; +} + +export function resolveGeminiModelsUrl( + baseUrl: string, + apiVersion: string, + apiKey: string, +): string { + const base = resolveGeminiNativeBaseUrl(baseUrl, apiVersion); + const [path, query = ''] = base.split('?', 2); + const params = new URLSearchParams(query); + params.set('key', apiKey); + return `${path}/models?${params.toString()}`; +} + +export function resolveGeminiGenerateContentUrl( + baseUrl: string, + apiVersion: string, + modelActionPath: string, + apiKey: string, + search: string, +): string { + const base = resolveGeminiNativeBaseUrl(baseUrl, apiVersion); + const normalizedAction = modelActionPath.replace(/^\/+/, ''); + const [path, baseQuery = ''] = base.split('?', 2); + const params = new URLSearchParams(baseQuery); + const extraParams = new URLSearchParams(search.startsWith('?') ? search.slice(1) : search); + for (const [key, value] of extraParams) { + params.set(key, value); + } + params.set('key', apiKey); + const query = params.toString(); + return `${path}/${normalizedAction}${query ? `?${query}` : ''}`; +} diff --git a/src/server/transformers/openai/chat/helpers.ts b/src/server/transformers/openai/chat/helpers.ts index cf70d277..a7c98e28 100644 --- a/src/server/transformers/openai/chat/helpers.ts +++ b/src/server/transformers/openai/chat/helpers.ts @@ -8,6 +8,7 @@ import type { OpenAiChatUsageDetails, } from './model.js'; import { fromTransformerMetadataRecord } from '../../shared/normalized.js'; +import { extractInlineThinkTags } from '../../shared/thinkTagParser.js'; function isRecord(value: unknown): value is Record { return !!value && typeof value === 'object' && !Array.isArray(value); @@ -98,15 +99,23 @@ function extractTextPart(value: unknown): string { return ''; } +function joinNonEmpty(parts: string[]): string { + return parts.map((item) => item.trim()).filter((item) => item.length > 0).join('\n\n'); +} + function extractTextAndReasoning(value: unknown): { content: string; reasoning: string } { - if (typeof value === 'string') return { content: value, reasoning: '' }; + if (typeof value === 'string') return extractInlineThinkTags(value); if (Array.isArray(value)) { const contentParts: string[] = []; const reasoningParts: string[] = []; for (const item of value) { if (!isRecord(item)) { - if (typeof item === 'string') contentParts.push(item); + if (typeof item === 'string') { + const parsed = extractInlineThinkTags(item); + if (parsed.content) contentParts.push(parsed.content); + if (parsed.reasoning) reasoningParts.push(parsed.reasoning); + } continue; } @@ -119,8 +128,9 @@ function extractTextAndReasoning(value: unknown): { content: string; reasoning: reasoningParts.push(item.reasoning); continue; } - const text = extractTextPart(item); - if (text) contentParts.push(text); + const parsed = extractInlineThinkTags(extractTextPart(item)); + if (parsed.content) contentParts.push(parsed.content); + if (parsed.reasoning) reasoningParts.push(parsed.reasoning); } return { @@ -130,13 +140,14 @@ function extractTextAndReasoning(value: unknown): { content: string; reasoning: } if (!isRecord(value)) return { content: '', reasoning: '' }; + const parsed = extractInlineThinkTags(extractTextPart(value.content ?? value)); return { - content: extractTextPart(value.content ?? value), - reasoning: typeof value.reasoning_content === 'string' - ? value.reasoning_content - : typeof value.reasoning === 'string' - ? value.reasoning - : '', + content: parsed.content, + reasoning: joinNonEmpty([ + typeof value.reasoning_content === 'string' ? value.reasoning_content : '', + typeof value.reasoning === 'string' ? value.reasoning : '', + parsed.reasoning, + ]), }; } diff --git a/src/server/transformers/openai/chat/inbound.ts b/src/server/transformers/openai/chat/inbound.ts index 3d1091e3..0e7f5b95 100644 --- a/src/server/transformers/openai/chat/inbound.ts +++ b/src/server/transformers/openai/chat/inbound.ts @@ -1,19 +1,41 @@ import { parseDownstreamChatRequest } from '../../shared/normalized.js'; +import { createProtocolRequestEnvelope } from '../../shared/protocolModel.js'; import { extractChatRequestMetadata } from './helpers.js'; -import type { OpenAiChatParsedRequest } from './model.js'; +import type { OpenAiChatParsedRequest, OpenAiChatRequestEnvelope } from './model.js'; export const openAiChatInbound = { - parse(body: unknown): { value?: OpenAiChatParsedRequest; error?: { statusCode: number; payload: unknown } } { + parse(body: unknown): { value?: OpenAiChatRequestEnvelope; error?: { statusCode: number; payload: unknown } } { const parsed = parseDownstreamChatRequest(body, 'openai') as { value?: OpenAiChatParsedRequest; error?: { statusCode: number; payload: unknown }; }; - if (!parsed.value) return parsed; + if (parsed.error) { + return { error: parsed.error }; + } + if (!parsed.value) { + return { + error: { + statusCode: 400, + payload: { + error: { + message: 'invalid chat request', + type: 'invalid_request_error', + }, + }, + }, + }; + } - parsed.value = { - ...parsed.value, - requestMetadata: extractChatRequestMetadata(body), + const metadata = extractChatRequestMetadata(body); + return { + value: createProtocolRequestEnvelope({ + protocol: 'openai/chat', + model: parsed.value.requestedModel, + stream: parsed.value.isStream, + rawBody: body, + parsed: parsed.value, + ...(metadata ? { metadata } : {}), + }), }; - return parsed; }, }; diff --git a/src/server/transformers/openai/chat/index.test.ts b/src/server/transformers/openai/chat/index.test.ts index 23497017..64f99bc4 100644 --- a/src/server/transformers/openai/chat/index.test.ts +++ b/src/server/transformers/openai/chat/index.test.ts @@ -11,6 +11,66 @@ function parseSsePayloads(lines: string[]): Array> { } describe('openAiChatTransformer.inbound', () => { + it('parses chat requests into canonical envelopes', () => { + const result = openAiChatTransformer.parseRequest({ + model: 'gpt-5', + stream: true, + messages: [{ role: 'user', content: 'hello' }], + prompt_cache_key: 'cache-key', + reasoning_effort: 'high', + reasoning_budget: 1024, + }); + + expect(result.error).toBeUndefined(); + expect(result.value).toMatchObject({ + operation: 'generate', + surface: 'openai-chat', + cliProfile: 'generic', + requestedModel: 'gpt-5', + stream: true, + messages: [ + { + role: 'user', + parts: [{ type: 'text', text: 'hello' }], + }, + ], + continuation: { + promptCacheKey: 'cache-key', + }, + reasoning: { + effort: 'high', + budgetTokens: 1024, + }, + }); + }); + + it('builds chat requests from canonical envelopes', () => { + const body = openAiChatTransformer.buildProtocolRequest({ + operation: 'generate', + surface: 'openai-chat', + cliProfile: 'codex', + requestedModel: 'gpt-5', + stream: true, + messages: [{ role: 'user', parts: [{ type: 'text', text: 'hello' }] }], + reasoning: { + effort: 'medium', + budgetTokens: 512, + }, + continuation: { + promptCacheKey: 'cache-key', + }, + }); + + expect(body).toMatchObject({ + model: 'gpt-5', + stream: true, + messages: [{ role: 'user', content: 'hello' }], + prompt_cache_key: 'cache-key', + reasoning_effort: 'medium', + reasoning_budget: 512, + }); + }); + it('captures chat request metadata fields without changing upstream body', () => { const result = openAiChatTransformer.transformRequest({ model: 'gpt-5', @@ -31,7 +91,16 @@ describe('openAiChatTransformer.inbound', () => { }); expect(result.error).toBeUndefined(); - expect(result.value?.upstreamBody).toMatchObject({ + expect(result.value).toMatchObject({ + protocol: 'openai/chat', + model: 'gpt-5', + stream: false, + rawBody: { + model: 'gpt-5', + messages: [{ role: 'user', content: 'hello' }], + }, + }); + expect(result.value?.parsed.upstreamBody).toMatchObject({ modalities: ['text', 'audio'], audio: { voice: 'alloy', format: 'mp3' }, reasoning_effort: 'high', @@ -46,7 +115,7 @@ describe('openAiChatTransformer.inbound', () => { response_format: { type: 'json_object' }, stream_options: { include_usage: true }, }); - expect((result.value as any)?.requestMetadata).toEqual({ + expect((result.value as any)?.metadata).toEqual({ modalities: ['text', 'audio'], audio: { voice: 'alloy', format: 'mp3' }, reasoningEffort: 'high', @@ -82,14 +151,19 @@ describe('openAiChatTransformer.inbound', () => { }); expect(result.error).toBeUndefined(); - expect(result.value?.upstreamBody).toMatchObject({ + expect(result.value).toMatchObject({ + protocol: 'openai/chat', + model: 'gpt-5', + stream: false, + }); + expect(result.value?.parsed.upstreamBody).toMatchObject({ modalities: ['text', 42, 'audio', '', null], reasoning_budget: '2048', top_logprobs: '5', logit_bias: { '42': '7', invalid: 'oops' }, stream_options: { include_usage: 1 }, }); - expect((result.value as any)?.requestMetadata).toEqual({ + expect((result.value as any)?.metadata).toEqual({ modalities: ['text', 'audio'], audio: { voice: 'alloy', format: 'wav' }, reasoningEffort: 'medium', @@ -107,6 +181,28 @@ describe('openAiChatTransformer.inbound', () => { }); describe('openAiChatTransformer.outbound', () => { + it('normalizes inline think tags in final chat responses', () => { + const normalized = openAiChatTransformer.transformFinalResponse({ + id: 'chatcmpl-inline-think', + model: 'gpt-5', + created: 123, + choices: [{ + index: 0, + finish_reason: 'stop', + message: { + role: 'assistant', + content: 'plan quietlyvisible answer', + }, + }], + }, 'gpt-5'); + + expect(normalized).toMatchObject({ + content: 'visible answer', + reasoningContent: 'plan quietly', + finishReason: 'stop', + }); + }); + it('carries annotations, citations, and detailed usage through final serialization', () => { const normalized = openAiChatTransformer.transformFinalResponse({ id: 'chatcmpl-1', @@ -402,6 +498,53 @@ describe('openAiChatTransformer.stream', () => { 'https://b.example', ]); }); + + it('normalizes inline think tags inside multi-choice stream chunks', () => { + const context = openAiChatTransformer.createStreamContext('gpt-5'); + const event = openAiChatTransformer.transformStreamEvent({ + id: 'chatcmpl-stream-multi-think', + model: 'gpt-5', + choices: [ + { + index: 0, + finish_reason: null, + delta: { + role: 'assistant', + content: 'plan-0choice-0', + }, + }, + { + index: 1, + finish_reason: null, + delta: { + role: 'assistant', + content: 'plan-1choice-1', + }, + }, + ], + }, context, 'gpt-5'); + + const payloads = parseSsePayloads( + openAiChatTransformer.serializeStreamEvent(event, context, createClaudeDownstreamContext()), + ); + + expect((payloads[0] as any).choices[0]).toMatchObject({ + index: 0, + delta: { + role: 'assistant', + content: 'choice-0', + reasoning_content: 'plan-0', + }, + }); + expect((payloads[0] as any).choices[1]).toMatchObject({ + index: 1, + delta: { + role: 'assistant', + content: 'choice-1', + reasoning_content: 'plan-1', + }, + }); + }); }); describe('openAiChatTransformer.aggregator', () => { diff --git a/src/server/transformers/openai/chat/index.ts b/src/server/transformers/openai/chat/index.ts index ee086869..9191ddc1 100644 --- a/src/server/transformers/openai/chat/index.ts +++ b/src/server/transformers/openai/chat/index.ts @@ -1,10 +1,18 @@ +import { canonicalRequestFromOpenAiBody, canonicalRequestToOpenAiChatBody } from '../../canonical/request.js'; +import type { CanonicalRequestEnvelope } from '../../canonical/types.js'; +import type { ProtocolBuildContext, ProtocolParseContext } from '../../contracts.js'; import { type NormalizedFinalResponse, type NormalizedStreamEvent, type StreamTransformContext } from '../../shared/normalized.js'; +import { createChatEndpointStrategy } from '../../shared/chatEndpointStrategy.js'; import { openAiChatInbound } from './inbound.js'; import { openAiChatOutbound } from './outbound.js'; +import { createChatProxyStreamSession } from './proxyStream.js'; import { openAiChatStream } from './stream.js'; import { openAiChatUsage } from './usage.js'; import { createOpenAiChatAggregateState, applyOpenAiChatStreamEvent, finalizeOpenAiChatAggregate } from './aggregator.js'; -import type { OpenAiChatParsedRequest as OpenAiChatParsedRequestModel } from './model.js'; +import type { + OpenAiChatParsedRequest as OpenAiChatParsedRequestModel, + OpenAiChatRequestEnvelope as OpenAiChatRequestEnvelopeModel, +} from './model.js'; export const openAiChatTransformer = { protocol: 'openai/chat' as const, @@ -12,11 +20,57 @@ export const openAiChatTransformer = { outbound: openAiChatOutbound, stream: openAiChatStream, usage: openAiChatUsage, + compatibility: { + createEndpointStrategy: createChatEndpointStrategy, + }, aggregator: { createState: createOpenAiChatAggregateState, applyEvent: applyOpenAiChatStreamEvent, finalize: finalizeOpenAiChatAggregate, }, + proxyStream: { + createSession: createChatProxyStreamSession, + }, + parseRequest( + body: unknown, + ctx?: ProtocolParseContext, + ): { value?: CanonicalRequestEnvelope; error?: { statusCode: number; payload: unknown } } { + const parsed = openAiChatInbound.parse(body); + if (parsed.error) { + return { error: parsed.error }; + } + if (!parsed.value) { + return { + error: { + statusCode: 400, + payload: { + error: { + message: 'invalid chat request', + type: 'invalid_request_error', + }, + }, + }, + }; + } + + return { + value: canonicalRequestFromOpenAiBody({ + body: parsed.value.parsed.upstreamBody, + surface: 'openai-chat', + cliProfile: ctx?.cliProfile, + operation: ctx?.operation, + metadata: ctx?.metadata, + passthrough: ctx?.passthrough, + continuation: ctx?.continuation, + }), + }; + }, + buildProtocolRequest( + request: CanonicalRequestEnvelope, + _ctx?: ProtocolBuildContext, + ): Record { + return canonicalRequestToOpenAiChatBody(request); + }, transformRequest(body: unknown): ReturnType { return openAiChatInbound.parse(body); }, @@ -72,3 +126,4 @@ export const openAiChatTransformer = { export type OpenAiChatTransformer = typeof openAiChatTransformer; export type OpenAiChatParsedRequest = OpenAiChatParsedRequestModel; +export type OpenAiChatRequestEnvelope = OpenAiChatRequestEnvelopeModel; diff --git a/src/server/transformers/openai/chat/model.ts b/src/server/transformers/openai/chat/model.ts index 074295dd..7b324eca 100644 --- a/src/server/transformers/openai/chat/model.ts +++ b/src/server/transformers/openai/chat/model.ts @@ -3,6 +3,7 @@ import type { NormalizedStreamEvent, ParsedDownstreamChatRequest, } from '../../shared/normalized.js'; +import type { ProtocolRequestEnvelope } from '../../shared/protocolModel.js'; export type OpenAiChatAudioRequest = { format?: string; @@ -31,9 +32,12 @@ export type OpenAiChatUsageDetails = { completion_tokens_details?: Record; }; -export type OpenAiChatParsedRequest = ParsedDownstreamChatRequest & { - requestMetadata?: OpenAiChatRequestMetadata; -}; +export type OpenAiChatParsedRequest = ParsedDownstreamChatRequest; +export type OpenAiChatRequestEnvelope = ProtocolRequestEnvelope< + 'openai/chat', + OpenAiChatParsedRequest, + OpenAiChatRequestMetadata +>; export type OpenAiChatToolCall = { id: string; diff --git a/src/server/transformers/openai/chat/proxyStream.ts b/src/server/transformers/openai/chat/proxyStream.ts new file mode 100644 index 00000000..54161ae7 --- /dev/null +++ b/src/server/transformers/openai/chat/proxyStream.ts @@ -0,0 +1,228 @@ +import { anthropicMessagesTransformer } from '../../anthropic/messages/index.js'; +import { createProxyStreamLifecycle } from '../../shared/protocolLifecycle.js'; +import { type DownstreamFormat, type ParsedSseEvent } from '../../shared/normalized.js'; +import { createOpenAiChatAggregateState, applyOpenAiChatStreamEvent, finalizeOpenAiChatAggregate } from './aggregator.js'; +import { openAiChatOutbound } from './outbound.js'; +import { openAiChatStream } from './stream.js'; + +type StreamReader = { + read(): Promise<{ done: boolean; value?: Uint8Array }>; + cancel(reason?: unknown): Promise; + releaseLock(): void; +}; + +type ChatProxyStreamSessionInput = { + downstreamFormat: DownstreamFormat; + modelName: string; + successfulUpstreamPath: string; + onParsedPayload?: (payload: unknown) => void; + writeLines: (lines: string[]) => void; + writeRaw: (chunk: string) => void; +}; + +type ResponseSink = { + end(): void; +}; + +type ChatProxyStreamResult = { + status: 'completed' | 'failed'; + errorMessage: string | null; +}; + +export function createChatProxyStreamSession(input: ChatProxyStreamSessionInput) { + const downstreamTransformer = input.downstreamFormat === 'claude' + ? anthropicMessagesTransformer + : { + createStreamContext: openAiChatStream.createContext, + transformStreamEvent: openAiChatStream.normalizeEvent, + serializeStreamEvent: openAiChatStream.serializeEvent, + serializeDone: openAiChatStream.serializeDone, + pullSseEvents: openAiChatStream.pullSseEvents, + }; + const streamContext = downstreamTransformer.createStreamContext(input.modelName); + const claudeContext = anthropicMessagesTransformer.createDownstreamContext(); + const chatAggregateState = input.downstreamFormat === 'openai' + ? createOpenAiChatAggregateState() + : null; + let finalized = false; + let terminalResult: ChatProxyStreamResult = { + status: 'completed', + errorMessage: null, + }; + + const extractFailureMessage = (payload: unknown, fallback = 'upstream stream failed'): string => { + if (payload && typeof payload === 'object' && !Array.isArray(payload)) { + const record = payload as Record; + if (record.error && typeof record.error === 'object' && !Array.isArray(record.error)) { + const message = (record.error as Record).message; + if (typeof message === 'string' && message.trim()) return message.trim(); + } + if (typeof record.message === 'string' && record.message.trim()) return record.message.trim(); + if (record.response && typeof record.response === 'object' && !Array.isArray(record.response)) { + const responseError = (record.response as Record).error; + if (responseError && typeof responseError === 'object' && !Array.isArray(responseError)) { + const message = (responseError as Record).message; + if (typeof message === 'string' && message.trim()) return message.trim(); + } + } + } + return fallback; + }; + + const markFailed = (payload: unknown, fallbackMessage?: string) => { + terminalResult = { + status: 'failed', + errorMessage: extractFailureMessage(payload, fallbackMessage), + }; + }; + + const finalize = () => { + if (finalized) return; + finalized = true; + + // For native Anthropic streams, EOF without message_stop is not a clean + // completion. Forward the partial stream as-is instead of fabricating an + // end_turn/message_stop pair that makes clients think the run finished. + if (input.downstreamFormat === 'claude' && !claudeContext.doneSent) { + return; + } + + if ( + input.downstreamFormat === 'openai' + && terminalResult.status !== 'failed' + && chatAggregateState + && chatAggregateState.choices.size > 0 + ) { + const needsTerminalFinishChunk = Array.from(chatAggregateState.choices.values()) + .some((choice) => !choice.finishReason); + if (needsTerminalFinishChunk) { + const terminalChunk = openAiChatOutbound.buildSyntheticChunks( + finalizeOpenAiChatAggregate(chatAggregateState, { + id: streamContext.id, + model: streamContext.model, + created: streamContext.created, + content: '', + reasoningContent: '', + finishReason: 'stop', + toolCalls: [], + }), + ).slice(-1)[0]; + if (terminalChunk) { + input.writeLines([`data: ${JSON.stringify(terminalChunk)}\n\n`]); + } + } + } + + input.writeLines(downstreamTransformer.serializeDone(streamContext, claudeContext)); + }; + + const handleEventBlock = async (eventBlock: ParsedSseEvent): Promise => { + if (eventBlock.data === '[DONE]') { + finalize(); + return true; + } + + let parsedPayload: unknown = null; + if (input.downstreamFormat === 'claude') { + const consumed = anthropicMessagesTransformer.consumeSseEventBlock( + eventBlock, + streamContext, + claudeContext, + input.modelName, + ); + parsedPayload = consumed.parsedPayload; + if (parsedPayload && typeof parsedPayload === 'object') { + input.onParsedPayload?.(parsedPayload); + } + if (consumed.handled) { + input.writeLines(consumed.lines); + return consumed.done; + } + } else { + try { + parsedPayload = JSON.parse(eventBlock.data); + } catch { + parsedPayload = null; + } + if (parsedPayload && typeof parsedPayload === 'object') { + input.onParsedPayload?.(parsedPayload); + } + } + + if (parsedPayload && typeof parsedPayload === 'object') { + const payloadType = typeof (parsedPayload as Record).type === 'string' + ? String((parsedPayload as Record).type) + : ''; + if (payloadType === 'response.failed' || payloadType === 'error') { + markFailed(parsedPayload); + } + const normalizedEvent = downstreamTransformer.transformStreamEvent(parsedPayload, streamContext, input.modelName); + if (input.downstreamFormat === 'openai' && chatAggregateState) { + applyOpenAiChatStreamEvent(chatAggregateState, normalizedEvent); + } + input.writeLines(downstreamTransformer.serializeStreamEvent(normalizedEvent, streamContext, claudeContext)); + return input.downstreamFormat === 'claude' && claudeContext.doneSent; + } + + if (input.downstreamFormat === 'openai') { + input.writeRaw(`data: ${eventBlock.data}\n\n`); + return false; + } + + input.writeLines(anthropicMessagesTransformer.serializeStreamEvent({ + contentDelta: eventBlock.data, + }, streamContext, claudeContext)); + return claudeContext.doneSent; + }; + + return { + consumeUpstreamFinalPayload(payload: unknown, fallbackText: string, response?: ResponseSink): ChatProxyStreamResult { + if (payload && typeof payload === 'object') { + input.onParsedPayload?.(payload); + } + if (payload && typeof payload === 'object' && !Array.isArray(payload)) { + const payloadType = typeof (payload as Record).type === 'string' + ? String((payload as Record).type) + : ''; + if (payloadType === 'response.failed' || payloadType === 'error') { + markFailed(payload); + } + } + if (input.downstreamFormat === 'openai') { + const normalizedFinal = openAiChatOutbound.normalizeFinal(payload, input.modelName, fallbackText); + streamContext.id = normalizedFinal.id; + streamContext.model = normalizedFinal.model; + streamContext.created = normalizedFinal.created; + input.writeLines( + openAiChatOutbound + .buildSyntheticChunks(normalizedFinal) + .map((chunk) => `data: ${JSON.stringify(chunk)}\n\n`), + ); + } else { + input.writeLines( + anthropicMessagesTransformer.serializeUpstreamFinalAsStream( + payload, + input.modelName, + fallbackText, + streamContext, + claudeContext, + ), + ); + } + finalize(); + response?.end(); + return terminalResult; + }, + async run(reader: StreamReader | null | undefined, response: ResponseSink): Promise { + const lifecycle = createProxyStreamLifecycle({ + reader, + response, + pullEvents: (buffer) => downstreamTransformer.pullSseEvents(buffer), + handleEvent: handleEventBlock, + onEof: finalize, + }); + await lifecycle.run(); + return terminalResult; + }, + }; +} diff --git a/src/server/transformers/openai/chat/stream.ts b/src/server/transformers/openai/chat/stream.ts index 3d34bb95..58744f6a 100644 --- a/src/server/transformers/openai/chat/stream.ts +++ b/src/server/transformers/openai/chat/stream.ts @@ -36,23 +36,35 @@ export const openAiChatStream = { return createStreamTransformContext(modelName); }, normalizeEvent(payload: unknown, context: StreamTransformContext, modelName: string): OpenAiChatNormalizedStreamEvent { + const normalized = normalizeUpstreamStreamEvent(payload, context, modelName); const choiceEvents = extractChatChoiceEvents(payload); const primaryChoice = choiceEvents[0]; + const normalizedChoiceEvents = choiceEvents.map((choiceEvent, index) => ( + index === 0 + ? { + ...choiceEvent, + role: normalized.role !== undefined ? normalized.role : choiceEvent.role, + contentDelta: normalized.contentDelta, + reasoningDelta: normalized.reasoningDelta, + toolCallDeltas: normalized.toolCallDeltas !== undefined + ? normalized.toolCallDeltas + : choiceEvent.toolCallDeltas, + finishReason: normalized.finishReason !== undefined + ? normalized.finishReason + : choiceEvent.finishReason, + } + : choiceEvent + )); return { - ...normalizeUpstreamStreamEvent(payload, context, modelName), + ...normalized, ...(primaryChoice ? { choiceIndex: primaryChoice.index, - role: primaryChoice.role, - contentDelta: primaryChoice.contentDelta, - reasoningDelta: primaryChoice.reasoningDelta, - toolCallDeltas: primaryChoice.toolCallDeltas, - finishReason: primaryChoice.finishReason, annotations: primaryChoice.annotations, citations: primaryChoice.citations, } : {}), - ...(choiceEvents.length > 0 ? { choiceEvents } : {}), + ...(normalizedChoiceEvents.length > 0 ? { choiceEvents: normalizedChoiceEvents } : {}), ...extractChatResponseExtras(payload), }; }, diff --git a/src/server/transformers/openai/responses/aggregator.test.ts b/src/server/transformers/openai/responses/aggregator.test.ts index 9a0d4d61..63470408 100644 --- a/src/server/transformers/openai/responses/aggregator.test.ts +++ b/src/server/transformers/openai/responses/aggregator.test.ts @@ -2,7 +2,9 @@ import { describe, expect, it } from 'vitest'; import { createStreamTransformContext } from '../../shared/normalized.js'; import { + completeResponsesStream, createOpenAiResponsesAggregateState, + failResponsesStream, serializeConvertedResponsesEvents, } from './aggregator.js'; @@ -23,6 +25,35 @@ function parseSsePayloads(lines: string[]): Array> { .filter((item): item is Record => !!item); } +function parseSseEvents(lines: string[]): Array<{ event: string | null; payload: Record | '[DONE]' }> { + return lines + .flatMap((line) => line.split('\n\n').filter((block) => block.trim().length > 0)) + .map((block) => { + const eventLine = block + .split('\n') + .find((line) => line.startsWith('event: ')); + const dataLine = block + .split('\n') + .find((line) => line.startsWith('data: ')); + if (!dataLine) return null; + if (dataLine === 'data: [DONE]') { + return { + event: eventLine ? eventLine.slice('event: '.length) : null, + payload: '[DONE]' as const, + }; + } + try { + return { + event: eventLine ? eventLine.slice('event: '.length) : null, + payload: JSON.parse(dataLine.slice('data: '.length)) as Record, + }; + } catch { + return null; + } + }) + .filter((item): item is { event: string | null; payload: Record | '[DONE]' } => !!item); +} + describe('serializeConvertedResponsesEvents', () => { it('aggregates reasoning summary events into the completed response payload', () => { const state = createOpenAiResponsesAggregateState('gpt-5'); @@ -233,6 +264,246 @@ describe('serializeConvertedResponsesEvents', () => { ]); }); + it('emits response.output_item.added before synthetic reasoning summary events for delta-only reasoning streams', () => { + const state = createOpenAiResponsesAggregateState('gpt-5'); + const streamContext = createStreamTransformContext('gpt-5'); + const usage = { + promptTokens: 1, + completionTokens: 2, + totalTokens: 3, + }; + + const lines = serializeConvertedResponsesEvents({ + state, + streamContext, + usage, + event: { + reasoningDelta: 'Think ', + } as any, + }); + + const events = parseSseEvents(lines); + + expect(events.map((entry) => entry.event)).toEqual([ + 'response.output_item.added', + 'response.reasoning_summary_part.added', + 'response.reasoning_summary_text.delta', + ]); + expect(events[0]?.payload).toMatchObject({ + type: 'response.output_item.added', + output_index: 0, + item: { + id: 'rs_0', + type: 'reasoning', + status: 'in_progress', + summary: [], + }, + }); + expect(events[1]?.payload).toMatchObject({ + type: 'response.reasoning_summary_part.added', + item_id: 'rs_0', + output_index: 0, + summary_index: 0, + part: { + type: 'summary_text', + text: '', + }, + }); + expect(events[2]?.payload).toMatchObject({ + type: 'response.reasoning_summary_text.delta', + item_id: 'rs_0', + output_index: 0, + summary_index: 0, + delta: 'Think ', + }); + }); + + it('keeps synthetic reasoning and message output items separate when reasoning arrives before content', () => { + const state = createOpenAiResponsesAggregateState('gpt-5'); + const streamContext = createStreamTransformContext('gpt-5'); + const usage = { + promptTokens: 1, + completionTokens: 2, + totalTokens: 3, + }; + + serializeConvertedResponsesEvents({ + state, + streamContext, + usage, + event: { + reasoningDelta: 'plan first', + } as any, + }); + + const contentLines = serializeConvertedResponsesEvents({ + state, + streamContext, + usage, + event: { + contentDelta: 'final answer', + } as any, + }); + + const contentEvents = parseSseEvents(contentLines); + expect(contentEvents.map((entry) => entry.event)).toEqual([ + 'response.output_item.added', + 'response.content_part.added', + 'response.output_text.delta', + ]); + expect(contentEvents[0]?.payload).toMatchObject({ + type: 'response.output_item.added', + output_index: 1, + item: { + id: 'msg_1', + type: 'message', + content: [], + }, + }); + + const completedLines = completeResponsesStream(state, streamContext, usage); + const payloads = parseSsePayloads(completedLines); + const completed = payloads.find((item) => item.type === 'response.completed'); + expect(completed?.response?.output).toEqual([ + { + id: 'rs_0', + type: 'reasoning', + status: 'completed', + summary: [ + { + type: 'summary_text', + text: 'plan first', + }, + ], + }, + { + id: 'msg_1', + type: 'message', + role: 'assistant', + status: 'completed', + content: [ + { + type: 'output_text', + text: 'final answer', + }, + ], + }, + ]); + expect(completed?.response?.output_text).toBe('final answer'); + }); + + it('keeps synthetic message and function call items separate when one event contains both', () => { + const state = createOpenAiResponsesAggregateState('gpt-5'); + const streamContext = createStreamTransformContext('gpt-5'); + const usage = { + promptTokens: 1, + completionTokens: 2, + totalTokens: 3, + }; + + const lines = serializeConvertedResponsesEvents({ + state, + streamContext, + usage, + event: { + contentDelta: 'hello', + toolCallDeltas: [{ + index: 0, + id: 'call_1', + name: 'Glob', + argumentsDelta: '{}', + }], + } as any, + }); + + const events = parseSseEvents(lines); + expect(events.map((entry) => entry.event)).toEqual([ + 'response.output_item.added', + 'response.content_part.added', + 'response.output_text.delta', + 'response.output_item.added', + 'response.function_call_arguments.delta', + ]); + expect(events[3]?.payload).toMatchObject({ + type: 'response.output_item.added', + output_index: 1, + item: { + id: 'call_1', + type: 'function_call', + call_id: 'call_1', + name: 'Glob', + arguments: '', + }, + }); + + const completedLines = completeResponsesStream(state, streamContext, usage); + const payloads = parseSsePayloads(completedLines); + const completed = payloads.find((item) => item.type === 'response.completed'); + expect(completed?.response?.output).toEqual([ + { + id: 'msg_0', + type: 'message', + role: 'assistant', + status: 'completed', + content: [ + { + type: 'output_text', + text: 'hello', + }, + ], + }, + { + id: 'call_1', + type: 'function_call', + status: 'completed', + call_id: 'call_1', + name: 'Glob', + arguments: '{}', + }, + ]); + }); + + it('backfills missing parent events before sparse native function call argument deltas', () => { + const state = createOpenAiResponsesAggregateState('gpt-5'); + const streamContext = createStreamTransformContext('gpt-5'); + const usage = { + promptTokens: 1, + completionTokens: 2, + totalTokens: 3, + }; + + const lines = serializeConvertedResponsesEvents({ + state, + streamContext, + usage, + event: { + responsesEventType: 'response.function_call_arguments.delta', + responsesPayload: { + type: 'response.function_call_arguments.delta', + output_index: 0, + call_id: 'call_sparse', + name: 'lookup', + delta: '{"q":"x"}', + }, + }, + }); + + const events = parseSseEvents(lines); + expect(events.map((entry) => entry.event)).toEqual([ + 'response.output_item.added', + 'response.function_call_arguments.delta', + ]); + expect(events[0]?.payload).toMatchObject({ + type: 'response.output_item.added', + output_index: 0, + item: { + type: 'function_call', + call_id: 'call_sparse', + name: 'lookup', + }, + }); + }); + it('preserves richer image_generation_call fields while aggregating progress events', () => { const state = createOpenAiResponsesAggregateState('gpt-5'); const streamContext = createStreamTransformContext('gpt-5'); @@ -301,4 +572,1529 @@ describe('serializeConvertedResponsesEvents', () => { ], }); }); + + it('emits response.output_item.done after native image generation completion', () => { + const state = createOpenAiResponsesAggregateState('gpt-5'); + const streamContext = createStreamTransformContext('gpt-5'); + const usage = { + promptTokens: 1, + completionTokens: 2, + totalTokens: 3, + }; + + serializeConvertedResponsesEvents({ + state, + streamContext, + usage, + event: { + responsesEventType: 'response.image_generation_call.completed', + responsesPayload: { + type: 'response.image_generation_call.completed', + item_id: 'img_done_1', + output_index: 0, + result: 'image-final', + }, + }, + }); + + const lines = serializeConvertedResponsesEvents({ + state, + streamContext, + usage, + event: { + responsesEventType: 'response.completed', + responsesPayload: { + type: 'response.completed', + response: { + id: 'resp_image_done', + model: 'gpt-5', + usage: { + input_tokens: 1, + output_tokens: 2, + total_tokens: 3, + }, + }, + }, + }, + }); + + const events = parseSseEvents(lines); + expect(events.map((entry) => entry.event)).toEqual([ + 'response.output_item.done', + 'response.completed', + ]); + }); + + it('backfills native parent lifecycle events before forwarding sparse function call argument deltas', () => { + const state = createOpenAiResponsesAggregateState('gpt-5'); + const streamContext = createStreamTransformContext('gpt-5'); + const usage = { + promptTokens: 1, + completionTokens: 2, + totalTokens: 3, + }; + + const lines = serializeConvertedResponsesEvents({ + state, + streamContext, + usage, + event: { + responsesEventType: 'response.function_call_arguments.delta', + responsesPayload: { + type: 'response.function_call_arguments.delta', + output_index: 0, + call_id: 'call_sparse_1', + name: 'Glob', + delta: '{}', + }, + }, + }); + + const events = parseSseEvents(lines); + expect(events.map((entry) => entry.event)).toEqual([ + 'response.output_item.added', + 'response.function_call_arguments.delta', + ]); + expect(events[0]?.payload).toMatchObject({ + type: 'response.output_item.added', + output_index: 0, + item: { + id: 'call_sparse_1', + type: 'function_call', + call_id: 'call_sparse_1', + name: 'Glob', + arguments: '', + }, + }); + }); + + it('backfills native content part openers before forwarding sparse output_text.done events', () => { + const state = createOpenAiResponsesAggregateState('gpt-5'); + const streamContext = createStreamTransformContext('gpt-5'); + const usage = { + promptTokens: 1, + completionTokens: 2, + totalTokens: 3, + }; + + const lines = serializeConvertedResponsesEvents({ + state, + streamContext, + usage, + event: { + responsesEventType: 'response.output_text.done', + responsesPayload: { + type: 'response.output_text.done', + output_index: 0, + item_id: 'msg_sparse_1', + text: 'hello world', + }, + }, + }); + + const events = parseSseEvents(lines); + expect(events.map((entry) => entry.event)).toEqual([ + 'response.output_item.added', + 'response.content_part.added', + 'response.output_text.done', + ]); + expect(events[0]?.payload).toMatchObject({ + type: 'response.output_item.added', + output_index: 0, + item: { + id: 'msg_sparse_1', + type: 'message', + }, + }); + expect(events[1]?.payload).toMatchObject({ + type: 'response.content_part.added', + output_index: 0, + item_id: 'msg_sparse_1', + content_index: 0, + part: { + type: 'output_text', + text: '', + }, + }); + expect(events[2]?.payload).toMatchObject({ + type: 'response.output_text.done', + output_index: 0, + item_id: 'msg_sparse_1', + text: 'hello world', + }); + }); + + it('preserves native message item ids when sparse content parts open the parent item', () => { + const state = createOpenAiResponsesAggregateState('gpt-5'); + const streamContext = createStreamTransformContext('gpt-5'); + const usage = { + promptTokens: 1, + completionTokens: 2, + totalTokens: 3, + }; + + const lines = serializeConvertedResponsesEvents({ + state, + streamContext, + usage, + event: { + responsesEventType: 'response.content_part.added', + responsesPayload: { + type: 'response.content_part.added', + output_index: 0, + item_id: 'msg_sparse_content_1', + content_index: 0, + part: { + type: 'output_text', + text: '', + }, + }, + }, + }); + + const events = parseSseEvents(lines); + expect(events.map((entry) => entry.event)).toEqual([ + 'response.output_item.added', + 'response.content_part.added', + ]); + expect(events[0]?.payload).toMatchObject({ + type: 'response.output_item.added', + output_index: 0, + item: { + id: 'msg_sparse_content_1', + type: 'message', + }, + }); + expect(events[1]?.payload).toMatchObject({ + type: 'response.content_part.added', + output_index: 0, + item_id: 'msg_sparse_content_1', + content_index: 0, + part: { + type: 'output_text', + text: '', + }, + }); + }); + + it('emits canonical message done events before response.completed when recovering a sparse text stream', () => { + const state = createOpenAiResponsesAggregateState('gpt-5'); + const streamContext = createStreamTransformContext('gpt-5'); + const usage = { + promptTokens: 2, + completionTokens: 4, + totalTokens: 6, + }; + + serializeConvertedResponsesEvents({ + state, + streamContext, + usage, + event: { + contentDelta: 'hello world', + } as any, + }); + + const completionLines = completeResponsesStream(state, streamContext, usage); + const events = parseSseEvents(completionLines); + + expect(events.map((entry) => entry.event ?? (entry.payload === '[DONE]' ? '[DONE]' : 'data'))).toEqual([ + 'response.output_text.done', + 'response.content_part.done', + 'response.output_item.done', + 'response.completed', + '[DONE]', + ]); + + expect(events[0]?.payload).toMatchObject({ + type: 'response.output_text.done', + output_index: 0, + item_id: 'msg_0', + text: 'hello world', + }); + expect(events[1]?.payload).toMatchObject({ + type: 'response.content_part.done', + output_index: 0, + item_id: 'msg_0', + content_index: 0, + part: { + type: 'output_text', + text: 'hello world', + }, + }); + expect(events[2]?.payload).toMatchObject({ + type: 'response.output_item.done', + output_index: 0, + item: { + id: 'msg_0', + type: 'message', + status: 'completed', + }, + }); + }); + + it('emits terminal done events before relaying an upstream response.completed event', () => { + const state = createOpenAiResponsesAggregateState('gpt-5'); + const streamContext = createStreamTransformContext('gpt-5'); + const usage = { + promptTokens: 2, + completionTokens: 4, + totalTokens: 6, + }; + + serializeConvertedResponsesEvents({ + state, + streamContext, + usage, + event: { + contentDelta: 'hello world', + } as any, + }); + + const completionLines = serializeConvertedResponsesEvents({ + state, + streamContext, + usage, + event: { + responsesEventType: 'response.completed', + responsesPayload: { + type: 'response.completed', + response: { + id: 'resp_done_1', + model: 'gpt-5', + usage: { + input_tokens: 2, + output_tokens: 4, + total_tokens: 6, + }, + }, + }, + }, + }); + + const events = parseSseEvents(completionLines); + expect(events.map((entry) => entry.event ?? 'data')).toEqual([ + 'response.output_text.done', + 'response.content_part.done', + 'response.output_item.done', + 'response.completed', + ]); + }); + + it('emits canonical reasoning done events before response.completed when recovering sparse reasoning output', () => { + const state = createOpenAiResponsesAggregateState('gpt-5'); + const streamContext = createStreamTransformContext('gpt-5'); + const usage = { + promptTokens: 2, + completionTokens: 4, + totalTokens: 6, + }; + + serializeConvertedResponsesEvents({ + state, + streamContext, + usage, + event: { + reasoningSignature: 'enc-signature', + reasoningDelta: 'plan first', + } as any, + }); + + const completionLines = completeResponsesStream(state, streamContext, usage); + const events = parseSseEvents(completionLines); + + expect(events.map((entry) => entry.event ?? (entry.payload === '[DONE]' ? '[DONE]' : 'data'))).toEqual([ + 'response.reasoning_summary_text.done', + 'response.reasoning_summary_part.done', + 'response.output_item.done', + 'response.completed', + '[DONE]', + ]); + + expect(events[0]?.payload).toMatchObject({ + type: 'response.reasoning_summary_text.done', + item_id: 'rs_0', + output_index: 0, + summary_index: 0, + text: 'plan first', + }); + expect(events[1]?.payload).toMatchObject({ + type: 'response.reasoning_summary_part.done', + item_id: 'rs_0', + output_index: 0, + summary_index: 0, + part: { + type: 'summary_text', + text: 'plan first', + }, + }); + expect(events[2]?.payload).toMatchObject({ + type: 'response.output_item.done', + output_index: 0, + item: { + id: 'rs_0', + type: 'reasoning', + status: 'completed', + encrypted_content: 'enc-signature', + }, + }); + }); + + it('emits canonical message done events before response.failed when failing a sparse text stream', () => { + const state = createOpenAiResponsesAggregateState('gpt-5'); + const streamContext = createStreamTransformContext('gpt-5'); + const usage = { + promptTokens: 2, + completionTokens: 4, + totalTokens: 6, + }; + + serializeConvertedResponsesEvents({ + state, + streamContext, + usage, + event: { + contentDelta: 'partial answer', + } as any, + }); + + const failedLines = failResponsesStream(state, streamContext, usage, { + error: { + message: 'upstream stream failed', + }, + }); + const events = parseSseEvents(failedLines); + + expect(events.map((entry) => entry.event ?? (entry.payload === '[DONE]' ? '[DONE]' : 'data'))).toEqual([ + 'response.output_text.done', + 'response.content_part.done', + 'response.output_item.done', + 'response.failed', + '[DONE]', + ]); + + expect(events[2]?.payload).toMatchObject({ + type: 'response.output_item.done', + output_index: 0, + item: { + id: 'msg_0', + type: 'message', + status: 'failed', + }, + }); + expect(events[3]?.payload).toMatchObject({ + type: 'response.failed', + response: { + status: 'failed', + output_text: 'partial answer', + }, + error: { + message: 'upstream stream failed', + type: 'upstream_error', + }, + }); + }); + + it('does not synthesize duplicate message done events after original text completion already arrived', () => { + const state = createOpenAiResponsesAggregateState('gpt-5'); + const streamContext = createStreamTransformContext('gpt-5'); + const usage = { + promptTokens: 2, + completionTokens: 4, + totalTokens: 6, + }; + + serializeConvertedResponsesEvents({ + state, + streamContext, + usage, + event: { + responsesEventType: 'response.output_item.added', + responsesPayload: { + type: 'response.output_item.added', + output_index: 0, + item: { + id: 'msg_1', + type: 'message', + role: 'assistant', + status: 'in_progress', + content: [], + }, + }, + }, + }); + + serializeConvertedResponsesEvents({ + state, + streamContext, + usage, + event: { + responsesEventType: 'response.content_part.added', + responsesPayload: { + type: 'response.content_part.added', + output_index: 0, + item_id: 'msg_1', + content_index: 0, + part: { + type: 'output_text', + text: '', + }, + }, + }, + }); + + serializeConvertedResponsesEvents({ + state, + streamContext, + usage, + event: { + responsesEventType: 'response.output_text.delta', + responsesPayload: { + type: 'response.output_text.delta', + output_index: 0, + item_id: 'msg_1', + delta: 'hello ', + }, + }, + }); + + serializeConvertedResponsesEvents({ + state, + streamContext, + usage, + event: { + responsesEventType: 'response.output_text.done', + responsesPayload: { + type: 'response.output_text.done', + output_index: 0, + item_id: 'msg_1', + text: 'hello world', + }, + }, + }); + + serializeConvertedResponsesEvents({ + state, + streamContext, + usage, + event: { + responsesEventType: 'response.content_part.done', + responsesPayload: { + type: 'response.content_part.done', + output_index: 0, + item_id: 'msg_1', + content_index: 0, + part: { + type: 'output_text', + text: 'hello world', + }, + }, + }, + }); + + const completionLines = completeResponsesStream(state, streamContext, usage); + const events = parseSseEvents(completionLines); + + expect(events.map((entry) => entry.event ?? (entry.payload === '[DONE]' ? '[DONE]' : 'data'))).toEqual([ + 'response.output_item.done', + 'response.completed', + '[DONE]', + ]); + + expect(events[0]?.payload).toMatchObject({ + type: 'response.output_item.done', + output_index: 0, + item: { + id: 'msg_1', + type: 'message', + status: 'completed', + }, + }); + expect(events[1]?.payload).toMatchObject({ + type: 'response.completed', + response: { + status: 'completed', + output_text: 'hello world', + }, + }); + }); + + it('backfills missing native content_part.done even after output_item.done already marks the message completed', () => { + const state = createOpenAiResponsesAggregateState('gpt-5'); + const streamContext = createStreamTransformContext('gpt-5'); + const usage = { + promptTokens: 2, + completionTokens: 4, + totalTokens: 6, + }; + + serializeConvertedResponsesEvents({ + state, + streamContext, + usage, + event: { + responsesEventType: 'response.content_part.added', + responsesPayload: { + type: 'response.content_part.added', + output_index: 0, + item_id: 'msg_native_done_1', + content_index: 0, + part: { + type: 'output_text', + text: '', + }, + }, + }, + }); + + serializeConvertedResponsesEvents({ + state, + streamContext, + usage, + event: { + responsesEventType: 'response.output_text.done', + responsesPayload: { + type: 'response.output_text.done', + output_index: 0, + item_id: 'msg_native_done_1', + text: 'hello world', + }, + }, + }); + + serializeConvertedResponsesEvents({ + state, + streamContext, + usage, + event: { + responsesEventType: 'response.output_item.done', + responsesPayload: { + type: 'response.output_item.done', + output_index: 0, + item: { + id: 'msg_native_done_1', + type: 'message', + status: 'completed', + }, + }, + }, + }); + + const lines = serializeConvertedResponsesEvents({ + state, + streamContext, + usage, + event: { + responsesEventType: 'response.completed', + responsesPayload: { + type: 'response.completed', + response: { + id: 'resp_native_done_1', + model: 'gpt-5', + usage: { + input_tokens: 2, + output_tokens: 4, + total_tokens: 6, + }, + }, + }, + }, + }); + + const events = parseSseEvents(lines); + expect(events.map((entry) => entry.event)).toEqual([ + 'response.completed', + ]); + }); + + it('does not synthesize duplicate reasoning done events after original reasoning completion already arrived', () => { + const state = createOpenAiResponsesAggregateState('gpt-5'); + const streamContext = createStreamTransformContext('gpt-5'); + const usage = { + promptTokens: 2, + completionTokens: 4, + totalTokens: 6, + }; + + serializeConvertedResponsesEvents({ + state, + streamContext, + usage, + event: { + responsesEventType: 'response.output_item.added', + responsesPayload: { + type: 'response.output_item.added', + output_index: 0, + item: { + id: 'rs_1', + type: 'reasoning', + status: 'in_progress', + summary: [], + }, + }, + }, + }); + + serializeConvertedResponsesEvents({ + state, + streamContext, + usage, + event: { + responsesEventType: 'response.reasoning_summary_part.added', + responsesPayload: { + type: 'response.reasoning_summary_part.added', + item_id: 'rs_1', + output_index: 0, + summary_index: 0, + part: { + type: 'summary_text', + text: '', + }, + }, + }, + }); + + serializeConvertedResponsesEvents({ + state, + streamContext, + usage, + event: { + responsesEventType: 'response.reasoning_summary_text.delta', + responsesPayload: { + type: 'response.reasoning_summary_text.delta', + item_id: 'rs_1', + output_index: 0, + summary_index: 0, + delta: 'plan ', + }, + }, + }); + + serializeConvertedResponsesEvents({ + state, + streamContext, + usage, + event: { + responsesEventType: 'response.reasoning_summary_text.done', + responsesPayload: { + type: 'response.reasoning_summary_text.done', + item_id: 'rs_1', + output_index: 0, + summary_index: 0, + text: 'plan first', + }, + }, + }); + + serializeConvertedResponsesEvents({ + state, + streamContext, + usage, + event: { + responsesEventType: 'response.reasoning_summary_part.done', + responsesPayload: { + type: 'response.reasoning_summary_part.done', + item_id: 'rs_1', + output_index: 0, + summary_index: 0, + part: { + type: 'summary_text', + text: 'plan first', + }, + }, + }, + }); + + const completionLines = completeResponsesStream(state, streamContext, usage); + const events = parseSseEvents(completionLines); + + expect(events.map((entry) => entry.event ?? (entry.payload === '[DONE]' ? '[DONE]' : 'data'))).toEqual([ + 'response.output_item.done', + 'response.completed', + '[DONE]', + ]); + + expect(events[0]?.payload).toMatchObject({ + type: 'response.output_item.done', + output_index: 0, + item: { + id: 'rs_1', + type: 'reasoning', + status: 'completed', + }, + }); + expect(events[1]?.payload).toMatchObject({ + type: 'response.completed', + response: { + status: 'completed', + output: [ + { + id: 'rs_1', + type: 'reasoning', + status: 'completed', + summary: [ + { + type: 'summary_text', + text: 'plan first', + }, + ], + }, + ], + }, + }); + }); + + it('emits terminal done events before forwarding an upstream response.completed event', () => { + const state = createOpenAiResponsesAggregateState('gpt-5'); + const streamContext = createStreamTransformContext('gpt-5'); + const usage = { + promptTokens: 2, + completionTokens: 4, + totalTokens: 6, + }; + + serializeConvertedResponsesEvents({ + state, + streamContext, + usage, + event: { + contentDelta: 'hello world', + } as any, + }); + + const lines = serializeConvertedResponsesEvents({ + state, + streamContext, + usage, + event: { + responsesEventType: 'response.completed', + responsesPayload: { + type: 'response.completed', + response: { + id: 'resp_1', + model: 'gpt-5', + usage: { + input_tokens: 2, + output_tokens: 4, + total_tokens: 6, + }, + }, + }, + }, + }); + + const events = parseSseEvents(lines); + expect(events.map((entry) => entry.event)).toEqual([ + 'response.output_text.done', + 'response.content_part.done', + 'response.output_item.done', + 'response.completed', + ]); + }); + + it('closes reasoning summary parts before forwarding an upstream response.completed event', () => { + const state = createOpenAiResponsesAggregateState('gpt-5'); + const streamContext = createStreamTransformContext('gpt-5'); + const usage = { + promptTokens: 1, + completionTokens: 2, + totalTokens: 3, + }; + + serializeConvertedResponsesEvents({ + state, + streamContext, + usage, + event: { + reasoningDelta: 'Think ', + } as any, + }); + + const lines = serializeConvertedResponsesEvents({ + state, + streamContext, + usage, + event: { + responsesEventType: 'response.completed', + responsesPayload: { + type: 'response.completed', + response: { + id: 'resp_reasoning_done', + model: 'gpt-5', + usage: { + input_tokens: 1, + output_tokens: 2, + total_tokens: 3, + }, + }, + }, + }, + }); + + const events = parseSseEvents(lines); + expect(events.map((entry) => entry.event)).toEqual([ + 'response.reasoning_summary_text.done', + 'response.reasoning_summary_part.done', + 'response.output_item.done', + 'response.completed', + ]); + }); + + it('preserves terminal response output from sparse response.incomplete payloads', () => { + const state = createOpenAiResponsesAggregateState('gpt-5'); + const streamContext = createStreamTransformContext('gpt-5'); + const usage = { + promptTokens: 1, + completionTokens: 2, + totalTokens: 3, + }; + + const lines = serializeConvertedResponsesEvents({ + state, + streamContext, + usage, + event: { + responsesEventType: 'response.incomplete', + responsesPayload: { + type: 'response.incomplete', + response: { + id: 'resp_sparse_incomplete', + model: 'gpt-5', + status: 'incomplete', + output: [ + { + id: 'msg_sparse_incomplete', + type: 'message', + role: 'assistant', + status: 'incomplete', + content: [ + { + type: 'output_text', + text: 'partial answer', + }, + ], + }, + ], + usage: { + input_tokens: 1, + output_tokens: 2, + total_tokens: 3, + }, + }, + }, + }, + }); + + const events = parseSseEvents(lines); + expect(events.at(-1)?.payload).toMatchObject({ + type: 'response.incomplete', + response: { + id: 'resp_sparse_incomplete', + status: 'incomplete', + output_text: 'partial answer', + output: [ + { + id: 'msg_sparse_incomplete', + type: 'message', + role: 'assistant', + status: 'incomplete', + content: [ + { + type: 'output_text', + text: 'partial answer', + }, + ], + }, + ], + }, + }); + }); + + it('preserves response.incomplete as a first-class terminal event', () => { + const state = createOpenAiResponsesAggregateState('gpt-5'); + const streamContext = createStreamTransformContext('gpt-5'); + const usage = { + promptTokens: 2, + completionTokens: 4, + totalTokens: 6, + }; + + serializeConvertedResponsesEvents({ + state, + streamContext, + usage, + event: { + contentDelta: 'partial answer', + } as any, + }); + + const lines = serializeConvertedResponsesEvents({ + state, + streamContext, + usage, + event: { + responsesEventType: 'response.incomplete', + responsesPayload: { + type: 'response.incomplete', + response: { + id: 'resp_incomplete_1', + model: 'gpt-5', + status: 'incomplete', + incomplete_details: { + reason: 'max_output_tokens', + }, + usage: { + input_tokens: 2, + output_tokens: 4, + total_tokens: 6, + }, + }, + }, + }, + }); + + const events = parseSseEvents(lines); + expect(events.map((entry) => entry.event)).toEqual([ + 'response.output_text.done', + 'response.content_part.done', + 'response.output_item.done', + 'response.incomplete', + ]); + expect(events[3]?.payload).toMatchObject({ + type: 'response.incomplete', + response: { + id: 'resp_incomplete_1', + status: 'incomplete', + incomplete_details: { + reason: 'max_output_tokens', + }, + output_text: 'partial answer', + }, + }); + }); + + it('closes every unterminated message part and only emits output_text.done for text parts', () => { + const state = createOpenAiResponsesAggregateState('gpt-5'); + const streamContext = createStreamTransformContext('gpt-5'); + const usage = { + promptTokens: 1, + completionTokens: 2, + totalTokens: 3, + }; + + serializeConvertedResponsesEvents({ + state, + streamContext, + usage, + event: { + responsesEventType: 'response.output_item.added', + responsesPayload: { + type: 'response.output_item.added', + output_index: 0, + item: { + id: 'msg_multi_1', + type: 'message', + role: 'assistant', + status: 'in_progress', + content: [], + }, + }, + }, + }); + + serializeConvertedResponsesEvents({ + state, + streamContext, + usage, + event: { + responsesEventType: 'response.content_part.added', + responsesPayload: { + type: 'response.content_part.added', + output_index: 0, + item_id: 'msg_multi_1', + content_index: 0, + part: { + type: 'refusal', + text: 'no', + }, + }, + }, + }); + + serializeConvertedResponsesEvents({ + state, + streamContext, + usage, + event: { + responsesEventType: 'response.content_part.added', + responsesPayload: { + type: 'response.content_part.added', + output_index: 0, + item_id: 'msg_multi_1', + content_index: 1, + part: { + type: 'output_text', + text: 'yes', + }, + }, + }, + }); + + const lines = completeResponsesStream(state, streamContext, usage); + const events = parseSseEvents(lines); + + expect(events.map((entry) => entry.event ?? (entry.payload === '[DONE]' ? '[DONE]' : 'data'))).toEqual([ + 'response.content_part.done', + 'response.output_text.done', + 'response.content_part.done', + 'response.output_item.done', + 'response.completed', + '[DONE]', + ]); + expect(events[0]?.payload).toMatchObject({ + type: 'response.content_part.done', + content_index: 0, + part: { + type: 'refusal', + text: 'no', + }, + }); + expect(events[1]?.payload).toMatchObject({ + type: 'response.output_text.done', + item_id: 'msg_multi_1', + text: 'yes', + }); + expect(events[2]?.payload).toMatchObject({ + type: 'response.content_part.done', + content_index: 1, + part: { + type: 'output_text', + text: 'yes', + }, + }); + }); + + it('closes every unterminated reasoning summary part before completion', () => { + const state = createOpenAiResponsesAggregateState('gpt-5'); + const streamContext = createStreamTransformContext('gpt-5'); + const usage = { + promptTokens: 1, + completionTokens: 2, + totalTokens: 3, + }; + + serializeConvertedResponsesEvents({ + state, + streamContext, + usage, + event: { + responsesEventType: 'response.output_item.added', + responsesPayload: { + type: 'response.output_item.added', + output_index: 0, + item: { + id: 'rs_multi_1', + type: 'reasoning', + status: 'in_progress', + summary: [], + }, + }, + }, + }); + + for (const [summaryIndex, text] of [[0, 'first'], [1, 'second']] as const) { + serializeConvertedResponsesEvents({ + state, + streamContext, + usage, + event: { + responsesEventType: 'response.reasoning_summary_part.added', + responsesPayload: { + type: 'response.reasoning_summary_part.added', + item_id: 'rs_multi_1', + output_index: 0, + summary_index: summaryIndex, + part: { + type: 'summary_text', + text, + }, + }, + }, + }); + } + + const lines = completeResponsesStream(state, streamContext, usage); + const events = parseSseEvents(lines); + + expect(events.map((entry) => entry.event ?? (entry.payload === '[DONE]' ? '[DONE]' : 'data'))).toEqual([ + 'response.reasoning_summary_text.done', + 'response.reasoning_summary_part.done', + 'response.reasoning_summary_text.done', + 'response.reasoning_summary_part.done', + 'response.output_item.done', + 'response.completed', + '[DONE]', + ]); + expect(events[0]?.payload).toMatchObject({ + type: 'response.reasoning_summary_text.done', + summary_index: 0, + text: 'first', + }); + expect(events[2]?.payload).toMatchObject({ + type: 'response.reasoning_summary_text.done', + summary_index: 1, + text: 'second', + }); + }); + + it('emits a generic output_item.done for native image generation items that completed earlier', () => { + const state = createOpenAiResponsesAggregateState('gpt-5'); + const streamContext = createStreamTransformContext('gpt-5'); + const usage = { + promptTokens: 1, + completionTokens: 2, + totalTokens: 3, + }; + + serializeConvertedResponsesEvents({ + state, + streamContext, + usage, + event: { + responsesEventType: 'response.image_generation_call.completed', + responsesPayload: { + type: 'response.image_generation_call.completed', + item_id: 'img_terminal_1', + output_index: 0, + result: 'final-image', + }, + }, + }); + + const lines = serializeConvertedResponsesEvents({ + state, + streamContext, + usage, + event: { + responsesEventType: 'response.completed', + responsesPayload: { + type: 'response.completed', + response: { + id: 'resp_image_done_1', + model: 'gpt-5', + usage: { + input_tokens: 1, + output_tokens: 2, + total_tokens: 3, + }, + }, + }, + }, + }); + + const events = parseSseEvents(lines); + expect(events.map((entry) => entry.event)).toEqual([ + 'response.output_item.done', + 'response.completed', + ]); + expect(events[0]?.payload).toMatchObject({ + type: 'response.output_item.done', + output_index: 0, + item: { + id: 'img_terminal_1', + type: 'image_generation_call', + status: 'completed', + result: 'final-image', + }, + }); + }); + + it('does not synthesize duplicate tool input done events after original tool completion already arrived', () => { + const state = createOpenAiResponsesAggregateState('gpt-5'); + const streamContext = createStreamTransformContext('gpt-5'); + const usage = { + promptTokens: 1, + completionTokens: 2, + totalTokens: 3, + }; + + serializeConvertedResponsesEvents({ + state, + streamContext, + usage, + event: { + responsesEventType: 'response.output_item.added', + responsesPayload: { + type: 'response.output_item.added', + output_index: 0, + item: { + id: 'fc_done_1', + type: 'function_call', + status: 'in_progress', + call_id: 'fc_done_1', + name: 'browser', + arguments: '', + }, + }, + }, + }); + + serializeConvertedResponsesEvents({ + state, + streamContext, + usage, + event: { + responsesEventType: 'response.function_call_arguments.done', + responsesPayload: { + type: 'response.function_call_arguments.done', + item_id: 'fc_done_1', + call_id: 'fc_done_1', + output_index: 0, + name: 'browser', + arguments: '{"url":"https://example.com"}', + }, + }, + }); + + serializeConvertedResponsesEvents({ + state, + streamContext, + usage, + event: { + responsesEventType: 'response.output_item.added', + responsesPayload: { + type: 'response.output_item.added', + output_index: 1, + item: { + id: 'ct_done_1', + type: 'custom_tool_call', + status: 'in_progress', + call_id: 'ct_done_1', + name: 'browser', + input: '', + }, + }, + }, + }); + + serializeConvertedResponsesEvents({ + state, + streamContext, + usage, + event: { + responsesEventType: 'response.custom_tool_call_input.done', + responsesPayload: { + type: 'response.custom_tool_call_input.done', + item_id: 'ct_done_1', + call_id: 'ct_done_1', + output_index: 1, + name: 'browser', + input: 'open example.com', + }, + }, + }); + + const lines = completeResponsesStream(state, streamContext, usage); + const events = parseSseEvents(lines); + + expect(events.map((entry) => entry.event ?? (entry.payload === '[DONE]' ? '[DONE]' : 'data'))).toEqual([ + 'response.output_item.done', + 'response.output_item.done', + 'response.completed', + '[DONE]', + ]); + }); + + it('keeps synthetic reasoning and message items separate when reasoning arrives before visible text', () => { + const state = createOpenAiResponsesAggregateState('gpt-5'); + const streamContext = createStreamTransformContext('gpt-5'); + const usage = { + promptTokens: 1, + completionTokens: 2, + totalTokens: 3, + }; + + serializeConvertedResponsesEvents({ + state, + streamContext, + usage, + event: { + reasoningDelta: 'think first', + } as any, + }); + + const messageLines = serializeConvertedResponsesEvents({ + state, + streamContext, + usage, + event: { + contentDelta: 'visible answer', + } as any, + }); + + const messageEvents = parseSseEvents(messageLines); + expect(messageEvents[0]?.payload).toMatchObject({ + type: 'response.output_item.added', + item: { + type: 'message', + }, + }); + + const completionLines = completeResponsesStream(state, streamContext, usage); + const completionPayloads = parseSsePayloads(completionLines); + const completed = completionPayloads.find((item) => item.type === 'response.completed'); + expect(completed?.response?.output).toHaveLength(2); + expect(completed?.response?.output?.find((item: any) => item.type === 'reasoning')).toMatchObject({ + type: 'reasoning', + summary: [{ type: 'summary_text', text: 'think first' }], + }); + expect(completed?.response?.output?.find((item: any) => item.type === 'message')).toMatchObject({ + type: 'message', + content: [{ type: 'output_text', text: 'visible answer' }], + }); + expect(completed?.response?.output_text).toBe('visible answer'); + }); + + it('keeps synthetic message and tool call items separate when content and tool deltas arrive together', () => { + const state = createOpenAiResponsesAggregateState('gpt-5'); + const streamContext = createStreamTransformContext('gpt-5'); + const usage = { + promptTokens: 1, + completionTokens: 2, + totalTokens: 3, + }; + + serializeConvertedResponsesEvents({ + state, + streamContext, + usage, + event: { + contentDelta: 'hi', + toolCallDeltas: [{ + index: 0, + id: 'call_1', + name: 'Glob', + argumentsDelta: '{"pattern":"README*"}', + }], + } as any, + }); + + const completionLines = completeResponsesStream(state, streamContext, usage); + const completionPayloads = parseSsePayloads(completionLines); + const completed = completionPayloads.find((item) => item.type === 'response.completed'); + expect(completed?.response?.output).toHaveLength(2); + expect(completed?.response?.output?.find((item: any) => item.type === 'message')).toMatchObject({ + type: 'message', + content: [{ type: 'output_text', text: 'hi' }], + }); + expect(completed?.response?.output?.find((item: any) => item.type === 'function_call')).toMatchObject({ + type: 'function_call', + call_id: 'call_1', + name: 'Glob', + arguments: '{"pattern":"README*"}', + }); + }); + + it('backfills missing output_item.added before sparse native function_call argument events', () => { + const state = createOpenAiResponsesAggregateState('gpt-5'); + const streamContext = createStreamTransformContext('gpt-5'); + const usage = { + promptTokens: 1, + completionTokens: 2, + totalTokens: 3, + }; + + const lines = serializeConvertedResponsesEvents({ + state, + streamContext, + usage, + event: { + responsesEventType: 'response.function_call_arguments.delta', + responsesPayload: { + type: 'response.function_call_arguments.delta', + output_index: 0, + call_id: 'call_sparse_1', + name: 'Glob', + delta: '{"pattern":"README*"}', + }, + }, + }); + + const events = parseSseEvents(lines); + expect(events.map((entry) => entry.event)).toEqual([ + 'response.output_item.added', + 'response.function_call_arguments.delta', + ]); + expect(events[0]?.payload).toMatchObject({ + type: 'response.output_item.added', + output_index: 0, + item: { + type: 'function_call', + call_id: 'call_sparse_1', + name: 'Glob', + }, + }); + }); + + it('emits a generic output_item.done for native image_generation completion before response.completed', () => { + const state = createOpenAiResponsesAggregateState('gpt-5'); + const streamContext = createStreamTransformContext('gpt-5'); + const usage = { + promptTokens: 1, + completionTokens: 2, + totalTokens: 3, + }; + + serializeConvertedResponsesEvents({ + state, + streamContext, + usage, + event: { + responsesEventType: 'response.image_generation_call.completed', + responsesPayload: { + type: 'response.image_generation_call.completed', + output_index: 0, + item_id: 'img_1', + result: 'image-result', + }, + }, + }); + + const lines = serializeConvertedResponsesEvents({ + state, + streamContext, + usage, + event: { + responsesEventType: 'response.completed', + responsesPayload: { + type: 'response.completed', + response: { + id: 'resp_image_1', + model: 'gpt-5', + usage: { + input_tokens: 1, + output_tokens: 2, + total_tokens: 3, + }, + }, + }, + }, + }); + + const events = parseSseEvents(lines); + expect(events.map((entry) => entry.event)).toEqual([ + 'response.output_item.done', + 'response.completed', + ]); + expect(events[0]?.payload).toMatchObject({ + type: 'response.output_item.done', + output_index: 0, + item: { + id: 'img_1', + type: 'image_generation_call', + status: 'completed', + result: 'image-result', + }, + }); + }); }); diff --git a/src/server/transformers/openai/responses/aggregator.ts b/src/server/transformers/openai/responses/aggregator.ts index c6e820b6..dfa1b506 100644 --- a/src/server/transformers/openai/responses/aggregator.ts +++ b/src/server/transformers/openai/responses/aggregator.ts @@ -55,6 +55,15 @@ type ResponsesUsageSummary = { }; type AggregateOutputItem = Record; +type AggregateOutputMetaCarrier = AggregateOutputItem & { [key: symbol]: true | undefined }; + +const outputTextDoneMarker = Symbol('responses.output_text.done'); +const contentPartDoneMarker = Symbol('responses.content_part.done'); +const reasoningSummaryTextDoneMarker = Symbol('responses.reasoning_summary_text.done'); +const reasoningSummaryPartDoneMarker = Symbol('responses.reasoning_summary_part.done'); +const functionCallArgumentsDoneMarker = Symbol('responses.function_call_arguments.done'); +const customToolInputDoneMarker = Symbol('responses.custom_tool_call_input.done'); +const outputItemDoneMarker = Symbol('responses.output_item.done'); export type OpenAiResponsesAggregateState = { modelName: string; @@ -70,6 +79,7 @@ export type OpenAiResponsesAggregateState = { usageExtras: Record; completed: boolean; failed: boolean; + incomplete: boolean; }; export function createOpenAiResponsesAggregateState(modelName: string): OpenAiResponsesAggregateState { @@ -87,9 +97,18 @@ export function createOpenAiResponsesAggregateState(modelName: string): OpenAiRe usageExtras: {}, completed: false, failed: false, + incomplete: false, }; } +function markTerminalMarker(item: AggregateOutputItem, marker: symbol): void { + (item as AggregateOutputMetaCarrier)[marker] = true; +} + +function hasTerminalMarker(item: AggregateOutputItem, marker: symbol): boolean { + return (item as AggregateOutputMetaCarrier)[marker] === true; +} + function mergeUsageExtras( state: OpenAiResponsesAggregateState, usagePayload: unknown, @@ -127,11 +146,147 @@ function rememberOutputId(state: OpenAiResponsesAggregateState, index: number, i } } +function preserveTerminalMarkers(source: AggregateOutputItem | undefined, target: AggregateOutputItem): void { + if (!isRecord(source)) return; + + const itemMarkers = [ + outputItemDoneMarker, + functionCallArgumentsDoneMarker, + customToolInputDoneMarker, + ]; + for (const marker of itemMarkers) { + if (hasTerminalMarker(source, marker)) { + markTerminalMarker(target, marker); + } + } + + const sourceContent = Array.isArray(source.content) ? source.content : []; + const targetContent = Array.isArray(target.content) ? target.content : []; + for (let index = 0; index < Math.min(sourceContent.length, targetContent.length); index += 1) { + const sourcePart = sourceContent[index]; + const targetPart = targetContent[index]; + if (!isRecord(sourcePart) || !isRecord(targetPart)) continue; + if (hasTerminalMarker(sourcePart, outputTextDoneMarker)) { + markTerminalMarker(targetPart, outputTextDoneMarker); + } + if (hasTerminalMarker(sourcePart, contentPartDoneMarker)) { + markTerminalMarker(targetPart, contentPartDoneMarker); + } + } + + const sourceSummary = Array.isArray(source.summary) ? source.summary : []; + const targetSummary = Array.isArray(target.summary) ? target.summary : []; + for (let index = 0; index < Math.min(sourceSummary.length, targetSummary.length); index += 1) { + const sourcePart = sourceSummary[index]; + const targetPart = targetSummary[index]; + if (!isRecord(sourcePart) || !isRecord(targetPart)) continue; + if (hasTerminalMarker(sourcePart, reasoningSummaryTextDoneMarker)) { + markTerminalMarker(targetPart, reasoningSummaryTextDoneMarker); + } + if (hasTerminalMarker(sourcePart, reasoningSummaryPartDoneMarker)) { + markTerminalMarker(targetPart, reasoningSummaryPartDoneMarker); + } + } +} + +function isTerminalStatus(value: unknown): boolean { + const status = asTrimmedString(value).toLowerCase(); + return status === 'completed' || status === 'failed' || status === 'incomplete'; +} + +function isOutputItemType(item: unknown, expectedType: string): boolean { + return isRecord(item) && asTrimmedString(item.type).toLowerCase() === expectedType; +} + +function resolveCompatibleOutputIndex( + state: OpenAiResponsesAggregateState, + expectedType: string, + rawIndex: unknown, + ...candidateIds: Array +): number { + const resolved = resolveOutputIndex(state, rawIndex, ...candidateIds); + const existing = state.outputItems[resolved]; + if (!existing || isOutputItemType(existing, expectedType)) return resolved; + return state.outputItems.length; +} + +function snapshotOutputItemForAdded(item: AggregateOutputItem): AggregateOutputItem { + const next = cloneJson(item); + const itemType = asTrimmedString(next.type).toLowerCase(); + + if (itemType === 'message') { + next.content = []; + } else if (itemType === 'reasoning') { + next.summary = []; + } else if (itemType === 'function_call') { + next.arguments = ''; + } else if (itemType === 'custom_tool_call') { + next.input = ''; + } else if (itemType === 'image_generation_call') { + next.result = null; + next.partial_images = []; + } + + next.status = 'in_progress'; + return next; +} + +function serializeOutputItemAdded(index: number, item: AggregateOutputItem): string { + return serializeSse('response.output_item.added', { + type: 'response.output_item.added', + output_index: index, + item: snapshotOutputItemForAdded(item), + }); +} + +function snapshotPartForAdded(part: AggregateOutputItem): AggregateOutputItem { + const next = cloneJson(part); + const partType = asTrimmedString(next.type).toLowerCase(); + if ( + (partType === 'output_text' || partType === 'text' || partType === 'summary_text') + && typeof next.text === 'string' + ) { + next.text = ''; + } + return next; +} + +function serializeContentPartAdded( + outputIndex: number, + itemId: string, + contentIndex: number, + part: AggregateOutputItem, +): string { + return serializeSse('response.content_part.added', { + type: 'response.content_part.added', + output_index: outputIndex, + item_id: itemId, + content_index: contentIndex, + part: snapshotPartForAdded(part), + }); +} + +function serializeReasoningSummaryPartAdded( + outputIndex: number, + itemId: string, + summaryIndex: number, + part: AggregateOutputItem, +): string { + return serializeSse('response.reasoning_summary_part.added', { + type: 'response.reasoning_summary_part.added', + item_id: itemId, + output_index: outputIndex, + summary_index: summaryIndex, + part: snapshotPartForAdded(part), + }); +} + function setOutputItem( state: OpenAiResponsesAggregateState, index: number, item: AggregateOutputItem, ): AggregateOutputItem { + const existing = isRecord(state.outputItems[index]) ? state.outputItems[index] as AggregateOutputItem : undefined; const current = cloneRecord(state.outputItems[index]) || {}; const incoming = cloneJson(item); const next = { @@ -150,6 +305,7 @@ function setOutputItem( ) { next.partial_images = current.partial_images; } + preserveTerminalMarkers(existing, next); state.outputItems[index] = next; rememberOutputId(state, index, next); return next; @@ -182,34 +338,53 @@ function resolveOutputIndex( return state.outputItems.length; } -function ensureMessageItem(state: OpenAiResponsesAggregateState, indexHint?: number): { index: number; item: AggregateOutputItem } { - const index = state.messageIndex ?? indexHint ?? 0; +function ensureMessageItem( + state: OpenAiResponsesAggregateState, + indexHint?: number, + itemIdRaw?: unknown, +): { index: number; item: AggregateOutputItem; created: boolean } { + const preferredIndex = state.messageIndex ?? indexHint ?? 0; + const itemId = asTrimmedString(itemIdRaw); + const index = resolveCompatibleOutputIndex(state, 'message', preferredIndex); + const created = !state.outputItems[index]; const item = ensureOutputItem(state, index, () => ({ - id: ensureOutputItemId('', 'msg', index), + id: ensureOutputItemId(itemId, 'msg', index), type: 'message', role: 'assistant', status: 'in_progress', content: [], })); + if (itemId) { + item.id = ensureOutputItemId(itemId, 'msg', index); + rememberOutputId(state, index, item); + } if (!Array.isArray(item.content)) item.content = []; - if (state.messageIndex === null) state.messageIndex = index; - return { index, item }; + state.messageIndex = index; + return { index, item, created }; } function ensureMessageOutputTextPart( state: OpenAiResponsesAggregateState, indexHint?: number, -): { index: number; item: AggregateOutputItem; part: AggregateOutputItem; created: boolean } { - const { index, item } = ensureMessageItem(state, indexHint); + itemIdRaw?: unknown, +): { + index: number; + item: AggregateOutputItem; + part: AggregateOutputItem; + created: boolean; + itemCreated: boolean; + partCreated: boolean; +} { + const { index, item, created: itemCreated } = ensureMessageItem(state, indexHint, itemIdRaw); const content = Array.isArray(item.content) ? item.content as AggregateOutputItem[] : []; if (!Array.isArray(item.content)) item.content = content; let part = content[0]; - const created = !isRecord(part) || asTrimmedString(part.type).toLowerCase() !== 'output_text'; - if (created) { + const partCreated = !isRecord(part) || asTrimmedString(part.type).toLowerCase() !== 'output_text'; + if (partCreated) { part = { type: 'output_text', text: '' }; content[0] = part; } - return { index, item, part, created }; + return { index, item, part, created: itemCreated || partCreated, itemCreated, partCreated }; } function ensureReasoningItem( @@ -221,7 +396,9 @@ function ensureReasoningItem( const existingIndex = itemId ? state.reasoningIndexById[itemId] : Object.values(state.reasoningIndexById)[0]; - const index = existingIndex ?? resolveOutputIndex(state, indexHint, itemId); + const index = existingIndex !== undefined + ? resolveCompatibleOutputIndex(state, 'reasoning', existingIndex, itemId) + : resolveCompatibleOutputIndex(state, 'reasoning', indexHint, itemId); const created = !state.outputItems[index]; const item = ensureOutputItem(state, index, () => ({ id: ensureOutputItemId(itemId, 'rs', index), @@ -279,7 +456,9 @@ function ensureFunctionCallItem( const callId = asTrimmedString(callIdRaw); const name = asTrimmedString(nameRaw); const existingIndex = callId ? state.functionIndexById[callId] : undefined; - const index = existingIndex ?? resolveOutputIndex(state, indexHint, callId); + const index = existingIndex !== undefined + ? resolveCompatibleOutputIndex(state, 'function_call', existingIndex, callId) + : resolveCompatibleOutputIndex(state, 'function_call', indexHint, callId); const created = !state.outputItems[index]; const item = ensureOutputItem(state, index, () => ({ id: ensureOutputItemId(callId, 'fc', index), @@ -308,7 +487,9 @@ function ensureCustomToolItem( const existingIndex = (callId && state.customToolIndexById[callId] !== undefined) ? state.customToolIndexById[callId] : (itemId && state.customToolIndexById[itemId] !== undefined ? state.customToolIndexById[itemId] : undefined); - const index = existingIndex ?? resolveOutputIndex(state, indexHint, itemId, callId); + const index = existingIndex !== undefined + ? resolveCompatibleOutputIndex(state, 'custom_tool_call', existingIndex, itemId, callId) + : resolveCompatibleOutputIndex(state, 'custom_tool_call', indexHint, itemId, callId); const created = !state.outputItems[index]; const item = ensureOutputItem(state, index, () => ({ id: ensureOutputItemId(itemId, 'ct', index), @@ -331,7 +512,9 @@ function ensureImageGenerationItem( ): { index: number; item: AggregateOutputItem; created: boolean } { const itemId = asTrimmedString(itemIdRaw); const existingIndex = itemId ? state.imageGenerationIndexById[itemId] : undefined; - const index = existingIndex ?? resolveOutputIndex(state, indexHint, itemId); + const index = existingIndex !== undefined + ? resolveCompatibleOutputIndex(state, 'image_generation_call', existingIndex, itemId) + : resolveCompatibleOutputIndex(state, 'image_generation_call', indexHint, itemId); const created = !state.outputItems[index]; const item = ensureOutputItem(state, index, () => ({ id: ensureOutputItemId(itemId, 'img', index), @@ -376,12 +559,34 @@ function collectOutputText(state: OpenAiResponsesAggregateState): string { return parts.join(''); } +function hydrateStateFromTerminalResponseOutput( + state: OpenAiResponsesAggregateState, + responsePayload: Record | null | undefined, +): void { + if (!isRecord(responsePayload) || !Array.isArray(responsePayload.output)) return; + + for (let index = 0; index < responsePayload.output.length; index += 1) { + const item = responsePayload.output[index]; + if (!isRecord(item)) continue; + const itemType = asTrimmedString(item.type).toLowerCase(); + if (!itemType) continue; + const resolvedIndex = resolveCompatibleOutputIndex( + state, + itemType, + index, + item.id, + item.call_id, + ); + setOutputItem(state, resolvedIndex, cloneJson(item)); + } +} + function materializeResponse( state: OpenAiResponsesAggregateState, streamContext: StreamTransformContext, usage: ResponsesUsageSummary, responseTemplate?: Record | null, - statusOverride?: 'completed' | 'failed', + statusOverride?: 'completed' | 'failed' | 'incomplete', ): Record { const base = cloneRecord(responseTemplate) || {}; const responseId = ensureResponseId( @@ -395,7 +600,7 @@ function materializeResponse( ? base.created_at : (typeof base.created === 'number' && Number.isFinite(base.created) ? base.created : null) ) ?? state.createdAt ?? Math.floor(Date.now() / 1000); - const output = state.outputItems + const aggregatedOutput = state.outputItems .filter((item): item is AggregateOutputItem => isRecord(item)) .map((item) => { const status = asTrimmedString(item.status).toLowerCase(); @@ -403,19 +608,24 @@ function materializeResponse( ...item, status: status && status !== 'in_progress' ? status - : (state.failed ? 'failed' : 'completed'), + : (state.failed ? 'failed' : state.incomplete ? 'incomplete' : 'completed'), }; }); + const output = aggregatedOutput.length > 0 + ? aggregatedOutput + : (Array.isArray(base.output) ? cloneJson(base.output) : []); + const outputText = collectOutputText(state) + || (typeof base.output_text === 'string' ? base.output_text : ''); return { ...base, id: responseId, object: 'response', created_at: createdAt, - status: statusOverride ?? (state.failed ? 'failed' : 'completed'), + status: statusOverride ?? (state.failed ? 'failed' : state.incomplete ? 'incomplete' : 'completed'), model: asTrimmedString(base.model) || streamContext.model || state.modelName, output, - output_text: collectOutputText(state), + output_text: outputText, usage: buildUsagePayload(usage), ...Object.keys(state.usageExtras).length > 0 ? { usage: { ...buildUsagePayload(usage), ...state.usageExtras } } : {}, }; @@ -479,99 +689,223 @@ function applyOriginalResponsesPayload( const itemType = asTrimmedString(item.type).toLowerCase(); if (itemType === 'reasoning') { const reasoningState = ensureReasoningItem(state, item.id, outputIndex); + const preservedSummary = ( + (!Array.isArray(item.summary) || item.summary.length === 0) && Array.isArray(reasoningState.item.summary) + ) + ? reasoningState.item.summary + : null; const next = { ...reasoningState.item, ...item, }; - if ((!Array.isArray(item.summary) || item.summary.length === 0) && Array.isArray(reasoningState.item.summary)) { - next.summary = reasoningState.item.summary; + if (eventType === 'response.output_item.done' && !asTrimmedString(next.status)) { + next.status = 'completed'; } - setOutputItem(state, reasoningState.index, next); - return serializeOriginalResponsesEvent(eventType, { - ...payload, - output_index: reasoningState.index, - item: state.outputItems[reasoningState.index] ?? payload.item, - }); + if (preservedSummary) { + next.summary = preservedSummary; + } + const stored = setOutputItem(state, reasoningState.index, next); + if (preservedSummary) { + stored.summary = preservedSummary; + } + if (eventType === 'response.output_item.done') { + markTerminalMarker(stored, outputItemDoneMarker); + } + return [ + ...(eventType === 'response.output_item.done' ? buildMissingSubordinateDoneEventsForItem(stored, reasoningState.index) : []), + ...serializeOriginalResponsesEvent(eventType, { + ...payload, + output_index: reasoningState.index, + item: state.outputItems[reasoningState.index] ?? payload.item, + }), + ]; } const existing = isRecord(state.outputItems[outputIndex]) ? cloneRecord(state.outputItems[outputIndex]) || {} : {}; const next = { ...existing, ...item, }; - if ((!Array.isArray(item.content) || item.content.length === 0) && Array.isArray(existing.content)) { - next.content = existing.content; + if (eventType === 'response.output_item.done' && !asTrimmedString(next.status)) { + next.status = 'completed'; } - if ((!Array.isArray(item.summary) || item.summary.length === 0) && Array.isArray(existing.summary)) { - next.summary = existing.summary; + const preservedContent = ( + (!Array.isArray(item.content) || item.content.length === 0) && Array.isArray(existing.content) + ) + ? existing.content + : null; + const preservedSummary = ( + (!Array.isArray(item.summary) || item.summary.length === 0) && Array.isArray(existing.summary) + ) + ? existing.summary + : null; + const preservedPartialImages = ( + (!Array.isArray(item.partial_images) || item.partial_images.length === 0) && Array.isArray(existing.partial_images) + ) + ? existing.partial_images + : null; + if (preservedContent) next.content = preservedContent; + if (preservedSummary) next.summary = preservedSummary; + if (preservedPartialImages) next.partial_images = preservedPartialImages; + const stored = setOutputItem(state, outputIndex, next); + if (preservedContent) stored.content = preservedContent; + if (preservedSummary) stored.summary = preservedSummary; + if (preservedPartialImages) stored.partial_images = preservedPartialImages; + if (eventType === 'response.output_item.done') { + markTerminalMarker(stored, outputItemDoneMarker); } - if ((!Array.isArray(item.partial_images) || item.partial_images.length === 0) && Array.isArray(existing.partial_images)) { - next.partial_images = existing.partial_images; - } - setOutputItem(state, outputIndex, next); } - return serializeOriginalResponsesEvent(eventType, { - ...payload, - item: state.outputItems[outputIndex] ?? payload.item, - }); + const stored = state.outputItems[outputIndex]; + return [ + ...(eventType === 'response.output_item.done' && isRecord(stored) + ? buildMissingSubordinateDoneEventsForItem(stored, outputIndex) + : []), + ...serializeOriginalResponsesEvent(eventType, { + ...payload, + item: state.outputItems[outputIndex] ?? payload.item, + }), + ]; } case 'response.content_part.added': case 'response.content_part.done': { - const outputIndex = resolveOutputIndex(state, payload.output_index, payload.item_id); + const lines: string[] = []; + const requestedOutputIndex = resolveCompatibleOutputIndex(state, 'message', payload.output_index, payload.item_id); const contentIndex = typeof payload.content_index === 'number' && Number.isFinite(payload.content_index) ? Math.max(0, Math.trunc(payload.content_index)) : 0; - const message = ensureMessageItem(state, outputIndex).item; + const messageState = ensureMessageItem(state, requestedOutputIndex, payload.item_id); + const message = messageState.item; + if (messageState.created) { + lines.push(serializeOutputItemAdded(messageState.index, message)); + } const content = Array.isArray(message.content) ? message.content as AggregateOutputItem[] : []; if (!Array.isArray(message.content)) message.content = content; - const part = cloneRecord(payload.part); - if (part) content[contentIndex] = part; - return serializeOriginalResponsesEvent(eventType, payload); + const existingPart = isRecord(content[contentIndex]) ? content[contentIndex] as AggregateOutputItem : null; + const incomingPart = cloneRecord(payload.part); + const partCreated = !existingPart && !!incomingPart; + if (incomingPart) { + content[contentIndex] = existingPart + ? { + ...existingPart, + ...incomingPart, + } + : incomingPart; + } + const part = isRecord(content[contentIndex]) ? content[contentIndex] as AggregateOutputItem : existingPart; + if (eventType === 'response.content_part.done' && partCreated && part) { + lines.push(serializeContentPartAdded(messageState.index, asTrimmedString(message.id), contentIndex, part)); + } + if (eventType === 'response.content_part.done' && part) { + markTerminalMarker(part, contentPartDoneMarker); + } + return [ + ...lines, + ...serializeOriginalResponsesEvent(eventType, { + ...payload, + output_index: messageState.index, + item_id: asTrimmedString(message.id) || payload.item_id, + }), + ]; } case 'response.output_text.delta': case 'response.output_text.done': { - const outputIndex = resolveOutputIndex(state, payload.output_index, payload.item_id); - const textPart = ensureMessageOutputTextPart(state, outputIndex); + const lines: string[] = []; + const outputIndex = resolveCompatibleOutputIndex(state, 'message', payload.output_index, payload.item_id); + const textPart = ensureMessageOutputTextPart(state, outputIndex, payload.item_id); + if (textPart.itemCreated) { + lines.push(serializeOutputItemAdded(textPart.index, textPart.item)); + } + if (textPart.partCreated) { + lines.push(serializeContentPartAdded(textPart.index, asTrimmedString(textPart.item.id), 0, textPart.part)); + } if (eventType === 'response.output_text.done') { textPart.part.text = typeof payload.text === 'string' ? payload.text : String(payload.text ?? ''); + markTerminalMarker(textPart.part, outputTextDoneMarker); } else { textPart.part.text = `${typeof textPart.part.text === 'string' ? textPart.part.text : ''}${typeof payload.delta === 'string' ? payload.delta : ''}`; } - return serializeOriginalResponsesEvent(eventType, payload); + return [ + ...lines, + ...serializeOriginalResponsesEvent(eventType, { + ...payload, + output_index: textPart.index, + item_id: asTrimmedString(textPart.item.id) || payload.item_id, + }), + ]; } case 'response.function_call_arguments.delta': case 'response.function_call_arguments.done': { + const lines: string[] = []; const entry = ensureFunctionCallItem( state, payload.call_id ?? payload.item_id, payload.name, - resolveOutputIndex(state, payload.output_index, payload.item_id, payload.call_id), + resolveCompatibleOutputIndex(state, 'function_call', payload.output_index, payload.item_id, payload.call_id), ); + if (entry.created) { + lines.push(serializeOutputItemAdded(entry.index, entry.item)); + } if (eventType === 'response.function_call_arguments.done') { entry.item.arguments = typeof payload.arguments === 'string' ? payload.arguments : String(payload.arguments ?? ''); + markTerminalMarker(entry.item, functionCallArgumentsDoneMarker); } else { entry.item.arguments = `${typeof entry.item.arguments === 'string' ? entry.item.arguments : ''}${typeof payload.delta === 'string' ? payload.delta : ''}`; } - return serializeOriginalResponsesEvent(eventType, payload); + return [ + ...lines, + ...serializeOriginalResponsesEvent(eventType, { + ...payload, + output_index: entry.index, + item_id: asTrimmedString(entry.item.id) || payload.item_id, + call_id: asTrimmedString(entry.item.call_id) || payload.call_id, + ...(asTrimmedString(entry.item.name) ? { name: entry.item.name } : {}), + }), + ]; } case 'response.custom_tool_call_input.delta': case 'response.custom_tool_call_input.done': { + const lines: string[] = []; const entry = ensureCustomToolItem( state, payload.item_id, payload.call_id, payload.name, - resolveOutputIndex(state, payload.output_index, payload.item_id, payload.call_id), + resolveCompatibleOutputIndex(state, 'custom_tool_call', payload.output_index, payload.item_id, payload.call_id), ); + if (entry.created) { + lines.push(serializeOutputItemAdded(entry.index, entry.item)); + } if (eventType === 'response.custom_tool_call_input.done') { entry.item.input = typeof payload.input === 'string' ? payload.input : String(payload.input ?? ''); + markTerminalMarker(entry.item, customToolInputDoneMarker); } else { entry.item.input = `${typeof entry.item.input === 'string' ? entry.item.input : ''}${typeof payload.delta === 'string' ? payload.delta : ''}`; } - return serializeOriginalResponsesEvent(eventType, payload); + return [ + ...lines, + ...serializeOriginalResponsesEvent(eventType, { + ...payload, + output_index: entry.index, + item_id: asTrimmedString(entry.item.id) || payload.item_id, + call_id: asTrimmedString(entry.item.call_id) || payload.call_id, + ...(asTrimmedString(entry.item.name) ? { name: entry.item.name } : {}), + }), + ]; } case 'response.reasoning_summary_part.added': case 'response.reasoning_summary_part.done': { + const lines: string[] = []; const summaryState = ensureReasoningSummaryPart(state, payload.item_id, payload.summary_index, payload.output_index); + if (summaryState.itemCreated) { + lines.push(serializeOutputItemAdded(summaryState.index, summaryState.item)); + } + if (summaryState.partCreated && eventType === 'response.reasoning_summary_part.done') { + lines.push(serializeReasoningSummaryPartAdded( + summaryState.index, + asTrimmedString(summaryState.item.id), + summaryState.summaryIndex, + summaryState.summary, + )); + } const part = cloneRecord(payload.part); if (part) { const summary = Array.isArray(summaryState.item.summary) ? summaryState.item.summary as AggregateOutputItem[] : []; @@ -580,28 +914,64 @@ function applyOriginalResponsesPayload( ...part, }; summaryState.item.summary = summary; + if (eventType === 'response.reasoning_summary_part.done') { + markTerminalMarker(summary[summaryState.summaryIndex] as AggregateOutputItem, reasoningSummaryPartDoneMarker); + } + } else if (eventType === 'response.reasoning_summary_part.done') { + markTerminalMarker(summaryState.summary, reasoningSummaryPartDoneMarker); } - return serializeOriginalResponsesEvent(eventType, payload); + return [ + ...lines, + ...serializeOriginalResponsesEvent(eventType, { + ...payload, + output_index: summaryState.index, + item_id: asTrimmedString(summaryState.item.id) || payload.item_id, + }), + ]; } case 'response.reasoning_summary_text.delta': case 'response.reasoning_summary_text.done': { + const lines: string[] = []; const summaryState = ensureReasoningSummaryPart(state, payload.item_id, payload.summary_index, payload.output_index); + if (summaryState.itemCreated) { + lines.push(serializeOutputItemAdded(summaryState.index, summaryState.item)); + } + if (summaryState.partCreated) { + lines.push(serializeReasoningSummaryPartAdded( + summaryState.index, + asTrimmedString(summaryState.item.id), + summaryState.summaryIndex, + summaryState.summary, + )); + } if (eventType === 'response.reasoning_summary_text.done') { summaryState.summary.text = typeof payload.text === 'string' ? payload.text : String(payload.text ?? ''); + markTerminalMarker(summaryState.summary, reasoningSummaryTextDoneMarker); } else { summaryState.summary.text = `${typeof summaryState.summary.text === 'string' ? summaryState.summary.text : ''}${typeof payload.delta === 'string' ? payload.delta : ''}`; } - return serializeOriginalResponsesEvent(eventType, payload); + return [ + ...lines, + ...serializeOriginalResponsesEvent(eventType, { + ...payload, + output_index: summaryState.index, + item_id: asTrimmedString(summaryState.item.id) || payload.item_id, + }), + ]; } case 'response.image_generation_call.generating': case 'response.image_generation_call.in_progress': case 'response.image_generation_call.partial_image': case 'response.image_generation_call.completed': { + const lines: string[] = []; const entry = ensureImageGenerationItem( state, payload.item_id, - resolveOutputIndex(state, payload.output_index, payload.item_id), + resolveCompatibleOutputIndex(state, 'image_generation_call', payload.output_index, payload.item_id), ); + if (entry.created) { + lines.push(serializeOutputItemAdded(entry.index, entry.item)); + } if (eventType === 'response.image_generation_call.partial_image') { const partialImages = Array.isArray(entry.item.partial_images) ? entry.item.partial_images as AggregateOutputItem[] : []; partialImages.push({ @@ -617,21 +987,50 @@ function applyOriginalResponsesPayload( if (eventType === 'response.image_generation_call.completed') { entry.item.status = 'completed'; } - return serializeOriginalResponsesEvent(eventType, payload); + return [ + ...lines, + ...serializeOriginalResponsesEvent(eventType, { + ...payload, + output_index: entry.index, + item_id: asTrimmedString(entry.item.id) || payload.item_id, + }), + ]; } case 'response.completed': { mergeUsageExtras(state, payload.response && isRecord(payload.response) ? payload.response.usage : payload.usage); - state.completed = true; const responsePayload = cloneRecord(payload.response); + hydrateStateFromTerminalResponseOutput(state, responsePayload); + const terminalLines = buildSyntheticTerminalItemDoneEvents(state, 'completed'); + state.completed = true; const materialized = materializeResponse(state, streamContext, usage, responsePayload, 'completed'); - return [serializeSse('response.completed', { ...payload, response: materialized })]; + return [ + ...terminalLines, + serializeSse('response.completed', { ...payload, response: materialized }), + ]; } case 'response.failed': { mergeUsageExtras(state, payload.response && isRecord(payload.response) ? payload.response.usage : payload.usage); - state.failed = true; const responsePayload = cloneRecord(payload.response); + hydrateStateFromTerminalResponseOutput(state, responsePayload); + const terminalLines = buildSyntheticTerminalItemDoneEvents(state, 'failed'); + state.failed = true; const materialized = materializeResponse(state, streamContext, usage, responsePayload, 'failed'); - return [serializeSse('response.failed', { ...payload, response: materialized })]; + return [ + ...terminalLines, + serializeSse('response.failed', { ...payload, response: materialized }), + ]; + } + case 'response.incomplete': { + mergeUsageExtras(state, payload.response && isRecord(payload.response) ? payload.response.usage : payload.usage); + const responsePayload = cloneRecord(payload.response); + hydrateStateFromTerminalResponseOutput(state, responsePayload); + const terminalLines = buildSyntheticTerminalItemDoneEvents(state, 'incomplete'); + state.incomplete = true; + const materialized = materializeResponse(state, streamContext, usage, responsePayload, 'incomplete'); + return [ + ...terminalLines, + serializeSse('response.incomplete', { ...payload, response: materialized }), + ]; } default: mergeUsageExtras(state, payload.usage); @@ -643,31 +1042,23 @@ function buildSyntheticMessageEvents( state: OpenAiResponsesAggregateState, delta: string, ): string[] { - const { index, item } = ensureMessageItem(state); - const textPartState = ensureMessageOutputTextPart(state, index); + const textPartState = ensureMessageOutputTextPart(state); + const { index } = textPartState; const lines: string[] = []; const currentText = typeof textPartState.part.text === 'string' ? textPartState.part.text : ''; const novelDelta = computeNovelDelta(currentText, delta); - if (textPartState.created) { - lines.push(serializeSse('response.output_item.added', { - type: 'response.output_item.added', - output_index: index, - item, - })); - lines.push(serializeSse('response.content_part.added', { - type: 'response.content_part.added', - output_index: index, - item_id: item.id, - content_index: 0, - part: { type: 'output_text', text: '' }, - })); + if (textPartState.itemCreated) { + lines.push(serializeOutputItemAdded(index, textPartState.item)); + } + if (textPartState.partCreated) { + lines.push(serializeContentPartAdded(index, asTrimmedString(textPartState.item.id), 0, textPartState.part)); } if (novelDelta) { textPartState.part.text = `${currentText}${novelDelta}`; lines.push(serializeSse('response.output_text.delta', { type: 'response.output_text.delta', output_index: index, - item_id: item.id, + item_id: textPartState.item.id, delta: novelDelta, })); } @@ -684,18 +1075,20 @@ function buildSyntheticReasoningEvents( const reasoningState = (signature || delta) ? ensureReasoningItem(state, '', state.outputItems.length) : null; + let emittedOutputItemAdded = false; + + const emitOutputItemAdded = (entry: { index: number; item: AggregateOutputItem; created: boolean } | null) => { + if (!entry?.created || emittedOutputItemAdded) return; + lines.push(serializeOutputItemAdded(entry.index, entry.item)); + emittedOutputItemAdded = true; + }; if (reasoningState && signature) { reasoningState.item.encrypted_content = signature; - if (reasoningState.created) { - lines.push(serializeSse('response.output_item.added', { - type: 'response.output_item.added', - output_index: reasoningState.index, - item: reasoningState.item, - })); - } } + emitOutputItemAdded(reasoningState); + if (!delta) { return lines; } @@ -709,21 +1102,9 @@ function buildSyntheticReasoningEvents( const itemId = asTrimmedString(summaryState.item.id); const currentText = typeof summaryState.summary.text === 'string' ? summaryState.summary.text : ''; const novelDelta = computeNovelDelta(currentText, delta); - if (summaryState.itemCreated && !reasoningState?.created) { - lines.push(serializeSse('response.output_item.added', { - type: 'response.output_item.added', - output_index: summaryState.index, - item: summaryState.item, - })); - } + emitOutputItemAdded(summaryState); if (summaryState.partCreated) { - lines.push(serializeSse('response.reasoning_summary_part.added', { - type: 'response.reasoning_summary_part.added', - item_id: itemId, - output_index: summaryState.index, - summary_index: 0, - part: { type: 'summary_text', text: '' }, - })); + lines.push(serializeReasoningSummaryPartAdded(summaryState.index, itemId, 0, summaryState.summary)); } if (novelDelta) { summaryState.summary.text = `${currentText}${novelDelta}`; @@ -747,11 +1128,7 @@ function buildSyntheticToolEvents( for (const toolDelta of event.toolCallDeltas) { const entry = ensureFunctionCallItem(state, toolDelta.id, toolDelta.name, toolDelta.index); if (entry.created) { - lines.push(serializeSse('response.output_item.added', { - type: 'response.output_item.added', - output_index: entry.index, - item: entry.item, - })); + lines.push(serializeOutputItemAdded(entry.index, entry.item)); } if (toolDelta.argumentsDelta !== undefined && toolDelta.argumentsDelta.length > 0) { entry.item.arguments = `${typeof entry.item.arguments === 'string' ? entry.item.arguments : ''}${toolDelta.argumentsDelta}`; @@ -768,6 +1145,134 @@ function buildSyntheticToolEvents( return lines; } +function buildMissingSubordinateDoneEventsForItem( + item: AggregateOutputItem, + index: number, +): string[] { + const lines: string[] = []; + const itemType = asTrimmedString(item.type).toLowerCase(); + const itemId = ensureOutputItemId(asTrimmedString(item.id), 'out', index); + item.id = itemId; + + if (itemType === 'message') { + const content = Array.isArray(item.content) ? item.content as AggregateOutputItem[] : []; + for (let contentIndex = 0; contentIndex < content.length; contentIndex += 1) { + const part = content[contentIndex]; + if (!isRecord(part)) continue; + const partType = asTrimmedString(part.type).toLowerCase(); + const text = typeof part.text === 'string' ? part.text : String(part.text ?? ''); + if ((partType === 'output_text' || partType === 'text') && !hasTerminalMarker(part, outputTextDoneMarker)) { + lines.push(serializeSse('response.output_text.done', { + type: 'response.output_text.done', + output_index: index, + item_id: itemId, + text, + })); + markTerminalMarker(part, outputTextDoneMarker); + } + if (!hasTerminalMarker(part, contentPartDoneMarker)) { + lines.push(serializeSse('response.content_part.done', { + type: 'response.content_part.done', + output_index: index, + item_id: itemId, + content_index: contentIndex, + part: cloneJson(part), + })); + markTerminalMarker(part, contentPartDoneMarker); + } + } + } else if (itemType === 'reasoning') { + const summary = Array.isArray(item.summary) ? item.summary as AggregateOutputItem[] : []; + for (let summaryIndex = 0; summaryIndex < summary.length; summaryIndex += 1) { + const part = summary[summaryIndex]; + if (!isRecord(part)) continue; + const text = typeof part.text === 'string' ? part.text : String(part.text ?? ''); + if (!hasTerminalMarker(part, reasoningSummaryTextDoneMarker)) { + lines.push(serializeSse('response.reasoning_summary_text.done', { + type: 'response.reasoning_summary_text.done', + item_id: itemId, + output_index: index, + summary_index: summaryIndex, + text, + })); + markTerminalMarker(part, reasoningSummaryTextDoneMarker); + } + if (!hasTerminalMarker(part, reasoningSummaryPartDoneMarker)) { + lines.push(serializeSse('response.reasoning_summary_part.done', { + type: 'response.reasoning_summary_part.done', + item_id: itemId, + output_index: index, + summary_index: summaryIndex, + part: cloneJson(part), + })); + markTerminalMarker(part, reasoningSummaryPartDoneMarker); + } + } + } else if (itemType === 'function_call') { + const callId = asTrimmedString(item.call_id) || itemId; + const name = asTrimmedString(item.name); + const argumentsText = typeof item.arguments === 'string' ? item.arguments : String(item.arguments ?? ''); + if (!hasTerminalMarker(item, functionCallArgumentsDoneMarker)) { + lines.push(serializeSse('response.function_call_arguments.done', { + type: 'response.function_call_arguments.done', + item_id: itemId, + call_id: callId, + output_index: index, + ...(name ? { name } : {}), + arguments: argumentsText, + })); + markTerminalMarker(item, functionCallArgumentsDoneMarker); + } + } else if (itemType === 'custom_tool_call') { + const callId = asTrimmedString(item.call_id) || itemId; + const name = asTrimmedString(item.name); + const inputText = typeof item.input === 'string' ? item.input : String(item.input ?? ''); + if (!hasTerminalMarker(item, customToolInputDoneMarker)) { + lines.push(serializeSse('response.custom_tool_call_input.done', { + type: 'response.custom_tool_call_input.done', + item_id: itemId, + call_id: callId, + output_index: index, + ...(name ? { name } : {}), + input: inputText, + })); + markTerminalMarker(item, customToolInputDoneMarker); + } + } + + return lines; +} + +function buildSyntheticTerminalItemDoneEvents( + state: OpenAiResponsesAggregateState, + status: 'completed' | 'failed' | 'incomplete', +): string[] { + const lines: string[] = []; + + for (let index = 0; index < state.outputItems.length; index += 1) { + const item = state.outputItems[index]; + if (!isRecord(item)) continue; + + const currentStatus = asTrimmedString(item.status).toLowerCase(); + const itemTerminal = isTerminalStatus(currentStatus); + lines.push(...buildMissingSubordinateDoneEventsForItem(item, index)); + + if (!hasTerminalMarker(item, outputItemDoneMarker)) { + if (!itemTerminal) { + item.status = status; + } + lines.push(serializeSse('response.output_item.done', { + type: 'response.output_item.done', + output_index: index, + item: cloneJson(item), + })); + markTerminalMarker(item, outputItemDoneMarker); + } + } + + return lines; +} + export function serializeConvertedResponsesEvents(input: { state: OpenAiResponsesAggregateState; streamContext: StreamTransformContext; @@ -803,11 +1308,13 @@ export function completeResponsesStream( streamContext: StreamTransformContext, usage: ResponsesUsageSummary, ): string[] { - if (state.failed || state.completed) { + if (state.failed || state.completed || state.incomplete) { return [serializeDone()]; } + const lines = buildSyntheticTerminalItemDoneEvents(state, 'completed'); state.completed = true; return [ + ...lines, serializeSse('response.completed', { type: 'response.completed', response: materializeResponse(state, streamContext, usage, null, 'completed'), @@ -825,6 +1332,7 @@ export function failResponsesStream( if (state.failed) { return [serializeDone()]; } + const lines = buildSyntheticTerminalItemDoneEvents(state, 'failed'); state.failed = true; const errorPayload = cloneRecord(payload); const message = ( @@ -833,6 +1341,7 @@ export function failResponsesStream( : (typeof errorPayload?.message === 'string' ? errorPayload.message : 'upstream stream failed') ); return [ + ...lines, serializeSse('response.failed', { type: 'response.failed', response: materializeResponse(state, streamContext, usage, cloneRecord(errorPayload?.response), 'failed'), @@ -844,3 +1353,25 @@ export function failResponsesStream( serializeDone(), ]; } + +export function incompleteResponsesStream( + state: OpenAiResponsesAggregateState, + streamContext: StreamTransformContext, + usage: ResponsesUsageSummary, + payload: unknown, +): string[] { + if (state.failed || state.completed || state.incomplete) { + return [serializeDone()]; + } + const lines = buildSyntheticTerminalItemDoneEvents(state, 'incomplete'); + state.incomplete = true; + const incompletePayload = cloneRecord(payload); + return [ + ...lines, + serializeSse('response.incomplete', { + ...incompletePayload, + response: materializeResponse(state, streamContext, usage, cloneRecord(incompletePayload?.response), 'incomplete'), + }), + serializeDone(), + ]; +} diff --git a/src/server/transformers/openai/responses/compatibility.ts b/src/server/transformers/openai/responses/compatibility.ts index 8afe4bdf..960a8a5c 100644 --- a/src/server/transformers/openai/responses/compatibility.ts +++ b/src/server/transformers/openai/responses/compatibility.ts @@ -1,4 +1,12 @@ -import { normalizeInputFileBlock, toResponsesInputFileBlock } from '../../shared/inputFile.js'; +import { + normalizeResponsesInputForCompatibility, + normalizeResponsesMessageContent, + normalizeResponsesMessageItem, +} from './normalization.js'; +import { + convertOpenAiBodyToResponsesBody, + sanitizeResponsesBodyForProxy, +} from './conversion.js'; function isRecord(value: unknown): value is Record { return !!value && typeof value === 'object'; @@ -8,170 +16,6 @@ function asTrimmedString(value: unknown): string { return typeof value === 'string' ? value.trim() : ''; } -function toTextBlockType(role: string): 'input_text' | 'output_text' { - return role === 'assistant' ? 'output_text' : 'input_text'; -} - -function normalizeImageUrlValue(value: unknown): string | Record | null { - if (typeof value === 'string' && value.trim().length > 0) return value; - if (!isRecord(value)) return null; - const url = asTrimmedString(value.url); - if (url) return { ...value, url }; - const imageUrl = asTrimmedString(value.image_url); - if (imageUrl) return imageUrl; - return Object.keys(value).length > 0 ? value : null; -} - -function normalizeAudioInputValue(value: unknown): Record | null { - if (!isRecord(value)) return null; - const data = asTrimmedString(value.data); - const format = asTrimmedString(value.format); - if (!data && !format) return Object.keys(value).length > 0 ? value : null; - return { - ...value, - ...(data ? { data } : {}), - ...(format ? { format } : {}), - }; -} - -function normalizeResponsesContentItem( - item: unknown, - role: string, -): Record | null { - if (typeof item === 'string') { - const text = item.trim(); - return text ? { type: toTextBlockType(role), text } : null; - } - - if (!isRecord(item)) return null; - - const type = asTrimmedString(item.type).toLowerCase(); - if (!type) { - const text = asTrimmedString(item.text ?? item.content ?? item.output_text); - return text ? { type: toTextBlockType(role), text } : null; - } - - if (type === 'input_text' || type === 'output_text' || type === 'text') { - const text = asTrimmedString(item.text ?? item.content ?? item.output_text); - if (!text) return null; - return { - ...item, - type: type === 'text' ? toTextBlockType(role) : type, - text, - }; - } - - if (type === 'input_image' || type === 'image_url') { - const imageUrl = normalizeImageUrlValue(item.image_url ?? item.url); - if (!imageUrl) return null; - return { - ...item, - type: 'input_image', - image_url: imageUrl, - }; - } - - if (type === 'input_audio') { - const inputAudio = normalizeAudioInputValue(item.input_audio); - if (!inputAudio) return null; - return { - ...item, - type: 'input_audio', - input_audio: inputAudio, - }; - } - - if (type === 'file' || type === 'input_file') { - const fileBlock = normalizeInputFileBlock(item); - return fileBlock ? toResponsesInputFileBlock(fileBlock) : null; - } - - if (type === 'function_call' || type === 'function_call_output') { - return item; - } - - return item; -} - -export function normalizeResponsesMessageContent(content: unknown, role: string): unknown { - if (Array.isArray(content)) { - const normalized = content - .map((item) => normalizeResponsesContentItem(item, role)) - .filter((item): item is Record => !!item); - return normalized.length > 0 ? normalized : content; - } - - const single = normalizeResponsesContentItem(content, role); - if (single) return [single]; - return content; -} - -function toResponsesInputMessageFromText(text: string): Record { - return { - type: 'message', - role: 'user', - content: [{ type: 'input_text', text }], - }; -} - -export function normalizeResponsesMessageItem(item: Record): Record { - const type = asTrimmedString(item.type).toLowerCase(); - if (type === 'function_call' || type === 'function_call_output') { - return item; - } - - const role = asTrimmedString(item.role).toLowerCase() || 'user'; - const normalizedContent = normalizeResponsesMessageContent(item.content ?? item.text, role); - - if (type === 'message') { - return { - ...item, - role, - content: normalizedContent, - }; - } - - if (asTrimmedString(item.role)) { - return { - type: 'message', - role, - content: normalizedContent, - }; - } - - if (typeof item.content === 'string') { - const text = item.content.trim(); - return text ? toResponsesInputMessageFromText(text) : item; - } - - return item; -} - -export function normalizeResponsesInputForCompatibility(input: unknown): unknown { - if (typeof input === 'string') { - const normalized = input.trim(); - if (!normalized) return input; - return [toResponsesInputMessageFromText(normalized)]; - } - - if (Array.isArray(input)) { - return input.map((item) => { - if (typeof item === 'string') { - const normalized = item.trim(); - return normalized ? toResponsesInputMessageFromText(normalized) : item; - } - if (!isRecord(item)) return item; - return normalizeResponsesMessageItem(item); - }); - } - - if (isRecord(input)) { - return [normalizeResponsesMessageItem(input)]; - } - - return input; -} - export function buildResponsesCompatibilityBodies( body: Record, ): Record[] { @@ -213,11 +57,19 @@ export function buildResponsesCompatibilityBodies( if (temperature !== null) richCandidate.temperature = temperature; const topP = toFiniteNumber(body.top_p); if (topP !== null) richCandidate.top_p = topP; - const instructions = typeof body.instructions === 'string' ? body.instructions.trim() : ''; - if (instructions) richCandidate.instructions = instructions; + const instructions = getExplicitResponsesInstructions(body); + if (instructions !== null) richCandidate.instructions = instructions; const passthroughFields = [ + 'tools', + 'tool_choice', + 'parallel_tool_calls', + 'include', 'reasoning', + 'previous_response_id', + 'truncation', + 'text', + 'service_tier', 'safety_identifier', 'max_tool_calls', 'prompt_cache_key', @@ -315,53 +167,6 @@ export function shouldDowngradeResponsesChatToMessages( return /messages\s+is\s+required/i.test(upstreamErrorText); } -function extractTextFromResponsesContent(content: unknown): string { - if (typeof content === 'string') return content.trim(); - if (!Array.isArray(content)) return ''; - return content - .map((item) => { - if (!isRecord(item)) return ''; - return asTrimmedString(item.text ?? item.content ?? item.output_text); - }) - .filter((item) => item.length > 0) - .join('\n'); -} - -function safeJsonStringify(value: unknown): string { - try { - return JSON.stringify(value); - } catch { - return ''; - } -} - -function normalizeOpenAiToolArguments(raw: unknown): string { - if (typeof raw === 'string') return raw; - if (raw === undefined || raw === null) return ''; - if (typeof raw === 'number' || typeof raw === 'boolean') return String(raw); - if (Array.isArray(raw) || isRecord(raw)) { - return safeJsonStringify(raw); - } - return ''; -} - -function normalizeToolMessageContent(raw: unknown): string { - if (raw === undefined || raw === null) return ''; - if (typeof raw === 'string') return raw; - if (typeof raw === 'number' || typeof raw === 'boolean') return String(raw); - if (Array.isArray(raw)) { - const normalized = normalizeResponsesMessageContent(raw, 'user'); - const text = extractTextFromResponsesContent(normalized); - return text || safeJsonStringify(raw); - } - if (isRecord(raw)) { - const normalized = normalizeResponsesMessageContent(raw, 'user'); - const text = extractTextFromResponsesContent(normalized); - return text || safeJsonStringify(raw); - } - return ''; -} - function toFiniteNumber(value: unknown): number | null { return typeof value === 'number' && Number.isFinite(value) ? value : null; } @@ -398,6 +203,11 @@ function parseUpstreamErrorShape(rawText: string): { } } +function getExplicitResponsesInstructions(body: Record): string | null { + if (!Object.prototype.hasOwnProperty.call(body, 'instructions')) return null; + return typeof body.instructions === 'string' ? body.instructions.trim() : ''; +} + function stripResponsesMetadata( body: Record, ): Record | null { @@ -407,50 +217,6 @@ function stripResponsesMetadata( return next; } -function buildCoreResponsesBody( - body: Record, -): Record | null { - const model = typeof body.model === 'string' ? body.model.trim() : ''; - if (!model) return null; - if (body.input === undefined) return null; - - const core: Record = { - model, - input: body.input, - stream: body.stream === true, - }; - - const maxOutputTokens = toFiniteNumber(body.max_output_tokens); - if (maxOutputTokens !== null && maxOutputTokens > 0) { - core.max_output_tokens = Math.trunc(maxOutputTokens); - } - - const temperature = toFiniteNumber(body.temperature); - if (temperature !== null) core.temperature = temperature; - - const topP = toFiniteNumber(body.top_p); - if (topP !== null) core.top_p = topP; - - const instructions = typeof body.instructions === 'string' ? body.instructions.trim() : ''; - if (instructions) core.instructions = instructions; - - const passthroughFields = [ - 'reasoning', - 'safety_identifier', - 'max_tool_calls', - 'prompt_cache_key', - 'prompt_cache_retention', - 'background', - 'top_logprobs', - ] as const; - for (const key of passthroughFields) { - if (body[key] === undefined) continue; - core[key] = cloneJsonValue(body[key]); - } - - return core; -} - function buildStrictResponsesBody( body: Record, ): Record | null { @@ -458,201 +224,24 @@ function buildStrictResponsesBody( if (!model) return null; if (body.input === undefined) return null; + const explicitInstructions = getExplicitResponsesInstructions(body); + return { model, input: body.input, stream: body.stream === true, + ...(body.tools !== undefined ? { tools: cloneJsonValue(body.tools) } : {}), + ...(body.tool_choice !== undefined ? { tool_choice: cloneJsonValue(body.tool_choice) } : {}), + ...(explicitInstructions !== null + ? { instructions: explicitInstructions } + : {}), }; } -const ALLOWED_RESPONSES_FIELDS = new Set([ - 'model', - 'input', - 'instructions', - 'max_output_tokens', - 'max_completion_tokens', - 'temperature', - 'top_p', - 'truncation', - 'tools', - 'tool_choice', - 'parallel_tool_calls', - 'metadata', - 'reasoning', - 'store', - 'stream', - 'user', - 'previous_response_id', - 'text', - 'audio', - 'include', - 'response_format', - 'service_tier', - 'stop', - 'n', -]); - -export function convertOpenAiBodyToResponsesBody( - openaiBody: Record, - modelName: string, - stream: boolean, -): Record { - const rawMessages = Array.isArray(openaiBody.messages) ? openaiBody.messages : []; - const systemContents: string[] = []; - const inputItems: Array> = []; - - for (const item of rawMessages) { - if (!isRecord(item)) continue; - const role = asTrimmedString(item.role).toLowerCase() || 'user'; - - if (role === 'system' || role === 'developer') { - const normalized = normalizeResponsesMessageContent(item.content, 'user'); - const content = extractTextFromResponsesContent(normalized).trim(); - if (content) systemContents.push(content); - continue; - } - - if (role === 'assistant') { - const normalizedAssistantContent = normalizeResponsesMessageContent(item.content, 'assistant'); - if (Array.isArray(normalizedAssistantContent) && normalizedAssistantContent.length > 0) { - inputItems.push({ - type: 'message', - role: 'assistant', - content: normalizedAssistantContent, - }); - } - - const rawToolCalls = Array.isArray(item.tool_calls) ? item.tool_calls : []; - for (let index = 0; index < rawToolCalls.length; index += 1) { - const toolCall = rawToolCalls[index]; - if (!isRecord(toolCall)) continue; - const functionPart = isRecord(toolCall.function) ? toolCall.function : {}; - const callId = asTrimmedString(toolCall.id) || `call_${Date.now()}_${index}`; - const name = ( - asTrimmedString(functionPart.name) - || asTrimmedString(toolCall.name) - || `tool_${index}` - ); - const argumentsValue = normalizeOpenAiToolArguments( - functionPart.arguments ?? toolCall.arguments, - ); - - inputItems.push({ - type: 'function_call', - call_id: callId, - name, - arguments: argumentsValue, - }); - } - continue; - } - - if (role === 'tool') { - const callId = asTrimmedString(item.tool_call_id) || asTrimmedString(item.id); - if (!callId) continue; - - inputItems.push({ - type: 'function_call_output', - call_id: callId, - output: normalizeToolMessageContent(item.content), - }); - continue; - } - - const normalizedUserContent = normalizeResponsesMessageContent(item.content, 'user'); - if (Array.isArray(normalizedUserContent) && normalizedUserContent.length > 0) { - inputItems.push({ - type: 'message', - role: 'user', - content: normalizedUserContent, - }); - } - } - - const maxOutputTokens = ( - toFiniteNumber(openaiBody.max_output_tokens) - ?? toFiniteNumber(openaiBody.max_completion_tokens) - ?? toFiniteNumber(openaiBody.max_tokens) - ?? 4096 - ); - - const body: Record = { - model: modelName, - stream, - max_output_tokens: maxOutputTokens, - input: inputItems, - }; - - if (systemContents.length > 0) { - body.instructions = systemContents.join('\n\n'); - } - - const temperature = toFiniteNumber(openaiBody.temperature); - if (temperature !== null) body.temperature = temperature; - - const topP = toFiniteNumber(openaiBody.top_p); - if (topP !== null) body.top_p = topP; - - if (openaiBody.metadata !== undefined) body.metadata = openaiBody.metadata; - if (openaiBody.reasoning !== undefined) body.reasoning = openaiBody.reasoning; - if (openaiBody.parallel_tool_calls !== undefined) body.parallel_tool_calls = openaiBody.parallel_tool_calls; - if (openaiBody.tools !== undefined) body.tools = openaiBody.tools; - if (openaiBody.tool_choice !== undefined) body.tool_choice = openaiBody.tool_choice; - - return { - ...body, - input: normalizeResponsesInputForCompatibility(body.input), - }; -} - -export function sanitizeResponsesBodyForProxy( - body: Record, - modelName: string, - stream: boolean, -): Record { - let normalized: Record = { - ...body, - model: modelName, - stream, - }; - - if (normalized.input === undefined) { - if (Array.isArray((normalized as Record).messages)) { - normalized = convertOpenAiBodyToResponsesBody(normalized, modelName, stream); - } else { - const prompt = asTrimmedString((normalized as Record).prompt); - if (prompt) { - normalized = { - ...normalized, - input: [toResponsesInputMessageFromText(prompt)], - }; - } - } - } else { - normalized = { - ...normalized, - input: normalizeResponsesInputForCompatibility(normalized.input), - }; - } - - const sanitized: Record = {}; - for (const [key, value] of Object.entries(normalized)) { - if (!ALLOWED_RESPONSES_FIELDS.has(key)) continue; - if (key === 'max_completion_tokens') continue; - sanitized[key] = value; - } - - const maxOutputTokens = toFiniteNumber(normalized.max_output_tokens); - if (maxOutputTokens !== null && maxOutputTokens > 0) { - sanitized.max_output_tokens = Math.trunc(maxOutputTokens); - } else { - const maxCompletionTokens = toFiniteNumber(normalized.max_completion_tokens); - if (maxCompletionTokens !== null && maxCompletionTokens > 0) { - sanitized.max_output_tokens = Math.trunc(maxCompletionTokens); - } - } - - sanitized.model = modelName; - sanitized.stream = stream; - return sanitized; -} +export { + convertOpenAiBodyToResponsesBody, + normalizeResponsesInputForCompatibility, + normalizeResponsesMessageContent, + normalizeResponsesMessageItem, + sanitizeResponsesBodyForProxy, +}; diff --git a/src/server/transformers/openai/responses/conversion.test.ts b/src/server/transformers/openai/responses/conversion.test.ts index 55533c2c..25c93d03 100644 --- a/src/server/transformers/openai/responses/conversion.test.ts +++ b/src/server/transformers/openai/responses/conversion.test.ts @@ -5,7 +5,119 @@ import { convertResponsesBodyToOpenAiBody, sanitizeResponsesBodyForProxy, } from './conversion.js'; -import { buildResponsesCompatibilityBodies } from './compatibility.js'; +import { + buildResponsesCompatibilityBodies, + convertOpenAiBodyToResponsesBody as convertOpenAiBodyToResponsesBodyViaCompatibility, + normalizeResponsesInputForCompatibility as normalizeResponsesInputForCompatibilityViaCompatibility, + normalizeResponsesMessageContent as normalizeResponsesMessageContentViaCompatibility, + sanitizeResponsesBodyForProxy as sanitizeResponsesBodyForProxyViaCompatibility, +} from './compatibility.js'; +import { + normalizeResponsesInputForCompatibility, + normalizeResponsesMessageContent, +} from './conversion.js'; + +describe('responses conversion single source of truth', () => { + it('exports shared conversion helpers from one implementation', () => { + expect(normalizeResponsesInputForCompatibilityViaCompatibility).toBe(normalizeResponsesInputForCompatibility); + expect( + normalizeResponsesMessageContentViaCompatibility( + [{ type: 'text', text: 'hello' }], + 'user', + ), + ).toEqual( + normalizeResponsesMessageContent( + 'user', + [{ type: 'text', text: 'hello' }], + ), + ); + expect(convertOpenAiBodyToResponsesBodyViaCompatibility).toBe(convertOpenAiBodyToResponsesBody); + expect(sanitizeResponsesBodyForProxyViaCompatibility).toBe(sanitizeResponsesBodyForProxy); + }); + + it('preserves extra properties when normalizing role-only message items', () => { + expect(normalizeResponsesInputForCompatibility([ + { + role: 'assistant', + content: 'done', + id: 'msg_1', + status: 'completed', + }, + ])).toEqual([ + { + type: 'message', + role: 'assistant', + content: [{ type: 'output_text', text: 'done' }], + id: 'msg_1', + status: 'completed', + }, + ]); + }); + + it('filters whitespace-only string entries from normalized responses input arrays', () => { + expect(normalizeResponsesInputForCompatibility([ + 'hello', + ' ', + { + role: 'user', + content: 'world', + }, + ])).toEqual([ + { + type: 'message', + role: 'user', + content: [{ type: 'input_text', text: 'hello' }], + }, + { + type: 'message', + role: 'user', + content: [{ type: 'input_text', text: 'world' }], + }, + ]); + }); + + it('falls back to non-empty text and image sources when earlier compatibility fields are blank', () => { + const normalized = normalizeResponsesInputForCompatibility([ + { + role: 'assistant', + content: '', + text: 'done', + }, + { + role: 'user', + content: [ + { type: 'text', text: ' ', content: 'hello' }, + { type: 'image_url', image_url: ' ', url: 'https://example.com/image.png' }, + ], + }, + ]); + + expect(normalized).toEqual([ + expect.objectContaining({ + type: 'message', + role: 'assistant', + text: 'done', + content: [{ type: 'output_text', text: 'done' }], + }), + expect.objectContaining({ + type: 'message', + role: 'user', + content: [ + expect.objectContaining({ + type: 'input_text', + text: 'hello', + content: 'hello', + }), + expect.objectContaining({ + type: 'input_image', + image_url: 'https://example.com/image.png', + url: 'https://example.com/image.png', + }), + ], + }), + ]); + }); +}); describe('sanitizeResponsesBodyForProxy', () => { it('preserves newer Responses request fields needed by the proxy', () => { @@ -187,6 +299,205 @@ describe('sanitizeResponsesBodyForProxy', () => { }, }); }); + + it('preserves unknown top-level fields while still normalizing known compatibility fields', () => { + const result = sanitizeResponsesBodyForProxy( + { + model: 'gpt-5', + input: 'hello', + max_completion_tokens: 256, + custom_vendor_flag: 'keep-me', + }, + 'gpt-5', + false, + ); + + expect(result).toMatchObject({ + model: 'gpt-5', + stream: false, + custom_vendor_flag: 'keep-me', + max_output_tokens: 256, + }); + expect(result.max_completion_tokens).toBeUndefined(); + }); + + it('normalizes failed input item statuses before proxying Responses requests', () => { + const result = sanitizeResponsesBodyForProxy( + { + model: 'gpt-5', + input: [ + { + role: 'assistant', + status: 'failed', + content: [ + { + type: 'output_text', + text: 'tool step failed', + }, + ], + }, + { + type: 'function_call', + call_id: 'call_1', + name: 'lookup_weather', + arguments: '{"city":"Shanghai"}', + status: 'failed', + }, + { + type: 'function_call_output', + call_id: 'call_1', + output: '{"error":"timeout"}', + status: 'completed', + }, + ], + }, + 'gpt-5', + false, + ); + + expect(result.input).toEqual([ + { + type: 'message', + role: 'assistant', + status: 'incomplete', + content: [ + { + type: 'output_text', + text: 'tool step failed', + }, + ], + }, + { + type: 'function_call', + call_id: 'call_1', + name: 'lookup_weather', + arguments: '{"city":"Shanghai"}', + status: 'incomplete', + }, + { + type: 'function_call_output', + call_id: 'call_1', + output: '{"error":"timeout"}', + status: 'completed', + }, + ]); + }); + + it('drops unsupported input item statuses before proxying Responses requests', () => { + const result = sanitizeResponsesBodyForProxy( + { + model: 'gpt-5', + input: [ + { + type: 'message', + role: 'user', + status: 'errored', + content: 'hello', + }, + { + type: 'function_call', + call_id: 'call_2', + name: 'lookup_weather', + arguments: '{}', + status: 'invalid', + }, + { + type: 'reasoning', + id: 'rs_1', + status: 'broken', + summary: [], + }, + ], + }, + 'gpt-5', + false, + ); + + expect(result.input).toEqual([ + { + type: 'message', + role: 'user', + content: [ + { + type: 'input_text', + text: 'hello', + }, + ], + }, + { + type: 'function_call', + call_id: 'call_2', + name: 'lookup_weather', + arguments: '{}', + }, + { + type: 'reasoning', + id: 'rs_1', + summary: [], + }, + ]); + }); + + it('drops blank tool calls before proxying Responses requests while preserving follow-up tool outputs', () => { + const result = sanitizeResponsesBodyForProxy( + { + model: 'gpt-5', + input: [ + { + type: 'function_call', + call_id: 'call_blank', + name: ' ', + arguments: '{"city":"Shanghai"}', + }, + { + type: 'function_call_output', + call_id: 'call_blank', + output: '{"temp":22}', + }, + { + type: 'message', + role: 'assistant', + content: [ + { + type: 'output_text', + text: 'continue normally', + }, + ], + }, + { + type: 'function_call_output', + call_id: 'call_missing', + output: 'orphaned output', + }, + ], + }, + 'gpt-5', + false, + ); + + expect(result.input).toEqual([ + { + type: 'function_call_output', + call_id: 'call_blank', + output: '{"temp":22}', + }, + { + type: 'message', + role: 'assistant', + content: [ + { + type: 'output_text', + text: 'continue normally', + }, + ], + }, + { + type: 'function_call_output', + call_id: 'call_missing', + output: 'orphaned output', + }, + ]); + }); }); describe('convertOpenAiBodyToResponsesBody', () => { @@ -387,29 +698,296 @@ describe('convertOpenAiBodyToResponsesBody', () => { messages: [{ role: 'user', content: 'hello' }], }, 'gpt-5', - true, + true, + ); + + expect(result.include).toBeUndefined(); + }); + + it('maps OpenAI file-style content blocks into inline-only Responses input_file blocks', () => { + const result = convertOpenAiBodyToResponsesBody( + { + model: 'gpt-5', + messages: [ + { + role: 'user', + content: [ + { type: 'text', text: 'summarize this file' }, + { + type: 'file', + file_id: 'file_local_123', + filename: 'report.pdf', + mime_type: 'application/pdf', + file_data: 'JVBERi0xLjQK', + }, + ], + }, + ], + }, + 'gpt-5', + false, + ); + + expect(result.input).toEqual([ + { + type: 'message', + role: 'user', + content: [ + { type: 'input_text', text: 'summarize this file' }, + { + type: 'input_file', + filename: 'report.pdf', + file_data: 'data:application/pdf;base64,JVBERi0xLjQK', + }, + ], + }, + ]); + }); + + it('preserves structured tool outputs and assistant phase when converting Responses bodies to OpenAI-compatible messages', () => { + const result = convertResponsesBodyToOpenAiBody( + { + model: 'gpt-5', + input: [ + { + type: 'function_call', + call_id: 'call_1', + name: 'lookup_weather', + arguments: '{"city":"Shanghai"}', + }, + { + type: 'function_call_output', + call_id: 'call_1', + output: [ + { + type: 'output_text', + text: 'tool result', + }, + { + type: 'input_image', + image_url: 'https://example.com/tool.png', + }, + ], + }, + { + type: 'message', + role: 'assistant', + phase: 'analysis', + content: [ + { + type: 'output_text', + text: 'done', + }, + ], + }, + ], + }, + 'gpt-5', + false, + ); + + expect(result.messages).toEqual([ + { + role: 'assistant', + content: '', + tool_calls: [{ + id: 'call_1', + type: 'function', + function: { + name: 'lookup_weather', + arguments: '{"city":"Shanghai"}', + }, + }], + }, + { + role: 'tool', + tool_call_id: 'call_1', + content: [ + { + type: 'text', + text: 'tool result', + }, + { + type: 'image_url', + image_url: 'https://example.com/tool.png', + }, + ], + }, + { + role: 'assistant', + phase: 'analysis', + content: [{ + type: 'text', + text: 'done', + }], + }, + ]); + }); + + it('drops tool outputs that do not have a matching prior tool call when converting to OpenAI-compatible messages', () => { + const result = convertResponsesBodyToOpenAiBody( + { + model: 'gpt-5', + input: [ + { + type: 'function_call_output', + call_id: 'call_missing', + output: 'orphaned tool result', + }, + { + type: 'message', + role: 'assistant', + content: [ + { + type: 'output_text', + text: 'continue normally', + }, + ], + }, + ], + }, + 'gpt-5', + false, + ); + + expect(result.messages).toEqual([ + { + role: 'assistant', + content: [ + { + type: 'text', + text: 'continue normally', + }, + ], + }, + ]); + }); + + it('shortens long MCP tool names consistently across tools, tool_choice and assistant tool calls', () => { + const sharedSuffix = 'server__execute_super_long_nested_tool_name_that_needs_shortening'; + const firstName = `mcp__alpha_workspace__${sharedSuffix}`; + const secondName = `mcp__beta_workspace__${sharedSuffix}`; + + const result = convertOpenAiBodyToResponsesBody( + { + model: 'gpt-5', + messages: [ + { + role: 'assistant', + tool_calls: [ + { + id: 'call_1', + type: 'function', + function: { + name: firstName, + arguments: '{"city":"shanghai"}', + }, + }, + ], + }, + { + role: 'tool', + tool_call_id: 'call_1', + content: 'done', + }, + ], + tools: [ + { + type: 'function', + function: { + name: firstName, + parameters: { type: 'object' }, + }, + }, + { + type: 'function', + function: { + name: secondName, + parameters: { type: 'object' }, + }, + }, + ], + tool_choice: { + type: 'function', + function: { + name: secondName, + }, + }, + }, + 'gpt-5', + false, + ); + + const toolNames = Array.isArray(result.tools) + ? result.tools.map((tool: any) => tool.name) + : []; + const assistantCall = Array.isArray(result.input) + ? result.input.find((item: any) => item.type === 'function_call') + : null; + + expect(toolNames).toHaveLength(2); + expect(toolNames[0].length).toBeLessThanOrEqual(64); + expect(toolNames[1].length).toBeLessThanOrEqual(64); + expect(toolNames[0].startsWith('mcp__')).toBe(true); + expect(toolNames[1].startsWith('mcp__')).toBe(true); + expect(toolNames[0]).not.toBe(toolNames[1]); + expect(result.tool_choice).toEqual({ + type: 'function', + name: toolNames[1], + }); + expect(assistantCall).toMatchObject({ + type: 'function_call', + name: toolNames[0], + }); + }); + + it('drops function tools with blank names when converting OpenAI-compatible input into Responses bodies', () => { + const result = convertOpenAiBodyToResponsesBody( + { + model: 'gpt-5', + messages: [{ role: 'user', content: 'hello' }], + tools: [ + { + type: 'function', + function: { + name: ' ', + parameters: { type: 'object' }, + }, + }, + { + type: 'function', + function: { + name: 'lookup_weather', + parameters: { type: 'object' }, + }, + }, + ], + }, + 'gpt-5', + false, ); - expect(result.include).toBeUndefined(); + expect(result.tools).toEqual([ + { + type: 'function', + name: 'lookup_weather', + parameters: { type: 'object' }, + }, + ]); }); - it('maps OpenAI file-style content blocks into Responses input_file blocks', () => { + it('drops all-invalid function tools instead of forwarding blank names into Responses bodies', () => { const result = convertOpenAiBodyToResponsesBody( { model: 'gpt-5', - messages: [ + messages: [{ role: 'user', content: 'hello' }], + tools: [ { - role: 'user', - content: [ - { type: 'text', text: 'summarize this file' }, - { - type: 'file', - file_id: 'file_local_123', - filename: 'report.pdf', - mime_type: 'application/pdf', - file_data: 'JVBERi0xLjQK', - }, - ], + type: 'function', + function: { + name: ' ', + parameters: { type: 'object' }, + }, }, ], }, @@ -417,21 +995,7 @@ describe('convertOpenAiBodyToResponsesBody', () => { false, ); - expect(result.input).toEqual([ - { - type: 'message', - role: 'user', - content: [ - { type: 'input_text', text: 'summarize this file' }, - { - type: 'input_file', - file_id: 'file_local_123', - filename: 'report.pdf', - file_data: 'data:application/pdf;base64,JVBERi0xLjQK', - }, - ], - }, - ]); + expect(result.tools).toEqual([]); }); }); @@ -474,6 +1038,7 @@ describe('convertResponsesBodyToOpenAiBody', () => { file: { filename: 'paper.pdf', file_data: Buffer.from('%PDF-demo').toString('base64'), + mime_type: 'application/pdf', }, }, ], @@ -617,6 +1182,65 @@ describe('convertResponsesBodyToOpenAiBody', () => { ]); }); + it('round-trips mcp item families through OpenAI-compatible fallback using compatibility tool calls', () => { + const source = { + model: 'gpt-5', + input: [ + { + type: 'mcp_call', + id: 'mcp_call_1', + call_id: 'mcp_call_1', + name: 'read_file', + server_label: 'filesystem', + arguments: { + path: '/tmp/demo.txt', + }, + }, + { + type: 'mcp_approval_request', + id: 'mcp_approval_request_1', + approval_request_id: 'mcp_approval_request_1', + name: 'read_file', + server_label: 'filesystem', + arguments: { + path: '/tmp/demo.txt', + }, + }, + { + type: 'mcp_approval_response', + id: 'mcp_approval_response_1', + approval_request_id: 'mcp_approval_request_1', + approve: true, + }, + ], + }; + + const openAiBody = convertResponsesBodyToOpenAiBody( + source, + 'gpt-5', + false, + ); + + expect(openAiBody.messages).toHaveLength(1); + expect(openAiBody.messages[0]).toMatchObject({ + role: 'assistant', + content: '', + tool_calls: [ + { id: 'mcp_call_1', type: 'function' }, + { id: 'mcp_approval_request_1', type: 'function' }, + { id: 'mcp_approval_response_1', type: 'function' }, + ], + }); + + const roundTripped = convertOpenAiBodyToResponsesBody( + openAiBody, + 'gpt-5', + false, + ); + + expect(roundTripped.input).toEqual(source.input); + }); + it('preserves remaining request fields needed for OpenAI-compatible downstream fallback', () => { const result = convertResponsesBodyToOpenAiBody( { @@ -766,7 +1390,7 @@ describe('convertResponsesBodyToOpenAiBody', () => { }); }); - it('keeps Responses input_file items when converting back to OpenAI-compatible bodies', () => { + it('keeps Responses input_file items when converting back to OpenAI-compatible bodies without conflicting file ids', () => { const result = convertResponsesBodyToOpenAiBody( { model: 'gpt-5', @@ -799,7 +1423,6 @@ describe('convertResponsesBodyToOpenAiBody', () => { { type: 'file', file: { - file_id: 'file_local_456', filename: 'notes.md', mime_type: 'text/markdown', file_data: 'IyBoZWxsbwo=', @@ -810,11 +1433,52 @@ describe('convertResponsesBodyToOpenAiBody', () => { ]); }); + it('keeps Responses input_file file_url items when converting back to OpenAI-compatible bodies', () => { + const result = convertResponsesBodyToOpenAiBody( + { + model: 'gpt-5', + input: [ + { + type: 'message', + role: 'user', + content: [ + { type: 'input_text', text: 'read this remote file' }, + { + type: 'input_file', + filename: 'remote.pdf', + file_url: 'https://example.com/remote.pdf', + }, + ], + }, + ], + }, + 'gpt-5', + false, + ); + + expect(result.messages).toEqual([ + { + role: 'user', + content: [ + { type: 'text', text: 'read this remote file' }, + { + type: 'file', + file: { + filename: 'remote.pdf', + file_url: 'https://example.com/remote.pdf', + }, + }, + ], + }, + ]); + }); + it('keeps richer field parity on compatibility retry bodies when metadata is absent', () => { const candidates = buildResponsesCompatibilityBodies({ model: 'gpt-5', input: 'hello', stream: true, + parallel_tool_calls: true, include: ['reasoning.encrypted_content'], reasoning: { effort: 'high', @@ -826,12 +1490,20 @@ describe('convertResponsesBodyToOpenAiBody', () => { prompt_cache_retention: { scope: 'workspace' }, background: true, top_logprobs: 2, + previous_response_id: 'resp_prev_9', + truncation: 'auto', + service_tier: 'priority', + text: { + verbosity: 'high', + }, }); expect(candidates).toContainEqual({ model: 'gpt-5', input: 'hello', stream: true, + parallel_tool_calls: true, + include: ['reasoning.encrypted_content'], reasoning: { effort: 'high', summary: 'auto', @@ -842,6 +1514,250 @@ describe('convertResponsesBodyToOpenAiBody', () => { prompt_cache_retention: { scope: 'workspace' }, background: true, top_logprobs: 2, + previous_response_id: 'resp_prev_9', + truncation: 'auto', + service_tier: 'priority', + text: { + verbosity: 'high', + }, + }); + }); + + it('preserves tools and tool_choice across all compatibility retry bodies', () => { + const candidates = buildResponsesCompatibilityBodies({ + model: 'gpt-5', + input: 'hello', + stream: true, + metadata: { user_id: 'user-1' }, + instructions: 'be helpful', + tools: [{ + type: 'function', + function: { + name: 'lookup_weather', + parameters: { + type: 'object', + properties: { + city: { type: 'string' }, + }, + }, + }, + }], + tool_choice: { + type: 'function', + function: { + name: 'lookup_weather', + }, + }, + }); + + expect(candidates.length).toBeGreaterThan(0); + for (const candidate of candidates) { + expect(candidate.tools).toEqual([{ + type: 'function', + function: { + name: 'lookup_weather', + parameters: { + type: 'object', + properties: { + city: { type: 'string' }, + }, + }, + }, + }]); + expect(candidate.tool_choice).toEqual({ + type: 'function', + function: { + name: 'lookup_weather', + }, + }); + } + }); + + it('maps native tool choices and strict function tools into OpenAI-compatible fallback bodies', () => { + const result = convertResponsesBodyToOpenAiBody( + { + model: 'gpt-5', + input: 'hello', + tools: [{ + type: 'function', + name: 'lookup_weather', + parameters: { + type: 'object', + properties: { + city: { type: 'string' }, + }, + }, + strict: true, + }], + tool_choice: { + type: 'tool', + name: 'lookup_weather', + }, + }, + 'gpt-5', + false, + ); + + expect(result.tools).toEqual([{ + type: 'function', + function: { + name: 'lookup_weather', + parameters: { + type: 'object', + properties: { + city: { type: 'string' }, + }, + }, + strict: true, + }, + }]); + expect(result.tool_choice).toEqual({ + type: 'function', + function: { + name: 'lookup_weather', + }, + }); + }); + + it('drops blank native function tools and tool choices when converting to OpenAI-compatible fallback bodies', () => { + const result = convertResponsesBodyToOpenAiBody( + { + model: 'gpt-5', + input: 'hello', + tools: [{ + type: 'function', + name: ' ', + parameters: { + type: 'object', + }, + }], + tool_choice: { + type: 'tool', + name: ' ', + }, + }, + 'gpt-5', + false, + ); + + expect(result.tools).toEqual([]); + expect(result.tool_choice).toBeUndefined(); + }); + + it('derives Responses reasoning config from chat-style reasoning request fields', () => { + const result = convertOpenAiBodyToResponsesBody( + { + model: 'gpt-5', + messages: [{ role: 'user', content: 'hello' }], + reasoning_effort: 'high', + reasoning_budget: 2048, + reasoning_summary: 'detailed', + }, + 'gpt-5', + false, + ); + + expect(result).toMatchObject({ + model: 'gpt-5', + reasoning: { + effort: 'high', + budget_tokens: 2048, + summary: 'detailed', + }, + }); + }); + + it('preserves assistant reasoning history when converting OpenAI-compatible bodies to Responses input', () => { + const result = convertOpenAiBodyToResponsesBody( + { + model: 'gpt-5', + messages: [{ + role: 'assistant', + content: '', + reasoning_content: 'plan quietly', + reasoning_signature: 'sig_1', + tool_calls: [{ + id: 'call_1', + type: 'function', + function: { + name: 'Glob', + arguments: '{"pattern":"README*"}', + }, + }], + }], + }, + 'gpt-5', + false, + ); + + expect(result.input).toEqual([ + { + type: 'reasoning', + summary: [{ + type: 'summary_text', + text: 'plan quietly', + }], + encrypted_content: 'sig_1', + }, + { + type: 'function_call', + call_id: 'call_1', + name: 'Glob', + arguments: '{"pattern":"README*"}', + }, + ]); + }); + + it('preserves chat-native modalities and audio settings when converting to Responses bodies', () => { + const result = convertOpenAiBodyToResponsesBody( + { + model: 'gpt-5', + messages: [{ role: 'user', content: 'hello' }], + metadata: { user_id: 'user-1' }, + modalities: ['text', 'audio'], + audio: { + voice: 'alloy', + format: 'mp3', + }, + }, + 'gpt-5', + false, + ); + + expect(result).toMatchObject({ + metadata: { user_id: 'user-1' }, + modalities: ['text', 'audio'], + audio: { + voice: 'alloy', + format: 'mp3', + }, + }); + }); + + it('preserves metadata, modalities, and audio when converting Responses bodies back to OpenAI-compatible bodies', () => { + const result = convertResponsesBodyToOpenAiBody( + { + model: 'gpt-5', + input: 'hello', + metadata: { user_id: 'user-1' }, + modalities: ['text', 'audio'], + audio: { + voice: 'alloy', + format: 'mp3', + }, + }, + 'gpt-5', + false, + ); + + expect(result).toMatchObject({ + model: 'gpt-5', + metadata: { user_id: 'user-1' }, + modalities: ['text', 'audio'], + audio: { + voice: 'alloy', + format: 'mp3', + }, }); }); @@ -887,6 +1803,27 @@ describe('convertResponsesBodyToOpenAiBody', () => { }); }); + it('preserves responses metadata when converting back to OpenAI-compatible bodies', () => { + const result = convertResponsesBodyToOpenAiBody( + { + model: 'gpt-5', + input: 'hello', + metadata: { + user_id: 'user-1', + }, + }, + 'gpt-5', + false, + ); + + expect(result).toMatchObject({ + model: 'gpt-5', + metadata: { + user_id: 'user-1', + }, + }); + }); + it('normalizes field parity when converting Responses input back to OpenAI-compatible input', () => { const result = convertResponsesBodyToOpenAiBody( { diff --git a/src/server/transformers/openai/responses/conversion.ts b/src/server/transformers/openai/responses/conversion.ts index b61f9864..c2507c7c 100644 --- a/src/server/transformers/openai/responses/conversion.ts +++ b/src/server/transformers/openai/responses/conversion.ts @@ -1,8 +1,15 @@ import { - normalizeResponsesInputForCompatibility as normalizeResponsesInputForCompatibilityViaCompatibility, + normalizeResponsesInputForCompatibility, + normalizeResponsesMessageContentBlocks, normalizeResponsesMessageItem, -} from './compatibility.js'; +} from './normalization.js'; +import { + decodeResponsesMcpCompatToolCall, + isResponsesMcpItem, + toResponsesMcpCompatToolCall, +} from './mcpCompatibility.js'; import { normalizeInputFileBlock, toOpenAiChatFileBlock } from '../../shared/inputFile.js'; +import { buildShortToolNameMap, getShortToolName } from '../../shared/toolNameShortener.js'; function isRecord(value: unknown): value is Record { return !!value && typeof value === 'object'; @@ -177,6 +184,20 @@ function normalizeResponsesRequestFieldParity( normalized.text = textConfig; } + if (!isRecord(normalized.reasoning)) { + const reasoning: Record = {}; + const effort = normalizeOptionalTrimmedString((normalized as Record).reasoning_effort); + if (effort) reasoning.effort = effort; + const budgetTokens = toFiniteIntegerLike((normalized as Record).reasoning_budget); + if (budgetTokens !== null) reasoning.budget_tokens = budgetTokens; + const summary = normalizeOptionalTrimmedString((normalized as Record).reasoning_summary); + if (summary) reasoning.summary = summary; + if (Object.keys(reasoning).length > 0) normalized.reasoning = reasoning; + } + delete normalized.reasoning_effort; + delete normalized.reasoning_budget; + delete normalized.reasoning_summary; + return normalized; } @@ -225,9 +246,12 @@ function normalizeOpenAiToolArguments(raw: unknown): string { return ''; } -function normalizeToolOutput(raw: unknown): string { - const text = extractTextContent(raw).trim(); - if (text) return text; +function normalizeToolOutput(raw: unknown): string | Array> { + const normalizedContent = toOpenAiMessageContent(raw); + const hasNormalizedContent = typeof normalizedContent === 'string' + ? normalizedContent.trim().length > 0 + : Array.isArray(normalizedContent) && normalizedContent.length > 0; + if (hasNormalizedContent) return normalizedContent; if (raw === undefined || raw === null) return ''; if (typeof raw === 'string') return raw; if (typeof raw === 'number' || typeof raw === 'boolean') return String(raw); @@ -235,10 +259,6 @@ function normalizeToolOutput(raw: unknown): string { return ''; } -export function normalizeResponsesInputForCompatibility(input: unknown): unknown { - return normalizeResponsesInputForCompatibilityViaCompatibility(input); -} - function toResponsesInputMessageFromText(text: string): Record { return { type: 'message', @@ -247,7 +267,49 @@ function toResponsesInputMessageFromText(text: string): Record }; } -function convertOpenAiToolsToResponses(rawTools: unknown): unknown { +function collectOpenAiToolNames(body: Record): string[] { + const names: string[] = []; + const pushName = (value: unknown) => { + const name = asTrimmedString(value); + if (name) names.push(name); + }; + + const rawTools = Array.isArray(body.tools) ? body.tools : []; + for (const item of rawTools) { + if (!isRecord(item)) continue; + const type = asTrimmedString(item.type).toLowerCase(); + if (type === 'function' && isRecord(item.function)) { + pushName(item.function.name); + continue; + } + if (type === 'function') { + pushName(item.name); + } + } + + const toolChoice = isRecord(body.tool_choice) ? body.tool_choice : null; + if (toolChoice && asTrimmedString(toolChoice.type).toLowerCase() === 'function') { + pushName(isRecord(toolChoice.function) ? toolChoice.function.name : toolChoice.name); + } + + const rawMessages = Array.isArray(body.messages) ? body.messages : []; + for (const message of rawMessages) { + if (!isRecord(message) || asTrimmedString(message.role).toLowerCase() !== 'assistant') continue; + const toolCalls = Array.isArray(message.tool_calls) ? message.tool_calls : []; + for (const toolCall of toolCalls) { + if (!isRecord(toolCall)) continue; + const functionPart = isRecord(toolCall.function) ? toolCall.function : null; + pushName(functionPart?.name ?? toolCall.name); + } + } + + return names; +} + +function convertOpenAiToolsToResponses( + rawTools: unknown, + toolNameMap: Record, +): unknown { if (!Array.isArray(rawTools)) return rawTools; const converted = rawTools @@ -262,7 +324,7 @@ function convertOpenAiToolsToResponses(rawTools: unknown): unknown { const mapped: Record = { type: 'function', - name, + name: getShortToolName(name, toolNameMap), }; const description = asTrimmedString(fn.description); if (description) mapped.description = description; @@ -272,7 +334,10 @@ function convertOpenAiToolsToResponses(rawTools: unknown): unknown { } if (type === 'function' && asTrimmedString(item.name)) { - return item; + return { + ...item, + name: getShortToolName(asTrimmedString(item.name), toolNameMap), + }; } if (type === 'image_generation') { @@ -287,10 +352,13 @@ function convertOpenAiToolsToResponses(rawTools: unknown): unknown { }) .filter((item): item is Record => !!item); - return converted.length > 0 ? converted : rawTools; + return converted; } -function convertOpenAiToolChoiceToResponses(rawToolChoice: unknown): unknown { +function convertOpenAiToolChoiceToResponses( + rawToolChoice: unknown, + toolNameMap: Record, +): unknown { if (rawToolChoice === undefined) return undefined; if (typeof rawToolChoice === 'string') return rawToolChoice; if (!isRecord(rawToolChoice)) return rawToolChoice; @@ -299,7 +367,7 @@ function convertOpenAiToolChoiceToResponses(rawToolChoice: unknown): unknown { if (type === 'function' && isRecord(rawToolChoice.function)) { const name = asTrimmedString(rawToolChoice.function.name); if (!name) return 'required'; - return { type: 'function', name }; + return { type: 'function', name: getShortToolName(name, toolNameMap) }; } return rawToolChoice; @@ -316,52 +384,59 @@ function normalizeResponsesBodyForCompatibility( }; } -export function normalizeResponsesMessageContent(role: string, content: unknown): Array> { - const normalized = normalizeResponsesMessageItem({ - type: 'message', - role, - content, - }); +const RESPONSES_TOOL_CALL_INPUT_TYPES = new Set([ + 'function_call', + 'custom_tool_call', +]); + +const RESPONSES_TOOL_CALL_OUTPUT_TYPES = new Set([ + 'function_call_output', + 'custom_tool_call_output', +]); + +function stripOrphanedResponsesToolOutputs(input: unknown): unknown { + if (!Array.isArray(input)) return input; - if (isRecord(normalized) && Array.isArray(normalized.content)) { - return normalized.content.filter((item): item is Record => isRecord(item)); + const seenToolCallIds = new Set(); + const sanitized: unknown[] = []; + + for (const item of input) { + if (!isRecord(item)) { + sanitized.push(item); + continue; + } + + const type = asTrimmedString(item.type).toLowerCase(); + if (RESPONSES_TOOL_CALL_INPUT_TYPES.has(type)) { + const callId = asTrimmedString(item.call_id ?? item.id); + if (callId) seenToolCallIds.add(callId); + sanitized.push(item); + continue; + } + + if (RESPONSES_TOOL_CALL_OUTPUT_TYPES.has(type)) { + const callId = asTrimmedString(item.call_id ?? item.id); + if (!callId || !seenToolCallIds.has(callId)) continue; + sanitized.push(item); + continue; + } + + sanitized.push(item); } - return []; + return sanitized; +} + +export function normalizeResponsesMessageContent(role: string, content: unknown): Array> { + return normalizeResponsesMessageContentBlocks(role, content); } -const ALLOWED_RESPONSES_FIELDS = new Set([ - 'model', - 'input', - 'instructions', - 'max_output_tokens', +const RESPONSES_COMPATIBILITY_FILTER_FIELDS = new Set([ 'max_completion_tokens', - 'temperature', - 'top_p', - 'truncation', - 'tools', - 'tool_choice', - 'parallel_tool_calls', - 'metadata', - 'reasoning', - 'store', - 'safety_identifier', - 'stream', - 'stream_options', - 'user', - 'max_tool_calls', - 'prompt_cache_key', - 'prompt_cache_retention', - 'background', - 'previous_response_id', - 'text', - 'audio', - 'include', + 'messages', + 'prompt', 'response_format', - 'service_tier', - 'top_logprobs', - 'stop', - 'n', + 'verbosity', ]); export function sanitizeResponsesBodyForProxy( @@ -397,11 +472,9 @@ export function sanitizeResponsesBodyForProxy( defaultEncryptedReasoningInclude: options?.defaultEncryptedReasoningInclude, }); - const sanitized: Record = {}; - for (const [key, value] of Object.entries(normalized)) { - if (!ALLOWED_RESPONSES_FIELDS.has(key)) continue; - if (key === 'max_completion_tokens') continue; - sanitized[key] = value; + const sanitized: Record = { ...normalized }; + for (const key of RESPONSES_COMPATIBILITY_FILTER_FIELDS) { + delete sanitized[key]; } const maxOutputTokens = toFiniteNumber(normalized.max_output_tokens); @@ -427,6 +500,7 @@ export function convertOpenAiBodyToResponsesBody( const rawMessages = Array.isArray(openaiBody.messages) ? openaiBody.messages : []; const systemContents: string[] = []; const inputItems: Array> = []; + const toolNameMap = buildShortToolNameMap(collectOpenAiToolNames(openaiBody)); for (const item of rawMessages) { if (!isRecord(item)) continue; @@ -439,6 +513,28 @@ export function convertOpenAiBodyToResponsesBody( } if (role === 'assistant') { + const reasoningContent = extractTextContent( + item.reasoning_content + ?? item.reasoning + ?? item.thinking, + ).trim(); + const reasoningSignature = asTrimmedString(item.reasoning_signature); + if (reasoningContent || reasoningSignature) { + const reasoningItem: Record = { + type: 'reasoning', + }; + if (reasoningContent) { + reasoningItem.summary = [{ + type: 'summary_text', + text: reasoningContent, + }]; + } + if (reasoningSignature) { + reasoningItem.encrypted_content = reasoningSignature; + } + inputItems.push(reasoningItem); + } + const normalizedContent = normalizeResponsesMessageContent('assistant', item.content); if (normalizedContent.length > 0) { inputItems.push({ @@ -453,6 +549,14 @@ export function convertOpenAiBodyToResponsesBody( const toolCall = rawToolCalls[index]; if (!isRecord(toolCall)) continue; const functionPart = isRecord(toolCall.function) ? toolCall.function : {}; + const mcpItem = decodeResponsesMcpCompatToolCall( + functionPart.name ?? toolCall.name, + functionPart.arguments ?? toolCall.arguments, + ); + if (mcpItem) { + inputItems.push(mcpItem); + continue; + } const callId = asTrimmedString(toolCall.id) || `call_${Date.now()}_${index}`; const name = ( asTrimmedString(functionPart.name) @@ -466,7 +570,7 @@ export function convertOpenAiBodyToResponsesBody( inputItems.push({ type: 'function_call', call_id: callId, - name, + name: getShortToolName(name, toolNameMap), arguments: argumentsValue, }); } @@ -518,9 +622,14 @@ export function convertOpenAiBodyToResponsesBody( if (topP !== null) body.top_p = topP; if (openaiBody.metadata !== undefined) body.metadata = openaiBody.metadata; + if (openaiBody.modalities !== undefined) body.modalities = cloneJsonValue(openaiBody.modalities); + if (openaiBody.audio !== undefined) body.audio = cloneJsonValue(openaiBody.audio); if (openaiBody.reasoning !== undefined) body.reasoning = openaiBody.reasoning; + if (openaiBody.reasoning_effort !== undefined) body.reasoning_effort = openaiBody.reasoning_effort; + if (openaiBody.reasoning_budget !== undefined) body.reasoning_budget = openaiBody.reasoning_budget; + if (openaiBody.reasoning_summary !== undefined) body.reasoning_summary = openaiBody.reasoning_summary; if (openaiBody.parallel_tool_calls !== undefined) body.parallel_tool_calls = openaiBody.parallel_tool_calls; - if (openaiBody.tools !== undefined) body.tools = convertOpenAiToolsToResponses(openaiBody.tools); + if (openaiBody.tools !== undefined) body.tools = convertOpenAiToolsToResponses(openaiBody.tools, toolNameMap); if (openaiBody.safety_identifier !== undefined) body.safety_identifier = openaiBody.safety_identifier; if (openaiBody.max_tool_calls !== undefined) body.max_tool_calls = openaiBody.max_tool_calls; if (openaiBody.prompt_cache_key !== undefined) body.prompt_cache_key = openaiBody.prompt_cache_key; @@ -546,8 +655,11 @@ export function convertOpenAiBodyToResponsesBody( body.text = textConfig; } - const responsesToolChoice = convertOpenAiToolChoiceToResponses(openaiBody.tool_choice); + const responsesToolChoice = convertOpenAiToolChoiceToResponses(openaiBody.tool_choice, toolNameMap); if (responsesToolChoice !== undefined) body.tool_choice = responsesToolChoice; + if (Array.isArray(body.tools) && body.tools.length === 0) { + delete body.tool_choice; + } return normalizeResponsesBodyForCompatibility( normalizeResponsesRequestFieldParity(body, { verbositySource: openaiBody.verbosity }), @@ -657,28 +769,30 @@ function toOpenAiMessageContent(content: unknown): string | Array { - if (!isRecord(item)) return item; - const type = asTrimmedString(item.type).toLowerCase(); + return rawTools + .map((item) => { + if (!isRecord(item)) return item; + const type = asTrimmedString(item.type).toLowerCase(); - if (type === 'custom' || type === 'image_generation') return item; - if (type !== 'function') return item; - if (isRecord(item.function) && asTrimmedString(item.function.name)) return item; + if (type === 'custom' || type === 'image_generation') return item; + if (type !== 'function') return item; + if (isRecord(item.function) && asTrimmedString(item.function.name)) return item; - const name = asTrimmedString(item.name); - if (!name) return item; + const name = asTrimmedString(item.name); + if (!name) return null; - const fn: Record = { name }; - const description = asTrimmedString(item.description); - if (description) fn.description = description; - if (item.parameters !== undefined) fn.parameters = item.parameters; - if (item.strict !== undefined) fn.strict = item.strict; + const fn: Record = { name }; + const description = asTrimmedString(item.description); + if (description) fn.description = description; + if (item.parameters !== undefined) fn.parameters = item.parameters; + if (item.strict !== undefined) fn.strict = item.strict; - return { - type: 'function', - function: fn, - }; - }); + return { + type: 'function', + function: fn, + }; + }) + .filter((item): item is Record => !!item); } function convertResponsesToolChoiceToOpenAi(rawToolChoice: unknown): unknown { @@ -687,6 +801,14 @@ function convertResponsesToolChoiceToOpenAi(rawToolChoice: unknown): unknown { if (!isRecord(rawToolChoice)) return rawToolChoice; const type = asTrimmedString(rawToolChoice.type).toLowerCase(); + if (type === 'tool') { + const name = asTrimmedString(rawToolChoice.name); + if (!name) return 'required'; + return { + type: 'function', + function: { name }, + }; + } if (type === 'function') { if (isRecord(rawToolChoice.function) && asTrimmedString(rawToolChoice.function.name)) { return rawToolChoice; @@ -713,11 +835,13 @@ export function convertResponsesBodyToOpenAiBody( stream: boolean, options?: { defaultEncryptedReasoningInclude?: boolean }, ): Record { - const normalizedBody = normalizeResponsesRequestFieldParity(body, { - defaultEncryptedReasoningInclude: options?.defaultEncryptedReasoningInclude, - }); + const normalizedBody = normalizeResponsesBodyForCompatibility( + normalizeResponsesRequestFieldParity(body, { + defaultEncryptedReasoningInclude: options?.defaultEncryptedReasoningInclude, + }), + ); const messages: Array> = []; - const input = normalizedBody.input; + const input = stripOrphanedResponsesToolOutputs(normalizedBody.input); let functionCallIndex = 0; let pendingToolCalls: OpenAiToolCall[] = []; @@ -752,6 +876,15 @@ export function convertResponsesBodyToOpenAiBody( if (!isRecord(item)) return; const itemType = asTrimmedString(item.type).toLowerCase(); + if (itemType.startsWith('mcp_') && isResponsesMcpItem(item)) { + const toolCall = toResponsesMcpCompatToolCall(item, `call_${Date.now()}_${functionCallIndex}`); + if (toolCall) { + pendingToolCalls.push(toolCall as OpenAiToolCall); + functionCallIndex += 1; + return; + } + } + if (itemType === 'function_call' || itemType === 'custom_tool_call') { const toolCall = toOpenAiToolCall(item, functionCallIndex); functionCallIndex += 1; @@ -800,10 +933,13 @@ export function convertResponsesBodyToOpenAiBody( : Array.isArray(content) && content.length > 0; if (!hasContent) return; - messages.push({ + const message: Record = { role: normalizedRole, content, - }); + }; + const phase = asTrimmedString(item.phase); + if (phase) message.phase = phase; + messages.push(message); }; if (typeof input === 'string') { @@ -836,9 +972,15 @@ export function convertResponsesBodyToOpenAiBody( if (typeof normalizedBody.max_output_tokens === 'number' && Number.isFinite(normalizedBody.max_output_tokens)) { payload.max_tokens = normalizedBody.max_output_tokens; } + if (normalizedBody.metadata !== undefined) payload.metadata = cloneJsonValue(normalizedBody.metadata); + if (normalizedBody.modalities !== undefined) payload.modalities = cloneJsonValue(normalizedBody.modalities); + if (normalizedBody.audio !== undefined) payload.audio = cloneJsonValue(normalizedBody.audio); if (normalizedBody.parallel_tool_calls !== undefined) payload.parallel_tool_calls = normalizedBody.parallel_tool_calls; if (normalizedBody.tools !== undefined) payload.tools = convertResponsesToolsToOpenAi(normalizedBody.tools); if (normalizedBody.tool_choice !== undefined) payload.tool_choice = convertResponsesToolChoiceToOpenAi(normalizedBody.tool_choice); + if (Array.isArray(payload.tools) && payload.tools.length === 0) { + delete payload.tool_choice; + } if (normalizedBody.safety_identifier !== undefined) payload.safety_identifier = normalizedBody.safety_identifier; if (normalizedBody.max_tool_calls !== undefined) payload.max_tool_calls = normalizedBody.max_tool_calls; if (normalizedBody.prompt_cache_key !== undefined) payload.prompt_cache_key = normalizedBody.prompt_cache_key; @@ -861,3 +1003,5 @@ export function convertResponsesBodyToOpenAiBody( return payload; } + +export { normalizeResponsesInputForCompatibility }; diff --git a/src/server/transformers/openai/responses/inbound.ts b/src/server/transformers/openai/responses/inbound.ts index 153524c9..9001ae42 100644 --- a/src/server/transformers/openai/responses/inbound.ts +++ b/src/server/transformers/openai/responses/inbound.ts @@ -1,6 +1,55 @@ -import { normalizeResponsesInputForCompatibility, normalizeResponsesMessageItem } from './compatibility.js'; +import { createProtocolRequestEnvelope } from '../../shared/protocolModel.js'; +import { sanitizeResponsesBodyForProxy } from './conversion.js'; +import type { + OpenAiResponsesParsedRequest, + OpenAiResponsesRequestEnvelope, +} from './model.js'; + +function isRecord(value: unknown): value is Record { + return !!value && typeof value === 'object' && !Array.isArray(value); +} + +function invalidRequest(message: string): { statusCode: number; payload: unknown } { + return { + statusCode: 400, + payload: { + error: { + message, + type: 'invalid_request_error', + }, + }, + }; +} export const openAiResponsesInbound = { - normalizeInput: normalizeResponsesInputForCompatibility, - normalizeMessage: normalizeResponsesMessageItem, + parse( + body: unknown, + options?: { defaultEncryptedReasoningInclude?: boolean }, + ): { value?: OpenAiResponsesRequestEnvelope; error?: { statusCode: number; payload: unknown } } { + const rawBody = isRecord(body) ? body : {}; + const requestedModel = typeof rawBody.model === 'string' ? rawBody.model.trim() : ''; + if (!requestedModel) { + return { error: invalidRequest('model is required') }; + } + + const isStream = rawBody.stream === true; + const normalizedBody = sanitizeResponsesBodyForProxy( + rawBody, + requestedModel, + isStream, + { defaultEncryptedReasoningInclude: options?.defaultEncryptedReasoningInclude }, + ); + + return { + value: createProtocolRequestEnvelope({ + protocol: 'openai/responses', + model: requestedModel, + stream: isStream, + rawBody: body, + parsed: { + normalizedBody, + } satisfies OpenAiResponsesParsedRequest, + }), + }; + }, }; diff --git a/src/server/transformers/openai/responses/index.test.ts b/src/server/transformers/openai/responses/index.test.ts new file mode 100644 index 00000000..6f0267e8 --- /dev/null +++ b/src/server/transformers/openai/responses/index.test.ts @@ -0,0 +1,129 @@ +import { describe, expect, it } from 'vitest'; + +import { openAiResponsesTransformer } from './index.js'; + +describe('openAiResponsesTransformer.inbound', () => { + it('parses responses requests into canonical envelopes', () => { + const result = openAiResponsesTransformer.parseRequest({ + model: 'gpt-5', + input: 'hello', + previous_response_id: 'resp_prev_1', + prompt_cache_key: 'cache-key', + reasoning: { + effort: 'high', + }, + }); + + expect(result.error).toBeUndefined(); + expect(result.value).toMatchObject({ + operation: 'generate', + surface: 'openai-responses', + cliProfile: 'generic', + requestedModel: 'gpt-5', + stream: false, + continuation: { + previousResponseId: 'resp_prev_1', + promptCacheKey: 'cache-key', + }, + messages: [ + { + role: 'user', + parts: [{ type: 'text', text: 'hello' }], + }, + ], + reasoning: { + effort: 'high', + }, + }); + }); + + it('builds responses requests from canonical envelopes', () => { + const body = openAiResponsesTransformer.buildProtocolRequest({ + operation: 'generate', + surface: 'openai-responses', + cliProfile: 'codex', + requestedModel: 'gpt-5', + stream: true, + messages: [{ role: 'user', parts: [{ type: 'text', text: 'hello' }] }], + continuation: { + previousResponseId: 'resp_prev_1', + promptCacheKey: 'cache-key', + }, + reasoning: { + effort: 'high', + }, + }); + + expect(body).toMatchObject({ + model: 'gpt-5', + stream: true, + previous_response_id: 'resp_prev_1', + prompt_cache_key: 'cache-key', + input: [ + { + type: 'message', + role: 'user', + content: [{ type: 'input_text', text: 'hello' }], + }, + ], + reasoning: { + effort: 'high', + }, + }); + }); + + it('returns a protocol request envelope with a normalized responses body', () => { + const result = openAiResponsesTransformer.transformRequest({ + model: 'gpt-5', + input: 'hello', + reasoning: { + effort: 'high', + }, + }); + + expect(result.error).toBeUndefined(); + expect(result.value).toMatchObject({ + protocol: 'openai/responses', + model: 'gpt-5', + stream: false, + rawBody: { + model: 'gpt-5', + input: 'hello', + }, + parsed: { + normalizedBody: { + model: 'gpt-5', + input: [ + { + type: 'message', + role: 'user', + content: [ + { + type: 'input_text', + text: 'hello', + }, + ], + }, + ], + stream: false, + }, + }, + }); + }); + + it('rejects requests without a model at the transformer boundary', () => { + const result = openAiResponsesTransformer.transformRequest({ + input: 'hello', + }); + + expect(result.error).toEqual({ + statusCode: 400, + payload: { + error: { + message: 'model is required', + type: 'invalid_request_error', + }, + }, + }); + }); +}); diff --git a/src/server/transformers/openai/responses/index.ts b/src/server/transformers/openai/responses/index.ts index c98bcf54..5f934802 100644 --- a/src/server/transformers/openai/responses/index.ts +++ b/src/server/transformers/openai/responses/index.ts @@ -1,3 +1,6 @@ +import { canonicalRequestFromOpenAiBody, canonicalRequestToOpenAiChatBody } from '../../canonical/request.js'; +import type { CanonicalRequestEnvelope } from '../../canonical/types.js'; +import type { ProtocolBuildContext, ProtocolParseContext } from '../../contracts.js'; import { type StreamTransformContext } from '../../shared/normalized.js'; import { convertOpenAiBodyToResponsesBody, @@ -21,12 +24,20 @@ import { serializeConvertedResponsesEvents, } from './aggregator.js'; import { openAiResponsesOutbound } from './outbound.js'; +import { openAiResponsesInbound } from './inbound.js'; +import { createResponsesProxyStreamSession } from './proxyStream.js'; +import { createResponsesEndpointStrategy } from './routeCompatibility.js'; import { openAiResponsesStream } from './stream.js'; import { openAiResponsesUsage } from './usage.js'; +import type { + OpenAiResponsesParsedRequest as OpenAiResponsesParsedRequestModel, + OpenAiResponsesRequestEnvelope as OpenAiResponsesRequestEnvelopeModel, +} from './model.js'; export const openAiResponsesTransformer = { protocol: 'openai/responses' as const, inbound: { + parse: openAiResponsesInbound.parse, normalizeInput: normalizeResponsesInputForCompatibility, normalizeMessage: normalizeResponsesMessageItem, normalizeContent: normalizeResponsesMessageContent, @@ -38,6 +49,7 @@ export const openAiResponsesTransformer = { stream: openAiResponsesStream, usage: openAiResponsesUsage, compatibility: { + createEndpointStrategy: createResponsesEndpointStrategy, buildRetryBodies, buildRetryHeaders, shouldRetry, @@ -49,8 +61,72 @@ export const openAiResponsesTransformer = { complete: completeResponsesStream, fail: failResponsesStream, }, - transformRequest(body: unknown) { - return body; + proxyStream: { + createSession: createResponsesProxyStreamSession, + }, + parseRequest( + body: unknown, + ctx?: ProtocolParseContext, + ): { value?: CanonicalRequestEnvelope; error?: { statusCode: number; payload: unknown } } { + const parsed = openAiResponsesInbound.parse(body, { + defaultEncryptedReasoningInclude: ctx?.defaultEncryptedReasoningInclude, + }); + if (parsed.error) { + return { error: parsed.error }; + } + if (!parsed.value) { + return { + error: { + statusCode: 400, + payload: { + error: { + message: 'invalid responses request', + type: 'invalid_request_error', + }, + }, + }, + }; + } + + const responsesBody = parsed.value.parsed.normalizedBody; + const openAiBody = convertResponsesBodyToOpenAiBody( + responsesBody, + typeof responsesBody.model === 'string' ? responsesBody.model : parsed.value.model, + responsesBody.stream === true, + { defaultEncryptedReasoningInclude: ctx?.defaultEncryptedReasoningInclude }, + ); + + return { + value: canonicalRequestFromOpenAiBody({ + body: openAiBody, + surface: 'openai-responses', + cliProfile: ctx?.cliProfile, + operation: ctx?.operation, + metadata: ctx?.metadata, + passthrough: ctx?.passthrough, + continuation: ctx?.continuation, + }), + }; + }, + buildProtocolRequest( + request: CanonicalRequestEnvelope, + _ctx?: ProtocolBuildContext, + ): Record { + const openAiBody = canonicalRequestToOpenAiChatBody(request); + if (request.reasoning) { + openAiBody.reasoning = { + ...(request.reasoning.effort ? { effort: request.reasoning.effort } : {}), + ...(request.reasoning.budgetTokens !== undefined ? { budget_tokens: request.reasoning.budgetTokens } : {}), + ...(request.reasoning.summary ? { summary: request.reasoning.summary } : {}), + }; + } + return convertOpenAiBodyToResponsesBody(openAiBody, request.requestedModel, request.stream); + }, + transformRequest( + body: unknown, + options?: { defaultEncryptedReasoningInclude?: boolean }, + ): { value?: OpenAiResponsesRequestEnvelopeModel; error?: { statusCode: number; payload: unknown } } { + return openAiResponsesInbound.parse(body, options); }, createStreamContext(modelName: string): StreamTransformContext { return openAiResponsesStream.createContext(modelName); @@ -68,6 +144,8 @@ export const openAiResponsesTransformer = { export type OpenAiResponsesTransformer = typeof openAiResponsesTransformer; export type OpenAiResponsesAggregate = OpenAiResponsesAggregateState; +export type OpenAiResponsesParsedRequest = OpenAiResponsesParsedRequestModel; +export type OpenAiResponsesRequestEnvelope = OpenAiResponsesRequestEnvelopeModel; export { convertOpenAiBodyToResponsesBody, convertResponsesBodyToOpenAiBody, diff --git a/src/server/transformers/openai/responses/mcpCompatibility.ts b/src/server/transformers/openai/responses/mcpCompatibility.ts new file mode 100644 index 00000000..3c5357ea --- /dev/null +++ b/src/server/transformers/openai/responses/mcpCompatibility.ts @@ -0,0 +1,110 @@ +function isRecord(value: unknown): value is Record { + return !!value && typeof value === 'object' && !Array.isArray(value); +} + +function asTrimmedString(value: unknown): string { + return typeof value === 'string' ? value.trim() : ''; +} + +function cloneJsonValue(value: T): T { + if (Array.isArray(value)) { + return value.map((item) => cloneJsonValue(item)) as T; + } + if (isRecord(value)) { + return Object.fromEntries( + Object.entries(value).map(([key, item]) => [key, cloneJsonValue(item)]), + ) as T; + } + return value; +} + +const RESPONSES_MCP_COMPAT_MARKER = 'responses_mcp_item'; +const RESPONSES_MCP_COMPAT_PREFIX = 'metapi_mcp_item__'; + +type ResponsesMcpCompatEnvelope = { + metapi_compat: typeof RESPONSES_MCP_COMPAT_MARKER; + itemType: string; + item: Record; +}; + +export function isResponsesMcpItem(item: unknown): item is Record { + return isRecord(item) && asTrimmedString(item.type).toLowerCase().startsWith('mcp_'); +} + +function createResponsesMcpCompatEnvelope( + item: Record, +): ResponsesMcpCompatEnvelope | null { + const itemType = asTrimmedString(item.type).toLowerCase(); + if (!itemType.startsWith('mcp_')) return null; + + return { + metapi_compat: RESPONSES_MCP_COMPAT_MARKER, + itemType, + item: cloneJsonValue(item), + }; +} + +export function toResponsesMcpCompatToolName(itemType: string): string { + const normalizedType = asTrimmedString(itemType).toLowerCase(); + return `${RESPONSES_MCP_COMPAT_PREFIX}${normalizedType || 'item'}`; +} + +export function toResponsesMcpCompatToolCall( + item: Record, + fallbackId: string, +): Record | null { + const envelope = createResponsesMcpCompatEnvelope(item); + if (!envelope) return null; + + const id = ( + asTrimmedString(item.call_id) + || asTrimmedString(item.id) + || asTrimmedString(item.approval_request_id) + || fallbackId + ); + + return { + id, + type: 'function', + function: { + name: toResponsesMcpCompatToolName(envelope.itemType), + arguments: JSON.stringify(envelope), + }, + }; +} + +function parseCompatEnvelope(rawArguments: unknown): ResponsesMcpCompatEnvelope | null { + if (typeof rawArguments !== 'string') return null; + const trimmed = rawArguments.trim(); + if (!trimmed) return null; + + try { + const parsed = JSON.parse(trimmed) as unknown; + if (!isRecord(parsed)) return null; + if (asTrimmedString(parsed.metapi_compat) !== RESPONSES_MCP_COMPAT_MARKER) return null; + + const itemType = asTrimmedString(parsed.itemType).toLowerCase(); + if (!itemType.startsWith('mcp_')) return null; + if (!isRecord(parsed.item)) return null; + + return { + metapi_compat: RESPONSES_MCP_COMPAT_MARKER, + itemType, + item: cloneJsonValue(parsed.item), + }; + } catch { + return null; + } +} + +export function decodeResponsesMcpCompatToolCall( + toolName: unknown, + rawArguments: unknown, +): Record | null { + const normalizedName = asTrimmedString(toolName).toLowerCase(); + if (!normalizedName.startsWith(RESPONSES_MCP_COMPAT_PREFIX)) return null; + + const envelope = parseCompatEnvelope(rawArguments); + if (!envelope) return null; + return envelope.item; +} diff --git a/src/server/transformers/openai/responses/model.ts b/src/server/transformers/openai/responses/model.ts new file mode 100644 index 00000000..9d50318e --- /dev/null +++ b/src/server/transformers/openai/responses/model.ts @@ -0,0 +1,10 @@ +import type { ProtocolRequestEnvelope } from '../../shared/protocolModel.js'; + +export type OpenAiResponsesParsedRequest = { + normalizedBody: Record; +}; + +export type OpenAiResponsesRequestEnvelope = ProtocolRequestEnvelope< + 'openai/responses', + OpenAiResponsesParsedRequest +>; diff --git a/src/server/transformers/openai/responses/normalization.ts b/src/server/transformers/openai/responses/normalization.ts new file mode 100644 index 00000000..f7c0dec4 --- /dev/null +++ b/src/server/transformers/openai/responses/normalization.ts @@ -0,0 +1,318 @@ +import { normalizeInputFileBlock, toResponsesInputFileBlock } from '../../shared/inputFile.js'; + +function isRecord(value: unknown): value is Record { + return !!value && typeof value === 'object' && !Array.isArray(value); +} + +function asTrimmedString(value: unknown): string { + return typeof value === 'string' ? value.trim() : ''; +} + +const RESPONSES_TOOL_CALL_ITEM_TYPES = new Set([ + 'function_call', + 'custom_tool_call', +]); + +const RESPONSES_TOOL_OUTPUT_ITEM_TYPES = new Set([ + 'function_call_output', + 'custom_tool_call_output', +]); + +const ALLOWED_RESPONSES_INPUT_STATUSES = new Set([ + 'in_progress', + 'completed', + 'incomplete', +]); + +function normalizeResponsesInputStatus(value: unknown): string | undefined { + const normalized = asTrimmedString(value).toLowerCase(); + if (!normalized) return undefined; + if (normalized === 'failed') return 'incomplete'; + return ALLOWED_RESPONSES_INPUT_STATUSES.has(normalized) ? normalized : undefined; +} + +function withNormalizedResponsesInputStatus(item: Record): Record { + const normalizedStatus = normalizeResponsesInputStatus(item.status); + if (normalizedStatus) { + return { + ...item, + status: normalizedStatus, + }; + } + + if (!Object.prototype.hasOwnProperty.call(item, 'status')) { + return item; + } + + const { status: _status, ...rest } = item; + return rest; +} + +function firstNonEmptyTrimmedString(...values: unknown[]): string { + for (const value of values) { + const normalized = asTrimmedString(value); + if (normalized) return normalized; + } + return ''; +} + +function firstMeaningfulValue(...values: unknown[]): unknown { + for (const value of values) { + if (typeof value === 'string') { + if (value.trim()) return value; + continue; + } + if (value !== undefined && value !== null) return value; + } + return undefined; +} + +function toTextBlockType(role: string): 'input_text' | 'output_text' { + return role === 'assistant' ? 'output_text' : 'input_text'; +} + +function normalizeImageUrlValue(value: unknown): string | Record | null { + if (typeof value === 'string' && value.trim().length > 0) return value; + if (!isRecord(value)) return null; + const url = asTrimmedString(value.url); + if (url) return { ...value, url }; + const imageUrl = asTrimmedString(value.image_url); + if (imageUrl) return imageUrl; + return Object.keys(value).length > 0 ? value : null; +} + +function normalizeAudioInputValue(value: unknown): Record | null { + if (!isRecord(value)) return null; + const data = asTrimmedString(value.data); + const format = asTrimmedString(value.format); + if (!data && !format) return Object.keys(value).length > 0 ? value : null; + return { + ...value, + ...(data ? { data } : {}), + ...(format ? { format } : {}), + }; +} + +function normalizeResponsesContentItem( + item: unknown, + role: string, +): Record | null { + if (typeof item === 'string') { + const text = item.trim(); + return text ? { type: toTextBlockType(role), text } : null; + } + + if (!isRecord(item)) return null; + + const type = asTrimmedString(item.type).toLowerCase(); + if (!type) { + const text = firstNonEmptyTrimmedString(item.text, item.content, item.output_text); + return text ? { type: toTextBlockType(role), text } : null; + } + + if (type === 'input_text' || type === 'output_text' || type === 'text') { + const text = firstNonEmptyTrimmedString(item.text, item.content, item.output_text); + if (!text) return null; + return { + ...item, + type: type === 'text' ? toTextBlockType(role) : type, + text, + }; + } + + if (type === 'input_image' || type === 'image_url') { + const imageUrl = normalizeImageUrlValue(item.image_url) ?? normalizeImageUrlValue(item.url); + if (!imageUrl) return null; + return { + ...item, + type: 'input_image', + image_url: imageUrl, + }; + } + + if (type === 'input_audio') { + const inputAudio = normalizeAudioInputValue(item.input_audio); + if (!inputAudio) return null; + return { + ...item, + type: 'input_audio', + input_audio: inputAudio, + }; + } + + if (type === 'file' || type === 'input_file') { + const fileBlock = normalizeInputFileBlock(item); + return fileBlock ? toResponsesInputFileBlock(fileBlock) : null; + } + + if (RESPONSES_TOOL_CALL_ITEM_TYPES.has(type) || RESPONSES_TOOL_OUTPUT_ITEM_TYPES.has(type)) { + return item; + } + + return item; +} + +export function normalizeResponsesMessageContent( + content: unknown, + role: string, +): unknown { + if (Array.isArray(content)) { + const normalized = content + .map((item) => normalizeResponsesContentItem(item, role)) + .filter((item): item is Record => !!item); + return normalized.length > 0 ? normalized : content; + } + + const single = normalizeResponsesContentItem(content, role); + if (single) return [single]; + return content; +} + +function toResponsesInputMessageFromText(text: string): Record { + return { + type: 'message', + role: 'user', + content: [{ type: 'input_text', text }], + }; +} + +function normalizeResponsesToolLifecycleItem(item: Record): Record | null { + const type = asTrimmedString(item.type).toLowerCase(); + if (!RESPONSES_TOOL_CALL_ITEM_TYPES.has(type) && !RESPONSES_TOOL_OUTPUT_ITEM_TYPES.has(type)) { + return withNormalizedResponsesInputStatus(item); + } + + const callId = asTrimmedString(item.call_id ?? item.id); + if (!callId) return null; + + const normalized: Record = { + ...item, + call_id: callId, + }; + + if (typeof item.id === 'string') { + const id = asTrimmedString(item.id); + if (id) normalized.id = id; + else delete normalized.id; + } + + if (RESPONSES_TOOL_CALL_ITEM_TYPES.has(type)) { + if (Object.prototype.hasOwnProperty.call(item, 'name')) { + const name = asTrimmedString(item.name); + if (!name) return null; + normalized.name = name; + } + } + + return withNormalizedResponsesInputStatus(normalized); +} + +function sanitizeResponsesInputToolLifecycle(items: unknown[]): unknown[] { + const sanitized: unknown[] = []; + + for (const item of items) { + if (!isRecord(item)) { + sanitized.push(item); + continue; + } + + const type = asTrimmedString(item.type).toLowerCase(); + if (RESPONSES_TOOL_CALL_ITEM_TYPES.has(type)) { + const normalized = normalizeResponsesToolLifecycleItem(item); + if (!normalized) continue; + sanitized.push(normalized); + continue; + } + + if (RESPONSES_TOOL_OUTPUT_ITEM_TYPES.has(type)) { + const normalized = normalizeResponsesToolLifecycleItem(item); + if (!normalized) continue; + sanitized.push(normalized); + continue; + } + + sanitized.push(item); + } + + return sanitized; +} + +export function normalizeResponsesMessageItem(item: Record): Record { + const type = asTrimmedString(item.type).toLowerCase(); + if (RESPONSES_TOOL_CALL_ITEM_TYPES.has(type) || RESPONSES_TOOL_OUTPUT_ITEM_TYPES.has(type)) { + return normalizeResponsesToolLifecycleItem(item) ?? item; + } + + const role = asTrimmedString(item.role).toLowerCase() || 'user'; + const normalizedContent = normalizeResponsesMessageContent( + firstMeaningfulValue(item.content, item.text), + role, + ); + + if (type === 'message') { + return withNormalizedResponsesInputStatus({ + ...item, + role, + content: normalizedContent, + }); + } + + if (asTrimmedString(item.role)) { + return withNormalizedResponsesInputStatus({ + ...item, + type: 'message', + role, + content: normalizedContent, + }); + } + + if (typeof item.content === 'string' || typeof item.text === 'string') { + const text = firstNonEmptyTrimmedString(item.content, item.text); + return text ? toResponsesInputMessageFromText(text) : item; + } + + return withNormalizedResponsesInputStatus(item); +} + +export function normalizeResponsesInputForCompatibility(input: unknown): unknown { + if (typeof input === 'string') { + const normalized = input.trim(); + if (!normalized) return input; + return [toResponsesInputMessageFromText(normalized)]; + } + + if (Array.isArray(input)) { + const normalized = input.flatMap((item) => { + if (typeof item === 'string') { + const normalized = item.trim(); + return normalized ? [toResponsesInputMessageFromText(normalized)] : []; + } + if (!isRecord(item)) return [item]; + return [normalizeResponsesMessageItem(item)]; + }); + return sanitizeResponsesInputToolLifecycle(normalized); + } + + if (isRecord(input)) { + return sanitizeResponsesInputToolLifecycle([normalizeResponsesMessageItem(input)]); + } + + return input; +} + +export function normalizeResponsesMessageContentBlocks( + role: string, + content: unknown, +): Array> { + const normalized = normalizeResponsesMessageItem({ + type: 'message', + role, + content, + }); + + if (isRecord(normalized) && Array.isArray(normalized.content)) { + return normalized.content.filter((item): item is Record => isRecord(item)); + } + + return []; +} diff --git a/src/server/transformers/openai/responses/outbound.test.ts b/src/server/transformers/openai/responses/outbound.test.ts index 73c0df6f..9a8a8302 100644 --- a/src/server/transformers/openai/responses/outbound.test.ts +++ b/src/server/transformers/openai/responses/outbound.test.ts @@ -3,6 +3,153 @@ import { describe, expect, it } from 'vitest'; import { serializeResponsesFinalPayload } from './outbound.js'; describe('serializeResponsesFinalPayload', () => { + it('preserves native response.compaction payloads when compact serialization is requested', () => { + const upstreamPayload = { + id: 'cmp_123', + object: 'response.compaction', + created_at: 1700000000, + output: [ + { + id: 'rs_123', + type: 'compaction', + encrypted_content: 'enc-compact-payload', + }, + ], + usage: { + input_tokens: 1234, + output_tokens: 321, + total_tokens: 1555, + }, + }; + + const payload = serializeResponsesFinalPayload({ + upstreamPayload, + normalized: { + id: 'cmp_123', + model: 'gpt-5', + created: 1700000000, + content: '', + reasoningContent: '', + finishReason: 'stop', + toolCalls: [], + }, + usage: { + promptTokens: 1234, + completionTokens: 321, + totalTokens: 1555, + }, + serializationMode: 'compact', + } as any); + + expect(payload).toEqual(upstreamPayload); + }); + + it('serializes compact mode as response.compaction instead of an ordinary response object', () => { + const payload = serializeResponsesFinalPayload({ + upstreamPayload: { + id: 'chatcmpl_compact', + object: 'chat.completion', + created: 1700000000, + model: 'gpt-5', + choices: [ + { + index: 0, + finish_reason: 'stop', + message: { + role: 'assistant', + content: 'hello', + }, + }, + ], + }, + normalized: { + id: 'chatcmpl_compact', + model: 'gpt-5', + created: 1700000000, + content: 'hello', + reasoningContent: '', + finishReason: 'stop', + toolCalls: [], + }, + usage: { + promptTokens: 11, + completionTokens: 7, + totalTokens: 18, + }, + serializationMode: 'compact', + } as any); + + expect(payload).toEqual({ + id: 'resp_chatcmpl_compact', + object: 'response.compaction', + created_at: 1700000000, + output: [ + { + id: 'msg_chatcmpl_compact', + type: 'message', + role: 'assistant', + status: 'completed', + content: [ + { + type: 'output_text', + text: 'hello', + }, + ], + }, + ], + usage: { + input_tokens: 11, + output_tokens: 7, + total_tokens: 18, + }, + }); + expect(payload).not.toHaveProperty('created'); + expect(payload).not.toHaveProperty('status'); + expect(payload).not.toHaveProperty('model'); + expect(payload).not.toHaveProperty('output_text'); + }); + + it('serializes response mode with created_at instead of created', () => { + const payload = serializeResponsesFinalPayload({ + upstreamPayload: { + id: 'chatcmpl_response_mode', + object: 'chat.completion', + created: 1700000000, + model: 'gpt-5', + choices: [ + { + index: 0, + finish_reason: 'stop', + message: { + role: 'assistant', + content: 'hello', + }, + }, + ], + }, + normalized: { + id: 'chatcmpl_response_mode', + model: 'gpt-5', + created: 1700000000, + content: 'hello', + reasoningContent: '', + finishReason: 'stop', + toolCalls: [], + }, + usage: { + promptTokens: 11, + completionTokens: 7, + totalTokens: 18, + }, + } as any); + + expect(payload).toMatchObject({ + object: 'response', + created_at: 1700000000, + }); + expect(payload).not.toHaveProperty('created'); + }); + it('preserves top-level chat annotations on synthetic assistant messages', () => { const payload = serializeResponsesFinalPayload({ upstreamPayload: { @@ -107,6 +254,52 @@ describe('serializeResponsesFinalPayload', () => { ]); }); + it('restores mcp items from compatibility tool calls when serializing fallback response payloads', () => { + const mcpCall = { + type: 'mcp_call', + id: 'mcp_call_1', + call_id: 'mcp_call_1', + name: 'read_file', + server_label: 'filesystem', + arguments: { + path: '/tmp/demo.txt', + }, + }; + + const payload = serializeResponsesFinalPayload({ + upstreamPayload: { + id: 'opaque_mcp_1', + model: 'gpt-5', + }, + normalized: { + id: 'opaque_mcp_1', + model: 'gpt-5', + created: 1700000000, + content: '', + reasoningContent: '', + finishReason: 'tool_calls', + toolCalls: [ + { + id: 'mcp_call_1', + name: 'metapi_mcp_item__mcp_call', + arguments: JSON.stringify({ + metapi_compat: 'responses_mcp_item', + itemType: 'mcp_call', + item: mcpCall, + }), + }, + ], + }, + usage: { + promptTokens: 1, + completionTokens: 2, + totalTokens: 3, + }, + }); + + expect(payload.output).toEqual([mcpCall]); + }); + it('preserves response-like custom tool and image generation items when synthesizing object=response payloads', () => { const payload = serializeResponsesFinalPayload({ upstreamPayload: { @@ -321,4 +514,49 @@ describe('serializeResponsesFinalPayload', () => { }, ]); }); + + it('preserves image generation mime_type on synthesized response payloads', () => { + const payload = serializeResponsesFinalPayload({ + upstreamPayload: { + id: 'resp_like_mime_1', + model: 'gpt-5', + output: [ + { + id: 'img_1', + type: 'image_generation_call', + status: 'completed', + result: 'data:image/png;base64,final', + mime_type: 'image/png', + output_format: 'png', + }, + ], + }, + normalized: { + id: 'resp_like_mime_1', + model: 'gpt-5', + created: 1700000000, + content: '', + reasoningContent: '', + finishReason: 'stop', + toolCalls: [], + }, + usage: { + promptTokens: 1, + completionTokens: 2, + totalTokens: 3, + }, + }); + + expect(payload.output).toEqual([ + { + id: 'img_1', + type: 'image_generation_call', + status: 'completed', + result: 'data:image/png;base64,final', + mime_type: 'image/png', + output_format: 'png', + partial_images: [], + }, + ]); + }); }); diff --git a/src/server/transformers/openai/responses/outbound.ts b/src/server/transformers/openai/responses/outbound.ts index ddcd0665..07e34d24 100644 --- a/src/server/transformers/openai/responses/outbound.ts +++ b/src/server/transformers/openai/responses/outbound.ts @@ -3,6 +3,7 @@ import { type NormalizedFinalResponse, } from '../../shared/normalized.js'; import { decodeOpenAiEncryptedReasoning } from '../../shared/reasoningTransport.js'; +import { decodeResponsesMcpCompatToolCall } from './mcpCompatibility.js'; function isRecord(value: unknown): value is Record { return !!value && typeof value === 'object'; @@ -64,6 +65,8 @@ export type ResponsesToolCall = { arguments: string; }; +export type ResponsesFinalSerializationMode = 'response' | 'compact'; + type ResponsesOutputItem = Record; function extractToolCallsFromUpstream(payload: unknown): ResponsesToolCall[] { @@ -218,6 +221,7 @@ function extractSyntheticOutputItemsFromUpstream(payload: unknown): ResponsesOut result: cloneJson(rawItem.result), partial_images: Array.isArray(rawItem.partial_images) ? cloneJson(rawItem.partial_images) : [], ...(rawItem.background !== undefined ? { background: cloneJson(rawItem.background) } : {}), + ...(rawItem.mime_type !== undefined ? { mime_type: cloneJson(rawItem.mime_type) } : {}), ...(rawItem.output_format !== undefined ? { output_format: cloneJson(rawItem.output_format) } : {}), ...(rawItem.quality !== undefined ? { quality: cloneJson(rawItem.quality) } : {}), ...(rawItem.size !== undefined ? { size: cloneJson(rawItem.size) } : {}), @@ -225,6 +229,10 @@ function extractSyntheticOutputItemsFromUpstream(payload: unknown): ResponsesOut }; } + if (itemType.startsWith('mcp_')) { + return cloneJson(rawItem); + } + return null; }) .filter((item): item is ResponsesOutputItem => item !== null); @@ -280,14 +288,45 @@ export function normalizeResponsesFinalPayload( return normalizeUpstreamFinalResponse(payload, modelName, fallbackText); } +function buildResponsesUsagePayload( + usage: ResponsesUsageSummary, + syntheticUsageDetails: { + inputTokensDetails?: Record; + outputTokensDetails?: Record; + }, +): Record { + return { + input_tokens: usage.promptTokens, + output_tokens: usage.completionTokens, + total_tokens: usage.totalTokens, + ...(usage.inputTokensDetails ?? syntheticUsageDetails.inputTokensDetails + ? { input_tokens_details: cloneJson(usage.inputTokensDetails ?? syntheticUsageDetails.inputTokensDetails) } + : {}), + ...(usage.outputTokensDetails ?? syntheticUsageDetails.outputTokensDetails + ? { output_tokens_details: cloneJson(usage.outputTokensDetails ?? syntheticUsageDetails.outputTokensDetails) } + : {}), + }; +} + export function serializeResponsesFinalPayload(input: { upstreamPayload: unknown; normalized: NormalizedFinalResponse; usage: ResponsesUsageSummary; + serializationMode?: ResponsesFinalSerializationMode; }): Record { - const { upstreamPayload, normalized, usage } = input; - if (isRecord(upstreamPayload) && upstreamPayload.object === 'response') { - return upstreamPayload; + const { + upstreamPayload, + normalized, + usage, + serializationMode = 'response', + } = input; + if (isRecord(upstreamPayload)) { + if (upstreamPayload.object === 'response.compaction') { + return upstreamPayload; + } + if (serializationMode === 'response' && upstreamPayload.object === 'response') { + return upstreamPayload; + } } const normalizedId = typeof normalized.id === 'string' && normalized.id.trim() @@ -360,6 +399,12 @@ export function serializeResponsesFinalPayload(input: { if (toolCalls.length > 0 && !hasToolLikeItem) { for (const toolCall of toolCalls) { + const mcpItem = decodeResponsesMcpCompatToolCall(toolCall.name, toolCall.arguments); + if (mcpItem) { + output.push(mcpItem); + continue; + } + output.push({ id: toolCall.id, type: 'function_call', @@ -371,25 +416,26 @@ export function serializeResponsesFinalPayload(input: { } } + const usagePayload = buildResponsesUsagePayload(usage, syntheticUsageDetails); + if (serializationMode === 'compact') { + return { + id: responseId, + object: 'response.compaction', + created_at: normalized.created, + output, + usage: usagePayload, + }; + } + return { id: responseId, object: 'response', - created: normalized.created, + created_at: normalized.created, status: 'completed', model: normalized.model, output, output_text: normalized.content || collectOutputTextFromItems(output), - usage: { - input_tokens: usage.promptTokens, - output_tokens: usage.completionTokens, - total_tokens: usage.totalTokens, - ...(usage.inputTokensDetails ?? syntheticUsageDetails.inputTokensDetails - ? { input_tokens_details: cloneJson(usage.inputTokensDetails ?? syntheticUsageDetails.inputTokensDetails) } - : {}), - ...(usage.outputTokensDetails ?? syntheticUsageDetails.outputTokensDetails - ? { output_tokens_details: cloneJson(usage.outputTokensDetails ?? syntheticUsageDetails.outputTokensDetails) } - : {}), - }, + usage: usagePayload, }; } diff --git a/src/server/transformers/openai/responses/proxyStream.test.ts b/src/server/transformers/openai/responses/proxyStream.test.ts new file mode 100644 index 00000000..f364dce0 --- /dev/null +++ b/src/server/transformers/openai/responses/proxyStream.test.ts @@ -0,0 +1,261 @@ +import { describe, expect, it } from 'vitest'; + +import { createResponsesProxyStreamSession } from './proxyStream.js'; + +describe('createResponsesProxyStreamSession', () => { + it('serializes non-SSE fallback payloads into canonical responses SSE closeout events', () => { + const lines: string[] = []; + let ended = false; + const usage = { + promptTokens: 5, + completionTokens: 3, + totalTokens: 8, + cacheReadTokens: 0, + cacheCreationTokens: 0, + promptTokensIncludeCache: null, + }; + const payload = { + id: 'resp_fallback_1', + object: 'response', + status: 'completed', + model: 'gpt-5.2', + output_text: 'hello from responses upstream', + output: [ + { + id: 'msg_fallback_1', + type: 'message', + role: 'assistant', + status: 'completed', + content: [{ type: 'output_text', text: 'hello from responses upstream' }], + }, + ], + usage: { + input_tokens: usage.promptTokens, + output_tokens: usage.completionTokens, + total_tokens: usage.totalTokens, + }, + }; + + const session = createResponsesProxyStreamSession({ + modelName: 'gpt-5.2', + successfulUpstreamPath: '/v1/responses', + getUsage: () => usage, + writeLines: (nextLines) => { + lines.push(...nextLines); + }, + writeRaw: () => {}, + }); + + const result = session.consumeUpstreamFinalPayload( + payload, + JSON.stringify(payload), + { + end() { + ended = true; + }, + }, + ); + + expect(result).toEqual({ + status: 'completed', + errorMessage: null, + }); + expect(ended).toBe(true); + + const output = lines.join(''); + expect(output).toContain('event: response.created'); + expect(output).toContain('event: response.completed'); + expect(output).toContain('"type":"response.completed"'); + expect(output).toContain('"output_text":"hello from responses upstream"'); + expect(output).toContain('data: [DONE]'); + }); + + it('preserves the canonical [DONE] terminator after an explicit response.completed SSE event', async () => { + const lines: string[] = []; + let ended = false; + const usage = { + promptTokens: 5, + completionTokens: 3, + totalTokens: 8, + cacheReadTokens: 0, + cacheCreationTokens: 0, + promptTokensIncludeCache: null, + }; + const chunk = [ + 'event: response.completed', + 'data: {"type":"response.completed","response":{"id":"resp_stream_1","model":"gpt-5","usage":{"input_tokens":5,"output_tokens":3,"total_tokens":8}}}', + '', + 'data: [DONE]', + '', + ].join('\n'); + + const reader = { + reads: 0, + async read() { + if (this.reads > 0) return { done: true }; + this.reads += 1; + return { done: false, value: new TextEncoder().encode(chunk) }; + }, + async cancel() { + return undefined; + }, + releaseLock() {}, + }; + + const session = createResponsesProxyStreamSession({ + modelName: 'gpt-5', + successfulUpstreamPath: '/v1/responses', + getUsage: () => usage, + writeLines: (nextLines) => { + lines.push(...nextLines); + }, + writeRaw: () => {}, + }); + + const result = await session.run(reader as any, { + end() { + ended = true; + }, + }); + + expect(result).toEqual({ + status: 'completed', + errorMessage: null, + }); + expect(ended).toBe(true); + const output = lines.join(''); + expect(output).toContain('event: response.completed'); + expect(output).toContain('data: [DONE]'); + }); + + it('preserves response.incomplete SSE terminals instead of coercing them to response.failed', async () => { + const lines: string[] = []; + let ended = false; + const usage = { + promptTokens: 5, + completionTokens: 3, + totalTokens: 8, + cacheReadTokens: 0, + cacheCreationTokens: 0, + promptTokensIncludeCache: null, + }; + const chunk = [ + 'event: response.incomplete', + 'data: {"type":"response.incomplete","response":{"id":"resp_incomplete_1","model":"gpt-5","status":"incomplete","incomplete_details":{"reason":"max_output_tokens"},"usage":{"input_tokens":5,"output_tokens":3,"total_tokens":8}}}', + '', + 'data: [DONE]', + '', + ].join('\n'); + + const reader = { + reads: 0, + async read() { + if (this.reads > 0) return { done: true }; + this.reads += 1; + return { done: false, value: new TextEncoder().encode(chunk) }; + }, + async cancel() { + return undefined; + }, + releaseLock() {}, + }; + + const session = createResponsesProxyStreamSession({ + modelName: 'gpt-5', + successfulUpstreamPath: '/v1/responses', + getUsage: () => usage, + writeLines: (nextLines) => { + lines.push(...nextLines); + }, + writeRaw: () => {}, + }); + + const result = await session.run(reader as any, { + end() { + ended = true; + }, + }); + + expect(result).toEqual({ + status: 'completed', + errorMessage: null, + }); + expect(ended).toBe(true); + const output = lines.join(''); + expect(output).toContain('event: response.incomplete'); + expect(output).toContain('"status":"incomplete"'); + expect(output).toContain('"incomplete_details":{"reason":"max_output_tokens"}'); + expect(output).not.toContain('event: response.failed'); + expect(output).toContain('data: [DONE]'); + }); + + it('preserves non-SSE incomplete fallback payloads as response.incomplete', () => { + const lines: string[] = []; + let ended = false; + const usage = { + promptTokens: 5, + completionTokens: 3, + totalTokens: 8, + cacheReadTokens: 0, + cacheCreationTokens: 0, + promptTokensIncludeCache: null, + }; + const payload = { + id: 'resp_incomplete_fallback_1', + object: 'response', + status: 'incomplete', + incomplete_details: { + reason: 'max_output_tokens', + }, + model: 'gpt-5.2', + output_text: 'partial answer', + output: [ + { + id: 'msg_incomplete_1', + type: 'message', + role: 'assistant', + status: 'incomplete', + content: [{ type: 'output_text', text: 'partial answer' }], + }, + ], + usage: { + input_tokens: usage.promptTokens, + output_tokens: usage.completionTokens, + total_tokens: usage.totalTokens, + }, + }; + + const session = createResponsesProxyStreamSession({ + modelName: 'gpt-5.2', + successfulUpstreamPath: '/v1/responses', + getUsage: () => usage, + writeLines: (nextLines) => { + lines.push(...nextLines); + }, + writeRaw: () => {}, + }); + + const result = session.consumeUpstreamFinalPayload( + payload, + JSON.stringify(payload), + { + end() { + ended = true; + }, + }, + ); + + expect(result).toEqual({ + status: 'completed', + errorMessage: null, + }); + expect(ended).toBe(true); + + const output = lines.join(''); + expect(output).toContain('event: response.incomplete'); + expect(output).toContain('"status":"incomplete"'); + expect(output).toContain('"output_text":"partial answer"'); + expect(output).not.toContain('event: response.completed'); + expect(output).toContain('data: [DONE]'); + }); +}); diff --git a/src/server/transformers/openai/responses/proxyStream.ts b/src/server/transformers/openai/responses/proxyStream.ts new file mode 100644 index 00000000..1a77d273 --- /dev/null +++ b/src/server/transformers/openai/responses/proxyStream.ts @@ -0,0 +1,397 @@ +import { createProxyStreamLifecycle } from '../../shared/protocolLifecycle.js'; +import { type ParsedSseEvent } from '../../shared/normalized.js'; +import { completeResponsesStream, createOpenAiResponsesAggregateState, failResponsesStream, serializeConvertedResponsesEvents } from './aggregator.js'; +import { openAiResponsesOutbound } from './outbound.js'; +import { openAiResponsesStream } from './stream.js'; +import { config } from '../../../config.js'; + +type StreamReader = { + read(): Promise<{ done: boolean; value?: Uint8Array }>; + cancel(reason?: unknown): Promise; + releaseLock(): void; +}; + +type ResponseSink = { + end(): void; +}; + +type ResponsesProxyStreamResult = { + status: 'completed' | 'failed'; + errorMessage: string | null; +}; + +type ResponsesProxyStreamSessionInput = { + modelName: string; + successfulUpstreamPath: string; + strictTerminalEvents?: boolean; + getUsage: () => { + promptTokens: number; + completionTokens: number; + totalTokens: number; + cacheReadTokens: number; + cacheCreationTokens: number; + promptTokensIncludeCache: boolean | null; + }; + onParsedPayload?: (payload: unknown) => void; + writeLines: (lines: string[]) => void; + writeRaw: (chunk: string) => void; +}; + +function isRecord(value: unknown): value is Record { + return !!value && typeof value === 'object'; +} + +function asTrimmedString(value: unknown): string { + return typeof value === 'string' ? value.trim() : ''; +} + +function hasNonEmptyString(value: unknown): boolean { + return typeof value === 'string' && value.trim().length > 0; +} + +function hasMeaningfulContentPart(part: unknown): boolean { + if (!isRecord(part)) return false; + const partType = typeof part.type === 'string' ? part.type.trim().toLowerCase() : ''; + if (partType === 'output_text' || partType === 'text') { + return hasNonEmptyString(part.text); + } + return partType.length > 0; +} + +function hasMeaningfulOutputItem(item: unknown): boolean { + if (!isRecord(item)) return false; + const itemType = typeof item.type === 'string' ? item.type.trim().toLowerCase() : ''; + if (itemType === 'message') { + return Array.isArray(item.content) && item.content.some((part) => hasMeaningfulContentPart(part)); + } + if (itemType === 'reasoning') { + return ( + (Array.isArray(item.summary) && item.summary.some((part) => hasMeaningfulContentPart(part))) + || hasNonEmptyString(item.encrypted_content) + ); + } + return itemType.length > 0; +} + +function hasMeaningfulResponsesPayloadOutput(payload: unknown): boolean { + if (!isRecord(payload)) return false; + if (hasNonEmptyString(payload.output_text)) return true; + return Array.isArray(payload.output) && payload.output.some((item) => hasMeaningfulOutputItem(item)); +} + +function hasMeaningfulAggregateOutput(state: ReturnType): boolean { + return state.outputItems.some((item) => hasMeaningfulOutputItem(item)); +} + +function shouldFailEmptyResponsesCompletion(input: { + payload: unknown; + state: ReturnType; + usage: { + promptTokens: number; + completionTokens: number; + totalTokens: number; + }; +}): boolean { + if (!config.proxyEmptyContentFailEnabled) return false; + const responsePayload = isRecord(input.payload) && isRecord(input.payload.response) + ? input.payload.response + : null; + if (hasMeaningfulAggregateOutput(input.state)) return false; + if (responsePayload && hasMeaningfulResponsesPayloadOutput(responsePayload)) return false; + return input.usage.completionTokens <= 0 && input.usage.totalTokens <= 0; +} + +function getResponsesStreamFailureMessage(payload: unknown, fallback = 'upstream stream failed'): string { + if (isRecord(payload)) { + if (isRecord(payload.error) && typeof payload.error.message === 'string' && payload.error.message.trim()) { + return payload.error.message.trim(); + } + if (typeof payload.message === 'string' && payload.message.trim()) { + return payload.message.trim(); + } + if (isRecord(payload.response) && isRecord(payload.response.error) && typeof payload.response.error.message === 'string' && payload.response.error.message.trim()) { + return payload.response.error.message.trim(); + } + } + return fallback; +} + +function preserveMeaningfulTerminalResponsesPayload( + lines: string[], + eventType: 'response.completed' | 'response.incomplete', + payload: Record, +): string[] { + const responsePayload = isRecord(payload.response) ? payload.response : null; + if (!responsePayload || !hasMeaningfulResponsesPayloadOutput(responsePayload)) { + return lines; + } + + const parsed = openAiResponsesStream.pullSseEvents(lines.join('')); + let replaced = false; + const nextLines: string[] = []; + + for (const event of parsed.events) { + if (event.data === '[DONE]') { + nextLines.push('data: [DONE]\n\n'); + continue; + } + + let parsedPayload: unknown = null; + try { + parsedPayload = JSON.parse(event.data); + } catch { + nextLines.push(`${event.event ? `event: ${event.event}\n` : ''}data: ${event.data}\n\n`); + continue; + } + + if ( + isRecord(parsedPayload) + && asTrimmedString(parsedPayload.type) === eventType + && isRecord(parsedPayload.response) + && !hasMeaningfulResponsesPayloadOutput(parsedPayload.response) + ) { + replaced = true; + nextLines.push( + `event: ${event.event || eventType}\ndata: ${JSON.stringify({ + ...parsedPayload, + response: { + ...parsedPayload.response, + ...responsePayload, + }, + })}\n\n`, + ); + continue; + } + + nextLines.push(`${event.event ? `event: ${event.event}\n` : ''}data: ${event.data}\n\n`); + } + + return replaced ? nextLines : lines; +} + +export function createResponsesProxyStreamSession(input: ResponsesProxyStreamSessionInput) { + const streamContext = openAiResponsesStream.createContext(input.modelName); + const responsesState = createOpenAiResponsesAggregateState(input.modelName); + const requiresExplicitTerminalEvent = input.strictTerminalEvents + || input.successfulUpstreamPath.endsWith('/responses') + || input.successfulUpstreamPath.endsWith('/responses/compact'); + let finalized = false; + let terminalEventSeen = false; + let terminalResult: ResponsesProxyStreamResult = { + status: 'completed', + errorMessage: null, + }; + + const finalize = () => { + if (finalized) return; + finalized = true; + terminalResult = { + status: 'completed', + errorMessage: null, + }; + input.writeLines(completeResponsesStream(responsesState, streamContext, input.getUsage())); + }; + + const fail = (payload: unknown, fallbackMessage?: string) => { + if (finalized) return; + finalized = true; + terminalResult = { + status: 'failed', + errorMessage: getResponsesStreamFailureMessage(payload, fallbackMessage), + }; + input.writeLines(failResponsesStream(responsesState, streamContext, input.getUsage(), payload)); + }; + + const complete = () => { + terminalResult = { + status: 'completed', + errorMessage: null, + }; + }; + + const closeOut = () => { + if (finalized) return; + if (terminalEventSeen) { + finalize(); + return; + } + if (requiresExplicitTerminalEvent) { + fail({ + type: 'response.failed', + error: { + message: 'stream closed before response.completed', + }, + }, 'stream closed before response.completed'); + return; + } + finalize(); + }; + + const handleEventBlock = (eventBlock: ParsedSseEvent): boolean => { + if (eventBlock.data === '[DONE]') { + closeOut(); + return true; + } + + let parsedPayload: unknown = null; + try { + parsedPayload = JSON.parse(eventBlock.data); + } catch { + parsedPayload = null; + } + + if (isRecord(parsedPayload)) { + input.onParsedPayload?.(parsedPayload); + } + + const payloadType = (isRecord(parsedPayload) && typeof parsedPayload.type === 'string') + ? parsedPayload.type + : ''; + const isFailureEvent = ( + eventBlock.event === 'error' + || eventBlock.event === 'response.failed' + || payloadType === 'error' + || payloadType === 'response.failed' + ); + if (isFailureEvent) { + fail(parsedPayload); + return true; + } + const isIncompleteEvent = eventBlock.event === 'response.incomplete' || payloadType === 'response.incomplete'; + + if (isRecord(parsedPayload)) { + const normalizedEvent = openAiResponsesStream.normalizeEvent(parsedPayload, streamContext, input.modelName); + let convertedLines = serializeConvertedResponsesEvents({ + state: responsesState, + streamContext, + event: normalizedEvent, + usage: input.getUsage(), + }); + if (isIncompleteEvent) { + convertedLines = preserveMeaningfulTerminalResponsesPayload(convertedLines, 'response.incomplete', parsedPayload); + } else if (eventBlock.event === 'response.completed' || payloadType === 'response.completed') { + convertedLines = preserveMeaningfulTerminalResponsesPayload(convertedLines, 'response.completed', parsedPayload); + } + if ( + (eventBlock.event === 'response.completed' || payloadType === 'response.completed') + && shouldFailEmptyResponsesCompletion({ + payload: parsedPayload, + state: responsesState, + usage: input.getUsage(), + }) + ) { + fail({ + type: 'response.failed', + error: { + message: 'Upstream returned empty content', + }, + }, 'Upstream returned empty content'); + return true; + } + input.writeLines(convertedLines); + if (eventBlock.event === 'response.completed' || payloadType === 'response.completed' || isIncompleteEvent) { + terminalEventSeen = true; + complete(); + } + return false; + } + + input.writeLines(serializeConvertedResponsesEvents({ + state: responsesState, + streamContext, + event: { contentDelta: eventBlock.data }, + usage: input.getUsage(), + })); + return false; + }; + + return { + consumeUpstreamFinalPayload(payload: unknown, fallbackText: string, response?: ResponseSink): ResponsesProxyStreamResult { + if (payload && typeof payload === 'object') { + input.onParsedPayload?.(payload); + } + + const payloadType = (isRecord(payload) && typeof payload.type === 'string') + ? payload.type + : ''; + const payloadStatus = isRecord(payload) && typeof payload.status === 'string' + ? payload.status + : ''; + if (payloadType === 'error' || payloadType === 'response.failed') { + fail(payload); + response?.end(); + return terminalResult; + } + + const normalizedFinal = openAiResponsesOutbound.normalizeFinal(payload, input.modelName, fallbackText); + streamContext.id = normalizedFinal.id; + streamContext.model = normalizedFinal.model; + streamContext.created = normalizedFinal.created; + + const streamPayload = openAiResponsesOutbound.serializeFinal({ + upstreamPayload: payload, + normalized: normalizedFinal, + usage: input.getUsage(), + serializationMode: 'response', + }); + const isIncompletePayload = payloadType === 'response.incomplete' || payloadStatus === 'incomplete'; + if (!isIncompletePayload && shouldFailEmptyResponsesCompletion({ + payload: { type: 'response.completed', response: streamPayload }, + state: responsesState, + usage: input.getUsage(), + })) { + fail({ + type: 'response.failed', + error: { + message: 'Upstream returned empty content', + }, + }, 'Upstream returned empty content'); + response?.end(); + return terminalResult; + } + const createdPayload = { + ...streamPayload, + status: 'in_progress', + output: [], + output_text: '', + }; + + if (isIncompletePayload) { + finalized = true; + terminalResult = { + status: 'completed', + errorMessage: null, + }; + input.writeLines([ + `event: response.created\ndata: ${JSON.stringify({ type: 'response.created', response: createdPayload })}\n\n`, + `event: response.incomplete\ndata: ${JSON.stringify({ type: 'response.incomplete', response: streamPayload })}\n\n`, + 'data: [DONE]\n\n', + ]); + } else { + finalized = true; + terminalResult = { + status: 'completed', + errorMessage: null, + }; + input.writeLines([ + `event: response.created\ndata: ${JSON.stringify({ type: 'response.created', response: createdPayload })}\n\n`, + `event: response.completed\ndata: ${JSON.stringify({ type: 'response.completed', response: streamPayload })}\n\n`, + 'data: [DONE]\n\n', + ]); + } + response?.end(); + return terminalResult; + }, + async run(reader: StreamReader | null | undefined, response: ResponseSink): Promise { + const lifecycle = createProxyStreamLifecycle({ + reader, + response, + pullEvents: (buffer) => openAiResponsesStream.pullSseEvents(buffer), + handleEvent: handleEventBlock, + onEof: closeOut, + }); + await lifecycle.run(); + return terminalResult; + }, + }; +} diff --git a/src/server/transformers/openai/responses/routeCompatibility.ts b/src/server/transformers/openai/responses/routeCompatibility.ts new file mode 100644 index 00000000..a3d1c4c2 --- /dev/null +++ b/src/server/transformers/openai/responses/routeCompatibility.ts @@ -0,0 +1,124 @@ +import type { Response as UndiciResponse } from 'undici'; +import { + buildMinimalJsonHeadersForCompatibility, + isEndpointDowngradeError, + isUnsupportedMediaTypeError, + type CompatibilityEndpoint, +} from '../../shared/endpointCompatibility.js'; +import { + buildResponsesCompatibilityBodies, + buildResponsesCompatibilityHeaderCandidates, + shouldDowngradeResponsesChatToMessages, + shouldRetryResponsesCompatibility, +} from './compatibility.js'; + +type CompatibilityRequest = { + endpoint: CompatibilityEndpoint; + path: string; + headers: Record; + body: Record; +}; + +type EndpointAttemptContext = { + request: CompatibilityRequest; + targetUrl: string; + response: UndiciResponse; + rawErrText: string; +}; + +type EndpointRecoverResult = { + upstream: UndiciResponse; + upstreamPath: string; + request?: CompatibilityRequest; + targetUrl?: string; +} | null; + +type UpstreamResponse = Exclude['upstream']; + +type CreateResponsesEndpointStrategyInput = { + isStream: boolean; + requiresNativeResponsesFileUrl: boolean; + dispatchRequest: ( + request: CompatibilityRequest, + targetUrl?: string, + ) => Promise; +}; + +export function createResponsesEndpointStrategy(input: CreateResponsesEndpointStrategyInput) { + return { + async tryRecover(ctx: EndpointAttemptContext): Promise { + if (shouldRetryResponsesCompatibility({ + endpoint: ctx.request.endpoint, + status: ctx.response.status, + rawErrText: ctx.rawErrText, + })) { + const compatibilityBodies = buildResponsesCompatibilityBodies(ctx.request.body); + const compatibilityHeaders = buildResponsesCompatibilityHeaderCandidates( + ctx.request.headers, + input.isStream, + ); + + for (const compatibilityHeadersCandidate of compatibilityHeaders) { + for (const compatibilityBody of compatibilityBodies) { + const compatibilityRequest = { + ...ctx.request, + headers: compatibilityHeadersCandidate, + body: compatibilityBody, + }; + const compatibilityResponse = await input.dispatchRequest( + compatibilityRequest, + ctx.targetUrl, + ); + if (compatibilityResponse.ok) { + return { + upstream: compatibilityResponse, + upstreamPath: compatibilityRequest.path, + }; + } + + ctx.request = compatibilityRequest; + ctx.response = compatibilityResponse; + ctx.rawErrText = await compatibilityResponse.text().catch(() => 'unknown error'); + } + } + } + + if (!isUnsupportedMediaTypeError(ctx.response.status, ctx.rawErrText)) { + return null; + } + + const minimalRequest = { + ...ctx.request, + headers: buildMinimalJsonHeadersForCompatibility({ + headers: ctx.request.headers, + endpoint: ctx.request.endpoint, + stream: input.isStream, + }), + }; + const minimalResponse = await input.dispatchRequest(minimalRequest, ctx.targetUrl); + if (minimalResponse.ok) { + return { + upstream: minimalResponse, + upstreamPath: minimalRequest.path, + }; + } + + ctx.request = minimalRequest; + ctx.response = minimalResponse; + ctx.rawErrText = await minimalResponse.text().catch(() => 'unknown error'); + return null; + }, + shouldDowngrade(ctx: EndpointAttemptContext): boolean { + if (input.requiresNativeResponsesFileUrl) return false; + return ( + ctx.response.status >= 500 + || isEndpointDowngradeError(ctx.response.status, ctx.rawErrText) + || shouldDowngradeResponsesChatToMessages( + ctx.request.path, + ctx.response.status, + ctx.rawErrText, + ) + ); + }, + }; +} diff --git a/src/server/transformers/openai/responses/stream.ts b/src/server/transformers/openai/responses/stream.ts index 7d4da3ea..4e50bc35 100644 --- a/src/server/transformers/openai/responses/stream.ts +++ b/src/server/transformers/openai/responses/stream.ts @@ -46,6 +46,7 @@ export const openAiResponsesStream = { 'response.image_generation_call.partial_image', 'response.image_generation_call.completed', 'response.completed', + 'response.incomplete', 'response.failed', ] as const, createContext(modelName: string): StreamTransformContext { diff --git a/src/server/transformers/shared/chatEndpointStrategy.ts b/src/server/transformers/shared/chatEndpointStrategy.ts new file mode 100644 index 00000000..fbbfc90d --- /dev/null +++ b/src/server/transformers/shared/chatEndpointStrategy.ts @@ -0,0 +1,138 @@ +import type { Response as UndiciResponse } from 'undici'; +import type { DownstreamFormat } from './normalized.js'; +import { + buildMinimalJsonHeadersForCompatibility, + isEndpointDispatchDeniedError, + isEndpointDowngradeError, + isUnsupportedMediaTypeError, + promoteResponsesCandidateAfterLegacyChatError, + type CompatibilityEndpoint, +} from './endpointCompatibility.js'; +import { + isMessagesRequiredError, + shouldRetryNormalizedMessagesBody, +} from '../anthropic/messages/compatibility.js'; + +type EndpointAttemptContext = { + request: CompatibilityRequest; + targetUrl: string; + response: UndiciResponse; + rawErrText: string; +}; + +type EndpointRecoverResult = { + upstream: UndiciResponse; + upstreamPath: string; + request?: CompatibilityRequest; + targetUrl?: string; +} | null; + +type CompatibilityRequest = { + endpoint: CompatibilityEndpoint; + path: string; + headers: Record; + body: Record; +}; + +type UpstreamResponse = Exclude['upstream']; + +type CreateChatEndpointStrategyInput = { + downstreamFormat: DownstreamFormat; + endpointCandidates: CompatibilityEndpoint[]; + modelName: string; + requestedModelHint: string; + sitePlatform?: string | null; + isStream: boolean; + buildRequest: (input: { + endpoint: CompatibilityEndpoint; + forceNormalizeClaudeBody?: boolean; + }) => CompatibilityRequest; + dispatchRequest: ( + request: CompatibilityRequest, + targetUrl?: string, + ) => Promise; +}; + +export function createChatEndpointStrategy(input: CreateChatEndpointStrategyInput) { + return { + async tryRecover(ctx: EndpointAttemptContext): Promise { + if (shouldRetryNormalizedMessagesBody({ + downstreamFormat: input.downstreamFormat, + endpointPath: ctx.request.path, + status: ctx.response.status, + upstreamErrorText: ctx.rawErrText, + })) { + const normalizedClaudeRequest = input.buildRequest({ + endpoint: ctx.request.endpoint, + forceNormalizeClaudeBody: true, + }); + const normalizedResponse = await input.dispatchRequest(normalizedClaudeRequest); + + if (normalizedResponse.ok) { + return { + upstream: normalizedResponse, + upstreamPath: normalizedClaudeRequest.path, + request: normalizedClaudeRequest, + }; + } + + ctx.request = normalizedClaudeRequest; + ctx.response = normalizedResponse; + ctx.rawErrText = await normalizedResponse.text().catch(() => 'unknown error'); + } + + if (!isUnsupportedMediaTypeError(ctx.response.status, ctx.rawErrText)) { + return null; + } + + const minimalHeaders = buildMinimalJsonHeadersForCompatibility({ + headers: ctx.request.headers, + endpoint: ctx.request.endpoint, + stream: input.isStream, + }); + const normalizedCurrentHeaders = Object.fromEntries( + Object.entries(ctx.request.headers).map(([key, value]) => [key.toLowerCase(), value]), + ); + if (JSON.stringify(minimalHeaders) === JSON.stringify(normalizedCurrentHeaders)) { + return null; + } + + const minimalRequest = { + ...ctx.request, + headers: minimalHeaders, + }; + const minimalResponse = await input.dispatchRequest(minimalRequest, ctx.targetUrl); + + if (minimalResponse.ok) { + return { + upstream: minimalResponse, + upstreamPath: minimalRequest.path, + request: minimalRequest, + targetUrl: ctx.targetUrl, + }; + } + + ctx.request = minimalRequest; + ctx.response = minimalResponse; + ctx.rawErrText = await minimalResponse.text().catch(() => 'unknown error'); + return null; + }, + shouldDowngrade(ctx: EndpointAttemptContext): boolean { + promoteResponsesCandidateAfterLegacyChatError(input.endpointCandidates, { + status: ctx.response.status, + upstreamErrorText: ctx.rawErrText, + downstreamFormat: input.downstreamFormat, + sitePlatform: input.sitePlatform, + modelName: input.modelName, + requestedModelHint: input.requestedModelHint, + currentEndpoint: ctx.request.endpoint, + }); + return ( + ctx.response.status >= 500 + || isEndpointDowngradeError(ctx.response.status, ctx.rawErrText) + || isMessagesRequiredError(ctx.rawErrText) + || isEndpointDispatchDeniedError(ctx.response.status, ctx.rawErrText) + ); + }, + }; +} diff --git a/src/server/transformers/shared/chatFormatsCore.test.ts b/src/server/transformers/shared/chatFormatsCore.test.ts new file mode 100644 index 00000000..a24fae9d --- /dev/null +++ b/src/server/transformers/shared/chatFormatsCore.test.ts @@ -0,0 +1,391 @@ +import { describe, expect, it } from 'vitest'; + +import { + convertClaudeRequestToOpenAiBody, + createClaudeDownstreamContext, + createStreamTransformContext, + normalizeUpstreamStreamEvent, + serializeNormalizedStreamEvent, +} from './chatFormatsCore.js'; + +describe('chatFormatsCore inline think parsing', () => { + it('tracks split think tags across stream chunks', () => { + const context = createStreamTransformContext('gpt-test'); + + expect(normalizeUpstreamStreamEvent({ + id: 'chatcmpl-split-think', + model: 'gpt-test', + choices: [{ + index: 0, + delta: { role: 'assistant' }, + finish_reason: null, + }], + }, context, 'gpt-test')).toMatchObject({ + role: 'assistant', + }); + + const openingFragment = normalizeUpstreamStreamEvent({ + id: 'chatcmpl-split-think', + model: 'gpt-test', + choices: [{ + index: 0, + delta: { content: 'plan ' }, + finish_reason: null, + }], + }, context, 'gpt-test')).toMatchObject({ + reasoningDelta: 'plan ', + }); + + expect(normalizeUpstreamStreamEvent({ + id: 'chatcmpl-split-think', + model: 'gpt-test', + choices: [{ + index: 0, + delta: { content: 'quietlyvisible answer' }, + finish_reason: null, + }], + }, context, 'gpt-test')).toMatchObject({ + contentDelta: 'visible answer', + }); + }); + + it('treats response.reasoning_summary_text.done as reasoning-only stream output', () => { + const context = createStreamTransformContext('gpt-test'); + + expect(normalizeUpstreamStreamEvent({ + type: 'response.reasoning_summary_text.done', + item_id: 'rs_1', + output_index: 0, + summary_index: 0, + text: 'plan first', + }, context, 'gpt-test')).toEqual({ + reasoningDelta: 'plan first', + }); + }); + + it('accumulates reasoning summary deltas before reconciling response.reasoning_summary_text.done', () => { + const context = createStreamTransformContext('gpt-test'); + + expect(normalizeUpstreamStreamEvent({ + type: 'response.reasoning_summary_text.delta', + item_id: 'rs_multi', + output_index: 0, + summary_index: 0, + delta: 'plan ', + }, context, 'gpt-test')).toEqual({ + reasoningDelta: 'plan ', + }); + + expect(normalizeUpstreamStreamEvent({ + type: 'response.reasoning_summary_text.delta', + item_id: 'rs_multi', + output_index: 0, + summary_index: 0, + delta: 'first', + }, context, 'gpt-test')).toEqual({ + reasoningDelta: 'first', + }); + + expect(normalizeUpstreamStreamEvent({ + type: 'response.reasoning_summary_text.done', + item_id: 'rs_multi', + output_index: 0, + summary_index: 0, + text: 'plan first', + }, context, 'gpt-test')).toEqual({}); + }); + + it('preserves terminal-only native responses output item payloads in stream normalization', () => { + const context = createStreamTransformContext('gpt-test'); + + expect(normalizeUpstreamStreamEvent({ + type: 'response.output_item.done', + output_index: 0, + item: { + id: 'msg_1', + type: 'message', + role: 'assistant', + status: 'completed', + content: [{ type: 'output_text', text: 'hello' }], + }, + }, context, 'gpt-test')).toEqual({ + role: 'assistant', + contentDelta: 'hello', + }); + + expect(normalizeUpstreamStreamEvent({ + type: 'response.output_item.done', + output_index: 1, + item: { + id: 'fc_1', + type: 'function_call', + call_id: 'call_1', + name: 'lookup', + arguments: '{"q":"x"}', + status: 'completed', + }, + }, context, 'gpt-test')).toEqual({ + toolCallDeltas: [{ + index: 0, + id: 'call_1', + name: 'lookup', + argumentsDelta: '{"q":"x"}', + }], + }); + }); + + it('keeps responses tool-call indices stable when response.completed replays mixed output arrays', () => { + const context = createStreamTransformContext('gpt-test'); + const claudeContext = createClaudeDownstreamContext(); + + const streamingDelta = normalizeUpstreamStreamEvent({ + type: 'response.function_call_arguments.delta', + output_index: 1, + call_id: 'call_1', + name: 'lookup', + delta: '{"q":"x"}', + }, context, 'gpt-test'); + + expect(streamingDelta).toEqual({ + toolCallDeltas: [{ + index: 0, + id: 'call_1', + name: 'lookup', + argumentsDelta: '{"q":"x"}', + }], + }); + expect(serializeNormalizedStreamEvent('openai', streamingDelta, context, claudeContext)).toHaveLength(1); + + expect(normalizeUpstreamStreamEvent({ + type: 'response.completed', + response: { + id: 'resp_3', + model: 'gpt-test', + status: 'completed', + output: [ + { + id: 'msg_1', + type: 'message', + role: 'assistant', + status: 'completed', + content: [{ type: 'output_text', text: 'working on it' }], + }, + { + id: 'fc_1', + type: 'function_call', + call_id: 'call_1', + name: 'lookup', + arguments: '{"q":"x"}', + status: 'completed', + }, + ], + }, + }, context, 'gpt-test')).toEqual({ + role: 'assistant', + contentDelta: 'working on it', + finishReason: 'stop', + done: true, + }); + }); + + it('preserves terminal response.completed payload output when it carries the only final content', () => { + const context = createStreamTransformContext('gpt-test'); + + expect(normalizeUpstreamStreamEvent({ + type: 'response.completed', + response: { + id: 'resp_1', + status: 'completed', + output: [ + { + id: 'msg_1', + type: 'message', + role: 'assistant', + status: 'completed', + content: [{ type: 'output_text', text: 'hello' }], + }, + ], + }, + }, context, 'gpt-test')).toEqual({ + role: 'assistant', + contentDelta: 'hello', + finishReason: 'stop', + done: true, + }); + }); + + it('preserves streamed trailing whitespace when reconciling response.completed content', () => { + const context = createStreamTransformContext('gpt-test'); + + expect(normalizeUpstreamStreamEvent({ + type: 'response.output_text.delta', + output_index: 0, + item_id: 'msg_ws_space', + delta: 'hello ', + }, context, 'gpt-test')).toEqual({ + contentDelta: 'hello ', + }); + + expect(normalizeUpstreamStreamEvent({ + type: 'response.completed', + response: { + id: 'resp_space_1', + status: 'completed', + output: [ + { + id: 'msg_ws_space', + type: 'message', + role: 'assistant', + status: 'completed', + content: [{ type: 'output_text', text: 'hello world' }], + }, + ], + }, + }, context, 'gpt-test')).toEqual({ + role: 'assistant', + contentDelta: 'world', + finishReason: 'stop', + done: true, + }); + }); + + it('preserves terminal response.completed custom tool metadata in stream normalization', () => { + const context = createStreamTransformContext('gpt-test'); + + expect(normalizeUpstreamStreamEvent({ + type: 'response.completed', + response: { + id: 'resp_2', + model: 'gpt-test', + status: 'completed', + output: [ + { + id: 'ct_1', + type: 'custom_tool_call', + call_id: 'call_custom_1', + name: 'Shell', + input: '{"command":"pwd"}', + }, + ], + }, + }, context, 'gpt-test')).toEqual({ + toolCallDeltas: [{ + index: 0, + id: 'call_custom_1', + name: 'Shell', + argumentsDelta: '{"command":"pwd"}', + }], + finishReason: 'tool_calls', + done: true, + }); + }); + + it('normalizes custom tool calls through the existing tool-call stream shape', () => { + const context = createStreamTransformContext('gpt-test'); + + expect(normalizeUpstreamStreamEvent({ + type: 'response.output_item.added', + output_index: 0, + item: { + id: 'ct_1', + type: 'custom_tool_call', + call_id: 'call_custom', + name: 'MyTool', + input: '', + }, + }, context, 'gpt-test')).toEqual({ + toolCallDeltas: [{ + index: 0, + id: 'call_custom', + name: 'MyTool', + }], + }); + + expect(normalizeUpstreamStreamEvent({ + type: 'response.custom_tool_call_input.done', + output_index: 0, + item_id: 'ct_1', + call_id: 'call_custom', + name: 'MyTool', + input: '{"path":"README.md"}', + }, context, 'gpt-test')).toEqual({ + toolCallDeltas: [{ + index: 0, + id: 'call_custom', + name: 'MyTool', + argumentsDelta: '{"path":"README.md"}', + }], + }); + }); +}); + +describe('convertClaudeRequestToOpenAiBody', () => { + it('keeps Claude tool_result content structured when a tool produces image blocks', () => { + const payload = { + model: 'gpt-test', + messages: [ + { + role: 'assistant', + content: [ + { + type: 'tool_use', + id: 'call-image', + name: 'ImageTool', + input: { query: 'cat' }, + }, + ], + }, + { + role: 'user', + content: [ + { + type: 'tool_result', + tool_use_id: 'call-image', + content: [ + { type: 'text', text: 'found 1' }, + { + type: 'image', + source: { + type: 'url', + url: 'https://example.com/cat.png', + media_type: 'image/png', + }, + }, + ], + }, + ], + }, + ], + }; + + const { messages } = convertClaudeRequestToOpenAiBody(payload); + const toolMessage = messages.find((message) => message.role === 'tool'); + expect(toolMessage).toBeTruthy(); + expect(Array.isArray(toolMessage?.content)).toBe(true); + expect(toolMessage?.content.some((part: any) => part?.type === 'image_url')).toBe(true); + }); +}); diff --git a/src/server/transformers/shared/chatFormatsCore.ts b/src/server/transformers/shared/chatFormatsCore.ts index 6dc20762..50c68e4e 100644 --- a/src/server/transformers/shared/chatFormatsCore.ts +++ b/src/server/transformers/shared/chatFormatsCore.ts @@ -1,6 +1,13 @@ import { decodeAnthropicReasoningSignature, } from './reasoningTransport.js'; +import { toOpenAiChatFileBlock } from './inputFile.js'; +import { + consumeThinkTaggedText, + createThinkTagParserState, + extractInlineThinkTags, + type ThinkTagParserState, +} from './thinkTagParser.js'; export type DownstreamFormat = 'openai' | 'claude'; @@ -16,6 +23,12 @@ export type StreamTransformContext = { roleSent: boolean; doneSent: boolean; toolCalls: Record; + responsesToolCallIndexByOutputIndex: Record; + responsesToolCallIndexById: Record; + nextResponsesToolCallIndex: number; + responsesTextByIndex: Record; + responsesReasoningByIndex: Record; + thinkTagParser: ThinkTagParserState; }; export type ClaudeDownstreamContext = { @@ -79,6 +92,10 @@ function isNonEmptyString(value: unknown): value is string { return typeof value === 'string' && value.trim().length > 0; } +function asTrimmedString(value: unknown): string { + return typeof value === 'string' ? value.trim() : ''; +} + function pickFiniteNumber(value: unknown): number | undefined { return typeof value === 'number' && Number.isFinite(value) ? value : undefined; } @@ -93,6 +110,24 @@ function joinNonEmpty(parts: string[]): string { return parts.map((item) => item.trim()).filter((item) => item.length > 0).join('\n\n'); } +function joinIndexedResponsesText(partsByIndex: Record): string { + const indexedParts = Object.entries(partsByIndex) + .map(([rawIndex, text]) => ({ + index: Number(rawIndex), + text, + })) + .filter((entry) => Number.isFinite(entry.index) && entry.index >= 0 && typeof entry.text === 'string' && entry.text.length > 0) + .sort((left, right) => left.index - right.index) + .map((entry) => entry.text); + + if (indexedParts.length > 0) { + return indexedParts.join('\n\n'); + } + + const snapshot = partsByIndex[-1]; + return typeof snapshot === 'string' ? snapshot : ''; +} + function textFromPart(part: unknown): string { if (typeof part === 'string') return part; if (!isRecord(part)) return ''; @@ -102,8 +137,6 @@ function textFromPart(part: unknown): string { if (typeof part.output_text === 'string') return part.output_text; if (typeof part.completion === 'string') return part.completion; if (typeof part.partial_json === 'string') return part.partial_json; - if (typeof part.reasoning_content === 'string') return part.reasoning_content; - if (typeof part.reasoning === 'string') return part.reasoning; if (Array.isArray(part.content)) { return part.content.map((item) => textFromPart(item)).join(''); @@ -118,13 +151,15 @@ function textFromPart(part: unknown): string { } function extractTextAndReasoning(value: unknown): { content: string; reasoning: string } { - if (typeof value === 'string') return { content: value, reasoning: '' }; + if (typeof value === 'string') return extractInlineThinkTags(value); if (Array.isArray(value)) { const contentParts: string[] = []; const reasoningParts: string[] = []; for (const item of value) { if (typeof item === 'string') { - contentParts.push(item); + const parsedString = extractInlineThinkTags(item); + if (parsedString.content) contentParts.push(parsedString.content); + if (parsedString.reasoning) reasoningParts.push(parsedString.reasoning); continue; } if (!isRecord(item)) continue; @@ -143,8 +178,9 @@ function extractTextAndReasoning(value: unknown): { content: string; reasoning: continue; } - const text = textFromPart(item); - if (text) contentParts.push(text); + const parsedText = extractInlineThinkTags(textFromPart(item)); + if (parsedText.content) contentParts.push(parsedText.content); + if (parsedText.reasoning) reasoningParts.push(parsedText.reasoning); } return { @@ -159,9 +195,34 @@ function extractTextAndReasoning(value: unknown): { content: string; reasoning: return extractTextAndReasoning(value.parts); } + const directReasoning = joinNonEmpty([ + typeof value.reasoning_content === 'string' ? value.reasoning_content : '', + typeof value.reasoning === 'string' ? value.reasoning : '', + typeof value.thinking === 'string' ? value.thinking : '', + ]); + const parsedText = extractInlineThinkTags(textFromPart(value)); + + return { + content: parsedText.content, + reasoning: joinNonEmpty([directReasoning, parsedText.reasoning]), + }; +} + +function extractStreamingTextAndReasoning( + value: unknown, + thinkTagParser: ThinkTagParserState, +): { content: string; reasoning: string } { + const parsed = extractTextAndReasoning(value); + if (!parsed.content) { + return parsed; + } + + const streamed = consumeThinkTaggedText(thinkTagParser, parsed.content); return { - content: textFromPart(value), - reasoning: '', + content: streamed.content, + reasoning: [parsed.reasoning, streamed.reasoning] + .filter((part) => part.length > 0) + .join(''), }; } @@ -218,6 +279,12 @@ export function createStreamTransformContext(modelName: string): StreamTransform roleSent: false, doneSent: false, toolCalls: {}, + responsesToolCallIndexByOutputIndex: {}, + responsesToolCallIndexById: {}, + nextResponsesToolCallIndex: 0, + responsesTextByIndex: {}, + responsesReasoningByIndex: {}, + thinkTagParser: createThinkTagParserState(), }; } @@ -254,10 +321,18 @@ function extractAssistantContent(choice: any): string { const content = extractTextAndReasoning(choice?.content).content; if (content) return content; - if (typeof choice?.text === 'string' && choice.text.length > 0) return choice.text; - if (typeof choice?.completion === 'string' && choice.completion.length > 0) return choice.completion; - if (typeof choice?.output_text === 'string' && choice.output_text.length > 0) return choice.output_text; - if (typeof choice?.delta?.content === 'string' && choice.delta.content.length > 0) return choice.delta.content; + if (typeof choice?.text === 'string' && choice.text.length > 0) { + return extractInlineThinkTags(choice.text).content; + } + if (typeof choice?.completion === 'string' && choice.completion.length > 0) { + return extractInlineThinkTags(choice.completion).content; + } + if (typeof choice?.output_text === 'string' && choice.output_text.length > 0) { + return extractInlineThinkTags(choice.output_text).content; + } + if (typeof choice?.delta?.content === 'string' && choice.delta.content.length > 0) { + return extractInlineThinkTags(choice.delta.content).content; + } return ''; } @@ -279,6 +354,11 @@ function extractAssistantReasoning(choice: any): string { const nested = extractTextAndReasoning(choice?.content).reasoning; if (nested) return nested; + if (typeof choice?.delta?.content === 'string' && choice.delta.content.length > 0) { + const parsedDelta = extractInlineThinkTags(choice.delta.content); + if (parsedDelta.reasoning) return parsedDelta.reasoning; + } + return ''; } @@ -286,6 +366,90 @@ function parseClaudeMessageContent(content: unknown): string { return extractTextAndReasoning(content).content; } +function buildOpenAiImageUrlBlock(url: string): Record { + return { + type: 'image_url', + image_url: { url }, + }; +} + +function buildOpenAiFileBlock(input: { + fileData?: string; + fileUrl?: string; + filename?: string; + mimeType?: string; +}): Record | null { + const fileData = typeof input.fileData === 'string' ? input.fileData.trim() : ''; + const fileUrl = typeof input.fileUrl === 'string' ? input.fileUrl.trim() : ''; + const filename = typeof input.filename === 'string' ? input.filename.trim() : ''; + const mimeType = typeof input.mimeType === 'string' ? input.mimeType.trim() : ''; + if (!fileData && !fileUrl) return null; + + const file: Record = {}; + if (fileData) file.file_data = fileData; + if (fileUrl && !fileData) file.file_url = fileUrl; + if (filename) file.filename = filename; + if (mimeType) file.mime_type = mimeType; + return { + type: 'file', + file, + }; +} + +function convertClaudeContentBlockToOpenAi(block: Record): Record | null { + const blockType = typeof block.type === 'string' ? block.type : ''; + + if (blockType === 'text') { + const text = parseClaudeMessageContent(block); + return text ? { type: 'text', text } : null; + } + + if (blockType === 'image') { + const source = isRecord(block.source) ? block.source : null; + const sourceType = typeof source?.type === 'string' ? source.type : ''; + if (sourceType === 'url' && typeof source?.url === 'string' && source.url.trim()) { + return buildOpenAiImageUrlBlock(source.url.trim()); + } + if ( + sourceType === 'base64' + && typeof source?.media_type === 'string' + && source.media_type.trim() + && typeof source?.data === 'string' + && source.data.trim() + ) { + return buildOpenAiImageUrlBlock(`data:${source.media_type.trim()};base64,${source.data.trim()}`); + } + return null; + } + + if (blockType === 'document') { + const source = isRecord(block.source) ? block.source : null; + const sourceType = typeof source?.type === 'string' ? source.type : ''; + return buildOpenAiFileBlock({ + fileData: sourceType === 'base64' && typeof source?.data === 'string' ? source.data : undefined, + fileUrl: sourceType === 'url' && typeof source?.url === 'string' ? source.url : undefined, + filename: typeof block.title === 'string' ? block.title : undefined, + mimeType: typeof source?.media_type === 'string' ? source.media_type : undefined, + }); + } + + const text = parseClaudeMessageContent(block); + return text ? { type: 'text', text } : null; +} + +function buildOpenAiMessageContent( + contentBlocks: Array>, +): string | Array> | undefined { + if (contentBlocks.length <= 0) return undefined; + if (contentBlocks.every((block) => block.type === 'text' && typeof block.text === 'string')) { + return contentBlocks + .map((block) => String(block.text).trim()) + .filter(Boolean) + .join('\n\n'); + } + return contentBlocks; +} + function parseResponsesOutputText(payload: Record): string { const direct = typeof payload.output_text === 'string' ? payload.output_text : ''; if (direct) return direct; @@ -301,6 +465,58 @@ function parseResponsesOutputText(payload: Record): string { return parts.join('\n\n'); } +function parseResponsesReasoning(payload: Record): { + reasoningContent: string; + reasoningSignature?: string; +} { + const output = Array.isArray(payload.output) ? payload.output : []; + const reasoningParts: string[] = []; + let reasoningSignature = ''; + + for (const item of output) { + if (!isRecord(item)) continue; + if (asTrimmedString(item.type).toLowerCase() !== 'reasoning') continue; + + const parsed = extractTextAndReasoning(item.summary ?? item.content ?? item); + const text = joinNonEmpty([parsed.content, parsed.reasoning]); + if (text) reasoningParts.push(text); + + const encrypted = asTrimmedString(item.encrypted_content); + if (!reasoningSignature && encrypted) { + reasoningSignature = encrypted; + } + } + + return { + reasoningContent: joinNonEmpty(reasoningParts), + ...(reasoningSignature ? { reasoningSignature } : {}), + }; +} + +function unwrapTerminalResponsesEnvelope( + payload: Record, +): Record | null { + const type = asTrimmedString(payload.type).toLowerCase(); + if ( + type !== 'response.completed' + && type !== 'response.failed' + && type !== 'response.incomplete' + ) { + return null; + } + if (!isRecord(payload.response)) return null; + + const responsePayload = payload.response as Record; + if (isNonEmptyString(responsePayload.status)) { + return responsePayload; + } + + return { + ...responsePayload, + status: type.slice('response.'.length), + }; +} + function stringifyUnknownValue(value: unknown): string { if (typeof value === 'string') return value; if (value === undefined || value === null) return ''; @@ -394,7 +610,7 @@ function collectToolCallsFromResponsesPayload(payload: Record): for (let index = 0; index < output.length; index += 1) { const item = output[index]; if (!isRecord(item)) continue; - if (item.type !== 'function_call') continue; + if (item.type !== 'function_call' && item.type !== 'custom_tool_call') continue; const id = ( typeof item.call_id === 'string' && item.call_id.trim().length > 0 @@ -405,7 +621,9 @@ function collectToolCallsFromResponsesPayload(payload: Record): const argumentsText = ( typeof item.arguments === 'string' ? item.arguments - : stringifyUnknownValue(item.arguments) + : (typeof item.input === 'string' + ? item.input + : stringifyUnknownValue(item.arguments ?? item.input)) ); toolCalls.push({ id, @@ -417,7 +635,396 @@ function collectToolCallsFromResponsesPayload(payload: Record): return toolCalls; } -function convertClaudeRequestToOpenAiBody(body: Record): { +function collectIndexedToolCallsFromResponsesPayload( + payload: Record, +): Array<{ id: string; name: string; arguments: string; outputIndex: number }> { + const output = Array.isArray(payload.output) ? payload.output : []; + const toolCalls: Array<{ id: string; name: string; arguments: string; outputIndex: number }> = []; + + for (let index = 0; index < output.length; index += 1) { + const item = output[index]; + if (!isRecord(item)) continue; + if (item.type !== 'function_call' && item.type !== 'custom_tool_call') continue; + + const id = ( + typeof item.call_id === 'string' && item.call_id.trim().length > 0 + ? item.call_id.trim() + : (typeof item.id === 'string' && item.id.trim().length > 0 ? item.id.trim() : `call_${index}`) + ); + const name = typeof item.name === 'string' ? item.name.trim() : ''; + const argumentsText = ( + typeof item.arguments === 'string' + ? item.arguments + : (typeof item.input === 'string' + ? item.input + : stringifyUnknownValue(item.arguments ?? item.input)) + ); + toolCalls.push({ + id, + name, + arguments: argumentsText, + outputIndex: index, + }); + } + + return toolCalls; +} + +function computeNovelResponsesDelta(existingText: string, incomingText: string): string { + if (!incomingText) return ''; + if (!existingText) return incomingText; + if (incomingText.startsWith(existingText)) return incomingText.slice(existingText.length); + if (existingText.endsWith(incomingText)) return ''; + + const maxOverlap = Math.min(existingText.length, incomingText.length); + for (let overlap = maxOverlap; overlap > 0; overlap -= 1) { + if (existingText.endsWith(incomingText.slice(0, overlap))) { + return incomingText.slice(overlap); + } + } + + return incomingText; +} + +function extractResponsesOutputIndex(payload: Record): number { + return ( + typeof payload.output_index === 'number' && Number.isFinite(payload.output_index) + ? Math.max(0, Math.trunc(payload.output_index)) + : 0 + ); +} + +function extractResponsesItemText(item: Record): string { + const itemType = asTrimmedString(item.type).toLowerCase(); + if (itemType === 'message') { + return extractTextAndReasoning(item.content ?? item).content; + } + if (itemType === 'reasoning') { + const parsed = extractTextAndReasoning(item.summary ?? item.content ?? item); + return joinNonEmpty([parsed.content, parsed.reasoning]); + } + return ''; +} + +function rememberResponsesToolCallIndex( + context: StreamTransformContext, + canonicalIndex: number, + input: { + outputIndex?: number; + itemId?: unknown; + callId?: unknown; + }, +): void { + if (typeof input.outputIndex === 'number' && Number.isFinite(input.outputIndex)) { + context.responsesToolCallIndexByOutputIndex[Math.max(0, Math.trunc(input.outputIndex))] = canonicalIndex; + } + const itemId = asTrimmedString(input.itemId); + if (itemId) { + context.responsesToolCallIndexById[`item:${itemId}`] = canonicalIndex; + } + const callId = asTrimmedString(input.callId); + if (callId) { + context.responsesToolCallIndexById[`call:${callId}`] = canonicalIndex; + } +} + +function resolveResponsesToolCallIndex( + context: StreamTransformContext, + input: { + outputIndex?: number; + itemId?: unknown; + callId?: unknown; + }, +): number { + const normalizedOutputIndex = ( + typeof input.outputIndex === 'number' && Number.isFinite(input.outputIndex) + ? Math.max(0, Math.trunc(input.outputIndex)) + : undefined + ); + if ( + normalizedOutputIndex !== undefined + && context.responsesToolCallIndexByOutputIndex[normalizedOutputIndex] !== undefined + ) { + const canonicalIndex = context.responsesToolCallIndexByOutputIndex[normalizedOutputIndex]!; + rememberResponsesToolCallIndex(context, canonicalIndex, { + outputIndex: normalizedOutputIndex, + itemId: input.itemId, + callId: input.callId, + }); + return canonicalIndex; + } + + const itemId = asTrimmedString(input.itemId); + if (itemId && context.responsesToolCallIndexById[`item:${itemId}`] !== undefined) { + const canonicalIndex = context.responsesToolCallIndexById[`item:${itemId}`]!; + rememberResponsesToolCallIndex(context, canonicalIndex, { + outputIndex: normalizedOutputIndex, + itemId, + callId: input.callId, + }); + return canonicalIndex; + } + + const callId = asTrimmedString(input.callId); + if (callId && context.responsesToolCallIndexById[`call:${callId}`] !== undefined) { + const canonicalIndex = context.responsesToolCallIndexById[`call:${callId}`]!; + rememberResponsesToolCallIndex(context, canonicalIndex, { + outputIndex: normalizedOutputIndex, + itemId: input.itemId, + callId, + }); + return canonicalIndex; + } + + const canonicalIndex = context.nextResponsesToolCallIndex; + context.nextResponsesToolCallIndex += 1; + rememberResponsesToolCallIndex(context, canonicalIndex, { + outputIndex: normalizedOutputIndex, + itemId: input.itemId, + callId: input.callId, + }); + return canonicalIndex; +} + +function buildResponsesToolCallDeltaFromItem( + item: Record, + outputIndex: number, + context: StreamTransformContext, +): NormalizedStreamEvent | null { + const itemType = asTrimmedString(item.type).toLowerCase(); + if (itemType !== 'function_call' && itemType !== 'custom_tool_call') return null; + + const toolCallId = ( + isNonEmptyString(item.call_id) ? item.call_id + : (isNonEmptyString(item.id) ? item.id : undefined) + ); + const toolName = isNonEmptyString(item.name) ? item.name : undefined; + const rawArguments = itemType === 'custom_tool_call' + ? (typeof item.input === 'string' ? item.input : stringifyUnknownValue(item.input)) + : (typeof item.arguments === 'string' ? item.arguments : stringifyUnknownValue(item.arguments)); + const canonicalIndex = resolveResponsesToolCallIndex(context, { + outputIndex, + itemId: item.id, + callId: item.call_id, + }); + const existingArguments = context.toolCalls[canonicalIndex]?.arguments || ''; + const argumentsDelta = computeNovelResponsesDelta(existingArguments, rawArguments); + const knownTool = context.toolCalls[canonicalIndex] || {}; + const shouldBackfillId = !!toolCallId && !knownTool.id; + const shouldBackfillName = !!toolName && !knownTool.name; + + if (!argumentsDelta && !shouldBackfillId && !shouldBackfillName) { + return null; + } + + return { + toolCallDeltas: [{ + index: canonicalIndex, + id: toolCallId, + name: toolName, + argumentsDelta: argumentsDelta || undefined, + }], + }; +} + +function formatAnthropicBase64DataUrl(mimeType: string, data: string): string { + return `data:${mimeType};base64,${data}`; +} + +function parseAnthropicBase64Source( + source: unknown, +): { mimeType: string; data: string } | null { + if (!isRecord(source)) return null; + const sourceType = asTrimmedString(source.type).toLowerCase(); + if (sourceType !== 'base64') return null; + const mimeType = ( + asTrimmedString(source.media_type) + || asTrimmedString(source.mime_type) + || asTrimmedString(source.mediaType) + || asTrimmedString(source.mimeType) + || 'application/octet-stream' + ); + const data = asTrimmedString(source.data); + if (!data) return null; + return { mimeType, data }; +} + +function parseAnthropicUrlSource( + source: unknown, +): { url: string; mimeType: string | null } | null { + if (!isRecord(source)) return null; + const sourceType = asTrimmedString(source.type).toLowerCase(); + if (sourceType !== 'url') return null; + const url = asTrimmedString(source.url); + if (!url) return null; + const mimeType = ( + asTrimmedString(source.media_type) + || asTrimmedString(source.mime_type) + || asTrimmedString(source.mediaType) + || asTrimmedString(source.mimeType) + || null + ); + return { url, mimeType }; +} + +function toOpenAiContentBlockFromClaudeBlock( + block: Record, +): Record | null { + const blockType = asTrimmedString(block.type).toLowerCase(); + + if (blockType === 'text') { + const text = asTrimmedString(block.text); + return text ? { type: 'text', text } : null; + } + + if (blockType !== 'image' && blockType !== 'document') { + return null; + } + + const title = asTrimmedString(block.title); + const base64Source = parseAnthropicBase64Source(block.source); + const urlSource = parseAnthropicUrlSource(block.source); + const mimeType = (base64Source?.mimeType || urlSource?.mimeType || '').toLowerCase(); + const treatAsImage = blockType === 'image' || mimeType.startsWith('image/'); + + if (treatAsImage) { + const imageUrl = base64Source + ? formatAnthropicBase64DataUrl(base64Source.mimeType, base64Source.data) + : (urlSource?.url || ''); + return imageUrl + ? { + type: 'image_url', + image_url: { url: imageUrl }, + } + : null; + } + + if (base64Source) { + return toOpenAiChatFileBlock({ + fileData: base64Source.data, + filename: title || undefined, + mimeType: base64Source.mimeType, + }); + } + + if (urlSource) { + return toOpenAiChatFileBlock({ + fileUrl: urlSource.url, + filename: title || undefined, + mimeType: urlSource.mimeType, + }); + } + + return null; +} + +function collapseOpenAiContentBlocks( + blocks: Array>, +): string | Array> | null { + if (blocks.length <= 0) return null; + const textOnly = blocks.every((block) => block.type === 'text' && typeof block.text === 'string'); + if (!textOnly) return blocks; + + const text = blocks + .map((block) => (typeof block.text === 'string' ? block.text : '')) + .join('\n\n') + .trim(); + return text || null; +} + +function pickPositiveInteger(value: unknown): number | undefined { + if (typeof value === 'number' && Number.isFinite(value) && value > 0) { + return Math.trunc(value); + } + if (typeof value === 'string') { + const parsed = Number.parseInt(value, 10); + if (Number.isFinite(parsed) && parsed > 0) return parsed; + } + return undefined; +} + +function convertClaudeToolsToOpenAiChat(rawTools: unknown): unknown { + if (!Array.isArray(rawTools)) return rawTools; + + return rawTools.map((item) => { + if (!isRecord(item)) return item; + + const type = asTrimmedString(item.type).toLowerCase(); + if (type === 'function' || type === 'custom' || type === 'image_generation') { + return item; + } + + const name = asTrimmedString(item.name); + if (!name) return item; + + return { + type: 'function', + function: { + name, + ...(asTrimmedString(item.description) + ? { description: asTrimmedString(item.description) } + : {}), + parameters: isRecord(item.input_schema) + ? item.input_schema + : (isRecord(item.parameters) ? item.parameters : { type: 'object' }), + }, + }; + }); +} + +function convertClaudeToolChoiceToOpenAiChat(rawToolChoice: unknown): unknown { + if (rawToolChoice === undefined) return undefined; + if (typeof rawToolChoice === 'string') { + const normalized = rawToolChoice.trim().toLowerCase(); + if (normalized === 'any') return 'required'; + return normalized || rawToolChoice; + } + if (!isRecord(rawToolChoice)) return rawToolChoice; + + const type = asTrimmedString(rawToolChoice.type).toLowerCase(); + if (type === 'auto' || type === 'none') return type; + if (type === 'any' || type === 'required') return 'required'; + if (type === 'function' && isRecord(rawToolChoice.function)) { + const name = asTrimmedString(rawToolChoice.function.name); + return name + ? { + type: 'function', + function: { name }, + } + : 'required'; + } + if (type !== 'tool') return rawToolChoice; + + const name = asTrimmedString( + rawToolChoice.name + ?? (isRecord(rawToolChoice.tool) ? rawToolChoice.tool.name : undefined), + ); + return name + ? { + type: 'function', + function: { name }, + } + : 'required'; +} + +function extractClaudeReasoningRequest( + body: Record, +): { reasoningEffort?: string; reasoningBudget?: number } { + const thinking = isRecord(body.thinking) ? body.thinking : null; + const outputConfig = isRecord(body.output_config) ? body.output_config : null; + const reasoningEffort = asTrimmedString(outputConfig?.effort).toLowerCase(); + const reasoningBudget = pickPositiveInteger( + thinking?.budget_tokens + ?? thinking?.budgetTokens, + ); + + return { + ...(reasoningEffort ? { reasoningEffort } : {}), + ...(reasoningBudget !== undefined ? { reasoningBudget } : {}), + }; +} + +export function convertClaudeRequestToOpenAiBody(body: Record): { model: string; stream: boolean; messages: Array>; @@ -428,6 +1035,42 @@ function convertClaudeRequestToOpenAiBody(body: Record): { const messages: Array> = []; + const convertToolResultContent = (content: unknown): string | Array> | null => { + const blocks: Array> = []; + + const appendContentBlock = (item: unknown) => { + if (isRecord(item)) { + const block = toOpenAiContentBlockFromClaudeBlock(item); + if (block) { + blocks.push(block); + return; + } + } + const text = parseClaudeMessageContent(item); + if (text) { + blocks.push({ type: 'text', text }); + } + }; + + const valueToProcess = (isRecord(content) && Array.isArray(content.content)) + ? (content.content as unknown[]) + : content; + + if (Array.isArray(valueToProcess)) { + for (const item of valueToProcess) { + appendContentBlock(item); + } + } else { + appendContentBlock(valueToProcess); + } + + if (blocks.length <= 0) return null; + const collapsed = collapseOpenAiContentBlocks(blocks); + if (!collapsed) return null; + if (typeof collapsed === 'string' && collapsed.length === 0) return null; + return collapsed; + }; + const appendMessage = (role: string, content: unknown) => { const text = parseClaudeMessageContent(content); if (!text) return; @@ -438,16 +1081,13 @@ function convertClaudeRequestToOpenAiBody(body: Record): { const toolCallId = typeof toolUseId === 'string' ? toolUseId.trim() : ''; if (!toolCallId) return; - const text = ( - parseClaudeMessageContent(content) - || stringifyUnknownValue(content) - ).trim(); - if (!text) return; + const contentPayload = convertToolResultContent(content); + if (!contentPayload) return; messages.push({ role: 'tool', tool_call_id: toolCallId, - content: text, + content: contentPayload, }); }; @@ -474,14 +1114,15 @@ function convertClaudeRequestToOpenAiBody(body: Record): { // Claude tool blocks need explicit OpenAI mapping: // - assistant.tool_use -> assistant.tool_calls // - user.tool_result -> tool messages with tool_call_id - const textParts: string[] = []; + const contentBlocks: Array> = []; const toolCalls: Array> = []; + const reasoningParts: string[] = []; - const flushTextAsMessage = () => { - const merged = textParts.map((item) => item.trim()).filter(Boolean).join('\n\n'); - textParts.length = 0; - if (!merged) return; - messages.push({ role: mappedRole, content: merged }); + const flushContentAsMessage = () => { + const contentPayload = collapseOpenAiContentBlocks(contentBlocks); + contentBlocks.length = 0; + if (contentPayload === null) return; + messages.push({ role: mappedRole, content: contentPayload }); }; for (const block of content) { @@ -489,7 +1130,6 @@ function convertClaudeRequestToOpenAiBody(body: Record): { const blockType = typeof block.type === 'string' ? block.type : ''; if (blockType === 'tool_result') { - flushTextAsMessage(); appendToolResultMessage(block.tool_use_id, block.content); continue; } @@ -517,23 +1157,47 @@ function convertClaudeRequestToOpenAiBody(body: Record): { continue; } - const text = parseClaudeMessageContent(block); - if (text) textParts.push(text); + const extracted = extractTextAndReasoning(block); + if (mappedRole === 'assistant' && extracted.reasoning) { + reasoningParts.push(extracted.reasoning); + } + + const contentBlock = toOpenAiContentBlockFromClaudeBlock(block); + if (contentBlock) { + contentBlocks.push(contentBlock); + continue; + } + + const text = extracted.content || parseClaudeMessageContent(block); + if (text) { + contentBlocks.push({ + type: 'text', + text, + }); + } } - const merged = textParts.map((item) => item.trim()).filter(Boolean).join('\n\n'); + const merged = collapseOpenAiContentBlocks(contentBlocks); if (toolCalls.length > 0) { const assistantMessage: Record = { role: 'assistant', tool_calls: toolCalls, }; - // Keep textual assistant preface when present. assistantMessage.content = merged || ''; + const reasoningContent = joinNonEmpty(reasoningParts); + if (reasoningContent) assistantMessage.reasoning_content = reasoningContent; messages.push(assistantMessage); - } else if (merged) { - messages.push({ + } else if (merged !== null || (mappedRole === 'assistant' && reasoningParts.length > 0)) { + const nextMessage: Record = { role: mappedRole, - content: merged, + content: merged ?? '', + }; + if (mappedRole === 'assistant') { + const reasoningContent = joinNonEmpty(reasoningParts); + if (reasoningContent) nextMessage.reasoning_content = reasoningContent; + } + messages.push({ + ...nextMessage, }); } } @@ -550,6 +1214,8 @@ function convertClaudeRequestToOpenAiBody(body: Record): { const topP = pickFiniteNumber(body.top_p); if (topP !== undefined) payload.top_p = topP; + if (isRecord(body.metadata)) payload.metadata = body.metadata; + const maxTokens = pickFiniteNumber(body.max_tokens); if (maxTokens !== undefined) { payload.max_tokens = maxTokens; @@ -561,8 +1227,12 @@ function convertClaudeRequestToOpenAiBody(body: Record): { payload.stop = body.stop_sequences; } - if (body.tools !== undefined) payload.tools = body.tools; - if (body.tool_choice !== undefined) payload.tool_choice = body.tool_choice; + const reasoningRequest = extractClaudeReasoningRequest(body); + if (reasoningRequest.reasoningEffort) payload.reasoning_effort = reasoningRequest.reasoningEffort; + if (reasoningRequest.reasoningBudget !== undefined) payload.reasoning_budget = reasoningRequest.reasoningBudget; + + if (body.tools !== undefined) payload.tools = convertClaudeToolsToOpenAiChat(body.tools); + if (body.tool_choice !== undefined) payload.tool_choice = convertClaudeToolChoiceToOpenAiChat(body.tool_choice); return { model, stream, messages, payload }; } @@ -643,6 +1313,13 @@ export function normalizeUpstreamFinalResponse( const now = Math.floor(Date.now() / 1000); const fallbackId = `chatcmpl-meta-${Date.now()}`; + if (isRecord(payload)) { + const terminalResponsesPayload = unwrapTerminalResponsesEnvelope(payload); + if (terminalResponsesPayload) { + return normalizeUpstreamFinalResponse(terminalResponsesPayload, fallbackModel, fallbackText); + } + } + if (isRecord(payload) && Array.isArray(payload.choices)) { const choice = payload.choices[0] ?? {}; const content = extractAssistantContent(choice) || extractAssistantContent(payload); @@ -676,12 +1353,14 @@ export function normalizeUpstreamFinalResponse( if (isRecord(payload) && ((payload as any).object === 'response' || Array.isArray((payload as any).output))) { const toolCalls = collectToolCallsFromResponsesPayload(payload); + const responsesReasoning = parseResponsesReasoning(payload); return { id: isNonEmptyString(payload.id) ? payload.id : fallbackId, model: isNonEmptyString(payload.model) ? payload.model : fallbackModel, - created: ensureIntegerTimestamp(payload.created, now), + created: ensureIntegerTimestamp((payload as any).created_at ?? payload.created, now), content: parseResponsesOutputText(payload) || (toolCalls.length > 0 ? '' : fallbackText), - reasoningContent: '', + reasoningContent: responsesReasoning.reasoningContent, + ...(responsesReasoning.reasoningSignature ? { reasoningSignature: responsesReasoning.reasoningSignature } : {}), finishReason: toolCalls.length > 0 ? 'tool_calls' : (normalizeStopReason(payload.finish_reason ?? payload.status) || 'stop'), @@ -742,17 +1421,19 @@ export function normalizeUpstreamStreamEvent( const choice = payload.choices[0] ?? {}; const delta = isRecord(choice?.delta) ? choice.delta : {}; - const deltaParsed = extractTextAndReasoning(delta.content ?? delta); + const deltaParsed = extractStreamingTextAndReasoning(delta.content ?? delta, context.thinkTagParser); + const messageParsed = extractStreamingTextAndReasoning(choice?.message?.content ?? '', context.thinkTagParser); const rawContentDelta = deltaParsed.content - || (typeof choice?.message?.content === 'string' ? choice.message.content : '') + || messageParsed.content || ''; const reasoningDelta = (typeof (delta as any).reasoning_content === 'string' ? (delta as any).reasoning_content : '') || (typeof (delta as any).reasoning === 'string' ? (delta as any).reasoning : '') || deltaParsed.reasoning + || messageParsed.reasoning || ''; const reasoningSignature = isNonEmptyString((delta as any).reasoning_signature) ? (delta as any).reasoning_signature @@ -810,20 +1491,39 @@ export function normalizeUpstreamStreamEvent( const type = typeof payload.type === 'string' ? payload.type : ''; if (type.startsWith('response.output_text')) { - const deltaText = typeof payload.delta === 'string' + const outputIndex = extractResponsesOutputIndex(payload); + const rawText = typeof payload.delta === 'string' ? payload.delta - : extractTextAndReasoning(payload.delta).content; + : (typeof (payload as any).text === 'string' ? (payload as any).text : ''); + const parsed = extractStreamingTextAndReasoning(rawText, context.thinkTagParser); + const nextContent = type === 'response.output_text.done' + ? (parsed.content || context.responsesTextByIndex[outputIndex] || '') + : `${context.responsesTextByIndex[outputIndex] || ''}${parsed.content || ''}`; + const novelContent = type === 'response.output_text.done' + ? computeNovelResponsesDelta(context.responsesTextByIndex[outputIndex] || '', parsed.content || '') + : (parsed.content || ''); + if (nextContent) context.responsesTextByIndex[outputIndex] = nextContent; return { - contentDelta: deltaText || undefined, + contentDelta: novelContent || undefined, + reasoningDelta: parsed.reasoning || undefined, }; } - if (type === 'response.reasoning_summary_text.delta') { - const deltaText = typeof payload.delta === 'string' - ? payload.delta - : extractTextAndReasoning(payload.delta).content; + if (type === 'response.reasoning_summary_text.delta' || type === 'response.reasoning_summary_text.done') { + const outputIndex = extractResponsesOutputIndex(payload); + const deltaText = type === 'response.reasoning_summary_text.done' + ? (typeof (payload as any).text === 'string' ? (payload as any).text : extractTextAndReasoning(payload.text).content) + : (typeof payload.delta === 'string' ? payload.delta : extractTextAndReasoning(payload.delta).content); + const previousReasoning = context.responsesReasoningByIndex[outputIndex] || ''; + const novelDelta = computeNovelResponsesDelta(previousReasoning, deltaText); + const nextReasoning = type === 'response.reasoning_summary_text.done' + ? (deltaText || previousReasoning) + : `${previousReasoning}${novelDelta}`; + if (nextReasoning) { + context.responsesReasoningByIndex[outputIndex] = nextReasoning; + } return { - reasoningDelta: deltaText || undefined, + reasoningDelta: novelDelta || undefined, }; } @@ -840,40 +1540,44 @@ export function normalizeUpstreamStreamEvent( }; } - if (type === 'response.output_item.added' && isRecord((payload as any).item)) { + if ((type === 'response.output_item.added' || type === 'response.output_item.done') && isRecord((payload as any).item)) { + const outputIndex = extractResponsesOutputIndex(payload as Record); const item = (payload as any).item as Record; if (item.type === 'reasoning' && isNonEmptyString(item.encrypted_content)) { + const reasoningText = extractResponsesItemText(item); + const novelReasoning = computeNovelResponsesDelta(context.responsesReasoningByIndex[outputIndex] || '', reasoningText); + if (reasoningText) { + context.responsesReasoningByIndex[outputIndex] = reasoningText; + } return { reasoningSignature: item.encrypted_content, + reasoningDelta: novelReasoning || undefined, }; } - if (item.type === 'function_call') { - const outputIndex = ( - typeof (payload as any).output_index === 'number' && Number.isFinite((payload as any).output_index) - ? Math.max(0, Math.trunc((payload as any).output_index)) - : 0 - ); - const toolCallId = ( - isNonEmptyString(item.call_id) ? item.call_id - : (isNonEmptyString(item.id) ? item.id : undefined) - ); - const toolName = isNonEmptyString(item.name) ? item.name : undefined; + const toolCallEvent = buildResponsesToolCallDeltaFromItem(item, outputIndex, context); + if (toolCallEvent) { + return toolCallEvent; + } + if (item.type === 'message') { + const fullText = extractResponsesItemText(item); + const novelDelta = computeNovelResponsesDelta(context.responsesTextByIndex[outputIndex] || '', fullText); + if (fullText) { + context.responsesTextByIndex[outputIndex] = fullText; + } return { - toolCallDeltas: [{ - index: outputIndex, - id: toolCallId, - name: toolName, - }], + role: item.role === 'assistant' ? 'assistant' : undefined, + contentDelta: novelDelta || undefined, }; } } if (type === 'response.function_call_arguments.delta' || type === 'response.function_call_arguments.done') { - const outputIndex = ( - typeof (payload as any).output_index === 'number' && Number.isFinite((payload as any).output_index) - ? Math.max(0, Math.trunc((payload as any).output_index)) - : 0 - ); + const outputIndex = extractResponsesOutputIndex(payload as Record); + const canonicalIndex = resolveResponsesToolCallIndex(context, { + outputIndex, + itemId: (payload as any).item_id, + callId: (payload as any).call_id, + }); const toolCallId = ( isNonEmptyString((payload as any).call_id) ? (payload as any).call_id : (isNonEmptyString((payload as any).item_id) ? (payload as any).item_id : undefined) @@ -890,7 +1594,7 @@ export function normalizeUpstreamStreamEvent( ); let argumentsDelta = rawArguments; if (type === 'response.function_call_arguments.done' && typeof rawArguments === 'string') { - const existingArguments = context.toolCalls[outputIndex]?.arguments || ''; + const existingArguments = context.toolCalls[canonicalIndex]?.arguments || ''; if (existingArguments && rawArguments.startsWith(existingArguments)) { const missingSuffix = rawArguments.slice(existingArguments.length); argumentsDelta = missingSuffix.length > 0 ? missingSuffix : undefined; @@ -899,7 +1603,7 @@ export function normalizeUpstreamStreamEvent( } } - const knownTool = context.toolCalls[outputIndex] || {}; + const knownTool = context.toolCalls[canonicalIndex] || {}; const shouldBackfillId = !!toolCallId && !knownTool.id; const shouldBackfillName = !!toolName && !knownTool.name; if (argumentsDelta === undefined && !shouldBackfillId && !shouldBackfillName) { @@ -908,7 +1612,7 @@ export function normalizeUpstreamStreamEvent( return { toolCallDeltas: [{ - index: outputIndex, + index: canonicalIndex, id: toolCallId, name: toolName, argumentsDelta, @@ -916,12 +1620,97 @@ export function normalizeUpstreamStreamEvent( }; } + if (type === 'response.custom_tool_call_input.delta' || type === 'response.custom_tool_call_input.done') { + const outputIndex = extractResponsesOutputIndex(payload as Record); + const canonicalIndex = resolveResponsesToolCallIndex(context, { + outputIndex, + itemId: (payload as any).item_id, + callId: (payload as any).call_id, + }); + const toolCallId = ( + isNonEmptyString((payload as any).call_id) ? (payload as any).call_id + : (isNonEmptyString((payload as any).item_id) ? (payload as any).item_id : undefined) + ); + const toolName = isNonEmptyString((payload as any).name) ? (payload as any).name : undefined; + const rawArguments = ( + type === 'response.custom_tool_call_input.done' + ? (typeof (payload as any).input === 'string' ? (payload as any).input : stringifyUnknownValue((payload as any).input)) + : ( + typeof payload.delta === 'string' + ? payload.delta + : stringifyUnknownValue((payload as any).input) + ) + ); + const existingArguments = context.toolCalls[canonicalIndex]?.arguments || ''; + const argumentsDelta = computeNovelResponsesDelta(existingArguments, rawArguments); + const knownTool = context.toolCalls[canonicalIndex] || {}; + const shouldBackfillId = !!toolCallId && !knownTool.id; + const shouldBackfillName = !!toolName && !knownTool.name; + if (!argumentsDelta && !shouldBackfillId && !shouldBackfillName) { + return {}; + } + + return { + toolCallDeltas: [{ + index: canonicalIndex, + id: toolCallId, + name: toolName, + argumentsDelta: argumentsDelta || undefined, + }], + }; + } + if (type === 'response.completed' && isRecord((payload as any).response)) { const responsePayload = (payload as any).response as Record; if (isNonEmptyString(responsePayload.id)) context.id = responsePayload.id; if (isNonEmptyString(responsePayload.model)) context.model = responsePayload.model; + const content = parseResponsesOutputText(responsePayload); + const contentDelta = computeNovelResponsesDelta(joinIndexedResponsesText(context.responsesTextByIndex), content); + if (content) { + context.responsesTextByIndex = { ...context.responsesTextByIndex, [-1]: content } as Record; + } + const responsesReasoning = parseResponsesReasoning(responsePayload); + const reasoningDelta = computeNovelResponsesDelta( + joinIndexedResponsesText(context.responsesReasoningByIndex), + responsesReasoning.reasoningContent, + ); + if (responsesReasoning.reasoningContent) { + context.responsesReasoningByIndex = { + ...context.responsesReasoningByIndex, + [-1]: responsesReasoning.reasoningContent, + } as Record; + } + const toolCalls = collectIndexedToolCallsFromResponsesPayload(responsePayload); + const toolCallDeltas = toolCalls + .map((toolCall) => { + const canonicalIndex = resolveResponsesToolCallIndex(context, { + outputIndex: toolCall.outputIndex, + callId: toolCall.id, + }); + const knownTool = context.toolCalls[canonicalIndex] || {}; + const argumentsDelta = computeNovelResponsesDelta(knownTool.arguments || '', toolCall.arguments); + const shouldBackfillId = !!toolCall.id && !knownTool.id; + const shouldBackfillName = !!toolCall.name && !knownTool.name; + if (!argumentsDelta && !shouldBackfillId && !shouldBackfillName) { + return null; + } + return { + index: canonicalIndex, + id: toolCall.id, + name: toolCall.name, + argumentsDelta: argumentsDelta || undefined, + }; + }) + .filter((item): item is NonNullable => !!item); return { - finishReason: normalizeStopReason(responsePayload.status) || 'stop', + ...(contentDelta || reasoningDelta ? { role: 'assistant' as const } : {}), + ...(contentDelta ? { contentDelta } : {}), + ...(reasoningDelta ? { reasoningDelta } : {}), + ...(responsesReasoning.reasoningSignature ? { reasoningSignature: responsesReasoning.reasoningSignature } : {}), + ...(toolCallDeltas.length > 0 ? { toolCallDeltas } : {}), + finishReason: toolCallDeltas.length > 0 + ? 'tool_calls' + : (normalizeStopReason(responsePayload.status) || 'stop'), done: true, }; } @@ -975,7 +1764,7 @@ export function normalizeUpstreamStreamEvent( }; } - const parsed = extractTextAndReasoning(payload.content_block); + const parsed = extractStreamingTextAndReasoning(payload.content_block, context.thinkTagParser); return { contentDelta: parsed.content || undefined, reasoningDelta: parsed.reasoning || undefined, @@ -985,7 +1774,7 @@ export function normalizeUpstreamStreamEvent( if (type === 'content_block_delta') { const delta = isRecord(payload.delta) ? payload.delta : {}; const deltaType = typeof delta.type === 'string' ? delta.type : ''; - const parsed = extractTextAndReasoning(delta); + const parsed = extractStreamingTextAndReasoning(delta, context.thinkTagParser); if (deltaType === 'input_json_delta') { const index = ( @@ -1029,7 +1818,10 @@ export function normalizeUpstreamStreamEvent( if (Array.isArray(payload.candidates)) { const candidate = payload.candidates[0] || {}; - const parsed = extractTextAndReasoning((candidate as any).content?.parts || (candidate as any).content); + const parsed = extractStreamingTextAndReasoning( + (candidate as any).content?.parts || (candidate as any).content, + context.thinkTagParser, + ); if (isNonEmptyString((payload as any).modelVersion)) { context.model = (payload as any).modelVersion; @@ -1044,7 +1836,7 @@ export function normalizeUpstreamStreamEvent( }; } - const fallback = extractTextAndReasoning(payload); + const fallback = extractStreamingTextAndReasoning(payload, context.thinkTagParser); return { contentDelta: fallback.content || undefined, reasoningDelta: fallback.reasoning || undefined, @@ -1055,15 +1847,17 @@ function buildOpenAiStreamChunk( context: StreamTransformContext, event: NormalizedStreamEvent, ): Record | null { + const normalizedContentDelta = event.contentDelta || ''; + const normalizedReasoningDelta = event.reasoningDelta || ''; const delta: Record = {}; const isInitialAssistantRoleOnlyEvent = ( !context.roleSent && event.role === 'assistant' - && !event.contentDelta - && !event.reasoningDelta + && !normalizedContentDelta + && !normalizedReasoningDelta ); - if (!context.roleSent && (event.role === 'assistant' || event.contentDelta || event.reasoningDelta)) { + if (!context.roleSent && (event.role === 'assistant' || normalizedContentDelta || normalizedReasoningDelta)) { delta.role = 'assistant'; context.roleSent = true; } else if (event.role === 'assistant') { @@ -1071,12 +1865,12 @@ function buildOpenAiStreamChunk( context.roleSent = true; } - if (event.contentDelta) { - delta.content = event.contentDelta; + if (normalizedContentDelta) { + delta.content = normalizedContentDelta; } - if (event.reasoningDelta) { - delta.reasoning_content = event.reasoningDelta; + if (normalizedReasoningDelta) { + delta.reasoning_content = normalizedReasoningDelta; } if (Array.isArray(event.toolCallDeltas) && event.toolCallDeltas.length > 0) { diff --git a/src/server/transformers/shared/endpointCompatibility.ts b/src/server/transformers/shared/endpointCompatibility.ts new file mode 100644 index 00000000..0540cb09 --- /dev/null +++ b/src/server/transformers/shared/endpointCompatibility.ts @@ -0,0 +1,228 @@ +import type { DownstreamFormat } from './normalized.js'; + +export type CompatibilityEndpoint = 'chat' | 'messages' | 'responses'; +export type CompatibilityEndpointPreference = DownstreamFormat | 'responses'; + +type PreferResponsesAfterLegacyChatErrorInput = { + status: number; + upstreamErrorText?: string | null; + downstreamFormat: CompatibilityEndpointPreference; + sitePlatform?: string | null; + modelName?: string | null; + requestedModelHint?: string | null; + currentEndpoint?: CompatibilityEndpoint | null; +}; + +function asTrimmedString(value: unknown): string { + return typeof value === 'string' ? value.trim() : ''; +} + +function normalizePlatformName(platform: unknown): string { + return asTrimmedString(platform).toLowerCase(); +} + +function isClaudeFamilyModel(modelName: string): boolean { + const normalized = asTrimmedString(modelName).toLowerCase(); + if (!normalized) return false; + return normalized === 'claude' || normalized.startsWith('claude-') || normalized.includes('claude'); +} + +function headerValueToString(value: unknown): string | null { + if (typeof value === 'string') { + const trimmed = value.trim(); + return trimmed || null; + } + + if (Array.isArray(value)) { + for (const item of value) { + if (typeof item !== 'string') continue; + const trimmed = item.trim(); + if (trimmed) return trimmed; + } + } + + return null; +} + +function normalizeHeaderMap(headers: Record): Record { + const normalized: Record = {}; + for (const [rawKey, rawValue] of Object.entries(headers)) { + const key = rawKey.trim().toLowerCase(); + if (!key) continue; + const value = headerValueToString(rawValue); + if (!value) continue; + normalized[key] = value; + } + return normalized; +} + +export function buildMinimalJsonHeadersForCompatibility(input: { + headers: Record; + endpoint: CompatibilityEndpoint; + stream: boolean; +}): Record { + const source = normalizeHeaderMap(input.headers); + const minimal: Record = {}; + + if (source.authorization) minimal.authorization = source.authorization; + if (source['x-api-key']) minimal['x-api-key'] = source['x-api-key']; + + if (input.endpoint === 'messages') { + for (const [key, value] of Object.entries(source)) { + if (!key.startsWith('anthropic-')) continue; + minimal[key] = value; + } + if (!minimal['anthropic-version']) { + minimal['anthropic-version'] = '2023-06-01'; + } + } + + minimal['content-type'] = 'application/json'; + minimal.accept = input.stream ? 'text/event-stream' : 'application/json'; + return minimal; +} + +export function isUnsupportedMediaTypeError(status: number, upstreamErrorText?: string | null): boolean { + if (status < 400) return false; + if (status !== 400 && status !== 415) return false; + const text = (upstreamErrorText || '').toLowerCase(); + if (!text) return status === 415; + + return ( + text.includes('unsupported media type') + || text.includes("only 'application/json' is allowed") + || text.includes('only "application/json" is allowed') + || text.includes('application/json') + || text.includes('content-type') + ); +} + +export function isEndpointDispatchDeniedError(status: number, upstreamErrorText?: string | null): boolean { + if (status !== 403) return false; + const text = (upstreamErrorText || '').toLowerCase(); + if (!text) return false; + + return ( + /does\s+not\s+allow\s+\/v1\/[a-z0-9/_:-]+\s+dispatch/i.test(upstreamErrorText || '') + || text.includes('dispatch denied') + ); +} + +export function shouldPreferResponsesAfterLegacyChatError( + input: PreferResponsesAfterLegacyChatErrorInput, +): boolean { + if (input.status < 400) return false; + if (input.downstreamFormat !== 'openai') return false; + if (input.currentEndpoint !== 'chat') return false; + + const sitePlatform = normalizePlatformName(input.sitePlatform); + if (sitePlatform === 'openai' || sitePlatform === 'claude' || sitePlatform === 'gemini' || sitePlatform === 'anyrouter') { + return false; + } + + const modelName = asTrimmedString(input.modelName); + const requestedModelHint = asTrimmedString(input.requestedModelHint); + if (isClaudeFamilyModel(modelName) || isClaudeFamilyModel(requestedModelHint)) { + return false; + } + + const text = (input.upstreamErrorText || '').toLowerCase(); + return ( + text.includes('unsupported legacy protocol') + && text.includes('/v1/chat/completions') + && text.includes('/v1/responses') + ); +} + +export function promoteResponsesCandidateAfterLegacyChatError( + endpointCandidates: CompatibilityEndpoint[], + input: PreferResponsesAfterLegacyChatErrorInput, +): void { + if (!shouldPreferResponsesAfterLegacyChatError(input)) return; + + const currentIndex = endpointCandidates.findIndex((endpoint) => endpoint === input.currentEndpoint); + const responsesIndex = endpointCandidates.indexOf('responses'); + if (currentIndex < 0 || responsesIndex < 0 || responsesIndex <= currentIndex + 1) return; + + endpointCandidates.splice(responsesIndex, 1); + endpointCandidates.splice(currentIndex + 1, 0, 'responses'); +} + +export function isEndpointDowngradeError(status: number, upstreamErrorText?: string | null): boolean { + if (status < 400) return false; + const text = (upstreamErrorText || '').toLowerCase(); + if (status === 404 || status === 405 || status === 415 || status === 501) return true; + if (!text) return false; + + let parsedCode = ''; + let parsedType = ''; + let parsedMessage = ''; + try { + const parsed = JSON.parse(upstreamErrorText || '{}') as Record; + const error = (parsed.error && typeof parsed.error === 'object') + ? parsed.error as Record + : parsed; + parsedCode = asTrimmedString(error.code).toLowerCase(); + parsedType = asTrimmedString(error.type).toLowerCase(); + parsedMessage = asTrimmedString(error.message).toLowerCase(); + } catch { + parsedCode = ''; + parsedType = ''; + parsedMessage = ''; + } + + return ( + isEndpointDispatchDeniedError(status, upstreamErrorText) + || text.includes('convert_request_failed') + || text.includes('not found') + || text.includes('unknown endpoint') + || text.includes('unsupported endpoint') + || text.includes('unsupported path') + || text.includes('unrecognized request url') + || text.includes('no route matched') + || text.includes('does not exist') + || text.includes('openai_error') + || text.includes('upstream_error') + || text.includes('bad_response_status_code') + || text.includes('unsupported media type') + || text.includes("only 'application/json' is allowed") + || text.includes('only "application/json" is allowed') + || (status === 400 && text.includes('unsupported')) + || text.includes('not implemented') + || text.includes('api not implemented') + || text.includes('unsupported legacy protocol') + || parsedCode === 'convert_request_failed' + || parsedCode === 'not_found' + || parsedCode === 'endpoint_not_found' + || parsedCode === 'unknown_endpoint' + || parsedCode === 'unsupported_endpoint' + || parsedCode === 'bad_response_status_code' + || parsedCode === 'openai_error' + || parsedCode === 'upstream_error' + || parsedType === 'not_found_error' + || parsedType === 'invalid_request_error' + || parsedType === 'unsupported_endpoint' + || parsedType === 'unsupported_path' + || parsedType === 'bad_response_status_code' + || parsedType === 'openai_error' + || parsedType === 'upstream_error' + || parsedMessage.includes('unknown endpoint') + || parsedMessage.includes('unsupported endpoint') + || parsedMessage.includes('unsupported path') + || parsedMessage.includes('unrecognized request url') + || parsedMessage.includes('no route matched') + || parsedMessage.includes('does not exist') + || parsedMessage.includes('bad_response_status_code') + || parsedMessage === 'openai_error' + || parsedMessage === 'upstream_error' + || parsedMessage.includes('unsupported media type') + || parsedMessage.includes("only 'application/json' is allowed") + || parsedMessage.includes('only "application/json" is allowed') + || ( + status === 400 + && parsedCode === 'invalid_request' + && parsedType === 'new_api_error' + && (parsedMessage.includes('claude code cli') || text.includes('claude code cli')) + ) + ); +} diff --git a/src/server/transformers/shared/inputFile.test.ts b/src/server/transformers/shared/inputFile.test.ts new file mode 100644 index 00000000..f1e4d982 --- /dev/null +++ b/src/server/transformers/shared/inputFile.test.ts @@ -0,0 +1,137 @@ +import { describe, expect, it } from 'vitest'; + +import { + ensureBase64DataUrl, + inferInputFileMimeType, + normalizeInputFileBlock, + toAnthropicDocumentBlock, + toOpenAiChatFileBlock, + toResponsesInputFileBlock, +} from './inputFile.js'; + +describe('shared input file helpers', () => { + it('preserves existing data URLs when building Responses input_file blocks', () => { + expect(toResponsesInputFileBlock({ + fileData: 'data:application/pdf;base64,JVBERi0x', + filename: 'brief.pdf', + })).toEqual({ + type: 'input_file', + file_data: 'data:application/pdf;base64,JVBERi0x', + filename: 'brief.pdf', + }); + }); + + it('infers mime type from filename when wrapping raw base64 for Responses blocks', () => { + expect(toResponsesInputFileBlock({ + fileData: 'JVBERi0x', + filename: 'brief.pdf', + })).toEqual({ + type: 'input_file', + file_data: 'data:application/pdf;base64,JVBERi0x', + filename: 'brief.pdf', + }); + }); + + it('prefers file_data over file_url and file_id when serializing Responses blocks', () => { + expect(toResponsesInputFileBlock({ + fileData: 'JVBERi0x', + fileUrl: 'https://example.com/brief.pdf', + fileId: 'file_123', + filename: 'brief.pdf', + })).toEqual({ + type: 'input_file', + file_data: 'data:application/pdf;base64,JVBERi0x', + filename: 'brief.pdf', + }); + }); + + it('strips data URL wrappers for OpenAI chat file blocks while preserving mime type', () => { + expect(toOpenAiChatFileBlock({ + fileData: 'data:application/pdf;base64,JVBERi0x', + filename: 'brief.pdf', + })).toEqual({ + type: 'file', + file: { + file_data: 'JVBERi0x', + filename: 'brief.pdf', + mime_type: 'application/pdf', + }, + }); + }); + + it('creates anthropic document blocks from inline file data', () => { + expect(toAnthropicDocumentBlock({ + fileData: 'data:text/plain;base64,SGVsbG8=', + filename: 'notes.txt', + hadDataUrl: true, + })).toEqual({ + type: 'document', + cache_control: { type: 'ephemeral' }, + source: { + type: 'base64', + media_type: 'text/plain', + data: 'SGVsbG8=', + }, + title: 'notes.txt', + }); + }); + + it('creates anthropic document blocks from remote file urls', () => { + expect(toAnthropicDocumentBlock({ + fileUrl: 'https://example.com/remote.pdf', + filename: 'remote.pdf', + mimeType: 'application/pdf', + })).toEqual({ + type: 'document', + source: { + type: 'url', + url: 'https://example.com/remote.pdf', + }, + title: 'remote.pdf', + }); + }); + + it('infers common mime types from filenames', () => { + expect(inferInputFileMimeType({ filename: 'notes.md', mimeType: null })).toBe('text/markdown'); + expect(inferInputFileMimeType({ filename: 'photo.jpeg', mimeType: null })).toBe('image/jpeg'); + expect(inferInputFileMimeType({ filename: 'voice.mp3', mimeType: null })).toBe('audio/mpeg'); + }); + + it('leaves raw base64 unchanged when mime type is unknown', () => { + expect(ensureBase64DataUrl('YWJj', null)).toBe('YWJj'); + }); + + it('prefers inline data when input_file blocks also carry file_url or file_id', () => { + expect(normalizeInputFileBlock({ + type: 'input_file', + file_data: 'data:application/pdf;base64,JVBERi0x', + file_url: 'https://example.com/brief.pdf', + file_id: 'file_123', + filename: 'brief.pdf', + })).toEqual({ + sourceType: 'input_file', + fileData: 'data:application/pdf;base64,JVBERi0x', + filename: 'brief.pdf', + mimeType: 'application/pdf', + hadDataUrl: true, + }); + }); + + it('prefers inline data when file wrapper blocks also carry file_url or file_id', () => { + expect(normalizeInputFileBlock({ + type: 'file', + file: { + file_data: 'JVBERi0x', + file_url: 'https://example.com/brief.pdf', + file_id: 'file_123', + filename: 'brief.pdf', + }, + })).toEqual({ + sourceType: 'file', + fileData: 'JVBERi0x', + filename: 'brief.pdf', + mimeType: null, + hadDataUrl: false, + }); + }); +}); diff --git a/src/server/transformers/shared/inputFile.ts b/src/server/transformers/shared/inputFile.ts index 9d3807e7..4fa22605 100644 --- a/src/server/transformers/shared/inputFile.ts +++ b/src/server/transformers/shared/inputFile.ts @@ -15,6 +15,20 @@ function splitBase64DataUrl(value: string): { mimeType: string; data: string } | }; } +function normalizeFileSourceSelection( + fileData: string, + fileUrl: string, + fileId: string, +): { fileData: string; fileUrl: string; fileId: string } { + if (fileData) { + return { fileData, fileUrl: '', fileId: '' }; + } + if (fileUrl) { + return { fileData: '', fileUrl, fileId: '' }; + } + return { fileData: '', fileUrl: '', fileId }; +} + export function ensureBase64DataUrl(fileData: string, mimeType?: string | null): string { const trimmedData = asTrimmedString(fileData); if (!trimmedData) return trimmedData; @@ -29,6 +43,7 @@ export type NormalizedInputFile = { sourceType?: 'file' | 'input_file'; fileId?: string; fileData?: string; + fileUrl?: string; filename?: string; mimeType?: string | null; hadDataUrl?: boolean; @@ -57,11 +72,17 @@ export function normalizeInputFileBlock(item: Record): Normaliz const type = asTrimmedString(item.type).toLowerCase(); if (type === 'input_file') { - const fileId = asTrimmedString(item.file_id); - const fileData = asTrimmedString(item.file_data); + const selectedSource = normalizeFileSourceSelection( + asTrimmedString(item.file_data), + asTrimmedString(item.file_url), + asTrimmedString(item.file_id), + ); + const fileId = selectedSource.fileId; + const fileData = selectedSource.fileData; + const fileUrl = selectedSource.fileUrl; const filename = asTrimmedString(item.filename); let mimeType = asTrimmedString(item.mime_type ?? item.mimeType) || null; - if (!fileId && !fileData) return null; + if (!fileId && !fileData && !fileUrl) return null; const parsedDataUrl = fileData ? splitBase64DataUrl(fileData) : null; if (parsedDataUrl) { mimeType = mimeType || parsedDataUrl.mimeType; @@ -70,6 +91,7 @@ export function normalizeInputFileBlock(item: Record): Normaliz sourceType: 'input_file', fileId: fileId || undefined, fileData: fileData || undefined, + fileUrl: fileUrl || undefined, filename: filename || undefined, mimeType, hadDataUrl: /^data:[^;,]+;base64,/i.test(fileData), @@ -78,11 +100,17 @@ export function normalizeInputFileBlock(item: Record): Normaliz if (type === 'file') { const file = isRecord(item.file) ? item.file : item; - const fileId = asTrimmedString(file.file_id ?? item.file_id); - const fileData = asTrimmedString(file.file_data ?? item.file_data); + const selectedSource = normalizeFileSourceSelection( + asTrimmedString(file.file_data ?? item.file_data), + asTrimmedString(file.file_url ?? item.file_url), + asTrimmedString(file.file_id ?? item.file_id), + ); + const fileId = selectedSource.fileId; + const fileData = selectedSource.fileData; + const fileUrl = selectedSource.fileUrl; const filename = asTrimmedString(file.filename ?? item.filename); let mimeType = asTrimmedString(file.mime_type ?? file.mimeType ?? item.mime_type ?? item.mimeType) || null; - if (!fileId && !fileData) return null; + if (!fileId && !fileData && !fileUrl) return null; const parsedDataUrl = fileData ? splitBase64DataUrl(fileData) : null; if (parsedDataUrl) { mimeType = mimeType || parsedDataUrl.mimeType; @@ -91,6 +119,7 @@ export function normalizeInputFileBlock(item: Record): Normaliz sourceType: 'file', fileId: fileId || undefined, fileData: fileData || undefined, + fileUrl: fileUrl || undefined, filename: filename || undefined, mimeType, hadDataUrl: /^data:[^;,]+;base64,/i.test(fileData), @@ -103,13 +132,14 @@ export function normalizeInputFileBlock(item: Record): Normaliz export function toResponsesInputFileBlock(file: NormalizedInputFile): Record { const parsedDataUrl = file.fileData ? splitBase64DataUrl(file.fileData) : null; const block: Record = { type: 'input_file' }; - if (file.fileId) block.file_id = file.fileId; if (file.fileData) { block.file_data = ensureBase64DataUrl( file.fileData, parsedDataUrl?.mimeType || inferInputFileMimeType(file), ); } + if (file.fileUrl && !block.file_data) block.file_url = file.fileUrl; + if (file.fileId && !block.file_data && !block.file_url) block.file_id = file.fileId; if (file.filename) block.filename = file.filename; return block; } @@ -117,8 +147,9 @@ export function toResponsesInputFileBlock(file: NormalizedInputFile): Record { const parsedDataUrl = file.fileData ? splitBase64DataUrl(file.fileData) : null; const payload: Record = {}; - if (file.fileId) payload.file_id = file.fileId; if (file.fileData) payload.file_data = parsedDataUrl?.data || file.fileData; + else if (file.fileUrl) payload.file_url = file.fileUrl; + else if (file.fileId) payload.file_id = file.fileId; if (file.filename) payload.filename = file.filename; if (file.mimeType) payload.mime_type = file.mimeType; else if (parsedDataUrl?.mimeType) payload.mime_type = parsedDataUrl.mimeType; @@ -128,6 +159,16 @@ export function toOpenAiChatFileBlock(file: NormalizedInputFile): Record | null { + if (file.fileUrl) { + return { + type: 'document', + source: { + type: 'url', + url: file.fileUrl, + }, + ...(file.filename ? { title: file.filename } : {}), + }; + } if (!file.fileData) return null; const parsedDataUrl = splitBase64DataUrl(file.fileData); const mimeType = parsedDataUrl?.mimeType || inferInputFileMimeType(file); diff --git a/src/server/transformers/shared/normalized.test.ts b/src/server/transformers/shared/normalized.test.ts index 74069c29..3d3d7df1 100644 --- a/src/server/transformers/shared/normalized.test.ts +++ b/src/server/transformers/shared/normalized.test.ts @@ -3,8 +3,11 @@ import { describe, expect, it } from 'vitest'; import { fromTransformerMetadataRecord, + createStreamTransformContext, normalizeStopReason, normalizeUpstreamFinalResponse, + normalizeUpstreamStreamEvent, + parseDownstreamChatRequest, pullSseEventsWithDone, serializeFinalResponse, toTransformerMetadataRecord, @@ -78,6 +81,412 @@ describe('shared normalized helpers', () => { }); }); + it('normalizes custom tool calls from responses payloads through the existing tool-call shape', () => { + expect(normalizeUpstreamFinalResponse({ + object: 'response', + id: 'resp_custom_tool_1', + model: 'gpt-test', + created: 123, + output: [ + { + type: 'custom_tool_call', + call_id: 'call_custom', + name: 'MyTool', + input: '{"path":"README.md"}', + }, + ], + status: 'completed', + }, 'fallback-model')).toEqual({ + id: 'resp_custom_tool_1', + model: 'gpt-test', + created: 123, + content: '', + reasoningContent: '', + finishReason: 'tool_calls', + toolCalls: [{ + id: 'call_custom', + name: 'MyTool', + arguments: '{"path":"README.md"}', + }], + }); + }); + + it('unwraps terminal response.completed envelopes when normalizing final responses', () => { + expect(normalizeUpstreamFinalResponse({ + type: 'response.completed', + response: { + id: 'resp_terminal_1', + model: 'gpt-test', + created_at: 123, + status: 'completed', + output: [ + { + type: 'message', + content: [{ type: 'output_text', text: 'hello' }], + }, + { + type: 'custom_tool_call', + call_id: 'call_custom_1', + name: 'Shell', + input: '{"command":"pwd"}', + }, + ], + }, + }, 'fallback-model')).toEqual({ + id: 'resp_terminal_1', + model: 'gpt-test', + created: 123, + content: 'hello', + reasoningContent: '', + finishReason: 'tool_calls', + toolCalls: [{ + id: 'call_custom_1', + name: 'Shell', + arguments: '{"command":"pwd"}', + }], + }); + }); + + it('unwraps terminal response.incomplete envelopes when normalizing final responses', () => { + expect(normalizeUpstreamFinalResponse({ + type: 'response.incomplete', + response: { + id: 'resp_terminal_2', + model: 'gpt-test', + created: 456, + output: [ + { + type: 'message', + content: [{ type: 'output_text', text: 'partial answer' }], + }, + ], + }, + }, 'fallback-model')).toEqual({ + id: 'resp_terminal_2', + model: 'gpt-test', + created: 456, + content: 'partial answer', + reasoningContent: '', + finishReason: 'length', + toolCalls: [], + }); + }); + + it('preserves responses reasoning summaries and encrypted reasoning signatures in final normalization', () => { + expect(normalizeUpstreamFinalResponse({ + object: 'response', + id: 'resp_reasoning_1', + model: 'gpt-test', + created_at: 456, + output: [ + { + type: 'reasoning', + summary: [{ type: 'summary_text', text: 'plan quietly' }], + encrypted_content: 'enc-1', + }, + { + type: 'message', + content: [{ type: 'output_text', text: 'hello' }], + }, + ], + status: 'completed', + }, 'fallback-model')).toEqual({ + id: 'resp_reasoning_1', + model: 'gpt-test', + created: 456, + content: 'hello', + reasoningContent: 'plan quietly', + reasoningSignature: 'enc-1', + finishReason: 'stop', + toolCalls: [], + }); + }); + + it('normalizes responses payloads with reasoning summaries and encrypted signatures', () => { + expect(normalizeUpstreamFinalResponse({ + object: 'response', + id: 'resp_reasoning_1', + model: 'gpt-test', + created: 123, + output: [ + { + type: 'reasoning', + encrypted_content: 'enc_1', + summary: [ + { type: 'summary_text', text: 'plan quietly' }, + ], + }, + ], + status: 'completed', + }, 'fallback-model')).toEqual({ + id: 'resp_reasoning_1', + model: 'gpt-test', + created: 123, + content: '', + reasoningContent: 'plan quietly', + reasoningSignature: 'enc_1', + finishReason: 'stop', + toolCalls: [], + }); + }); + + it('treats response.reasoning_summary_text.done as reasoning-only stream output', () => { + const context = createStreamTransformContext('gpt-test'); + + expect(normalizeUpstreamStreamEvent({ + type: 'response.reasoning_summary_text.done', + item_id: 'rs_1', + output_index: 0, + summary_index: 0, + text: 'plan first', + }, context, 'fallback-model')).toEqual({ + reasoningDelta: 'plan first', + }); + }); + + it('normalizes terminal-only responses output_item.done message content into visible stream content', () => { + const context = createStreamTransformContext('gpt-test'); + + expect(normalizeUpstreamStreamEvent({ + type: 'response.output_item.done', + output_index: 0, + item: { + id: 'msg_1', + type: 'message', + role: 'assistant', + status: 'completed', + content: [{ type: 'output_text', text: 'hello' }], + }, + }, context, 'fallback-model')).toEqual({ + role: 'assistant', + contentDelta: 'hello', + }); + }); + + it('normalizes terminal-only responses output_item.done tool metadata into tool deltas', () => { + const context = createStreamTransformContext('gpt-test'); + + expect(normalizeUpstreamStreamEvent({ + type: 'response.output_item.done', + output_index: 0, + item: { + id: 'fc_1', + type: 'function_call', + call_id: 'call_1', + name: 'Glob', + arguments: '{"pattern":"README*"}', + status: 'completed', + }, + }, context, 'fallback-model')).toEqual({ + toolCallDeltas: [{ + index: 0, + id: 'call_1', + name: 'Glob', + argumentsDelta: '{"pattern":"README*"}', + }], + }); + }); + + it('normalizes terminal-only custom tool responses into tool deltas and final tool calls', () => { + const context = createStreamTransformContext('gpt-test'); + + expect(normalizeUpstreamStreamEvent({ + type: 'response.output_item.done', + output_index: 0, + item: { + id: 'ct_1', + type: 'custom_tool_call', + call_id: 'call_custom_1', + name: 'MyTool', + input: '{"foo":"bar"}', + status: 'completed', + }, + }, context, 'fallback-model')).toEqual({ + toolCallDeltas: [{ + index: 0, + id: 'call_custom_1', + name: 'MyTool', + argumentsDelta: '{"foo":"bar"}', + }], + }); + + expect(normalizeUpstreamFinalResponse({ + object: 'response', + id: 'resp_custom_tool_1', + model: 'gpt-test', + created: 321, + output: [ + { + id: 'ct_1', + type: 'custom_tool_call', + call_id: 'call_custom_1', + name: 'MyTool', + input: '{"foo":"bar"}', + }, + ], + status: 'completed', + }, 'fallback-model')).toEqual({ + id: 'resp_custom_tool_1', + model: 'gpt-test', + created: 321, + content: '', + reasoningContent: '', + finishReason: 'tool_calls', + toolCalls: [{ + id: 'call_custom_1', + name: 'MyTool', + arguments: '{"foo":"bar"}', + }], + }); + }); + + it('normalizes terminal-only responses output payloads carried on response.completed', () => { + const context = createStreamTransformContext('gpt-test'); + + expect(normalizeUpstreamStreamEvent({ + type: 'response.completed', + response: { + id: 'resp_done_only', + model: 'gpt-test', + status: 'completed', + output: [ + { + id: 'msg_1', + type: 'message', + role: 'assistant', + status: 'completed', + content: [{ type: 'output_text', text: 'hello' }], + }, + ], + }, + }, context, 'fallback-model')).toMatchObject({ + contentDelta: 'hello', + finishReason: 'stop', + done: true, + }); + }); + + it('maps claude tools, tool choice, metadata, and reasoning when parsing downstream requests', () => { + const result = parseDownstreamChatRequest({ + model: 'gpt-5', + stream: true, + metadata: { user_id: 'user-1' }, + thinking: { + type: 'enabled', + budget_tokens: 2048, + }, + output_config: { + effort: 'high', + }, + tools: [{ + name: 'Glob', + description: 'Search files', + input_schema: { + type: 'object', + properties: { + pattern: { type: 'string' }, + }, + required: ['pattern'], + }, + }], + tool_choice: { + type: 'tool', + name: 'Glob', + }, + messages: [{ + role: 'user', + content: 'hello', + }], + }, 'claude'); + + expect(result.error).toBeUndefined(); + expect(result.value?.upstreamBody).toMatchObject({ + model: 'gpt-5', + stream: true, + metadata: { user_id: 'user-1' }, + reasoning_effort: 'high', + reasoning_budget: 2048, + tools: [{ + type: 'function', + function: { + name: 'Glob', + description: 'Search files', + parameters: { + type: 'object', + properties: { + pattern: { type: 'string' }, + }, + required: ['pattern'], + }, + }, + }], + tool_choice: { + type: 'function', + function: { + name: 'Glob', + }, + }, + }); + }); + + it('keeps claude thinking in reasoning_content and emits tool results before follow-up user text', () => { + const result = parseDownstreamChatRequest({ + model: 'gpt-5', + max_tokens: 256, + messages: [ + { + role: 'assistant', + content: [ + { type: 'thinking', thinking: 'plan quietly' }, + { + type: 'tool_use', + id: 'toolu_abc', + name: 'Glob', + input: { pattern: 'README*' }, + }, + ], + }, + { + role: 'user', + content: [ + { + type: 'tool_result', + tool_use_id: 'toolu_abc', + content: [{ type: 'text', text: '{"matches":1}' }], + }, + { type: 'text', text: 'continue' }, + ], + }, + ], + }, 'claude'); + + expect(result.error).toBeUndefined(); + expect(result.value?.upstreamBody.messages).toEqual([ + { + role: 'assistant', + content: '', + reasoning_content: 'plan quietly', + tool_calls: [{ + id: 'toolu_abc', + type: 'function', + function: { + name: 'Glob', + arguments: '{"pattern":"README*"}', + }, + }], + }, + { + role: 'tool', + tool_call_id: 'toolu_abc', + content: '{"matches":1}', + }, + { + role: 'user', + content: 'continue', + }, + ]); + }); + it('serializes normalized final responses for claude', () => { const normalized = { id: 'chatcmpl-1', diff --git a/src/server/transformers/shared/normalized.ts b/src/server/transformers/shared/normalized.ts index c40caa79..ab8cf036 100644 --- a/src/server/transformers/shared/normalized.ts +++ b/src/server/transformers/shared/normalized.ts @@ -88,7 +88,7 @@ export type TransformerMetadata = { }; export type NormalizedRequest = { - protocol: import('./chatFormatsCore.js').DownstreamFormat | 'responses' | 'gemini'; + protocol: import('./chatFormatsCore.js').DownstreamFormat | 'responses' | 'gemini' | 'gemini-cli'; model: string; stream: boolean; rawBody: unknown; @@ -98,7 +98,7 @@ export type NormalizedRequest = { }; export type NormalizedResponseEnvelope = { - protocol: import('./chatFormatsCore.js').DownstreamFormat | 'responses' | 'gemini'; + protocol: import('./chatFormatsCore.js').DownstreamFormat | 'responses' | 'gemini' | 'gemini-cli'; model: string; final: import('./chatFormatsCore.js').NormalizedFinalResponse; usage?: unknown; @@ -107,7 +107,7 @@ export type NormalizedResponseEnvelope = { }; export type NormalizedStreamEnvelope = { - protocol: import('./chatFormatsCore.js').DownstreamFormat | 'responses' | 'gemini'; + protocol: import('./chatFormatsCore.js').DownstreamFormat | 'responses' | 'gemini' | 'gemini-cli'; model: string; event: import('./chatFormatsCore.js').NormalizedStreamEvent; metadata?: TransformerMetadata; diff --git a/src/server/transformers/shared/protocolLifecycle.ts b/src/server/transformers/shared/protocolLifecycle.ts new file mode 100644 index 00000000..0297d83f --- /dev/null +++ b/src/server/transformers/shared/protocolLifecycle.ts @@ -0,0 +1,88 @@ +type PulledEventBatch = { + events: TEvent[]; + rest: string; +}; + +type ProxyStreamReader = { + read(): Promise<{ done: boolean; value?: Uint8Array }>; + cancel(reason?: unknown): Promise; + releaseLock(): void; +}; + +type ProxyStreamLifecycleInput = { + reader: ProxyStreamReader | null | undefined; + response: { end(): void }; + pullEvents(buffer: string): PulledEventBatch; + handleEvent(event: TEvent): Promise | boolean | void; + onEof?: () => Promise | void; +}; + +export function createProxyStreamLifecycle(input: ProxyStreamLifecycleInput) { + const flushBuffer = async (buffer: string): Promise<{ rest: string; stop: boolean }> => { + const pulled = input.pullEvents(buffer); + for (const event of pulled.events) { + if (await input.handleEvent(event)) { + return { + rest: pulled.rest, + stop: true, + }; + } + } + + return { + rest: pulled.rest, + stop: false, + }; + }; + + return { + async run(): Promise { + const reader = input.reader; + if (!reader) { + try { + await input.onEof?.(); + } finally { + input.response.end(); + } + return; + } + + const decoder = new TextDecoder(); + let sseBuffer = ''; + let shouldStop = false; + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + if (!value) continue; + + sseBuffer += decoder.decode(value, { stream: true }); + const flushed = await flushBuffer(sseBuffer); + sseBuffer = flushed.rest; + if (!flushed.stop) continue; + + shouldStop = true; + await reader.cancel().catch(() => {}); + break; + } + + if (!shouldStop) { + sseBuffer += decoder.decode(); + if (sseBuffer.trim().length > 0) { + const flushed = await flushBuffer(`${sseBuffer}\n\n`); + sseBuffer = flushed.rest; + shouldStop = flushed.stop; + } + } + + if (!shouldStop) { + await input.onEof?.(); + } + } finally { + reader.releaseLock(); + input.response.end(); + } + }, + }; +} diff --git a/src/server/transformers/shared/protocolModel.test.ts b/src/server/transformers/shared/protocolModel.test.ts new file mode 100644 index 00000000..b8626b13 --- /dev/null +++ b/src/server/transformers/shared/protocolModel.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from 'vitest'; + +import { + createProtocolRequestEnvelope, + createProtocolResponseEnvelope, + createProtocolStreamEnvelope, +} from './protocolModel.js'; + +describe('protocolModel', () => { + it('creates protocol request envelopes with shared top-level fields', () => { + const envelope = createProtocolRequestEnvelope({ + protocol: 'openai/chat', + model: 'gpt-5', + stream: true, + rawBody: { model: 'gpt-5', stream: true }, + parsed: { requestedModel: 'gpt-5', isStream: true }, + metadata: { serviceTier: 'priority' }, + }); + + expect(envelope).toEqual({ + protocol: 'openai/chat', + model: 'gpt-5', + stream: true, + rawBody: { model: 'gpt-5', stream: true }, + parsed: { requestedModel: 'gpt-5', isStream: true }, + metadata: { serviceTier: 'priority' }, + }); + }); + + it('creates protocol response and stream envelopes without losing metadata', () => { + const response = createProtocolResponseEnvelope({ + protocol: 'openai/responses', + model: 'gpt-5', + final: { + id: 'resp_1', + model: 'gpt-5', + created: 123, + content: 'done', + reasoningContent: 'think', + finishReason: 'stop', + toolCalls: [], + }, + metadata: { citations: ['https://example.com'] }, + }); + const stream = createProtocolStreamEnvelope({ + protocol: 'openai/responses', + model: 'gpt-5', + event: { + contentDelta: 'done', + finishReason: 'stop', + }, + metadata: { citations: ['https://example.com'] }, + }); + + expect(response.metadata).toEqual({ citations: ['https://example.com'] }); + expect(stream.metadata).toEqual({ citations: ['https://example.com'] }); + }); +}); diff --git a/src/server/transformers/shared/protocolModel.ts b/src/server/transformers/shared/protocolModel.ts new file mode 100644 index 00000000..e80b148a --- /dev/null +++ b/src/server/transformers/shared/protocolModel.ts @@ -0,0 +1,71 @@ +import type { + NormalizedFinalResponse, + NormalizedStreamEvent, +} from './normalized.js'; + +export type TransformerProtocol = + | 'openai/chat' + | 'anthropic/messages' + | 'openai/responses' + | 'gemini/generate-content' + | 'gemini-cli/generate-content'; + +export type ProtocolRequestEnvelope< + TProtocol extends TransformerProtocol = TransformerProtocol, + TParsed = unknown, + TMetadata = unknown, +> = { + protocol: TProtocol; + model: string; + stream: boolean; + rawBody: unknown; + parsed: TParsed; + metadata?: TMetadata; +}; + +export type ProtocolResponseEnvelope< + TProtocol extends TransformerProtocol = TransformerProtocol, + TFinal extends NormalizedFinalResponse = NormalizedFinalResponse, + TMetadata = unknown, +> = { + protocol: TProtocol; + model: string; + final: TFinal; + usage?: unknown; + metadata?: TMetadata; +}; + +export type ProtocolStreamEnvelope< + TProtocol extends TransformerProtocol = TransformerProtocol, + TEvent extends NormalizedStreamEvent = NormalizedStreamEvent, + TMetadata = unknown, +> = { + protocol: TProtocol; + model: string; + event: TEvent; + metadata?: TMetadata; +}; + +export function createProtocolRequestEnvelope< + TProtocol extends TransformerProtocol, + TParsed, + TMetadata = unknown, +>(envelope: ProtocolRequestEnvelope): ProtocolRequestEnvelope { + return envelope; +} + +export function createProtocolResponseEnvelope< + TProtocol extends TransformerProtocol, + TFinal extends NormalizedFinalResponse, + TMetadata = unknown, +>(envelope: ProtocolResponseEnvelope): ProtocolResponseEnvelope { + return envelope; +} + +export function createProtocolStreamEnvelope< + TProtocol extends TransformerProtocol, + TEvent extends NormalizedStreamEvent, + TMetadata = unknown, +>(envelope: ProtocolStreamEnvelope): ProtocolStreamEnvelope { + return envelope; +} diff --git a/src/server/transformers/shared/thinkTagParser.ts b/src/server/transformers/shared/thinkTagParser.ts new file mode 100644 index 00000000..c2cc5ce8 --- /dev/null +++ b/src/server/transformers/shared/thinkTagParser.ts @@ -0,0 +1,107 @@ +export type ThinkTagParserState = { + mode: 'content' | 'reasoning'; + pending: string; +}; + +const OPEN_TAG = ''; +const CLOSE_TAG = ''; + +function trailingPrefixLength(value: string, prefix: string): number { + const maxLength = Math.min(value.length, prefix.length - 1); + for (let length = maxLength; length > 0; length -= 1) { + if (prefix.startsWith(value.slice(-length))) { + return length; + } + } + return 0; +} + +function consumeChunkAgainstTag( + source: string, + tag: string, + target: 'content' | 'reasoning', +): { emitted: string; rest: string; matched: boolean } { + const sourceLower = source.toLowerCase(); + const tagIndex = sourceLower.indexOf(tag); + if (tagIndex >= 0) { + return { + emitted: source.slice(0, tagIndex), + rest: source.slice(tagIndex + tag.length), + matched: true, + }; + } + + const pendingLength = trailingPrefixLength(sourceLower, tag); + return { + emitted: source.slice(0, source.length - pendingLength), + rest: source.slice(source.length - pendingLength), + matched: false, + }; +} + +export function createThinkTagParserState(): ThinkTagParserState { + return { + mode: 'content', + pending: '', + }; +} + +export function consumeThinkTaggedText( + state: ThinkTagParserState, + chunk: string, +): { content: string; reasoning: string } { + if (!chunk) { + return { content: '', reasoning: '' }; + } + + let content = ''; + let reasoning = ''; + let rest = `${state.pending}${chunk}`; + state.pending = ''; + + while (rest.length > 0) { + const currentTag = state.mode === 'content' ? OPEN_TAG : CLOSE_TAG; + const consumed = consumeChunkAgainstTag(rest, currentTag, state.mode); + if (state.mode === 'content') { + content += consumed.emitted; + } else { + reasoning += consumed.emitted; + } + + if (!consumed.matched) { + state.pending = consumed.rest; + break; + } + + rest = consumed.rest; + state.mode = state.mode === 'content' ? 'reasoning' : 'content'; + } + + return { content, reasoning }; +} + +export function flushThinkTaggedText(state: ThinkTagParserState): { content: string; reasoning: string } { + if (!state.pending) { + return { content: '', reasoning: '' }; + } + + const remainder = state.pending; + state.pending = ''; + return state.mode === 'content' + ? { content: remainder, reasoning: '' } + : { content: '', reasoning: remainder }; +} + +export function extractInlineThinkTags(text: string): { content: string; reasoning: string } { + if (!text) { + return { content: '', reasoning: '' }; + } + + const state = createThinkTagParserState(); + const consumed = consumeThinkTaggedText(state, text); + const flushed = flushThinkTaggedText(state); + return { + content: `${consumed.content}${flushed.content}`, + reasoning: `${consumed.reasoning}${flushed.reasoning}`, + }; +} diff --git a/src/server/transformers/shared/toolNameShortener.ts b/src/server/transformers/shared/toolNameShortener.ts new file mode 100644 index 00000000..4d116666 --- /dev/null +++ b/src/server/transformers/shared/toolNameShortener.ts @@ -0,0 +1,50 @@ +const TOOL_NAME_LIMIT = 64; +const MCP_PREFIX = 'mcp__'; + +function asTrimmedString(value: unknown): string { + return typeof value === 'string' ? value.trim() : ''; +} + +export function shortenToolNameIfNeeded(name: string): string { + const trimmed = asTrimmedString(name); + if (trimmed.length <= TOOL_NAME_LIMIT) return trimmed; + if (trimmed.startsWith(MCP_PREFIX)) { + const lastSeparator = trimmed.lastIndexOf('__'); + if (lastSeparator > 0) { + const candidate = `${MCP_PREFIX}${trimmed.slice(lastSeparator + 2)}`; + return candidate.length > TOOL_NAME_LIMIT ? candidate.slice(0, TOOL_NAME_LIMIT) : candidate; + } + } + return trimmed.slice(0, TOOL_NAME_LIMIT); +} + +export function buildShortToolNameMap(names: string[]): Record { + const uniqueNames = Array.from(new Set( + names + .map((name) => asTrimmedString(name)) + .filter((name) => name.length > 0), + )); + const used = new Set(); + const mapping: Record = {}; + + for (const name of uniqueNames) { + const base = shortenToolNameIfNeeded(name); + let candidate = base; + let suffixIndex = 1; + while (used.has(candidate)) { + const suffix = `_${suffixIndex}`; + const allowedLength = Math.max(0, TOOL_NAME_LIMIT - suffix.length); + candidate = `${base.slice(0, allowedLength)}${suffix}`; + suffixIndex += 1; + } + used.add(candidate); + mapping[name] = candidate; + } + + return mapping; +} + +export function getShortToolName(name: string, mapping: Record): string { + const trimmed = asTrimmedString(name); + return mapping[trimmed] || shortenToolNameIfNeeded(trimmed); +} diff --git a/src/shared/conversationFileTypes.d.ts b/src/shared/conversationFileTypes.d.ts new file mode 100644 index 00000000..9717c167 --- /dev/null +++ b/src/shared/conversationFileTypes.d.ts @@ -0,0 +1,21 @@ +export type ConversationFileKind = 'document' | 'image' | 'audio' | 'unknown'; + +export declare const CONVERSATION_DOCUMENT_ACCEPT_PARTS: string[]; +export declare function inferConversationFileMimeType(filename: string | null | undefined): string; +export declare function resolveConversationFileMimeType( + mimeType: string | null | undefined, + filename: string | null | undefined, +): string; +export declare function classifyConversationFileMimeType( + mimeType: string | null | undefined, +): Exclude; +export declare function detectConversationFileKind(file: { + filename?: string | null; + mimeType?: string | null; +}): ConversationFileKind; +export declare function isSupportedConversationFileMimeType(mimeType: string): boolean; +export declare function buildConversationAcceptList(input: { + document: boolean; + image: boolean; + audio: boolean; +}): string; diff --git a/src/shared/conversationFileTypes.js b/src/shared/conversationFileTypes.js new file mode 100644 index 00000000..d0af49cd --- /dev/null +++ b/src/shared/conversationFileTypes.js @@ -0,0 +1,94 @@ +export const CONVERSATION_DOCUMENT_ACCEPT_PARTS = ['.pdf', '.txt', '.md', '.markdown', '.json']; +const GENERIC_MIME_TYPES = new Set([ + 'application/octet-stream', + 'binary/octet-stream', +]); +const DOCUMENT_MIME_TYPES = new Set([ + 'application/json', + 'application/pdf', + 'text/markdown', + 'text/plain', +]); +const DOCUMENT_EXTENSIONS = ['.json', '.md', '.markdown', '.pdf', '.txt']; +const IMAGE_EXTENSIONS = ['.avif', '.bmp', '.gif', '.jpeg', '.jpg', '.png', '.svg', '.webp']; +const AUDIO_EXTENSIONS = ['.aac', '.flac', '.m4a', '.mp3', '.ogg', '.wav', '.weba']; + +function normalizeValue(value) { + return (value || '').trim().toLowerCase(); +} + +function normalizeMimeType(value) { + const normalized = normalizeValue(value); + const [essence] = normalized.split(';'); + return (essence || '').trim(); +} + +export function inferConversationFileMimeType(filename) { + const normalized = normalizeValue(filename); + if (normalized.endsWith('.pdf')) return 'application/pdf'; + if (normalized.endsWith('.txt')) return 'text/plain'; + if (normalized.endsWith('.md') || normalized.endsWith('.markdown')) return 'text/markdown'; + if (normalized.endsWith('.json')) return 'application/json'; + if (normalized.endsWith('.png')) return 'image/png'; + if (normalized.endsWith('.jpg') || normalized.endsWith('.jpeg')) return 'image/jpeg'; + if (normalized.endsWith('.gif')) return 'image/gif'; + if (normalized.endsWith('.webp')) return 'image/webp'; + if (normalized.endsWith('.svg')) return 'image/svg+xml'; + if (normalized.endsWith('.avif')) return 'image/avif'; + if (normalized.endsWith('.bmp')) return 'image/bmp'; + if (normalized.endsWith('.wav')) return 'audio/wav'; + if (normalized.endsWith('.mp3')) return 'audio/mpeg'; + if (normalized.endsWith('.m4a')) return 'audio/mp4'; + if (normalized.endsWith('.ogg')) return 'audio/ogg'; + if (normalized.endsWith('.aac')) return 'audio/aac'; + if (normalized.endsWith('.flac')) return 'audio/flac'; + if (normalized.endsWith('.weba')) return 'audio/webm'; + return 'application/octet-stream'; +} + +export function resolveConversationFileMimeType(mimeType, filename) { + const normalizedMimeType = normalizeMimeType(mimeType); + if (normalizedMimeType && !GENERIC_MIME_TYPES.has(normalizedMimeType)) { + return normalizedMimeType; + } + return inferConversationFileMimeType(filename); +} + +export function classifyConversationFileMimeType(mimeType) { + const normalized = normalizeMimeType(mimeType); + if (normalized.startsWith('image/')) return 'image'; + if (normalized.startsWith('audio/')) return 'audio'; + return 'document'; +} + +export function detectConversationFileKind(file) { + const mimeType = normalizeMimeType(file?.mimeType); + if (mimeType) { + if (mimeType.startsWith('image/')) return 'image'; + if (mimeType.startsWith('audio/')) return 'audio'; + if (DOCUMENT_MIME_TYPES.has(mimeType)) return 'document'; + if (!GENERIC_MIME_TYPES.has(mimeType)) return 'unknown'; + } + + const filename = normalizeValue(file?.filename); + if (!filename) return 'unknown'; + if (DOCUMENT_EXTENSIONS.some((extension) => filename.endsWith(extension))) return 'document'; + if (IMAGE_EXTENSIONS.some((extension) => filename.endsWith(extension))) return 'image'; + if (AUDIO_EXTENSIONS.some((extension) => filename.endsWith(extension))) return 'audio'; + return 'unknown'; +} + +export function isSupportedConversationFileMimeType(mimeType) { + const normalized = normalizeMimeType(mimeType); + return DOCUMENT_MIME_TYPES.has(normalized) + || normalized.startsWith('image/') + || normalized.startsWith('audio/'); +} + +export function buildConversationAcceptList(input) { + const parts = []; + if (input.document) parts.push(...CONVERSATION_DOCUMENT_ACCEPT_PARTS); + if (input.image) parts.push('image/*'); + if (input.audio) parts.push('audio/*'); + return parts.join(','); +} diff --git a/src/shared/conversationFileTypes.test.ts b/src/shared/conversationFileTypes.test.ts new file mode 100644 index 00000000..fef7cb32 --- /dev/null +++ b/src/shared/conversationFileTypes.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from 'vitest'; + +import { + buildConversationAcceptList, + classifyConversationFileMimeType, + detectConversationFileKind, + inferConversationFileMimeType, + isSupportedConversationFileMimeType, + resolveConversationFileMimeType, +} from './conversationFileTypes.js'; + +describe('conversationFileTypes', () => { + it('classifies conversation file kinds from mime type and filename', () => { + expect(classifyConversationFileMimeType('image/png')).toBe('image'); + expect(classifyConversationFileMimeType('audio/mpeg')).toBe('audio'); + expect(classifyConversationFileMimeType('application/pdf')).toBe('document'); + + expect(detectConversationFileKind({ filename: 'paper.pdf', mimeType: null })).toBe('document'); + expect(detectConversationFileKind({ filename: 'photo.webp', mimeType: null })).toBe('image'); + expect(detectConversationFileKind({ filename: 'voice.mp3', mimeType: null })).toBe('audio'); + expect(detectConversationFileKind({ filename: 'unknown.bin', mimeType: null })).toBe('unknown'); + }); + + it('keeps supported mime and accept list rules in one place', () => { + expect(isSupportedConversationFileMimeType('application/pdf')).toBe(true); + expect(isSupportedConversationFileMimeType('image/jpeg')).toBe(true); + expect(isSupportedConversationFileMimeType('audio/wav')).toBe(true); + expect(isSupportedConversationFileMimeType('application/octet-stream')).toBe(false); + + expect(buildConversationAcceptList({ + document: true, + image: true, + audio: false, + })).toBe('.pdf,.txt,.md,.markdown,.json,image/*'); + }); + + it('falls back from generic mime types to filename-based inference consistently', () => { + expect(inferConversationFileMimeType('photo.avif')).toBe('image/avif'); + expect(resolveConversationFileMimeType('application/octet-stream', 'paper.pdf')).toBe('application/pdf'); + expect(detectConversationFileKind({ + filename: 'paper.pdf', + mimeType: 'application/octet-stream', + })).toBe('document'); + }); + + it('strips MIME parameters before matching support and kind rules', () => { + expect(resolveConversationFileMimeType('text/plain; charset=utf-8', 'notes.txt')).toBe('text/plain'); + expect(classifyConversationFileMimeType('text/plain; charset=utf-8')).toBe('document'); + expect(detectConversationFileKind({ + filename: 'notes.txt', + mimeType: 'text/plain; charset=utf-8', + })).toBe('document'); + expect(isSupportedConversationFileMimeType('text/plain; charset=utf-8')).toBe(true); + }); +}); diff --git a/src/shared/platformIdentity.d.ts b/src/shared/platformIdentity.d.ts new file mode 100644 index 00000000..d6b308c7 --- /dev/null +++ b/src/shared/platformIdentity.d.ts @@ -0,0 +1,3 @@ +export declare const PLATFORM_ALIASES: Record; +export declare function normalizePlatformAlias(platform: unknown): string; +export declare function detectPlatformByUrlHint(url: string): string | undefined; diff --git a/src/shared/platformIdentity.js b/src/shared/platformIdentity.js new file mode 100644 index 00000000..dd5adb93 --- /dev/null +++ b/src/shared/platformIdentity.js @@ -0,0 +1,94 @@ +export const PLATFORM_ALIASES = Object.assign(Object.create(null), { + anyrouter: 'anyrouter', + 'wong-gongyi': 'new-api', + 'vo-api': 'new-api', + 'super-api': 'new-api', + 'rix-api': 'new-api', + 'neo-api': 'new-api', + newapi: 'new-api', + 'new api': 'new-api', + 'new-api': 'new-api', + oneapi: 'one-api', + 'one api': 'one-api', + 'one-api': 'one-api', + onehub: 'one-hub', + 'one-hub': 'one-hub', + donehub: 'done-hub', + 'done-hub': 'done-hub', + veloera: 'veloera', + sub2api: 'sub2api', + openai: 'openai', + codex: 'codex', + 'chatgpt-codex': 'codex', + 'chatgpt codex': 'codex', + anthropic: 'claude', + claude: 'claude', + gemini: 'gemini', + 'gemini-cli': 'gemini-cli', + antigravity: 'antigravity', + 'anti-gravity': 'antigravity', + google: 'gemini', + cliproxyapi: 'cliproxyapi', + cpa: 'cliproxyapi', + 'cli-proxy-api': 'cliproxyapi', +}); + +function getPlatformAlias(raw) { + return Object.prototype.hasOwnProperty.call(PLATFORM_ALIASES, raw) + ? PLATFORM_ALIASES[raw] + : undefined; +} + +function normalizeUrlCandidate(url) { + return typeof url === 'string' ? url.trim() : ''; +} + +function parseUrlCandidate(url) { + const normalized = normalizeUrlCandidate(url); + if (!normalized) return null; + + const candidates = normalized.includes('://') + ? [normalized] + : [`https://${normalized}`]; + for (const candidate of candidates) { + try { + return new URL(candidate); + } catch {} + } + return null; +} + +export function normalizePlatformAlias(platform) { + const raw = typeof platform === 'string' ? platform.trim().toLowerCase() : ''; + if (!raw) return ''; + return getPlatformAlias(raw) ?? raw; +} + +export function detectPlatformByUrlHint(url) { + const normalized = normalizeUrlCandidate(url).toLowerCase(); + if (!normalized) return undefined; + const parsed = parseUrlCandidate(normalized); + const host = parsed?.hostname?.trim().toLowerCase() || ''; + const port = parsed?.port?.trim() || ''; + const path = parsed?.pathname?.trim().toLowerCase() || ''; + + if (host === 'api.openai.com') return 'openai'; + if (host === 'chatgpt.com' && path.startsWith('/backend-api/codex')) return 'codex'; + if (host === 'api.anthropic.com' || (host === 'anthropic.com' && path.startsWith('/v1'))) return 'claude'; + if ( + host === 'generativelanguage.googleapis.com' + || host === 'gemini.google.com' + || ((host === 'googleapis.com' || host.endsWith('.googleapis.com')) && path.startsWith('/v1beta/openai')) + ) { + return 'gemini'; + } + if (host === 'cloudcode-pa.googleapis.com') return 'gemini-cli'; + if ((host === '127.0.0.1' || host === 'localhost') && port === '8317') return 'cliproxyapi'; + if (host.includes('anyrouter')) return 'anyrouter'; + if (host.includes('donehub') || host.includes('done-hub')) return 'done-hub'; + if (host.includes('onehub') || host.includes('one-hub')) return 'one-hub'; + if (host.includes('veloera')) return 'veloera'; + if (host.includes('sub2api')) return 'sub2api'; + + return undefined; +} diff --git a/src/shared/platformIdentity.test.ts b/src/shared/platformIdentity.test.ts new file mode 100644 index 00000000..12441702 --- /dev/null +++ b/src/shared/platformIdentity.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from 'vitest'; + +import { + detectPlatformByUrlHint, + normalizePlatformAlias, +} from './platformIdentity.js'; + +describe('platformIdentity', () => { + it('normalizes shared platform aliases', () => { + expect(normalizePlatformAlias('chatgpt-codex')).toBe('codex'); + expect(normalizePlatformAlias('anti-gravity')).toBe('antigravity'); + expect(normalizePlatformAlias('one api')).toBe('one-api'); + expect(normalizePlatformAlias('')).toBe(''); + }); + + it('detects platform by well-known url hints', () => { + expect(detectPlatformByUrlHint('https://api.openai.com/v1/models')).toBe('openai'); + expect(detectPlatformByUrlHint('https://chatgpt.com/backend-api/codex')).toBe('codex'); + expect(detectPlatformByUrlHint('https://api.anthropic.com/v1/messages')).toBe('claude'); + expect(detectPlatformByUrlHint('https://generativelanguage.googleapis.com/v1beta')).toBe('gemini'); + expect(detectPlatformByUrlHint('https://cloudcode-pa.googleapis.com')).toBe('gemini-cli'); + expect(detectPlatformByUrlHint('http://127.0.0.1:8317/v1/models')).toBe('cliproxyapi'); + expect(detectPlatformByUrlHint('https://evil.example.com/?next=https://api.openai.com/v1/models')).toBeUndefined(); + }); +}); diff --git a/src/shared/proxyLogMeta.d.ts b/src/shared/proxyLogMeta.d.ts new file mode 100644 index 00000000..0e462085 --- /dev/null +++ b/src/shared/proxyLogMeta.d.ts @@ -0,0 +1,9 @@ +export type ParsedProxyLogMetadata = { + clientKind: string | null; + sessionId: string | null; + downstreamPath: string | null; + upstreamPath: string | null; + messageText: string; +}; + +export declare function parseProxyLogMetadata(rawMessage: string): ParsedProxyLogMetadata; diff --git a/src/shared/proxyLogMeta.js b/src/shared/proxyLogMeta.js new file mode 100644 index 00000000..902b7452 --- /dev/null +++ b/src/shared/proxyLogMeta.js @@ -0,0 +1,18 @@ +export function parseProxyLogMetadata(rawMessage) { + const clientMatch = rawMessage.match(/\[client:([^\]]+)\]/i); + const sessionMatch = rawMessage.match(/\[session:([^\]]+)\]/i); + const downstreamMatch = rawMessage.match(/\[downstream:([^\]]+)\]/i); + const upstreamMatch = rawMessage.match(/\[upstream:([^\]]+)\]/i); + const messageText = rawMessage.replace( + /^\s*(?:\[(?:client|session|downstream|upstream):[^\]]+\]\s*)+/i, + '', + ).trim(); + + return { + clientKind: clientMatch?.[1]?.trim() || null, + sessionId: sessionMatch?.[1]?.trim() || null, + downstreamPath: downstreamMatch?.[1]?.trim() || null, + upstreamPath: upstreamMatch?.[1]?.trim() || null, + messageText, + }; +} diff --git a/src/shared/proxyLogMeta.test.ts b/src/shared/proxyLogMeta.test.ts new file mode 100644 index 00000000..4bec696a --- /dev/null +++ b/src/shared/proxyLogMeta.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from 'vitest'; + +import { parseProxyLogMetadata } from './proxyLogMeta.js'; + +describe('proxyLogMeta', () => { + it('parses all supported proxy log prefixes', () => { + expect(parseProxyLogMetadata( + '[client:codex] [session:turn-1] [downstream:/v1/responses] [upstream:/responses] boom', + )).toEqual({ + clientKind: 'codex', + sessionId: 'turn-1', + downstreamPath: '/v1/responses', + upstreamPath: '/responses', + messageText: 'boom', + }); + }); + + it('keeps plain message text when no metadata prefixes exist', () => { + expect(parseProxyLogMetadata('network timeout')).toEqual({ + clientKind: null, + sessionId: null, + downstreamPath: null, + upstreamPath: null, + messageText: 'network timeout', + }); + }); + + it('handles mixed-case, partial, reordered, and empty metadata safely', () => { + expect(parseProxyLogMetadata('[CLIENT:codex] [SESSION:turn-1] boom')).toEqual({ + clientKind: 'codex', + sessionId: 'turn-1', + downstreamPath: null, + upstreamPath: null, + messageText: 'boom', + }); + + expect(parseProxyLogMetadata('[client:codex] boom')).toEqual({ + clientKind: 'codex', + sessionId: null, + downstreamPath: null, + upstreamPath: null, + messageText: 'boom', + }); + + expect(parseProxyLogMetadata('[upstream:/x] [client:codex] [session:1] msg')).toEqual({ + clientKind: 'codex', + sessionId: '1', + downstreamPath: null, + upstreamPath: '/x', + messageText: 'msg', + }); + + expect(parseProxyLogMetadata('')).toEqual({ + clientKind: null, + sessionId: null, + downstreamPath: null, + upstreamPath: null, + messageText: '', + }); + }); +}); diff --git a/src/shared/tokenRouteContract.d.ts b/src/shared/tokenRouteContract.d.ts new file mode 100644 index 00000000..5595a53c --- /dev/null +++ b/src/shared/tokenRouteContract.d.ts @@ -0,0 +1,25 @@ +export type RouteMode = 'pattern' | 'explicit_group'; +export type RouteDecisionCandidate = { + channelId: number; + accountId: number; + username: string; + siteName: string; + tokenName: string; + priority: number; + weight: number; + eligible: boolean; + recentlyFailed: boolean; + avoidedByRecentFailure: boolean; + probability: number; + reason: string; +}; +export type RouteDecision = { + requestedModel: string; + actualModel: string; + matched: boolean; + selectedChannelId?: number; + selectedLabel?: string; + summary: string[]; + candidates: RouteDecisionCandidate[]; +}; +export declare function normalizeTokenRouteMode(routeMode: unknown): RouteMode; diff --git a/src/shared/tokenRouteContract.js b/src/shared/tokenRouteContract.js new file mode 100644 index 00000000..c5d0667e --- /dev/null +++ b/src/shared/tokenRouteContract.js @@ -0,0 +1,3 @@ +export function normalizeTokenRouteMode(routeMode) { + return routeMode === 'explicit_group' ? 'explicit_group' : 'pattern'; +} diff --git a/src/shared/tokenRouteContract.test.ts b/src/shared/tokenRouteContract.test.ts new file mode 100644 index 00000000..d984ee5e --- /dev/null +++ b/src/shared/tokenRouteContract.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from 'vitest'; + +describe('token route contract', () => { + it('normalizes unknown route modes to pattern', async () => { + const { normalizeTokenRouteMode } = await import('./tokenRouteContract.js'); + + expect(normalizeTokenRouteMode('explicit_group')).toBe('explicit_group'); + expect(normalizeTokenRouteMode('pattern')).toBe('pattern'); + expect(normalizeTokenRouteMode('anything-else')).toBe('pattern'); + expect(normalizeTokenRouteMode(null)).toBe('pattern'); + expect(normalizeTokenRouteMode(undefined)).toBe('pattern'); + }); +}); diff --git a/src/shared/tokenRoutePatterns.d.ts b/src/shared/tokenRoutePatterns.d.ts new file mode 100644 index 00000000..39970263 --- /dev/null +++ b/src/shared/tokenRoutePatterns.d.ts @@ -0,0 +1,10 @@ +export type TokenRoutePatternMatcher = { + test(value: string): boolean; +}; +export declare function isTokenRouteRegexPattern(pattern: string): boolean; +export declare function isExactTokenRouteModelPattern(pattern: string): boolean; +export declare function parseTokenRouteRegexPattern(pattern: string): { + regex: TokenRoutePatternMatcher | null; + error: string | null; +}; +export declare function matchesTokenRouteModelPattern(model: string, pattern: string): boolean; diff --git a/src/shared/tokenRoutePatterns.js b/src/shared/tokenRoutePatterns.js new file mode 100644 index 00000000..99c0ed57 --- /dev/null +++ b/src/shared/tokenRoutePatterns.js @@ -0,0 +1,516 @@ +let nextNodeId = 1; + +function createNode(type, fields = {}) { + return { + id: nextNodeId++, + type, + ...fields, + }; +} + +function isDigitCharacter(ch) { + if (!ch) return false; + const code = ch.charCodeAt(0); + return code >= 48 && code <= 57; +} + +function readRegexQuantifierLength(pattern, startIndex) { + const ch = pattern[startIndex]; + if (ch === '*' || ch === '+' || ch === '?') return 1; + if (ch !== '{') return 0; + + let index = startIndex + 1; + let sawDigit = false; + while (index < pattern.length && isDigitCharacter(pattern[index])) { + sawDigit = true; + index += 1; + } + if (!sawDigit) return 0; + if (pattern[index] === ',') { + index += 1; + while (index < pattern.length && isDigitCharacter(pattern[index])) { + index += 1; + } + } + if (pattern[index] !== '}') return 0; + return index - startIndex + 1; +} + +function isAllowedSafeRegexCharacter(ch) { + if (!ch) return false; + const code = ch.charCodeAt(0); + const isLowerAlpha = code >= 97 && code <= 122; + const isUpperAlpha = code >= 65 && code <= 90; + const isDigit = code >= 48 && code <= 57; + if (isLowerAlpha || isUpperAlpha || isDigit) return true; + return ' .^$|()[]{}+*?\\:_/-'.includes(ch); +} + +function hasUnsafeRegexBackreference(body) { + for (let index = 0; index < body.length; index += 1) { + if (body[index] !== '\\') continue; + const prev = index > 0 ? body[index - 1] : ''; + const next = body[index + 1]; + if (prev !== '\\' && isDigitCharacter(next) && next !== '0') { + return true; + } + } + return false; +} + +function isSafeRegexPatternBody(body) { + if (!body || body.length > 256) return false; + for (const ch of body) { + if (!isAllowedSafeRegexCharacter(ch)) return false; + } + if ( + body.includes('(?=') + || body.includes('(?!') + || body.includes('(?<=') + || body.includes('(? 0) { + groupStack[groupStack.length - 1].hasAlternation = true; + } + continue; + } + if (ch === ')') { + const group = groupStack.pop(); + if (!group) return false; + const quantifierLength = readRegexQuantifierLength(body, index + 1); + if (quantifierLength > 0 && (group.hasInnerQuantifier || group.hasAlternation)) { + return false; + } + const parent = groupStack[groupStack.length - 1]; + if (parent && (group.hasInnerQuantifier || quantifierLength > 0)) { + parent.hasInnerQuantifier = true; + } + continue; + } + const quantifierLength = readRegexQuantifierLength(body, index); + if (quantifierLength > 0) { + if (groupStack.length > 0) { + groupStack[groupStack.length - 1].hasInnerQuantifier = true; + } + index += quantifierLength - 1; + } + } + return !escaped && !inCharClass && groupStack.length === 0; +} + +function matchesGlobPattern(model, pattern) { + let modelIndex = 0; + let patternIndex = 0; + let starIndex = -1; + let matchIndex = 0; + + while (modelIndex < model.length) { + const patternChar = pattern[patternIndex]; + const modelChar = model[modelIndex]; + if (patternChar === '*' ) { + starIndex = patternIndex; + matchIndex = modelIndex; + patternIndex += 1; + continue; + } + if (patternChar === '?' || patternChar === modelChar) { + patternIndex += 1; + modelIndex += 1; + continue; + } + if (starIndex === -1) { + return false; + } + patternIndex = starIndex + 1; + matchIndex += 1; + modelIndex = matchIndex; + } + + while (pattern[patternIndex] === '*') { + patternIndex += 1; + } + + return patternIndex === pattern.length; +} + +function toArraySet(values) { + const unique = []; + for (const value of values) { + if (!unique.includes(value)) unique.push(value); + } + return unique; +} + +function parseCharClassChar(body, state) { + const ch = body[state.index]; + if (ch === '\\') { + state.index += 1; + const escaped = body[state.index]; + if (!escaped) throw new Error('invalid escape'); + state.index += 1; + if (escaped === 'd') return { kind: 'digit' }; + return { kind: 'char', value: escaped }; + } + if (!ch) throw new Error('invalid char class'); + state.index += 1; + return { kind: 'char', value: ch }; +} + +function parseCharClass(body, state) { + state.index += 1; + let negated = false; + if (body[state.index] === '^') { + negated = true; + state.index += 1; + } + + const entries = []; + while (state.index < body.length && body[state.index] !== ']') { + const start = parseCharClassChar(body, state); + if (body[state.index] === '-' && body[state.index + 1] && body[state.index + 1] !== ']') { + state.index += 1; + const end = parseCharClassChar(body, state); + entries.push({ kind: 'range', start, end }); + continue; + } + entries.push(start); + } + + if (body[state.index] !== ']' || entries.length === 0) { + throw new Error('invalid character class'); + } + state.index += 1; + return createNode('charclass', { negated, entries }); +} + +function parseAtom(body, state) { + const ch = body[state.index]; + if (!ch) throw new Error('unexpected end'); + + if (ch === '(') { + state.index += 1; + const group = parseExpression(body, state, ')'); + if (body[state.index] !== ')') { + throw new Error('missing )'); + } + state.index += 1; + return group; + } + if (ch === '[') { + return parseCharClass(body, state); + } + if (ch === '.') { + state.index += 1; + return createNode('any'); + } + if (ch === '\\') { + state.index += 1; + const escaped = body[state.index]; + if (!escaped) throw new Error('invalid escape'); + state.index += 1; + if (escaped === 'd') { + return createNode('digit'); + } + return createNode('literal', { value: escaped }); + } + + state.index += 1; + return createNode('literal', { value: ch }); +} + +function parseQuantifier(body, state) { + const ch = body[state.index]; + if (ch === '*') { + state.index += 1; + return { min: 0, max: Infinity }; + } + if (ch === '+') { + state.index += 1; + return { min: 1, max: Infinity }; + } + if (ch === '?') { + state.index += 1; + return { min: 0, max: 1 }; + } + if (ch !== '{') return null; + + let index = state.index + 1; + let minText = ''; + while (index < body.length && isDigitCharacter(body[index])) { + minText += body[index]; + index += 1; + } + if (!minText) return null; + + let max = Number.parseInt(minText, 10); + if (body[index] === ',') { + index += 1; + let maxText = ''; + while (index < body.length && isDigitCharacter(body[index])) { + maxText += body[index]; + index += 1; + } + max = maxText ? Number.parseInt(maxText, 10) : Infinity; + } + if (body[index] !== '}') return null; + state.index = index + 1; + return { + min: Number.parseInt(minText, 10), + max, + }; +} + +function parseTerm(body, state) { + const atom = parseAtom(body, state); + const quantifier = parseQuantifier(body, state); + if (!quantifier) return atom; + return createNode('repeat', { + atom, + min: quantifier.min, + max: quantifier.max, + }); +} + +function parseSequence(body, state, stopChar) { + const terms = []; + while (state.index < body.length) { + const ch = body[state.index]; + if (ch === '|' || ch === stopChar) break; + terms.push(parseTerm(body, state)); + } + if (terms.length === 0) { + return createNode('empty'); + } + if (terms.length === 1) return terms[0]; + return createNode('sequence', { terms }); +} + +function parseExpression(body, state, stopChar = '') { + const branches = [parseSequence(body, state, stopChar)]; + while (state.index < body.length && body[state.index] === '|') { + state.index += 1; + branches.push(parseSequence(body, state, stopChar)); + } + if (branches.length === 1) return branches[0]; + return createNode('alternation', { branches }); +} + +function charMatchesCharClassEntry(entry, ch) { + if (entry.kind === 'digit') return isDigitCharacter(ch); + if (entry.kind === 'char') return ch === entry.value; + if (entry.kind === 'range') { + if (entry.start.kind !== 'char' || entry.end.kind !== 'char') { + return false; + } + const code = ch.charCodeAt(0); + return code >= entry.start.value.charCodeAt(0) && code <= entry.end.value.charCodeAt(0); + } + return false; +} + +function matchNode(node, value, position, cache) { + const cacheKey = `${node.id}:${position}`; + const cached = cache.get(cacheKey); + if (cache.has(cacheKey)) return cached; + + let result = []; + switch (node.type) { + case 'empty': + result = [position]; + break; + case 'literal': + result = value.startsWith(node.value, position) ? [position + node.value.length] : []; + break; + case 'any': + result = position < value.length ? [position + 1] : []; + break; + case 'digit': + result = position < value.length && isDigitCharacter(value[position]) ? [position + 1] : []; + break; + case 'charclass': { + if (position < value.length) { + const matched = node.entries.some((entry) => charMatchesCharClassEntry(entry, value[position])); + if ((matched && !node.negated) || (!matched && node.negated)) { + result = [position + 1]; + } + } + break; + } + case 'sequence': { + let positions = [position]; + for (const term of node.terms) { + const nextPositions = []; + for (const current of positions) { + nextPositions.push(...matchNode(term, value, current, cache)); + } + positions = toArraySet(nextPositions); + if (positions.length === 0) break; + } + result = positions; + break; + } + case 'alternation': { + const positions = []; + for (const branch of node.branches) { + positions.push(...matchNode(branch, value, position, cache)); + } + result = toArraySet(positions); + break; + } + case 'repeat': { + let results = node.min === 0 ? [position] : []; + let frontier = [position]; + const maxRepeat = Number.isFinite(node.max) ? node.max : (value.length - position + 1); + for (let count = 1; count <= maxRepeat; count += 1) { + const nextPositions = []; + for (const current of frontier) { + const ends = matchNode(node.atom, value, current, cache); + for (const end of ends) { + if (end !== current) nextPositions.push(end); + } + } + frontier = toArraySet(nextPositions); + if (frontier.length === 0) break; + if (count >= node.min) { + results = toArraySet([...results, ...frontier]); + } + } + result = results; + break; + } + default: + result = []; + break; + } + + cache.set(cacheKey, result); + return result; +} + +function compileSafeRegexPattern(body) { + nextNodeId = 1; + const anchoredStart = body.startsWith('^'); + const anchoredEnd = body.endsWith('$') && body[body.length - 2] !== '\\'; + const normalizedBody = body + .slice(anchoredStart ? 1 : 0, anchoredEnd ? -1 : body.length) + .trim(); + const state = { index: 0 }; + const root = parseExpression(normalizedBody, state); + if (state.index !== normalizedBody.length) { + throw new Error('unsupported regex syntax'); + } + + return { + test(value) { + const starts = anchoredStart + ? [0] + : Array.from({ length: value.length + 1 }, (_, index) => index); + for (const start of starts) { + const ends = matchNode(root, value, start, new Map()); + if (!anchoredEnd && ends.length > 0) return true; + if (anchoredEnd && ends.includes(value.length)) return true; + } + return false; + }, + }; +} + +const matchCache = new Map(); +const MATCH_CACHE_LIMIT = 4000; + +export function isTokenRouteRegexPattern(pattern) { + return pattern.trim().toLowerCase().startsWith('re:'); +} + +export function isExactTokenRouteModelPattern(pattern) { + const normalized = pattern.trim(); + if (!normalized) return false; + if (isTokenRouteRegexPattern(normalized)) return false; + return !/[\*\?]/.test(normalized); +} + +export function parseTokenRouteRegexPattern(pattern) { + if (!isTokenRouteRegexPattern(pattern)) { + return { regex: null, error: null }; + } + const body = pattern.trim().slice(3).trim(); + if (!body) { + return { regex: null, error: 're: 后缺少正则表达式' }; + } + if (!isSafeRegexPatternBody(body)) { + return { regex: null, error: '出于安全原因不支持该正则表达式' }; + } + try { + return { + regex: compileSafeRegexPattern(body), + error: null, + }; + } catch (error) { + return { regex: null, error: error?.message || '无效正则' }; + } +} + +export function matchesTokenRouteModelPattern(model, pattern) { + const normalized = (pattern || '').trim(); + if (!normalized) return false; + if (normalized === model) return true; + + const cacheKey = `${model}\0${normalized}`; + const cached = matchCache.get(cacheKey); + if (cached !== undefined) return cached; + + let result; + if (isTokenRouteRegexPattern(normalized)) { + const parsed = parseTokenRouteRegexPattern(normalized); + result = !!parsed.regex && parsed.regex.test(model); + } else { + result = matchesGlobPattern(model, normalized); + } + + if (matchCache.size >= MATCH_CACHE_LIMIT) { + matchCache.clear(); + } + matchCache.set(cacheKey, result); + return result; +} diff --git a/src/shared/tokenRoutePatterns.test.ts b/src/shared/tokenRoutePatterns.test.ts new file mode 100644 index 00000000..d1adf223 --- /dev/null +++ b/src/shared/tokenRoutePatterns.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from 'vitest'; + +describe('token route pattern helpers', () => { + it('treats bracket-prefixed literal model names as exact patterns', async () => { + const { + isExactTokenRouteModelPattern, + matchesTokenRouteModelPattern, + } = await import('./tokenRoutePatterns.js'); + expect(isExactTokenRouteModelPattern('[NV]deepseek-v3.1-terminus')).toBe(true); + expect(matchesTokenRouteModelPattern('[NV]deepseek-v3.1-terminus', '[NV]deepseek-v3.1-terminus')).toBe(true); + expect(matchesTokenRouteModelPattern('Ndeepseek-v3.1-terminus', '[NV]deepseek-v3.1-terminus')).toBe(false); + }); + + it('rejects unsafe nested-quantifier regex patterns', async () => { + const { + matchesTokenRouteModelPattern, + parseTokenRouteRegexPattern, + } = await import('./tokenRoutePatterns.js'); + + expect(parseTokenRouteRegexPattern('re:(?=claude)').regex).toBeNull(); + expect(matchesTokenRouteModelPattern('claude-sonnet-4-6', 're:(?=claude)')).toBe(false); + }); + + it('rejects regex syntax that the lightweight parser does not implement', async () => { + const { + matchesTokenRouteModelPattern, + parseTokenRouteRegexPattern, + } = await import('./tokenRoutePatterns.js'); + + expect(parseTokenRouteRegexPattern('re:^(?:gpt|claude)-5$').regex).toBeNull(); + expect(matchesTokenRouteModelPattern('gpt-5', 're:^(?:gpt|claude)-5$')).toBe(false); + expect(parseTokenRouteRegexPattern('re:^gpt-\\s+$').regex).toBeNull(); + expect(matchesTokenRouteModelPattern('gpt- ', 're:^gpt-\\s+$')).toBe(false); + }); + + it('supports exact, glob, and safe regex route matches', async () => { + const { matchesTokenRouteModelPattern } = await import('./tokenRoutePatterns.js'); + + expect(matchesTokenRouteModelPattern('gpt-4o-mini', 'gpt-4o-mini')).toBe(true); + expect(matchesTokenRouteModelPattern('claude-sonnet-4-6', 'claude-*')).toBe(true); + expect(matchesTokenRouteModelPattern('claude-sonnet-4-6', 're:^claude-(opus|sonnet)-4-6$')).toBe(true); + expect(matchesTokenRouteModelPattern('gpt-4o-mini-2025', 're:^gpt-4o-mini-\\d+$')).toBe(true); + }); +}); diff --git a/src/web/App.login-surface.test.tsx b/src/web/App.login-surface.test.tsx new file mode 100644 index 00000000..f0e2c3e6 --- /dev/null +++ b/src/web/App.login-surface.test.tsx @@ -0,0 +1,78 @@ +import { describe, expect, it, vi } from 'vitest'; +import { create, type ReactTestInstance } from 'react-test-renderer'; +import { Login } from './App.js'; +import { SITE_DOCS_URL, SITE_GITHUB_URL } from './docsLink.js'; + +function collectText(node: ReactTestInstance): string { + return (node.children || []).map((child) => { + if (typeof child === 'string') return child; + return collectText(child); + }).join(''); +} + +describe('Login surface', () => { + it('uses the site root as the documentation URL', () => { + expect(SITE_DOCS_URL).toBe('https://metapi.cita777.me'); + }); + + it('uses the author github profile for the login github shortcut', () => { + expect(SITE_GITHUB_URL).toBe('https://github.com/cita-777'); + }); + + it('renders a poster-style hero with a floating admin login panel', () => { + const root = create( + text} />, + ); + + try { + const pageText = collectText(root.root); + const lightBrandPanel = root.root.find((node) => ( + node.type === 'section' + && typeof node.props.className === 'string' + && node.props.className.includes('login-brand-panel-light') + )); + const authStage = root.root.find((node) => ( + node.type === 'section' + && typeof node.props.className === 'string' + && node.props.className.includes('login-auth-stage') + )); + const brandMarkCanvas = root.root.find((node) => ( + node.type === 'div' + && typeof node.props.className === 'string' + && node.props.className.includes('brand-mark-canvas') + )); + + expect(pageText).toContain('Metapi'); + expect(pageText).toContain('中转站的中转站'); + expect(pageText).not.toContain('一个 API Key,一个入口'); + expect(pageText).toContain('兼容 New API / One API / OneHub / DoneHub / Veloera / AnyRouter / Sub2API'); + expect(pageText).toContain('统一代理网关'); + expect(pageText).toContain('智能路由引擎'); + expect(pageText).toContain('自动模型发现'); + expect(pageText).toContain('部署文档'); + expect(lightBrandPanel).toBeTruthy(); + expect(authStage).toBeTruthy(); + expect(brandMarkCanvas).toBeTruthy(); + + const docsLink = root.root.find((node) => ( + node.type === 'a' + && node.props.href === SITE_DOCS_URL + )); + const tokenInput = root.root.find((node) => ( + node.type === 'input' + && node.props.placeholder === '管理员令牌' + )); + const githubLink = root.root.find((node) => ( + node.type === 'a' + && node.props.href === SITE_GITHUB_URL + )); + + expect(docsLink.props.target).toBe('_blank'); + expect(githubLink.props['aria-label']).toBe('GitHub'); + expect(githubLink.props.target).toBe('_blank'); + expect(tokenInput.props.type).toBe('password'); + } finally { + root?.unmount(); + } + }); +}); diff --git a/src/web/App.mobile-layout.test.tsx b/src/web/App.mobile-layout.test.tsx new file mode 100644 index 00000000..ce5662f3 --- /dev/null +++ b/src/web/App.mobile-layout.test.tsx @@ -0,0 +1,186 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { ReactNode } from 'react'; +import { act, create } from 'react-test-renderer'; +import { MemoryRouter } from 'react-router-dom'; +import App from './App.js'; + +const { apiMock, authSessionMock } = vi.hoisted(() => ({ + apiMock: { + getEvents: vi.fn(), + }, + authSessionMock: { + hasValidAuthSession: vi.fn(), + persistAuthSession: vi.fn(), + clearAuthSession: vi.fn(), + }, +})); + +vi.mock('react-dom', async () => { + const actual = await vi.importActual('react-dom'); + return { + ...actual, + createPortal: (node: unknown) => node, + }; +}); + +vi.mock('./api.js', () => ({ + api: apiMock, +})); + +vi.mock('./authSession.js', () => ({ + hasValidAuthSession: authSessionMock.hasValidAuthSession, + persistAuthSession: authSessionMock.persistAuthSession, + clearAuthSession: authSessionMock.clearAuthSession, +})); + +vi.mock('./components/SearchModal.js', () => ({ + default: () => null, +})); + +vi.mock('./components/NotificationPanel.js', () => ({ + default: () => null, +})); + +vi.mock('./components/TooltipLayer.js', () => ({ + default: () => null, +})); + +vi.mock('./components/useAnimatedVisibility.js', () => ({ + useAnimatedVisibility: (open: boolean) => ({ + shouldRender: open, + isVisible: open, + }), +})); + +vi.mock('./i18n.js', () => ({ + I18nProvider: ({ children }: { children: ReactNode }) => children, + useI18n: () => ({ + language: 'zh', + toggleLanguage: vi.fn(), + t: (text: string) => text, + }), +})); + +vi.mock('./pages/Dashboard.js', () => ({ + default: ({ adminName }: { adminName?: string }) =>
{adminName || 'Dashboard'}
, +})); + +function createLocalStorage() { + const store = new Map([ + ['metapi.theme.mode', 'light'], + ['metapi.firstUseDocReminder', '1'], + ['metapi.userProfile', JSON.stringify({ + name: '管理员', + avatarSeed: 'seed-1', + avatarStyle: 'identicon', + })], + ]); + + return { + getItem: (key: string) => store.get(key) ?? null, + setItem: (key: string, value: string) => { + store.set(key, String(value)); + }, + removeItem: (key: string) => { + store.delete(key); + }, + }; +} + +function setupRuntime(width: number) { + const matchMedia = (query: string) => ({ + matches: query.includes('prefers-color-scheme') + ? false + : width <= 768, + media: query, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + dispatchEvent: vi.fn(), + onchange: null, + }); + + const documentElementAttributes = new Map(); + const documentElement = { + setAttribute: (name: string, value: string) => { + documentElementAttributes.set(name, value); + }, + getAttribute: (name: string) => documentElementAttributes.get(name) ?? null, + }; + + vi.stubGlobal('localStorage', createLocalStorage()); + vi.stubGlobal('window', { + innerWidth: width, + matchMedia, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + }); + vi.stubGlobal('document', { + body: { style: {} }, + documentElement, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + }); +} + +async function flushMicrotasks() { + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + }); +} + +describe('App mobile layout', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.useFakeTimers(); + apiMock.getEvents.mockResolvedValue([]); + authSessionMock.hasValidAuthSession.mockReturnValue(true); + }); + + afterEach(() => { + vi.runOnlyPendingTimers(); + vi.useRealTimers(); + vi.unstubAllGlobals(); + vi.clearAllMocks(); + }); + + it.each([ + { width: 767, expectedLayout: 'mobile', hasHamburger: true }, + { width: 768, expectedLayout: 'mobile', hasHamburger: true }, + { width: 769, expectedLayout: 'desktop', hasHamburger: false }, + ])( + 'uses the shared breakpoint at width $width', + async ({ width, expectedLayout, hasHamburger }) => { + setupRuntime(width); + let root!: WebTestRenderer; + + try { + await act(async () => { + root = create( + + + , + ); + }); + await flushMicrotasks(); + + const hamburgerButtons = root.root.findAll((node) => ( + node.type === 'button' + && node.props['aria-label'] === '打开导航' + )); + + expect(document.documentElement.getAttribute('data-layout')).toBe(expectedLayout); + expect(hamburgerButtons.length > 0).toBe(hasHamburger); + } finally { + if (root) { + await act(async () => { + root.unmount(); + }); + } + } + }, + ); +}); diff --git a/src/web/App.sidebar-mobile.test.tsx b/src/web/App.sidebar-mobile.test.tsx new file mode 100644 index 00000000..8e224ac1 --- /dev/null +++ b/src/web/App.sidebar-mobile.test.tsx @@ -0,0 +1,200 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { ReactNode } from 'react'; +import { act, create, type ReactTestInstance } from 'react-test-renderer'; +import { MemoryRouter } from 'react-router-dom'; +import App from './App.js'; + +const { apiMock, authSessionMock } = vi.hoisted(() => ({ + apiMock: { + getEvents: vi.fn(), + }, + authSessionMock: { + hasValidAuthSession: vi.fn(), + persistAuthSession: vi.fn(), + clearAuthSession: vi.fn(), + }, +})); + +vi.mock('react-dom', async () => { + const actual = await vi.importActual('react-dom'); + return { + ...actual, + createPortal: (node: unknown) => node, + }; +}); + +vi.mock('./api.js', () => ({ + api: apiMock, +})); + +vi.mock('./authSession.js', () => ({ + hasValidAuthSession: authSessionMock.hasValidAuthSession, + persistAuthSession: authSessionMock.persistAuthSession, + clearAuthSession: authSessionMock.clearAuthSession, +})); + +vi.mock('./components/SearchModal.js', () => ({ + default: () => null, +})); + +vi.mock('./components/NotificationPanel.js', () => ({ + default: () => null, +})); + +vi.mock('./components/TooltipLayer.js', () => ({ + default: () => null, +})); + +vi.mock('./components/useAnimatedVisibility.js', () => ({ + useAnimatedVisibility: (open: boolean) => ({ + shouldRender: open, + isVisible: open, + }), +})); + +vi.mock('./i18n.js', () => ({ + I18nProvider: ({ children }: { children: ReactNode }) => children, + useI18n: () => ({ + language: 'zh', + toggleLanguage: vi.fn(), + t: (text: string) => text, + }), +})); + +vi.mock('./pages/Dashboard.js', () => ({ + default: () =>
Dashboard
, +})); + +function createLocalStorage() { + const store = new Map([ + ['metapi.theme.mode', 'light'], + ['metapi.firstUseDocReminder', '1'], + ['metapi.userProfile', JSON.stringify({ + name: '管理员', + avatarSeed: 'seed-1', + avatarStyle: 'identicon', + })], + ]); + + return { + getItem: (key: string) => store.get(key) ?? null, + setItem: (key: string, value: string) => { + store.set(key, String(value)); + }, + removeItem: (key: string) => { + store.delete(key); + }, + }; +} + +function setupRuntime(width: number) { + const matchMedia = (query: string) => ({ + matches: query.includes('prefers-color-scheme') + ? false + : width <= 768, + media: query, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + dispatchEvent: vi.fn(), + onchange: null, + }); + + vi.stubGlobal('localStorage', createLocalStorage()); + vi.stubGlobal('window', { + innerWidth: width, + matchMedia, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + }); + vi.stubGlobal('document', { + body: { style: {} }, + documentElement: { + setAttribute: vi.fn(), + getAttribute: vi.fn(), + }, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + }); +} + +function collectText(node: ReactTestInstance): string { + return (node.children || []).map((child) => { + if (typeof child === 'string') return child; + return collectText(child); + }).join(''); +} + +async function flushMicrotasks() { + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + }); +} + +describe('App mobile sidebar', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.useFakeTimers(); + apiMock.getEvents.mockResolvedValue([]); + authSessionMock.hasValidAuthSession.mockReturnValue(true); + }); + + afterEach(() => { + vi.runOnlyPendingTimers(); + vi.useRealTimers(); + vi.unstubAllGlobals(); + vi.clearAllMocks(); + }); + + it('opens the mobile drawer from the hamburger trigger and exposes the close affordance', async () => { + setupRuntime(768); + let root!: WebTestRenderer; + + try { + await act(async () => { + root = create( + + + , + ); + }); + await flushMicrotasks(); + + const openButton = root.root.find((node) => ( + node.type === 'button' + && node.props['aria-label'] === '打开导航' + )); + + await act(async () => { + openButton.props.onClick(); + }); + await flushMicrotasks(); + + expect(collectText(root.root)).toContain('导航菜单'); + + const closeButton = root.root.find((node) => ( + node.type === 'button' + && node.props['aria-label'] === '关闭导航' + )); + + await act(async () => { + closeButton.props.onClick(); + }); + await act(async () => { + vi.advanceTimersByTime(300); + }); + await flushMicrotasks(); + + expect(collectText(root.root)).not.toContain('导航菜单'); + } finally { + if (root) { + await act(async () => { + root.unmount(); + }); + } + } + }); +}); diff --git a/src/web/App.sidebar.test.ts b/src/web/App.sidebar.test.ts index 6b41f8ef..70500fab 100644 --- a/src/web/App.sidebar.test.ts +++ b/src/web/App.sidebar.test.ts @@ -10,4 +10,23 @@ describe('App sidebar config', () => { expect(source).not.toContain("{ to: '/accounts', label: '账号'"); expect(source).not.toContain("{ to: '/tokens', label: '令牌管理'"); }); + + it('places downstream key navigation under 控制台 instead of 系统', () => { + const source = readFileSync(resolve(process.cwd(), 'src/web/App.tsx'), 'utf8'); + const consoleGroupIndex = source.indexOf("label: '控制台'"); + const downstreamIndex = source.indexOf("{ to: '/downstream-keys', label: '下游密钥'"); + const systemGroupIndex = source.indexOf("label: '系统'"); + + expect(consoleGroupIndex).toBeGreaterThanOrEqual(0); + expect(downstreamIndex).toBeGreaterThan(consoleGroupIndex); + expect(systemGroupIndex).toBeGreaterThan(downstreamIndex); + }); + + it('adds standalone OAuth 管理 navigation entry', () => { + const source = readFileSync(resolve(process.cwd(), 'src/web/App.tsx'), 'utf8'); + + expect(source).toContain("{ to: '/oauth', label: 'OAuth 管理'"); + expect(source).toContain("const OAuthManagement = lazy(() => import('./pages/OAuthManagement.js'));"); + expect(source).toContain('} />'); + }); }); diff --git a/src/web/App.topbar-tooltips.test.ts b/src/web/App.topbar-tooltips.test.ts new file mode 100644 index 00000000..4e406f8f --- /dev/null +++ b/src/web/App.topbar-tooltips.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +describe('App topbar tooltips', () => { + it('removes topbar hover tooltips while preserving sidebar collapsed tooltips', () => { + const source = readFileSync(resolve(process.cwd(), 'src/web/App.tsx'), 'utf8'); + const topbarStart = source.indexOf('
'); + const topbarEnd = source.indexOf(''); + + expect(topbarStart).toBeGreaterThanOrEqual(0); + expect(topbarEnd).toBeGreaterThan(topbarStart); + + const topbarSection = source.slice(topbarStart, topbarEnd); + expect(topbarSection).not.toContain('data-tooltip='); + expect(source).toContain("data-tooltip={sidebarCollapsed ? t(item.label) : undefined}"); + }); +}); diff --git a/src/web/App.tsx b/src/web/App.tsx index 751186c3..abd5ba43 100644 --- a/src/web/App.tsx +++ b/src/web/App.tsx @@ -3,6 +3,7 @@ import { Routes, Route, NavLink, Navigate, useLocation } from 'react-router-dom' import { ToastProvider, useToast } from './components/Toast.js'; import SearchModal from './components/SearchModal.js'; import NotificationPanel from './components/NotificationPanel.js'; +import TooltipLayer from './components/TooltipLayer.js'; import { api } from './api.js'; import { clearAuthSession, hasValidAuthSession, persistAuthSession } from './authSession.js'; import { @@ -13,8 +14,11 @@ import { } from './appLocalState.js'; import { I18nProvider, useI18n } from './i18n.js'; import { resolveLoginErrorMessage } from './loginError.js'; -import { SITE_DOCS_URL } from './docsLink.js'; +import { SITE_DOCS_URL, SITE_GITHUB_URL } from './docsLink.js'; import { useAnimatedVisibility } from './components/useAnimatedVisibility.js'; +import { useIsMobile } from './components/useIsMobile.js'; +import { MobileDrawer } from './components/MobileDrawer.js'; +import CenteredModal from './components/CenteredModal.js'; const Dashboard = lazy(() => import('./pages/Dashboard.js')); const Sites = lazy(() => import('./pages/Sites.js')); const Accounts = lazy(() => import('./pages/Accounts.js')); @@ -23,6 +27,7 @@ const CheckinLog = lazy(() => import('./pages/CheckinLog.js')); const TokenRoutes = lazy(() => import('./pages/TokenRoutes.js')); const ProxyLogs = lazy(() => import('./pages/ProxyLogs.js')); const Settings = lazy(() => import('./pages/Settings.js')); +const DownstreamKeys = lazy(() => import('./pages/DownstreamKeys.js')); const ImportExport = lazy(() => import('./pages/ImportExport.js')); const NotificationSettings = lazy(() => import('./pages/NotificationSettings.js')); const ProgramLogs = lazy(() => import('./pages/ProgramLogs.js')); @@ -30,6 +35,8 @@ const Models = lazy(() => import('./pages/Models.js')); const About = lazy(() => import('./pages/About.js')); const ModelTester = lazy(() => import('./pages/ModelTester.js')); const Monitors = lazy(() => import('./pages/Monitors.js')); +const OAuthManagement = lazy(() => import('./pages/OAuthManagement.js')); +const SiteAnnouncements = lazy(() => import('./pages/SiteAnnouncements.js')); type ThemeMode = 'system' | 'light' | 'dark'; @@ -121,10 +128,24 @@ function resolveStoredProfile(): UserProfile { } } -function Login({ onLogin, t }: { onLogin: (token: string) => void; t: (text: string) => string }) { +export function Login({ onLogin, t }: { onLogin: (token: string) => void; t: (text: string) => string }) { const [token, setToken] = useState(''); const [loading, setLoading] = useState(false); const [error, setError] = useState(''); + const capabilityRows = [ + { + title: t('统一代理网关'), + description: t('一个 Key、一个入口,兼容 OpenAI / Claude 下游格式'), + }, + { + title: t('自动模型发现'), + description: t('上游新增模型自动出现在模型列表,零配置路由生成'), + }, + { + title: t('智能路由引擎'), + description: t('按成本、延迟、成功率自动选择最优通道,故障自动转移'), + }, + ]; const handleLogin = async () => { if (!token) return; @@ -150,7 +171,7 @@ function Login({ onLogin, t }: { onLogin: (token: string) => void; t: (text: str reason = text; } } - } catch {} + } catch { } setError(t(resolveLoginErrorMessage(res.status, reason))); setLoading(false); } @@ -161,37 +182,100 @@ function Login({ onLogin, t }: { onLogin: (token: string) => void; t: (text: str }; return ( -
-
-
- Metapi -

Metapi

-
-

{t('请输入管理员令牌后继续。')}

- { - setToken(e.target.value); - setError(''); - }} - onKeyDown={(e) => e.key === 'Enter' && handleLogin()} - style={{ width: '100%', padding: '10px 14px', border: '1px solid var(--color-border)', borderRadius: 'var(--radius-sm)', fontSize: 14, outline: 'none', background: 'var(--color-bg)', color: 'var(--color-text-primary)', marginBottom: 12, transition: 'border-color 0.2s' }} - /> - {error && ( -
- {error} +
+
+
+
+
+
+ Metapi +
+
+
+
Metapi
+
{t('中转站的中转站')}
+
- )} - +
+

+ {t('把分散的 New API / One API / OneHub 等站点聚合成统一网关,自动发现模型、智能路由、成本更优。')} +

+
+
{t('兼容 New API / One API / OneHub / DoneHub / Veloera / AnyRouter / Sub2API')}
+
+ {capabilityRows.map((feature, index) => ( +
+
{String(index + 1).padStart(2, '0')}
+
+
{feature.title}
+

{feature.description}

+
+
+ ))} +
+ +
+ +
+
+
{t('管理员入口')}
+

{t('登录')}

+

{t('请输入管理员令牌后继续。')}

+ + { + setToken(e.target.value); + setError(''); + }} + onKeyDown={(e) => e.key === 'Enter' && handleLogin()} + className="login-auth-input" + /> + {error && ( +
+ {error} +
+ )} + +
{t('仅校验本地服务访问权限,不会把令牌发送到第三方。')}
+
+ {t('管理员登录后继续。')} +
+
+
); @@ -210,7 +294,6 @@ function UserProfileModal({ onSave: (nextProfile: UserProfile) => void; t: (text: string) => string; }) { - const presence = useAnimatedVisibility(open, 200); const [name, setName] = useState(profile.name); const [avatarSeed, setAvatarSeed] = useState(profile.avatarSeed); const [avatarStyle, setAvatarStyle] = useState(profile.avatarStyle); @@ -224,8 +307,6 @@ function UserProfileModal({ setError(''); }, [open, profile]); - if (!presence.shouldRender) return null; - const avatarUrl = buildDicebearAvatarUrl(avatarStyle, avatarSeed); const inputStyle: React.CSSProperties = { @@ -265,65 +346,73 @@ function UserProfileModal({ }; return ( -
-
e.stopPropagation()} style={{ maxWidth: 440 }}> -
{t('个人信息')}
-
-
-
- {name.trim() -
-
{t('右上角头像实时预览')}
-
- -
-
{t('用户名')}
- { - setName(e.target.value); - setError(''); - }} - placeholder={t('例如:小王')} - style={inputStyle} - /> -
- -
-
- {t('头像(Dicebear 随机) · 风格:')}{avatarStyle} -
- -
- - {error && ( -
- {error} -
- )} -
-
+ + + )} + > +
+
+ {name.trim()
+
{t('右上角头像实时预览')}
-
+ +
+
{t('用户名')}
+ { + setName(e.target.value); + setError(''); + }} + placeholder={t('例如:小王')} + style={inputStyle} + /> +
+ +
+
+ {t('头像(Dicebear 随机) · 风格:')}{avatarStyle} +
+ +
+ + {error && ( +
+ {error} +
+ )} + ); } -const sidebarGroups = [ +export const sidebarGroups = [ { label: '控制台', items: [ { to: '/', label: '仪表盘', icon: }, - { to: '/sites', label: '站点', icon: }, + { to: '/sites', label: '站点管理', icon: }, + { to: '/site-announcements', label: '站点公告', icon: }, { to: '/accounts', label: '连接管理', icon: }, + { to: '/oauth', label: 'OAuth 管理', icon: }, + { to: '/downstream-keys', label: '下游密钥', icon: }, { to: '/checkin', label: '签到记录', icon: }, { to: '/routes', label: '路由', icon: }, { to: '/logs', label: '使用日志', icon: }, @@ -376,12 +465,14 @@ function AppShell() { const [showProfileModal, setShowProfileModal] = useState(false); const [showSearch, setShowSearch] = useState(false); const [showNotifications, setShowNotifications] = useState(false); + const [drawerOpen, setDrawerOpen] = useState(false); const themeMenuPresence = useAnimatedVisibility(showThemeMenu, 160); const userMenuPresence = useAnimatedVisibility(showUserMenu, 160); const [unreadCount, setUnreadCount] = useState(0); const notifBtnRef = useRef(null); const latestTaskEventIdRef = useRef(0); const toast = useToast(); + const isMobile = useIsMobile(); const resolvedTheme: 'light' | 'dark' = themeMode === 'system' ? (systemPrefersDark ? 'dark' : 'light') : themeMode; @@ -414,6 +505,16 @@ function AppShell() { } }, [resolvedTheme, themeMode]); + useEffect(() => { + document.documentElement.setAttribute('data-layout', isMobile ? 'mobile' : 'desktop'); + }, [isMobile]); + + useEffect(() => { + if (!isMobile && drawerOpen) { + setDrawerOpen(false); + } + }, [drawerOpen, isMobile]); + useEffect(() => { const handler = (e: KeyboardEvent) => { if ((e.ctrlKey || e.metaKey) && e.key === 'k') { @@ -544,6 +645,18 @@ function AppShell() { return ( <>
+ {isMobile && ( + + )}
Metapi Metapi @@ -558,20 +671,19 @@ function AppShell() {
-
- {userMenuPresence.shouldRender && (
- + + + ) : ( + + )}
@@ -704,13 +861,16 @@ function AppShell() { } /> } /> + } /> } /> + } /> } /> } /> } /> } /> } /> } /> + } /> } /> } /> } /> @@ -741,6 +901,7 @@ export default function App() { + ); diff --git a/src/web/api.test.ts b/src/web/api.test.ts new file mode 100644 index 00000000..8ae56c9f --- /dev/null +++ b/src/web/api.test.ts @@ -0,0 +1,157 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { api, type ProxyTestRequestEnvelope } from './api.js'; +import { persistAuthSession } from './authSession.js'; + +function createMemoryStorage() { + const store = new Map(); + return { + getItem(key: string) { + return store.has(key) ? store.get(key)! : null; + }, + setItem(key: string, value: string) { + store.set(key, value); + }, + removeItem(key: string) { + store.delete(key); + }, + }; +} + +function installPendingFetch() { + const fetchMock = vi.fn((_input: RequestInfo | URL, init?: RequestInit) => new Promise((_resolve, reject) => { + const signal = init?.signal; + if (!signal) return; + if (signal.aborted) { + reject(new DOMException('Aborted', 'AbortError')); + return; + } + signal.addEventListener('abort', () => reject(new DOMException('Aborted', 'AbortError')), { once: true }); + })); + + vi.stubGlobal('fetch', fetchMock); + return fetchMock; +} + +describe('api proxy test timeout handling', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.stubGlobal('localStorage', createMemoryStorage()); + persistAuthSession(globalThis.localStorage as Storage, 'token-1'); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it('keeps image generation proxy tests alive past the default 30 second timeout', async () => { + installPendingFetch(); + + const payload: ProxyTestRequestEnvelope = { + method: 'POST', + path: '/v1/images/generations', + requestKind: 'json', + jsonBody: { + model: 'gemini-imagen', + prompt: 'banana cat', + }, + }; + + let settled = false; + const promise = api.proxyTest(payload); + const handled = promise + .then(() => ({ ok: true as const })) + .catch((error: Error) => ({ ok: false as const, error })) + .finally(() => { + settled = true; + }); + + await vi.advanceTimersByTimeAsync(30_000); + expect(settled).toBe(false); + + await vi.advanceTimersByTimeAsync(120_000); + const result = await handled; + expect(result.ok).toBe(false); + if (result.ok) { + throw new Error('Expected image generation proxy test to time out'); + } + expect(result.error.message).toBe('请求超时(150s)'); + }); + + it('still uses the default 30 second timeout for generic proxy tests', async () => { + installPendingFetch(); + + const payload: ProxyTestRequestEnvelope = { + method: 'POST', + path: '/v1/embeddings', + requestKind: 'json', + jsonBody: { + model: 'text-embedding-3-small', + input: 'hello', + }, + }; + + const promise = api.proxyTest(payload).catch((error: Error) => error); + + await vi.advanceTimersByTimeAsync(30_000); + await expect(promise).resolves.toMatchObject({ message: '请求超时(30s)' }); + }); + + it('times out replay hydration file-content fetches after 30 seconds', async () => { + installPendingFetch(); + + const getProxyFileContentDataUrl = (api as Record).getProxyFileContentDataUrl; + let settled = false; + const handled = getProxyFileContentDataUrl?.('file-metapi-123') + .then(() => ({ ok: true as const })) + .catch((error: Error) => ({ ok: false as const, error })) + .finally(() => { + settled = true; + }); + + await vi.advanceTimersByTimeAsync(30_000); + expect(settled).toBe(true); + + const result = await handled; + expect(result.ok).toBe(false); + if (result.ok) { + throw new Error('Expected replay hydration file-content fetch to time out'); + } + expect(result.error.message).toBe('请求超时(30s)'); + }); + + it('loads proxy file content as a data URL for replay hydration', async () => { + const fetchMock = vi.fn().mockResolvedValue(new Response( + new Blob([Buffer.from('PDF')], { type: 'application/pdf' }), + { + status: 200, + headers: { + 'content-type': 'application/pdf', + 'content-disposition': 'inline; filename="brief.pdf"', + }, + }, + )); + vi.stubGlobal('fetch', fetchMock); + + const getProxyFileContentDataUrl = (api as Record).getProxyFileContentDataUrl; + const result = await getProxyFileContentDataUrl?.('file-metapi-123'); + + expect(fetchMock).toHaveBeenCalledWith('/v1/files/file-metapi-123/content', expect.objectContaining({ + method: 'GET', + headers: expect.objectContaining({ + Authorization: 'Bearer token-1', + }), + })); + expect(result).toEqual({ + filename: 'brief.pdf', + mimeType: 'application/pdf', + data: 'data:application/pdf;base64,UERG', + }); + }); + + it('reuses the same proxy test implementations for legacy aliases', () => { + expect(api.proxyTest).toBe(api.testProxy); + expect(api.proxyTestStream).toBe(api.testProxyStream); + }); +}); diff --git a/src/web/api.ts b/src/web/api.ts index 7663238c..3403cadc 100644 --- a/src/web/api.ts +++ b/src/web/api.ts @@ -4,13 +4,80 @@ type RequestOptions = RequestInit & { timeoutMs?: number; }; -async function request(url: string, options: RequestOptions = {}) { +function requireAuthToken(): string { + const token = getAuthToken(localStorage); + if (!token) { + const hadToken = !!localStorage.getItem('auth_token'); + clearAuthSession(localStorage); + if (hadToken && typeof window !== 'undefined' && typeof window.location?.reload === 'function') { + window.location.reload(); + } + throw new Error('Session expired'); + } + return token; +} + +async function extractResponseErrorMessage(res: Response): Promise { + let message = `HTTP ${res.status}`; + try { + const text = await res.text(); + if (text) { + try { + const json = JSON.parse(text); + if (json?.message && typeof json.message === 'string') { + message = json.message; + } else if (json?.error && typeof json.error === 'string') { + message = json.error; + } else if (json?.error?.message && typeof json.error.message === 'string') { + message = json.error.message; + } else { + message = `${message}: ${text.slice(0, 120)}`; + } + } catch { + message = `${message}: ${text.slice(0, 120)}`; + } + } + } catch { } + return message; +} + +function parseContentDispositionFilename(headerValue: string | null): string | null { + if (!headerValue) return null; + const utf8Match = /filename\*=UTF-8''([^;]+)/i.exec(headerValue); + if (utf8Match?.[1]) { + try { + return decodeURIComponent(utf8Match[1]); + } catch { + return utf8Match[1]; + } + } + const quotedMatch = /filename="([^"]+)"/i.exec(headerValue); + if (quotedMatch?.[1]) return quotedMatch[1]; + const bareMatch = /filename=([^;]+)/i.exec(headerValue); + return bareMatch?.[1]?.trim() || null; +} + +function arrayBufferToBase64(buffer: ArrayBuffer): string { + if (typeof Buffer !== 'undefined') { + return Buffer.from(buffer).toString('base64'); + } + + let binary = ''; + const bytes = new Uint8Array(buffer); + const chunkSize = 0x8000; + for (let index = 0; index < bytes.length; index += chunkSize) { + binary += String.fromCharCode(...bytes.subarray(index, index + chunkSize)); + } + return btoa(binary); +} + +async function fetchAuthenticatedResponse(url: string, options: RequestOptions = {}): Promise { const { timeoutMs = 30_000, signal: externalSignal, ...fetchOptions } = options; const controller = new AbortController(); let timeoutHandle: ReturnType | null = setTimeout(() => { controller.abort(); }, timeoutMs); - let cleanupExternalSignal = () => {}; + let cleanupExternalSignal = () => { }; if (externalSignal) { if (externalSignal.aborted) { @@ -22,11 +89,7 @@ async function request(url: string, options: RequestOptions = {}) { } } - const token = getAuthToken(localStorage); - if (!token) { - clearAuthSession(localStorage); - throw new Error('Session expired'); - } + const token = requireAuthToken(); const headers: Record = { 'Authorization': `Bearer ${token}`, }; @@ -44,31 +107,12 @@ async function request(url: string, options: RequestOptions = {}) { if (res.status === 401 || res.status === 403) { const hadToken = !!getAuthToken(localStorage); clearAuthSession(localStorage); - if (hadToken) window.location.reload(); + if (hadToken && typeof window !== 'undefined' && typeof window.location?.reload === 'function') { + window.location.reload(); + } throw new Error('Session expired'); } - if (!res.ok) { - let message = `HTTP ${res.status}`; - try { - const text = await res.text(); - if (text) { - try { - const json = JSON.parse(text); - if (json?.message && typeof json.message === 'string') { - message = json.message; - } else if (json?.error && typeof json.error === 'string') { - message = json.error; - } else { - message = `${message}: ${text.slice(0, 120)}`; - } - } catch { - message = `${message}: ${text.slice(0, 120)}`; - } - } - } catch {} - throw new Error(message); - } - return res.json(); + return res; } catch (error: any) { if (error?.name === 'AbortError') { if (externalSignal?.aborted) throw error; @@ -84,6 +128,14 @@ async function request(url: string, options: RequestOptions = {}) { } } +async function request(url: string, options: RequestOptions = {}) { + const res = await fetchAuthenticatedResponse(url, options); + if (!res.ok) { + throw new Error(await extractResponseErrorMessage(res)); + } + return res.json(); +} + function buildQueryString(params?: Record) { if (!params) return ''; const searchParams = new URLSearchParams(); @@ -131,6 +183,47 @@ export type ProxyTestRequestEnvelope = { multipartFiles?: ProxyTestMultipartFile[]; }; +const DEFAULT_PROXY_TEST_TIMEOUT_MS = 30_000; +const LONG_RUNNING_PROXY_TEST_TIMEOUT_MS = 150_000; + +function resolveProxyTestTimeoutMs(data: ProxyTestRequestEnvelope) { + if (data.jobMode) return LONG_RUNNING_PROXY_TEST_TIMEOUT_MS; + if (data.path === '/v1/images/generations') return LONG_RUNNING_PROXY_TEST_TIMEOUT_MS; + if (data.path === '/v1/images/edits') return LONG_RUNNING_PROXY_TEST_TIMEOUT_MS; + if (data.path === '/v1/videos' && data.method === 'POST') return LONG_RUNNING_PROXY_TEST_TIMEOUT_MS; + return DEFAULT_PROXY_TEST_TIMEOUT_MS; +} + +function proxyTestRequest(data: ProxyTestRequestEnvelope) { + return request('/api/test/proxy', { + method: 'POST', + body: JSON.stringify(data), + timeoutMs: resolveProxyTestTimeoutMs(data), + }); +} + +async function proxyTestStreamRequest(data: ProxyTestRequestEnvelope, signal?: AbortSignal) { + const token = getAuthToken(localStorage); + if (!token) { + clearAuthSession(localStorage); + throw new Error('Session expired'); + } + const response = await fetch('/api/test/proxy/stream', { + method: 'POST', + signal, + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(data), + }); + if (response.status === 401 || response.status === 403) { + clearAuthSession(localStorage); + throw new Error('Session expired'); + } + return response; +} + export type ProxyTestJobResponse = { jobId: string; status: 'pending' | 'succeeded' | 'failed' | 'cancelled'; @@ -141,7 +234,23 @@ export type ProxyTestJobResponse = { expiresAt?: string; }; +export type SystemProxyTestRequest = { + proxyUrl?: string; +}; + +export type SystemProxyTestResponse = { + success: true; + proxyUrl: string; + probeUrl: string; + finalUrl: string; + reachable: true; + ok: boolean; + statusCode: number; + latencyMs: number; +}; + export type ProxyLogStatusFilter = 'all' | 'success' | 'failed'; +export type ProxyLogClientConfidence = 'exact' | 'heuristic' | 'unknown' | null; export type ProxyLogBillingDetails = { quotaType: number; @@ -184,10 +293,19 @@ export type ProxyLogListItem = { totalTokens: number | null; retryCount: number; accountId?: number | null; + siteId?: number | null; username?: string | null; siteName?: string | null; siteUrl?: string | null; errorMessage?: string | null; + downstreamKeyId?: number | null; + downstreamKeyName?: string | null; + downstreamKeyGroupName?: string | null; + downstreamKeyTags?: string[]; + clientFamily?: string | null; + clientAppId?: string | null; + clientAppName?: string | null; + clientConfidence?: ProxyLogClientConfidence; promptTokens?: number | null; completionTokens?: number | null; estimatedCost?: number | null; @@ -213,6 +331,15 @@ export type ProxyLogsQuery = { offset?: number; status?: ProxyLogStatusFilter; search?: string; + client?: string; + siteId?: number; + from?: string; + to?: string; +}; + +export type ProxyLogClientOption = { + value: string; + label: string; }; export type ProxyLogsResponse = { @@ -220,9 +347,100 @@ export type ProxyLogsResponse = { total: number; page: number; pageSize: number; + clientOptions: ProxyLogClientOption[]; summary: ProxyLogsSummary; }; +export type OAuthProviderInfo = { + provider: string; + label: string; + platform: string; + enabled: boolean; + loginType: 'oauth'; + requiresProjectId: boolean; + supportsDirectAccountRouting: boolean; + supportsCloudValidation: boolean; + supportsNativeProxy: boolean; +}; + +export type OAuthStartInstructions = { + redirectUri: string; + callbackPort: number; + callbackPath: string; + manualCallbackDelayMs: number; + sshTunnelCommand?: string; + sshTunnelKeyCommand?: string; +}; + +export type OAuthStartResponse = { + provider: string; + state: string; + authorizationUrl: string; + instructions: OAuthStartInstructions; +}; + +export type OAuthSessionInfo = { + provider: string; + state: string; + status: 'pending' | 'success' | 'error'; + accountId?: number; + siteId?: number; + error?: string; +}; + +export type OAuthQuotaWindowInfo = { + supported: boolean; + limit?: number | null; + used?: number | null; + remaining?: number | null; + resetAt?: string | null; + message?: string | null; +}; + +export type OAuthQuotaInfo = { + status: 'supported' | 'unsupported' | 'error'; + source: 'official' | 'reverse_engineered'; + lastSyncAt?: string | null; + lastError?: string | null; + providerMessage?: string | null; + subscription?: { + planType?: string | null; + activeStart?: string | null; + activeUntil?: string | null; + } | null; + windows: { + fiveHour: OAuthQuotaWindowInfo; + sevenDay: OAuthQuotaWindowInfo; + }; + lastLimitResetAt?: string | null; +}; + +export type OAuthConnectionInfo = { + accountId: number; + siteId: number; + provider: string; + username?: string | null; + email?: string | null; + accountKey?: string | null; + planType?: string | null; + projectId?: string | null; + modelCount: number; + modelsPreview: string[]; + status: 'healthy' | 'abnormal'; + quota?: OAuthQuotaInfo | null; + routeChannelCount?: number; + lastModelSyncAt?: string | null; + lastModelSyncError?: string | null; + site?: { id: number; name: string; url: string; platform: string } | null; +}; + +export type OAuthConnectionsResponse = { + items: OAuthConnectionInfo[]; + total: number; + limit: number; + offset: number; +}; + export const api = { // Sites getSites: () => request('/api/sites'), @@ -231,6 +449,9 @@ export const api = { deleteSite: (id: number) => request(`/api/sites/${id}`, { method: 'DELETE' }), batchUpdateSites: (data: any) => request('/api/sites/batch', { method: 'POST', body: JSON.stringify(data) }), detectSite: (url: string) => request('/api/sites/detect', { method: 'POST', body: JSON.stringify({ url }) }), + getSiteDisabledModels: (siteId: number) => request(`/api/sites/${siteId}/disabled-models`), + updateSiteDisabledModels: (siteId: number, models: string[]) => request(`/api/sites/${siteId}/disabled-models`, { method: 'PUT', body: JSON.stringify({ models }) }), + getSiteAvailableModels: (siteId: number) => request(`/api/sites/${siteId}/available-models`), // Accounts getAccounts: () => request('/api/accounts'), @@ -243,6 +464,8 @@ export const api = { deleteAccount: (id: number) => request(`/api/accounts/${id}`, { method: 'DELETE' }), batchUpdateAccounts: (data: any) => request('/api/accounts/batch', { method: 'POST', body: JSON.stringify(data) }), refreshBalance: (id: number) => request(`/api/accounts/${id}/balance`, { method: 'POST' }), + getAccountModels: (id: number) => request(`/api/accounts/${id}/models`), + addAccountAvailableModels: (accountId: number, models: string[]) => request(`/api/accounts/${accountId}/models/manual`, { method: 'POST', body: JSON.stringify({ models }) }), refreshAccountHealth: (data?: { accountId?: number; wait?: boolean }) => request('/api/accounts/health/refresh', { method: 'POST', body: JSON.stringify(data || {}), @@ -273,9 +496,16 @@ export const api = { // Routes getRoutes: () => request('/api/routes'), + getRoutesLite: () => request('/api/routes/lite'), + getRoutesSummary: () => request('/api/routes/summary'), + getRouteChannels: (routeId: number) => request(`/api/routes/${routeId}/channels`), + batchAddChannels: (routeId: number, channels: Array<{ accountId: number; tokenId?: number; sourceModel?: string }>) => + request(`/api/routes/${routeId}/channels/batch`, { method: 'POST', body: JSON.stringify({ channels }) }), addRoute: (data: any) => request('/api/routes', { method: 'POST', body: JSON.stringify(data) }), updateRoute: (id: number, data: any) => request(`/api/routes/${id}`, { method: 'PUT', body: JSON.stringify(data) }), deleteRoute: (id: number) => request(`/api/routes/${id}`, { method: 'DELETE' }), + batchUpdateRoutes: (data: { ids: number[]; action: 'enable' | 'disable' }) => + request('/api/routes/batch', { method: 'POST', body: JSON.stringify(data) }), addChannel: (routeId: number, data: any) => request(`/api/routes/${routeId}/channels`, { method: 'POST', body: JSON.stringify(data) }), updateChannel: (id: number, data: any) => request(`/api/channels/${id}`, { method: 'PUT', body: JSON.stringify(data) }), batchUpdateChannels: (updates: Array<{ id: number; priority: number }>) => @@ -325,12 +555,45 @@ export const api = { // Search search: (query: string) => request('/api/search', { method: 'POST', body: JSON.stringify({ query, limit: 20 }) }), + // OAuth + getOAuthProviders: () => request('/api/oauth/providers') as Promise<{ providers: OAuthProviderInfo[] }>, + startOAuthProvider: (provider: string, data?: { accountId?: number; projectId?: string }) => request(`/api/oauth/providers/${encodeURIComponent(provider)}/start`, { + method: 'POST', + body: JSON.stringify(data || {}), + }) as Promise, + getOAuthSession: (state: string) => request(`/api/oauth/sessions/${encodeURIComponent(state)}`) as Promise, + submitOAuthManualCallback: (state: string, callbackUrl: string) => request(`/api/oauth/sessions/${encodeURIComponent(state)}/manual-callback`, { + method: 'POST', + body: JSON.stringify({ callbackUrl }), + }) as Promise<{ success: true }>, + getOAuthConnections: (params?: { limit?: number; offset?: number }) => + request(`/api/oauth/connections${buildQueryString(params)}`) as Promise, + refreshOAuthConnectionQuota: (accountId: number) => request(`/api/oauth/connections/${accountId}/quota/refresh`, { + method: 'POST', + body: JSON.stringify({}), + }) as Promise<{ success: true; quota: OAuthQuotaInfo }>, + rebindOAuthConnection: (accountId: number) => request(`/api/oauth/connections/${accountId}/rebind`, { + method: 'POST', + body: JSON.stringify({}), + }) as Promise, + deleteOAuthConnection: (accountId: number) => request(`/api/oauth/connections/${accountId}`, { + method: 'DELETE', + }) as Promise<{ success: true }>, + // Events getEvents: (params?: string) => request(`/api/events${params ? '?' + params : ''}`), getEventCount: () => request('/api/events/count'), markEventRead: (id: number) => request(`/api/events/${id}/read`, { method: 'POST' }), markAllEventsRead: () => request('/api/events/read-all', { method: 'POST' }), clearEvents: () => request('/api/events', { method: 'DELETE' }), + getSiteAnnouncements: (params?: string) => request(`/api/site-announcements${params ? '?' + params : ''}`), + markSiteAnnouncementRead: (id: number) => request(`/api/site-announcements/${id}/read`, { method: 'POST' }), + markAllSiteAnnouncementsRead: () => request('/api/site-announcements/read-all', { method: 'POST' }), + clearSiteAnnouncements: () => request('/api/site-announcements', { method: 'DELETE' }), + syncSiteAnnouncements: (payload?: { siteId?: number }) => request('/api/site-announcements/sync', { + method: 'POST', + body: JSON.stringify(payload || {}), + }), getTasks: (limit = 50) => request(`/api/tasks?limit=${Math.max(1, Math.min(200, Math.trunc(limit)))}`), getTask: (id: string) => request(`/api/tasks/${encodeURIComponent(id)}`), @@ -340,10 +603,16 @@ export const api = { method: 'POST', body: JSON.stringify({ oldToken, newToken }), }), getRuntimeSettings: () => request('/api/settings/runtime'), + getBrandList: () => request('/api/settings/brand-list'), updateRuntimeSettings: (data: any) => request('/api/settings/runtime', { method: 'PUT', body: JSON.stringify(data), }), + testSystemProxy: (data: SystemProxyTestRequest) => request('/api/settings/system-proxy/test', { + method: 'POST', + body: JSON.stringify(data), + timeoutMs: 20_000, + }), getRuntimeDatabaseConfig: () => request('/api/settings/database/runtime'), updateRuntimeDatabaseConfig: (data: { dialect: 'sqlite' | 'mysql' | 'postgres'; connectionString: string; ssl?: boolean }) => request('/api/settings/database/runtime', { @@ -373,9 +642,26 @@ export const api = { deleteDownstreamApiKey: (id: number) => request(`/api/downstream-keys/${id}`, { method: 'DELETE', }), + batchDownstreamApiKeys: (data: { + ids: number[]; + action: 'enable' | 'disable' | 'delete' | 'resetUsage' | 'updateMetadata'; + groupOperation?: 'keep' | 'set' | 'clear'; + groupName?: string; + tagOperation?: 'keep' | 'append'; + tags?: string[]; + }) => + request('/api/downstream-keys/batch', { + method: 'POST', + body: JSON.stringify(data), + }), resetDownstreamApiKeyUsage: (id: number) => request(`/api/downstream-keys/${id}/reset-usage`, { method: 'POST', }), + getDownstreamApiKeysSummary: (params?: { range?: '24h' | '7d' | 'all'; status?: 'all' | 'enabled' | 'disabled'; search?: string }) => + request(`/api/downstream-keys/summary${buildQueryString(params)}`), + getDownstreamApiKeyOverview: (id: number) => request(`/api/downstream-keys/${id}/overview`), + getDownstreamApiKeyTrend: (id: number, params?: { range?: '24h' | '7d' | 'all' }) => + request(`/api/downstream-keys/${id}/trend${buildQueryString(params)}`), exportBackup: (type: 'all' | 'accounts' | 'preferences' = 'all') => request(`/api/settings/backup/export?type=${encodeURIComponent(type)}`), importBackup: (data: any) => @@ -383,6 +669,33 @@ export const api = { method: 'POST', body: JSON.stringify({ data }), }), + getBackupWebdavConfig: () => request('/api/settings/backup/webdav'), + saveBackupWebdavConfig: (data: { + enabled: boolean; + fileUrl: string; + username: string; + password?: string; + clearPassword?: boolean; + exportType: 'all' | 'accounts' | 'preferences'; + autoSyncEnabled: boolean; + autoSyncCron: string; + }) => + request('/api/settings/backup/webdav', { + method: 'PUT', + body: JSON.stringify(data), + }), + exportBackupToWebdav: (type?: 'all' | 'accounts' | 'preferences') => + request('/api/settings/backup/webdav/export', { + method: 'POST', + body: JSON.stringify(type ? { type } : {}), + timeoutMs: 60_000, + }), + importBackupFromWebdav: () => + request('/api/settings/backup/webdav/import', { + method: 'POST', + body: JSON.stringify({}), + timeoutMs: 60_000, + }), clearRuntimeCache: () => request('/api/settings/maintenance/clear-cache', { method: 'POST' }), clearUsageData: () => request('/api/settings/maintenance/clear-usage', { method: 'POST' }), factoryReset: () => request('/api/settings/maintenance/factory-reset', { method: 'POST' }), @@ -412,47 +725,42 @@ export const api = { getTestChatJob: (jobId: string) => request(`/api/test/chat/jobs/${encodeURIComponent(jobId)}`), deleteTestChatJob: (jobId: string) => request(`/api/test/chat/jobs/${encodeURIComponent(jobId)}`, { method: 'DELETE' }), startProxyTestJob: (data: ProxyTestRequestEnvelope) => - request('/api/test/proxy/jobs', { method: 'POST', body: JSON.stringify(data) }), - getProxyTestJob: (jobId: string) => request(`/api/test/proxy/jobs/${encodeURIComponent(jobId)}`), - deleteProxyTestJob: (jobId: string) => request(`/api/test/proxy/jobs/${encodeURIComponent(jobId)}`, { method: 'DELETE' }), - testProxy: (data: ProxyTestRequestEnvelope) => - request('/api/test/proxy', { method: 'POST', body: JSON.stringify(data) }), - proxyTest: (data: ProxyTestRequestEnvelope) => - request('/api/test/proxy', { method: 'POST', body: JSON.stringify(data) }), - testChat: (data: TestChatRequestPayload) => - request('/api/test/chat', { method: 'POST', body: JSON.stringify(data) }), - testProxyStream: async (data: ProxyTestRequestEnvelope, signal?: AbortSignal) => { - const token = getAuthToken(localStorage); - if (!token) { - clearAuthSession(localStorage); - throw new Error('Session expired'); - } - return fetch('/api/test/proxy/stream', { + request('/api/test/proxy/jobs', { method: 'POST', - signal, - headers: { - 'Authorization': `Bearer ${token}`, - 'Content-Type': 'application/json', - }, body: JSON.stringify(data), + timeoutMs: resolveProxyTestTimeoutMs(data), + }), + getProxyTestJob: (jobId: string) => request(`/api/test/proxy/jobs/${encodeURIComponent(jobId)}`), + deleteProxyTestJob: (jobId: string) => request(`/api/test/proxy/jobs/${encodeURIComponent(jobId)}`, { method: 'DELETE' }), + getProxyFileContentDataUrl: async ( + fileId: string, + options: Pick = {}, + ) => { + const response = await fetchAuthenticatedResponse(`/v1/files/${encodeURIComponent(fileId)}/content`, { + method: 'GET', + ...options, }); - }, - proxyTestStream: async (data: ProxyTestRequestEnvelope, signal?: AbortSignal) => { - const token = getAuthToken(localStorage); - if (!token) { - clearAuthSession(localStorage); - throw new Error('Session expired'); + if (!response.ok) { + throw new Error(await extractResponseErrorMessage(response)); } - return fetch('/api/test/proxy/stream', { - method: 'POST', - signal, - headers: { - 'Authorization': `Bearer ${token}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify(data), - }); + + const mimeType = (response.headers.get('content-type') || 'application/octet-stream') + .split(';')[0] + .trim() || 'application/octet-stream'; + const filename = parseContentDispositionFilename(response.headers.get('content-disposition')); + const base64 = arrayBufferToBase64(await response.arrayBuffer()); + return { + filename, + mimeType, + data: `data:${mimeType};base64,${base64}`, + }; }, + testProxy: proxyTestRequest, + proxyTest: proxyTestRequest, + testChat: (data: TestChatRequestPayload) => + request('/api/test/chat', { method: 'POST', body: JSON.stringify(data) }), + testProxyStream: proxyTestStreamRequest, + proxyTestStream: proxyTestStreamRequest, testChatStream: async (data: TestChatRequestPayload, signal?: AbortSignal) => { const token = getAuthToken(localStorage); if (!token) { diff --git a/src/web/components/BrandIcon.architecture.test.ts b/src/web/components/BrandIcon.architecture.test.ts new file mode 100644 index 00000000..2aae7907 --- /dev/null +++ b/src/web/components/BrandIcon.architecture.test.ts @@ -0,0 +1,16 @@ +import { readFileSync } from 'node:fs'; +import { describe, expect, it } from 'vitest'; + +function readSource(relativePath: string): string { + return readFileSync(new URL(relativePath, import.meta.url), 'utf8'); +} + +describe('BrandIcon architecture boundaries', () => { + it('keeps brand registry rules outside the React render component', () => { + const source = readSource('./BrandIcon.tsx'); + + expect(source).toContain("from './brandRegistry.js'"); + expect(source).not.toContain('const BRAND_DEFINITIONS'); + expect(source).not.toContain('function getBrand('); + }); +}); diff --git a/src/web/components/BrandIcon.tsx b/src/web/components/BrandIcon.tsx index 73f00022..22aee129 100644 --- a/src/web/components/BrandIcon.tsx +++ b/src/web/components/BrandIcon.tsx @@ -1,32 +1,25 @@ import React, { useEffect, useState, type CSSProperties } from 'react'; +import { + avatarLetters, + getBrand, + getBrandIconUrl, + hashColor, + normalizeBrandIconKey, + type BrandInfo, +} from './brandRegistry.js'; + +export type { BrandInfo } from './brandRegistry.js'; +export { + getBrand, + getBrandIconUrl, + hashColor, + normalizeBrandIconKey, +} from './brandRegistry.js'; const BRAND_ICON_VERSION = '1.83.0'; const ICON_CDN = `https://registry.npmmirror.com/@lobehub/icons-static-png/${BRAND_ICON_VERSION}/files/dark`; const ICON_CDN_LIGHT = `https://registry.npmmirror.com/@lobehub/icons-static-png/${BRAND_ICON_VERSION}/files/light`; -const LEGACY_ICON_ALIASES: Record = { - 'anthropic': 'claude-color', - 'claude.color': 'claude-color', - 'cohere.color': 'cohere-color', - 'doubao.color': 'doubao-color', - 'gemini.color': 'gemini-color', - 'hunyuan.color': 'hunyuan-color', - 'meta': 'meta-color', - 'meta-brand-color': 'meta-color', - 'minimax.color': 'minimax-color', - 'qwen.color': 'qwen-color', - 'spark.color': 'spark-color', - 'stability': 'stability-color', - 'stability-brand-color': 'stability-color', - 'stepfun': 'stepfun-color', - 'wenxin.color': 'wenxin-color', - 'xai': 'xai', - 'yi.color': 'yi-color', - 'zhipu.color': 'zhipu-color', - 'azure': 'microsoft-color', - 'bytedance-brand-color': 'bytedance-color', -}; - export function useIconCdn() { const [isDark, setIsDark] = useState(() => { if (typeof document === 'undefined') return false; @@ -43,372 +36,6 @@ export function useIconCdn() { return isDark ? ICON_CDN : ICON_CDN_LIGHT; } -export interface BrandMatchContext { - raw: string; - cleaned: string; - segments: string[]; -} - -interface InternalBrandMatchContext extends BrandMatchContext { - candidates: string[]; -} - -export interface BrandInfo { - name: string; - icon: string; - color: string; -} - -type BrandDefinition = BrandInfo & { - match: (context: InternalBrandMatchContext) => boolean; -}; - -function normalizeInput(value: string): string { - return String(value || '').trim().toLowerCase(); -} - -function stripCommonWrappers(value: string): string { - return value - .replace(/^(?:\[[^\]]+\]|【[^】]+】)\s*/g, '') - .replace(/^re:\s*/g, '') - .replace(/^\^+/, '') - .replace(/\$+$/, '') - .trim(); -} - -function collectBrandCandidates(modelName: string): string[] { - const queue: string[] = []; - const seen = new Set(); - const push = (value: string) => { - const normalized = normalizeInput(value); - if (!normalized || seen.has(normalized)) return; - seen.add(normalized); - queue.push(normalized); - }; - - push(modelName); - - for (let index = 0; index < queue.length; index += 1) { - const candidate = queue[index]!; - const cleaned = stripCommonWrappers(candidate); - push(cleaned); - - if (cleaned.includes('/')) { - for (const part of cleaned.split('/')) push(part); - } - if (cleaned.includes(':')) { - for (const part of cleaned.split(':')) push(part); - } - if (cleaned.includes(',')) { - for (const part of cleaned.split(',')) push(part); - } - } - - return queue; -} - -function buildMatchContext(modelName: string): InternalBrandMatchContext { - const candidates = collectBrandCandidates(modelName); - const raw = candidates[0] || normalizeInput(modelName); - const cleaned = stripCommonWrappers(raw); - const segments = Array.from(new Set( - candidates - .flatMap((candidate) => candidate.split(/[/:,\s]+/g)) - .map((segment) => segment.trim()) - .filter(Boolean), - )); - - return { - raw, - cleaned, - segments, - candidates, - }; -} - -function includesAny(context: InternalBrandMatchContext, needles: string[]): boolean { - return needles.some((needle) => ( - context.raw.includes(needle) - || context.cleaned.includes(needle) - || context.candidates.some((candidate) => candidate.includes(needle)) - )); -} - -function startsWithAny(context: InternalBrandMatchContext, needles: string[]): boolean { - return needles.some((needle) => ( - context.raw.startsWith(needle) - || context.cleaned.startsWith(needle) - || context.segments.some((segment) => segment.startsWith(needle)) - || context.candidates.some((candidate) => candidate.startsWith(needle)) - )); -} - -function hasExactSegment(context: InternalBrandMatchContext, needles: string[]): boolean { - return needles.some((needle) => context.segments.includes(needle)); -} - -function matchesBoundary(context: InternalBrandMatchContext, pattern: RegExp): boolean { - return pattern.test(context.raw) || pattern.test(context.cleaned) || context.candidates.some((candidate) => pattern.test(candidate)); -} - -const BRAND_DEFINITIONS: BrandDefinition[] = [ - { - name: 'OpenAI', - icon: 'openai', - color: 'linear-gradient(135deg, #10a37f, #1a7f5a)', - match: (context) => ( - startsWithAny(context, ['gpt', 'chatgpt', 'dall-e', 'whisper', 'text-embedding', 'text-moderation', 'davinci', 'babbage', 'codex-mini']) - || startsWithAny(context, ['o1', 'o3', 'o4', 'tts']) - ), - }, - { - name: 'Anthropic', - icon: 'claude-color', - color: 'linear-gradient(135deg, #d4a574, #c4956a)', - match: (context) => includesAny(context, ['claude']), - }, - { - name: 'Google', - icon: 'gemini-color', - color: 'linear-gradient(135deg, #4285f4, #34a853)', - match: (context) => ( - includesAny(context, ['gemini', 'gemma', 'google/', 'palm', 'paligemma', 'shieldgemma', 'recurrentgemma', 'deplot', 'codegemma', 'imagen', 'learnlm', 'aqa']) - || startsWithAny(context, ['veo', 'google/']) - ), - }, - { - name: 'DeepSeek', - icon: 'deepseek-color', - color: 'linear-gradient(135deg, #4d6bfe, #44a3ec)', - match: (context) => includesAny(context, ['deepseek']) || hasExactSegment(context, ['ds-chat']), - }, - { - name: '通义千问', - icon: 'qwen-color', - color: 'linear-gradient(135deg, #615cf7, #9b8afb)', - match: (context) => includesAny(context, ['qwen', 'qwq', 'tongyi']), - }, - { - name: '智谱 AI', - icon: 'zhipu-color', - color: 'linear-gradient(135deg, #3b6cf5, #6366f1)', - match: (context) => includesAny(context, ['glm', 'chatglm', 'codegeex', 'cogview', 'cogvideo']), - }, - { - name: 'Meta', - icon: 'meta-color', - color: 'linear-gradient(135deg, #0668E1, #1877f2)', - match: (context) => includesAny(context, ['llama', 'code-llama', 'codellama']), - }, - { - name: 'Mistral', - icon: 'mistral-color', - color: 'linear-gradient(135deg, #f7d046, #f2a900)', - match: (context) => includesAny(context, ['mistral', 'mixtral', 'codestral', 'pixtral', 'ministral', 'voxtral', 'magistral']), - }, - { - name: 'Moonshot', - icon: 'moonshot', - color: 'linear-gradient(135deg, #000000, #333333)', - match: (context) => includesAny(context, ['moonshot', 'kimi']), - }, - { - name: '零一万物', - icon: 'yi-color', - color: 'linear-gradient(135deg, #1d4ed8, #3b82f6)', - match: (context) => ( - startsWithAny(context, ['yi-']) - || matchesBoundary(context, /(^|[/:_\-\s])yi(?=$|[/:_\-\s])/) - ), - }, - { - name: '文心一言', - icon: 'wenxin-color', - color: 'linear-gradient(135deg, #2932e1, #4468f2)', - match: (context) => includesAny(context, ['ernie', 'eb-']), - }, - { - name: '讯飞星火', - icon: 'spark-color', - color: 'linear-gradient(135deg, #0070f3, #00d4ff)', - match: (context) => includesAny(context, ['spark', 'generalv']), - }, - { - name: '腾讯混元', - icon: 'hunyuan-color', - color: 'linear-gradient(135deg, #00b7ff, #0052d9)', - match: (context) => includesAny(context, ['hunyuan', 'tencent-hunyuan']), - }, - { - name: '豆包', - icon: 'doubao-color', - color: 'linear-gradient(135deg, #3b5bdb, #7048e8)', - match: (context) => includesAny(context, ['doubao']), - }, - { - name: 'MiniMax', - icon: 'minimax-color', - color: 'linear-gradient(135deg, #6366f1, #818cf8)', - match: (context) => includesAny(context, ['minimax', 'abab']) || hasExactSegment(context, ['mini2.1']), - }, - { - name: 'Cohere', - icon: 'cohere-color', - color: 'linear-gradient(135deg, #39594d, #5ba77f)', - match: (context) => includesAny(context, ['command', 'c4ai-']) || startsWithAny(context, ['embed-']), - }, - { - name: 'Microsoft', - icon: 'microsoft-color', - color: 'linear-gradient(135deg, #00bcf2, #0078d4)', - match: (context) => ( - includesAny(context, ['microsoft/', 'phi-', 'kosmos']) - || hasExactSegment(context, ['phi4']) - ), - }, - { - name: 'xAI', - icon: 'xai', - color: 'linear-gradient(135deg, #111, #444)', - match: (context) => includesAny(context, ['grok']), - }, - { - name: '阶跃星辰', - icon: 'stepfun-color', - color: 'linear-gradient(135deg, #0066ff, #3399ff)', - match: (context) => includesAny(context, ['stepfun']) || startsWithAny(context, ['step-', 'step3']), - }, - { - name: 'Stability', - icon: 'stability-color', - color: 'linear-gradient(135deg, #8b5cf6, #a855f7)', - match: (context) => includesAny(context, ['flux', 'stablediffusion', 'stable-diffusion', 'sdxl']) || startsWithAny(context, ['sd3']), - }, - { - name: 'NVIDIA', - icon: 'nvidia-color', - color: 'linear-gradient(135deg, #76b900, #4a8c0b)', - match: (context) => ( - includesAny(context, ['nvidia/', 'nvclip', 'nemotron', 'nemoretriever', 'neva', 'riva-translate', 'cosmos']) - || startsWithAny(context, ['nv-']) - ), - }, - { - name: 'IBM', - icon: 'ibm', - color: 'linear-gradient(135deg, #0f62fe, #4589ff)', - match: (context) => includesAny(context, ['ibm/', 'granite']), - }, - { - name: 'BAAI', - icon: 'baai', - color: 'linear-gradient(135deg, #111827, #374151)', - match: (context) => includesAny(context, ['baai/', 'bge-']), - }, - { - name: 'ByteDance', - icon: 'bytedance-color', - color: 'linear-gradient(135deg, #325ab4, #0f66ff)', - match: (context) => includesAny(context, ['bytedance', 'seed-oss', 'kolors', 'kwai', 'kwaipilot']) || startsWithAny(context, ['wan-', 'kat-']), - }, - { - name: 'InternLM', - icon: 'internlm-color', - color: 'linear-gradient(135deg, #1b3882, #4063c5)', - match: (context) => includesAny(context, ['internlm']), - }, - { - name: 'Midjourney', - icon: 'midjourney', - color: 'linear-gradient(135deg, #4c6ef5, #748ffc)', - match: (context) => includesAny(context, ['midjourney']) || startsWithAny(context, ['mj_']), - }, - { - name: 'DeepL', - icon: 'deepl-color', - color: 'linear-gradient(135deg, #0f2b46, #21476f)', - match: (context) => startsWithAny(context, ['deepl-']) || includesAny(context, ['deepl/']), - }, - { - name: 'Jina AI', - icon: 'jina', - color: 'linear-gradient(135deg, #111827, #4b5563)', - match: (context) => includesAny(context, ['jina']), - }, -]; - -function escapeRegExp(value: string): string { - return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); -} - -const BRAND_FALLBACK_BOUNDARY_RULES = BRAND_DEFINITIONS.map((brand) => ({ - brand, - boundaryRegex: new RegExp(`(^|[^a-z0-9])${escapeRegExp(brand.name.toLowerCase())}(?=$|[^a-z0-9])`), -})); - -export function normalizeBrandIconKey(icon: string | null | undefined): string | null { - const normalized = normalizeInput(icon).replace(/\./g, '-'); - if (!normalized) return null; - return LEGACY_ICON_ALIASES[normalized] || normalized; -} - -export function getBrandIconUrl(icon: string | null | undefined, cdn: string): string | null { - const normalized = normalizeBrandIconKey(icon); - if (!normalized) return null; - return `${cdn}/${normalized}.png`; -} - -export function getBrand(modelName: string): BrandInfo | null { - const context = buildMatchContext(modelName); - - for (const definition of BRAND_DEFINITIONS) { - if (definition.match(context)) { - return { - name: definition.name, - icon: definition.icon, - color: definition.color, - }; - } - } - - for (const candidate of context.candidates) { - for (const rule of BRAND_FALLBACK_BOUNDARY_RULES) { - if (rule.boundaryRegex.test(candidate)) { - return { - name: rule.brand.name, - icon: rule.brand.icon, - color: rule.brand.color, - }; - } - } - } - - return null; -} - -const FALLBACK_COLORS = [ - 'linear-gradient(135deg, #4f46e5, #818cf8)', - 'linear-gradient(135deg, #059669, #34d399)', - 'linear-gradient(135deg, #2563eb, #60a5fa)', - 'linear-gradient(135deg, #d946ef, #f0abfc)', - 'linear-gradient(135deg, #ea580c, #fb923c)', - 'linear-gradient(135deg, #0891b2, #22d3ee)', - 'linear-gradient(135deg, #7c3aed, #a78bfa)', - 'linear-gradient(135deg, #dc2626, #f87171)', -]; - -export function hashColor(name: string): string { - let h = 0; - for (let i = 0; i < name.length; i += 1) h = (h * 31 + name.charCodeAt(i)) | 0; - return FALLBACK_COLORS[Math.abs(h) % FALLBACK_COLORS.length]; -} - -function avatarLetters(name: string): string { - const parts = name.replace(/[-_/.]/g, ' ').trim().split(/\s+/).filter(Boolean); - if (parts.length >= 2) return (parts[0]![0] + parts[1]![0]).toUpperCase(); - return name.slice(0, 2).toUpperCase(); -} - type BrandGlyphProps = { brand?: Pick | null; model?: string | null; diff --git a/src/web/components/CenteredModal.tsx b/src/web/components/CenteredModal.tsx index a0b2c268..f8dffb30 100644 --- a/src/web/components/CenteredModal.tsx +++ b/src/web/components/CenteredModal.tsx @@ -10,6 +10,9 @@ type CenteredModalProps = { footer?: React.ReactNode; maxWidth?: number; bodyStyle?: React.CSSProperties; + closeOnBackdrop?: boolean; + closeOnEscape?: boolean; + showCloseButton?: boolean; }; export default function CenteredModal({ @@ -20,6 +23,9 @@ export default function CenteredModal({ footer, maxWidth = 860, bodyStyle, + closeOnBackdrop = false, + closeOnEscape = false, + showCloseButton = true, }: CenteredModalProps) { const presence = useAnimatedVisibility(open, 220); @@ -33,7 +39,7 @@ export default function CenteredModal({ }, [open]); useEffect(() => { - if (!open || typeof document === 'undefined') return; + if (!open || !closeOnEscape || typeof document === 'undefined') return; const handleKeydown = (event: KeyboardEvent) => { if (event.key === 'Escape') onClose(); }; @@ -41,21 +47,33 @@ export default function CenteredModal({ return () => { document.removeEventListener('keydown', handleKeydown); }; - }, [open, onClose]); + }, [closeOnEscape, open, onClose]); if (!presence.shouldRender) return null; const modal = (
event.stopPropagation()} > -
{title}
+
+
{title}
+ {showCloseButton ? ( + + ) : null} +
{children}
diff --git a/src/web/components/DeleteConfirmModal.tsx b/src/web/components/DeleteConfirmModal.tsx new file mode 100644 index 00000000..5c9f0d42 --- /dev/null +++ b/src/web/components/DeleteConfirmModal.tsx @@ -0,0 +1,49 @@ +import React from 'react'; +import CenteredModal from './CenteredModal.js'; + +type DeleteConfirmModalProps = { + open: boolean; + title?: string; + description: React.ReactNode; + confirmText?: string; + cancelText?: string; + loading?: boolean; + onConfirm: () => void; + onClose: () => void; +}; + +export default function DeleteConfirmModal({ + open, + title = '确认删除', + description, + confirmText = '确认删除', + cancelText = '取消', + loading = false, + onConfirm, + onClose, +}: DeleteConfirmModalProps) { + return ( + + + + + )} + > +
+
此操作不可撤销
+
{description}
+
+
+ ); +} diff --git a/src/web/components/MobileBatchBar.tsx b/src/web/components/MobileBatchBar.tsx new file mode 100644 index 00000000..6327b8e5 --- /dev/null +++ b/src/web/components/MobileBatchBar.tsx @@ -0,0 +1,15 @@ +import React from 'react'; + +type MobileBatchBarProps = { + info: React.ReactNode; + children: React.ReactNode; +}; + +export default function MobileBatchBar({ info, children }: MobileBatchBarProps) { + return ( +
+ {info} +
{children}
+
+ ); +} diff --git a/src/web/components/MobileCard.tsx b/src/web/components/MobileCard.tsx new file mode 100644 index 00000000..592adea6 --- /dev/null +++ b/src/web/components/MobileCard.tsx @@ -0,0 +1,57 @@ +import React from 'react'; + +type MobileCardProps = { + title: React.ReactNode; + subtitle?: React.ReactNode; + actions?: React.ReactNode; + headerActions?: React.ReactNode; + footerActions?: React.ReactNode; + compact?: boolean; + className?: string; + bodyClassName?: string; + children: React.ReactNode; +}; + +type MobileFieldProps = { + label: React.ReactNode; + value: React.ReactNode; + stacked?: boolean; +}; + +export function MobileCard({ + title, + subtitle, + actions, + headerActions, + footerActions, + compact = false, + className = '', + bodyClassName = '', + children, +}: MobileCardProps) { + const resolvedHeaderActions = headerActions ?? actions; + const cardClassName = ['mobile-card', compact ? 'is-compact' : '', className].filter(Boolean).join(' '); + const cardBodyClassName = ['mobile-card-body', bodyClassName].filter(Boolean).join(' '); + return ( +
+
+
+
{title}
+ {subtitle ?
{subtitle}
: null} +
+ {resolvedHeaderActions ?
{resolvedHeaderActions}
: null} +
+
{children}
+ {footerActions ?
{footerActions}
: null} +
+ ); +} + +export function MobileField({ label, value, stacked = false }: MobileFieldProps) { + return ( +
+
{label}
+
{value}
+
+ ); +} diff --git a/src/web/components/MobileDrawer.tsx b/src/web/components/MobileDrawer.tsx new file mode 100644 index 00000000..6816ff05 --- /dev/null +++ b/src/web/components/MobileDrawer.tsx @@ -0,0 +1,97 @@ +import React, { useEffect, useId, useMemo, useState, useCallback } from 'react'; +import { createPortal } from 'react-dom'; + +type MobileDrawerProps = { + open: boolean; + onClose: () => void; + children: React.ReactNode; + title?: React.ReactNode; + closeLabel?: string; + side?: 'left' | 'right'; +}; + +function MobileDrawer({ + open, + onClose, + children, + title, + closeLabel = '关闭导航', + side = 'left', +}: MobileDrawerProps) { + const [shouldRender, setShouldRender] = useState(open); + const [isClosing, setIsClosing] = useState(false); + const titleId = useId(); + const labelledBy = title ? titleId : undefined; + + useEffect(() => { + if (open) { + setShouldRender(true); + setIsClosing(false); + } else if (shouldRender) { + setIsClosing(true); + const timer = setTimeout(() => { + setShouldRender(false); + setIsClosing(false); + }, 280); + return () => clearTimeout(timer); + } + }, [open]); + + const handleClose = useCallback(() => { + setIsClosing(true); + onClose(); + }, [onClose]); + + useEffect(() => { + if (!open || typeof document === 'undefined') return; + const previousOverflow = document.body.style.overflow; + document.body.style.overflow = 'hidden'; + return () => { + document.body.style.overflow = previousOverflow; + }; + }, [open]); + + useEffect(() => { + if (!open || typeof document === 'undefined') return; + const handleKeydown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + handleClose(); + } + }; + document.addEventListener('keydown', handleKeydown); + return () => { + document.removeEventListener('keydown', handleKeydown); + }; + }, [handleClose, open]); + + if (!shouldRender) return null; + + const drawer = ( +
+