From f6921b7b23ae0190054dab6172808391e9dc9ad2 Mon Sep 17 00:00:00 2001 From: m1saka Date: Fri, 23 Oct 2026 18:35:44 +0800 Subject: [PATCH] chore: initial commit --- .dockerignore | 10 + .gitignore | 12 + Dockerfile | 23 + Makefile | 9 + README.md | 149 ++ compose.yaml | 35 + config.docker.example.yaml | 18 + config.example.yaml | 16 + go.mod | 20 + go.sum | 54 + internal/config/config.go | 76 + internal/config/config_test.go | 66 + internal/protocol/README.md | 71 + internal/protocol/anthropic_thinking_test.go | 101 + internal/protocol/chat_reasoning_test.go | 92 + internal/protocol/encode.go | 516 +++++ internal/protocol/protocol.go | 669 ++++++ internal/protocol/protocol_test.go | 267 +++ internal/protocol/reasoning.go | 352 +++ internal/protocol/reasoning_test.go | 136 ++ internal/protocol/stream.go | 879 ++++++++ internal/protocol/stream_limits_test.go | 164 ++ internal/protocol/stream_usage_limits_test.go | 89 + internal/protocol/validation_test.go | 39 + internal/server/admin.go | 265 +++ internal/server/discovery.go | 136 ++ internal/server/discovery_test.go | 273 +++ internal/server/integration_test.go | 443 ++++ internal/server/log_writer.go | 158 ++ internal/server/log_writer_test.go | 248 +++ internal/server/model_probe.go | 229 ++ internal/server/model_probe_test.go | 267 +++ internal/server/performance_test.go | 203 ++ internal/server/proxy.go | 273 +++ internal/server/security.go | 203 ++ internal/server/security_stats_test.go | 161 ++ internal/server/security_test.go | 359 ++++ internal/server/server.go | 327 +++ internal/server/server_test.go | 340 +++ internal/server/stats.go | 306 +++ internal/server/stats_timezone_test.go | 74 + internal/server/transport.go | 33 + internal/server/write_deadline_test.go | 51 + main.go | 70 + web/.gitignore | 2 + web/index.html | 2 + web/package-lock.json | 1883 +++++++++++++++++ web/package.json | 22 + web/public/theme-init.js | 7 + web/src/App.tsx | 158 ++ web/src/api.ts | 14 + web/src/common.ts | 35 + web/src/components/Filters.tsx | 64 + web/src/components/Shell.tsx | 76 + web/src/components/UsageChart.tsx | 68 + web/src/components/icons.tsx | 209 ++ web/src/components/ui.tsx | 53 + web/src/main.tsx | 5 + web/src/pages/Channels.tsx | 218 ++ web/src/pages/Keys.tsx | 166 ++ web/src/pages/Login.tsx | 42 + web/src/pages/Logs.tsx | 32 + web/src/pages/Models.tsx | 137 ++ web/src/pages/Overview.tsx | 63 + web/src/style.css | 522 +++++ web/src/theme.ts | 34 + web/src/time.ts | 38 + web/tsconfig.json | 4 + web/vite.config.ts | 12 + 69 files changed, 12148 insertions(+) create mode 100644 .dockerignore create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 Makefile create mode 100644 README.md create mode 100644 compose.yaml create mode 100644 config.docker.example.yaml create mode 100644 config.example.yaml create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/config/config.go create mode 100644 internal/config/config_test.go create mode 100644 internal/protocol/README.md create mode 100644 internal/protocol/anthropic_thinking_test.go create mode 100644 internal/protocol/chat_reasoning_test.go create mode 100644 internal/protocol/encode.go create mode 100644 internal/protocol/protocol.go create mode 100644 internal/protocol/protocol_test.go create mode 100644 internal/protocol/reasoning.go create mode 100644 internal/protocol/reasoning_test.go create mode 100644 internal/protocol/stream.go create mode 100644 internal/protocol/stream_limits_test.go create mode 100644 internal/protocol/stream_usage_limits_test.go create mode 100644 internal/protocol/validation_test.go create mode 100644 internal/server/admin.go create mode 100644 internal/server/discovery.go create mode 100644 internal/server/discovery_test.go create mode 100644 internal/server/integration_test.go create mode 100644 internal/server/log_writer.go create mode 100644 internal/server/log_writer_test.go create mode 100644 internal/server/model_probe.go create mode 100644 internal/server/model_probe_test.go create mode 100644 internal/server/performance_test.go create mode 100644 internal/server/proxy.go create mode 100644 internal/server/security.go create mode 100644 internal/server/security_stats_test.go create mode 100644 internal/server/security_test.go create mode 100644 internal/server/server.go create mode 100644 internal/server/server_test.go create mode 100644 internal/server/stats.go create mode 100644 internal/server/stats_timezone_test.go create mode 100644 internal/server/transport.go create mode 100644 internal/server/write_deadline_test.go create mode 100644 main.go create mode 100644 web/.gitignore create mode 100644 web/index.html create mode 100644 web/package-lock.json create mode 100644 web/package.json create mode 100644 web/public/theme-init.js create mode 100644 web/src/App.tsx create mode 100644 web/src/api.ts create mode 100644 web/src/common.ts create mode 100644 web/src/components/Filters.tsx create mode 100644 web/src/components/Shell.tsx create mode 100644 web/src/components/UsageChart.tsx create mode 100644 web/src/components/icons.tsx create mode 100644 web/src/components/ui.tsx create mode 100644 web/src/main.tsx create mode 100644 web/src/pages/Channels.tsx create mode 100644 web/src/pages/Keys.tsx create mode 100644 web/src/pages/Login.tsx create mode 100644 web/src/pages/Logs.tsx create mode 100644 web/src/pages/Models.tsx create mode 100644 web/src/pages/Overview.tsx create mode 100644 web/src/style.css create mode 100644 web/src/theme.ts create mode 100644 web/src/time.ts create mode 100644 web/tsconfig.json create mode 100644 web/vite.config.ts diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..cd4c22d --- /dev/null +++ b/.dockerignore @@ -0,0 +1,10 @@ +.git +.mimocode +config.yaml +.env +data +ai-router +web/node_modules +web/dist +output +.playwright-cli diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..733bd2c --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +/config.yaml +/.env +/data/ +/ai-router +/web/node_modules/ +/web/dist/ +*.db +*.db-shm +*.db-wal +/output/ +/.playwright-cli/ +/.playwright/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..24df99a --- /dev/null +++ b/Dockerfile @@ -0,0 +1,23 @@ +FROM node:24-alpine AS frontend +WORKDIR /src/web +COPY web/package*.json ./ +RUN npm ci +COPY web/ ./ +RUN npm run build + +FROM golang:1.26-alpine AS backend +WORKDIR /src +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +COPY --from=frontend /src/web/dist ./web/dist +RUN CGO_ENABLED=0 go build -trimpath -ldflags='-s -w' -o /ai-router . + +FROM alpine:3.23 +RUN apk add --no-cache ca-certificates && addgroup -g 10001 router && adduser -D -u 10001 -G router router +WORKDIR /app +RUN mkdir /app/data && chown router:router /app/data +COPY --from=backend /ai-router /usr/local/bin/ai-router +USER router +EXPOSE 8080 +ENTRYPOINT ["ai-router", "-config", "/app/config.yaml"] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..810dd0f --- /dev/null +++ b/Makefile @@ -0,0 +1,9 @@ +.PHONY: build frontend test dev +frontend: + cd web && npm ci && npm run build +build: frontend + go build -trimpath -o ai-router . +test: frontend + go test -race ./... +dev: + go run . -config config.yaml diff --git a/README.md b/README.md new file mode 100644 index 0000000..4be7488 --- /dev/null +++ b/README.md @@ -0,0 +1,149 @@ +# AI Router + +个人使用的 Go AI API 网关,React 管理台内嵌在单个二进制中,数据保存在 SQLite。 + +## 构建与启动 + +需要 Go 1.26+、Node.js 22+ 和 npm(Node.js 仅用于构建前端)。 + +```sh +make build +cp config.example.yaml config.yaml +chmod 600 config.yaml +# 编辑 config.yaml,替换 admin.password(至少 12 个字符) +./ai-router -config config.yaml +``` + +打开 http://127.0.0.1:8080,使用配置中的密码登录,无需用户名。示例密码不能用于启动。默认监听本机;远程使用应配置 HTTPS 反向代理,禁止代理缓冲 SSE。 + +HTTPS 反向代理部署时,在配置中设置 `server.public_origin: "https://router.example.com"`(仅 origin,不含路径或末尾斜线)。这用于管理请求的同源检查并启用 Secure Cookie,比较不区分大小写;不会盲目信任客户端传入的转发头。 + +数据库路径相对于进程工作目录。备份时建议停止服务后复制整个 data 目录(包含可能存在的 WAL 文件)。上游凭据保存在数据库中,务必保护数据库及备份;不要提交 config.yaml 到 Git。下游 API Key 仅保存哈希,创建时完整显示一次。 + +## Docker Compose 部署 + +需要 Docker Engine 和 Docker Compose v2。Compose 使用现有多阶段 Dockerfile +构建前后端,容器以 UID/GID 10001 运行,SQLite 保存在命名卷 router-data 中。 + +首次部署时创建配置(如果已有 config.yaml,请备份并编辑,不要覆盖): + +```sh +cp config.docker.example.yaml config.yaml +openssl rand -hex 32 +# 将生成的随机值填入 config.yaml 的 admin.password +sudo chown 10001:10001 config.yaml +sudo chmod 600 config.yaml +docker compose config --quiet +docker compose up -d --build +docker compose ps +``` + +配置只读挂载到容器,不会打入镜像。容器进程必须有权限读取该文件; +上面的权限命令适用于普通 Linux Docker Engine,rootless/user namespace 环境 +需按实际 UID 映射设置。不要将真实密码提交到版本库。 + +默认访问 http://127.0.0.1:8080。容器内监听必须是 0.0.0.0:8080, +不能沿用本地运行示例的 127.0.0.1。宿主机端口已被占用时,例如已有本地服务: + +```sh +AI_ROUTER_PORT=8081 docker compose up -d --build +``` + +后续 Compose 命令也应使用相同的 AI_ROUTER_PORT 值,或将它写入本地 .env。 +默认仅发布到宿主机回环地址,不直接暴露公网。远程访问建议在宿主机配置 +HTTPS 反向代理,同时设置 server.public_origin 并禁用 SSE 代理缓冲。 +如果反向代理也在容器中,应加入同一 Docker 网络,连接 ai-router:8080。 + +常用命令: + +```sh +docker compose logs -f --tail=100 +docker compose up -d --build # 更新源码后重新构建和部署 +docker compose restart # 修改配置文件后重启 +docker compose down # 停止并删除容器,保留数据库卷 +``` + +不要执行 docker compose down -v,除非明确要删除数据库卷。 +首次启动使用独立的新数据库,不会自动导入本地运行的数据。 +迁移时先停止旧服务,备份原数据库目录(含可能存在的 WAL/SHM 文件), +再将数据复制到命名卷并确保 UID/GID 10001 可读写;不要让两套服务同时写同一数据库。 + +## 渠道与模型映射 + +在管理台创建渠道,选择协议并填写 Base URL。OpenAI Chat/Responses 填 API 前缀,例如 https://api.openai.com/v1,程序分别追加 /chat/completions、/responses;Anthropic 填服务根地址,例如 https://api.anthropic.com(不带 /v1),程序追加 /v1/messages,获取模型追加 /v1/models。自定义路径前缀会保留。不要填写完整接口端点。不兼容 Anthropic 旧的带 /v1 配置,也不会自动去重或迁移;已有渠道需手动修改 Base URL。下游接口路径不变。 + +点击“获取模型列表”读取上游模型(新建渠道无需先保存,已有渠道密钥留空时使用原密钥),随后弹窗选择要加入列表的模型。支持搜索、多选、全选搜索结果和清空选择;已有模型标记为已加入,不会重复添加或覆盖展示名称。确认后只将所选模型加入编辑草稿,取消不改变原列表。可修改展示名称;上游不支持模型列表时,可手动添加。点击“保存渠道”后,勾选展示的模型才会保存并汇总到“模型”页面。展示名称即下面的模型别名,修改后下游调用名也随之改变。 + +管理台模型页汇总所有渠道已保存的展示模型,并标注渠道是否停用;下游 `/v1/models` 进一步按渠道启用状态及当前 Key 权限过滤。只允许调用已配置的模型,未映射模型不能调用;历史渠道的透传设置也不再生效。 + +下游模型格式为 `渠道名/模型别名`,按第一个 `/` 拆分。别名在渠道内查询一次,得到的真实模型名原样发给上游: + +| 渠道 | 模型别名 | 上游真实模型 | 下游调用 | +| --- | --- | --- | --- | +| abc | gpt-4 | abc/gpt-4 | abc/gpt-4 | +| abc | gpt-5 | gpt-4 | abc/gpt-5 | + +不会再次拼接渠道前缀,也不会递归路由。未配置的别名一律拒绝,不支持未映射模型透传。创建下游 API Key 时可授权全部渠道或选择渠道白名单。空白名单不允许任何渠道。 + +```sh +curl http://127.0.0.1:8080/v1/chat/completions \ + -H "Authorization: Bearer YOUR_ROUTER_KEY" \ + -H "Content-Type: application/json" \ + -d '{"model":"abc/gpt-5","messages":[{"role":"user","content":"hello"}],"stream":true}' +``` + +## API 与兼容范围 + +模型页面支持“手动测试”和“一键测试全部”:手动测试可填写最多 2000 字符的提示词,一键测试按顺序检查当前所有启用渠道的已展示模型,跳过停用渠道。每次发送一条限制输出 Token 的非流式请求,最长等待 30 秒,展示成功/失败、耗时及上游 HTTP 状态。支持停止测试,离开模型页也会停止后续请求;已发送的请求仍可能计费。 + +测试属于管理员诊断,使用已保存的渠道密钥和模型映射,不创建下游 Key,不计入正式调用日志及统计,也不验证某个下游 Key 的授权。结果仅保留在当前页面,不返回上游响应正文;成功表示接口返回有效协议响应,不代表回答质量、所有协议转换或流式能力均通过验证。测试可能产生上游 Token 费用,一键测试开始前会要求确认。 + +- POST /v1/chat/completions — OpenAI Chat Completions。 +- POST /v1/responses — OpenAI Responses。 +- POST /v1/messages — Anthropic Messages,支持 x-api-key。同协议透传时,客户端提供的 `anthropic-version`/`anthropic-beta` 只有通过校验(短且仅含字母数字与 `-`、`_`、`.`、`,`、空格的 token 列表)才会转发上游,其余一律丢弃并由服务端默认版本兜底。 +- GET /v1/models — 当前 Key 有权限访问的已配置模型别名。 + +同协议保留原生字段;跨协议转换通用文本、图片、工具调用、工具结果及 SSE。Responses 输出中的 phase 元数据不会阻断转换;转换为 Chat/Claude 时省略 reasoning 项及相关推理事件,不将其混入回答正文,不伪造 Claude thinking 签名,正文、工具调用和用量继续正常转换。需要保留原生推理内容时应使用 Responses 接口。其它厂商独有的服务端工具、思考块、会话引用等不保证可跨协议转换,不支持的内容应明确报错。强制工具选择是否可用取决于上游支持,路由器不会静默降级为自动选择。编程代理建议优先选择其原生协议渠道。此项目的本地自动化测试不等于所有 Claude Code/Codex 版本的真实上游兼容认证;使用自己的渠道后仍需联调。 + +跨协议转为 Anthropic 时必须提供 token 上限(Chat 的 `max_tokens` / `max_completion_tokens`,或 Responses 的 `max_output_tokens`);不会猜测额度。详细限制见 [协议说明](internal/protocol/README.md)。不提供 Responses 的服务端会话存储、WebSocket 或 Anthropic token-counting 扩展端点。 + +## 按 Key 统计 + +统计包含请求数、成功/失败数、输入、输出、缓存读取、缓存写入 Token,支持按 Key 和时间范围筛选,以及按天、渠道/模型汇总。调用记录保存请求模型与真实模型,不保存提示词、响应正文或工具参数。 + +输入 Token 统一为包含缓存的总输入,缓存读取/写入为细分项,不能再重复相加。OpenAI 的缓存通常包含在输入内;Anthropic 普通输入与缓存字段相加得到总输入。缺失 usage 标记未知;流中断时已收到的用量可能不完整。撤销 Key 不删除历史记录。 + +后端统一保存 UTC 时间。页面记录、Key 创建/到期时间按浏览器本地时区展示;时间筛选、今天/近 7/30 天和有效期输入按浏览器本地时间解释,并转换为 UTC 提交。无需时区配置。每日用量也按浏览器时区的自然日汇总:前端自动传递 IANA 时区名称,后端仅在查询时调整分组边界,支持夏令时且不改变 UTC 存储。统计 API 的 `timezone` 参数省略时默认 UTC,非法时区返回 400。 + +汇总仅累计完整已知用量,未知/中断请求另行计数。统计由数据库聚合,不再将全部原始日志载入应用内存;查询有超时及结果规模限制,大量历史数据建议按时间范围筛选。可通过 `storage.log_retention_days` 显式开启日志清理,默认关闭。Key 创建后支持停用与撤销,修改授权需重新创建 Key。 + +### 异步日志持久化 + +模型请求完成后将元数据交给后台写入队列,不再等待该条日志的数据库提交。队列最多 1024 条,后台每批最多 100 条,或每 100ms 提交一次事务。统计页面采用最终一致性,刚完成的请求可能稍后才出现;数据库繁忙时延迟可能超过 100ms。队列满时请求处理会等待可用位置,不静默丢弃日志。 + +正常退出会先等待在途请求结束,再排空日志队列后关闭数据库。写入失败进行有限重试,并输出不含请求内容的错误提示;持续数据库故障仍可能导致日志丢失,不能保证恰好一次持久化。进程崩溃、SIGKILL 或断电时,队列及尚未提交批次可能丢失。此队列是内存缓冲,不是持久化消息队列。 + +## 开发与验证 + +```sh +make test +# 前端开发服务器(后端须先启动) +cd web && npm run dev +``` + +管理写接口要求同源请求和 `X-Requested-With: ai-router` 请求头;不要对管理接口开放宽泛 CORS。生产构建使用内嵌前端,不需要单独启动 Vite。 + +## 生产安全配置 + +- 管理台建议仅通过 VPN 或 IP 白名单访问。公网入口必须使用 HTTPS,设置正确的 `server.public_origin`,并保持后端端口不直接对公网开放。 +- `server.trusted_proxies` 默认是空列表,不信任任何客户端转发头。使用反向代理时,填写**应用实际看到的代理 IP/CIDR**,例如宿主机本地代理可用 `["127.0.0.1/32", "::1/128"]`;Docker 场景须按实际网络填写,不能照抄。代理必须覆盖或正确追加 `X-Forwarded-For`。应用从右向左剥离可信代理,按首个非可信地址执行登录限流;未配置时仍按直接连接地址限流,多个用户可能共享额度。不要配置 `0.0.0.0/0` 或 `::/0`。 +- `limits.requests_per_minute`、`limits.per_key_concurrency`、`limits.global_concurrency` 分别控制每个 Key 的请求速率、并发数和全部 Key 的总并发,默认是 120、8、64,必须为正整数。超过限制返回 `429` 和 `Retry-After`。这些限制保存在单进程内存中,重启会重置,多副本需要网关侧统一限流。 +- 登录限速在**校验密码之前**执行:同一 IP 每分钟 10 次失败、或全部 IP 合计每分钟 300 次失败后,锁定期间**一切登录尝试(包括正确密码)统一返回 429** 且不做密码比对——响应不泄露候选密码的正确性,爆破速率被硬性封顶(每 IP 10 次/分、全局 300 次/分)。代价是攻击进行中管理员也可能收到 429:已有会话(24 小时有效)与数据面不受影响,稍候重试或换网络即可,这也是建议管理面仅经 VPN 或 IP 白名单访问的原因之一。成功登录不消耗失败配额,并会清空本 IP 的失败计数,自己的手误不会累积锁定。 +- `/v1` 数据面在鉴权**之前**按 IP 对鉴权失败限速:同一 IP 每分钟 60 次「无效 Key」之后,该 IP 的后续请求在查询数据库前即返回 429,用于保护单一 SQLite 连接不被无效 Key 洪泛挤占。注意该门限发生在 Key 校验之前,因此与管理员不同出口共享 IP 的合法 Key 也可能被连坐至多一分钟。 +- 请求体读取限时 30 秒;登录请求体上限 16 KiB,模型请求体上限 16 MiB(超限返回 413,无法读取返回 400)。上游模型请求最长 10 分钟。反向代理也应设置请求体读取超时、连接数和请求大小限制,同时关闭 SSE 响应缓冲。 +- 请求与响应的 JSON 解析有节点数上限(262144 个 token):由海量微型节点构成的敌意 JSON 会在解码前被拒绝,防止内存放大耗尽进程;正常的对话请求与响应远低于该上限。 +- SSE 单事件最多 16 MiB,跨协议累计保留内容最多 16 MiB、块/索引最多 4096 个,累计 usage 最多 16 KiB;同协议透传模式另有整条流 64 MiB 总量上限,超限都会中断流。所有下游响应的单次写入/刷新限时 30 秒,防止停止读取的客户端长期占用并发槽。上游请求不发送 `Accept-Encoding: gzip`(禁用透明解压),避免被控上游以压缩流消耗代理 CPU。 +- 所有响应默认携带 `Content-Security-Policy`(`default-src 'none'` + 按需放开 connect/script/style,管理台脚本全部同源、无内联 script)、`X-Frame-Options: DENY`、`Referrer-Policy: no-referrer` 与 `X-Content-Type-Options: nosniff`;反代不应移除这些头。 +- `storage.log_retention_days` 默认 0,即不自动删除历史记录。需要自动清理时先备份,再设置正数(例如 90);**启用后会分批删除早于保留期限的日志,历史统计也会相应减少**。删除并不保证 SQLite 文件立即缩小,仍需监控数据卷空间。 +- 请求限流不是费用硬额度。仍需在上游供应商设置预算、费用告警和密钥权限,并在密钥泄露时及时撤销。上游渠道只配置可信目标,公网目标使用 HTTPS;必要时通过网络出口规则禁止访问内网和云元数据地址。 +- 上游密钥保存在数据库中。配置文件、数据卷和备份应限制访问权限;备份应加密,不要将开发输出或真实配置上传到公开仓库。 diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..e7f1684 --- /dev/null +++ b/compose.yaml @@ -0,0 +1,35 @@ +services: + ai-router: + build: + context: . + image: ai-router:local + restart: unless-stopped + ports: + - "127.0.0.1:${AI_ROUTER_PORT:-8080}:8080" + volumes: + - type: bind + source: ./config.yaml + target: /app/config.yaml + read_only: true + bind: + create_host_path: false + - router-data:/app/data + security_opt: + - no-new-privileges:true + cap_drop: + - ALL + stop_grace_period: 30s + healthcheck: + test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1:8080/admin/api/session"] + interval: 30s + timeout: 5s + start_period: 10s + retries: 3 + logging: + driver: json-file + options: + max-size: "10m" + max-file: "3" + +volumes: + router-data: diff --git a/config.docker.example.yaml b/config.docker.example.yaml new file mode 100644 index 0000000..e2bf2cc --- /dev/null +++ b/config.docker.example.yaml @@ -0,0 +1,18 @@ +server: + # 容器内必须监听所有接口;Compose 默认仅发布到宿主机回环地址。 + listen: "0.0.0.0:8080" + # 使用 HTTPS 反向代理时填写实际访问 origin,不含路径或末尾斜线。 + # public_origin: "https://router.example.com" + # 填实际代理的地址/CIDR,不要信任整个公网或不受控的 Docker 网络。 + trusted_proxies: [] +admin: + # 启动前必须替换,至少 12 字符;建议使用 32 字节随机值。 + password: "CHANGE-ME-use-a-long-random-password" +storage: + path: "/app/data/router.db" + # 0 不自动删除;启用正数前备份,过期日志会被分批删除。 + log_retention_days: 0 +limits: + requests_per_minute: 120 + per_key_concurrency: 8 + global_concurrency: 64 diff --git a/config.example.yaml b/config.example.yaml new file mode 100644 index 0000000..0dd11f5 --- /dev/null +++ b/config.example.yaml @@ -0,0 +1,16 @@ +server: + listen: "127.0.0.1:8080" + # HTTPS 反向代理部署时填写浏览器访问的 origin,不含路径或末尾斜线。 + # public_origin: "https://router.example.com" + # 仅填写实际反向代理的地址/CIDR;默认不信任任何转发头。 + trusted_proxies: [] +admin: + password: "CHANGE-ME-use-a-long-random-password" +storage: + path: "./data/router.db" + # 0 禁用自动删除;设为 90 将分批删除超过 90 天的日志,请先备份。 + log_retention_days: 0 +limits: + requests_per_minute: 120 + per_key_concurrency: 8 + global_concurrency: 64 diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..bf695f3 --- /dev/null +++ b/go.mod @@ -0,0 +1,20 @@ +module ai-router + +go 1.26.0 + +require ( + gopkg.in/yaml.v3 v3.0.1 + modernc.org/sqlite v1.57.0 +) + +require ( + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/mattn/go-isatty v0.0.24 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + golang.org/x/sys v0.47.0 // indirect + modernc.org/libc v1.74.4 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..b43966e --- /dev/null +++ b/go.sum @@ -0,0 +1,54 @@ +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo= +github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI= +github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= +golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= +golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= +golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= +golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +modernc.org/cc/v4 v4.29.1 h1:MKgdCV3WykTSPqpVrnxdEDS0HEd2FHpKZDzxzU5LyeI= +modernc.org/cc/v4 v4.29.1/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= +modernc.org/ccgo/v4 v4.34.6 h1:sBgfIwyN0TQ9C5hwIeuqyeAKyMWnbvj2fvpF4L11uzU= +modernc.org/ccgo/v4 v4.34.6/go.mod h1:SZ8YcN9NG7XVsQYdm6jYBvi8PQP1qi+kqB6OhjqI3Fk= +modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= +modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/gc/v3 v3.1.4 h1:2g65LGVSmFQrXeITAw97x7hCRvZFcyE1uDP+7Vng7JI= +modernc.org/gc/v3 v3.1.4/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= +modernc.org/libc v1.74.4 h1:fX1Omw4o2/1C2iRkkIsrQTasJQldLhRmuPreXLoWs9k= +modernc.org/libc v1.74.4/go.mod h1:eeQAS9W3sZeKYMFubydxJpII9ybHWshk+7or7bLG9co= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg= +modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= +modernc.org/sqlite v1.57.0 h1:qNQP6xnx5M0ISNtlnxoOX0+cD5bJ0/gr9aMmndFczzg= +modernc.org/sqlite v1.57.0/go.mod h1:yCJ2cmAaIkHQ25oXWrF8H4O1lIfPYPR26yCEDj2P3pQ= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..a65bc02 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,76 @@ +package config + +import ( + "fmt" + "io" + "net/netip" + "os" + "strings" + + "gopkg.in/yaml.v3" +) + +type Config struct { + Server struct { + TrustedProxies []string `yaml:"trusted_proxies"` + Listen string `yaml:"listen"` + PublicOrigin string `yaml:"public_origin"` + } `yaml:"server"` + Admin struct { + Password string `yaml:"password"` + } `yaml:"admin"` + Storage struct { + LogRetentionDays int `yaml:"log_retention_days"` + Path string `yaml:"path"` + } `yaml:"storage"` + Limits struct { + RequestsPerMinute int `yaml:"requests_per_minute"` + PerKeyConcurrency int `yaml:"per_key_concurrency"` + GlobalConcurrency int `yaml:"global_concurrency"` + } `yaml:"limits"` +} + +func Load(path string) (Config, error) { + var c Config + c.Limits.RequestsPerMinute = 120 + c.Limits.PerKeyConcurrency = 8 + c.Limits.GlobalConcurrency = 64 + c.Server.Listen = "127.0.0.1:8080" + c.Storage.Path = "./data/router.db" + f, err := os.Open(path) + if err != nil { + return c, err + } + defer f.Close() + dec := yaml.NewDecoder(f) + dec.KnownFields(true) + if err = dec.Decode(&c); err != nil { + return c, fmt.Errorf("invalid config: %w", err) + } + var extra any + if err = dec.Decode(&extra); err != io.EOF { + return c, fmt.Errorf("config must contain exactly one YAML document") + } + if len(c.Admin.Password) < 12 || strings.HasPrefix(c.Admin.Password, "CHANGE-ME") { + return c, fmt.Errorf("admin.password must be a non-placeholder password of at least 12 characters") + } + if c.Server.Listen == "" || c.Storage.Path == "" { + return c, fmt.Errorf("server.listen and storage.path must not be empty") + } + if c.Limits.RequestsPerMinute <= 0 || c.Limits.PerKeyConcurrency <= 0 || c.Limits.GlobalConcurrency <= 0 { + return c, fmt.Errorf("limits must be positive integers") + } + if c.Storage.LogRetentionDays < 0 { + return c, fmt.Errorf("storage.log_retention_days must be nonnegative") + } + for _, cidr := range c.Server.TrustedProxies { + p, err := netip.ParsePrefix(cidr) + if err != nil { + return c, fmt.Errorf("invalid trusted proxy CIDR %q: %w", cidr, err) + } + if p.Bits() == 0 { + return c, fmt.Errorf("trusted proxy CIDR must not trust all addresses") + } + } + return c, nil +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..e26e689 --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,66 @@ +package config + +import ( + "os" + "path/filepath" + "testing" +) + +func TestLoad(t *testing.T) { + for _, tt := range []struct { + name, body string + wantErr bool + }{ + {"valid", "admin:\n password: a-long-test-password\n", false}, + {"short", "admin:\n password: short\n", true}, + {"multiple documents", "admin:\n password: a-long-test-password\n---\nadmin:\n password: ignored-password\n", true}, + {"placeholder", "admin:\n password: CHANGE-ME-use-a-long-random-password\n", true}, + {"unknown", "admin:\n password: a-long-test-password\nunknown: true\n", true}, + } { + t.Run(tt.name, func(t *testing.T) { + path := filepath.Join(t.TempDir(), "config.yaml") + if err := os.WriteFile(path, []byte(tt.body), 0600); err != nil { + t.Fatal(err) + } + c, err := Load(path) + if (err != nil) != tt.wantErr { + t.Fatalf("error=%v, wantErr=%v", err, tt.wantErr) + } + if err == nil && (c.Server.Listen != "127.0.0.1:8080" || c.Storage.Path != "./data/router.db") { + t.Fatal("defaults missing") + } + }) + } +} + +func TestSecurityConfig(t *testing.T) { + for _, tt := range []struct { + body string + invalid bool + }{ + {"", false}, + {"limits:\n requests_per_minute: 0\n", true}, + {"limits:\n per_key_concurrency: -1\n", true}, + {"limits:\n global_concurrency: 0\n", true}, + {"limits:\n requests_per_minute: nope\n", true}, + {"server:\n trusted_proxies: [not-a-cidr]\n", true}, + {"server:\n trusted_proxies: [0.0.0.0/0]\n", true}, + {"server:\n trusted_proxies: ['::/0']\n", true}, + {"server:\n trusted_proxies: [10.0.0.0/8, '::1/128']\n", false}, + {"storage:\n log_retention_days: -1\n", true}, + } { + t.Run(tt.body, func(t *testing.T) { + p := filepath.Join(t.TempDir(), "config.yaml") + if err := os.WriteFile(p, []byte("admin:\n password: a-long-test-password\n"+tt.body), 0600); err != nil { + t.Fatal(err) + } + c, err := Load(p) + if (err != nil) != tt.invalid { + t.Fatalf("error=%v", err) + } + if err == nil && (c.Limits.RequestsPerMinute != 120 || c.Limits.PerKeyConcurrency != 8 || c.Limits.GlobalConcurrency != 64 || c.Storage.LogRetentionDays != 0) { + t.Fatal("security defaults missing") + } + }) + } +} diff --git a/internal/protocol/README.md b/internal/protocol/README.md new file mode 100644 index 0000000..3697a59 --- /dev/null +++ b/internal/protocol/README.md @@ -0,0 +1,71 @@ +# Protocol conversion + +Public entry points are `ConvertRequest`, `ConvertResponse`, `Stream`, and +`ExtractUsage`. No external dependencies are needed. The service should bypass +conversion for same-protocol non-streaming traffic. `Stream` preserves original +SSE bytes for same-protocol traffic, including comments and CRLF, and returns +upstream errors instead of silently swallowing them. + +Cross-protocol support includes text, user URL/base64 images, system messages, +function schemas and selection, multiple function calls, tool results, multi-turn +history, and incremental tool argument JSON. Responses output item/content part/ +argument start, delta, done and terminal events are emitted. Anthropic content +block and message lifecycles are emitted. Each write flushes when supported. + +## Deliberate limits + +- Anthropic output thinking/redacted_thinking blocks and their thinking/signature + stream deltas are omitted when converting to Chat/Responses, never appended to + visible text. Block indices and lifecycle are checked. Native Anthropic output + bytes remain unchanged; request-side thinking is not translated. + +- Chat output message/delta reasoning_content (string or null) is omitted only + during cross-protocol conversion; it is never appended to visible text or + fabricated as Anthropic thinking. Native Chat responses/SSE remain unchanged. + Optional indices on complete Chat tool calls must match their array positions. + +- Responses output message `phase` is accepted as metadata. Cross-protocol + conversion deliberately omits Responses output reasoning, summaries and + encrypted content, including their recognized stream events. They are never + appended to visible text or converted to Anthropic thinking/signatures. + Same-protocol responses and SSE retain these bytes unchanged. Reasoning + item/part indices, identities and lifecycles are checked; unrelated unknown + content and event types remain errors. Empty output-text logprobs arrays + are accepted, but actual logprobs remain unsupported. +- Only one choice and function tools are supported. Other reasoning/thinking blocks, + audio/video/documents, hosted tools, citations, refusal content, logprobs, + structured-output modes, prompt-cache controls and Responses server-side + conversation state are rejected rather than approximated. Unknown request + fields and unknown stream event types are rejected. +- Anthropic targets require an explicit token limit. Developer roles, + mid-conversation system messages, strict tool schemas and explicit image + detail levels cannot be translated to Anthropic. The implementation currently + conservatively rejects explicit image detail for all cross-protocol pairs. +- Tool results must be text and cannot carry Anthropic's `is_error=true`. + Stop sequences cannot target Responses; stop-sequence termination cannot + be represented losslessly across protocols. +- Responses streaming supports one output-text content part per message. + Final content must agree with deltas; nonincremental/final-only variants + are rejected. Tool identity must be complete before argument streaming; + later ID/name fragments are rejected. Parallel tool argument fragments are + tracked independently. +- `Usage.Input` includes cache-read and cache-write tokens. Anthropic input + counters exclude these, so they are added on extraction and subtracted on + encoding. `Usage.Raw` holds upstream usage JSON; Anthropic stream snapshots + are merged so final output-only usage does not erase initial input counts. + Unknown usage remains `Known=false`; Anthropic wire messages still carry + required zero counters until actual usage is available. OpenAI wire schemas + have no separate Anthropic cache-write counter; it remains in returned Usage. +- SSE events are limited to 16 MiB; generated content is retained for final + Responses snapshots and argument validation. LF and CRLF framing is supported + (not standalone CR). Same-protocol streams allow EOF without a terminal marker; + cross-protocol streams report truncation. Terminal markers finish immediately. +- Context is checked between reads and writes. A generic `io.Reader` cannot be + interrupted safely: the service must bind upstream HTTP requests to the same + context or close their body on cancellation. Writers/readers remain caller-owned. +- Response transport metadata and echoed Responses request settings are not + round-tripped. Generated IDs use cryptographic randomness if upstream omits + an ID. No external provider or SDK integration tests have been performed. + +Run `go test ./internal/protocol`, `go test -race -cover ./internal/protocol`, +and `go vet ./internal/protocol`. diff --git a/internal/protocol/anthropic_thinking_test.go b/internal/protocol/anthropic_thinking_test.go new file mode 100644 index 0000000..11dc62d --- /dev/null +++ b/internal/protocol/anthropic_thinking_test.go @@ -0,0 +1,101 @@ +package protocol + +import ( + "bytes" + "context" + "strings" + "testing" +) + +func TestAnthropicThinkingOutput(t *testing.T) { + for _, tool := range []bool{false, true} { + visible := `{"type":"text","text":"OK"}` + initial := `{"type":"text","text":""}` + delta := `{"type":"text_delta","text":"OK"}` + finish := "end_turn" + if tool { + visible = `{"type":"tool_use","id":"call_1","name":"weather","input":{}}` + initial = visible + delta = `{"type":"input_json_delta","partial_json":"{}"}` + finish = "tool_use" + } + hidden := `{"type":"thinking","thinking":"PRIVATE","signature":"SIGNATURE"}` + redacted := `{"type":"redacted_thinking","data":"CIPHER"}` + body := `{"id":"m","type":"message","role":"assistant","model":"m","content":[` + hidden + "," + redacted + "," + visible + `],"stop_reason":"` + finish + `","usage":{"input_tokens":9,"output_tokens":7}}` + events := []string{ + `{"type":"message_start","message":{"id":"m","content":[],"usage":{"input_tokens":9,"output_tokens":0}}}`, + `{"type":"content_block_start","index":0,"content_block":{"type":"thinking","thinking":"","signature":""}}`, + `{"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":"PRIVATE"}}`, + `{"type":"content_block_delta","index":0,"delta":{"type":"signature_delta","signature":"SIGNATURE"}}`, + `{"type":"content_block_stop","index":0}`, + `{"type":"content_block_start","index":1,"content_block":` + redacted + "}", + `{"type":"content_block_stop","index":1}`, + `{"type":"content_block_start","index":2,"content_block":` + initial + "}", + `{"type":"content_block_delta","index":2,"delta":` + delta + "}", + `{"type":"content_block_stop","index":2}`, + `{"type":"message_delta","delta":{"stop_reason":"` + finish + `"},"usage":{"output_tokens":7}}`, + `{"type":"message_stop"}`, + } + src := reasoningSSE(events...) + for _, target := range []Kind{Chat, Responses, Anthropic} { + out, err := ConvertResponse(Anthropic, target, []byte(body)) + if err != nil { + t.Fatal(err) + } + var dst bytes.Buffer + u, err := Stream(context.Background(), &dst, strings.NewReader(src), Anthropic, target, "m") + if err != nil { + t.Fatal(err) + } + if !u.Known || u.Input != 9 || u.Output != 7 { + t.Fatalf("usage %+v", u) + } + if target == Anthropic { + if string(out) != body || dst.String() != src { + t.Fatal("native bytes changed") + } + continue + } + for _, result := range []string{string(out), dst.String()} { + for _, secret := range []string{"PRIVATE", "SIGNATURE", "CIPHER", "thinking"} { + if strings.Contains(result, secret) { + t.Fatal("thinking leaked") + } + } + want := "OK" + if tool { + want = "weather" + } + if !strings.Contains(result, want) { + t.Fatal("lost visible output") + } + } + terminal := "[DONE]" + if target == Responses { + terminal = "response.completed" + } + if !strings.Contains(dst.String(), terminal) { + t.Fatal("no terminal") + } + } + for _, bad := range []string{ + strings.Replace(src, `{"type":"content_block_stop","index":0}`, `{"type":"ping"}`, 1), + strings.Replace(src, `"type":"thinking_delta"`, `"type":"text_delta"`, 1), + strings.Replace(src, `"index":2,"content_block"`, `"index":0,"content_block"`, 1), + } { + var dst bytes.Buffer + if _, err := Stream(context.Background(), &dst, strings.NewReader(bad), Anthropic, Chat, "m"); err == nil { + t.Fatal("accepted invalid thinking lifecycle") + } + } + } + for _, block := range []object{ + {"type": "thinking", "thinking": true, "signature": ""}, + {"type": "thinking", "thinking": "", "signature": "", "unknown": true}, + {"type": "redacted_thinking", "data": nil}, + } { + if _, err := anthropicThinking(block); err == nil { + t.Fatal("accepted malformed thinking") + } + } +} diff --git a/internal/protocol/chat_reasoning_test.go b/internal/protocol/chat_reasoning_test.go new file mode 100644 index 0000000..29f7f2b --- /dev/null +++ b/internal/protocol/chat_reasoning_test.go @@ -0,0 +1,92 @@ +package protocol + +import ( + "bytes" + "context" + "encoding/json" + "strings" + "testing" +) + +func TestChatOutputReasoning(t *testing.T) { + for _, tool := range []bool{false, true} { + message := object{"role": "assistant", "content": "OK", "reasoning_content": "PRIVATE"} + delta := object{"content": "OK", "reasoning_content": "PRIVATE"} + finish := "stop" + if tool { + call := object{"index": 0, "id": "call_1", "type": "function", "function": object{"name": "weather", "arguments": "{}"}} + message["content"] = nil + message["tool_calls"] = []any{call} + delta = object{"tool_calls": []any{call}, "reasoning_content": nil} + finish = "tool_calls" + } + usage := object{"prompt_tokens": 9, "completion_tokens": 7} + response := object{"id": "c", "object": "chat.completion", "model": "m", "choices": []any{object{"index": 0, "message": message, "finish_reason": finish}}, "usage": usage} + body, _ := json.Marshal(response) + chunk, _ := json.Marshal(object{"id": "c", "choices": []any{object{"index": 0, "delta": delta, "finish_reason": finish}}, "usage": usage}) + reasoning, _ := json.Marshal(object{"id": "c", "choices": []any{object{"index": 0, "delta": object{"role": "assistant", "reasoning_content": "PRIVATE"}, "finish_reason": nil}}}) + sep := string([]byte{10, 10}) + src := "data: " + string(reasoning) + sep + "data: " + string(chunk) + sep + "data: [DONE]" + sep + for _, target := range []Kind{Chat, Responses, Anthropic} { + out, err := ConvertResponse(Chat, target, body) + if err != nil { + t.Fatal(err) + } + var dst bytes.Buffer + u, err := Stream(context.Background(), &dst, strings.NewReader(src), Chat, target, "m") + if err != nil { + t.Fatal(err) + } + if !u.Known || u.Input != 9 || u.Output != 7 { + t.Fatalf("usage: %+v", u) + } + if target == Chat { + if !bytes.Equal(out, body) || dst.String() != src { + t.Fatal("native bytes changed") + } + continue + } + for _, text := range []string{string(out), dst.String()} { + if strings.Contains(text, "PRIVATE") || strings.Contains(text, "reasoning_content") || strings.Contains(text, "thinking") { + t.Fatal("reasoning leaked") + } + want := "OK" + if tool { + want = "weather" + } + if !strings.Contains(text, want) { + t.Fatal("lost output") + } + } + terminal := "response.completed" + if target == Anthropic { + terminal = "message_stop" + } + if !strings.Contains(dst.String(), terminal) { + t.Fatal("no terminal") + } + } + } +} + +func TestChatOutputMetadataValidation(t *testing.T) { + for _, val := range []any{nil, "", "private"} { + if _, err := chatOutput(object{"reasoning_content": val}); err != nil { + t.Fatal(err) + } + } + for _, val := range []any{true, object{}, []any{}} { + if _, err := chatOutput(object{"reasoning_content": val}); err == nil { + t.Fatal("accepted malformed reasoning") + } + } + for _, index := range []any{json.Number("1"), json.Number("-1"), json.Number("0.5"), "0", nil} { + if _, err := chatResponseMessage(object{"tool_calls": []any{object{"index": index}}}); err == nil { + t.Fatal("accepted malformed index") + } + } + body := []byte(`{"id":"c","choices":[{"message":{"role":"assistant","content":"OK","reasoning_content":"hidden","unknown_field":true},"finish_reason":"stop"}]}`) + if _, err := ConvertResponse(Chat, Responses, body); err == nil { + t.Fatal("accepted unknown field") + } +} diff --git a/internal/protocol/encode.go b/internal/protocol/encode.go new file mode 100644 index 0000000..8becd1f --- /dev/null +++ b/internal/protocol/encode.go @@ -0,0 +1,516 @@ +package protocol + +import ( + "crypto/rand" + "encoding/json" + "fmt" + "strings" + "time" +) + +func newID() string { return fmt.Sprintf("resp_%x", randomID()) } +func randomID() []byte { + b := make([]byte, 16) + if _, e := rand.Read(b); e != nil { + panic(e) + } + return b +} + +func contentBlock(k Kind, p part, role string) (object, error) { + switch p.typ { + case "text": + typ := "text" + if k == Responses { + typ = "input_text" + if role == "assistant" { + typ = "output_text" + } + } + return object{"type": typ, "text": p.text}, nil + case "image": + if role != "user" { + return nil, fmt.Errorf("images only supported in user messages") + } + switch k { + case Chat: + return object{"type": "image_url", "image_url": object{"url": p.url}}, nil + case Responses: + return object{"type": "input_image", "image_url": p.url}, nil + default: + src := object{"type": "url", "url": p.url} + if strings.HasPrefix(p.url, "data:") { + a, b, ok := strings.Cut(strings.TrimPrefix(p.url, "data:"), ";base64,") + if !ok || a == "" || b == "" { + return nil, fmt.Errorf("unsupported image data URL") + } + src = object{"type": "base64", "media_type": a, "data": b} + } + return object{"type": "image", "source": src}, nil + } + case "call": + if role != "assistant" { + return nil, fmt.Errorf("tool call outside assistant message") + } + switch k { + case Chat: + return object{"id": p.id, "type": "function", "function": object{"name": p.name, "arguments": p.args}}, nil + case Responses: + return object{"type": "function_call", "call_id": p.id, "name": p.name, "arguments": p.args}, nil + default: + args, e := decode([]byte(p.args)) + if e != nil { + return nil, e + } + return object{"type": "tool_use", "id": p.id, "name": p.name, "input": args}, nil + } + case "result": + if role != "tool" && role != "user" { + return nil, fmt.Errorf("tool result outside user/tool message") + } + switch k { + case Chat: + return object{"role": "tool", "tool_call_id": p.id, "content": p.text}, nil + case Responses: + return object{"type": "function_call_output", "call_id": p.id, "output": p.text}, nil + default: + return object{"type": "tool_result", "tool_use_id": p.id, "content": p.text}, nil + } + } + return nil, fmt.Errorf("unsupported part") +} +func writeRequest(k Kind, r request) (object, error) { + out := object{} + for key, val := range r.options { + switch key { + case "max": + switch k { + case Chat: + key = "max_completion_tokens" + case Responses: + key = "max_output_tokens" + case Anthropic: + key = "max_tokens" + } + case "stop": + if k == Responses { + return nil, fmt.Errorf("Responses does not support stop sequences") + } + if k == Anthropic { + key = "stop_sequences" + } + case "parallel_tool_calls": + if k == Anthropic { + continue + } + } + out[key] = val + } + if k == Anthropic { + if _, ok := out["max_tokens"]; !ok { + return nil, fmt.Errorf("Anthropic requires explicit max_tokens") + } + } + ms := []any{} + sys := []any{} + seenNonSystem := false + for _, m := range r.messages { + if k == Anthropic && (m.role == "system" || m.role == "developer") { + if m.role == "developer" { + return nil, fmt.Errorf("developer role cannot be represented by Anthropic") + } + if seenNonSystem { + return nil, fmt.Errorf("mid-conversation system message cannot be represented by Anthropic") + } + for _, p := range m.parts { + if p.typ != "text" { + return nil, fmt.Errorf("non-text system content") + } + sys = append(sys, object{"type": "text", "text": p.text}) + } + continue + } + seenNonSystem = true + role := m.role + if k == Anthropic && role == "tool" { + role = "user" + } + cs := []any{} + calls := []any{} + flush := func() { + if len(cs) > 0 { + ms = append(ms, object{"role": role, "content": cs}) + cs = []any{} + } + } + for _, p := range m.parts { + b, e := contentBlock(k, p, m.role) + if e != nil { + return nil, e + } + switch { + case k == Chat && p.typ == "call": + calls = append(calls, b) + case (k == Chat && p.typ == "result") || (k == Responses && (p.typ == "call" || p.typ == "result")): + flush() + ms = append(ms, b) + default: + cs = append(cs, b) + } + } + if k == Chat && len(calls) > 0 { + x := object{"role": role, "tool_calls": calls, "content": nil} + if len(cs) > 0 { + x["content"] = cs + } + ms = append(ms, x) + } else { + flush() + } + } + if k == Anthropic { + merged := []any{} + for _, v := range ms { + m := obj(v) + if len(merged) > 0 && obj(merged[len(merged)-1])["role"] == m["role"] { + last := obj(merged[len(merged)-1]) + last["content"] = append(arr(last["content"]), arr(m["content"])...) + } else { + merged = append(merged, m) + } + } + ms = merged + if len(sys) > 0 { + out["system"] = sys + } + } + if k == Responses { + out["input"] = ms + out["store"] = false + } else { + out["messages"] = ms + } + if len(r.tools) > 0 { + ts := []any{} + for _, f := range r.tools { + switch k { + case Chat: + ts = append(ts, object{"type": "function", "function": f}) + case Responses: + t := object{"type": "function"} + for key, v := range f { + t[key] = v + } + ts = append(ts, t) + case Anthropic: + t := object{} + for key, v := range f { + if key == "strict" { + if v != false { + return nil, fmt.Errorf("strict tool schema cannot be enforced by Anthropic") + } + continue + } + if key == "parameters" { + key = "input_schema" + } + t[key] = v + } + ts = append(ts, t) + } + } + out["tools"] = ts + } + if r.choice != nil { + if s, ok := r.choice.(string); ok { + if k == Anthropic { + if s == "required" { + s = "any" + } + out["tool_choice"] = object{"type": s} + } else { + out["tool_choice"] = s + } + } else { + name := obj(r.choice)["name"] + switch k { + case Chat: + out["tool_choice"] = object{"type": "function", "function": object{"name": name}} + case Responses: + out["tool_choice"] = object{"type": "function", "name": name} + case Anthropic: + out["tool_choice"] = object{"type": "tool", "name": name} + } + } + } + if k == Anthropic { + if v, ok := r.options["parallel_tool_calls"]; ok { + b, ok := v.(bool) + if !ok { + return nil, fmt.Errorf("invalid parallel_tool_calls") + } + tc := obj(out["tool_choice"]) + if tc == nil { + tc = object{"type": "auto"} + out["tool_choice"] = tc + } + tc["disable_parallel_tool_use"] = !b + } + } + if k == Chat && out["stream"] == true { + out["stream_options"] = object{"include_usage": true} + } + return out, nil +} + +type response struct { + id, model, finish string + parts []part + usage Usage +} + +func finishReason(k Kind, s string) (string, error) { + switch k { + case Chat: + switch s { + case "stop": + return "stop", nil + case "tool_calls": + return "tool", nil + case "length": + return "length", nil + } + case Anthropic: + switch s { + case "end_turn": + return "stop", nil + case "tool_use": + return "tool", nil + case "max_tokens": + return "length", nil + } + case Responses: + if s == "completed" { + return "stop", nil + } + } + return "", fmt.Errorf("unsupported finish reason %q", s) +} +func wireFinish(k Kind, s string) string { + switch k { + case Chat: + switch s { + case "tool": + return "tool_calls" + case "length": + return "length" + } + return "stop" + case Anthropic: + switch s { + case "tool": + return "tool_use" + case "length": + return "max_tokens" + } + return "end_turn" + default: + if s == "length" { + return "incomplete" + } + return "completed" + } +} +func ConvertResponse(from, to Kind, body []byte) ([]byte, error) { + if !valid(from) || !valid(to) { + return nil, fmt.Errorf("unknown protocol") + } + m, e := decode(body) + if e != nil { + return nil, e + } + if from == to { + return body, nil + } + r, e := readResponse(from, m) + if e != nil { + return nil, e + } + out, e := writeResponse(to, r) + if e != nil { + return nil, e + } + return json.Marshal(out) +} +func readResponse(k Kind, m object) (response, error) { + r := response{id: str(m["id"]), model: str(m["model"]), usage: usage(k, obj(m["usage"]))} + if m["error"] != nil { + return r, fmt.Errorf("upstream error: %v", m["error"]) + } + var e error + switch k { + case Chat: + if e = fields(m, "id", "object", "created", "model", "choices", "usage", "system_fingerprint", "service_tier"); e != nil { + return r, e + } + cs := arr(m["choices"]) + if len(cs) != 1 { + return r, fmt.Errorf("expected one choice") + } + c := obj(cs[0]) + if e = fields(c, "index", "message", "finish_reason", "logprobs"); e != nil { + return r, e + } + if c["logprobs"] != nil { + return r, fmt.Errorf("logprobs unsupported") + } + message, e := chatResponseMessage(obj(c["message"])) + if e != nil { + return r, e + } + mm, e := readMessage(k, message) + if e != nil { + return r, e + } + r.parts = mm.parts + r.finish, e = finishReason(k, str(c["finish_reason"])) + if e != nil { + return r, e + } + case Anthropic: + if e = fields(m, "id", "type", "role", "model", "content", "stop_reason", "stop_sequence", "usage"); e != nil { + return r, e + } + if m["stop_sequence"] != nil { + return r, fmt.Errorf("stop sequence cannot be preserved") + } + content, e := anthropicOutput(m["content"]) + if e != nil { + return r, e + } + r.parts, e = readContent(k, content) + if e != nil { + return r, e + } + r.finish, e = finishReason(k, str(m["stop_reason"])) + if e != nil { + return r, e + } + case Responses: + // Responses echoes request configuration; only output items carry generated content. + for _, v := range arr(m["output"]) { + x := obj(v) + switch str(x["type"]) { + case "reasoning": + if e = validateReasoning(x); e != nil { + return r, e + } + case "message": + x = responseMessage(x) + mm, e := readMessage(k, x) + if e != nil { + return r, e + } + r.parts = append(r.parts, mm.parts...) + case "function_call": + if e = fields(x, "type", "id", "call_id", "name", "arguments", "status"); e != nil { + return r, e + } + p, e := callPart(x, "call_id", "arguments") + if e != nil { + return r, e + } + r.parts = append(r.parts, p) + default: + return r, fmt.Errorf("unsupported output item %q", str(x["type"])) + } + } + if str(m["status"]) == "incomplete" { + if str(obj(m["incomplete_details"])["reason"]) != "max_output_tokens" { + return r, fmt.Errorf("unsupported incomplete reason") + } + r.finish = "length" + } else { + r.finish, e = finishReason(k, str(m["status"])) + if e != nil { + return r, e + } + for _, p := range r.parts { + if p.typ == "call" { + r.finish = "tool" + } + } + } + } + return r, nil +} +func writeResponse(k Kind, r response) (object, error) { + if r.id == "" { + r.id = newID() + } + out := object{"id": r.id, "model": r.model} + cs := []any{} + calls := []any{} + text := "" + for i, p := range r.parts { + if p.typ != "text" && p.typ != "call" { + return nil, fmt.Errorf("unsupported response content") + } + b, e := contentBlock(k, p, "assistant") + if e != nil { + return nil, e + } + switch k { + case Chat: + if p.typ == "call" { + calls = append(calls, b) + } else { + text += p.text + } + case Anthropic: + cs = append(cs, b) + case Responses: + if p.typ == "call" { + b["id"] = fmt.Sprintf("fc_%s_%d", r.id, i) + b["status"] = "completed" + cs = append(cs, b) + } else { + b["annotations"] = []any{} + cs = append(cs, object{"type": "message", "id": fmt.Sprintf("msg_%s_%d", r.id, i), "role": "assistant", "status": "completed", "content": []any{b}}) + } + } + } + switch k { + case Chat: + m := object{"role": "assistant", "content": text} + if len(calls) > 0 { + m["tool_calls"] = calls + if text == "" { + m["content"] = nil + } + } + out["object"] = "chat.completion" + out["created"] = 0 + out["choices"] = []any{object{"index": 0, "message": m, "finish_reason": wireFinish(k, r.finish)}} + case Anthropic: + out["type"] = "message" + out["role"] = "assistant" + out["content"] = cs + out["stop_reason"] = wireFinish(k, r.finish) + out["stop_sequence"] = nil + case Responses: + out["object"] = "response" + out["created_at"] = time.Now().Unix() + out["status"] = wireFinish(k, r.finish) + out["output"] = cs + out["error"] = nil + out["incomplete_details"] = nil + if r.finish == "length" { + out["incomplete_details"] = object{"reason": "max_output_tokens"} + } + } + if r.usage.Known || k == Anthropic { + out["usage"] = usageJSON(k, r.usage) + } + return out, nil +} diff --git a/internal/protocol/protocol.go b/internal/protocol/protocol.go new file mode 100644 index 0000000..d519620 --- /dev/null +++ b/internal/protocol/protocol.go @@ -0,0 +1,669 @@ +package protocol + +import ( + "bytes" + "encoding/json" + "fmt" + "io" +) + +type Kind string + +const ( + Chat Kind = "openai-chat" + Responses Kind = "openai-responses" + Anthropic Kind = "anthropic" +) + +type Usage struct { + Input int64 `json:"input_tokens"` + Output int64 `json:"output_tokens"` + CacheRead int64 `json:"cache_read_tokens"` + CacheWrite int64 `json:"cache_write_tokens"` + Known bool `json:"known"` + Raw json.RawMessage `json:"raw,omitempty"` +} +type object = map[string]any + +// maxJSONNodes bounds how many JSON tokens one body may contain before it is +// materialized into maps. Without it a 16 MiB body of micro-values expands +// into hundreds of MB of Go objects per request. +const maxJSONNodes = 1 << 18 + +func valid(k Kind) bool { return k == Chat || k == Responses || k == Anthropic } + +// scanNodeCount walks b with the streaming decoder and fails once more than +// limit tokens have been seen. Malformed JSON is not an error here; the +// materializing pass reports its usual message. +func scanNodeCount(b []byte, limit int) error { + d := json.NewDecoder(bytes.NewReader(b)) + n := 0 + for { + _, err := d.Token() + if err != nil { + return nil + } + if n++; n > limit { + return fmt.Errorf("JSON exceeds %d nodes", limit) + } + } +} +func decode(b []byte) (object, error) { + if err := scanNodeCount(b, maxJSONNodes); err != nil { + return nil, err + } + d := json.NewDecoder(bytes.NewReader(b)) + d.UseNumber() + var v object + if err := d.Decode(&v); err != nil { + return nil, err + } + if v == nil { + return nil, fmt.Errorf("expected JSON object") + } + var extra any + if err := d.Decode(&extra); err != io.EOF { + return nil, fmt.Errorf("trailing JSON data") + } + return v, nil +} +func obj(v any) object { m, _ := v.(map[string]any); return m } +func str(v any) string { s, _ := v.(string); return s } +func arr(v any) []any { a, _ := v.([]any); return a } +func num(v any) int64 { + switch n := v.(type) { + case json.Number: + i, _ := n.Int64() + return i + case int64: + return n + case int: + return int64(n) + case float64: + return int64(n) + } + return 0 +} +func fields(m object, names ...string) error { + allowed := map[string]bool{} + for _, n := range names { + allowed[n] = true + } + for k := range m { + if !allowed[k] { + return fmt.Errorf("unsupported field %q", k) + } + } + return nil +} +func required(m object, k string) (string, error) { + s, ok := m[k].(string) + if !ok || s == "" { + return "", fmt.Errorf("missing or invalid %s", k) + } + return s, nil +} +func ExtractUsage(kind Kind, body []byte) Usage { + m, e := decode(body) + if e != nil { + return Usage{} + } + if r := obj(m["response"]); r != nil { + m = r + } + if r := obj(m["message"]); r != nil { + m = r + } + return usage(kind, obj(m["usage"])) +} +func usage(k Kind, m object) Usage { + if m == nil { + return Usage{} + } + raw, _ := json.Marshal(m) + u := Usage{Raw: raw} + switch k { + case Chat: + _, a := m["prompt_tokens"] + _, b := m["completion_tokens"] + u.Known = a || b + u.Input = num(m["prompt_tokens"]) + u.Output = num(m["completion_tokens"]) + u.CacheRead = num(obj(m["prompt_tokens_details"])["cached_tokens"]) + case Responses: + _, a := m["input_tokens"] + _, b := m["output_tokens"] + u.Known = a || b + u.Input = num(m["input_tokens"]) + u.Output = num(m["output_tokens"]) + u.CacheRead = num(obj(m["input_tokens_details"])["cached_tokens"]) + case Anthropic: + _, a := m["input_tokens"] + _, b := m["output_tokens"] + u.Known = a || b + u.CacheRead = num(m["cache_read_input_tokens"]) + u.CacheWrite = num(m["cache_creation_input_tokens"]) + u.Input = num(m["input_tokens"]) + u.CacheRead + u.CacheWrite + u.Output = num(m["output_tokens"]) + } + return u +} +func usageJSON(k Kind, u Usage) object { + switch k { + case Chat: + return object{"prompt_tokens": u.Input, "completion_tokens": u.Output, "total_tokens": u.Input + u.Output, "prompt_tokens_details": object{"cached_tokens": u.CacheRead}} + case Responses: + return object{"input_tokens": u.Input, "output_tokens": u.Output, "total_tokens": u.Input + u.Output, "input_tokens_details": object{"cached_tokens": u.CacheRead}} + default: + return object{"input_tokens": u.Input - u.CacheRead - u.CacheWrite, "output_tokens": u.Output, "cache_read_input_tokens": u.CacheRead, "cache_creation_input_tokens": u.CacheWrite} + } +} + +type part struct{ typ, text, id, name, args, url string } +type message struct { + role string + parts []part +} +type request struct { + messages []message + tools []object + options object + choice any +} + +func textPart(s string) part { return part{typ: "text", text: s} } + +func ConvertRequest(from, to Kind, body []byte, model string) ([]byte, error) { + if !valid(from) || !valid(to) { + return nil, fmt.Errorf("unknown protocol") + } + m, err := decode(body) + if err != nil { + return nil, err + } + if from == to { + if model != "" { + m["model"] = model + } + return json.Marshal(m) + } + r, err := readRequest(from, m) + if err != nil { + return nil, err + } + out, err := writeRequest(to, r) + if err != nil { + return nil, err + } + if model == "" { + model = str(m["model"]) + } + out["model"] = model + return json.Marshal(out) +} +func readRequest(k Kind, m object) (request, error) { + r := request{options: object{}} + var err error + common := []string{"model", "stream", "temperature", "top_p", "tools", "tool_choice"} + switch k { + case Chat: + err = fields(m, append(common, "messages", "max_tokens", "max_completion_tokens", "stop", "stream_options", "parallel_tool_calls", "n")...) + case Responses: + err = fields(m, append(common, "input", "instructions", "max_output_tokens", "parallel_tool_calls", "store")...) + case Anthropic: + err = fields(m, append(common, "messages", "system", "max_tokens", "stop_sequences")...) + } + if err != nil { + return r, err + } + if n, ok := m["n"]; ok && num(n) != 1 { + return r, fmt.Errorf("multiple choices unsupported") + } + if v, ok := m["store"]; ok && v != false { + return r, fmt.Errorf("stored Responses unsupported") + } + if v, ok := m["stream_options"]; ok { + o := obj(v) + if o == nil { + return r, fmt.Errorf("invalid stream_options") + } + if e := fields(o, "include_usage"); e != nil { + return r, e + } + } + for _, key := range []string{"stream", "temperature", "top_p", "parallel_tool_calls"} { + if v, ok := m[key]; ok { + r.options[key] = v + } + } + for _, key := range []string{"max_tokens", "max_completion_tokens", "max_output_tokens"} { + if v, ok := m[key]; ok { + if _, exists := r.options["max"]; exists { + return r, fmt.Errorf("conflicting token limits") + } + r.options["max"] = v + } + } + for _, key := range []string{"stop", "stop_sequences"} { + if v, ok := m[key]; ok { + if s, ok := v.(string); ok { + v = []any{s} + } + if _, ok := v.([]any); !ok { + return r, fmt.Errorf("invalid stop sequences") + } + r.options["stop"] = v + } + } + if k == Responses { + if s, ok := m["instructions"]; ok { + v, ok := s.(string) + if !ok { + return r, fmt.Errorf("invalid instructions") + } + r.messages = append(r.messages, message{"system", []part{textPart(v)}}) + } + if s, ok := m["input"].(string); ok { + r.messages = append(r.messages, message{"user", []part{textPart(s)}}) + } else { + items, ok := m["input"].([]any) + if !ok { + return r, fmt.Errorf("invalid input") + } + for _, v := range items { + x := obj(v) + typ := str(x["type"]) + switch typ { + case "function_call": + if e := fields(x, "type", "id", "call_id", "name", "arguments", "status"); e != nil { + return r, e + } + p, e := callPart(x, "call_id", "arguments") + if e != nil { + return r, e + } + r.messages = append(r.messages, message{"assistant", []part{p}}) + case "function_call_output": + if e := fields(x, "type", "id", "call_id", "output", "status"); e != nil { + return r, e + } + id, e := required(x, "call_id") + if e != nil { + return r, e + } + s, ok := x["output"].(string) + if !ok { + return r, fmt.Errorf("non-text function output unsupported") + } + r.messages = append(r.messages, message{"tool", []part{{typ: "result", id: id, text: s}}}) + case "", "message": + mm, e := readMessage(k, x) + if e != nil { + return r, e + } + r.messages = append(r.messages, mm) + default: + return r, fmt.Errorf("unsupported input item %q", typ) + } + } + } + } else { + if k == Anthropic { + if s, ok := m["system"]; ok { + ps, e := readContent(k, s) + if e != nil { + return r, e + } + for _, p := range ps { + if p.typ != "text" { + return r, fmt.Errorf("non-text system") + } + } + r.messages = append(r.messages, message{"system", ps}) + } + } + ms, ok := m["messages"].([]any) + if !ok { + return r, fmt.Errorf("invalid messages") + } + for _, v := range ms { + mm, e := readMessage(k, obj(v)) + if e != nil { + return r, e + } + r.messages = append(r.messages, mm) + } + } + if v, ok := m["tools"]; ok { + ts, ok := v.([]any) + if !ok { + return r, fmt.Errorf("invalid tools") + } + for _, v := range ts { + t := obj(v) + var f object + switch k { + case Chat: + if e := fields(t, "type", "function"); e != nil { + return r, e + } + if str(t["type"]) != "function" { + return r, fmt.Errorf("unsupported tool") + } + f = obj(t["function"]) + case Responses: + if str(t["type"]) != "function" { + return r, fmt.Errorf("unsupported tool") + } + f = object{} + for key, val := range t { + if key != "type" { + f[key] = val + } + } + case Anthropic: + f = object{} + for key, val := range t { + if key == "input_schema" { + key = "parameters" + } + f[key] = val + } + } + if e := fields(f, "name", "description", "parameters", "strict"); e != nil { + return r, e + } + if _, e := required(f, "name"); e != nil { + return r, e + } + if obj(f["parameters"]) == nil { + return r, fmt.Errorf("missing tool schema") + } + r.tools = append(r.tools, f) + } + } + if v, ok := m["tool_choice"]; ok { + if s, ok := v.(string); ok { + if s != "auto" && s != "none" && s != "required" { + return r, fmt.Errorf("unsupported tool_choice") + } + r.choice = s + } else { + x := obj(v) + if k == Chat { + if e := fields(x, "type", "function"); e != nil { + return r, e + } + if str(x["type"]) != "function" { + return r, fmt.Errorf("unsupported tool_choice") + } + f := obj(x["function"]) + if e := fields(f, "name"); e != nil { + return r, e + } + s, e := required(f, "name") + if e != nil { + return r, e + } + r.choice = object{"name": s} + } else if k == Responses { + if e := fields(x, "type", "name"); e != nil { + return r, e + } + if str(x["type"]) != "function" { + return r, fmt.Errorf("unsupported tool_choice") + } + s, e := required(x, "name") + if e != nil { + return r, e + } + r.choice = object{"name": s} + } else { + if e := fields(x, "type", "name", "disable_parallel_tool_use"); e != nil { + return r, e + } + switch str(x["type"]) { + case "auto", "none": + r.choice = x["type"] + case "any": + r.choice = "required" + case "tool": + s, e := required(x, "name") + if e != nil { + return r, e + } + r.choice = object{"name": s} + default: + return r, fmt.Errorf("unsupported tool_choice") + } + if b, ok := x["disable_parallel_tool_use"].(bool); ok { + r.options["parallel_tool_calls"] = !b + } + } + } + } + return r, nil +} +func callPart(x object, idKey, argsKey string) (part, error) { + id, e := required(x, idKey) + if e != nil { + return part{}, e + } + name, e := required(x, "name") + if e != nil { + return part{}, e + } + s, ok := x[argsKey].(string) + if !ok { + return part{}, fmt.Errorf("invalid tool arguments") + } + if _, e := decode([]byte(s)); e != nil { + return part{}, fmt.Errorf("tool arguments must be JSON object: %w", e) + } + return part{typ: "call", id: id, name: name, args: s}, nil +} +func readMessage(k Kind, x object) (message, error) { + m := message{role: str(x["role"])} + if x == nil { + return m, fmt.Errorf("invalid message") + } + if e := fields(x, "role", "content", "type", "id", "status", "tool_calls", "tool_call_id", "refusal"); e != nil { + return m, e + } + if x["refusal"] != nil { + return m, fmt.Errorf("refusal content cannot be preserved") + } + switch m.role { + case "user", "assistant", "system", "developer", "tool": + default: + return m, fmt.Errorf("unsupported role %q", m.role) + } + if m.role == "tool" { + if k != Chat { + return m, fmt.Errorf("invalid tool role") + } + id, e := required(x, "tool_call_id") + if e != nil { + return m, e + } + s, ok := x["content"].(string) + if !ok { + return m, fmt.Errorf("non-text tool result unsupported") + } + m.parts = []part{{typ: "result", id: id, text: s}} + return m, nil + } + if c := x["content"]; c != nil { + ps, e := readContent(k, c) + if e != nil { + return m, e + } + m.parts = ps + } + if tc, ok := x["tool_calls"]; ok { + if k != Chat || m.role != "assistant" { + return m, fmt.Errorf("invalid tool_calls") + } + ts, ok := tc.([]any) + if !ok { + return m, fmt.Errorf("invalid tool_calls") + } + for _, v := range ts { + t := obj(v) + if e := fields(t, "id", "type", "function"); e != nil { + return m, e + } + if str(t["type"]) != "function" { + return m, fmt.Errorf("unsupported tool call") + } + f := obj(t["function"]) + if e := fields(f, "name", "arguments"); e != nil { + return m, e + } + p, e := callPart(object{"id": t["id"], "name": f["name"], "arguments": f["arguments"]}, "id", "arguments") + if e != nil { + return m, e + } + m.parts = append(m.parts, p) + } + } + return m, nil +} +func readContent(k Kind, v any) ([]part, error) { + if s, ok := v.(string); ok { + return []part{textPart(s)}, nil + } + a, ok := v.([]any) + if !ok { + return nil, fmt.Errorf("invalid content") + } + var ps []part + for _, v := range a { + x := obj(v) + switch str(x["type"]) { + case "text", "input_text", "output_text": + if e := fields(x, "type", "text", "annotations"); e != nil { + return nil, e + } + if an, ok := x["annotations"]; ok && len(arr(an)) != 0 { + return nil, fmt.Errorf("annotations unsupported") + } + s, ok := x["text"].(string) + if !ok { + return nil, fmt.Errorf("invalid text") + } + ps = append(ps, textPart(s)) + case "image_url": + if k != Chat { + return nil, fmt.Errorf("invalid image block") + } + if e := fields(x, "type", "image_url"); e != nil { + return nil, e + } + im := obj(x["image_url"]) + if e := fields(im, "url", "detail"); e != nil { + return nil, e + } + if d := str(im["detail"]); d != "" && d != "auto" { + return nil, fmt.Errorf("image detail cannot be preserved") + } + s, e := required(im, "url") + if e != nil { + return nil, e + } + ps = append(ps, part{typ: "image", url: s}) + case "input_image": + if k != Responses { + return nil, fmt.Errorf("invalid image block") + } + if e := fields(x, "type", "image_url", "detail"); e != nil { + return nil, e + } + if d := str(x["detail"]); d != "" && d != "auto" { + return nil, fmt.Errorf("image detail cannot be preserved") + } + s, e := required(x, "image_url") + if e != nil { + return nil, e + } + ps = append(ps, part{typ: "image", url: s}) + case "image": + if k != Anthropic { + return nil, fmt.Errorf("invalid image block") + } + if e := fields(x, "type", "source"); e != nil { + return nil, e + } + src := obj(x["source"]) + var url string + switch str(src["type"]) { + case "url": + if e := fields(src, "type", "url"); e != nil { + return nil, e + } + url = str(src["url"]) + case "base64": + if e := fields(src, "type", "media_type", "data"); e != nil { + return nil, e + } + if str(src["media_type"]) == "" || str(src["data"]) == "" { + return nil, fmt.Errorf("invalid image source") + } + url = "data:" + str(src["media_type"]) + ";base64," + str(src["data"]) + default: + return nil, fmt.Errorf("unsupported image source") + } + if url == "" { + return nil, fmt.Errorf("empty image URL") + } + ps = append(ps, part{typ: "image", url: url}) + case "tool_use": + if k != Anthropic { + return nil, fmt.Errorf("invalid tool block") + } + if e := fields(x, "type", "id", "name", "input"); e != nil { + return nil, e + } + b, e := json.Marshal(x["input"]) + if e != nil { + return nil, e + } + p, e := callPart(object{"id": x["id"], "name": x["name"], "arguments": string(b)}, "id", "arguments") + if e != nil { + return nil, e + } + ps = append(ps, p) + case "tool_result": + if k != Anthropic { + return nil, fmt.Errorf("invalid result block") + } + if e := fields(x, "type", "tool_use_id", "content", "is_error"); e != nil { + return nil, e + } + if x["is_error"] == true { + return nil, fmt.Errorf("tool error flag cannot be preserved") + } + id, e := required(x, "tool_use_id") + if e != nil { + return nil, e + } + s, ok := x["content"].(string) + if !ok { + pp, e := readContent(k, x["content"]) + if e != nil { + return nil, e + } + for _, p := range pp { + if p.typ != "text" { + return nil, fmt.Errorf("non-text tool result unsupported") + } + s += p.text + } + } + ps = append(ps, part{typ: "result", id: id, text: s}) + default: + return nil, fmt.Errorf("unsupported content type %q", str(x["type"])) + } + } + return ps, nil +} diff --git a/internal/protocol/protocol_test.go b/internal/protocol/protocol_test.go new file mode 100644 index 0000000..8b59d63 --- /dev/null +++ b/internal/protocol/protocol_test.go @@ -0,0 +1,267 @@ +package protocol + +import ( + "bytes" + "context" + "errors" + "io" + "strings" + "testing" + "time" +) + +func TestRequestMatrix(t *testing.T) { + chat := []byte(`{"model":"old","max_tokens":100,"stream":true,"messages":[{"role":"system","content":"help"},{"role":"user","content":[{"type":"text","text":"look"},{"type":"image_url","image_url":{"url":"data:image/png;base64,YQ=="}}]},{"role":"assistant","content":null,"tool_calls":[{"id":"c1","type":"function","function":{"name":"read","arguments":"{\"path\":\"a\"}"}}]},{"role":"tool","tool_call_id":"c1","content":"ok"},{"role":"user","content":"continue"}],"tools":[{"type":"function","function":{"name":"read","parameters":{"type":"object","properties":{"path":{"type":"string"}}}}}],"tool_choice":"auto"}`) + for _, from := range []Kind{Chat, Responses, Anthropic} { + src := chat + var e error + if from != Chat { + src, e = ConvertRequest(Chat, from, chat, "new") + if e != nil { + t.Fatal(e) + } + } + for _, to := range []Kind{Chat, Responses, Anthropic} { + t.Run(string(from)+"/"+string(to), func(t *testing.T) { + b, e := ConvertRequest(from, to, src, "target") + if e != nil { + t.Fatal(e) + } + m, e := decode(b) + if e != nil || m["model"] != "target" { + t.Fatalf("%s %v", b, e) + } + r, e := readRequest(to, m) + if e != nil { + t.Fatal(e) + } + var calls, results, images int + for _, m := range r.messages { + for _, p := range m.parts { + switch p.typ { + case "call": + calls++ + if p.id != "c1" { + t.Fatal(p) + } + case "result": + results++ + case "image": + images++ + } + } + } + if calls != 1 || results != 1 || images != 1 { + t.Fatalf("lost content: %s", b) + } + }) + } + } +} +func TestStrictRejection(t *testing.T) { + cases := []struct { + k Kind + b string + to Kind + }{ + {Chat, `{"messages":[],"response_format":{"type":"json_object"}}`, Responses}, + {Chat, `{"messages":[]}`, Anthropic}, + {Responses, `{"input":[],"previous_response_id":"r"}`, Chat}, + {Anthropic, `{"max_tokens":10,"messages":[{"role":"assistant","content":[{"type":"thinking","thinking":"x"}]}]}`, Chat}, + {Chat, `{"messages":[],"n":2}`, Responses}, + {Chat, `{"messages":[],"stop":["x"]}`, Responses}, + {Chat, `{"max_tokens":10,"messages":[],"tools":[{"type":"function","function":{"name":"x","parameters":{},"strict":true}}]}`, Anthropic}, + } + for _, c := range cases { + if b, e := ConvertRequest(c.k, c.to, []byte(c.b), "m"); e == nil { + t.Fatalf("accepted %s => %s", c.b, b) + } + } +} +func TestResponseMatrix(t *testing.T) { + chat := []byte(`{"id":"r","model":"m","choices":[{"index":0,"message":{"role":"assistant","content":"working","tool_calls":[{"id":"c","type":"function","function":{"name":"read","arguments":"{}"}}]},"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":12,"completion_tokens":4,"prompt_tokens_details":{"cached_tokens":3}}}`) + for _, from := range []Kind{Chat, Responses, Anthropic} { + src, e := ConvertResponse(Chat, from, chat) + if e != nil { + t.Fatal(e) + } + for _, to := range []Kind{Chat, Responses, Anthropic} { + b, e := ConvertResponse(from, to, src) + if e != nil { + t.Fatal(from, to, e) + } + m, _ := decode(b) + r, e := readResponse(to, m) + if e != nil || len(r.parts) != 2 || r.finish != "tool" { + t.Fatalf("%s %v", b, e) + } + u := ExtractUsage(to, b) + if u.Input != 12 || u.Output != 4 || u.CacheRead != 3 || !u.Known { + t.Fatal(u) + } + } + } +} + +type flushBuffer struct { + bytes.Buffer + flushes int +} + +func (b *flushBuffer) Flush() { b.flushes++ } +func chatStream() string { + return strings.Join([]string{ + `data: {"id":"r","model":"m","choices":[{"index":0,"delta":{"role":"assistant","content":"hello"}}]}`, + `data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"c1","type":"function","function":{"name":"read","arguments":"{\"x\":"}},{"index":1,"id":"c2","type":"function","function":{"name":"write","arguments":"{"}}]}}]}`, + `data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":1,"function":{"arguments":"}"}},{"index":0,"function":{"arguments":"1}"}}]}}]}`, + `data: {"choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]}`, + `data: {"choices":[],"usage":{"prompt_tokens":10,"completion_tokens":7}}`, + `data: [DONE]`, + }, "\n\n") + "\n\n" +} +func TestStreamMatrix(t *testing.T) { + for _, from := range []Kind{Chat, Responses, Anthropic} { + src := chatStream() + if from != Chat { + var b bytes.Buffer + if _, e := Stream(context.Background(), &b, strings.NewReader(src), Chat, from, "m"); e != nil { + t.Fatal(e) + } + src = b.String() + } + for _, to := range []Kind{Chat, Responses, Anthropic} { + t.Run(string(from)+"/"+string(to), func(t *testing.T) { + var b flushBuffer + u, e := Stream(context.Background(), &b, strings.NewReader(src), from, to, "m") + if e != nil { + t.Fatal(e) + } + if u.Input != 10 || u.Output != 7 || !u.Known { + t.Fatal(u) + } + if b.flushes < 3 { + t.Fatal("not flushed") + } + if from == to && b.String() != src { + t.Fatal("passthrough changed bytes") + } + if to == Responses { + for _, event := range []string{"response.output_item.added", "response.function_call_arguments.delta", "response.function_call_arguments.done", "response.output_item.done", "response.completed"} { + if !strings.Contains(b.String(), event) { + t.Fatal("missing", event) + } + } + } + if to == Anthropic && !strings.Contains(b.String(), "message_stop") { + t.Fatal("missing stop") + } + }) + } + } +} +func TestUsageCumulative(t *testing.T) { + src := "data: {\"type\":\"message_start\",\"message\":{\"usage\":{\"input_tokens\":5,\"output_tokens\":1,\"cache_read_input_tokens\":8,\"cache_creation_input_tokens\":2}}}\n\n" + "data: {\"type\":\"message_delta\",\"usage\":{\"output_tokens\":9}}\n\n" + var b bytes.Buffer + u, e := Stream(context.Background(), &b, strings.NewReader(src), Anthropic, Anthropic, "") + if e != nil { + t.Fatal(e) + } + if u.Input != 15 || u.Output != 9 || u.CacheRead != 8 || u.CacheWrite != 2 { + t.Fatal(u) + } + raw, e := decode(u.Raw) + if e != nil || num(raw["input_tokens"]) != 5 || num(raw["output_tokens"]) != 9 || num(raw["cache_read_input_tokens"]) != 8 { + t.Fatal(string(u.Raw), e) + } +} +func TestRealtimeBeforeEOF(t *testing.T) { + r, w := io.Pipe() + defer r.Close() + defer w.Close() + done := make(chan error, 1) + out := &signalWriter{signal: make(chan struct{}, 1)} + go func() { _, e := Stream(context.Background(), out, r, Chat, Responses, "m"); done <- e }() + first, rest, _ := strings.Cut(chatStream(), "\n\n") + if _, e := io.WriteString(w, first+"\n\n"); e != nil { + t.Fatal(e) + } + select { + case <-out.signal: + case <-time.After(time.Second): + t.Fatal("first delta buffered until EOF") + } + if _, e := io.WriteString(w, rest); e != nil { + t.Fatal(e) + } + select { + case e := <-done: + if e != nil { + t.Fatal(e) + } + case <-time.After(time.Second): + t.Fatal("terminal marker waits for EOF") + } +} + +type signalWriter struct{ signal chan struct{} } + +func (w *signalWriter) Write(p []byte) (int, error) { + select { + case w.signal <- struct{}{}: + default: + } + return len(p), nil +} +func TestUnknownUsageAndUniqueID(t *testing.T) { + src := "data: {\"choices\":[{\"index\":0,\"delta\":{\"content\":\"a\"},\"finish_reason\":\"stop\"}]}\n\ndata: [DONE]\n\n" + var previous string + for i := 0; i < 2; i++ { + var b bytes.Buffer + u, e := Stream(context.Background(), &b, strings.NewReader(src), Chat, Anthropic, "m") + if e != nil || u.Known { + t.Fatal(u, e) + } + line := strings.Split(b.String(), "\n")[1] + m, e := decode([]byte(strings.TrimPrefix(line, "data: "))) + if e != nil { + t.Fatal(e) + } + msg := obj(m["message"]) + if obj(msg["usage"]) == nil { + t.Fatal("missing initial usage") + } + id := str(msg["id"]) + if id == "" || id == previous { + t.Fatal("nonunique id") + } + previous = id + } +} +func TestStreamErrors(t *testing.T) { + for _, src := range []string{"data: nope\n\n", "data: {\"error\":{\"message\":\"bad\"}}\n\n", strings.Replace(chatStream(), "data: [DONE]\n\n", "", 1), strings.Replace(chatStream(), `"arguments":"1}"`, `"arguments":"oops}"`, 1)} { + var b bytes.Buffer + if _, e := Stream(context.Background(), &b, strings.NewReader(src), Chat, Responses, "m"); e == nil { + t.Fatal("accepted broken stream", src) + } + } + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if _, e := Stream(ctx, io.Discard, strings.NewReader(chatStream()), Chat, Chat, ""); !errors.Is(e, context.Canceled) { + t.Fatal(e) + } + if _, e := Stream(context.Background(), badWriter{}, strings.NewReader(chatStream()), Chat, Anthropic, ""); !errors.Is(e, io.ErrClosedPipe) { + t.Fatal(e) + } +} + +type badWriter struct{} + +func (badWriter) Write([]byte) (int, error) { return 0, io.ErrClosedPipe } +func TestSSEMultilineAndCRLF(t *testing.T) { + src := ": comment\r\n\r\ndata: {\r\ndata: \"usage\":{\"prompt_tokens\":2,\"completion_tokens\":3}}\r\n\r\ndata: [DONE]\r\n\r\n" + var b flushBuffer + u, e := Stream(context.Background(), &b, strings.NewReader(src), Chat, Chat, "") + if e != nil || u.Input != 2 || u.Output != 3 || b.String() != src { + t.Fatal(u, e, b.String()) + } +} diff --git a/internal/protocol/reasoning.go b/internal/protocol/reasoning.go new file mode 100644 index 0000000..ff7b017 --- /dev/null +++ b/internal/protocol/reasoning.go @@ -0,0 +1,352 @@ +package protocol + +import ( + "encoding/json" + "fmt" +) + +// Recognized Anthropic thinking blocks are omitted only from converted output. +func anthropicThinking(x object) (bool, error) { + var names []string + switch str(x["type"]) { + case "thinking": + names = []string{"thinking", "signature"} + case "redacted_thinking": + names = []string{"data"} + default: + return false, nil + } + if err := fields(x, append([]string{"type"}, names...)...); err != nil { + return true, err + } + for _, name := range names { + if _, ok := x[name].(string); !ok { + return true, fmt.Errorf("invalid thinking %s", name) + } + } + return true, nil +} + +func anthropicOutput(v any) (any, error) { + content, ok := v.([]any) + if !ok { + return nil, fmt.Errorf("invalid Anthropic content") + } + clean := []any{} + for _, block := range content { + thinking, err := anthropicThinking(obj(block)) + if err != nil { + return nil, err + } + if !thinking { + clean = append(clean, block) + } + } + return clean, nil +} + +type thinkingBlock struct { + kind string + closed bool +} + +func (s *streamState) thinkingEvent(typ string, m object) (bool, error) { + if typ == "message_delta" || typ == "message_stop" { + for _, b := range s.thinking { + if !b.closed { + return true, fmt.Errorf("unfinished thinking block") + } + } + return false, nil + } + if typ != "content_block_start" && typ != "content_block_delta" && typ != "content_block_stop" { + return false, nil + } + index, err := responseIndex(m, "index") + if err != nil { + return true, err + } + b := s.thinking[index] + if typ == "content_block_start" { + if b != nil || s.source[index] != nil { + return true, fmt.Errorf("duplicate content index") + } + x := obj(m["content_block"]) + thinking, err := anthropicThinking(x) + if err != nil || !thinking { + return thinking, err + } + if !s.started || s.finished { + return true, fmt.Errorf("thinking outside active message") + } + if s.thinking == nil { + s.thinking = map[int]*thinkingBlock{} + } + s.thinking[index] = &thinkingBlock{kind: str(x["type"])} + return true, nil + } + if b == nil { + return false, nil + } + if b.closed || s.finished { + return true, fmt.Errorf("event after thinking block closed") + } + if typ == "content_block_stop" { + b.closed = true + return true, nil + } + d := obj(m["delta"]) + field := "" + switch str(d["type"]) { + case "thinking_delta": + field = "thinking" + case "signature_delta": + field = "signature" + } + if b.kind != "thinking" || field == "" { + return true, fmt.Errorf("invalid thinking delta") + } + if err := fields(d, "type", field); err != nil { + return true, err + } + if _, ok := d[field].(string); !ok { + return true, fmt.Errorf("invalid thinking delta value") + } + return true, nil +} + +// chatOutput omits output-only reasoning without exposing it as visible text. +func chatOutput(x object) (object, error) { + if v := x["reasoning_content"]; v != nil { + if _, ok := v.(string); !ok { + return nil, fmt.Errorf("reasoning_content must be a string or null") + } + } + m := object{} + for k, v := range x { + if k != "reasoning_content" { + m[k] = v + } + } + return m, nil +} + +// Some Chat providers include streaming-style indices in complete tool calls. +func chatResponseMessage(x object) (object, error) { + m, err := chatOutput(x) + if err != nil { + return nil, err + } + if calls, ok := x["tool_calls"].([]any); ok { + clean := make([]any, len(calls)) + for i, v := range calls { + call := obj(v) + if _, exists := call["index"]; exists { + index, err := responseIndex(call, "index") + if err != nil || index != i { + return nil, fmt.Errorf("invalid nonstream tool index") + } + } + c := object{} + for k, v := range call { + if k != "index" { + c[k] = v + } + } + clean[i] = c + } + m["tool_calls"] = clean + } + return m, nil +} + +// Phase describes output metadata, not message content or request semantics. +func responseMessage(x object) object { + m := object{} + for k, v := range x { + if k != "phase" { + m[k] = v + } + } + if content, ok := x["content"].([]any); ok { + clean := make([]any, len(content)) + for i, v := range content { + p := obj(v) + if logs, ok := p["logprobs"].([]any); ok && len(logs) == 0 && str(p["type"]) == "output_text" { + q := object{} + for k, v := range p { + if k != "logprobs" { + q[k] = v + } + } + clean[i] = q + } else { + clean[i] = v + } + } + m["content"] = clean + } + return m +} + +func validateReasoning(x object) error { + if err := fields(x, "type", "id", "status", "content", "summary", "encrypted_content"); err != nil { + return err + } + for field, typ := range map[string]string{"content": "reasoning_text", "summary": "summary_text"} { + if v, exists := x[field]; exists && v != nil { + if _, ok := v.([]any); !ok { + return fmt.Errorf("reasoning %s must be an array", field) + } + } + for _, v := range arr(x[field]) { + p := obj(v) + if str(p["type"]) != typ { + return fmt.Errorf("unsupported reasoning %s content", field) + } + if err := fields(p, "type", "text"); err != nil { + return err + } + } + } + return nil +} + +type reasoningItem struct { + id string + closed bool + parts map[string]bool +} + +func responseIndex(m object, field string) (int, error) { + n, ok := m[field].(json.Number) + i, err := n.Int64() + if !ok || err != nil || i < 0 || int64(int(i)) != i { + return 0, fmt.Errorf("invalid %s", field) + } + return int(i), nil +} + +func (s *streamState) reasoningEvent(typ string, m object) (bool, error) { + if typ == "response.completed" || typ == "response.incomplete" { + seen := 0 + for i, v := range arr(obj(m["response"])["output"]) { + x := obj(v) + r := s.reasoning[i] + if str(x["type"]) == "reasoning" { + if r == nil || !r.closed || r.id != str(x["id"]) { + return true, fmt.Errorf("final reasoning item disagrees with stream") + } + seen++ + } else if r != nil { + return true, fmt.Errorf("final reasoning index changed type") + } + } + if seen != len(s.reasoning) { + return true, fmt.Errorf("final response missing reasoning item") + } + return false, nil + } + known := false + switch typ { + case "response.reasoning_text.delta", "response.reasoning_text.done", + "response.reasoning_summary_text.delta", "response.reasoning_summary_text.done", + "response.reasoning_summary_part.added", "response.reasoning_summary_part.done": + known = true + case "response.output_item.added", "response.output_item.done", + "response.content_part.added", "response.content_part.done", + "response.output_text.delta", "response.output_text.done", + "response.function_call_arguments.delta", "response.function_call_arguments.done": + default: + return false, nil + } + key, err := responseIndex(m, "output_index") + if err != nil { + return true, err + } + r := s.reasoning[key] + x := obj(m["item"]) + if typ == "response.output_item.added" { + if r != nil || s.source[key] != nil { + return true, fmt.Errorf("duplicate output index %d", key) + } + if str(x["type"]) != "reasoning" { + return false, nil + } + if err := validateReasoning(x); err != nil { + return true, err + } + if str(x["id"]) == "" { + return true, fmt.Errorf("missing reasoning item id") + } + if s.reasoning == nil { + s.reasoning = map[int]*reasoningItem{} + } + s.reasoning[key] = &reasoningItem{id: str(x["id"]), parts: map[string]bool{}} + return true, nil + } + if r == nil { + if known || str(x["type"]) == "reasoning" { + return true, fmt.Errorf("reasoning event outside reasoning item") + } + return false, nil + } + if r.closed { + return true, fmt.Errorf("event after reasoning item closed") + } + if typ == "response.output_item.done" { + if str(x["type"]) != "reasoning" || str(x["id"]) != r.id { + return true, fmt.Errorf("reasoning item identity mismatch") + } + if err := validateReasoning(x); err != nil { + return true, err + } + for _, closed := range r.parts { + if !closed { + return true, fmt.Errorf("reasoning item has open parts") + } + } + r.closed = true + return true, nil + } + if str(m["item_id"]) != r.id { + return true, fmt.Errorf("reasoning event item identity mismatch") + } + field, partType := "content_index", "reasoning_text" + switch typ { + case "response.reasoning_summary_text.delta", "response.reasoning_summary_text.done", + "response.reasoning_summary_part.added", "response.reasoning_summary_part.done": + field, partType = "summary_index", "summary_text" + case "response.reasoning_text.delta", "response.reasoning_text.done", + "response.content_part.added", "response.content_part.done": + default: + return true, fmt.Errorf("non-reasoning event on reasoning item") + } + i, err := responseIndex(m, field) + if err != nil { + return true, err + } + partKey := fmt.Sprintf("%s:%d", field, i) + closed, exists := r.parts[partKey] + switch typ { + case "response.content_part.added", "response.reasoning_summary_part.added", + "response.content_part.done", "response.reasoning_summary_part.done": + p := obj(m["part"]) + if str(p["type"]) != partType { + return true, fmt.Errorf("unsupported reasoning part") + } + if err := fields(p, "type", "text"); err != nil { + return true, err + } + added := typ == "response.content_part.added" || typ == "response.reasoning_summary_part.added" + if (added && exists) || (!added && (!exists || closed)) { + return true, fmt.Errorf("invalid reasoning part lifecycle") + } + r.parts[partKey] = !added + default: + if !exists || closed { + return true, fmt.Errorf("reasoning text outside active part") + } + } + return true, nil +} diff --git a/internal/protocol/reasoning_test.go b/internal/protocol/reasoning_test.go new file mode 100644 index 0000000..4975a27 --- /dev/null +++ b/internal/protocol/reasoning_test.go @@ -0,0 +1,136 @@ +package protocol + +import ( + "bytes" + "context" + "strings" + "testing" +) + +const reasoningJSON = `{"type":"reasoning","id":"r","content":[{"type":"reasoning_text","text":"SECRET"}],"summary":[{"type":"summary_text","text":"SUMMARY"}],"encrypted_content":"CIPHER"}` +const visibleJSON = `{"type":"message","id":"m","role":"assistant","phase":"final_answer","content":[{"type":"output_text","text":"OK","logprobs":[]}]}` + +func reasoningSSE(events ...string) string { + sep := string([]byte{10, 10}) + return "data: " + strings.Join(events, sep+"data: ") + sep +} +func TestReasoningConversions(t *testing.T) { + for _, tool := range []bool{false, true} { + for _, incomplete := range []bool{false, true} { + visible, initial, delta := visibleJSON, `{"type":"message","content":[]}`, `{"type":"response.output_text.delta","output_index":1,"delta":"OK"}` + if tool { + visible = `{"type":"function_call","call_id":"c","name":"lookup","arguments":"{}"}` + initial = `{"type":"function_call","call_id":"c","name":"lookup","arguments":""}` + delta = `{"type":"response.function_call_arguments.delta","output_index":1,"delta":"{}"}` + } + status, details := "completed", "" + if incomplete { + status = "incomplete" + details = `,"incomplete_details":{"reason":"max_output_tokens"}` + } + body := `{"id":"res","model":"m","status":"` + status + `","output":[` + reasoningJSON + `,` + visible + `],"usage":{"input_tokens":9,"output_tokens":7,"output_tokens_details":{"reasoning_tokens":5}}` + details + `}` + src := reasoningSSE(`{"type":"response.output_item.added","output_index":0,"item":{"type":"reasoning","id":"r"}}`, + `{"type":"response.content_part.added","output_index":0,"item_id":"r","content_index":0,"part":{"type":"reasoning_text","text":""}}`, + `{"type":"response.reasoning_text.delta","output_index":0,"item_id":"r","content_index":0,"delta":"SECRET"}`, + `{"type":"response.reasoning_text.done","output_index":0,"item_id":"r","content_index":0,"text":"SECRET"}`, + `{"type":"response.content_part.done","output_index":0,"item_id":"r","content_index":0,"part":{"type":"reasoning_text","text":"SECRET"}}`, + `{"type":"response.reasoning_summary_part.added","output_index":0,"item_id":"r","summary_index":0,"part":{"type":"summary_text","text":""}}`, + `{"type":"response.reasoning_summary_text.delta","output_index":0,"item_id":"r","summary_index":0,"delta":"SUMMARY"}`, + `{"type":"response.reasoning_summary_text.done","output_index":0,"item_id":"r","summary_index":0,"text":"SUMMARY"}`, + `{"type":"response.reasoning_summary_part.done","output_index":0,"item_id":"r","summary_index":0,"part":{"type":"summary_text","text":"SUMMARY"}}`, + `{"type":"response.output_item.done","output_index":0,"item":`+reasoningJSON+`}`, + `{"type":"response.output_item.added","output_index":1,"item":`+initial+`}`, delta, + `{"type":"response.output_item.done","output_index":1,"item":`+visible+`}`, + `{"type":"response.`+status+`","response":`+body+`}`) + for _, target := range []Kind{Chat, Anthropic, Responses} { + out, err := ConvertResponse(Responses, target, []byte(body)) + if err != nil { + t.Fatal(err) + } + var b bytes.Buffer + u, err := Stream(context.Background(), &b, strings.NewReader(src), Responses, target, "m") + if err != nil { + t.Fatal(err) + } + if !u.Known || u.Input != 9 || u.Output != 7 || !bytes.Contains(u.Raw, []byte("reasoning_tokens")) { + t.Fatalf("usage: %+v", u) + } + if target == Responses { + if string(out) != body || b.String() != src { + t.Fatal("changed same-protocol bytes") + } + continue + } + for _, result := range []string{string(out), b.String()} { + for _, secret := range []string{"SECRET", "SUMMARY", "CIPHER", "signature", "thinking", "phase"} { + if strings.Contains(result, secret) { + t.Fatal("leaked " + secret) + } + } + want := "OK" + if tool { + want = "lookup" + } + if !strings.Contains(result, want) { + t.Fatal("lost visible content") + } + finish := "stop" + if target == Anthropic { + finish = "end_turn" + } + if tool { + finish = "tool_calls" + if target == Anthropic { + finish = "tool_use" + } + } + if incomplete { + finish = "length" + if target == Anthropic { + finish = "max_tokens" + } + } + if !strings.Contains(result, finish) { + t.Fatal("lost finish " + finish) + } + } + if target == Chat && !strings.Contains(b.String(), "[DONE]") { + t.Fatal("missing terminal") + } + if target == Anthropic && (!strings.Contains(b.String(), "message_stop") || !strings.Contains(b.String(), `"index":0`)) { + t.Fatal("terminal/index") + } + } + } + } +} + +func TestReasoningRejectsInvalidStreams(t *testing.T) { + start := `{"type":"response.output_item.added","output_index":0,"item":{"type":"reasoning","id":"r"}}` + part := `{"type":"response.reasoning_summary_part.added","output_index":0,"item_id":"r","summary_index":0,"part":{"type":"summary_text","text":""}}` + done := `{"type":"response.output_item.done","output_index":0,"item":` + reasoningJSON + `}` + for name, events := range map[string][]string{ + "duplicate": {start, start}, "part before item": {part}, "open part": {start, part, done}, "closed item": {start, done, part}, + "wrong id": {start, strings.Replace(part, `"item_id":"r"`, `"item_id":"x"`, 1)}, + "unknown": {start, `{"type":"response.reasoning_unknown.delta","output_index":0}`}, + "text collision": {start, `{"type":"response.output_text.delta","output_index":0,"item_id":"r","delta":"lost"}`}, + "tool collision": {start, `{"type":"response.function_call_arguments.delta","output_index":0,"item_id":"r","delta":"{}"}`}, + "unknown part": {start, strings.Replace(part, "summary_text", "output_text", 1)}, + "negative": {strings.Replace(start, `"output_index":0`, `"output_index":-1`, 1)}, + "fractional": {strings.Replace(start, `"output_index":0`, `"output_index":0.5`, 1)}, + "final missing": {start, done, `{"type":"response.completed","response":{"status":"completed","output":[]}}`}, + } { + t.Run(name, func(t *testing.T) { + var b bytes.Buffer + _, err := Stream(context.Background(), &b, strings.NewReader(reasoningSSE(events...)), Responses, Chat, "") + if err == nil || strings.Contains(err.Error(), "truncat") { + t.Fatalf("expected validation error: %v", err) + } + }) + } + for _, bad := range []string{strings.Replace(reasoningJSON, "reasoning_text", "output_text", 1), strings.Replace(reasoningJSON, "summary_text", "unknown", 1), `{"type":"reasoning","summary":{}}`, strings.Replace(visibleJSON, `"logprobs":[]`, `"logprobs":[{}]`, 1), strings.Replace(visibleJSON, `"phase":`, `"unknown":`, 1)} { + if _, err := ConvertResponse(Responses, Chat, []byte(`{"status":"completed","output":[`+bad+`]}`)); err == nil { + t.Fatal("accepted invalid output") + } + } +} diff --git a/internal/protocol/stream.go b/internal/protocol/stream.go new file mode 100644 index 0000000..2ea6060 --- /dev/null +++ b/internal/protocol/stream.go @@ -0,0 +1,879 @@ +package protocol + +import ( + "bufio" + "context" + "encoding/json" + "fmt" + "io" + "strings" + "time" +) + +const maxStreamBytes = 16 << 20 +const maxStreamBlocks = 4096 +const maxStreamUsageBytes = 16 << 10 +const maxPassthroughBytes = 64 << 20 + +type streamBlock struct { + part + content, identity, functionName strings.Builder + index int + closed bool + emitted bool + sent int +} +type streamState struct { + thinking map[int]*thinkingBlock + ctx context.Context + w io.Writer + from, to Kind + r response + blocks []*streamBlock + source map[int]*streamBlock + reasoning map[int]*reasoningItem + started, finished, terminal bool + sequence int + retained, blockCount int + byteLimit, blockLimit int +} + +func (s *streamState) retain(n int) error { + if n > s.byteLimit-s.retained { + return fmt.Errorf("stream retained content exceeds %d bytes", s.byteLimit) + } + s.retained += n + return nil +} +func (s *streamState) reserveBlock() error { + if s.blockCount >= s.blockLimit { + return fmt.Errorf("stream exceeds %d blocks", s.blockLimit) + } + s.blockCount++ + return nil +} + +func writeFlush(w io.Writer, b []byte) error { + n, e := w.Write(b) + if e != nil { + return e + } + if n != len(b) { + return io.ErrShortWrite + } + if f, ok := w.(interface{ Flush() error }); ok { + return f.Flush() + } + if f, ok := w.(interface{ Flush() }); ok { + f.Flush() + } + return nil +} +func (s *streamState) event(name string, m object) error { + if e := s.ctx.Err(); e != nil { + return e + } + if s.to == Responses { + m["sequence_number"] = s.sequence + s.sequence++ + } + b, e := json.Marshal(m) + if e != nil { + return e + } + prefix := "" + if name != "" { + prefix = "event: " + name + "\n" + } + return writeFlush(s.w, []byte(prefix+"data: "+string(b)+"\n\n")) +} +func (s *streamState) typed(name string, m object) error { m["type"] = name; return s.event(name, m) } +func (s *streamState) chat(delta object, finish any) error { + return s.event("", object{"id": s.r.id, "object": "chat.completion.chunk", "created": 0, "model": s.r.model, "choices": []any{object{"index": 0, "delta": delta, "finish_reason": finish}}}) +} +func (s *streamState) start() error { + if s.started { + return nil + } + s.started = true + if s.r.id == "" { + s.r.id = newID() + } + switch s.to { + case Chat: + return s.chat(object{"role": "assistant", "content": ""}, nil) + case Anthropic: + m := object{"id": s.r.id, "type": "message", "role": "assistant", "model": s.r.model, "content": []any{}, "stop_reason": nil, "stop_sequence": nil} + m["usage"] = usageJSON(Anthropic, s.r.usage) + return s.typed("message_start", object{"message": m}) + case Responses: + r := object{"id": s.r.id, "object": "response", "created_at": time.Now().Unix(), "model": s.r.model, "status": "in_progress", "output": []any{}, "error": nil, "incomplete_details": nil, "usage": nil} + if e := s.typed("response.created", object{"response": r}); e != nil { + return e + } + return s.typed("response.in_progress", object{"response": r}) + } + return nil +} +func (s *streamState) itemID(b *streamBlock) string { + if b.typ == "call" { + return fmt.Sprintf("fc_%s_%d", s.r.id, b.index) + } + return fmt.Sprintf("msg_%s_%d", s.r.id, b.index) +} +func (s *streamState) item(b *streamBlock, done bool) object { + status := "in_progress" + if done { + status = "completed" + } + if b.typ == "call" { + args := "" + if done { + args = b.args + } + return object{"id": s.itemID(b), "type": "function_call", "call_id": b.id, "name": b.name, "arguments": args, "status": status} + } + content := []any{} + if done { + content = append(content, object{"type": "output_text", "text": b.text, "annotations": []any{}}) + } + return object{"id": s.itemID(b), "type": "message", "role": "assistant", "status": status, "content": content} +} +func (s *streamState) open(b *streamBlock) error { + if b.emitted { + return nil + } + if b.typ == "call" && (b.id == "" || b.name == "") { + return nil + } + if e := s.start(); e != nil { + return e + } + b.emitted = true + switch s.to { + case Chat: + if b.typ == "call" { + return s.chat(object{"tool_calls": []any{object{"index": s.toolIndex(b), "id": b.id, "type": "function", "function": object{"name": b.name, "arguments": ""}}}}, nil) + } + case Anthropic: + c := object{"type": "text", "text": ""} + if b.typ == "call" { + c = object{"type": "tool_use", "id": b.id, "name": b.name, "input": object{}} + } + return s.typed("content_block_start", object{"index": b.index, "content_block": c}) + case Responses: + if e := s.typed("response.output_item.added", object{"output_index": b.index, "item": s.item(b, false)}); e != nil { + return e + } + if b.typ == "text" { + return s.typed("response.content_part.added", object{"output_index": b.index, "item_id": s.itemID(b), "content_index": 0, "part": object{"type": "output_text", "text": "", "annotations": []any{}}}) + } + } + return nil +} +func (s *streamState) toolIndex(b *streamBlock) int { + i := 0 + for _, x := range s.blocks { + if x == b { + return i + } + if x.typ == "call" { + i++ + } + } + return i +} +func (s *streamState) add(key int, p part) (*streamBlock, error) { + if s.finished { + return nil, fmt.Errorf("content after finish") + } + if _, ok := s.source[key]; ok { + return nil, fmt.Errorf("duplicate content index %d", key) + } + if e := s.reserveBlock(); e != nil { + return nil, e + } + if e := s.retain(len(p.text) + len(p.args) + len(p.id) + len(p.name)); e != nil { + return nil, e + } + b := &streamBlock{part: p, index: len(s.blocks)} + b.content.WriteString(p.text) + b.content.WriteString(p.args) + b.identity.WriteString(p.id) + b.functionName.WriteString(p.name) + s.blocks = append(s.blocks, b) + s.source[key] = b + return b, s.open(b) +} +func (s *streamState) delta(b *streamBlock, text string) error { + if b == nil || b.closed || s.finished { + return fmt.Errorf("delta outside active content block") + } + if e := s.retain(len(text)); e != nil { + return e + } + b.content.WriteString(text) + // Builder.String shares storage without copying accumulated content. + if b.typ == "text" { + b.text = b.content.String() + } else { + b.args = b.content.String() + } + if e := s.open(b); e != nil { + return e + } + if !b.emitted { + return nil + } + whole := b.text + if b.typ == "call" { + whole = b.args + } + text = whole[b.sent:] + b.sent = len(whole) + if text == "" { + return nil + } + switch s.to { + case Chat: + if b.typ == "text" { + return s.chat(object{"content": text}, nil) + } + return s.chat(object{"tool_calls": []any{object{"index": s.toolIndex(b), "function": object{"arguments": text}}}}, nil) + case Anthropic: + d := object{"type": "text_delta", "text": text} + if b.typ == "call" { + d = object{"type": "input_json_delta", "partial_json": text} + } + return s.typed("content_block_delta", object{"index": b.index, "delta": d}) + case Responses: + name := "response.output_text.delta" + m := object{"output_index": b.index, "item_id": s.itemID(b), "delta": text} + if b.typ == "call" { + name = "response.function_call_arguments.delta" + } else { + m["content_index"] = 0 + } + return s.typed(name, m) + } + return nil +} +func (s *streamState) close(b *streamBlock) error { + if b == nil { + return fmt.Errorf("unknown content block") + } + if b.closed { + return nil + } + if b.typ == "call" { + if b.id == "" || b.name == "" { + return fmt.Errorf("incomplete tool identity") + } + if b.args == "" { + if e := s.delta(b, "{}"); e != nil { + return e + } + } + if _, e := decode([]byte(b.args)); e != nil { + return fmt.Errorf("invalid streamed tool arguments: %w", e) + } + } + if e := s.open(b); e != nil { + return e + } + if e := s.delta(b, ""); e != nil { + return e + } + b.closed = true + switch s.to { + case Anthropic: + return s.typed("content_block_stop", object{"index": b.index}) + case Responses: + m := object{"output_index": b.index, "item_id": s.itemID(b)} + name := "response.function_call_arguments.done" + if b.typ == "call" { + m["arguments"] = b.args + } else { + name = "response.output_text.done" + m["text"] = b.text + m["content_index"] = 0 + } + if e := s.typed(name, m); e != nil { + return e + } + if b.typ == "text" { + if e := s.typed("response.content_part.done", object{"output_index": b.index, "item_id": s.itemID(b), "content_index": 0, "part": object{"type": "output_text", "text": b.text, "annotations": []any{}}}); e != nil { + return e + } + } + return s.typed("response.output_item.done", object{"output_index": b.index, "item": s.item(b, true)}) + } + return nil +} +func (s *streamState) finish(reason string) error { + if s.finished { + return fmt.Errorf("duplicate finish") + } + if e := s.start(); e != nil { + return e + } + for _, b := range s.blocks { + if e := s.close(b); e != nil { + return e + } + } + s.r.finish = reason + s.finished = true + return nil +} +func (s *streamState) end() error { + if !s.finished { + return fmt.Errorf("stream ended without finish reason") + } + switch s.to { + case Chat: + if e := s.chat(object{}, wireFinish(Chat, s.r.finish)); e != nil { + return e + } + if s.r.usage.Known { + if e := s.event("", object{"id": s.r.id, "object": "chat.completion.chunk", "created": 0, "model": s.r.model, "choices": []any{}, "usage": usageJSON(Chat, s.r.usage)}); e != nil { + return e + } + } + return writeFlush(s.w, []byte("data: [DONE]\n\n")) + case Anthropic: + m := object{"delta": object{"stop_reason": wireFinish(Anthropic, s.r.finish), "stop_sequence": nil}} + if s.r.usage.Known { + m["usage"] = usageJSON(Anthropic, s.r.usage) + } + if e := s.typed("message_delta", m); e != nil { + return e + } + return s.typed("message_stop", object{}) + case Responses: + r := s.r + for _, b := range s.blocks { + r.parts = append(r.parts, b.part) + } + m, e := writeResponse(Responses, r) + if e != nil { + return e + } + items := []any{} + for _, b := range s.blocks { + items = append(items, s.item(b, true)) + } + m["output"] = items + name := "response.completed" + if r.finish == "length" { + name = "response.incomplete" + } + return s.typed(name, object{"response": m}) + } + return nil +} +func mergeUsage(k Kind, old Usage, m object) (Usage, error) { + if r := obj(m["response"]); r != nil { + m = r + } + if r := obj(m["message"]); r != nil { + m = r + } + u := obj(m["usage"]) + if u == nil { + return old, nil + } + raw, err := json.Marshal(u) + if err != nil { + return old, err + } + if len(raw) > maxStreamUsageBytes { + return old, fmt.Errorf("stream usage exceeds %d bytes", maxStreamUsageBytes) + } + if len(old.Raw) > 0 { + prior, e := decode(old.Raw) + if e != nil { + return old, e + } + for key, v := range u { + prior[key] = v + } + u = prior + } + n := usage(k, u) + if len(n.Raw) > maxStreamUsageBytes { + return old, fmt.Errorf("stream usage exceeds %d bytes", maxStreamUsageBytes) + } + return n, nil +} +func streamError(m object) error { + if m["error"] != nil { + return fmt.Errorf("upstream stream error: %v", m["error"]) + } + switch str(m["type"]) { + case "error", "response.failed": + return fmt.Errorf("upstream stream error: %v", m) + } + return nil +} + +func (s *streamState) consume(m object, event string) error { + if e := streamError(m); e != nil { + return e + } + u, err := mergeUsage(s.from, s.r.usage, m) + if err != nil { + return err + } + s.r.usage = u + if s.from == Chat { + if id := str(m["id"]); id != "" && !s.started { + s.r.id = id + } + if s.r.model == "" { + s.r.model = str(m["model"]) + } + cs := arr(m["choices"]) + if len(cs) > 1 { + return fmt.Errorf("multiple streaming choices unsupported") + } + for _, v := range cs { + c := obj(v) + if num(c["index"]) != 0 { + return fmt.Errorf("nonzero choice index unsupported") + } + if c["logprobs"] != nil { + return fmt.Errorf("logprobs unsupported") + } + d, e := chatOutput(obj(c["delta"])) + if e != nil { + return e + } + if e := fields(d, "role", "content", "tool_calls"); e != nil { + return e + } + if role := str(d["role"]); role != "" && role != "assistant" { + return fmt.Errorf("invalid delta role") + } + if e := s.start(); e != nil { + return e + } + if text, ok := d["content"]; ok && text != nil { + t, ok := text.(string) + if !ok { + return fmt.Errorf("non-text streamed content") + } + b := s.source[-1] + if b == nil { + var e error + b, e = s.add(-1, part{typ: "text"}) + if e != nil { + return e + } + } + if e := s.delta(b, t); e != nil { + return e + } + } + for _, v := range arr(d["tool_calls"]) { + t := obj(v) + if e := fields(t, "index", "id", "type", "function"); e != nil { + return e + } + idx, ok := t["index"] + if !ok || num(idx) < 0 { + return fmt.Errorf("invalid tool index") + } + if typ := str(t["type"]); typ != "" && typ != "function" { + return fmt.Errorf("unsupported streamed tool") + } + f := obj(t["function"]) + if e := fields(f, "name", "arguments"); e != nil { + return e + } + key := int(num(idx)) + b := s.source[key] + if b == nil { + var e error + b, e = s.add(key, part{typ: "call"}) + if e != nil { + return e + } + } + if b.emitted && (str(t["id"]) != "" || str(f["name"]) != "") { + return fmt.Errorf("fragmented tool identity after arguments unsupported") + } + if e := s.retain(len(str(t["id"])) + len(str(f["name"]))); e != nil { + return e + } + b.identity.WriteString(str(t["id"])) + b.functionName.WriteString(str(f["name"])) + b.id = b.identity.String() + b.name = b.functionName.String() + if e := s.delta(b, str(f["arguments"])); e != nil { + return e + } + } + if reason := str(c["finish_reason"]); reason != "" { + f, e := finishReason(Chat, reason) + if e != nil { + return e + } + if e = s.finish(f); e != nil { + return e + } + } + } + return nil + } + typ := str(m["type"]) + if typ == "" { + typ = event + } + if event != "" && typ != event { + return fmt.Errorf("SSE event/type mismatch") + } + // Discarded blocks still retain lifecycle bookkeeping in separate maps. + if (s.from == Anthropic && typ == "content_block_start" && (str(obj(m["content_block"])["type"]) == "thinking" || str(obj(m["content_block"])["type"]) == "redacted_thinking")) || + (s.from == Responses && typ == "response.output_item.added" && str(obj(m["item"])["type"]) == "reasoning") || + (s.from == Responses && (typ == "response.content_part.added" || typ == "response.reasoning_summary_part.added") && s.reasoning[int(num(m["output_index"]))] != nil) { + if e := s.reserveBlock(); e != nil { + return e + } + if typ == "response.output_item.added" { + if e := s.retain(len(str(obj(m["item"])["id"]))); e != nil { + return e + } + } + } + if s.from == Anthropic { + if handled, err := s.thinkingEvent(typ, m); handled || err != nil { + return err + } + switch typ { + case "ping": + return nil + case "message_start": + if s.started { + return fmt.Errorf("duplicate message_start") + } + x := obj(m["message"]) + s.r.id = str(x["id"]) + if s.r.model == "" { + s.r.model = str(x["model"]) + } + if len(arr(x["content"])) != 0 { + return fmt.Errorf("nonempty message_start content") + } + return s.start() + case "content_block_start": + x := obj(m["content_block"]) + p := part{typ: "text"} + initial := "" + switch str(x["type"]) { + case "text": + if e := fields(x, "type", "text"); e != nil { + return e + } + initial = str(x["text"]) + case "tool_use": + if e := fields(x, "type", "id", "name", "input"); e != nil { + return e + } + p = part{typ: "call", id: str(x["id"]), name: str(x["name"])} + if obj(x["input"]) == nil { + return fmt.Errorf("tool input must be an object") + } + if len(obj(x["input"])) > 0 { + b, e := json.Marshal(x["input"]) + if e != nil { + return e + } + initial = string(b) + } + default: + return fmt.Errorf("unsupported streamed block %q", str(x["type"])) + } + b, e := s.add(int(num(m["index"])), p) + if e != nil { + return e + } + return s.delta(b, initial) + case "content_block_delta": + b := s.source[int(num(m["index"]))] + d := obj(m["delta"]) + switch str(d["type"]) { + case "text_delta": + if b == nil || b.typ != "text" { + return fmt.Errorf("text delta block mismatch") + } + return s.delta(b, str(d["text"])) + case "input_json_delta": + if b == nil || b.typ != "call" { + return fmt.Errorf("tool delta block mismatch") + } + return s.delta(b, str(d["partial_json"])) + default: + return fmt.Errorf("unsupported delta %q", str(d["type"])) + } + case "content_block_stop": + return s.close(s.source[int(num(m["index"]))]) + case "message_delta": + d := obj(m["delta"]) + if e := fields(d, "stop_reason", "stop_sequence"); e != nil { + return e + } + if d["stop_sequence"] != nil { + return fmt.Errorf("stop sequence cannot be preserved") + } + f, e := finishReason(Anthropic, str(d["stop_reason"])) + if e != nil { + return e + } + return s.finish(f) + case "message_stop": + s.terminal = true + return nil + default: + return fmt.Errorf("unsupported Anthropic event %q", typ) + } + } + if handled, err := s.reasoningEvent(typ, m); handled || err != nil { + return err + } + switch typ { + case "response.created", "response.in_progress": + x := obj(m["response"]) + if !s.started { + s.r.id = str(x["id"]) + if s.r.model == "" { + s.r.model = str(x["model"]) + } + } + return s.start() + case "response.output_item.added": + x := obj(m["item"]) + key := int(num(m["output_index"])) + p := part{typ: "text"} + switch str(x["type"]) { + case "message": + if len(arr(x["content"])) > 0 { + return fmt.Errorf("nonempty initial response content") + } + case "function_call": + if _, ok := x["arguments"].(string); !ok { + return fmt.Errorf("function arguments must be a string") + } + p = part{typ: "call", id: str(x["call_id"]), name: str(x["name"])} + default: + return fmt.Errorf("unsupported output item %q", str(x["type"])) + } + b, e := s.add(key, p) + if e != nil { + return e + } + if p.typ == "call" { + return s.delta(b, str(x["arguments"])) + } + return nil + case "response.content_part.added": + p := obj(m["part"]) + if str(p["type"]) != "output_text" || num(m["content_index"]) != 0 { + return fmt.Errorf("unsupported response content part") + } + if len(arr(p["annotations"])) > 0 { + return fmt.Errorf("annotations unsupported") + } + return s.delta(s.source[int(num(m["output_index"]))], str(p["text"])) + case "response.output_text.delta", "response.function_call_arguments.delta": + b := s.source[int(num(m["output_index"]))] + if b == nil { + return fmt.Errorf("delta before output item") + } + if (typ == "response.output_text.delta") != (b.typ == "text") || num(m["content_index"]) != 0 { + return fmt.Errorf("response delta block mismatch") + } + return s.delta(b, str(m["delta"])) + case "response.output_text.done", "response.function_call_arguments.done": + b := s.source[int(num(m["output_index"]))] + if b == nil { + return fmt.Errorf("done before output item") + } + full := str(m["text"]) + got := b.text + if b.typ == "call" { + full = str(m["arguments"]) + got = b.args + } + if full != got { + return fmt.Errorf("final content disagrees with deltas") + } + return nil + case "response.content_part.done": + p := obj(m["part"]) + if str(p["type"]) != "output_text" || len(arr(p["annotations"])) > 0 || num(m["content_index"]) != 0 { + return fmt.Errorf("unsupported content part") + } + return nil + case "response.output_item.done": + return s.close(s.source[int(num(m["output_index"]))]) + case "response.completed", "response.incomplete": + r, e := readResponse(Responses, obj(m["response"])) + if e != nil { + return e + } + if len(r.parts) != len(s.blocks) { + return fmt.Errorf("final response disagrees with streamed items") + } + for i, p := range r.parts { + b := s.blocks[i] + if p.typ != b.typ || p.text != b.text || p.args != b.args || p.id != b.id || p.name != b.name { + return fmt.Errorf("final response disagrees with streamed content") + } + } + if e = s.finish(r.finish); e != nil { + return e + } + s.terminal = true + return nil + default: + return fmt.Errorf("unsupported Responses event %q", typ) + } +} + +// Stream does not own r. A caller must cancel or close a blocked upstream read. +func Stream(ctx context.Context, w io.Writer, r io.Reader, from, to Kind, model string) (Usage, error) { + return streamWithLimits(ctx, w, r, from, to, model, maxStreamBytes, maxStreamBlocks, maxPassthroughBytes) +} + +func readSSELine(ctx context.Context, br *bufio.Reader, remaining int) (string, error) { + var line strings.Builder + for { + if err := ctx.Err(); err != nil { + return "", err + } + fragment, err := br.ReadSlice('\n') + if len(fragment) > remaining-line.Len() { + return "", fmt.Errorf("SSE line/event exceeds size limit") + } + line.Write(fragment) + if err != bufio.ErrBufferFull { + return line.String(), err + } + } +} + +func streamWithLimits(ctx context.Context, w io.Writer, r io.Reader, from, to Kind, model string, byteLimit, blockLimit, totalLimit int) (Usage, error) { + if !valid(from) || !valid(to) { + return Usage{}, fmt.Errorf("unknown protocol") + } + s := &streamState{ctx: ctx, w: w, from: from, to: to, r: response{model: model}, source: map[int]*streamBlock{}, byteLimit: byteLimit, blockLimit: blockLimit} + br := bufio.NewReader(r) + data := []string{} + event := "" + size := 0 + total := 0 + dispatch := func() error { + if len(data) == 0 { + event = "" + return nil + } + payload := strings.Join(data, "\n") + data = nil + ev := event + event = "" + size = 0 + if payload == "[DONE]" { + if from != Chat { + return fmt.Errorf("unexpected DONE marker") + } + s.terminal = true + return nil + } + m, e := decode([]byte(payload)) + if e != nil { + return fmt.Errorf("invalid SSE JSON: %w", e) + } + if e = streamError(m); e != nil { + return e + } + if from == to { + u, err := mergeUsage(from, s.r.usage, m) + if err != nil { + return err + } + s.r.usage = u + switch str(m["type"]) { + case "message_stop", "response.completed", "response.incomplete": + s.terminal = true + } + return nil + } + if s.terminal { + return fmt.Errorf("data after terminal event") + } + return s.consume(m, ev) + } + for { + if e := ctx.Err(); e != nil { + return s.r.usage, e + } + line, e := readSSELine(ctx, br, byteLimit-size) + if e != nil && e != io.EOF { + return s.r.usage, e + } + if len(line) > 0 { + if from == to { + if total+len(line) > totalLimit { + return s.r.usage, fmt.Errorf("stream passthrough exceeds %d bytes", totalLimit) + } + total += len(line) + if err := writeFlush(w, []byte(line)); err != nil { + return s.r.usage, err + } + } + size += len(line) + line = strings.TrimSuffix(strings.TrimSuffix(line, "\n"), "\r") + if line == "" { + if err := dispatch(); err != nil { + return s.r.usage, err + } + size = 0 + if s.terminal { + break + } + } else if !strings.HasPrefix(line, ":") { + key, val, _ := strings.Cut(line, ":") + val = strings.TrimPrefix(val, " ") + switch key { + case "data": + data = append(data, val) + case "event": + event = val + } + } + } + if e != nil { + if e != io.EOF { + return s.r.usage, e + } + if err := dispatch(); err != nil { + return s.r.usage, err + } + break + } + } + if e := ctx.Err(); e != nil { + return s.r.usage, e + } + if from == to { + return s.r.usage, nil + } + if !s.terminal { + return s.r.usage, fmt.Errorf("truncated SSE stream: missing terminal event") + } + if e := s.end(); e != nil { + return s.r.usage, e + } + return s.r.usage, nil +} diff --git a/internal/protocol/stream_limits_test.go b/internal/protocol/stream_limits_test.go new file mode 100644 index 0000000..8174bf5 --- /dev/null +++ b/internal/protocol/stream_limits_test.go @@ -0,0 +1,164 @@ +package protocol + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "strings" + "testing" +) + +func limitEvent(s string) string { return "data: " + s + "\n\n" } +func TestSSEReadLimits(t *testing.T) { + for _, to := range []Kind{Chat, Anthropic} { + var out bytes.Buffer + _, err := streamWithLimits(context.Background(), &out, strings.NewReader(strings.Repeat("x", 10000)), Chat, to, "", 64, 8, 64) + if err == nil || !strings.Contains(err.Error(), "size limit") || out.Len() != 0 { + t.Fatalf("%s: %v", to, err) + } + } + for _, input := range []string{"12345678", "1234567\n"} { + line, err := readSSELine(context.Background(), bufio.NewReader(strings.NewReader(input)), 8) + if line != input || (err != nil && err != io.EOF) { + t.Fatalf("%q %v", line, err) + } + } + var out bytes.Buffer + _, err := streamWithLimits(context.Background(), &out, strings.NewReader(":123456\n:123456\n"), Chat, Chat, "", 12, 8, 16) + if err == nil || out.String() != ":123456\n" { + t.Fatalf("%q %v", out.String(), err) + } +} +func TestStreamPassthroughTotalLimit(t *testing.T) { + frame := "data: {\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":null}]}\n\n" + var out bytes.Buffer + _, err := streamWithLimits(context.Background(), &out, strings.NewReader(strings.Repeat(frame, 4)), Chat, Chat, "", 512, 8, 2*len(frame)) + if err == nil || !strings.Contains(err.Error(), "passthrough") || out.Len() != 2*len(frame) { + t.Fatalf("out=%d %q %v", out.Len(), out.String(), err) + } +} +func TestStreamCumulativeLimits(t *testing.T) { + for _, field := range []string{"text", "arguments", "identity"} { + delta := object{"content": strings.Repeat("x", 100)} + if field != "text" { + call := object{"index": 0} + if field == "arguments" { + call["function"] = object{"arguments": strings.Repeat("x", 100)} + } else { + call["id"] = strings.Repeat("x", 100) + } + delta = object{"tool_calls": []any{call}} + } + event := limitEvent(fmt.Sprintf("{\"choices\":[{\"index\":0,\"delta\":%s}]}", mustLimitJSON(t, delta))) + _, err := streamWithLimits(context.Background(), io.Discard, strings.NewReader(strings.Repeat(event, 6)), Chat, Responses, "", 512, 8, 512) + if err == nil || !strings.Contains(err.Error(), "retained content") { + t.Fatalf("%s: %v", field, err) + } + } +} +func TestStreamBlockLimits(t *testing.T) { + for _, kind := range []string{"text", "thinking", "reasoning", "summary", "tool"} { + from := Anthropic + in := limitEvent("{\"type\":\"message_start\",\"message\":{\"content\":[]}}") + if kind == "reasoning" || kind == "summary" || kind == "tool" { + from = Responses + in = "" + } + if kind == "summary" { + in = limitEvent("{\"type\":\"response.output_item.added\",\"output_index\":0,\"item\":{\"type\":\"reasoning\",\"id\":\"r\"}}") + } + for i := 0; i < 4; i++ { + m := object{"type": "content_block_start", "index": i, "content_block": object{"type": kind, "text": ""}} + switch kind { + case "thinking": + m["content_block"] = object{"type": "thinking", "thinking": "", "signature": ""} + case "reasoning", "tool": + item := object{"type": "reasoning", "id": "r"} + if kind == "tool" { + item = object{"type": "function_call", "call_id": "c", "name": "f", "arguments": ""} + } + m = object{"type": "response.output_item.added", "output_index": i, "item": item} + case "summary": + m = object{"type": "response.reasoning_summary_part.added", "output_index": 0, "item_id": "r", "summary_index": i, "part": object{"type": "summary_text", "text": ""}} + } + in += limitEvent(mustLimitJSON(t, m)) + } + _, err := streamWithLimits(context.Background(), io.Discard, strings.NewReader(in), from, Chat, "", 1024, 3, 1024) + if err == nil || !strings.Contains(err.Error(), "3 blocks") { + t.Fatalf("%s: %v", kind, err) + } + } +} + +func TestReasoningIdentityCumulativeLimit(t *testing.T) { + var in strings.Builder + for i := 0; i < 6; i++ { + in.WriteString(limitEvent(mustLimitJSON(t, object{"type": "response.output_item.added", "output_index": i, "item": object{"type": "reasoning", "id": strings.Repeat("r", 100)}}))) + } + _, err := streamWithLimits(context.Background(), io.Discard, strings.NewReader(in.String()), Responses, Chat, "", 512, 8, 512) + if err == nil || !strings.Contains(err.Error(), "retained content") { + t.Fatal(err) + } +} +func TestStreamBufferSemantics(t *testing.T) { + var out bytes.Buffer + s := &streamState{ctx: context.Background(), w: &out, from: Chat, to: Responses, source: map[int]*streamBlock{}, byteLimit: 64, blockLimit: 4} + b, err := s.add(0, part{typ: "call"}) + if err != nil { + t.Fatal(err) + } + if err = s.delta(b, "{\"a\":"); err != nil { + t.Fatal(err) + } + if out.Len() != 0 { + t.Fatal("incomplete identity emitted") + } + b.id, b.name = "c", "f" + if err = s.delta(b, "1}"); err != nil { + t.Fatal(err) + } + if b.args != "{\"a\":1}" || b.sent != 7 { + t.Fatal(b.part) + } + if err = s.close(b); err != nil { + t.Fatal(err) + } + text, err := s.add(1, part{typ: "text"}) + if err != nil { + t.Fatal(err) + } + for _, v := range []string{"ab", "cd"} { + if err = s.delta(text, v); err != nil { + t.Fatal(err) + } + } + if text.text != "abcd" { + t.Fatal(text.text) + } + if err = s.finish("stop"); err != nil { + t.Fatal(err) + } + if err = s.end(); err != nil { + t.Fatal(err) + } + if !strings.Contains(out.String(), "abcd") || !strings.Contains(out.String(), "response.completed") { + t.Fatal(out.String()) + } + s.finished = false + text.closed = false + s.byteLimit = s.retained + if err = s.delta(text, "x"); err == nil || text.text != "abcd" { + t.Fatal("overflow changed content") + } +} +func mustLimitJSON(t *testing.T, m object) string { + t.Helper() + b, err := json.Marshal(m) + if err != nil { + t.Fatal(err) + } + return string(b) +} diff --git a/internal/protocol/stream_usage_limits_test.go b/internal/protocol/stream_usage_limits_test.go new file mode 100644 index 0000000..5873e5f --- /dev/null +++ b/internal/protocol/stream_usage_limits_test.go @@ -0,0 +1,89 @@ +package protocol + +import ( + "context" + "fmt" + "io" + "reflect" + "strings" + "testing" +) + +func TestMergeUsageSizeBoundary(t *testing.T) { + for _, kind := range []Kind{Chat, Responses, Anthropic} { + t.Run(string(kind), func(t *testing.T) { + m := object{"padding": strings.Repeat("x", maxStreamUsageBytes-len(`{"padding":""}`))} + u, err := mergeUsage(kind, Usage{}, object{"usage": m}) + if err != nil || len(u.Raw) != maxStreamUsageBytes { + t.Fatalf("exact limit: size=%d err=%v", len(u.Raw), err) + } + for _, update := range []object{ + {"usage": object{"padding": str(m["padding"]) + "x"}}, + {"usage": object{"new_key": 1}}, + } { + got, err := mergeUsage(kind, u, update) + if err == nil || !strings.Contains(err.Error(), "usage exceeds 16384 bytes") || !reflect.DeepEqual(got, u) { + t.Fatalf("overflow: retained=%d err=%v", len(got.Raw), err) + } + } + for i := 0; i < 3; i++ { + got, err := mergeUsage(kind, u, object{"usage": m}) + if err != nil || !reflect.DeepEqual(got, u) { + t.Fatalf("replacement at limit: %v", err) + } + } + }) + } +} + +func TestStreamUsageSizeLimits(t *testing.T) { + for _, from := range []Kind{Chat, Responses, Anthropic} { + for _, to := range []Kind{Chat, Responses, Anthropic} { + for _, cumulative := range []bool{false, true} { + t.Run(fmt.Sprintf("%s/%s/cumulative=%t", from, to, cumulative), func(t *testing.T) { + var input strings.Builder + writeUsage := func(u object) { + m := object{"usage": u} + switch from { + case Anthropic: + m["type"] = "ping" + case Responses: + m = object{"type": "response.in_progress", "response": m} + } + input.WriteString(limitEvent(mustLimitJSON(t, m))) + } + writeUsage(object{"prompt_tokens": 7, "input_tokens": 7}) + if cumulative { + for i := 0; i < 32; i++ { + writeUsage(object{fmt.Sprintf("key_%d", i): strings.Repeat("x", 1024)}) + } + } else { + writeUsage(object{"padding": strings.Repeat("x", maxStreamUsageBytes)}) + } + u, err := Stream(context.Background(), io.Discard, strings.NewReader(input.String()), from, to, "") + if err == nil || !strings.Contains(err.Error(), "usage exceeds 16384 bytes") { + t.Fatalf("expected usage limit error, got %v", err) + } + if !u.Known || u.Input != 7 || len(u.Raw) > maxStreamUsageBytes { + t.Fatalf("lost valid usage: known=%t input=%d size=%d", u.Known, u.Input, len(u.Raw)) + } + }) + } + } + } +} + +func TestMergeUsagePartialUpdates(t *testing.T) { + u, err := mergeUsage(Anthropic, Usage{}, object{"message": object{"usage": object{"input_tokens": 5, "cache_read_input_tokens": 2}}}) + if err != nil { + t.Fatal(err) + } + u, err = mergeUsage(Anthropic, u, object{"usage": object{"output_tokens": 3}}) + if err != nil || !u.Known || u.Input != 7 || u.Output != 3 || u.CacheRead != 2 { + t.Fatalf("partial update: %+v err=%v", u, err) + } + got, err := mergeUsage(Anthropic, u, object{}) + if err != nil || !reflect.DeepEqual(got, u) { + t.Fatalf("missing usage changed totals: %+v err=%v", got, err) + } +} diff --git a/internal/protocol/validation_test.go b/internal/protocol/validation_test.go new file mode 100644 index 0000000..687b94f --- /dev/null +++ b/internal/protocol/validation_test.go @@ -0,0 +1,39 @@ +package protocol + +import ( + "context" + "io" + "strings" + "testing" +) + +func TestMalformedInitialToolArguments(t *testing.T) { + for _, bad := range []string{`null`, `[]`, `12`, `"not-an-object"`} { + src := `data: {"type":"content_block_start","index":0,"content_block":{"type":"tool_use","id":"tool1","name":"fn","input":` + bad + `}}` + "\n\n" + _, err := Stream(context.Background(), io.Discard, strings.NewReader(src), Anthropic, Chat, "m") + if err == nil || !strings.Contains(err.Error(), "tool input must be an object") { + t.Fatalf("input %s: %v", bad, err) + } + } + for _, field := range []string{``, `,"arguments":null`, `,"arguments":{}`, `,"arguments":12`} { + src := `data: {"type":"response.output_item.added","output_index":0,"item":{"type":"function_call","call_id":"call1","name":"fn"` + field + `}}` + "\n\n" + _, err := Stream(context.Background(), io.Discard, strings.NewReader(src), Responses, Chat, "m") + if err == nil || !strings.Contains(err.Error(), "function arguments must be a string") { + t.Fatalf("field %s: %v", field, err) + } + } +} + +func TestDecodeNodeLimit(t *testing.T) { + dense := `{"model":"m","pad":[` + strings.Repeat("0,", maxJSONNodes) + `0]}` + if _, err := ConvertRequest(Chat, Chat, []byte(dense), "m"); err == nil || !strings.Contains(err.Error(), "exceeds") { + t.Fatalf("dense body accepted: %v", err) + } + under := `{"model":"m","pad":[` + strings.Repeat("0,", maxJSONNodes/2) + `0]}` + if _, err := ConvertRequest(Chat, Chat, []byte(under), "m"); err != nil { + t.Fatal(err) + } + if _, err := ConvertRequest(Chat, Chat, []byte(`{"broken":`), "m"); err == nil || strings.Contains(err.Error(), "exceeds") { + t.Fatalf("malformed JSON misreported: %v", err) + } +} diff --git a/internal/server/admin.go b/internal/server/admin.go new file mode 100644 index 0000000..d238b8f --- /dev/null +++ b/internal/server/admin.go @@ -0,0 +1,265 @@ +package server + +import ( + "database/sql" + "encoding/json" + "net/http" + "net/url" + "strings" + "time" +) + +type channel struct { + ID string `json:"id"` + Name string `json:"name"` + BaseURL string `json:"base_url"` + Protocol string `json:"protocol"` + Enabled bool `json:"enabled"` + Models map[string]string `json:"models"` + APIKey string `json:"api_key,omitempty"` +} +type apiKey struct { + Revoked bool `json:"revoked"` + ID string `json:"id"` + Name string `json:"name"` + Prefix string `json:"prefix"` + Enabled bool `json:"enabled"` + AllChannels bool `json:"all_channels"` + Channels []string `json:"channels"` + ExpiresAt string `json:"expires_at"` + CreatedAt string `json:"created_at"` +} + +func (s *Server) loadChannels() ([]channel, error) { + rows, e := s.db.Query("SELECT id,name,base_url,protocol,enabled,models,api_key FROM channels ORDER BY name") + if e != nil { + return nil, e + } + defer rows.Close() + out := []channel{} + for rows.Next() { + var c channel + var m string + if e = rows.Scan(&c.ID, &c.Name, &c.BaseURL, &c.Protocol, &c.Enabled, &m, &c.APIKey); e != nil { + return nil, e + } + if e = json.Unmarshal([]byte(m), &c.Models); e != nil { + return nil, e + } + out = append(out, c) + } + return out, rows.Err() +} +func (s *Server) channels(w http.ResponseWriter, r *http.Request) { + if r.Method == "GET" { + cs, e := s.loadChannels() + if e != nil { + errJSON(w, 500, "database error") + return + } + for i := range cs { + cs[i].APIKey = "" + } + writeJSON(w, cs) + return + } + if r.Method != "POST" { + errJSON(w, 405, "method not allowed") + return + } + var c channel + if decode(w, r, &c) != nil { + errJSON(w, 400, "invalid channel") + return + } + u, e := url.Parse(c.BaseURL) + if e != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Hostname() == "" || u.User != nil || u.RawQuery != "" || u.ForceQuery || u.Fragment != "" || strings.Contains(c.BaseURL, "#") || c.Name == "" || strings.Contains(c.Name, "/") || (c.Protocol != "openai-chat" && c.Protocol != "openai-responses" && c.Protocol != "anthropic") { + errJSON(w, 400, "invalid channel") + return + } + if c.Models == nil { + c.Models = map[string]string{} + } + for a, m := range c.Models { + if a == "" || m == "" { + errJSON(w, 400, "invalid model mapping") + return + } + } + c.BaseURL = strings.TrimRight(c.BaseURL, "/") + m, _ := json.Marshal(c.Models) + if c.ID == "" { + c.ID = randomID() + _, e = s.db.Exec("INSERT INTO channels VALUES(?,?,?,?,?,?,?,?)", c.ID, c.Name, c.BaseURL, c.Protocol, c.Enabled, false, string(m), c.APIKey) + } else { + var old string + if e = s.db.QueryRow("SELECT api_key FROM channels WHERE id=?", c.ID).Scan(&old); e == sql.ErrNoRows { + errJSON(w, 404, "not found") + return + } else if e != nil { + errJSON(w, 500, "database error") + return + } + if c.APIKey == "" { + c.APIKey = old + } + _, e = s.db.Exec("UPDATE channels SET name=?,base_url=?,protocol=?,enabled=?,models=?,api_key=? WHERE id=?", c.Name, c.BaseURL, c.Protocol, c.Enabled, string(m), c.APIKey, c.ID) + } + if e != nil { + errJSON(w, 400, "channel could not be saved; names must be unique") + return + } + c.APIKey = "" + writeJSON(w, c) +} +func (s *Server) channelID(w http.ResponseWriter, r *http.Request) { + if r.Method != "DELETE" { + errJSON(w, 405, "method not allowed") + return + } + res, e := s.db.Exec("DELETE FROM channels WHERE id=?", strings.TrimPrefix(r.URL.Path, "/admin/api/channels/")) + mutation(w, res, e) +} +func mutation(w http.ResponseWriter, res sql.Result, e error) { + if e != nil { + errJSON(w, 500, "database error") + return + } + n, e := res.RowsAffected() + if e != nil { + errJSON(w, 500, "database error") + return + } + if n == 0 { + errJSON(w, 404, "not found") + return + } + writeJSON(w, map[string]bool{"ok": true}) +} +func (s *Server) keys(w http.ResponseWriter, r *http.Request) { + if r.Method == "GET" { + rows, e := s.db.Query("SELECT id,name,prefix,enabled,all_channels,channels,expires_at,created_at,revoked FROM keys ORDER BY created_at DESC") + if e != nil { + errJSON(w, 500, "database error") + return + } + defer rows.Close() + out := []apiKey{} + for rows.Next() { + var k apiKey + var ch string + if rows.Scan(&k.ID, &k.Name, &k.Prefix, &k.Enabled, &k.AllChannels, &ch, &k.ExpiresAt, &k.CreatedAt, &k.Revoked) != nil { + errJSON(w, 500, "database error") + return + } + if json.Unmarshal([]byte(ch), &k.Channels) != nil { + errJSON(w, 500, "database error") + return + } + out = append(out, k) + } + if rows.Err() != nil { + errJSON(w, 500, "database error") + return + } + writeJSON(w, out) + return + } + if r.Method != "POST" { + errJSON(w, 405, "method not allowed") + return + } + var k apiKey + if decode(w, r, &k) != nil || k.Name == "" { + errJSON(w, 400, "invalid key") + return + } + if k.ExpiresAt != "" { + t, e := time.Parse(time.RFC3339, k.ExpiresAt) + if e != nil || !t.After(time.Now()) { + errJSON(w, 400, "invalid expiry") + return + } + k.ExpiresAt = t.UTC().Format(time.RFC3339Nano) + } + if k.Channels == nil { + k.Channels = []string{} + } + for _, id := range k.Channels { + var n int + if s.db.QueryRow("SELECT count(*) FROM channels WHERE id=?", id).Scan(&n) != nil || n == 0 { + errJSON(w, 400, "invalid channel permission") + return + } + } + raw := "sk-" + randomID() + ch, _ := json.Marshal(k.Channels) + _, e := s.db.Exec("INSERT INTO keys(id,name,prefix,hash,enabled,all_channels,channels,expires_at,created_at) VALUES(?,?,?,?,?,?,?,?,?)", randomID(), k.Name, raw[:11], hash(raw), true, k.AllChannels, string(ch), k.ExpiresAt, time.Now().UTC().Format(time.RFC3339Nano)) + if e != nil { + errJSON(w, 500, "database error") + return + } + writeJSON(w, map[string]string{"key": raw}) +} +func (s *Server) keyID(w http.ResponseWriter, r *http.Request) { + id := strings.TrimPrefix(r.URL.Path, "/admin/api/keys/") + if r.Method == "DELETE" { + res, e := s.db.Exec("UPDATE keys SET enabled=0,revoked=1 WHERE id=?", id) + mutation(w, res, e) + return + } + if r.Method == "PATCH" { + var x struct { + Enabled *bool `json:"enabled"` + } + if decode(w, r, &x) != nil || x.Enabled == nil { + errJSON(w, 400, "invalid request") + return + } + res, e := s.db.Exec("UPDATE keys SET enabled=? WHERE id=? AND revoked=0", *x.Enabled, id) + mutation(w, res, e) + return + } + errJSON(w, 405, "method not allowed") +} +func (s *Server) authenticate(r *http.Request) (apiKey, error) { + var k apiKey + raw := r.Header.Get("x-api-key") + if a := r.Header.Get("Authorization"); a != "" { + parts := strings.Fields(a) + if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") { + return k, sql.ErrNoRows + } + if raw != "" && raw != parts[1] { + return k, sql.ErrNoRows + } + raw = parts[1] + } + if raw == "" { + return k, sql.ErrNoRows + } + var ch string + e := s.db.QueryRow("SELECT id,name,prefix,enabled,all_channels,channels,expires_at,created_at FROM keys WHERE hash=? AND enabled=1 AND revoked=0", hash(raw)).Scan(&k.ID, &k.Name, &k.Prefix, &k.Enabled, &k.AllChannels, &ch, &k.ExpiresAt, &k.CreatedAt) + if e != nil { + return k, e + } + if k.ExpiresAt != "" { + t, e := time.Parse(time.RFC3339Nano, k.ExpiresAt) + if e != nil || !time.Now().Before(t) { + return apiKey{}, sql.ErrNoRows + } + } + e = json.Unmarshal([]byte(ch), &k.Channels) + return k, e +} +func (k apiKey) allows(id string) bool { + if k.AllChannels { + return true + } + for _, v := range k.Channels { + if v == id { + return true + } + } + return false +} diff --git a/internal/server/discovery.go b/internal/server/discovery.go new file mode 100644 index 0000000..b6bf397 --- /dev/null +++ b/internal/server/discovery.go @@ -0,0 +1,136 @@ +package server + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "io" + "net/http" + "net/url" + "sort" + "strings" + "time" +) + +const ( + discoveryTimeout = 30 * time.Second + discoveryMaxBody = 1 << 20 + discoveryMaxPages = 20 +) + +func (s *Server) discoverModels(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + errJSON(w, 405, "method not allowed") + return + } + var c channel + if decode(w, r, &c) != nil { + errJSON(w, 400, "invalid channel") + return + } + u, e := url.Parse(c.BaseURL) + if e != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Hostname() == "" || u.User != nil || u.RawQuery != "" || u.ForceQuery || strings.Contains(c.BaseURL, "#") || (c.Protocol != "openai-chat" && c.Protocol != "openai-responses" && c.Protocol != "anthropic") { + errJSON(w, 400, "invalid channel") + return + } + if c.ID != "" { + var old string + e = s.db.QueryRowContext(r.Context(), "SELECT api_key FROM channels WHERE id=?", c.ID).Scan(&old) + if e == sql.ErrNoRows { + errJSON(w, 404, "not found") + return + } + if e != nil { + errJSON(w, 500, "database error") + return + } + if c.APIKey == "" { + c.APIKey = old + } + } + ctx, cancel := context.WithTimeout(r.Context(), discoveryTimeout) + defer cancel() + models, e := s.fetchModels(ctx, c) + if e != nil { + if errors.Is(ctx.Err(), context.DeadlineExceeded) { + errJSON(w, 504, "model discovery timed out") + } else { + errJSON(w, 502, "model discovery failed") + } + return + } + writeJSON(w, map[string][]string{"models": models}) +} + +func (s *Server) fetchModels(ctx context.Context, c channel) ([]string, error) { + // Share the bounded transport, but never forward credentials across redirects. + client := &http.Client{Transport: s.client.Transport, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }} + endpoint := strings.TrimRight(c.BaseURL, "/") + "/models" + if c.Protocol == "anthropic" { + endpoint = strings.TrimRight(c.BaseURL, "/") + "/v1/models" + } + seen := map[string]bool{} + cursors := map[string]bool{} + after := "" + for page := 0; page < discoveryMaxPages; page++ { + u := endpoint + if after != "" { + u += "?" + url.Values{"after_id": {after}}.Encode() + } + req, e := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) + if e != nil { + return nil, e + } + req.Header.Set("Accept", "application/json") + if c.Protocol == "anthropic" { + req.Header.Set("x-api-key", c.APIKey) + req.Header.Set("anthropic-version", "2023-06-01") + } else { + req.Header.Set("Authorization", "Bearer "+c.APIKey) + } + res, e := client.Do(req) + if e != nil { + return nil, e + } + if res.StatusCode != http.StatusOK { + res.Body.Close() + return nil, errors.New("upstream status") + } + body, e := io.ReadAll(io.LimitReader(res.Body, discoveryMaxBody+1)) + res.Body.Close() + if e != nil || len(body) > discoveryMaxBody { + return nil, errors.New("upstream body") + } + var result struct { + Data []struct { + ID string `json:"id"` + } `json:"data"` + HasMore bool `json:"has_more"` + LastID string `json:"last_id"` + } + if json.Unmarshal(body, &result) != nil || result.Data == nil { + return nil, errors.New("upstream models") + } + for _, model := range result.Data { + if strings.TrimSpace(model.ID) == "" { + return nil, errors.New("upstream model id") + } + seen[model.ID] = true + } + if c.Protocol != "anthropic" || !result.HasMore { + models := make([]string, 0, len(seen)) + for id := range seen { + models = append(models, id) + } + sort.Strings(models) + return models, nil + } + if result.LastID == "" || cursors[result.LastID] { + return nil, errors.New("upstream pagination") + } + cursors[result.LastID] = true + after = result.LastID + } + return nil, errors.New("upstream page limit") +} diff --git a/internal/server/discovery_test.go b/internal/server/discovery_test.go new file mode 100644 index 0000000..395d1fb --- /dev/null +++ b/internal/server/discovery_test.go @@ -0,0 +1,273 @@ +package server + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "reflect" + "strings" + "testing" + "time" +) + +const discoveryPath = "/admin/api/channels/discover-models" + +func discover(t *testing.T, s *Server, cookie *http.Cookie, c channel) *httptest.ResponseRecorder { + t.Helper() + b, e := json.Marshal(c) + if e != nil { + t.Fatal(e) + } + return call(s, "POST", discoveryPath, string(b), cookie) +} + +func TestDiscoveryProtocolsAndReadOnly(t *testing.T) { + for _, proto := range []string{"openai-chat", "openai-responses", "anthropic"} { + t.Run(proto, func(t *testing.T) { + s := testServer(t) + cookie := loginCookie(t, s) + calls := 0 + up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls++ + if r.Method != "GET" || r.URL.Path != "/v1/models" { + t.Error(r.Method, r.URL) + } + if proto == "anthropic" { + if r.Header.Get("x-api-key") != "secret" || r.Header.Get("anthropic-version") != "2023-06-01" || r.Header.Get("Authorization") != "" { + t.Error("wrong anthropic headers") + } + if r.URL.Query().Get("after_id") == "" { + fmt.Fprint(w, `{"data":[{"id":"z"}],"has_more":true,"last_id":"cursor/&?"}`) + return + } + if r.URL.Query().Get("after_id") != "cursor/&?" { + t.Error(r.URL) + } + } else if r.Header.Get("Authorization") != "Bearer secret" || r.Header.Get("x-api-key") != "" { + t.Error("wrong openai headers") + } + fmt.Fprint(w, `{"data":[{"id":"z"},{"id":"a"},{"id":"a"}]}`) + })) + defer up.Close() + draft := channel{BaseURL: up.URL + "/v1/", Protocol: proto, APIKey: "secret"} + if proto == "anthropic" { + draft.BaseURL = up.URL + "/" + } + w := discover(t, s, cookie, draft) + if w.Code != 200 || strings.TrimSpace(w.Body.String()) != `{"models":["a","z"]}` { + t.Fatal(w.Code, w.Body.String()) + } + wantCalls := 1 + if proto == "anthropic" { + wantCalls = 2 + } + if calls != wantCalls { + t.Fatal(calls) + } + cs, e := s.loadChannels() + if e != nil || len(cs) != 0 { + t.Fatal(cs, e) + } + draft.Name = "saved" + draft.Models = map[string]string{"alias": "a"} + b, _ := json.Marshal(draft) + w = call(s, "POST", "/admin/api/channels", string(b), cookie) + if w.Code != 200 { + t.Fatal(w.Body.String()) + } + var saved channel + if e := json.Unmarshal(w.Body.Bytes(), &saved); e != nil { + t.Fatal(e) + } + before, _ := s.loadChannels() + draft.ID, draft.APIKey, draft.Name, draft.Models = saved.ID, "", "unsaved", nil + if w = discover(t, s, cookie, draft); w.Code != 200 { + t.Fatal(w.Code, w.Body.String()) + } + after, _ := s.loadChannels() + if !reflect.DeepEqual(before, after) { + t.Fatal("discovery changed channel") + } + replacement := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if proto == "anthropic" { + if r.Header.Get("x-api-key") != "replacement" { + t.Error("draft key not used") + } + } else if r.Header.Get("Authorization") != "Bearer replacement" { + t.Error("draft key not used") + } + fmt.Fprint(w, `{"data":[]}`) + })) + defer replacement.Close() + draft.BaseURL, draft.APIKey = replacement.URL, "replacement" + w = discover(t, s, cookie, draft) + if w.Code != 200 || strings.TrimSpace(w.Body.String()) != `{"models":[]}` { + t.Fatal(w.Code, w.Body.String()) + } + after, _ = s.loadChannels() + if !reflect.DeepEqual(before, after) { + t.Fatal("draft replacement was saved") + } + var logs int + if e := s.db.QueryRow("SELECT count(*) FROM logs").Scan(&logs); e != nil || logs != 0 { + t.Fatal(logs, e) + } + }) + } +} + +func TestDiscoveryAuthAndValidation(t *testing.T) { + s := testServer(t) + cookie := loginCookie(t, s) + if w := call(s, "POST", discoveryPath, "{}", nil); w.Code != 401 { + t.Fatal(w.Code) + } + for _, header := range []string{"Origin", "X-Requested-With"} { + r := httptest.NewRequest("POST", discoveryPath, strings.NewReader("{}")) + r.AddCookie(cookie) + r.Header.Set("Origin", "http://example.com") + r.Header.Set("X-Requested-With", "ai-router") + r.Header.Del(header) + w := httptest.NewRecorder() + s.ServeHTTP(w, r) + if w.Code != 403 { + t.Fatal(header, w.Code) + } + } + for _, method := range []string{"GET", "DELETE", "PUT"} { + if w := call(s, method, discoveryPath, "{}", cookie); w.Code != 405 { + t.Fatal(method, w.Code) + } + } + for _, base := range []string{"", "ftp://example.com", "http://", "http://user:secret@example.com", "http://example.com?x=1", "http://example.com?", "http://example.com#"} { + if w := discover(t, s, cookie, channel{BaseURL: base, Protocol: "openai-chat"}); w.Code != 400 { + t.Fatal(base, w.Code) + } + } + if w := discover(t, s, cookie, channel{BaseURL: "http://example.com", Protocol: "unknown"}); w.Code != 400 { + t.Fatal(w.Code) + } + if w := discover(t, s, cookie, channel{ID: "missing", BaseURL: "http://example.com", Protocol: "anthropic"}); w.Code != 404 { + t.Fatal(w.Code) + } + for _, body := range []string{"{", "{} {}", strings.Repeat(" ", maxBody+1)} { + if w := call(s, "POST", discoveryPath, body, cookie); w.Code != 400 { + t.Fatal(w.Code) + } + } +} + +func TestDiscoveryUpstreamErrors(t *testing.T) { + for _, tc := range []struct { + name, body string + status, calls int + }{ + {"status", "secret upstream details", 401, 1}, + {"json", "secret", 200, 1}, + {"missing data", "{}", 200, 1}, + {"null", `{"data":null}`, 200, 1}, + {"empty id", `{"data":[{"id":""}]}`, 200, 1}, + {"trailing", `{"data":[]}{}`, 200, 1}, + {"oversized", strings.Repeat("x", discoveryMaxBody+1), 200, 1}, + {"missing cursor", `{"data":[],"has_more":true}`, 200, 1}, + {"repeated cursor", `{"data":[],"has_more":true,"last_id":"same"}`, 200, 2}, + {"page limit", "", 200, discoveryMaxPages}, + } { + t.Run(tc.name, func(t *testing.T) { + s := testServer(t) + cookie := loginCookie(t, s) + calls := 0 + up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls++ + w.WriteHeader(tc.status) + if tc.name == "page limit" { + fmt.Fprintf(w, `{"data":[],"has_more":true,"last_id":"%d"}`, calls) + } else { + fmt.Fprint(w, tc.body) + } + })) + defer up.Close() + w := discover(t, s, cookie, channel{BaseURL: up.URL, Protocol: "anthropic", APIKey: "secret"}) + if w.Code != 502 || strings.TrimSpace(w.Body.String()) != `{"error":"model discovery failed"}` || calls != tc.calls { + t.Fatal(w.Code, w.Body.String(), calls) + } + }) + } +} + +func TestDiscoveryRedirectAndDeadline(t *testing.T) { + s := testServer(t) + cookie := loginCookie(t, s) + target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { t.Error("redirect followed") })) + defer target.Close() + up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, target.URL, 307) })) + defer up.Close() + for _, proto := range []string{"openai-chat", "anthropic"} { + if w := discover(t, s, cookie, channel{BaseURL: up.URL, Protocol: proto, APIKey: "secret"}); w.Code != 502 { + t.Fatal(w.Code) + } + } + slow := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { <-r.Context().Done() })) + defer slow.Close() + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond) + defer cancel() + b, _ := json.Marshal(channel{BaseURL: slow.URL, Protocol: "openai-chat"}) + r := httptest.NewRequest("POST", discoveryPath, strings.NewReader(string(b))).WithContext(ctx) + r.AddCookie(cookie) + r.Header.Set("Origin", "http://example.com") + r.Header.Set("X-Requested-With", "ai-router") + w := httptest.NewRecorder() + s.ServeHTTP(w, r) + if w.Code != 504 { + t.Fatal(w.Code, w.Body.String()) + } +} + +func TestModelsListSelectedAliasesAndPermissions(t *testing.T) { + s := testServer(t) + cookie := loginCookie(t, s) + var allowed string + for _, name := range []string{"selected", "forbidden", "disabled"} { + b, _ := json.Marshal(channel{Name: name, BaseURL: "http://example.com", Protocol: "openai-chat", Enabled: name != "disabled", Models: map[string]string{"z": "vendor/real", "alias": "vendor/real"}}) + w := call(s, "POST", "/admin/api/channels", string(b), cookie) + if w.Code != 200 { + t.Fatal(w.Body.String()) + } + var c channel + json.Unmarshal(w.Body.Bytes(), &c) + if name != "forbidden" { + if allowed != "" { + allowed += "," + } + allowed += fmt.Sprintf("%q", c.ID) + } + } + w := call(s, "POST", "/admin/api/keys", `{"name":"client","channels":[`+allowed+`]}`, cookie) + if w.Code != 200 { + t.Fatal(w.Body.String()) + } + var key map[string]string + json.Unmarshal(w.Body.Bytes(), &key) + r := httptest.NewRequest("GET", "/v1/models", nil) + r.Header.Set("Authorization", "Bearer "+key["key"]) + w = httptest.NewRecorder() + s.ServeHTTP(w, r) + var result struct{ Data []struct{ ID string } } + if e := json.Unmarshal(w.Body.Bytes(), &result); e != nil || w.Code != 200 { + t.Fatal(w.Code, e) + } + if len(result.Data) != 2 || result.Data[0].ID != "selected/alias" || result.Data[1].ID != "selected/z" { + t.Fatal(w.Body.String()) + } + for _, model := range []string{"forbidden/alias", "disabled/alias"} { + r = httptest.NewRequest("POST", "/v1/chat/completions", strings.NewReader(fmt.Sprintf(`{"model":%q,"messages":[]}`, model))) + r.Header.Set("Authorization", "Bearer "+key["key"]) + w = httptest.NewRecorder() + s.ServeHTTP(w, r) + if w.Code != 403 { + t.Fatal(model, w.Code) + } + } +} diff --git a/internal/server/integration_test.go b/internal/server/integration_test.go new file mode 100644 index 0000000..97eb7d2 --- /dev/null +++ b/internal/server/integration_test.go @@ -0,0 +1,443 @@ +package server + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync" + "sync/atomic" + "testing" + "time" +) + +type integrationKey struct{ raw, id string } + +type integrationStreamRecorder struct { + *httptest.ResponseRecorder + flushed chan struct{} +} + +func (w *integrationStreamRecorder) Flush() { + w.ResponseRecorder.Flush() + select { + case w.flushed <- struct{}{}: + default: + } +} + +func integrationAdmin(t *testing.T, s *Server, cookie *http.Cookie, method, path string, body any, out any) { + t.Helper() + b, err := json.Marshal(body) + if err != nil { + t.Fatal(err) + } + w := call(s, method, path, string(b), cookie) + if w.Code != http.StatusOK { + t.Fatalf("%s %s: %d %s", method, path, w.Code, w.Body.String()) + } + if out != nil { + if err := json.Unmarshal(w.Body.Bytes(), out); err != nil { + t.Fatal(err) + } + } +} + +func integrationChannel(t *testing.T, s *Server, cookie *http.Cookie, name, kind, base string) channel { + t.Helper() + if kind != "anthropic" { + base += "/v1" + } + var ch channel + integrationAdmin(t, s, cookie, "POST", "/admin/api/channels", channel{ + Name: name, Protocol: kind, BaseURL: base, Enabled: true, + APIKey: "upstream-secret", Models: map[string]string{"gpt-5": "gpt-4", "gpt-4": "abc/gpt-4"}, + }, &ch) + if ch.ID == "" || ch.APIKey != "" { + t.Fatalf("invalid channel response: %+v", ch) + } + return ch +} + +func integrationCreateKey(t *testing.T, s *Server, cookie *http.Cookie, name string, ch channel) integrationKey { + t.Helper() + var created struct { + Key string `json:"key"` + } + integrationAdmin(t, s, cookie, "POST", "/admin/api/keys", apiKey{Name: name, Channels: []string{ch.ID}}, &created) + var keys []apiKey + integrationAdmin(t, s, cookie, "GET", "/admin/api/keys", nil, &keys) + for _, k := range keys { + if k.Name == name && created.Key != "" { + return integrationKey{created.Key, k.ID} + } + } + t.Fatal("created key missing from management API") + return integrationKey{} +} + +func integrationRequest(kind, model string, stream bool) string { + if kind == "openai-responses" { + return fmt.Sprintf(`{"model":%q,"input":"hello","max_output_tokens":32,"stream":%t}`, model, stream) + } + return fmt.Sprintf(`{"model":%q,"messages":[{"role":"user","content":"hello"}],"max_tokens":32,"stream":%t}`, model, stream) +} + +func integrationPath(kind string) string { + return map[string]string{"openai-chat": "/v1/chat/completions", "openai-responses": "/v1/responses", "anthropic": "/v1/messages"}[kind] +} + +func integrationProxy(s *Server, key integrationKey, kind, model string, stream bool) *httptest.ResponseRecorder { + r := httptest.NewRequest("POST", integrationPath(kind), strings.NewReader(integrationRequest(kind, model, stream))) + if kind == "anthropic" { + r.Header.Set("x-api-key", key.raw) + } else { + r.Header.Set("Authorization", "Bearer "+key.raw) + } + w := httptest.NewRecorder() + s.ServeHTTP(w, r) + return w +} + +func integrationResponse(kind string) string { + switch kind { + case "openai-chat": + return `{"id":"chat-test","object":"chat.completion","model":"gpt-4","choices":[{"index":0,"message":{"role":"assistant","content":"hello"},"finish_reason":"stop"}],"usage":{"prompt_tokens":12,"completion_tokens":3,"prompt_tokens_details":{"cached_tokens":4}}}` + case "openai-responses": + return `{"id":"resp-test","object":"response","model":"gpt-4","status":"completed","output":[{"type":"message","id":"msg-test","role":"assistant","status":"completed","content":[{"type":"output_text","text":"hello","annotations":[]}]}],"usage":{"input_tokens":12,"output_tokens":3,"input_tokens_details":{"cached_tokens":4}}}` + default: + return `{"id":"msg-test","type":"message","role":"assistant","model":"gpt-4","content":[{"type":"text","text":"hello"}],"stop_reason":"end_turn","stop_sequence":null,"usage":{"input_tokens":6,"output_tokens":3,"cache_read_input_tokens":4,"cache_creation_input_tokens":2}}` + } +} + +func integrationSSE(kind string) string { + switch kind { + case "openai-chat": + return "data: " + `{"id":"chat-test","choices":[{"index":0,"delta":{"role":"assistant","content":"hello"},"finish_reason":null}]}` + "\n\ndata: " + `{"choices":[{"index":0,"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":12,"completion_tokens":3,"prompt_tokens_details":{"cached_tokens":4}}}` + "\n\ndata: [DONE]\n\n" + case "openai-responses": + return "event: response.created\ndata: " + `{"type":"response.created","response":{"id":"resp-test","status":"in_progress"}}` + "\n\n" + + "event: response.output_item.added\ndata: " + `{"type":"response.output_item.added","output_index":0,"item":{"type":"message","role":"assistant","content":[]}}` + "\n\n" + + "event: response.output_text.delta\ndata: " + `{"type":"response.output_text.delta","output_index":0,"content_index":0,"delta":"hello"}` + "\n\n" + + "event: response.completed\ndata: " + `{"type":"response.completed","response":` + integrationResponse(kind) + "}\n\n" + default: + return "event: message_start\ndata: " + `{"type":"message_start","message":{"id":"msg-test","role":"assistant","content":[],"usage":{"input_tokens":6,"output_tokens":0,"cache_read_input_tokens":4,"cache_creation_input_tokens":2}}}` + "\n\n" + + "event: content_block_start\ndata: " + `{"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}` + "\n\n" + + "event: content_block_delta\ndata: " + `{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"hello"}}` + "\n\n" + + "event: content_block_stop\ndata: " + `{"type":"content_block_stop","index":0}` + "\n\n" + + "event: message_delta\ndata: " + `{"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null},"usage":{"output_tokens":3}}` + "\n\n" + + "event: message_stop\ndata: " + `{"type":"message_stop"}` + "\n\n" + } +} + +func integrationLogs(t *testing.T, s *Server, c *http.Cookie, key integrationKey) []requestLog { + t.Helper() + if err := s.flushLogs(); err != nil { + t.Fatal(err) + } + var logs []requestLog + integrationAdmin(t, s, c, "GET", "/admin/api/logs?key_id="+key.id, nil, &logs) + for _, l := range logs { + if l.KeyID != key.id { + t.Fatalf("cross-key log leak: %+v", l) + } + } + return logs +} + +func integrationCount(t *testing.T, name string, got *int64, want int64) { + t.Helper() + if got == nil || *got != want { + t.Fatalf("%s: got %v, want %d", name, got, want) + } +} + +func TestIntegrationProtocolMatrix(t *testing.T) { + kinds := []string{"openai-chat", "openai-responses", "anthropic"} + for _, upKind := range kinds { + for _, downKind := range kinds { + t.Run(downKind+"_to_"+upKind, func(t *testing.T) { + s := testServer(t) + cookie := loginCookie(t, s) + var calls atomic.Int64 + var expectedModel atomic.Value + up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls.Add(1) + if r.URL.Path != integrationPath(upKind) { + t.Errorf("upstream path: %s", r.URL.Path) + } + if upKind == "anthropic" { + if r.Header.Get("x-api-key") != "upstream-secret" || r.Header.Get("anthropic-version") == "" { + t.Error("missing Anthropic credentials/version") + } + } else if r.Header.Get("Authorization") != "Bearer upstream-secret" { + t.Error("wrong upstream credentials") + } + var body struct { + Model string `json:"model"` + Stream bool `json:"stream"` + } + b, err := io.ReadAll(r.Body) + if err != nil || json.Unmarshal(b, &body) != nil { + t.Error("invalid upstream request") + w.WriteHeader(400) + return + } + if body.Model != expectedModel.Load() || !strings.Contains(string(b), "hello") { + t.Errorf("upstream request: %s; want model %s", b, expectedModel.Load()) + } + if body.Stream { + w.Header().Set("Content-Type", "text/event-stream") + for _, frame := range strings.Split(integrationSSE(upKind), "\n\n") { + if frame != "" { + fmt.Fprint(w, frame+"\n\n") + w.(http.Flusher).Flush() + } + } + } else { + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, integrationResponse(upKind)) + } + })) + defer up.Close() + abc := integrationChannel(t, s, cookie, "abc", upKind, up.URL) + other := integrationChannel(t, s, cookie, "other", upKind, up.URL) + keys := []integrationKey{integrationCreateKey(t, s, cookie, "first", abc), integrationCreateKey(t, s, cookie, "second", other)} + for i, key := range keys { + name, forbidden := "abc", "other" + if i == 1 { + name, forbidden = forbidden, name + } + for _, stream := range []bool{false, true} { + for _, alias := range []string{"gpt-5", "gpt-4"} { + expectedModel.Store("gpt-4") + if alias == "gpt-4" { + expectedModel.Store("abc/gpt-4") + } + w := integrationProxy(s, key, downKind, name+"/"+alias, stream) + if w.Code != 200 || !strings.Contains(w.Body.String(), "hello") { + t.Fatalf("stream=%t model=%s: %d %s", stream, alias, w.Code, w.Body.String()) + } + if stream { + terminal := map[string]string{"openai-chat": "data: [DONE]", "openai-responses": "event: response.completed", "anthropic": "event: message_stop"}[downKind] + if !w.Flushed || !strings.Contains(w.Header().Get("Content-Type"), "text/event-stream") || !strings.Contains(w.Body.String(), terminal) { + t.Fatalf("incomplete SSE: %s", w.Body.String()) + } + } else { + var body map[string]any + if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil { + t.Fatal(err) + } + field := map[string]string{"openai-chat": "choices", "openai-responses": "output", "anthropic": "content"}[downKind] + if body[field] == nil { + t.Fatalf("wrong downstream shape: %s", w.Body.String()) + } + } + } + } + before := calls.Load() + if w := integrationProxy(s, key, downKind, forbidden+"/gpt-5", false); w.Code != 403 { + t.Fatalf("permission: %d %s", w.Code, w.Body.String()) + } + if calls.Load() != before { + t.Fatal("forbidden request reached upstream") + } + } + if calls.Load() != 8 { + t.Fatalf("upstream calls: %d", calls.Load()) + } + for _, key := range keys { + logs := integrationLogs(t, s, cookie, key) + if len(logs) != 5 { + t.Fatalf("logs: %+v", logs) + } + for _, l := range logs { + if l.Status == 403 { + if l.UsageKnown || l.Input != nil { + t.Fatal("denied request has usage") + } + continue + } + actual := "gpt-4" + if strings.HasSuffix(l.RequestModel, "/gpt-4") { + actual = "abc/gpt-4" + } + if l.Status != 200 || l.Error != "" || !l.UsageKnown || l.ActualModel != actual || l.Downstream != downKind || l.Upstream != upKind { + t.Fatalf("wrong log: %+v", l) + } + integrationCount(t, "input", l.Input, 12) + integrationCount(t, "output", l.Output, 3) + integrationCount(t, "cache read", l.CacheRead, 4) + write := int64(0) + if upKind == "anthropic" { + write = 2 + } + integrationCount(t, "cache write", l.CacheWrite, write) + } + var stats struct { + Summary summary `json:"summary"` + ByKey []struct { + KeyID string `json:"key_id"` + } `json:"by_key"` + } + integrationAdmin(t, s, cookie, "GET", "/admin/api/stats?key_id="+key.id, nil, &stats) + if stats.Summary.Requests != 5 || stats.Summary.Success != 4 || stats.Summary.Failed != 1 || stats.Summary.Unknown != 1 || len(stats.ByKey) != 1 || stats.ByKey[0].KeyID != key.id { + t.Fatalf("wrong isolated stats: %+v", stats) + } + integrationCount(t, "total input", stats.Summary.Input, 48) + integrationCount(t, "total output", stats.Summary.Output, 12) + integrationCount(t, "total cache read", stats.Summary.CacheRead, 16) + write := int64(0) + if upKind == "anthropic" { + write = 8 + } + integrationCount(t, "total cache write", stats.Summary.CacheWrite, write) + } + }) + } + } +} + +func TestIntegrationStreamFailures(t *testing.T) { + for _, tc := range []struct { + name, body string + status int + }{ + {"upstream_http_error", `{"error":"private upstream detail"}`, 429}, + {"error_event", "event: error\ndata: {\"type\":\"error\",\"error\":{\"message\":\"private upstream detail\"}}\n\n", 200}, + {"malformed_event", "data: {invalid}\n\n", 200}, + {"truncated_stream", "data: {\"choices\":[{\"index\":0,\"delta\":{\"content\":\"partial\"},\"finish_reason\":null}]}\n\n", 200}, + } { + t.Run(tc.name, func(t *testing.T) { + s := testServer(t) + cookie := loginCookie(t, s) + up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + w.WriteHeader(tc.status) + fmt.Fprint(w, tc.body) + })) + defer up.Close() + ch := integrationChannel(t, s, cookie, "abc", "openai-chat", up.URL) + key := integrationCreateKey(t, s, cookie, "errors", ch) + w := integrationProxy(s, key, "openai-responses", "abc/gpt-5", true) + if w.Code != tc.status { + t.Fatalf("status: %d body: %s", w.Code, w.Body.String()) + } + if strings.Contains(w.Body.String(), "response.completed") || strings.Contains(w.Body.String(), "private upstream detail") { + t.Fatalf("false success or leaked upstream detail: %s", w.Body.String()) + } + logs := integrationLogs(t, s, cookie, key) + if len(logs) != 1 { + t.Fatalf("logs: %+v", logs) + } + l := logs[0] + if l.Status < 400 || l.Error == "" || l.UsageKnown || l.Input != nil || l.Output != nil { + t.Fatalf("failure not recorded correctly: %+v", l) + } + }) + } +} + +func TestAnthropicHeaderForwarding(t *testing.T) { + s := testServer(t) + cookie := loginCookie(t, s) + var mu sync.Mutex + got := map[string]string{} + up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + got["version"], got["beta"] = r.Header.Get("anthropic-version"), r.Header.Get("anthropic-beta") + mu.Unlock() + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, integrationResponse("anthropic")) + })) + defer up.Close() + ch := integrationChannel(t, s, cookie, "abc", "anthropic", up.URL) + key := integrationCreateKey(t, s, cookie, "beta", ch) + request := func(version, beta string) { + r := httptest.NewRequest("POST", "/v1/messages", strings.NewReader(integrationRequest("anthropic", "abc/gpt-5", false))) + r.Header.Set("x-api-key", key.raw) + if version != "" { + r.Header.Set("anthropic-version", version) + } + if beta != "" { + r.Header.Set("anthropic-beta", beta) + } + w := httptest.NewRecorder() + s.ServeHTTP(w, r) + if w.Code != 200 { + t.Fatalf("status %d: %s", w.Code, w.Body.String()) + } + } + request("2023-06-01", "context-1m-2025-08-07,interleaved-thinking-2025-05-14") + mu.Lock() + if got["version"] != "2023-06-01" || got["beta"] != "context-1m-2025-08-07,interleaved-thinking-2025-05-14" { + t.Fatalf("valid headers dropped: %v", got) + } + mu.Unlock() + request("bad version; injected", `weird"value`) + mu.Lock() + if got["version"] != "2023-06-01" || got["beta"] != "" { + t.Fatalf("unsafe headers forwarded: %v", got) + } + mu.Unlock() +} + +func TestIntegrationCancellationClosesUpstream(t *testing.T) { + s := testServer(t) + cookie := loginCookie(t, s) + started, stopped := make(chan struct{}), make(chan struct{}) + release := make(chan struct{}) + up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + io.Copy(io.Discard, r.Body) + w.Header().Set("Content-Type", "text/event-stream") + fmt.Fprint(w, "data: {\"choices\":[{\"index\":0,\"delta\":{\"content\":\"partial\"},\"finish_reason\":null}]}\n\n") + w.(http.Flusher).Flush() + close(started) + select { + case <-r.Context().Done(): + close(stopped) + case <-release: + } + })) + defer up.Close() + defer close(release) + ch := integrationChannel(t, s, cookie, "abc", "openai-chat", up.URL) + key := integrationCreateKey(t, s, cookie, "cancel", ch) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + r := httptest.NewRequest("POST", "/v1/responses", strings.NewReader(integrationRequest("openai-responses", "abc/gpt-5", true))).WithContext(ctx) + r.Header.Set("Authorization", "Bearer "+key.raw) + done := make(chan struct{}) + w := &integrationStreamRecorder{ResponseRecorder: httptest.NewRecorder(), flushed: make(chan struct{}, 1)} + go func() { s.ServeHTTP(w, r); close(done) }() + select { + case <-started: + case <-time.After(3 * time.Second): + t.Fatal("upstream did not start") + } + select { + case <-w.flushed: + case <-time.After(3 * time.Second): + t.Fatal("downstream did not receive streaming output") + } + cancel() + select { + case <-done: + case <-time.After(3 * time.Second): + t.Fatal("proxy did not stop on cancellation") + } + select { + case <-stopped: + case <-time.After(3 * time.Second): + t.Fatal("upstream request was not cancelled") + } + logs := integrationLogs(t, s, cookie, key) + if len(logs) != 1 || logs[0].Status < 400 || logs[0].Error == "" || logs[0].UsageKnown || logs[0].Input != nil { + t.Fatalf("cancel log: %+v", logs) + } + if strings.Contains(w.Body.String(), "response.completed") { + t.Fatal("cancelled response marked completed") + } +} diff --git a/internal/server/log_writer.go b/internal/server/log_writer.go new file mode 100644 index 0000000..304de78 --- /dev/null +++ b/internal/server/log_writer.go @@ -0,0 +1,158 @@ +package server + +import ( + "context" + "errors" + "log" + "strings" + "time" + "unicode/utf8" +) + +const ( + maxLogFieldBytes = 512 + maxLogErrorBytes = 4096 + maxLogUsageBytes = 16384 + logCleanupBatch = 1000 +) + +func (s *Server) SetLogRetention(days int) { + if days < 0 { + days = 0 + } + s.logRetentionDays.Store(int64(days)) +} + +func truncateLogField(value string, limit int) string { + if len(value) <= limit { + return value + } + for limit > 0 && !utf8.RuneStart(value[limit]) { + limit-- + } + return strings.Clone(value[:limit]) +} + +func boundedLog(l requestLog) requestLog { + for _, field := range []*string{&l.CreatedAt, &l.KeyID, &l.KeyName, &l.Channel, &l.RequestModel, &l.ActualModel, &l.Downstream, &l.Upstream} { + *field = truncateLogField(*field, maxLogFieldBytes) + } + l.Error = truncateLogField(l.Error, maxLogErrorBytes) + if len(l.Usage.Raw) > maxLogUsageBytes { + // Keep valid JSON without retaining an attacker-controlled oversized object. + l.Usage.Raw = []byte(`{"truncated":true}`) + } + l.RawUsage = nil + return l +} + +func (s *Server) purgeExpiredLogs(ctx context.Context, days int) error { + if days <= 0 { + return nil + } + cutoff := time.Now().UTC().AddDate(0, 0, -days).Truncate(time.Second) + _, err := s.db.ExecContext(ctx, "DELETE FROM logs WHERE id IN (SELECT id FROM logs WHERE created_at 0 { + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + err := s.purgeExpiredLogs(ctx, int(days)) + cancel() + if err != nil { + log.Print("ai-router: request log retention cleanup failed") + } + } + } + } +} + +func (s *Server) persistLogs(batch []requestLog) error { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer tx.Rollback() + for _, l := range batch { + if err := insertLog(ctx, tx, l); err != nil { + return err + } + } + return tx.Commit() +} diff --git a/internal/server/log_writer_test.go b/internal/server/log_writer_test.go new file mode 100644 index 0000000..5856f74 --- /dev/null +++ b/internal/server/log_writer_test.go @@ -0,0 +1,248 @@ +package server + +import ( + "ai-router/internal/protocol" + "bytes" + "context" + "database/sql" + "log" + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "sync" + "testing" + "time" +) + +func waitLogCondition(t *testing.T, condition func() bool) { + t.Helper() + deadline := time.Now().Add(3 * time.Second) + for !condition() { + if time.Now().After(deadline) { + t.Fatal("condition timed out") + } + time.Sleep(time.Millisecond) + } +} + +func logCount(t *testing.T, db *sql.DB) int { + t.Helper() + var n int + if err := db.QueryRow("SELECT COUNT(*) FROM logs").Scan(&n); err != nil { + t.Fatal(err) + } + return n +} + +func TestLogAsyncAndRawCopy(t *testing.T) { + s := testServer(t) + conn, err := s.db.Conn(context.Background()) + if err != nil { + t.Fatal(err) + } + defer conn.Close() + raw := []byte(`{"input_tokens":12}`) + done := make(chan struct{}) + go func() { + s.record(requestLog{Usage: protocol.Usage{Raw: raw}}) + close(done) + }() + select { + case <-done: + case <-time.After(time.Second): + t.Fatal("record waited for database") + } + raw[0] = 'x' + conn.Close() + if err := s.flushLogs(); err != nil { + t.Fatal(err) + } + var got string + if err := s.db.QueryRow("SELECT raw_usage FROM logs").Scan(&got); err != nil { + t.Fatal(err) + } + if got != `{"input_tokens":12}` { + t.Fatal(got) + } +} + +func TestLogTimerAndBatchDrain(t *testing.T) { + s := testServer(t) + s.record(requestLog{}) + waitLogCondition(t, func() bool { return logCount(t, s.db) == 1 }) + for i := 0; i < 257; i++ { + s.record(requestLog{}) + } + if err := s.flushLogs(); err != nil { + t.Fatal(err) + } + if n := logCount(t, s.db); n != 258 { + t.Fatal(n) + } +} + +func TestLogBackpressureAndConcurrentClose(t *testing.T) { + path := filepath.Join(t.TempDir(), "logs.db") + s, err := New(path, "password", nil) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { s.Close() }) + conn, err := s.db.Conn(context.Background()) + if err != nil { + t.Fatal(err) + } + defer conn.Close() + s.record(requestLog{}) + waitLogCondition(t, func() bool { return s.db.Stats().WaitCount > 0 }) + if cap(s.logQueue) != 1024 { + t.Fatal(cap(s.logQueue)) + } + for i := 0; i < 1024; i++ { + s.record(requestLog{}) + } + enqueued := make(chan struct{}) + go func() { s.record(requestLog{}); close(enqueued) }() + select { + case <-enqueued: + t.Fatal("full queue did not apply backpressure") + case <-time.After(20 * time.Millisecond): + } + var wg sync.WaitGroup + for i := 0; i < 8; i++ { + wg.Add(1) + go func() { + defer wg.Done() + if err := s.Close(); err != nil { + t.Error(err) + } + }() + } + conn.Close() + done := make(chan struct{}) + go func() { wg.Wait(); close(done) }() + select { + case <-done: + case <-time.After(5 * time.Second): + t.Fatal("Close deadlocked") + } + <-enqueued + s.record(requestLog{}) + if err := s.flushLogs(); err != nil { + t.Fatal(err) + } + db, err := sql.Open("sqlite", path) + if err != nil { + t.Fatal(err) + } + defer db.Close() + if n := logCount(t, db); n != 1026 { + t.Fatal(n) + } +} + +func TestLogPersistenceErrorIsStickyAndRedacted(t *testing.T) { + s := testServer(t) + var output bytes.Buffer + previous := log.Writer() + log.SetOutput(&output) + defer log.SetOutput(previous) + if _, err := s.db.Exec(`CREATE TRIGGER reject_log BEFORE INSERT ON logs WHEN NEW.key_name='secret' BEGIN SELECT RAISE(FAIL,'private database secret'); END`); err != nil { + t.Fatal(err) + } + s.record(requestLog{KeyName: "ok"}) + s.record(requestLog{KeyName: "secret"}) + start := time.Now() + err := s.flushLogs() + if err == nil { + t.Fatal("missing persistence error") + } + if time.Since(start) > time.Second { + t.Fatal("unbounded retries") + } + if n := logCount(t, s.db); n != 0 { + t.Fatalf("partial transaction: %d", n) + } + if strings.Contains(output.String(), "secret") || strings.Contains(err.Error(), "secret") { + t.Fatal("error leaked metadata") + } + if n := strings.Count(output.String(), "retrying"); n != 3 { + t.Fatalf("retries: %d", n) + } + if _, err := s.db.Exec("DROP TRIGGER reject_log"); err != nil { + t.Fatal(err) + } + s.record(requestLog{}) + if err := s.flushLogs(); err == nil { + t.Fatal("error was not sticky") + } + if n := logCount(t, s.db); n != 1 { + t.Fatal(n) + } + if err := s.Close(); err == nil { + t.Fatal("Close lost persistence error") + } + if err := s.Close(); err == nil { + t.Fatal("second Close lost error") + } +} + +func TestCloseWaitsForActiveRequest(t *testing.T) { + path := filepath.Join(t.TempDir(), "logs.db") + s, err := New(path, "test-password", nil) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { s.Close() }) + started, release := make(chan struct{}), make(chan struct{}) + up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + close(started) + <-release + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(integrationResponse("openai-chat"))) + })) + defer up.Close() + defer func() { + select { + case <-release: + default: + close(release) + } + }() + cookie := loginCookie(t, s) + ch := integrationChannel(t, s, cookie, "abc", "openai-chat", up.URL) + key := integrationCreateKey(t, s, cookie, "active", ch) + requestDone := make(chan struct{}) + go func() { integrationProxy(s, key, "openai-chat", "abc/gpt-5", false); close(requestDone) }() + <-started + closed := make(chan error, 1) + go func() { closed <- s.Close() }() + waitLogCondition(t, func() bool { s.lifecycleMu.Lock(); defer s.lifecycleMu.Unlock(); return s.closing }) + if w := call(s, "GET", "/v1/models", "", nil); w.Code != 503 { + t.Fatal(w.Code) + } + select { + case <-closed: + t.Fatal("Close skipped active request") + default: + } + close(release) + <-requestDone + select { + case err := <-closed: + if err != nil { + t.Fatal(err) + } + case <-time.After(3 * time.Second): + t.Fatal("Close did not drain") + } + db, err := sql.Open("sqlite", path) + if err != nil { + t.Fatal(err) + } + defer db.Close() + if n := logCount(t, db); n != 1 { + t.Fatal(n) + } +} diff --git a/internal/server/model_probe.go b/internal/server/model_probe.go new file mode 100644 index 0000000..8b2e81e --- /dev/null +++ b/internal/server/model_probe.go @@ -0,0 +1,229 @@ +package server + +import ( + "ai-router/internal/protocol" + "bytes" + "context" + "database/sql" + "encoding/json" + "errors" + "io" + "net/http" + "strings" + "time" + "unicode/utf8" +) + +const modelTestTimeout = 30 * time.Second +const modelTestMaxBody = 1 << 20 + +type modelTestResult struct { + OK bool `json:"ok"` + DurationMS int64 `json:"duration_ms"` + Status int `json:"status"` + Error string `json:"error,omitempty"` +} + +// testModel is an administrative diagnostic, excluded from call logs and usage +// statistics. It creates no API key; upstream providers may still bill the probe. +func (s *Server) testModel(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + errJSON(w, 405, "method not allowed") + return + } + var input struct { + Model string `json:"model"` + Prompt *string `json:"prompt"` + } + if decode(w, r, &input) != nil { + errJSON(w, 400, "invalid request") + return + } + name, alias, ok := strings.Cut(input.Model, "/") + prompt := "Reply with OK." + if input.Prompt != nil { + prompt = *input.Prompt + } + if !ok || name == "" || alias == "" || strings.TrimSpace(prompt) == "" || utf8.RuneCountInString(prompt) > 2000 { + errJSON(w, 400, "invalid model or prompt") + return + } + ctx, cancel := context.WithTimeout(r.Context(), modelTestTimeout) + defer cancel() + var c channel + var models string + e := s.db.QueryRowContext(ctx, "SELECT base_url,protocol,enabled,models,api_key FROM channels WHERE name=?", name).Scan(&c.BaseURL, &c.Protocol, &c.Enabled, &models, &c.APIKey) + if e == sql.ErrNoRows { + errJSON(w, 403, "model or channel unavailable") + return + } + if e != nil || json.Unmarshal([]byte(models), &c.Models) != nil { + errJSON(w, 500, "database error") + return + } + actual, mapped := c.Models[alias] + if !c.Enabled || !mapped || actual == "" { + errJSON(w, 403, "model or channel unavailable") + return + } + writeJSON(w, s.probeModel(ctx, c, actual, prompt)) +} + +func (s *Server) probeModel(ctx context.Context, c channel, actual, prompt string) (result modelTestResult) { + start := time.Now() + defer func() { + result.DurationMS = time.Since(start).Milliseconds() + if ctx.Err() != nil { + result.OK = false + result.Error = "model test canceled" + if ctx.Err() == context.DeadlineExceeded { + result.Error = "model test timed out" + } + } + }() + result.Error = "upstream request failed" + up := protocol.Kind(c.Protocol) + body, _ := json.Marshal(map[string]any{"messages": []any{map[string]string{"role": "user", "content": prompt}}, "stream": false, "max_completion_tokens": 128}) + payload, e := protocol.ConvertRequest(protocol.Chat, up, body, actual) + if e != nil { + result.Error = "invalid upstream configuration" + return + } + suffix := map[protocol.Kind]string{protocol.Chat: "/chat/completions", protocol.Responses: "/responses", protocol.Anthropic: "/v1/messages"}[up] + req, e := http.NewRequestWithContext(ctx, http.MethodPost, c.BaseURL+suffix, bytes.NewReader(payload)) + if e != nil { + result.Error = "invalid upstream configuration" + return + } + req.Header.Set("Content-Type", "application/json") + if up == protocol.Anthropic { + req.Header.Set("x-api-key", c.APIKey) + req.Header.Set("anthropic-version", "2023-06-01") + } else { + req.Header.Set("Authorization", "Bearer "+c.APIKey) + } + client := &http.Client{Transport: s.client.Transport, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }} + res, e := client.Do(req) + if e != nil { + if errors.Is(e, context.DeadlineExceeded) { + result.Error = "model test timed out" + } + return + } + defer res.Body.Close() + result.Status = res.StatusCode + if res.StatusCode < 200 || res.StatusCode >= 300 { + result.Error = "upstream rejected request" + return + } + body, e = io.ReadAll(io.LimitReader(res.Body, modelTestMaxBody+1)) + if len(body) > modelTestMaxBody { + result.Error = "upstream response too large" + return + } + if e != nil || !validModelTestResponse(up, body) { + result.Error = "invalid upstream response" + return + } + result.OK, result.Error = true, "" + return +} + +func validModelTestResponse(kind protocol.Kind, body []byte) bool { + var r struct { + ID string `json:"id"` + Object string `json:"object"` + Type string `json:"type"` + Role string `json:"role"` + Error json.RawMessage `json:"error"` + Status string `json:"status"` + StopReason string `json:"stop_reason"` + Incomplete struct { + Reason string `json:"reason"` + } `json:"incomplete_details"` + Output []json.RawMessage `json:"output"` + Content []json.RawMessage `json:"content"` + Choices []struct { + Finish string `json:"finish_reason"` + Message struct { + Role string `json:"role"` + Content *string `json:"content"` + } `json:"message"` + } `json:"choices"` + } + if json.Unmarshal(body, &r) != nil || r.ID == "" || (len(r.Error) != 0 && string(r.Error) != "null") { + return false + } + switch kind { + case protocol.Chat: + if r.Object != "chat.completion" || len(r.Choices) == 0 { + return false + } + for _, c := range r.Choices { + if c.Message.Role != "assistant" || (c.Finish != "stop" && c.Finish != "length") || (c.Message.Content == nil && c.Finish != "length") { + return false + } + } + return true + case protocol.Responses: + if r.Object != "response" || r.Output == nil { + return false + } + exhausted := r.Status == "incomplete" && r.Incomplete.Reason == "max_output_tokens" + if !exhausted && (r.Status != "completed" || len(r.Output) == 0) { + return false + } + for _, raw := range r.Output { + var item struct { + Type string `json:"type"` + Role string `json:"role"` + Content []json.RawMessage `json:"content"` + Summary []json.RawMessage `json:"summary"` + } + if json.Unmarshal(raw, &item) != nil { + return false + } + if item.Type == "reasoning" && item.Summary != nil { + continue + } + if item.Type != "message" || item.Role != "assistant" || !validProbeBlocks(item.Content, "output_text") { + return false + } + } + return true + case protocol.Anthropic: + if r.Type != "message" || r.Role != "assistant" || r.Content == nil { + return false + } + if r.StopReason == "max_tokens" && len(r.Content) == 0 { + return true + } + return (r.StopReason == "end_turn" || r.StopReason == "max_tokens") && validProbeBlocks(r.Content, "text") + } + return false +} + +func validProbeBlocks(blocks []json.RawMessage, typ string) bool { + if len(blocks) == 0 { + return false + } + for _, raw := range blocks { + var b struct { + Type string `json:"type"` + Text *string `json:"text"` + Thinking *string `json:"thinking"` + Signature *string `json:"signature"` + Data *string `json:"data"` + } + if json.Unmarshal(raw, &b) != nil { + return false + } + if typ == "text" && ((b.Type == "thinking" && b.Thinking != nil && b.Signature != nil) || (b.Type == "redacted_thinking" && b.Data != nil)) { + continue + } + if b.Type != typ || b.Text == nil { + return false + } + } + return true +} diff --git a/internal/server/model_probe_test.go b/internal/server/model_probe_test.go new file mode 100644 index 0000000..753e827 --- /dev/null +++ b/internal/server/model_probe_test.go @@ -0,0 +1,267 @@ +package server + +import ( + "ai-router/internal/protocol" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" +) + +const probePath = "/admin/api/models/test" +const probeChat = `{"id":"c","object":"chat.completion","choices":[{"message":{"role":"assistant","content":"OK"},"finish_reason":"stop"}]}` + +func saveProbeChannel(t *testing.T, s *Server, cookie *http.Cookie, url, kind string, enabled bool) { + t.Helper() + b, _ := json.Marshal(channel{Name: "saved", BaseURL: url, Protocol: kind, Enabled: enabled, Models: map[string]string{"alias": "actual/model"}, APIKey: "secret-key"}) + if w := call(s, "POST", "/admin/api/channels", string(b), cookie); w.Code != 200 { + t.Fatal(w.Body.String()) + } +} + +func probeResult(t *testing.T, w *httptest.ResponseRecorder) modelTestResult { + t.Helper() + var result modelTestResult + if w.Code != 200 || json.Unmarshal(w.Body.Bytes(), &result) != nil { + t.Fatal(w.Code, w.Body.String()) + } + if result.DurationMS < 0 || strings.Contains(w.Body.String(), "secret-key") { + t.Fatal(w.Body.String()) + } + return result +} + +func TestModelProbeProtocols(t *testing.T) { + for _, tc := range []struct{ kind, path, limit, response string }{ + {"openai-chat", "/chat/completions", "max_completion_tokens", probeChat}, + {"openai-responses", "/responses", "max_output_tokens", `{"id":"r","object":"response","status":"completed","output":[{"type":"message","role":"assistant","content":[{"type":"output_text","text":"OK"}]}]}`}, + {"anthropic", "/v1/messages", "max_tokens", `{"id":"m","type":"message","role":"assistant","stop_reason":"end_turn","content":[{"type":"text","text":"OK"}]}`}, + } { + t.Run(tc.kind, func(t *testing.T) { + s := testServer(t) + cookie := loginCookie(t, s) + expectedPrompt := "Reply with OK." + up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + b, _ := io.ReadAll(r.Body) + var obj map[string]any + if json.Unmarshal(b, &obj) != nil || obj["model"] != "actual/model" || obj[tc.limit] != float64(128) || obj["stream"] != false || !strings.Contains(string(b), expectedPrompt) { + t.Error(string(b)) + } + if tc.kind == "openai-chat" && obj["max_tokens"] != nil { + t.Error("legacy token limit") + } + if r.Method != "POST" || r.URL.Path != tc.path { + t.Error(r.Method, r.URL.Path) + } + if tc.kind == "anthropic" { + if r.Header.Get("x-api-key") != "secret-key" || r.Header.Get("anthropic-version") != "2023-06-01" { + t.Error("headers") + } + } else if r.Header.Get("Authorization") != "Bearer secret-key" { + t.Error("authorization") + } + fmt.Fprint(w, tc.response) + })) + defer up.Close() + saveProbeChannel(t, s, cookie, up.URL, tc.kind, true) + for _, prompt := range []string{"", strings.Repeat("中", 2000)} { + input := map[string]string{"model": "saved/alias"} + if prompt != "" { + expectedPrompt = prompt + input["prompt"] = prompt + } + b, _ := json.Marshal(input) + result := probeResult(t, call(s, "POST", probePath, string(b), cookie)) + if !result.OK || result.Status != 200 || result.Error != "" { + t.Fatal(result) + } + } + for _, table := range []string{"keys", "logs"} { + var n int + if e := s.db.QueryRow("SELECT count(*) FROM " + table).Scan(&n); e != nil || n != 0 { + t.Fatal(table, n, e) + } + } + if len(s.logQueue) != 0 { + t.Fatal("queued formal log") + } + }) + } +} + +func TestModelProbeGuardAndInput(t *testing.T) { + s := testServer(t) + cookie := loginCookie(t, s) + for _, prompt := range []string{"", " ", "\t\r\n", "\u3000\u00a0"} { + body, _ := json.Marshal(map[string]string{"model": "saved/alias", "prompt": prompt}) + if w := call(s, "POST", probePath, string(body), cookie); w.Code != 400 { + t.Fatalf("blank prompt %q: status %d", prompt, w.Code) + } + } + up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { t.Error("unexpected upstream request") })) + defer up.Close() + saveProbeChannel(t, s, cookie, up.URL, "openai-chat", false) + if w := call(s, "POST", probePath, `{"model":"saved/alias"}`, nil); w.Code != 401 { + t.Fatal(w.Code) + } + for _, header := range []string{"Origin", "X-Requested-With"} { + r := httptest.NewRequest("POST", probePath, strings.NewReader(`{"model":"saved/alias"}`)) + r.AddCookie(cookie) + r.Header.Set("Origin", "http://example.com") + r.Header.Set("X-Requested-With", "ai-router") + r.Header.Del(header) + w := httptest.NewRecorder() + s.ServeHTTP(w, r) + if w.Code != 403 { + t.Fatal(w.Code) + } + } + for _, body := range []string{`{"model":"saved/alias"}`, `{"model":"missing/alias"}`} { + if w := call(s, "POST", probePath, body, cookie); w.Code != 403 { + t.Fatal(w.Code) + } + } + if _, e := s.db.Exec("UPDATE channels SET enabled=1"); e != nil { + t.Fatal(e) + } + if w := call(s, "POST", probePath, `{"model":"saved/unmapped"}`, cookie); w.Code != 403 { + t.Fatal(w.Code) + } + for _, body := range []string{`{}`, `null`, `{"model":"alias"}`, `{"model":"/alias"}`, `{"model":"saved/"}`, `{"model":"saved/alias","prompt":2}`, `{"model":"saved/alias"} {}`, `{"model":"saved/alias","prompt":"` + strings.Repeat("中", 2001) + `"}`} { + if w := call(s, "POST", probePath, body, cookie); w.Code != 400 { + t.Fatal(body, w.Code) + } + } + if w := call(s, "GET", probePath, "", cookie); w.Code != 405 { + t.Fatal(w.Code) + } +} + +func TestModelProbeFailures(t *testing.T) { + for _, tc := range []struct { + name, body string + status int + error string + }{ + {"rejected", "secret-key upstream body", 401, "upstream rejected request"}, + {"redirect", "secret-key", 302, "upstream rejected request"}, + {"html", "secret-key", 200, "invalid upstream response"}, + {"empty", "{}", 200, "invalid upstream response"}, + {"oversize", strings.Repeat("x", modelTestMaxBody+1), 200, "upstream response too large"}, + {"limit", probeChat + strings.Repeat(" ", modelTestMaxBody-len(probeChat)), 200, ""}, + } { + t.Run(tc.name, func(t *testing.T) { + s := testServer(t) + cookie := loginCookie(t, s) + up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/chat/completions" { + t.Error("followed redirect") + } + w.Header().Set("Location", "/redirected") + w.WriteHeader(tc.status) + fmt.Fprint(w, tc.body) + })) + defer up.Close() + saveProbeChannel(t, s, cookie, up.URL, "openai-chat", true) + result := probeResult(t, call(s, "POST", probePath, `{"model":"saved/alias"}`, cookie)) + if result.Status != tc.status || result.Error != tc.error || result.OK != (tc.error == "") { + t.Fatal(result) + } + }) + } +} + +func TestModelProbeResponseValidation(t *testing.T) { + if !validModelTestResponse(protocol.Anthropic, []byte(`{"id":"m","type":"message","role":"assistant","stop_reason":"max_tokens","content":[{"type":"thinking","thinking":"reasoning","signature":"sig"}]}`)) { + t.Fatal("valid thinking-only response rejected") + } + for _, tc := range []struct { + kind protocol.Kind + body string + ok bool + }{ + {protocol.Chat, `{"id":"c","object":"chat.completion","choices":[{"message":{"role":"assistant","content":null},"finish_reason":"length"}]}`, true}, + {protocol.Responses, `{"id":"r","object":"response","status":"incomplete","incomplete_details":{"reason":"max_output_tokens"},"output":[{"type":"reasoning","summary":[]}]}`, true}, + {protocol.Anthropic, `{"id":"m","type":"message","role":"assistant","stop_reason":"max_tokens","content":[]}`, true}, + {protocol.Chat, `{"id":"c","object":"chat.completion","choices":[{}]}`, false}, + {protocol.Responses, `{"id":"r","object":"response","status":"failed","output":[]}`, false}, + {protocol.Responses, `{"id":"r","object":"response","status":"completed","output":[{}]}`, false}, + {protocol.Anthropic, `{"id":"m","type":"message","role":"assistant","stop_reason":"end_turn","content":[{}]}`, false}, + } { + if got := validModelTestResponse(tc.kind, []byte(tc.body)); got != tc.ok { + t.Error(tc.kind, tc.body, got) + } + } + for _, kind := range []protocol.Kind{protocol.Chat, protocol.Responses, protocol.Anthropic} { + for _, body := range []string{"{}", "null", "[]", probeChat + "{}", `{"error":{"message":"secret-key"}}`} { + if validModelTestResponse(kind, []byte(body)) { + t.Error(kind, body) + } + } + } +} + +type probeRoundTripper func(*http.Request) (*http.Response, error) + +func (f probeRoundTripper) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) } + +func TestModelProbeTimeoutAndCancellation(t *testing.T) { + for _, cancelRequest := range []bool{false, true} { + t.Run(fmt.Sprint(cancelRequest), func(t *testing.T) { + s := testServer(t) + cookie := loginCookie(t, s) + up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) + defer up.Close() + saveProbeChannel(t, s, cookie, up.URL, "openai-chat", true) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + s.client.Transport = probeRoundTripper(func(r *http.Request) (*http.Response, error) { + deadline, ok := r.Context().Deadline() + if !ok || time.Until(deadline) > 30*time.Second || time.Until(deadline) < 29*time.Second { + t.Error("missing 30s deadline", deadline) + } + if cancelRequest { + cancel() + <-r.Context().Done() + return nil, r.Context().Err() + } + return nil, context.DeadlineExceeded + }) + r := httptest.NewRequest("POST", probePath, strings.NewReader(`{"model":"saved/alias"}`)).WithContext(ctx) + r.AddCookie(cookie) + r.Header.Set("Origin", "http://example.com") + r.Header.Set("X-Requested-With", "ai-router") + w := httptest.NewRecorder() + s.ServeHTTP(w, r) + result := probeResult(t, w) + if result.OK || result.Status != 0 || result.Error == "" { + t.Fatal(result) + } + }) + } + s := testServer(t) + canceled := make(chan struct{}) + up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + w.(http.Flusher).Flush() + <-r.Context().Done() + close(canceled) + })) + defer up.Close() + ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) + defer cancel() + result := s.probeModel(ctx, channel{BaseURL: up.URL, Protocol: "openai-chat"}, "actual", "test") + if result.OK || result.Status != 200 || result.Error != "model test timed out" { + t.Fatal(result) + } + select { + case <-canceled: + case <-time.After(time.Second): + t.Fatal("upstream was not canceled") + } +} diff --git a/internal/server/performance_test.go b/internal/server/performance_test.go new file mode 100644 index 0000000..d01dec3 --- /dev/null +++ b/internal/server/performance_test.go @@ -0,0 +1,203 @@ +package server + +import ( + "ai-router/internal/protocol" + "fmt" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "runtime" + "sort" + "strings" + "sync" + "sync/atomic" + "testing" + "time" +) + +// Explicitly opt in: ROUTER_PERF=1 go test ./internal/server -run TestPerformanceHTTP -v -count=1. +func TestPerformanceHTTP(t *testing.T) { + if os.Getenv("ROUTER_PERF") != "1" { + t.Skip("opt-in load test") + } + for _, stream := range []bool{false, true} { + up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + io.Copy(io.Discard, r.Body) + r.Body.Close() + if stream { + w.Header().Set("Content-Type", "text/event-stream") + for _, f := range strings.Split(integrationSSE("openai-chat"), "\n\n") { + if f != "" { + fmt.Fprint(w, f+"\n\n") + w.(http.Flusher).Flush() + } + } + } else { + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, integrationResponse("openai-chat")) + } + })) + for _, mode := range []string{"direct", "memory", "disk"} { + for _, down := range []string{"openai-chat", "anthropic"} { + if mode == "direct" && down != "openai-chat" { + continue + } + for _, concurrency := range []int{1, 8, 32} { + t.Run(fmt.Sprintf("stream=%t/%s/%s/c%d", stream, mode, down, concurrency), func(t *testing.T) { + target := up.URL + integrationPath("openai-chat") + raw := "test-key" + var s *Server + var router *httptest.Server + if mode != "direct" { + path := ":memory:" + if mode == "disk" { + path = filepath.Join(t.TempDir(), "router.db") + } + var err error + s, err = New(path, "test-password", nil) + if err != nil { + t.Fatal(err) + } + defer s.Close() + if err := s.SetLimits(1000000, 64, 128); err != nil { + t.Fatal(err) + } + cookie := loginCookie(t, s) + ch := integrationChannel(t, s, cookie, "abc", "openai-chat", up.URL) + key := integrationCreateKey(t, s, cookie, "perf", ch) + raw = key.raw + router = httptest.NewServer(s) + defer router.Close() + target = router.URL + integrationPath(down) + } + tr := http.DefaultTransport.(*http.Transport).Clone() + tr.MaxIdleConnsPerHost = 64 + defer tr.CloseIdleConnections() + client := &http.Client{Transport: tr, Timeout: 10 * time.Second} + body := integrationRequest(down, "abc/gpt-5", stream) + request := func() (time.Duration, time.Duration, bool) { + start := time.Now() + r, _ := http.NewRequest("POST", target, strings.NewReader(body)) + r.Header.Set("Authorization", "Bearer "+raw) + r.Header.Set("Content-Type", "application/json") + resp, err := client.Do(r) + if err != nil { + return time.Since(start), 0, false + } + first := make([]byte, 1) + _, err = resp.Body.Read(first) + ttfb := time.Since(start) + _, e := io.Copy(io.Discard, resp.Body) + resp.Body.Close() + return time.Since(start), ttfb, resp.StatusCode == 200 && (err == nil || err == io.EOF) && e == nil + } + for i := 0; i < 20; i++ { + if _, _, ok := request(); !ok { + t.Fatal("warmup failed") + } + } + const n = 400 + dur := make([]float64, n) + first := make([]float64, n) + var index atomic.Int64 + var failures atomic.Int64 + var wg sync.WaitGroup + runtime.GC() + var before, after runtime.MemStats + runtime.ReadMemStats(&before) + start := time.Now() + for c := 0; c < concurrency; c++ { + wg.Add(1) + go func() { + defer wg.Done() + for { + i := int(index.Add(1)) - 1 + if i >= n { + return + } + d, f, ok := request() + dur[i] = float64(d) / 1e6 + first[i] = float64(f) / 1e6 + if !ok { + failures.Add(1) + } + } + }() + } + wg.Wait() + elapsed := time.Since(start) + runtime.ReadMemStats(&after) + sort.Float64s(dur) + sort.Float64s(first) + var total float64 + for _, d := range dur { + total += d + } + drained := elapsed + if s != nil { + router.Close() + if err := s.flushLogs(); err != nil { + t.Fatal(err) + } + drained = time.Since(start) + var count int + if err := s.db.QueryRow("SELECT COUNT(*) FROM logs").Scan(&count); err != nil || count != n+20 { + t.Fatalf("log count %d err %v", count, err) + } + } + t.Logf("RESULT rps=%.1f drained_rps=%.1f mean_ms=%.3f p50_ms=%.3f p95_ms=%.3f p99_ms=%.3f first_p50_ms=%.3f first_p95_ms=%.3f alloc_B_req=%d errors=%d", float64(n)/elapsed.Seconds(), float64(n)/drained.Seconds(), total/n, dur[n/2], dur[n*95/100], dur[n*99/100], first[n/2], first[n*95/100], (after.TotalAlloc-before.TotalAlloc)/n, failures.Load()) + if failures.Load() != 0 { + t.Fail() + } + }) + } + } + } + up.Close() + } +} + +func BenchmarkPerformanceConversion(b *testing.B) { + for _, size := range []int{1024, 65536} { + for _, to := range []protocol.Kind{protocol.Chat, protocol.Responses, protocol.Anthropic} { + b.Run(fmt.Sprintf("%d/%s", size, to), func(b *testing.B) { + body := []byte(`{"model":"abc/gpt-5","max_tokens":1024,"messages":[{"role":"user","content":"` + strings.Repeat("x", size) + `"}]}`) + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + if _, err := protocol.ConvertRequest(protocol.Chat, to, body, "gpt-4"); err != nil { + b.Fatal(err) + } + } + }) + } + } +} + +func BenchmarkPerformanceStats(b *testing.B) { + for _, n := range []int{1000, 10000, 100000} { + b.Run(fmt.Sprint(n), func(b *testing.B) { + s, err := New(filepath.Join(b.TempDir(), "stats.db"), "test-password", nil) + if err != nil { + b.Fatal(err) + } + defer s.Close() + _, err = s.db.Exec(`WITH RECURSIVE seq(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM seq WHERE x= 300 { + status := response.StatusCode + if status < 400 { + status = 502 + } + fail(status, "upstream rejected request") + return + } + if req.Stream { + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("X-Accel-Buffering", "no") + w.WriteHeader(response.StatusCode) + l.Status = response.StatusCode + streamWriter := &deadlineWriter{ResponseWriter: w} + u, e := protocol.Stream(ctx, streamWriter, response.Body, up, down, req.Model) + l.Usage = u + if e != nil { + l.Status = 502 + l.Error = "upstream stream interrupted" + l.Usage.Known = false + if ctx.Err() == nil { + event := "data: {\"error\":{\"type\":\"upstream_error\",\"message\":\"upstream stream interrupted\"}}\n\n" + if down != protocol.Chat { + event = "event: error\n" + event + } + _, _ = io.WriteString(streamWriter, event) + if f, ok := w.(http.Flusher); ok { + f.Flush() + } + } + } + return + } + b, e = io.ReadAll(io.LimitReader(response.Body, maxBody+1)) + if e != nil || len(b) > maxBody { + fail(502, "invalid upstream response") + return + } + l.Usage = protocol.ExtractUsage(up, b) + out, e := protocol.ConvertResponse(up, down, b) + if e != nil { + fail(502, "unsupported response conversion") + return + } + l.Status = response.StatusCode + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(response.StatusCode) + if _, e = w.Write(out); e != nil { + l.Status = 499 + l.Error = "client disconnected" + } +} + +type deadlineWriter struct{ http.ResponseWriter } + +// safeForwardedHeader reports whether v is a short printable token list that +// is safe to forward upstream; anything else is dropped so clients cannot +// smuggle arbitrary features or exotic header content past the channel +// configuration. +func safeForwardedHeader(v string, max int) bool { + if len(v) == 0 || len(v) > max { + return false + } + for i := 0; i < len(v); i++ { + c := v[i] + switch { + case c >= 'a' && c <= 'z', c >= 'A' && c <= 'Z', c >= '0' && c <= '9': + case c == '-' || c == '_' || c == '.' || c == ',' || c == ' ': + default: + return false + } + } + return true +} + +func (w *deadlineWriter) Unwrap() http.ResponseWriter { return w.ResponseWriter } +func (w *deadlineWriter) WriteHeader(status int) { + _ = http.NewResponseController(w.ResponseWriter).SetWriteDeadline(time.Now().Add(30 * time.Second)) + w.ResponseWriter.WriteHeader(status) +} + +func (w *deadlineWriter) Write(b []byte) (int, error) { + _ = http.NewResponseController(w.ResponseWriter).SetWriteDeadline(time.Now().Add(30 * time.Second)) + return w.ResponseWriter.Write(b) +} +func (w *deadlineWriter) FlushError() error { + _ = http.NewResponseController(w.ResponseWriter).SetWriteDeadline(time.Now().Add(30 * time.Second)) + return http.NewResponseController(w.ResponseWriter).Flush() +} +func (w *deadlineWriter) Flush() { _ = w.FlushError() } diff --git a/internal/server/security.go b/internal/server/security.go new file mode 100644 index 0000000..8eab858 --- /dev/null +++ b/internal/server/security.go @@ -0,0 +1,203 @@ +package server + +import ( + "fmt" + "net" + "net/http" + "net/netip" + "strconv" + "strings" + "sync" + "time" +) + +const bodyReadTimeout = 30 * time.Second +const maxLimitEntries = 10000 + +// Login rate limits: failures only, per IP and globally; successful logins +// never consume or hit these buckets. +const ( + loginFailuresPerMinute = 10 + loginGlobalFailuresPerMinute = 300 +) + +// authFailuresPerMinute bounds how many failed /v1 authentications one client +// IP may attempt per minute before further attempts are rejected before the +// database lookup. +const authFailuresPerMinute = 60 + +func pruneFailures(m map[string][]time.Time, now time.Time) { + for k, ts := range m { + if len(ts) == 0 || now.Sub(ts[len(ts)-1]) > time.Minute { + delete(m, k) + } + } +} + +// trimRecent drops entries older than one minute while reusing the backing +// array, so a long-lived failure window cannot grow without bound. +func trimRecent(ts []time.Time, now time.Time) []time.Time { + i := 0 + for i < len(ts) && now.Sub(ts[i]) >= time.Minute { + i++ + } + if i > 0 { + ts = append(ts[:0], ts[i:]...) + } + return ts +} + +// authAllowed reports whether the client IP may attempt authentication. Only +// failures are counted, so legitimate traffic is unaffected. +func (s *Server) authAllowed(ip string) bool { + now := time.Now() + s.mu.Lock() + defer s.mu.Unlock() + if now.Sub(s.authFailuresSeen) >= time.Minute { + pruneFailures(s.authFailures, now) + s.authFailuresSeen = now + } + recent := 0 + for _, t := range s.authFailures[ip] { + if now.Sub(t) < time.Minute { + recent++ + } + } + return recent < authFailuresPerMinute && (recent > 0 || len(s.authFailures) < maxLimitEntries) +} + +// authFail records one failed authentication from ip. +func (s *Server) authFail(ip string) { + now := time.Now() + s.mu.Lock() + defer s.mu.Unlock() + if _, ok := s.authFailures[ip]; !ok && len(s.authFailures) >= maxLimitEntries { + return + } + s.authFailures[ip] = append(s.authFailures[ip], now) +} + +func readBody(w http.ResponseWriter, r *http.Request, limit int64, timeout time.Duration, read func() error) error { + controller := http.NewResponseController(w) + _ = controller.SetReadDeadline(time.Now().Add(timeout)) + defer controller.SetReadDeadline(time.Time{}) + r.Body = http.MaxBytesReader(w, r.Body, limit) + defer r.Body.Close() + return read() +} + +// SetTrustedProxies must be called before serving requests. +func (s *Server) SetTrustedProxies(cidrs []string) error { + prefixes := make([]netip.Prefix, 0, len(cidrs)) + for _, cidr := range cidrs { + p, err := netip.ParsePrefix(cidr) + if err != nil { + return fmt.Errorf("invalid trusted proxy CIDR %q: %w", cidr, err) + } + if p.Bits() == 0 { + return fmt.Errorf("trusted proxy CIDR must not trust all addresses") + } + prefixes = append(prefixes, p) + } + s.trustedProxies = prefixes + return nil +} + +func (s *Server) trusted(ip netip.Addr) bool { + for _, p := range s.trustedProxies { + if p.Contains(ip) { + return true + } + } + return false +} + +func (s *Server) clientIP(r *http.Request) string { + host, _, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { + host = r.RemoteAddr + } + peer, err := netip.ParseAddr(host) + if err != nil { + return host + } + peer = peer.Unmap() + if !s.trusted(peer) { + return peer.String() + } + parts := strings.Split(strings.Join(r.Header.Values("X-Forwarded-For"), ","), ",") + ips := make([]netip.Addr, len(parts)) + for i, part := range parts { + ip, err := netip.ParseAddr(strings.TrimSpace(part)) + if err != nil || ip.Zone() != "" { + return peer.String() + } + ips[i] = ip.Unmap() + } + for i := len(ips) - 1; i >= 0; i-- { + if !s.trusted(ips[i]) { + return ips[i].String() + } + } + return peer.String() +} + +type limitEntry struct { + window time.Time + requests, active int +} +type requestLimiter struct { + mu sync.Mutex + rpm, perKey, global, active int + entries map[string]*limitEntry + nextCleanup time.Time +} + +func newRequestLimiter(rpm, perKey, global int) *requestLimiter { + return &requestLimiter{rpm: rpm, perKey: perKey, global: global, entries: make(map[string]*limitEntry)} +} + +// SetLimits must be called before serving requests. +func (s *Server) SetLimits(rpm, perKey, global int) error { + if rpm <= 0 || perKey <= 0 || global <= 0 { + return fmt.Errorf("limits must be positive integers") + } + s.limiter = newRequestLimiter(rpm, perKey, global) + return nil +} +func (l *requestLimiter) acquire(key string, now time.Time) (func(), string) { + l.mu.Lock() + defer l.mu.Unlock() + if !now.Before(l.nextCleanup) { + for k, e := range l.entries { + if e.active == 0 && now.Sub(e.window) >= time.Minute { + delete(l.entries, k) + } + } + l.nextCleanup = now.Add(time.Minute) + } + e := l.entries[key] + if e == nil { + if len(l.entries) >= maxLimitEntries { + return nil, "60" + } + e = &limitEntry{window: now} + l.entries[key] = e + } + if now.Sub(e.window) >= time.Minute { + e.window = now + e.requests = 0 + } + if e.requests >= l.rpm { + seconds := int((time.Minute - now.Sub(e.window) + time.Second - 1) / time.Second) + return nil, strconv.Itoa(seconds) + } + if e.active >= l.perKey || l.active >= l.global { + return nil, "1" + } + e.requests++ + e.active++ + l.active++ + var once sync.Once + return func() { once.Do(func() { l.mu.Lock(); defer l.mu.Unlock(); e.active--; l.active-- }) }, "" +} diff --git a/internal/server/security_stats_test.go b/internal/server/security_stats_test.go new file mode 100644 index 0000000..6fbb08b --- /dev/null +++ b/internal/server/security_stats_test.go @@ -0,0 +1,161 @@ +package server + +import ( + "ai-router/internal/protocol" + "context" + "encoding/json" + "fmt" + "net/http/httptest" + "strings" + "testing" + "time" + "unicode/utf8" +) + +func TestSecurityStatsSQLSemantics(t *testing.T) { + s := testServer(t) + now := time.Now().UTC().Format(time.RFC3339Nano) + for _, l := range []requestLog{ + {CreatedAt: now, KeyID: "a", KeyName: "old", Status: 200, Usage: protocol.Usage{Known: true, Input: 8}}, + {CreatedAt: now, KeyID: "a", KeyName: "new", Status: 200, Error: "failed", Usage: protocol.Usage{Raw: []byte("{}"), Input: 100}}, + {CreatedAt: now, KeyID: "b", Status: 400}, + {CreatedAt: now, KeyID: "c", Status: 204, Usage: protocol.Usage{Known: true}}, + } { + s.record(l) + } + if err := s.flushLogs(); err != nil { + t.Fatal(err) + } + for _, tc := range []struct { + query string + requests, success, unknown int64 + input *int64 + }{ + {"", 4, 2, 2, new(int64(8))}, + {"?key_id=b", 1, 0, 1, nil}, + {"?key_id=c", 1, 1, 0, new(int64(0))}, + {"?key_id=missing", 0, 0, 0, nil}, + } { + w := httptest.NewRecorder() + s.stats(w, httptest.NewRequest("GET", "/admin/api/stats"+tc.query, nil)) + if w.Code != 200 { + t.Fatal(w.Body.String()) + } + var got struct { + Summary summary + ByKey []struct{ Name string } `json:"by_key"` + } + if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil { + t.Fatal(err) + } + if got.Summary.Requests != tc.requests || got.Summary.Success != tc.success || got.Summary.Unknown != tc.unknown { + t.Fatal(w.Body.String()) + } + if (got.Summary.Input == nil) != (tc.input == nil) || tc.input != nil && *got.Summary.Input != *tc.input { + t.Fatal(w.Body.String()) + } + if tc.query == "" && got.ByKey[0].Name != "new" { + t.Fatal(w.Body.String()) + } + } +} + +func TestSecurityLogFieldsBounded(t *testing.T) { + s := testServer(t) + huge := strings.Repeat("界", 10000) + s.record(requestLog{RequestModel: huge, ActualModel: huge, Channel: huge, KeyID: huge, KeyName: huge, Upstream: huge, Downstream: huge, Error: huge, Usage: protocol.Usage{Known: true, Input: 12, Raw: []byte(huge)}}) + if err := s.flushLogs(); err != nil { + t.Fatal(err) + } + var model, actual, channel, key, name, up, down, message, raw string + var input int64 + if err := s.db.QueryRow("SELECT request_model,actual_model,channel,key_id,key_name,upstream,downstream,error,raw_usage,input_tokens FROM logs").Scan(&model, &actual, &channel, &key, &name, &up, &down, &message, &raw, &input); err != nil { + t.Fatal(err) + } + for _, field := range []string{model, actual, channel, key, name, up, down} { + if len(field) > maxLogFieldBytes || !utf8.ValidString(field) { + t.Fatal("unbounded or invalid field") + } + } + if len(message) > maxLogErrorBytes || !utf8.ValidString(message) || len(raw) > maxLogUsageBytes || !json.Valid([]byte(raw)) || input != 12 { + t.Fatal("invalid bounded log") + } +} + +func TestSecurityLogRetentionOptInAndBatch(t *testing.T) { + s := testServer(t) + old := time.Now().Add(-91 * 24 * time.Hour).Format(time.RFC3339Nano) + for i := 0; i < logCleanupBatch+3; i++ { + s.record(requestLog{CreatedAt: old}) + } + s.record(requestLog{CreatedAt: time.Now().Format(time.RFC3339Nano)}) + if err := s.flushLogs(); err != nil { + t.Fatal(err) + } + if s.logRetentionDays.Load() != 0 { + t.Fatal("retention enabled by default") + } + if err := s.purgeExpiredLogs(context.Background(), 0); err != nil { + t.Fatal(err) + } + if n := logCount(t, s.db); n != logCleanupBatch+4 { + t.Fatal(n) + } + s.SetLogRetention(90) + if s.logRetentionDays.Load() != 90 { + t.Fatal("retention not set") + } + if err := s.purgeExpiredLogs(context.Background(), 90); err != nil { + t.Fatal(err) + } + if n := logCount(t, s.db); n != 4 { + t.Fatal("unbounded purge", n) + } + if err := s.purgeExpiredLogs(context.Background(), 90); err != nil { + t.Fatal(err) + } + if n := logCount(t, s.db); n != 1 { + t.Fatal(n) + } + s.SetLogRetention(0) + if s.logRetentionDays.Load() != 0 { + t.Fatal("retention not disabled") + } + s.SetLogRetention(90) + if err := s.Close(); err != nil { + t.Fatal(err) + } +} + +func TestStatsGroupLimitAndCancellation(t *testing.T) { + s := testServer(t) + tx, err := s.db.Begin() + if err != nil { + t.Fatal(err) + } + for i := 0; i < 10001; i++ { + if err := insertLog(context.Background(), tx, requestLog{CreatedAt: "2026-01-01T00:00:00Z", KeyID: fmt.Sprint(i)}); err != nil { + tx.Rollback() + t.Fatal(err) + } + } + if err := tx.Commit(); err != nil { + t.Fatal(err) + } + w := httptest.NewRecorder() + s.stats(w, httptest.NewRequest("GET", "/admin/api/stats", nil)) + if w.Code != 422 { + t.Fatal("unbounded group result", w.Code) + } + ctx, cancel := context.WithCancel(context.Background()) + cancel() + w = httptest.NewRecorder() + s.stats(w, httptest.NewRequest("GET", "/admin/api/stats", nil).WithContext(ctx)) + if w.Code == 200 { + t.Fatal("cancelled query succeeded") + } + var day string + if err := s.db.QueryRow("SELECT router_log_day(NULL, 'UTC')").Scan(&day); err == nil { + t.Fatal("invalid timestamp accepted") + } +} diff --git a/internal/server/security_test.go b/internal/server/security_test.go new file mode 100644 index 0000000..3a813e8 --- /dev/null +++ b/internal/server/security_test.go @@ -0,0 +1,359 @@ +package server + +import ( + "bufio" + "fmt" + "io" + "net" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" +) + +func TestClientIPTrust(t *testing.T) { + s := &Server{} + if err := s.SetTrustedProxies([]string{"10.0.0.0/8", "::1/128"}); err != nil { + t.Fatal(err) + } + if err := s.SetTrustedProxies([]string{"bad"}); err == nil { + t.Fatal("invalid CIDR accepted") + } + for _, tt := range []struct{ peer, xff, want string }{ + {"192.0.2.1:42", "198.51.100.1", "192.0.2.1"}, + {"10.0.0.1:42", "198.51.100.1, 10.0.0.2", "198.51.100.1"}, + {"10.0.0.1:42", "198.51.100.1, 192.0.2.2", "192.0.2.2"}, + {"10.0.0.1:42", "bad, 192.0.2.2", "10.0.0.1"}, + {"10.0.0.1:42", "", "10.0.0.1"}, + {"10.0.0.1:42", "10.0.0.2", "10.0.0.1"}, + {"[::1]:42", "2001:db8::1", "2001:db8::1"}, + } { + r := httptest.NewRequest("POST", "/", nil) + r.RemoteAddr = tt.peer + r.Header.Set("X-Forwarded-For", tt.xff) + r.Header.Set("X-Real-IP", "203.0.113.1") + if got := s.clientIP(r); got != tt.want { + t.Errorf("%+v: got %s", tt, got) + } + } +} + +func TestLoginProxyIsolation(t *testing.T) { + s, err := New(":memory:", "password", nil) + if err != nil { + t.Fatal(err) + } + defer s.Close() + if err := s.SetTrustedProxies([]string{"10.0.0.0/8"}); err != nil { + t.Fatal(err) + } + login := func(peer, forwarded string) int { + r := httptest.NewRequest("POST", "/admin/api/login", strings.NewReader(`{"password":"wrong"}`)) + r.RemoteAddr = peer + r.Header.Set("X-Forwarded-For", forwarded) + w := httptest.NewRecorder() + s.login(w, r) + return w.Code + } + for i := 0; i < 10; i++ { + if got := login("10.0.0.1:1", "192.0.2.1"); got != 401 { + t.Fatal(got) + } + } + if got := login("10.0.0.1:1", "192.0.2.1"); got != 429 { + t.Fatal(got) + } + if got := login("10.0.0.1:1", "192.0.2.2"); got != 401 { + t.Fatal(got) + } + for i := 0; i < 10; i++ { + login("192.0.2.3:1", fmt.Sprintf("198.51.100.%d", i)) + } + if got := login("192.0.2.3:1", "203.0.113.1"); got != 429 { + t.Fatal(got) + } +} + +func TestBodyDeadline(t *testing.T) { + result := make(chan error, 1) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + err := readBody(w, r, 1024, 40*time.Millisecond, func() error { _, err := io.ReadAll(r.Body); return err }) + result <- err + w.WriteHeader(400) + })) + defer srv.Close() + conn, err := net.Dial("tcp", srv.Listener.Addr().String()) + if err != nil { + t.Fatal(err) + } + defer conn.Close() + fmt.Fprint(conn, "POST / HTTP/1.1\r\nHost: test\r\nContent-Length: 20\r\n\r\nx") + select { + case err := <-result: + if err == nil { + t.Fatal("slow body accepted") + } + case <-time.After(2 * time.Second): + t.Fatal("body deadline did not fire") + } +} + +func TestBodyDeadlineCleared(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + err := readBody(w, r, 1024, 40*time.Millisecond, func() error { _, err := io.ReadAll(r.Body); return err }) + if err != nil { + http.Error(w, "read", 400) + return + } + time.Sleep(100 * time.Millisecond) + select { + case <-r.Context().Done(): + http.Error(w, "canceled", 500) + return + default: + } + fmt.Fprint(w, "data: ok\n\n") + })) + defer srv.Close() + conn, err := net.Dial("tcp", srv.Listener.Addr().String()) + if err != nil { + t.Fatal(err) + } + defer conn.Close() + conn.SetDeadline(time.Now().Add(2 * time.Second)) + fmt.Fprint(conn, "POST / HTTP/1.1\r\nHost: test\r\nContent-Length: 1\r\n\r\nx") + resp, err := http.ReadResponse(bufio.NewReader(conn), nil) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != 200 { + t.Fatal(resp.StatusCode) + } +} + +func TestLoginBodyLimit(t *testing.T) { + r := httptest.NewRequest("POST", "/admin/api/login", strings.NewReader(`{"password":"`+strings.Repeat("x", 16<<10)+`"}`)) + var v any + if decode(httptest.NewRecorder(), r, &v) == nil { + t.Fatal("oversize login accepted") + } +} + +func TestLimiterReleaseAndCleanup(t *testing.T) { + now := time.Now() + l := newRequestLimiter(2, 1, 2) + a, _ := l.acquire("a", now) + if a == nil { + t.Fatal("denied") + } + if release, _ := l.acquire("a", now); release != nil { + t.Fatal("per-key concurrency bypass") + } + b, _ := l.acquire("b", now) + if b == nil { + t.Fatal("denied") + } + if release, _ := l.acquire("c", now); release != nil { + t.Fatal("global concurrency bypass") + } + a() + a() + b() + a, _ = l.acquire("a", now) + if a == nil { + t.Fatal("not released") + } + a() + if release, retry := l.acquire("a", now); release != nil || retry != "60" { + t.Fatal("rpm bypass") + } + c, _ := l.acquire("c", now.Add(time.Minute)) + if c == nil { + t.Fatal("window not reset") + } + c() + if len(l.entries) != 1 { + t.Fatalf("stale entries: %d", len(l.entries)) + } + for i := 0; i < maxLimitEntries; i++ { + l.entries[fmt.Sprint(i)] = &limitEntry{window: now.Add(time.Minute)} + } + if release, _ := l.acquire("overflow", now.Add(time.Minute)); release != nil { + t.Fatal("map cap bypass") + } +} + +func TestLimiterConcurrent(t *testing.T) { + l := newRequestLimiter(10000, 8, 64) + var wg sync.WaitGroup + for i := 0; i < 100; i++ { + wg.Add(1) + go func() { + defer wg.Done() + if release, _ := l.acquire("key", time.Now()); release != nil { + release() + } + }() + } + wg.Wait() + if l.active != 0 || l.entries["key"].active != 0 { + t.Fatal("permit leak") + } +} + +func TestProxyLimitRejectsBeforeBodyAndLogging(t *testing.T) { + s, err := New(":memory:", "test-password", nil) + if err != nil { + t.Fatal(err) + } + defer s.Close() + if err := s.SetLimits(1, 1, 1); err != nil { + t.Fatal(err) + } + cookie := loginCookie(t, s) + ch := integrationChannel(t, s, cookie, "abc", "openai-chat", "http://127.0.0.1:1") + key := integrationCreateKey(t, s, cookie, "limited", ch) + request := func(body io.Reader) *httptest.ResponseRecorder { + r := httptest.NewRequest("POST", "/v1/chat/completions", body) + r.Header.Set("Authorization", "Bearer "+key.raw) + w := httptest.NewRecorder() + s.ServeHTTP(w, r) + return w + } + if w := request(strings.NewReader("{}")); w.Code != 400 { + t.Fatal(w.Code) + } + if s.limiter.active != 0 { + t.Fatal("permit leaked after invalid request") + } + body := &observedReader{} + w := request(body) + if w.Code != 429 || w.Header().Get("Retry-After") == "" || body.read { + t.Fatalf("status=%d read=%t", w.Code, body.read) + } + if err := s.flushLogs(); err != nil { + t.Fatal(err) + } + var count int + if err := s.db.QueryRow("SELECT count(*) FROM logs").Scan(&count); err != nil { + t.Fatal(err) + } + if count != 1 { + t.Fatalf("rejected request logged: %d logs", count) + } +} + +type observedReader struct{ read bool } + +func (r *observedReader) Read(p []byte) (int, error) { r.read = true; return 0, io.EOF } + +func TestLoginFailuresOnlyAndGlobalCap(t *testing.T) { + s, err := New(":memory:", "password", nil) + if err != nil { + t.Fatal(err) + } + defer s.Close() + if err := s.SetTrustedProxies([]string{"10.0.0.0/8"}); err != nil { + t.Fatal(err) + } + login := func(peer, forwarded, password string) int { + r := httptest.NewRequest("POST", "/admin/api/login", strings.NewReader(`{"password":"`+password+`"}`)) + r.RemoteAddr = peer + if forwarded != "" { + r.Header.Set("X-Forwarded-For", forwarded) + } + w := httptest.NewRecorder() + s.login(w, r) + return w.Code + } + for i := 0; i < 15; i++ { + if got := login("10.0.0.1:1", "192.0.2.1", "password"); got != 200 { + t.Fatalf("correct login %d rejected: %d", i, got) + } + } + for i := 0; i < loginFailuresPerMinute; i++ { + if got := login("10.0.0.1:1", "192.0.2.2", "wrong"); got != 401 { + t.Fatalf("failure %d: %d", i, got) + } + } + if got := login("10.0.0.1:1", "192.0.2.2", "wrong"); got != 429 { + t.Fatal(got) + } + // The per-IP lockout is uniform: it must not confirm candidate passwords. + if got := login("10.0.0.1:1", "192.0.2.2", "password"); got != 429 { + t.Fatal("lockout leaks password validity:", got) + } + // A successful login clears its IP bucket, so fat-fingers do not accumulate. + if got := login("10.0.0.1:1", "192.0.2.3", "wrong"); got != 401 { + t.Fatal(got) + } + if got := login("10.0.0.1:1", "192.0.2.3", "password"); got != 200 { + t.Fatal(got) + } + for i := 0; i < loginFailuresPerMinute; i++ { + if got := login("10.0.0.1:1", "192.0.2.3", "wrong"); got != 401 { + t.Fatalf("post-clear failure %d: %d", i, got) + } + } + if got := login("10.0.0.1:1", "192.0.2.3", "wrong"); got != 429 { + t.Fatal(got) + } + for i := 0; i < loginGlobalFailuresPerMinute; i++ { + login("10.0.0.1:1", fmt.Sprintf("198.18.%d.%d", i/256, i%256), "wrong") + } + if got := login("10.0.0.1:1", "203.0.113.1", "wrong"); got != 429 { + t.Fatal(got) + } + // The global cap fails closed too: aggregate brute force locks every + // login out without revealing candidate-password validity. + if got := login("10.0.0.1:1", "203.0.113.1", "password"); got != 429 { + t.Fatal("global lockout leaks password validity:", got) + } +} + +func TestAuthFailureGate(t *testing.T) { + s := testServer(t) + cookie := loginCookie(t, s) + ch := integrationChannel(t, s, cookie, "abc", "openai-chat", "http://127.0.0.1:1") + key := integrationCreateKey(t, s, cookie, "gated", ch) + request := func(auth string) *httptest.ResponseRecorder { + r := httptest.NewRequest("POST", "/v1/chat/completions", strings.NewReader(integrationRequest("openai-chat", "abc/gpt-5", false))) + r.Header.Set("Authorization", "Bearer "+auth) + w := httptest.NewRecorder() + s.ServeHTTP(w, r) + return w + } + if w := request(key.raw); w.Code != 502 { + t.Fatal("valid key setup:", w.Code, w.Body.String()) + } + for i := 0; i < authFailuresPerMinute; i++ { + if w := request("wrong"); w.Code != 401 { + t.Fatalf("%d: %d", i, w.Code) + } + } + if w := request("wrong"); w.Code != 429 || w.Header().Get("Retry-After") == "" { + t.Fatal(w.Code, w.Header()) + } + // The gate applies before the key lookup: a valid key from the same + // (attacker-shared) IP is throttled the same way. + if w := request(key.raw); w.Code != 429 { + t.Fatal(w.Code) + } +} + +func TestProxyBodyTooLarge(t *testing.T) { + s := testServer(t) + cookie := loginCookie(t, s) + ch := integrationChannel(t, s, cookie, "abc", "openai-chat", "http://127.0.0.1:1") + key := integrationCreateKey(t, s, cookie, "limit", ch) + r := httptest.NewRequest("POST", "/v1/chat/completions", strings.NewReader(`{"model":"abc/gpt-5","messages":[],"pad":"`+strings.Repeat("x", maxBody)+`"}`)) + r.Header.Set("Authorization", "Bearer "+key.raw) + w := httptest.NewRecorder() + s.ServeHTTP(w, r) + if w.Code != 413 || !strings.Contains(w.Body.String(), "too large") { + t.Fatal(w.Code, w.Body.String()) + } +} diff --git a/internal/server/server.go b/internal/server/server.go new file mode 100644 index 0000000..03dc7cf --- /dev/null +++ b/internal/server/server.go @@ -0,0 +1,327 @@ +package server + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "crypto/subtle" + "database/sql" + "encoding/hex" + "encoding/json" + "errors" + "io" + "io/fs" + _ "modernc.org/sqlite" + "net" + "net/http" + "net/netip" + "net/url" + "os" + "path/filepath" + "strings" + "sync" + "sync/atomic" + "time" +) + +type Server struct { + logRetentionDays atomic.Int64 + trustedProxies []netip.Prefix + limiter *requestLimiter + db *sql.DB + password [32]byte + assets fs.FS + mu sync.Mutex + sessions map[string]time.Time + attempts map[string][]time.Time + authFailures map[string][]time.Time + authFailuresSeen time.Time + loginFailures []time.Time + client *http.Client + publicOrigin string + lifecycleMu sync.Mutex + closing bool + active sync.WaitGroup + closeOnce sync.Once + closeErr error + logMu sync.RWMutex + logsClosed bool + logQueue chan logEntry + logDone chan struct{} + logErr error +} + +const maxBody = 16 << 20 + +func New(p, password string, a fs.FS) (*Server, error) { + if password == "" { + return nil, errors.New("empty admin password") + } + if p != ":memory:" { + if e := os.MkdirAll(filepath.Dir(p), 0700); e != nil { + return nil, e + } + } + d, e := sql.Open("sqlite", p) + if e != nil { + return nil, e + } + d.SetMaxOpenConns(1) + schema := `PRAGMA busy_timeout=5000; CREATE TABLE IF NOT EXISTS channels(id TEXT PRIMARY KEY,name TEXT NOT NULL UNIQUE,base_url TEXT NOT NULL,protocol TEXT NOT NULL,enabled INTEGER NOT NULL,passthrough INTEGER NOT NULL,models TEXT NOT NULL,api_key TEXT NOT NULL); CREATE TABLE IF NOT EXISTS keys(id TEXT PRIMARY KEY,name TEXT NOT NULL,prefix TEXT NOT NULL,hash TEXT NOT NULL UNIQUE,enabled INTEGER NOT NULL,all_channels INTEGER NOT NULL,channels TEXT NOT NULL,expires_at TEXT NOT NULL,created_at TEXT NOT NULL,revoked INTEGER NOT NULL DEFAULT 0); CREATE TABLE IF NOT EXISTS logs(id INTEGER PRIMARY KEY AUTOINCREMENT,created_at TEXT NOT NULL,key_id TEXT NOT NULL,key_name TEXT NOT NULL,channel TEXT NOT NULL,request_model TEXT NOT NULL,actual_model TEXT NOT NULL,downstream TEXT NOT NULL,upstream TEXT NOT NULL,status INTEGER NOT NULL,duration_ms INTEGER NOT NULL,stream INTEGER NOT NULL,input_tokens INTEGER,output_tokens INTEGER,cache_read_tokens INTEGER,cache_write_tokens INTEGER,usage_known INTEGER NOT NULL,error TEXT NOT NULL,raw_usage TEXT NOT NULL); CREATE INDEX IF NOT EXISTS logs_time ON logs(created_at); CREATE INDEX IF NOT EXISTS logs_key ON logs(key_id);` + if _, e = d.Exec(schema); e != nil { + d.Close() + return nil, e + } + if p != ":memory:" { + if e = os.Chmod(p, 0600); e != nil { + d.Close() + return nil, e + } + } + tr := http.DefaultTransport.(*http.Transport).Clone() + // Upstreams must not be able to turn compressed streams into proxy-side + // decompression work; the router asks for identity encoding. + tr.DisableCompression = true + tr.ResponseHeaderTimeout = 60 * time.Second + tr.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) { + c, e := (&net.Dialer{Timeout: 15 * time.Second, KeepAlive: 30 * time.Second}).DialContext(ctx, network, addr) + if e != nil { + return nil, e + } + return &idleConn{Conn: c}, nil + } + s := &Server{db: d, password: sha256.Sum256([]byte(password)), assets: a, sessions: map[string]time.Time{}, attempts: map[string][]time.Time{}, authFailures: map[string][]time.Time{}, client: &http.Client{Transport: tr, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}, logQueue: make(chan logEntry, 1024), logDone: make(chan struct{})} + go s.writeLogs() + s.limiter = newRequestLimiter(120, 8, 64) + return s, nil +} +func (s *Server) Close() error { + s.closeOnce.Do(func() { + s.lifecycleMu.Lock() + s.closing = true + s.lifecycleMu.Unlock() + s.active.Wait() + s.logMu.Lock() + s.logsClosed = true + close(s.logQueue) + s.logMu.Unlock() + <-s.logDone + s.client.CloseIdleConnections() + s.closeErr = errors.Join(s.logErr, s.db.Close()) + }) + return s.closeErr +} +func writeJSON(w http.ResponseWriter, v any) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(v) +} +func errJSON(w http.ResponseWriter, n int, m string) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(n) + json.NewEncoder(w).Encode(map[string]string{"error": m}) +} +func decode(w http.ResponseWriter, r *http.Request, v any) error { + limit := int64(maxBody) + if r.URL.Path == "/admin/api/login" { + limit = 16 << 10 + } + return readBody(w, r, limit, bodyReadTimeout, func() error { + d := json.NewDecoder(r.Body) + if e := d.Decode(v); e != nil { + return e + } + var extra any + if d.Decode(&extra) != io.EOF { + return errors.New("trailing JSON") + } + return nil + }) +} +func randomID() string { + b := make([]byte, 32) + if _, e := rand.Read(b); e != nil { + panic(e) + } + return hex.EncodeToString(b) +} +func hash(v string) string { h := sha256.Sum256([]byte(v)); return hex.EncodeToString(h[:]) } +func (s *Server) session(r *http.Request) bool { + s.mu.Lock() + defer s.mu.Unlock() + c, e := r.Cookie("ai_router_session") + if e != nil { + return false + } + t, ok := s.sessions[c.Value] + return ok && time.Now().Before(t) +} +func (s *Server) guard(r *http.Request) bool { + if r.Header.Get("X-Requested-With") != "ai-router" { + return false + } + o, e := url.Parse(r.Header.Get("Origin")) + if s.publicOrigin != "" { + // Origins are scheme/host case-insensitive; a mixed-case deployment + // value must not lock the admin out. + return e == nil && strings.EqualFold(r.Header.Get("Origin"), s.publicOrigin) + } + scheme := "http" + if r.TLS != nil { + scheme = "https" + } + return e == nil && o.Scheme == scheme && strings.EqualFold(o.Host, r.Host) && o.User == nil && o.RawQuery == "" && o.Fragment == "" && o.Path == "" +} +func (s *Server) secureCookie() bool { + return len(s.publicOrigin) >= 8 && strings.EqualFold(s.publicOrigin[:8], "https://") +} +func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { + w = &deadlineWriter{ResponseWriter: w} + _ = http.NewResponseController(w).SetReadDeadline(time.Now().Add(bodyReadTimeout)) + s.lifecycleMu.Lock() + if s.closing { + s.lifecycleMu.Unlock() + errJSON(w, http.StatusServiceUnavailable, "server shutting down") + return + } + s.active.Add(1) + s.lifecycleMu.Unlock() + defer s.active.Done() + w.Header().Set("X-Content-Type-Options", "nosniff") + w.Header().Set("Referrer-Policy", "no-referrer") + w.Header().Set("X-Frame-Options", "DENY") + w.Header().Set("Content-Security-Policy", "default-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'none'; connect-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:") + if strings.HasPrefix(r.URL.Path, "/admin/api/") || r.URL.Path == "/admin/api" { + w.Header().Set("Cache-Control", "no-store") + s.admin(w, r) + return + } + if strings.HasPrefix(r.URL.Path, "/v1/") { + s.proxy(w, r) + return + } + if strings.HasPrefix(r.URL.Path, "/api/") { + http.NotFound(w, r) + return + } + if s.assets == nil || (r.Method != "GET" && r.Method != "HEAD") { + http.NotFound(w, r) + return + } + p := strings.TrimPrefix(r.URL.Path, "/") + if p == "" { + p = "index.html" + } + if st, e := fs.Stat(s.assets, p); e != nil || st.IsDir() { + p = "index.html" + } + http.ServeFileFS(w, r, s.assets, p) +} +func (s *Server) admin(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" && !s.guard(r) { + errJSON(w, 403, "forbidden") + return + } + if r.URL.Path == "/admin/api/login" && r.Method == "POST" { + s.login(w, r) + return + } + if r.URL.Path == "/admin/api/session" && r.Method == "GET" { + writeJSON(w, map[string]bool{"authenticated": s.session(r)}) + return + } + if !s.session(r) { + errJSON(w, 401, "unauthorized") + return + } + if r.URL.Path == "/admin/api/logout" && r.Method == "POST" { + c, _ := r.Cookie("ai_router_session") + s.mu.Lock() + delete(s.sessions, c.Value) + s.mu.Unlock() + http.SetCookie(w, &http.Cookie{Name: "ai_router_session", MaxAge: -1, Path: "/", HttpOnly: true, SameSite: http.SameSiteStrictMode, Secure: r.TLS != nil || s.secureCookie()}) + writeJSON(w, map[string]bool{"authenticated": false}) + return + } + switch { + case r.URL.Path == "/admin/api/models/test": + s.testModel(w, r) + case r.URL.Path == "/admin/api/channels": + s.channels(w, r) + case r.URL.Path == "/admin/api/channels/discover-models": + s.discoverModels(w, r) + case strings.HasPrefix(r.URL.Path, "/admin/api/channels/"): + s.channelID(w, r) + case r.URL.Path == "/admin/api/keys": + s.keys(w, r) + case strings.HasPrefix(r.URL.Path, "/admin/api/keys/"): + s.keyID(w, r) + case r.URL.Path == "/admin/api/logs" && r.Method == "GET": + s.logs(w, r) + case r.URL.Path == "/admin/api/stats" && r.Method == "GET": + s.stats(w, r) + default: + errJSON(w, 404, "not found") + } +} +// login gates on failure counts BEFORE verifying the password. While an IP is +// locked — or the global failure budget is spent — every attempt, including a +// correct password, gets the same 429 without a comparison, so responses never +// reveal whether a candidate password is right and brute force stays capped +// at loginFailuresPerMinute per IP and loginGlobalFailuresPerMinute overall. +// Successful logins are not failures: their gate slot is released by clearing +// the IP bucket, and only wrong passwords feed the global window. +func (s *Server) login(w http.ResponseWriter, r *http.Request) { + ip := s.clientIP(r) + now := time.Now() + var x struct { + Password string `json:"password"` + } + if decode(w, r, &x) != nil { + errJSON(w, 400, "invalid request") + return + } + s.mu.Lock() + pruneFailures(s.attempts, now) + s.loginFailures = trimRecent(s.loginFailures, now) + recent := []time.Time{} + for _, t := range s.attempts[ip] { + if now.Sub(t) < time.Minute { + recent = append(recent, t) + } + } + blocked := len(recent) >= loginFailuresPerMinute || len(s.loginFailures) >= loginGlobalFailuresPerMinute || (len(recent) == 0 && len(s.attempts) >= 10000) + if !blocked { + // Reserve the gate slot atomically with the check so a concurrent + // burst cannot slip past the limit; a success erases the bucket below. + s.attempts[ip] = append(recent, now) + } + s.mu.Unlock() + if blocked { + w.Header().Set("Retry-After", "60") + errJSON(w, 429, "too many attempts") + return + } + h := sha256.Sum256([]byte(x.Password)) + if subtle.ConstantTimeCompare(h[:], s.password[:]) != 1 { + s.mu.Lock() + s.loginFailures = append(s.loginFailures, now) + s.mu.Unlock() + errJSON(w, 401, "invalid credentials") + return + } + v := randomID() + s.mu.Lock() + delete(s.attempts, ip) + for k, t := range s.sessions { + if now.After(t) { + delete(s.sessions, k) + } + } + if c, e := r.Cookie("ai_router_session"); e == nil { + delete(s.sessions, c.Value) + } + s.sessions[v] = now.Add(24 * time.Hour) + s.mu.Unlock() + http.SetCookie(w, &http.Cookie{Name: "ai_router_session", Value: v, Path: "/", MaxAge: 86400, HttpOnly: true, SameSite: http.SameSiteStrictMode, Secure: r.TLS != nil || s.secureCookie()}) + writeJSON(w, map[string]bool{"authenticated": true}) +} diff --git a/internal/server/server_test.go b/internal/server/server_test.go new file mode 100644 index 0000000..526e310 --- /dev/null +++ b/internal/server/server_test.go @@ -0,0 +1,340 @@ +package server + +import ( + "ai-router/internal/protocol" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "testing/fstest" + "time" +) + +func testServer(t *testing.T) *Server { + t.Helper() + s, e := New(":memory:", "test-password", fstest.MapFS{"index.html": &fstest.MapFile{Data: []byte("SPA")}}) + if e != nil { + t.Fatal(e) + } + t.Cleanup(func() { s.Close() }) + return s +} +func call(s *Server, m, p, b string, c *http.Cookie) *httptest.ResponseRecorder { + r := httptest.NewRequest(m, p, strings.NewReader(b)) + r.Header.Set("Origin", "http://example.com") + r.Header.Set("X-Requested-With", "ai-router") + if c != nil { + r.AddCookie(c) + } + w := httptest.NewRecorder() + s.ServeHTTP(w, r) + return w +} +func loginCookie(t *testing.T, s *Server) *http.Cookie { + t.Helper() + w := call(s, "POST", "/admin/api/login", `{"password":"test-password"}`, nil) + if w.Code != 200 { + t.Fatal(w.Code, w.Body.String()) + } + return w.Result().Cookies()[0] +} +func TestAuthentication(t *testing.T) { + s := testServer(t) + for _, origin := range []string{"", "http://evil.test", "https://example.com"} { + r := httptest.NewRequest("POST", "/admin/api/login", strings.NewReader(`{"password":"test-password"}`)) + r.Header.Set("Origin", origin) + r.Header.Set("X-Requested-With", "ai-router") + w := httptest.NewRecorder() + s.ServeHTTP(w, r) + if w.Code != 403 { + t.Fatal(origin, w.Code) + } + } + c := loginCookie(t, s) + if !c.HttpOnly || c.SameSite != http.SameSiteStrictMode { + t.Fatal(c) + } + if w := call(s, "GET", "/admin/api/channels", "", nil); w.Code != 401 { + t.Fatal(w.Code) + } + if w := call(s, "POST", "/admin/api/logout", "", c); w.Code != 200 { + t.Fatal(w.Code) + } + if w := call(s, "GET", "/admin/api/channels", "", c); w.Code != 401 { + t.Fatal(w.Code) + } + if w := call(s, "GET", "/admin/api/session", "", nil); w.Code != 200 || w.Header().Get("Content-Security-Policy") == "" || w.Header().Get("X-Frame-Options") != "DENY" || w.Header().Get("Referrer-Policy") != "no-referrer" { + t.Fatal("security headers missing", w.Code, w.Header()) + } + for i := 0; i < 10; i++ { + call(s, "POST", "/admin/api/login", `{"password":"wrong"}`, nil) + } + if w := call(s, "POST", "/admin/api/login", `{"password":"wrong"}`, nil); w.Code != 429 { + t.Fatal(w.Code) + } + // While locked, a correct password gets the same 429: the lockout must + // not become an oracle that confirms candidate passwords. + if w := call(s, "POST", "/admin/api/login", `{"password":"test-password"}`, nil); w.Code != 429 { + t.Fatal(w.Code) + } + // The lockout is per IP; a fresh address still logs in. + r := httptest.NewRequest("POST", "/admin/api/login", strings.NewReader(`{"password":"test-password"}`)) + r.Header.Set("Origin", "http://example.com") + r.Header.Set("X-Requested-With", "ai-router") + r.RemoteAddr = "192.0.2.9:1" + w := httptest.NewRecorder() + s.ServeHTTP(w, r) + if w.Code != 200 { + t.Fatal("fresh IP rejected:", w.Code) + } +} +func TestMappingPermissions(t *testing.T) { + cs := []channel{{ID: "stable", Name: "renamed", Enabled: true, Models: map[string]string{"alias": "vendor/real", "vendor/real": "wrong"}}} + k := apiKey{Channels: []string{"stable"}} + _, actual, e := resolve(cs, k, "renamed/alias") + if e != nil || actual != "vendor/real" { + t.Fatal(actual, e) + } + if _, _, e = resolve(cs, apiKey{Channels: []string{"renamed"}}, "renamed/alias"); e == nil { + t.Fatal("name granted access") + } + if _, _, e = resolve(cs, k, "renamed/vendor/unknown"); e == nil { + t.Fatal("unknown allowed") + } + cs[0].Enabled = false + if _, _, e = resolve(cs, k, "renamed/alias"); e == nil { + t.Fatal("disabled allowed") + } +} +func TestLegacyPassthroughIgnored(t *testing.T) { + s := testServer(t) + cookie := loginCookie(t, s) + up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Error("unmapped model reached upstream") + w.WriteHeader(http.StatusBadGateway) + })) + defer up.Close() + body := map[string]any{ + "name": "legacy", "base_url": up.URL, "protocol": "openai-chat", + "enabled": true, "passthrough": true, "models": map[string]string{"alias": "vendor/real"}, + } + save := func() channel { + t.Helper() + b, err := json.Marshal(body) + if err != nil { + t.Fatal(err) + } + w := call(s, "POST", "/admin/api/channels", string(b), cookie) + if w.Code != 200 || strings.Contains(w.Body.String(), `"passthrough"`) { + t.Fatal(w.Code, w.Body.String()) + } + var c channel + if err := json.Unmarshal(w.Body.Bytes(), &c); err != nil { + t.Fatal(err) + } + return c + } + c := save() + var stored bool + if err := s.db.QueryRow("SELECT passthrough FROM channels WHERE id=?", c.ID).Scan(&stored); err != nil || stored { + t.Fatal("new channel enabled passthrough", stored, err) + } + body["id"] = c.ID + save() + if err := s.db.QueryRow("SELECT passthrough FROM channels WHERE id=?", c.ID).Scan(&stored); err != nil || stored { + t.Fatal("update enabled passthrough", stored, err) + } + if _, err := s.db.Exec("UPDATE channels SET passthrough=1 WHERE id=?", c.ID); err != nil { + t.Fatal(err) + } + cs, err := s.loadChannels() + if err != nil { + t.Fatal(err) + } + if _, actual, err := resolve(cs, apiKey{AllChannels: true}, "legacy/alias"); err != nil || actual != "vendor/real" { + t.Fatal(actual, err) + } + w := call(s, "GET", "/admin/api/channels", "", cookie) + if w.Code != 200 || strings.Contains(w.Body.String(), `"passthrough"`) { + t.Fatal(w.Code, w.Body.String()) + } + w = call(s, "POST", "/admin/api/keys", `{"name":"client","all_channels":true}`, cookie) + var key map[string]string + if err := json.Unmarshal(w.Body.Bytes(), &key); err != nil || w.Code != 200 { + t.Fatal(w.Code, w.Body.String(), err) + } + for _, path := range []string{"/v1/chat/completions", "/v1/responses", "/v1/messages"} { + for _, model := range []string{"legacy/unknown", "legacy/vendor/real"} { + t.Run(path+"/"+model, func(t *testing.T) { + r := httptest.NewRequest("POST", path, strings.NewReader(`{"model":"`+model+`","messages":[]}`)) + r.Header.Set("Authorization", "Bearer "+key["key"]) + w := httptest.NewRecorder() + s.ServeHTTP(w, r) + if w.Code != http.StatusForbidden { + t.Fatal(w.Code, w.Body.String()) + } + }) + } + } + save() + cs, err = s.loadChannels() + if err != nil { + t.Fatal(err) + } + if _, _, err := resolve(cs, apiKey{AllChannels: true}, "legacy/unknown"); err == nil { + t.Fatal("updated legacy channel allowed unknown model") + } +} + +func TestKeyRevocationAndProxy(t *testing.T) { + s := testServer(t) + c := loginCookie(t, s) + up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/chat/completions" || r.Header.Get("Authorization") != "Bearer upstream-secret" { + t.Error(r.URL.Path, r.Header) + } + var b map[string]any + json.NewDecoder(r.Body).Decode(&b) + if b["model"] != "vendor/real" || b["custom"] != "keep" { + t.Error(b) + } + writeJSON(w, map[string]any{"choices": []any{}, "usage": map[string]int{"prompt_tokens": 7, "completion_tokens": 3}}) + })) + defer up.Close() + b, _ := json.Marshal(channel{Name: "demo", BaseURL: up.URL + "/v1", Protocol: "openai-chat", Enabled: true, Models: map[string]string{"alias": "vendor/real"}, APIKey: "upstream-secret"}) + w := call(s, "POST", "/admin/api/channels", string(b), c) + if w.Code != 200 { + t.Fatal(w.Body.String()) + } + var ch channel + json.Unmarshal(w.Body.Bytes(), &ch) + if strings.Contains(w.Body.String(), "upstream-secret") { + t.Fatal("secret leak") + } + w = call(s, "POST", "/admin/api/keys", `{"name":"client","channels":["`+ch.ID+`"]}`, c) + if w.Code != 200 { + t.Fatal(w.Body.String()) + } + var key map[string]string + json.Unmarshal(w.Body.Bytes(), &key) + r := httptest.NewRequest("POST", "/v1/chat/completions", strings.NewReader(`{"model":"demo/alias","messages":[],"custom":"keep"}`)) + r.Header.Set("x-api-key", key["key"]) + w = httptest.NewRecorder() + s.ServeHTTP(w, r) + if w.Code != 200 { + t.Fatal(w.Code, w.Body.String()) + } + var id, stored string + if e := s.db.QueryRow("SELECT id,hash FROM keys").Scan(&id, &stored); e != nil || stored == key["key"] { + t.Fatal(e, stored) + } + w = call(s, "DELETE", "/admin/api/keys/"+id, "", c) + if w.Code != 200 { + t.Fatal(w.Body.String()) + } + w = call(s, "PATCH", "/admin/api/keys/"+id, `{"enabled":true}`, c) + if w.Code != 404 { + t.Fatal("revived revoked key", w.Code) + } + r = httptest.NewRequest("GET", "/v1/models", nil) + r.Header.Set("Authorization", "Bearer "+key["key"]) + w = httptest.NewRecorder() + s.ServeHTTP(w, r) + if w.Code != 401 { + t.Fatal(w.Code) + } + var n int + if err := s.flushLogs(); err != nil { + t.Fatal(err) + } + s.db.QueryRow("SELECT count(*) FROM logs").Scan(&n) + if n != 1 { + t.Fatal(n) + } +} +func TestStatsUnknownAndFilters(t *testing.T) { + s := testServer(t) + c := loginCookie(t, s) + now := time.Now().UTC().Format(time.RFC3339Nano) + s.record(requestLog{CreatedAt: now, KeyID: "a", KeyName: "A", Status: 200, Usage: protocol.Usage{Input: 8, Output: 2, Known: true}}) + s.record(requestLog{CreatedAt: now, KeyID: "a", KeyName: "A", Status: 400}) + s.record(requestLog{CreatedAt: now, KeyID: "b", Status: 200}) + if err := s.flushLogs(); err != nil { + t.Fatal(err) + } + w := call(s, "GET", "/admin/api/stats?key_id=a", "", c) + if w.Code != 200 { + t.Fatal(w.Body.String()) + } + var out struct { + Summary summary `json:"summary"` + } + if e := json.Unmarshal(w.Body.Bytes(), &out); e != nil { + t.Fatal(e) + } + if out.Summary.Requests != 2 || out.Summary.Success != 1 || out.Summary.Unknown != 1 || out.Summary.Input == nil || *out.Summary.Input != 8 { + t.Fatal(w.Body.String()) + } + w = call(s, "GET", "/admin/api/logs?key_id=b", "", c) + if !strings.Contains(w.Body.String(), `"input_tokens":null`) { + t.Fatal(w.Body.String()) + } + w = call(s, "GET", "/admin/api/stats?from=invalid", "", c) + if w.Code != 400 { + t.Fatal(w.Code) + } + w = call(s, "GET", "/admin/api/unknown", "", c) + if w.Code != 404 { + t.Fatal(w.Code) + } + w = call(s, "GET", "/some/spa/path", "", nil) + if w.Code != 200 || w.Body.String() != "SPA" { + t.Fatal(w.Code, w.Body.String()) + } +} + +func TestPublicOriginCaseInsensitive(t *testing.T) { + s := testServer(t) + if e := s.SetPublicOrigin("https://Router.Example.com"); e != nil { + t.Fatal(e) + } + r := httptest.NewRequest("POST", "/admin/api/login", strings.NewReader(`{"password":"test-password"}`)) + r.Header.Set("Origin", "https://router.example.com") + r.Header.Set("X-Requested-With", "ai-router") + w := httptest.NewRecorder() + s.ServeHTTP(w, r) + if w.Code != 200 || !w.Result().Cookies()[0].Secure { + t.Fatal(w.Code, w.Header()) + } +} + +func TestPublicOriginAndRedirect(t *testing.T) { + s := testServer(t) + if e := s.SetPublicOrigin("https://router.example"); e != nil { + t.Fatal(e) + } + r := httptest.NewRequest("POST", "/admin/api/login", strings.NewReader(`{"password":"test-password"}`)) + r.Header.Set("Origin", "https://router.example") + r.Header.Set("X-Requested-With", "ai-router") + w := httptest.NewRecorder() + s.ServeHTTP(w, r) + if w.Code != 200 || !w.Result().Cookies()[0].Secure { + t.Fatal(w.Code, w.Header()) + } + reached := false + target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { reached = true })) + defer target.Close() + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, target.URL, 307) })) + defer upstream.Close() + req, _ := http.NewRequest("POST", upstream.URL, strings.NewReader("body")) + req.Header.Set("Authorization", "Bearer private") + res, e := s.client.Do(req) + if e != nil { + t.Fatal(e) + } + res.Body.Close() + if reached || res.StatusCode != 307 { + t.Fatal("redirect followed") + } +} diff --git a/internal/server/stats.go b/internal/server/stats.go new file mode 100644 index 0000000..9e2cb05 --- /dev/null +++ b/internal/server/stats.go @@ -0,0 +1,306 @@ +package server + +import ( + "ai-router/internal/protocol" + "context" + "database/sql" + "database/sql/driver" + "encoding/json" + "fmt" + "net/http" + "sort" + "strings" + "sync" + "time" + _ "time/tzdata" + + "modernc.org/sqlite" +) + +func init() { + var locationMu sync.Mutex + var cachedZone string + var cachedLocation *time.Location + if err := sqlite.RegisterDeterministicScalarFunction("router_log_day", 2, func(_ *sqlite.FunctionContext, args []driver.Value) (driver.Value, error) { + stampText, ok := args[0].(string) + zone, zoneOK := args[1].(string) + if !ok || !zoneOK { + return nil, fmt.Errorf("invalid log timestamp or timezone") + } + stamp, err := time.Parse(time.RFC3339Nano, stampText) + if err != nil { + return nil, err + } + locationMu.Lock() + defer locationMu.Unlock() + if cachedLocation == nil || cachedZone != zone { + loc, err := time.LoadLocation(zone) + if err != nil { + return nil, err + } + cachedZone, cachedLocation = zone, loc + } + return stamp.In(cachedLocation).Format("2006-01-02"), nil + }); err != nil { + panic(err) + } +} + +type requestLog struct { + ID int64 `json:"id"` + CreatedAt string `json:"created_at"` + KeyID string `json:"key_id"` + KeyName string `json:"key_name"` + Channel string `json:"channel"` + RequestModel string `json:"request_model"` + ActualModel string `json:"actual_model"` + Downstream string `json:"downstream"` + Upstream string `json:"upstream"` + Status int `json:"status"` + DurationMS int64 `json:"duration_ms"` + Stream bool `json:"stream"` + Input *int64 `json:"input_tokens"` + Output *int64 `json:"output_tokens"` + CacheRead *int64 `json:"cache_read_tokens"` + CacheWrite *int64 `json:"cache_write_tokens"` + UsageKnown bool `json:"usage_known"` + Error string `json:"error"` + RawUsage json.RawMessage `json:"raw_usage"` + Usage protocol.Usage `json:"-"` +} + +func insertLog(ctx context.Context, tx *sql.Tx, l requestLog) error { + l = boundedLog(l) + var in, out, cr, cw any + if l.Usage.Known || len(l.Usage.Raw) > 0 { + in = l.Usage.Input + out = l.Usage.Output + cr = l.Usage.CacheRead + cw = l.Usage.CacheWrite + } + _, err := tx.ExecContext(ctx, "INSERT INTO logs(created_at,key_id,key_name,channel,request_model,actual_model,downstream,upstream,status,duration_ms,stream,input_tokens,output_tokens,cache_read_tokens,cache_write_tokens,usage_known,error,raw_usage) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", l.CreatedAt, l.KeyID, l.KeyName, l.Channel, l.RequestModel, l.ActualModel, l.Downstream, l.Upstream, l.Status, l.DurationMS, l.Stream, in, out, cr, cw, l.Usage.Known, l.Error, string(l.Usage.Raw)) + return err +} +func filter(r *http.Request) (string, []any, error) { + parts := []string{"1=1"} + args := []any{} + for _, name := range []string{"channel", "model"} { + if v := r.URL.Query().Get(name); v != "" { + col := name + if name == "model" { + col = "actual_model" + } + parts = append(parts, col+"=?") + args = append(args, v) + } + } + if k := r.URL.Query().Get("key_id"); k != "" { + parts = append(parts, "key_id=?") + args = append(args, k) + } + var from, to time.Time + for _, n := range []string{"from", "to"} { + if v := r.URL.Query().Get(n); v != "" { + t, e := time.Parse(time.RFC3339Nano, v) + if e != nil { + return "", nil, e + } + if n == "from" { + from = t + parts = append(parts, "julianday(created_at)>=julianday(?)") + } else { + to = t + parts = append(parts, "julianday(created_at)<=julianday(?)") + } + args = append(args, t.UTC().Format(time.RFC3339Nano)) + } + } + if !from.IsZero() && !to.IsZero() && from.After(to) { + return "", nil, sql.ErrNoRows + } + return strings.Join(parts, " AND "), args, nil +} +func (s *Server) readLogs(r *http.Request, limited bool) ([]requestLog, error) { + where, args, e := filter(r) + if e != nil { + return nil, e + } + q := "SELECT id,created_at,key_id,key_name,channel,request_model,actual_model,downstream,upstream,status,duration_ms,stream,input_tokens,output_tokens,cache_read_tokens,cache_write_tokens,usage_known,error,raw_usage FROM logs WHERE " + where + " ORDER BY id DESC" + if limited { + q += " LIMIT 200" + } + rows, e := s.db.Query(q, args...) + if e != nil { + return nil, e + } + defer rows.Close() + out := []requestLog{} + for rows.Next() { + var l requestLog + var raw string + if e = rows.Scan(&l.ID, &l.CreatedAt, &l.KeyID, &l.KeyName, &l.Channel, &l.RequestModel, &l.ActualModel, &l.Downstream, &l.Upstream, &l.Status, &l.DurationMS, &l.Stream, &l.Input, &l.Output, &l.CacheRead, &l.CacheWrite, &l.UsageKnown, &l.Error, &raw); e != nil { + return nil, e + } + if json.Valid([]byte(raw)) { + l.RawUsage = json.RawMessage(raw) + } + out = append(out, l) + } + return out, rows.Err() +} +func (s *Server) logs(w http.ResponseWriter, r *http.Request) { + if _, _, e := filter(r); e != nil { + errJSON(w, 400, "invalid time filter") + return + } + ls, e := s.readLogs(r, true) + if e != nil { + errJSON(w, 500, "database error") + return + } + writeJSON(w, ls) +} + +type summary struct { + Requests int64 `json:"requests"` + Success int64 `json:"success"` + Failed int64 `json:"failed"` + Input *int64 `json:"input_tokens"` + Output *int64 `json:"output_tokens"` + CacheRead *int64 `json:"cache_read_tokens"` + CacheWrite *int64 `json:"cache_write_tokens"` + Unknown int64 `json:"unknown_usage"` +} + +func (a *summary) add(l requestLog) { + a.Requests++ + if l.Status >= 200 && l.Status < 300 && l.Error == "" { + a.Success++ + } else { + a.Failed++ + } + if !l.UsageKnown { + a.Unknown++ + return + } + if a.Input == nil { + a.Input = new(int64) + a.Output = new(int64) + a.CacheRead = new(int64) + a.CacheWrite = new(int64) + } + if l.Input != nil { + *a.Input += *l.Input + } + if l.Output != nil { + *a.Output += *l.Output + } + if l.CacheRead != nil { + *a.CacheRead += *l.CacheRead + } + if l.CacheWrite != nil { + *a.CacheWrite += *l.CacheWrite + } +} +func (s *Server) stats(w http.ResponseWriter, r *http.Request) { + zone := r.URL.Query().Get("timezone") + if zone == "" { + zone = "UTC" + } + _, err := time.LoadLocation(zone) + if err != nil || zone == "Local" { + errJSON(w, 400, "invalid timezone") + return + } + where, args, e := filter(r) + if e != nil { + errJSON(w, 400, "invalid time filter") + return + } + const aggregates = `COUNT(*), COALESCE(SUM(status>=200 AND status<300 AND error=''),0), +COALESCE(SUM(NOT(status>=200 AND status<300 AND error='')),0), +SUM(CASE WHEN usage_known THEN COALESCE(input_tokens,0) END), +SUM(CASE WHEN usage_known THEN COALESCE(output_tokens,0) END), +SUM(CASE WHEN usage_known THEN COALESCE(cache_read_tokens,0) END), +SUM(CASE WHEN usage_known THEN COALESCE(cache_write_tokens,0) END), +COALESCE(SUM(NOT usage_known),0), MAX(id)` + // NOT MATERIALIZED avoids copying the full log payload into a temporary table. + query := `WITH filtered AS NOT MATERIALIZED (SELECT * FROM logs WHERE ` + where + `) +SELECT 'total','','',` + aggregates + ` FROM filtered +UNION ALL SELECT 'key',key_id,key_name,` + aggregates + ` FROM filtered GROUP BY key_id +UNION ALL SELECT 'day',router_log_day(created_at,?), '',` + aggregates + ` FROM filtered GROUP BY router_log_day(created_at,?) +UNION ALL SELECT 'model',channel,actual_model,` + aggregates + ` FROM filtered GROUP BY channel,actual_model` + args = append(args, zone, zone) + ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) + defer cancel() + rows, e := s.db.QueryContext(ctx, query, args...) + if e != nil { + errJSON(w, 500, "database error") + return + } + defer rows.Close() + type byKey struct { + KeyID string `json:"key_id"` + Name string `json:"name"` + summary + } + type daily struct { + Day string `json:"day"` + summary + } + type byModel struct { + Channel string `json:"channel"` + Model string `json:"model"` + summary + } + total := summary{} + ks := map[string]*byKey{} + ds := map[string]*daily{} + ms := map[[2]string]*byModel{} + groups := 0 + for rows.Next() { + groups++ + if groups > 10000 { + errJSON(w, 422, "too many statistics groups; narrow the time range") + return + } + var kind, a, b string + var v summary + var latest sql.NullInt64 + if err := rows.Scan(&kind, &a, &b, &v.Requests, &v.Success, &v.Failed, &v.Input, &v.Output, &v.CacheRead, &v.CacheWrite, &v.Unknown, &latest); err != nil { + errJSON(w, 500, "database error") + return + } + switch kind { + case "total": + total = v + case "key": + // SQLite's sole MAX selects key_name from the latest ID, not timestamp. + ks[a] = &byKey{KeyID: a, Name: b, summary: v} + case "day": + ds[a] = &daily{Day: a, summary: v} + case "model": + ms[[2]string{a, b}] = &byModel{Channel: a, Model: b, summary: v} + } + } + if err := rows.Err(); err != nil { + errJSON(w, 500, "database error") + return + } + keys := []*byKey{} + days := []*daily{} + mods := []*byModel{} + for _, v := range ks { + keys = append(keys, v) + } + for _, v := range ds { + days = append(days, v) + } + for _, v := range ms { + mods = append(mods, v) + } + sort.Slice(keys, func(i, j int) bool { return keys[i].KeyID < keys[j].KeyID }) + sort.Slice(days, func(i, j int) bool { return days[i].Day < days[j].Day }) + sort.Slice(mods, func(i, j int) bool { return mods[i].Channel+mods[i].Model < mods[j].Channel+mods[j].Model }) + writeJSON(w, map[string]any{"summary": total, "by_key": keys, "daily": days, "by_model": mods, "timezone": zone}) +} diff --git a/internal/server/stats_timezone_test.go b/internal/server/stats_timezone_test.go new file mode 100644 index 0000000..1a12038 --- /dev/null +++ b/internal/server/stats_timezone_test.go @@ -0,0 +1,74 @@ +package server + +import ( + "ai-router/internal/protocol" + "encoding/json" + "net/url" + "reflect" + "testing" +) + +func TestStatsBrowserTimezone(t *testing.T) { + for _, tc := range []struct { + name, zone string + stamps []string + want map[string]int + }{ + {"Shanghai midnight", "Asia/Shanghai", []string{"2026-10-20T15:59:59Z", "2026-10-20T16:00:00Z"}, map[string]int{"2026-10-20": 1, "2026-10-21": 1}}, + {"UTC default", "", []string{"2026-10-20T15:59:59Z", "2026-10-20T16:00:00Z"}, map[string]int{"2026-10-20": 2}}, + {"New York DST spring", "America/New_York", []string{"2025-03-09T04:59:59Z", "2025-03-09T05:00:00Z", "2025-03-10T03:59:59Z", "2025-03-10T04:00:00Z"}, map[string]int{"2025-03-08": 1, "2025-03-09": 2, "2025-03-10": 1}}, + {"New York DST fall", "America/New_York", []string{"2025-11-02T03:59:59Z", "2025-11-02T04:00:00Z", "2025-11-03T04:59:59Z", "2025-11-03T05:00:00Z"}, map[string]int{"2025-11-01": 1, "2025-11-02": 2, "2025-11-03": 1}}, + } { + t.Run(tc.name, func(t *testing.T) { + s := testServer(t) + cookie := loginCookie(t, s) + for _, stamp := range tc.stamps { + s.record(requestLog{CreatedAt: stamp, KeyID: "one", Status: 200, Usage: protocol.Usage{Known: true, Input: 10}}) + } + s.record(requestLog{CreatedAt: tc.stamps[0], KeyID: "other", Status: 200}) + if err := s.flushLogs(); err != nil { + t.Fatal(err) + } + w := call(s, "GET", "/admin/api/stats?key_id=one&timezone="+url.QueryEscape(tc.zone), "", cookie) + if w.Code != 200 { + t.Fatal(w.Code, w.Body.String()) + } + var result struct { + Timezone string `json:"timezone"` + Summary summary `json:"summary"` + Daily []struct { + Day string `json:"day"` + Requests int `json:"requests"` + } `json:"daily"` + } + if err := json.Unmarshal(w.Body.Bytes(), &result); err != nil { + t.Fatal(err) + } + got := map[string]int{} + for _, d := range result.Daily { + got[d.Day] = d.Requests + } + if !reflect.DeepEqual(got, tc.want) { + t.Fatalf("got %v want %v", got, tc.want) + } + if result.Summary.Requests != int64(len(tc.stamps)) || result.Summary.Input == nil || *result.Summary.Input != int64(10*len(tc.stamps)) { + t.Fatal(result.Summary) + } + var stored string + if err := s.db.QueryRow("SELECT created_at FROM logs ORDER BY id LIMIT 1").Scan(&stored); err != nil || stored != tc.stamps[0] { + t.Fatal("UTC storage changed", stored, err) + } + }) + } +} + +func TestStatsRejectsInvalidTimezone(t *testing.T) { + s := testServer(t) + c := loginCookie(t, s) + for _, zone := range []string{"Local", "not/a-zone", "../../etc/passwd"} { + w := call(s, "GET", "/admin/api/stats?timezone="+url.QueryEscape(zone), "", c) + if w.Code != 400 { + t.Fatal(zone, w.Code) + } + } +} diff --git a/internal/server/transport.go b/internal/server/transport.go new file mode 100644 index 0000000..9b0be8c --- /dev/null +++ b/internal/server/transport.go @@ -0,0 +1,33 @@ +package server + +import ( + "errors" + "net" + "net/url" + "time" +) + +type idleConn struct{ net.Conn } + +func (c *idleConn) Read(b []byte) (int, error) { + if e := c.SetReadDeadline(time.Now().Add(2 * time.Minute)); e != nil { + return 0, e + } + return c.Conn.Read(b) +} +func (c *idleConn) Write(b []byte) (int, error) { + if e := c.SetWriteDeadline(time.Now().Add(30 * time.Second)); e != nil { + return 0, e + } + return c.Conn.Write(b) +} + +// SetPublicOrigin must be called before serving requests; forwarded headers are not trusted. +func (s *Server) SetPublicOrigin(origin string) error { + u, e := url.Parse(origin) + if e != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" || u.User != nil || u.Path != "" || u.RawQuery != "" || u.ForceQuery || u.Fragment != "" { + return errors.New("public origin must be an http(s) origin") + } + s.publicOrigin = origin + return nil +} diff --git a/internal/server/write_deadline_test.go b/internal/server/write_deadline_test.go new file mode 100644 index 0000000..34da509 --- /dev/null +++ b/internal/server/write_deadline_test.go @@ -0,0 +1,51 @@ +package server + +import ( + "net/http" + "net/http/httptest" + "testing" + "time" +) + +type observedDeadlineWriter struct { + *httptest.ResponseRecorder + writes, reads int + deadline time.Time +} + +func (w *observedDeadlineWriter) SetWriteDeadline(d time.Time) error { + w.writes++ + w.deadline = d + return nil +} +func (w *observedDeadlineWriter) SetReadDeadline(d time.Time) error { w.reads++; return nil } + +func TestResponseWriteDeadlines(t *testing.T) { + raw := &observedDeadlineWriter{ResponseRecorder: httptest.NewRecorder()} + w := &deadlineWriter{ResponseWriter: raw} + w.WriteHeader(200) + if raw.writes != 1 || !raw.deadline.After(time.Now()) { + t.Fatal("headers lack deadline") + } + if _, err := w.Write([]byte("body")); err != nil { + t.Fatal(err) + } + if err := w.FlushError(); err != nil { + t.Fatal(err) + } + if raw.writes != 3 { + t.Fatal("write/flush must refresh deadline", raw.writes) + } + if err := http.NewResponseController(w).SetReadDeadline(time.Time{}); err != nil || raw.reads != 1 { + t.Fatal("wrapper must preserve read deadline control", err) + } +} + +func TestNonStreamResponseHasWriteDeadline(t *testing.T) { + s := testServer(t) + raw := &observedDeadlineWriter{ResponseRecorder: httptest.NewRecorder()} + s.ServeHTTP(raw, httptest.NewRequest("GET", "/admin/api/session", nil)) + if raw.Code != 200 || raw.writes == 0 || !raw.deadline.After(time.Now()) { + t.Fatal("nonstream response lacks write deadline") + } +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..f07bd53 --- /dev/null +++ b/main.go @@ -0,0 +1,70 @@ +package main + +import ( + "ai-router/internal/config" + "ai-router/internal/server" + "context" + "embed" + "flag" + "io/fs" + "log" + "net/http" + "os" + "os/signal" + "syscall" + "time" +) + +//go:embed all:web/dist +var frontend embed.FS + +func main() { + path := flag.String("config", "config.yaml", "YAML configuration path") + flag.Parse() + cfg, err := config.Load(*path) + if err != nil { + log.Fatal(err) + } + assets, err := fs.Sub(frontend, "web/dist") + if err != nil { + log.Fatal(err) + } + app, err := server.New(cfg.Storage.Path, cfg.Admin.Password, assets) + if err != nil { + log.Fatal(err) + } + defer func() { + if err := app.Close(); err != nil { + log.Print("AI Router shutdown: failed to persist all request logs or close database") + } + }() + if cfg.Server.PublicOrigin != "" { + if err := app.SetPublicOrigin(cfg.Server.PublicOrigin); err != nil { + log.Fatal(err) + } + } + if err := app.SetTrustedProxies(cfg.Server.TrustedProxies); err != nil { + log.Fatal(err) + } + if err := app.SetLimits(cfg.Limits.RequestsPerMinute, cfg.Limits.PerKeyConcurrency, cfg.Limits.GlobalConcurrency); err != nil { + log.Fatal(err) + } + app.SetLogRetention(cfg.Storage.LogRetentionDays) + srv := &http.Server{Addr: cfg.Server.Listen, Handler: app, ReadTimeout: 30 * time.Second, ReadHeaderTimeout: 10 * time.Second, IdleTimeout: 90 * time.Second, MaxHeaderBytes: 1 << 20} + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + done := make(chan error, 1) + go func() { log.Printf("AI Router listening on %s", cfg.Server.Listen); done <- srv.ListenAndServe() }() + select { + case err := <-done: + if err != http.ErrServerClosed { + log.Printf("server: %v", err) + } + case <-ctx.Done(): + shutdown, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + if err := srv.Shutdown(shutdown); err != nil { + _ = srv.Close() + } + } +} diff --git a/web/.gitignore b/web/.gitignore new file mode 100644 index 0000000..ff2c585 --- /dev/null +++ b/web/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +*.tsbuildinfo diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..0600126 --- /dev/null +++ b/web/index.html @@ -0,0 +1,2 @@ + +AI Router · 管理台
diff --git a/web/package-lock.json b/web/package-lock.json new file mode 100644 index 0000000..ffa8680 --- /dev/null +++ b/web/package-lock.json @@ -0,0 +1,1883 @@ +{ + "name": "ai-router-admin", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "ai-router-admin", + "version": "1.0.0", + "dependencies": { + "react": "^19.0.0", + "react-dom": "^19.0.0" + }, + "devDependencies": { + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "@vitejs/plugin-react": "^4.3.4", + "typescript": "~5.7.3", + "vite": "^6.1.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.10", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.10.tgz", + "integrity": "sha512-EMCx3AGZ3WfAl6ht7AyQoPm2Lh+BGYEMdc4Q6Hf7uSCDOZFuLq44qNwL+iaim8VlyYhRAo4X43+weadK8ZB4Yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.9", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.9.tgz", + "integrity": "sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.10", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.10.tgz", + "integrity": "sha512-B3lYAs2nkKskbgQ1nfy8Ma9ao9Q1rntTAQHrzzyDuj+TSO+X3le6PfWwqDvodsb+OumE10eMFfuMCTQSQ5iuIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.8", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.9", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "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/@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/@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/@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/@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/@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/@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/@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/@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/@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/@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/@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/@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/@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/@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/@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/@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/@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/@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/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/@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/@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/@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/@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/@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/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.64.12", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.64.12.tgz", + "integrity": "sha512-Z8lz+5UOiCJLonjeqwJlhUtHA/Sr6R1AidkccQxdWF0nigNzP81aLoU+udgOaGfL5s1uzFXzcux/pCwGAMu0lw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.64.12", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.64.12.tgz", + "integrity": "sha512-+tKxNa2psFmiTb0KfXlXs4Yj7vkEhzAvZ0DrQ5+0gWHQJM10jSleQ3l1FIVm5uvD53YM8lHfM4mgCLDDN9f1Gg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.64.12", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.64.12.tgz", + "integrity": "sha512-iiripdfvyRBugV55/Dk7GplNXQtN/PG85OcKeveU1z2UkTg0MofbBF1qk//qgPWiqJbVvQcXAVCfE56oDGO4EQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.64.12", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.64.12.tgz", + "integrity": "sha512-BmATib8swSMAagWNIfDZMnb+k34RXR4bbGOUfZgK8lLlniuMyVoSDDXCOGkdhtUmBXL4aygDh7bhjQ+TmhTvIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.64.12", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.64.12.tgz", + "integrity": "sha512-GzrO0CiUq8vcAI/hYJuIcp6wNm3T3YhOpumYtf9Z3Fz+cfN1VESCVfy7EPcM3/m2K8l+Gn2U+4XtNZjwhUXctQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.64.12", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.64.12.tgz", + "integrity": "sha512-Dpta6gigm4o6SYioXtTkZBlGLA/Sl6OpzgLqY+51uafJE2blPejAtR1w82CVLPN+F9cvo1+/em92x/zcOgY0uQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.64.12", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.64.12.tgz", + "integrity": "sha512-YpeDwQ5uFaogRvytNC8RK0keDO65kI5wNEhGSs+4GiJc3KHhxj4j5cnQh2nCu9ybJj6RP0/ycB6bkN0yFJ9I+w==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.64.12", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.64.12.tgz", + "integrity": "sha512-LQrB3/sImIr1ycrlEyaApOLH1rAy8JV4/dH4eoK817aWATdcs4fESXz53/w72NwDnwPM5zEUpoL7socH4ZDnwA==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.64.12", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.64.12.tgz", + "integrity": "sha512-iAF2iYu9S4SBfNsb4k0ewNBjO83WxHEltIuKQBGMzEKFgrYfmJn+gatBDXR2t/0zJN0UW0kzR4rfvQ1UHzkZ1A==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.64.12", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.64.12.tgz", + "integrity": "sha512-0MQdRtdbirJCXPIVeW/hd/LXLR8Qz96pc+2EBptuuV5q/zSGOzND7SZt8gBTE58NC9cUG/8UxcUXQiKRZ2WzzA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.64.12", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.64.12.tgz", + "integrity": "sha512-yGDEwn0AGnrY/KCz2oY8R9KKNiQnTcwNuCWXeSXF0VLxXzlqoCgaS5QHsf1lbQvKWwPuN2Czkjk1wXRCBONTOQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.64.12", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.64.12.tgz", + "integrity": "sha512-AjlbD0efZEhHaJ3bsNjxSFDoguNq6VZvC+FKGJGBCA+ZN7BrZq2TuuSWFa6AI8IpOeuvhp2LoYcP7jiZwmtU0Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.64.12", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.64.12.tgz", + "integrity": "sha512-sDW0CiPoUCEEUV25uBG5sJ2gKe75DJqLpj91m1eK1vKffywyY7v4i6q1450ZuR6QW4NArXg7JpbMGgKU8di3HQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.64.12", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.64.12.tgz", + "integrity": "sha512-VTQYUhMjl8kv999VuYqScgv7P9cR9KuxI/6bkUsEa3bsswqG0+otFjuLZmJ1U+ffEP4IAz0jxOeV0DX6NRBhjg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.64.12", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.64.12.tgz", + "integrity": "sha512-ppsKwpSjNrzJ8hge3Z3uiCcoRgAAQk2tU7auy3MFF348ffWRldFPnWIT/a/enK7huijlTfnwn4DYpDoapEb45A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.64.12", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.64.12.tgz", + "integrity": "sha512-Gc3cVRC1trFH+GFWWTsJz6YuD4u75fzr/q99jqQGimonN/zvN6Wecck5WGzllOHj+XYXHcq0Ibor0tanGjfI6A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.64.12", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.64.12.tgz", + "integrity": "sha512-pjmDTFStvwFE3shTeUDvSDiSp8/qAf5bQXejcEUDDavWz4jEhTIpCdpS06G0PEwSCSNK0Lngjz2v9l4hk3Z74A==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.64.12", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.64.12.tgz", + "integrity": "sha512-mkbjxPqpR91u1JD7xY4aLZhr/Mqka4/RqGcG4WNGgN/lC1o7TzyCyqJoSJXp9qVVxMwpJGFlEF1VlERb+Ghw1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.64.12", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.64.12.tgz", + "integrity": "sha512-vIeut2LkuVc3gcsawLJPahNAzvzBcFRhu2O+yVb//BuQKz7PIItMF0piXRbN/lIDO3d68TSDd368Vg6YwgTybw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.64.12", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.64.12.tgz", + "integrity": "sha512-3Sx+D3r9oPkTdNUcPrIRM2Am+vzEV6VGWoImHG4ZVK2jJeOW+eDjLeTm6nvu/z/m0fiX2YmGLXIypblc336MXg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.64.12", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.64.12.tgz", + "integrity": "sha512-Nn2xh0teFnl71EiiUrKvECWcdGXtWF8q0uGclYwXGVw6QClGAcME0/bJSGdK+xIvd6MLEKYmjkTUvCBV+7m5Kw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.64.12", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.64.12.tgz", + "integrity": "sha512-n1/0H9TS+kBa4xp6n9aV7yiynBNfmerjkj2nAMIfY0ZHv1zShWRXnsAuAPAH0sfEK4ZSMK+z24q2RjAPFLCN1w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.64.12", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.64.12.tgz", + "integrity": "sha512-AdlDbQpsLfGlsHr1NfLZFtdnvxCKNp9TwlYVYExs8JJLWjLafUQEKJk0r9zUbmU57mw9nglMEvzqPHuLWYmaKw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.64.12", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.64.12.tgz", + "integrity": "sha512-u5SSUJBHod51UamjkigSr+JPre6xMy5vaWTTV2K0SnFG4RuKyYpf66dTSLFKX2cwaldpwyBurXPUCxq8riQMEg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.64.12", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.64.12.tgz", + "integrity": "sha512-j5rXD4R/4DM5+V991AA9xOnG2Rwu2g3p5TPgu1Eu7apju0NUrLl+Bn/KpWc6wYJtlXqzwz1r+LNV1JeBU4VGvw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz", + "integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.3.0.tgz", + "integrity": "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.3.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.30", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.30.tgz", + "integrity": "sha512-YJs6Pz17RMNv8HoyKREtCY38E3BV1nPkWVmIb+XN1uHC1Vqt3KZCH/uJH9ZKRoiKb/icZ6tvehyee6RZNbFfRA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.29.4", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.29.4.tgz", + "integrity": "sha512-2TcbbiwXEq+N0kD36tykpCfyyQigUXmuwNQbnPp4Xk4PX9JOqVIKjJlIDjpH7uY6tmRGoAd+apeT1p60T8wsdA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.11.29", + "caniuse-lite": "^1.0.30001816", + "electron-to-chromium": "^1.5.459", + "node-releases": "^2.0.59", + "update-browserslist-db": "^1.3.4" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001817", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001817.tgz", + "integrity": "sha512-uZ1JG80BwhtMalIAGPxPfmNhHyd/B/nTdr3hITWlRamSyAdUStP1Gop01e7SD5q6J90ixJvWDbvmiFVfDgn4bw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "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==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.461", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.461.tgz", + "integrity": "sha512-3WZFARz1By+/rYcqQ0OLj4UcEdhd6tz2LEcDtKBUq7ey77EeJ1OtetY/5hjSUxw7dC5r5mA7FfkhRbqO9vnTCg==", + "dev": true, + "license": "ISC" + }, + "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/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/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.20", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", + "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.59", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.59.tgz", + "integrity": "sha512-5qr2okC4ItAI0hG5SaV0HoYMpnM/Y6+Oqr7AmPYy/2uEoGvS+GbSKLeRxSS1uAdu59yz0ouzcuXievSgg///WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.31", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.31.tgz", + "integrity": "sha512-YUbWYCgErwnMAVkjUnYa+KlMBzGpHwiIt/DE7f0G33AuX2LH9UcTk6KgWxd3zRU+pN3u/C+Xl3EnMLM4Hea7Sg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.20", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "19.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-19.3.1.tgz", + "integrity": "sha512-QNhWdt7nrgVWWQG58QuGsAR3jv3Hbe4mb4vz7w2csnHavciZJgda1CPNaldF6eVxv90ud3jUXpKlHL69gO38yw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.3.1.tgz", + "integrity": "sha512-Y0bARz53lhEC1NMCASs0/j+faB/zqt3R8hpvg1e8+61ZVJ4UL0UdC4cBbXfZMcIuhrfgeILTLe+ZeBCavQPtKQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.28.0" + }, + "peerDependencies": { + "react": "^19.3.1" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.64.12", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.64.12.tgz", + "integrity": "sha512-GhAu8ZglZ61VWSX/ZW3K/qkazgURBIQL0/Yy51QsS8DPyebR5Cf/Z2PANJFpTU0aVZ9byJcyqR6xvBbkXhdGYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.64.12", + "@rollup/rollup-android-arm64": "4.64.12", + "@rollup/rollup-darwin-arm64": "4.64.12", + "@rollup/rollup-darwin-x64": "4.64.12", + "@rollup/rollup-freebsd-arm64": "4.64.12", + "@rollup/rollup-freebsd-x64": "4.64.12", + "@rollup/rollup-linux-arm-gnueabihf": "4.64.12", + "@rollup/rollup-linux-arm-musleabihf": "4.64.12", + "@rollup/rollup-linux-arm64-gnu": "4.64.12", + "@rollup/rollup-linux-arm64-musl": "4.64.12", + "@rollup/rollup-linux-loong64-gnu": "4.64.12", + "@rollup/rollup-linux-loong64-musl": "4.64.12", + "@rollup/rollup-linux-ppc64-gnu": "4.64.12", + "@rollup/rollup-linux-ppc64-musl": "4.64.12", + "@rollup/rollup-linux-riscv64-gnu": "4.64.12", + "@rollup/rollup-linux-riscv64-musl": "4.64.12", + "@rollup/rollup-linux-s390x-gnu": "4.64.12", + "@rollup/rollup-linux-x64-gnu": "4.64.12", + "@rollup/rollup-linux-x64-musl": "4.64.12", + "@rollup/rollup-openbsd-x64": "4.64.12", + "@rollup/rollup-openharmony-arm64": "4.64.12", + "@rollup/rollup-win32-arm64-msvc": "4.64.12", + "@rollup/rollup-win32-ia32-msvc": "4.64.12", + "@rollup/rollup-win32-x64-gnu": "4.64.12", + "@rollup/rollup-win32-x64-msvc": "4.64.12", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz", + "integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/source-map-js": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/typescript": { + "version": "5.7.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.3.tgz", + "integrity": "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.4.tgz", + "integrity": "sha512-t8wonIOdQyOjtPd2VktlBu6QjTVTks6v8bSi7kd0r2Vll8TK1JYDZtRRdbmCHOAMphkZgrzK+L+vXK4pyZ8t4A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vite": { + "version": "6.4.4", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.4.tgz", + "integrity": "sha512-xGjU/SyjeNyb0E4KxXBvYz/1C+3JXuujvf2hXNOtnlcE+58o5ajd9N2/n2VhkiJvpCP2qQasnUpx5PXNeI+p1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "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 + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000..ee01439 --- /dev/null +++ b/web/package.json @@ -0,0 +1,22 @@ +{ + "name": "ai-router-admin", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite --host 127.0.0.1", + "build": "tsc -b && vite build", + "preview": "vite preview --host 127.0.0.1" + }, + "dependencies": { + "react": "^19.0.0", + "react-dom": "^19.0.0" + }, + "devDependencies": { + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "@vitejs/plugin-react": "^4.3.4", + "typescript": "~5.7.3", + "vite": "^6.1.0" + } +} diff --git a/web/public/theme-init.js b/web/public/theme-init.js new file mode 100644 index 0000000..53f178e --- /dev/null +++ b/web/public/theme-init.js @@ -0,0 +1,7 @@ +(function () { + try { + var t = localStorage.getItem('theme') + if (t !== 'light' && t !== 'dark') t = matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light' + document.documentElement.dataset.theme = t + } catch (e) {} +})() diff --git a/web/src/App.tsx b/web/src/App.tsx new file mode 100644 index 0000000..2d90917 --- /dev/null +++ b/web/src/App.tsx @@ -0,0 +1,158 @@ +import { useEffect, useState } from 'react' +import { api, APIError, type Channel, type Key, type Log, type Stats } from './api' +import { emptyFilter, type Action, type Filter, type Page } from './common' +import { applyTheme, initialTheme, type Theme } from './theme' +import { Filters } from './components/Filters' +import { Shell } from './components/Shell' +import { Feedback } from './components/ui' +import { ChannelsPage } from './pages/Channels' +import { KeysPage } from './pages/Keys' +import { Login } from './pages/Login' +import { LogsPage } from './pages/Logs' +import { ModelsPage } from './pages/Models' +import { Overview } from './pages/Overview' + +export default function App() { + const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone + const [theme, setTheme] = useState(initialTheme) + const [auth, setAuth] = useState(null) + const [page, setPage] = useState('概览') + const [error, setError] = useState('') + const [notice, setNotice] = useState('') + const [busy, setBusy] = useState(false) + const [loading, setLoading] = useState(false) + const [rev, setRev] = useState(0) + const [channels, setChannels] = useState([]) + const [keys, setKeys] = useState([]) + const [stats, setStats] = useState(null) + const [logs, setLogs] = useState([]) + const [filter, setFilter] = useState(emptyFilter) + const [query, setQuery] = useState('') + + useEffect(() => applyTheme(theme), [theme]) + + function fail(e: unknown) { + setError(e instanceof Error ? e.message : '操作失败,请重试。') + // 会话失效后回到登录页,各页面的弹窗状态随组件卸载自动清理 + if (e instanceof APIError && e.status === 401) setAuth(false) + } + + const action: Action = fn => { + setBusy(true) + setError('') + setNotice('') + void fn().catch(fail).finally(() => setBusy(false)) + } + + async function loadSession() { + const session = await api<{ authenticated: boolean }>('/session') + setAuth(session.authenticated) + } + + useEffect(() => { + loadSession().catch(e => { + setAuth(false) + if (!(e instanceof APIError && e.status === 401)) fail(e) + }) + }, []) + + useEffect(() => { + if (!auth) return + let alive = true + setLoading(true) + setError('') + setStats(null) + setLogs([]) + const statsParams = new URLSearchParams(query) + statsParams.set('timezone', timezone || 'UTC') + Promise.all([ + api('/channels'), + api('/keys'), + page === '概览' || page === 'Keys' ? api('/stats?' + statsParams) : Promise.resolve(null), + page === '记录' ? api('/logs' + query) : Promise.resolve([] as Log[]), + ]) + .then(([c, k, s, l]) => { if (alive) { setChannels(c ?? []); setKeys(k ?? []); setStats(s); setLogs(l ?? []) } }) + .catch(e => { if (alive) fail(e) }) + .finally(() => { if (alive) setLoading(false) }) + return () => { alive = false } + }, [auth, page, rev, query, timezone]) + + const refresh = () => setRev(v => v + 1) + + /** 从 Keys 页跳转:概览页按该 Key 筛选统计。 */ + function viewKeyStats(k: Key) { + setFilter({ ...filter, key_id: k.id }) + const p = new URLSearchParams(query) + p.set('key_id', k.id) + setQuery('?' + p) + setPage('概览') + } + + if (auth === null) return

正在检查登录状态…

+ + if (!auth) { + return + } + + return ( + { setPage(p); setNotice('') }} + onRefresh={refresh} + onThemeChange={setTheme} + onLogout={() => action(async () => { await api('/logout', 'POST'); setAuth(false) })} + > + + {(page === '概览' || page === '记录') && ( + { setQuery(q); setError('') }} + onRefresh={refresh} + onError={fail} + /> + )} + {loading &&
正在加载数据…
} + {/* 加载时隐藏而非卸载页面:保留弹窗与表单等本地状态(如创建后展示密钥的弹窗) */} +
+ {page === '概览' && } + {page === '模型' && } + {page === '渠道' && ( + setError('')} + /> + )} + {page === 'Keys' && ( + setError('')} + onViewStats={viewKeyStats} + /> + )} + {page === '记录' && } +
+
+ ) +} diff --git a/web/src/api.ts b/web/src/api.ts new file mode 100644 index 0000000..5648261 --- /dev/null +++ b/web/src/api.ts @@ -0,0 +1,14 @@ +export type Channel = { id: string; name: string; base_url: string; protocol: string; enabled: boolean; models: Record } +export type Key = { id: string; name: string; prefix: string; enabled: boolean; revoked?: boolean; all_channels: boolean; channels: string[]; expires_at: string; created_at: string } +export type Usage = { requests: number; success: number; failed: number; input_tokens: number; output_tokens: number; cache_read_tokens: number; cache_write_tokens: number; unknown_usage: number } +export type Stats = { summary: Usage; by_key: (Usage & { key_id: string; name: string })[]; daily: (Usage & { day: string })[]; by_model: (Usage & { channel: string; model: string })[] } +export type Log = { id: string; created_at: string; key_id: string; key_name: string; channel: string; request_model: string; actual_model: string; downstream: string; upstream: string; status: number; duration_ms: number; stream: boolean; input_tokens: number; output_tokens: number; cache_read_tokens: number; cache_write_tokens: number; usage_known: boolean; error: string } +export class APIError extends Error { constructor(message: string, public status: number) { super(message) } } +export async function api(path: string, method = 'GET', body?: unknown, signal?: AbortSignal): Promise { + const res = await fetch('/admin/api' + path, { method, signal, credentials: 'same-origin', headers: method === 'GET' ? {} : { 'Content-Type': 'application/json', 'X-Requested-With': 'ai-router' }, body: body === undefined ? undefined : JSON.stringify(body) }) + const text = await res.text() + let data + try { data = text ? JSON.parse(text) : undefined } catch { if (res.ok) throw new Error('服务器返回了无法识别的数据,请稍后重试。') } + if (!res.ok) throw new APIError(typeof data?.error === 'string' ? data.error : typeof data?.message === 'string' ? data.message : res.status === 401 ? '登录已失效,请重新登录。' : '请求失败(' + res.status + '),请稍后重试。', res.status) + return data as T +} diff --git a/web/src/common.ts b/web/src/common.ts new file mode 100644 index 0000000..1879791 --- /dev/null +++ b/web/src/common.ts @@ -0,0 +1,35 @@ +import type { Channel, Usage } from './api' + +export type Page = '概览' | '渠道' | '模型' | 'Keys' | '记录' + +export const PAGES: Page[] = ['概览', '渠道', '模型', 'Keys', '记录'] + +export const PAGE_DESC: Record = { + 概览: '查看请求趋势与模型用量。', + 渠道: '配置上游服务与模型映射。', + 模型: '汇总各渠道选定的展示模型;/v1/models 仅返回当前 Key 有权访问的启用渠道模型。', + Keys: '按渠道授权,安全管理每一个访问凭证。', + 记录: '查看最近请求明细,快速定位问题。', +} + +export interface Filter { + key_id: string + from: string + to: string +} + +export const emptyFilter: Filter = { key_id: '', from: '', to: '' } + +/** 包装异步操作:统一 busy 状态与错误处理,见 App。 */ +export type Action = (fn: () => Promise) => void + +export const num = (v: number | null | undefined) => (v == null ? '—' : v.toLocaleString('zh-CN')) + +export function channelName(channels: Channel[], id: string) { + return channels.find(c => c.id === id)?.name || id +} + +export const usageHeads = ['请求数', '输入 tokens', '输出 tokens', '缓存读取', '缓存写入'] + +export const usageCells = (u: Usage) => + [u.requests, u.input_tokens, u.output_tokens, u.cache_read_tokens, u.cache_write_tokens].map(num) diff --git a/web/src/components/Filters.tsx b/web/src/components/Filters.tsx new file mode 100644 index 0000000..e1a5e61 --- /dev/null +++ b/web/src/components/Filters.tsx @@ -0,0 +1,64 @@ +import type { FormEvent } from 'react' +import type { Key } from '../api' +import { emptyFilter, type Filter } from '../common' +import { filterParams, quickRange } from '../time' +import { IconGlobe } from './icons' + +interface FiltersProps { + keys: Key[] + filter: Filter + loading: boolean + timezone: string + onChange: (f: Filter) => void + /** 提交新的查询串(含前导 ?,空串表示无筛选),同时清除已有错误。 */ + onQuery: (q: string) => void + onRefresh: () => void + onError: (e: unknown) => void +} + +export function Filters({ keys, filter, loading, timezone, onChange, onQuery, onRefresh, onError }: FiltersProps) { + function submit(e: FormEvent) { + e.preventDefault() + try { + onQuery('?' + filterParams(filter)) + onRefresh() + } catch (err) { + onError(err) + } + } + + function quick(days: number) { + const range = quickRange(days) + onChange({ ...filter, from: range.fromInput, to: range.toInput }) + const p = new URLSearchParams({ from: range.from, to: range.to }) + if (filter.key_id) p.set('key_id', filter.key_id) + onQuery('?' + p) + } + + return ( +
+ + + +
+ + +
+
+ {[1, 7, 30].map(days => ( + + ))} + {timezone} +
+
+ ) +} diff --git a/web/src/components/Shell.tsx b/web/src/components/Shell.tsx new file mode 100644 index 0000000..5e4828d --- /dev/null +++ b/web/src/components/Shell.tsx @@ -0,0 +1,76 @@ +import type { ReactNode } from 'react' +import { PAGES, PAGE_DESC, type Page } from '../common' +import type { Theme } from '../theme' +import { IconChannel, IconKey, IconLog, IconLogout, IconModel, IconMonitor, IconMoon, IconOverview, IconRefresh, IconRoute, IconSun } from './icons' + +const PAGE_ICONS: Record = { + 概览: , + 渠道: , + 模型: , + Keys: , + 记录: , +} + +const THEMES: { value: Theme; label: string; icon: ReactNode }[] = [ + { value: 'system', label: '跟随系统', icon: }, + { value: 'light', label: '浅色模式', icon: }, + { value: 'dark', label: '深色模式', icon: }, +] + +interface ShellProps { + page: Page + theme: Theme + busy: boolean + loading: boolean + onNavigate: (p: Page) => void + onRefresh: () => void + onThemeChange: (theme: Theme) => void + onLogout: () => void + children: ReactNode +} + +export function Shell({ page, theme, busy, loading, onNavigate, onRefresh, onThemeChange, onLogout, children }: ShellProps) { + return ( +
+ +
+
+
+

{page === 'Keys' ? '访问密钥' : page}

+

{PAGE_DESC[page]}

+
+ +
+ {children} +
+
+ ) +} diff --git a/web/src/components/UsageChart.tsx b/web/src/components/UsageChart.tsx new file mode 100644 index 0000000..b1351a2 --- /dev/null +++ b/web/src/components/UsageChart.tsx @@ -0,0 +1,68 @@ +import { useLayoutEffect, useRef, useState } from 'react' +import type { Usage } from '../api' +import { num } from '../common' + +/** + * 每日用量堆叠柱状图(纯 CSS 实现,无第三方依赖)。 + * 按 README 的口径,缓存读/写是输入 tokens 的细分项,不能再与输入相加, + * 因此图表只堆叠 输入 + 输出 构成真实总量;缓存明细见下方表格。 + * X 轴标签数量随容器宽度自适应,窄屏(手机)自动减少,避免重叠。 + */ +export function UsageChart({ data }: { data: (Usage & { day: string })[] }) { + const plotRef = useRef(null) + const [plotWidth, setPlotWidth] = useState(720) + + useLayoutEffect(() => { + const el = plotRef.current + if (!el) return + const ro = new ResizeObserver(entries => setPlotWidth(entries[0].contentRect.width)) + ro.observe(el) + return () => ro.disconnect() + }, []) + + if (!data.length) return
所选时间范围内暂无用量数据。
+ + const totals = data.map(d => d.input_tokens + d.output_tokens) + const max = Math.max(...totals, 1) + // max 较小时四舍五入会产生重复刻度,去重保持纵轴整洁 + const ticks = [...new Set([1, 0.75, 0.5, 0.25].map(r => Math.round(max * r)))] + // 天数较多时抽稀 X 轴标签;每个标签约需 52px,按容器实际宽度定上限 + const maxLabels = Math.max(3, Math.min(12, Math.floor(plotWidth / 52))) + const labelStep = Math.ceil(data.length / maxLabels) + + return ( +
+
+ {ticks.map((t, i) => ( +
+ {compact(t)} +
+ ))} +
+ {data.map((d, i) => { + const total = totals[i] + return ( +
+
+ + +
+ {(i % labelStep === 0 || i === data.length - 1) && {d.day.slice(5)}} +
+ ) + })} +
+
+
+ 输入 tokens + 输出 tokens +
+
+ ) +} + +function compact(v: number): string { + if (v >= 1e6) return (v / 1e6).toFixed(1).replace(/\.0$/, '') + 'M' + if (v >= 1e3) return (v / 1e3).toFixed(1).replace(/\.0$/, '') + 'k' + return String(v) +} diff --git a/web/src/components/icons.tsx b/web/src/components/icons.tsx new file mode 100644 index 0000000..9410874 --- /dev/null +++ b/web/src/components/icons.tsx @@ -0,0 +1,209 @@ +import type { SVGProps } from 'react' + +type IconProps = SVGProps + +function base(props: IconProps): IconProps { + return { + width: 16, + height: 16, + viewBox: '0 0 24 24', + fill: 'none', + stroke: 'currentColor', + strokeWidth: 1.8, + strokeLinecap: 'round', + strokeLinejoin: 'round', + 'aria-hidden': true, + ...props, + } +} + +/** 路由/分流图标,用作品牌标识。 */ +export const IconRoute = (p: IconProps) => ( + + + + + + +) + +export const IconOverview = (p: IconProps) => ( + + + + + + +) + +export const IconChannel = (p: IconProps) => ( + + + + + + +) + +export const IconKey = (p: IconProps) => ( + + + + + +) + +export const IconLog = (p: IconProps) => ( + + + + + + + + +) + +export const IconRefresh = (p: IconProps) => ( + + + + +) + +export const IconPlus = (p: IconProps) => ( + + + + +) + +export const IconSun = (p: IconProps) => ( + + + + + + + + + + + +) + +export const IconMoon = (p: IconProps) => ( + + + +) + +export const IconMonitor = (p: IconProps) => ( + + + + + +) + +export const IconLogout = (p: IconProps) => ( + + + + + +) + +export const IconClose = (p: IconProps) => ( + + + + +) + +export const IconGlobe = (p: IconProps) => ( + + + + + + +) + +export const IconModel = (p: IconProps) => ( + + + + + +) + +export const IconCopy = (p: IconProps) => ( + + + + +) + +export const IconCheck = (p: IconProps) => ( + + + +) + +export const IconActivity = (p: IconProps) => ( + + + +) + +export const IconCheckCircle = (p: IconProps) => ( + + + + +) + +export const IconXCircle = (p: IconProps) => ( + + + + + +) + +export const IconHelpCircle = (p: IconProps) => ( + + + + + +) + +export const IconArrowDown = (p: IconProps) => ( + + + + +) + +export const IconArrowUp = (p: IconProps) => ( + + + + +) + +export const IconZap = (p: IconProps) => ( + + + +) + +export const IconDatabase = (p: IconProps) => ( + + + + + +) diff --git a/web/src/components/ui.tsx b/web/src/components/ui.tsx new file mode 100644 index 0000000..2d6bdf0 --- /dev/null +++ b/web/src/components/ui.tsx @@ -0,0 +1,53 @@ +import type { ReactNode } from 'react' +import { IconClose } from './icons' + +export function Table({ heads, rows }: { heads: string[]; rows: ReactNode[][] }) { + return ( +
+ + + {heads.map(h => )} + + + {rows.length + ? rows.map((r, i) => {r.map((v, j) => )}) + : } + +
{h}
{v}
暂无数据
+
+ ) +} + +export type BadgeVariant = 'good' | 'warn' | 'danger' | 'neutral' + +export function Badge({ variant = 'neutral', children }: { variant?: BadgeVariant; children: ReactNode }) { + return