diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..cb32d74 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,6 @@ +.git +bin/ +data/ +*.log +*.exe +config.toml diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e07dd3c --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +bin/ +data/ +*.log +*.exe +config.toml +config.toml.bak +.freebuff/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..435bdcb --- /dev/null +++ b/Dockerfile @@ -0,0 +1,22 @@ +# syntax=docker/dockerfile:1 + +FROM golang:1.27-alpine AS build +WORKDIR /src +ENV CGO_ENABLED=0 GOOS=linux GOPROXY=https://goproxy.cn,https://proxy.golang.org,direct +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN go build -trimpath -ldflags "-s -w" -o /out/dsfree2api ./cmd/dsfree2api + +FROM alpine:3.21 +RUN apk add --no-cache ca-certificates tzdata \ + && adduser -D -H -u 10001 app +WORKDIR /app +COPY --from=build /out/dsfree2api /usr/local/bin/dsfree2api +COPY config.example.toml /app/config.toml +USER app +EXPOSE 8000 8001 +ENV HOST=0.0.0.0 PORT=8000 ADMIN_ENABLED=true DATA_DIR=/app/data +VOLUME ["/app/data"] +ENTRYPOINT ["dsfree2api"] +CMD ["-config", "/app/config.toml"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..c1ff432 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 nyoungo + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..512c3ac --- /dev/null +++ b/Makefile @@ -0,0 +1,43 @@ +# dsfree2api — build / test helpers +GO ?= go +VERSION ?= 0.3.0 +LDFLAGS := -s -w -X dsfree2api/internal/admin.Version=$(VERSION) +BIN := bin/dsfree2api +IMAGE ?= dsfree2api:latest + +.PHONY: build run test vet fmt tidy check linux docker clean + +build: + CGO_ENABLED=0 $(GO) build -trimpath -ldflags "$(LDFLAGS)" -o $(BIN) ./cmd/dsfree2api + +run: build + ./$(BIN) + +test: + $(GO) test ./... + +vet: + $(GO) vet ./... + +fmt: + gofmt -w . + +tidy: + $(GO) go mod tidy + +check: fmt vet test + +# Static linux/amd64 binary for servers (scp it over and run it). +linux: + CGO_ENABLED=0 GOOS=linux GOARCH=amd64 $(GO) build -trimpath \ + -ldflags "$(LDFLAGS)" -o bin/dsfree2api-linux-amd64 ./cmd/dsfree2api + +linux-arm: + CGO_ENABLED=0 GOOS=linux GOARCH=arm64 $(GO) build -trimpath \ + -ldflags "$(LDFLAGS)" -o bin/dsfree2api-linux-arm64 ./cmd/dsfree2api + +docker: + docker build -t $(IMAGE) . + +clean: + rm -rf bin diff --git a/README.md b/README.md index 2a6fb32..eb32bca 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,366 @@ # dsfree2api -deepseek.de,deepseek.es,deepseek.fr 转为api + +[![Go](https://img.shields.io/badge/Go-1.27-00ADD8?logo=go&logoColor=white)](https://go.dev) +[![MIT License](https://img.shields.io/badge/license-MIT-green)](./LICENSE) +[![Docker](https://img.shields.io/badge/docker-supported-2496ED?logo=docker&logoColor=white)](./docker-compose.yml) + +逆向 **deepseek.de / deepseek.es / deepseek.fr** 三个站点的 V4-Flash / V4-Pro 模型,提供 **OpenAI API 完全兼容** 的中转服务。 + +Go 实现,编译为**单个静态二进制**,无运行时依赖;内置 **Web 管理控制台**(模型 / Key / 代理 / Turnstile / 日志 / 调试)。 + +> ⚠️ 这些站点并非 DeepSeek 官方网站,请谨慎使用。 + +--- + +## 目录 + +- [功能特性](#功能特性) +- [界面预览](#界面预览) +- [模型映射](#模型映射) +- [快速开始](#快速开始) +- [配置](#配置) +- [API 用法](#api-用法) +- [Web 管理台](#web-管理台) +- [环境变量](#环境变量) +- [与 Python 版的差异](#与-python-版的差异) +- [架构](#架构) +- [开发](#开发) +- [免责声明](#免责声明) + +--- + +## 功能特性 + +- **多格式对话接口**:`POST /v1/chat/completions`(OpenAI,流式 / 非流式)、`POST /v1/responses`(OpenAI Responses)、`POST /v1/messages`(Anthropic Messages)、`GET /v1/models`、`GET /health` +- **Tool Calling**:三个对话端点均支持 `tools` / `tool_choice`,模型以 JSON 输出 tool_calls,自动回填到响应(`tool_calls` / `function_call` / `tool_use`) +- **三站点聚合**:单站点配额耗尽自动切换到其他站点的同款模型 +- **代理线路**:主线路 + 多条备用线路,支持 `http://` `https://` `socks5://`;slow-start 检测 + 失败降级 + 流中失败不重放 +- **Turnstile 自动突破**:三种 Token 获取方式 — 求解服务 API(`provider="api"`)、本地浏览器自动求解(`provider="browser"`,CDP 直连 Chrome/Edge,无需 Playwright/Docker)、手动导入 Cookie(`provider="manual"`);结果按「代理线路 × 站点」缓存,TTL 过期自动重解 +- **会话自愈**:nonce / data-config 缓存 + 配额耗尽 / 验证失败时自动刷新重试 +- **Web 管理台**:仪表盘、模型与站点、API Key、代理线路、Turnstile、对话调试、实时日志、设置 +- **可观测**:按模型 / 站点 / 按日统计、P50/P95 延迟、最近请求、实时日志(SSE),数据持久化到 `data/` +- **安全**:下游 Bearer / `X-Api-Key` 鉴权、每 Key 每分钟限流、每站点并发闸门、管理台独立口令 + +--- + +## 界面预览 + +| 仪表盘 | Turnstile | +|--------|-----------| +| ![仪表盘](docs/screenshots/dashboard.png) | ![Turnstile 求解配置](docs/screenshots/turnstile.png) | +| **对话调试** | **实时日志** | +| ![对话调试](docs/screenshots/playground.png) | ![实时日志](docs/screenshots/logs.png) | + +--- + +## 模型映射 + +共 **6 个模型**,分布在三个欧洲站点: + +| 模型 ID | 站点 | 对应模型 | bot_id | post_id | +|---------|------|---------|--------|---------| +| `deepseek-v4-flash-de` | deepseek.de | V4-Flash | 27487 | 106 | +| `deepseek-v4-pro-de` | deepseek.de | V4-Pro | 27533 | 27177 | +| `deepseek-v4-flash-es` | deepseek.es | V4-Flash | 27623 | 27568 | +| `deepseek-v4-pro-es` | deepseek.es | V4-Pro | 27637 | 27615 | +| `deepseek-v4-flash-fr` | deepseek.fr | V4-Flash | 27645 | 27569 | +| `deepseek-v4-pro-fr` | deepseek.fr | V4-Pro | 27647 | 27616 | + +> 三个站点各自有独立配额,聚合后每日可用 token 约 3 倍。 + +--- + +## 快速开始 + +### 方式一:源码运行 + +需要 Go ≥ 1.27。 + +```bash +cp config.example.toml config.toml +$EDITOR config.toml # Turnstile 默认关闭;启用时按 provider 填 api_key 或 browser_path + +go run ./cmd/dsfree2api -config config.toml +``` + +### 方式二:编译单文件二进制 + +```bash +make build # 本机 → bin/dsfree2api +make linux # linux/amd64 静态二进制 → bin/dsfree2api-linux-amd64 +``` + +部署到服务器: + +```bash +scp bin/dsfree2api-linux-amd64 user@host:/opt/dsfree2api/dsfree2api +ssh user@host '/opt/dsfree2api/dsfree2api -config /opt/dsfree2api/config.toml' +``` + +> 二进制不带任何运行时依赖(`CGO_ENABLED=0`),无需安装 Python / glibc / libcurl。 + +### 方式三:Docker + +```bash +cp config.example.toml config.toml +docker compose up -d --build +``` + +容器内监听 `8000`(API)与 `8001`(管理台),数据卷挂在 `./data`。 + +### 验证 + +```bash +curl http://127.0.0.1:8000/health +curl http://127.0.0.1:8000/v1/models -H "Authorization: Bearer sk-你的key" +``` + +--- + +## 配置 + +配置文件是 TOML,结构见 [`config.example.toml`](./config.example.toml)(含全部字段注释)。 + +- 不存在该文件时按内置默认值启动 +- **环境变量优先级高于配置文件** +- 通过管理台或 `PUT /api/config` 修改会**自动写回** `config.toml`(旧文件备份为 `config.toml.bak`) +- 启动时可用 `-check` 只校验配置: + +```bash +dsfree2api -config config.toml -check +``` + +### 常用字段 + +| 字段 | 说明 | +|------|------| +| `[server]` | API 监听 `host` / `port` / `log_level` | +| `[security].api_keys` | 下游鉴权,空数组 = 不鉴权 | +| `[admin]` | 管理台 `enabled` / `host` / `port` / `password`(留空启动时生成随机密码) | +| `[limits]` | `max_concurrent_per_site` 每站点并发、`rate_per_minute` 每 Key 每分钟限流(0 = 不限) | +| `[proxy]` | `url` 主线路、`fallback_urls` 备用线路、`slow_start_seconds` 首事件超时 | +| `[upstream]` | 超时、配置缓存 TTL、会话自动刷新、`cross_site_failover` 跨站切换 | +| `[turnstile]` | **默认 `enabled = false` 且不内置任何求解服务**;`provider` 三选一:`api`(填 `api_url` / `api_key`)、`browser`(填 `browser_path` 指向本机 Chrome/Edge)、`manual`(只用控制台导入的 Cookie),另有 Cookie TTL、重试次数 | +| `[sites.*]` | 三个站点的 `base_url` / `ajax_url` / `sitekey` / `language` | +| `[models.*]` | 模型到 `site` + `upstream_id` + `bot_id` / `post_id` 的映射 | + +### Turnstile 说明 + +站点在发消息前会校验 Turnstile(`ts_required`),`[turnstile]` 默认关闭时不会自动求解。`provider` 决定 Token / Cookie 的获取方式,三选一: + +| provider | 方式 | 需要配置 | 说明 | +|----------|------|----------|------| +| `api`(默认) | 1 · 求解服务 API | `api_url` + `api_key` | 把站点 + sitekey 交给你的求解服务,返回 Token 后由本程序完成站点验证(**默认不预置任何求解地址**,空地址开启会被拒绝) | +| `browser` | 2 · 本地浏览器自动获取 | `browser_path` | CDP 直连你配置的 Chrome / Edge:打开站点 → 注入 Turnstile widget → 可信点击 → 轮询拿到 Token;等价于 heartmore/cloudflare-solver 的做法,但**无需 Playwright / FlareSolverr / Docker**,单二进制即可。可选 `browser_user_data_dir`(真实 profile)、`browser_timezone` / `browser_locale`(对齐代理出口) | +| `manual` | 3 · 手动导入 Cookie | — | 不自动求解,只使用控制台导入的 Cookie | + +- **手动导入 Cookie**:在走同一条出口线路的浏览器里过一次验证,把 `document.cookie` 粘到控制台 **Turnstile → 手动导入 Cookie**,在 `cookie_ttl_seconds` 内免求解(Cookie 与出口 IP / UA 绑定,换线路需重新导入); +- **浏览器模式提示**:求解期间会短暂弹出浏览器窗口(`browser_headless = true` 可关闭,但过验证率更低);带账号密码的代理暂不支持 Chrome,请用 IP 白名单代理;浏览器被系统策略锁定时会报 `browser launch failed` 并附 stderr。 + +> `deepseek.fr` / `deepseek.es` 对中国 IP 会 302 到 `deepseek.com`,需给 `[proxy].url` 配一条境外线路(如本机 `http://127.0.0.1:7897`),`deepseek.de` 不受影响。 + +--- + +## API 用法 + +### 非流式 + +```bash +curl http://127.0.0.1:8000/v1/chat/completions \ + -H "Authorization: Bearer sk-你的key" \ + -H "Content-Type: application/json" \ + -d '{ + "model": "deepseek-v4-flash-fr", + "messages": [{"role": "user", "content": "你好"}], + "stream": false + }' +``` + +### 流式 + +```bash +curl -N http://127.0.0.1:8000/v1/chat/completions \ + -H "Authorization: Bearer sk-你的key" \ + -H "Content-Type: application/json" \ + -d '{"model":"deepseek-v4-flash-fr","messages":[{"role":"user","content":"你好"}],"stream":true}' +``` + +### OpenAI SDK + +```python +from openai import OpenAI + +client = OpenAI(base_url="http://127.0.0.1:8000/v1", api_key="sk-你的key") + +stream = client.chat.completions.create( + model="deepseek-v4-flash-fr", + messages=[{"role": "user", "content": "写一首五言绝句"}], + stream=True, +) +for chunk in stream: + print(chunk.choices[0].delta.content or "", end="") +``` + +### Tool Calling + +请求里带 `tools` 即可,模型会返回 `tool_calls`: + +```json +{ + "model": "deepseek-v4-pro-fr", + "messages": [{"role": "user", "content": "北京天气怎么样?"}], + "tools": [{ + "type": "function", + "function": { + "name": "get_weather", + "description": "获取城市天气", + "parameters": { + "type": "object", + "properties": {"city": {"type": "string"}}, + "required": ["city"] + } + } + }] +} +``` + +> 只有请求带 `tools` 时才会缓冲并输出 `tool_calls`,普通对话不受影响。 + +### Anthropic Messages(Claude Code 等) + +`POST /v1/messages` 接受 Anthropic Messages 格式(`system`、content blocks、`tools.input_schema`、`tool_choice`、流式 SSE `message_start` → `content_block_*` → `message_stop`),错误返回 `{"type":"error","error":{...}}` Anthropic 格式: + +```bash +curl http://127.0.0.1:8000/v1/messages \ + -H "x-api-key: sk-你的key" \ + -H "anthropic-version: 2023-06-01" \ + -H "Content-Type: application/json" \ + -d '{ + "model": "deepseek-v4-flash-fr", + "max_tokens": 1024, + "messages": [{"role": "user", "content": "你好"}] + }' +``` + +带 `tools` 时返回 `tool_use` content block(`stop_reason: "tool_use"`),多轮 `tool_result` 会回填为工具结果。 + +### 其它端点 + +| 端点 | 说明 | +|------|------| +| `POST /v1/messages` | Anthropic Messages API(流式 / 非流式、tool_use;错误为 Anthropic 格式) | +| `POST /v1/responses` | OpenAI Responses API(流式错误会发 `response.failed`,支持 `tools` → `function_call`) | +| `GET /v1/models` | 模型列表(需鉴权) | +| `GET /health` | 存活状态 + 模型/站点/Turnstile 概况(免鉴权) | +| `GET /` | 服务信息(免鉴权) | + +--- + +## Web 管理台 + +默认监听 `http://127.0.0.1:8001`。 + +| 页面 | 功能 | +|------|------| +| 仪表盘 | 请求数 / 错误 / Token / P50·P95、模型用量、站点状态(含 Cookie TTL 进度条)、最近请求 | +| 模型与站点 | 编辑标签、路径、bot_id/post_id,启停模型与站点,修改站点 URL / 语言 | +| API Key | 生成 / 删除 / 复制下游 key,立即写回配置 | +| 代理线路 | 主线路、备用线路增删、连通性测试、slow-start / 并发 / 限流 / 跨站切换 | +| Turnstile | 三种 Token 获取方式的模式面板(求解服务 API / 本地浏览器自动获取 / 手动导入 Cookie)、各站 Cookie 状态与 TTL、强制刷新、求解历史 | +| 对话调试 | 选模型 / 系统提示 / tools JSON,流式或非流式,带连接计时与取消 | +| 实时日志 | 级别过滤 + SSE 实时推送 | +| 设置 | 服务与上游参数、数据目录、危险操作 | + +- 登录口令取自 `[admin].password`;未配置时启动日志会打印**一次性随机密码** +- 鉴权通过 Cookie `dsfr_admin`,也可用 `X-Admin-Token` / `Authorization: Bearer ` +- 生产环境请将管理台置于反向代理后(默认只绑定 `127.0.0.1`) + +--- + +## 环境变量 + +| 变量 | 说明 | 默认 | +|------|------|------| +| `HOST` / `PORT` | API 监听地址 | `0.0.0.0` / `8000` | +| `LOG_LEVEL` | `DEBUG` `INFO` `WARN` `ERROR` | `INFO` | +| `API_KEYS` | 下游 key,逗号分隔 | —(不鉴权) | +| `PROXY_URL` | 主代理 | —(直连) | +| `PROXY_FALLBACK_URLS` | 备用代理,逗号分隔 | — | +| `PROXY_SLOW_START_SECONDS` | 首事件超时秒数 | `8` | +| `TURNSTILE_ENABLED` | Turnstile 开关(`true`/`false`) | `false` | +| `TURNSTILE_API_KEY` | Turnstile 求解服务 key(仅在 `provider=api` 且开启时校验) | 配置文件 | +| `TURNSTILE_PROVIDER` | Token 获取方式:`api` / `browser` / `manual` | `api` | +| `TURNSTILE_BROWSER_PATH` | 浏览器模式的 Chrome / Edge 可执行文件路径 | — | +| `ADMIN_ENABLED` / `ADMIN_HOST` / `ADMIN_PORT` / `ADMIN_PASSWORD` | 管理台 | `true` / `127.0.0.1` / `8001` | +| `DATA_DIR` | 统计等运行时数据目录 | `./data` | + +--- + +## 与 Python 版的差异 + +功能对齐 `deepseek-fr-2api-main`(Python/FastAPI 版),并做了以下修复与增强: + +**修复** + +- 流式 `tool_calls` 仅在请求带 `tools` 时缓冲后再下发,避免内容重复 +- `/v1/responses` 流式错误发 `response.failed`,不再假报 `completed` +- 会话标记识别只匹配明确 marker,不再误判裸 `"nonce"` 字段 +- `temperature` / `max_tokens` / `stop` / `response_format` 以 prompt 尾部约束下发 +- 空 role 不再导致 prompt 构建 panic + +**增强** + +- 站点配额耗尽跨站切换(`cross_site_failover`) +- 每站点并发闸门 + 每 Key 每分钟限流 +- 启动期配置校验(`-check`),按 provider 检查 `api_url` / `api_key` 或 `browser_path`,缺失直接报错而非运行时失败 +- 统计持久化(`data/metrics.json`)、实时日志 SSE、对话调试页 +- `POST /v1/messages` Anthropic Messages 格式(流式 / 非流式 / `tool_use`),`/v1/responses` 支持 `tools` → `function_call`,多轮 tool 结果回填 +- `Chat` / `Responses` / `Messages` / `Models` / 代理与 Turnstile 的单元测试 +- 单二进制部署,无 Python / uv / libcurl 依赖 + +--- + +## 架构 + +``` +cmd/dsfree2api 入口:配置加载、日志、双 HTTP server、优雅退出 +internal/config TOML 结构 + 默认值 + env 覆盖 + 校验 + 写回 +internal/httpx TLS 指纹会话封装(bogdanfinn/tls-client, Chrome_120) +internal/openai OpenAI 协议类型、prompt 拼接、tool_calls 解析 +internal/anthropic Anthropic Messages 协议类型 + 与 OpenAI 消息的互转 +internal/turnstile Turnstile 三种求解方式(API / 浏览器 CDP / 手动)+ 按线路/站点的 Cookie 缓存 +internal/upstream 上游逆向核心:data-config/nonce → SSE → 事件翻译, + 线路降级、慢启动、会话刷新、跨站切换 +internal/api OpenAI / Anthropic 路由、鉴权、限流、流式写出 +internal/admin 管理台后端 + go:embed 前端 +internal/metrics 计数/分模型/分站点/按日/延迟分位数,JSON 持久化 +internal/logbuf 环形日志 + 订阅广播(供 SSE 日志页) +``` + +--- + +## 开发 + +```bash +make fmt # gofmt -w . +make vet # go vet ./... +make test # go test ./... +make check # 以上全部 +make linux # 交叉编译静态 linux 二进制 +``` + +命令行参数: + +``` +-config 配置文件路径(默认 config.toml) +-host 覆盖监听地址 +-port 覆盖监听端口 +-check 只校验配置后退出 +-version 打印版本 +``` + +--- + +## 免责声明 + +本项目仅供学习与研究网络协议使用,所对接站点与 DeepSeek 官方无关。使用者需自行承担因使用本项目产生的一切责任与后果。 diff --git a/cmd/dsfree2api/main.go b/cmd/dsfree2api/main.go new file mode 100644 index 0000000..127a38e --- /dev/null +++ b/cmd/dsfree2api/main.go @@ -0,0 +1,159 @@ +package main + +import ( + "context" + "errors" + "flag" + "fmt" + "log/slog" + "net/http" + "os" + "os/signal" + "strings" + "syscall" + "time" + + "github.com/nyoungo/dsfree2api/internal/admin" + "github.com/nyoungo/dsfree2api/internal/api" + "github.com/nyoungo/dsfree2api/internal/config" + "github.com/nyoungo/dsfree2api/internal/logbuf" + "github.com/nyoungo/dsfree2api/internal/metrics" + "github.com/nyoungo/dsfree2api/internal/turnstile" + "github.com/nyoungo/dsfree2api/internal/upstream" +) + +func main() { + var ( + configPath = flag.String("config", "config.toml", "path to config.toml") + host = flag.String("host", "", "override bind host") + port = flag.Int("port", 0, "override bind port") + checkOnly = flag.Bool("check", false, "validate the config and exit") + showVer = flag.Bool("version", false, "print version and exit") + ) + flag.Parse() + + if *showVer { + fmt.Printf("dsfree2api %s\n", admin.Version) + return + } + + cfg, err := config.Load(*configPath) + if err != nil { + fmt.Fprintf(os.Stderr, "config error: %v\n", err) + os.Exit(1) + } + if *host != "" { + cfg.Server.Host = *host + } + if *port != 0 { + cfg.Server.Port = *port + } + if err := cfg.Validate(); err != nil { + fmt.Fprintf(os.Stderr, "config invalid: %v\n", err) + os.Exit(1) + } + if *checkOnly { + fmt.Println("config OK") + return + } + + level := parseLevel(cfg.Server.LogLevel) + logs := logbuf.New(500) + logger := slog.New(logbuf.NewHandler( + slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: level}), + logs, + )) + + dataDir, err := cfg.DataDir() + if err != nil { + logger.Error("cannot create data dir", "error", err) + os.Exit(1) + } + + met := metrics.New(dataDir) + met.Start() + defer met.Close() + + ts := turnstile.New(cfg.Turnstile, cfg.Upstream.UserAgent) + up := upstream.New(cfg, ts, logger) + apiSrv := api.New(cfg, up, met, logs, ts, logger) + adminSrv := admin.New(cfg, up, met, logs, ts, logger) + + mainAddr := fmt.Sprintf("%s:%d", cfg.Server.Host, cfg.Server.Port) + mainHTTP := &http.Server{ + Addr: mainAddr, + Handler: apiSrv.Handler(), + ReadHeaderTimeout: 15 * time.Second, + ErrorLog: nil, + } + + errCh := make(chan error, 2) + go func() { + logger.Info("OpenAI-compatible API listening", + "addr", mainAddr, "models", countModels(cfg), "admin", cfg.Admin.Enabled) + if err := mainHTTP.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + errCh <- fmt.Errorf("api server: %w", err) + } + }() + + var adminHTTP *http.Server + if cfg.Admin.Enabled { + adminAddr := fmt.Sprintf("%s:%d", cfg.Admin.Host, cfg.Admin.Port) + adminHTTP = &http.Server{ + Addr: adminAddr, + Handler: adminSrv.Handler(), + ReadHeaderTimeout: 15 * time.Second, + } + go func() { + logger.Info("web console listening", "addr", adminAddr) + if err := adminHTTP.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + errCh <- fmt.Errorf("admin server: %w", err) + } + }() + } + + sig := make(chan os.Signal, 1) + signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM) + + select { + case err := <-errCh: + logger.Error("server failed", "error", err) + os.Exit(1) + case s := <-sig: + logger.Info("shutting down", "signal", s.String()) + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + _ = mainHTTP.Shutdown(ctx) + if adminHTTP != nil { + _ = adminHTTP.Shutdown(ctx) + } + met.Close() + logger.Info("bye") +} + +func countModels(cfg *config.Config) int { + cfg.RLock() + defer cfg.RUnlock() + n := 0 + for _, m := range cfg.Models { + if m.Enabled { + n++ + } + } + return n +} + +func parseLevel(s string) slog.Level { + switch strings.ToUpper(strings.TrimSpace(s)) { + case "DEBUG": + return slog.LevelDebug + case "WARN", "WARNING": + return slog.LevelWarn + case "ERROR": + return slog.LevelError + default: + return slog.LevelInfo + } +} diff --git a/config.example.toml b/config.example.toml new file mode 100644 index 0000000..5b27c5d --- /dev/null +++ b/config.example.toml @@ -0,0 +1,137 @@ +# dsfree2api 示例配置。 +# 复制为 config.toml 后按需修改;运行 `dsfree2api -check -config config.toml` 可校验。 +# 环境变量优先级高于本文件:HOST PORT LOG_LEVEL API_KEYS PROXY_URL PROXY_FALLBACK_URLS +# PROXY_SLOW_START_SECONDS TURNSTILE_ENABLED TURNSTILE_API_KEY TURNSTILE_PROVIDER +# TURNSTILE_BROWSER_PATH ADMIN_ENABLED ADMIN_HOST ADMIN_PORT ADMIN_PASSWORD DATA_DIR + +[server] +host = "0.0.0.0" +port = 8000 +log_level = "INFO" + +[security] +# 下游鉴权。空数组 = 不鉴权;建议部署时换成自己的 key。 +api_keys = ["sk-dsfr-local-change-me"] + +[admin] +enabled = true +host = "127.0.0.1" +port = 8001 +# 留空时启动会生成一次性随机密码并打印到日志。 +password = "admin123" + +[limits] +# 同一站点的并发上游请求数;rate_per_minute = 0 表示不限流。 +max_concurrent_per_site = 4 +rate_per_minute = 0 + +[proxy] +# 主线路,支持 http:// / https:// / socks5://,留空则直连。 +url = "" +# 主线路失败或 slow-start 超时后依次尝试的备用线路。 +fallback_urls = [] +# 主线路允许的首事件等待秒数(>0 且存在备用线路时生效)。 +slow_start_seconds = 8 + +[upstream] +timeout = 60.0 +stream_timeout = 300.0 +config_ttl_seconds = 60 +auto_refresh = true +refresh_retries = 4 +retry_backoff_seconds = 1.0 +user_agent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36" +# 本站点配额耗尽时自动切换到其他站点的同款模型。 +cross_site_failover = true + +[turnstile] +# 默认关闭,且不内置任何求解服务。Token 获取方式三选一(provider): +# api —— 调用你自己的求解服务 API(需 api_url / api_key,方式 1) +# browser —— 用本机浏览器自动求解(需 browser_path,方式 2) +# manual —— 只用控制台手动导入的 Cookie(方式 3) +enabled = false +provider = "api" +# 方式 1:求解服务 API +# api_url = "https://your-solver.example.com/turnstile/sync" +# api_key = "REPLACE_ME" +# 方式 2:本地浏览器自动获取(CDP 直连,无需 Playwright / Docker) +# browser_path = "C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe" +# browser_headless = false # 默认有头窗口,过验证率更高 +# browser_user_data_dir = "" # 可选:指向已有 profile(需先关掉正在运行的浏览器) +# browser_timezone = "" # 可选:时区,与代理出口对齐,如 "Asia/Tokyo" +# browser_locale = "" # 可选:如 "ja-JP",留空取站点语言 +sitekey = "0x4AAAAAADlLZ3ljqZP6cQwq" +action = "chat" +timeout_seconds = 90 +cookie_ttl_seconds = 10800 +retries = 5 +retry_backoff_seconds = 1.5 + +[runtime] +data_dir = "./data" + +[sites.de] +base_url = "https://deepseek.de" +ajax_url = "https://deepseek.de/wp-admin/admin-ajax.php" +sitekey = "0x4AAAAAADlLZ3ljqZP6cQwq" +language = "de-DE,de;q=0.9,en-US;q=0.8,en;q=0.7" + +[sites.es] +base_url = "https://deepseek.es" +ajax_url = "https://deepseek.es/wp-admin/admin-ajax.php" +sitekey = "0x4AAAAAADlLZ3ljqZP6cQwq" +language = "es-ES,es;q=0.9,en-US;q=0.8,en;q=0.7" + +[sites.fr] +base_url = "https://deepseek.fr" +ajax_url = "https://deepseek.fr/wp-admin/admin-ajax.php" +sitekey = "0x4AAAAAADlLZ3ljqZP6cQwq" +language = "fr-FR,fr;q=0.9,en-US;q=0.8,en;q=0.7" + +[models.deepseek-v4-flash-de] +site = "de" +upstream_id = "deepseek-v4-flash" +label = "DeepSeek V4-Flash DE" +page_path = "/" +bot_id = 27487 +post_id = 106 + +[models.deepseek-v4-pro-de] +site = "de" +upstream_id = "deepseek-v4-pro" +label = "DeepSeek V4-Pro DE" +page_path = "/pro/" +bot_id = 27533 +post_id = 27177 + +[models.deepseek-v4-flash-es] +site = "es" +upstream_id = "deepseek-v4-flash" +label = "DeepSeek V4-Flash ES" +page_path = "/" +bot_id = 27623 +post_id = 27568 + +[models.deepseek-v4-pro-es] +site = "es" +upstream_id = "deepseek-v4-pro" +label = "DeepSeek V4-Pro ES" +page_path = "/pro/" +bot_id = 27637 +post_id = 27615 + +[models.deepseek-v4-flash-fr] +site = "fr" +upstream_id = "deepseek-v4-flash" +label = "DeepSeek V4-Flash FR" +page_path = "/" +bot_id = 27645 +post_id = 27569 + +[models.deepseek-v4-pro-fr] +site = "fr" +upstream_id = "deepseek-v4-pro" +label = "DeepSeek V4-Pro FR" +page_path = "/pro/" +bot_id = 27647 +post_id = 27616 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..65d4bc6 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,23 @@ +services: + dsfree2api: + build: . + ports: + - "8000:8000" + - "8001:8001" + environment: + HOST: 0.0.0.0 + PORT: 8000 + # Web console listens on 127.0.0.1 by default; expose it to reach it + # from outside the container (put a reverse proxy in front of it). + ADMIN_HOST: 0.0.0.0 + ADMIN_PASSWORD: ${ADMIN_PASSWORD:-} + TURNSTILE_API_KEY: ${TURNSTILE_API_KEY:-} + API_KEYS: ${API_KEYS:-} + PROXY_URL: ${PROXY_URL:-} + PROXY_FALLBACK_URLS: ${PROXY_FALLBACK_URLS:-} + PROXY_SLOW_START_SECONDS: ${PROXY_SLOW_START_SECONDS:-8} + volumes: + - ./data:/app/data + # 先执行: cp config.example.toml config.toml + - ./config.toml:/app/config.toml + restart: unless-stopped diff --git a/docs/screenshots/dashboard.png b/docs/screenshots/dashboard.png new file mode 100644 index 0000000..d8a392f Binary files /dev/null and b/docs/screenshots/dashboard.png differ diff --git a/docs/screenshots/logs.png b/docs/screenshots/logs.png new file mode 100644 index 0000000..70a2c59 Binary files /dev/null and b/docs/screenshots/logs.png differ diff --git a/docs/screenshots/playground.png b/docs/screenshots/playground.png new file mode 100644 index 0000000..0d3bdd0 Binary files /dev/null and b/docs/screenshots/playground.png differ diff --git a/docs/screenshots/turnstile.png b/docs/screenshots/turnstile.png new file mode 100644 index 0000000..e65c715 Binary files /dev/null and b/docs/screenshots/turnstile.png differ diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..f7b9101 --- /dev/null +++ b/go.mod @@ -0,0 +1,26 @@ +module github.com/nyoungo/dsfree2api + +go 1.27.0 + +require ( + github.com/BurntSushi/toml v1.6.0 + github.com/bogdanfinn/fhttp v0.6.9 + github.com/bogdanfinn/tls-client v1.16.0 + golang.org/x/net v0.48.0 +) + +require ( + github.com/andybalholm/brotli v1.2.0 // indirect + github.com/bdandy/go-errors v1.2.2 // indirect + github.com/bdandy/go-socks4 v1.2.3 // indirect + github.com/bogdanfinn/quic-go-utls v1.0.10-utls // indirect + github.com/bogdanfinn/utls v1.7.8-barnius // indirect + github.com/bogdanfinn/websocket v1.5.6-barnius // indirect + github.com/cloudflare/circl v1.6.2 // indirect + github.com/klauspost/compress v1.18.2 // indirect + github.com/quic-go/qpack v0.6.0 // indirect + github.com/tam7t/hpkp v0.0.0-20160821193359-2b70b4024ed5 // indirect + golang.org/x/crypto v0.46.0 // indirect + golang.org/x/sys v0.39.0 // indirect + golang.org/x/text v0.32.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..bb1959c --- /dev/null +++ b/go.sum @@ -0,0 +1,52 @@ +github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= +github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= +github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= +github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= +github.com/bdandy/go-errors v1.2.2 h1:WdFv/oukjTJCLa79UfkGmwX7ZxONAihKu4V0mLIs11Q= +github.com/bdandy/go-errors v1.2.2/go.mod h1:NkYHl4Fey9oRRdbB1CoC6e84tuqQHiqrOcZpqFEkBxM= +github.com/bdandy/go-socks4 v1.2.3 h1:Q6Y2heY1GRjCtHbmlKfnwrKVU/k81LS8mRGLRlmDlic= +github.com/bdandy/go-socks4 v1.2.3/go.mod h1:98kiVFgpdogR8aIGLWLvjDVZ8XcKPsSI/ypGrO+bqHI= +github.com/bogdanfinn/fhttp v0.6.9 h1:F9MsWR1K6JMRlXN4Q/MfjYmptr29nCdycQo+OPBG5U0= +github.com/bogdanfinn/fhttp v0.6.9/go.mod h1:7phoj3f/q54WuiTnCTlAhEoaVcdlruKkSzCSD47LzHk= +github.com/bogdanfinn/quic-go-utls v1.0.10-utls h1:oZN+J3OKMmWaVbI/sx4ULvHiAXbWJKVcvEFW4fkybMU= +github.com/bogdanfinn/quic-go-utls v1.0.10-utls/go.mod h1:bBLJ2Vy68Q7y6VfbG9czd825rTcQua7n+y12RM9Q588= +github.com/bogdanfinn/tls-client v1.16.0 h1:km3YLI6CMRLZnfrC+hGStePPryIicd3hoxFQvHB1itY= +github.com/bogdanfinn/tls-client v1.16.0/go.mod h1:TxbiXlpZhNN5xmxzsr3KhNAeqoEEPJafwgBbyp26Phc= +github.com/bogdanfinn/utls v1.7.8-barnius h1:wRT83mP4hmuMM1/lIpLKKhzaEuyEKjux7VSj7XHFXdg= +github.com/bogdanfinn/utls v1.7.8-barnius/go.mod h1:o5v09jf+9ayMEfcP+8sssNdSMm278cyhBV25H8jb5Lw= +github.com/bogdanfinn/websocket v1.5.6-barnius h1:QpdWVEZ9rKtcT7C2pU19uE2KQw2XevuKTDTzLxfDi/I= +github.com/bogdanfinn/websocket v1.5.6-barnius/go.mod h1:41/rXLDWpqJ5A4gOKSY/z4NLbW/hHZHneRcYM0kbpxA= +github.com/cloudflare/circl v1.6.2 h1:hL7VBpHHKzrV5WTfHCaBsgx/HGbBYlgrwvNXEVDYYsQ= +github.com/cloudflare/circl v1.6.2/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk= +github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= +github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/tam7t/hpkp v0.0.0-20160821193359-2b70b4024ed5 h1:YqAladjX7xpA6BM04leXMWAEjS0mTZ5kUU9KRBriQJc= +github.com/tam7t/hpkp v0.0.0-20160821193359-2b70b4024ed5/go.mod h1:2JjD2zLQYH5HO74y5+aE3remJQvl6q4Sn6aWA2wD1Ng= +github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= +github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= +go.uber.org/mock v0.5.2 h1:LbtPTcP8A5k9WPXj54PPPbjcI4Y6lhyOZXn+VS7wNko= +go.uber.org/mock v0.5.2/go.mod h1:wLlUxC2vVTPTaE3UD51E0BGOAElKrILxhVSDYQLld5o= +golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU= +golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= +golang.org/x/net v0.0.0-20211104170005-ce137452f963/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= +golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= +golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= +golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/admin/actions.go b/internal/admin/actions.go new file mode 100644 index 0000000..0369aa5 --- /dev/null +++ b/internal/admin/actions.go @@ -0,0 +1,596 @@ +package admin + +import ( + "bytes" + "context" + "crypto/rand" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" + + "github.com/nyoungo/dsfree2api/internal/config" + "github.com/nyoungo/dsfree2api/internal/httpx" + "github.com/nyoungo/dsfree2api/internal/openai" + "github.com/nyoungo/dsfree2api/internal/upstream" +) + +func (s *Server) handleActions(w http.ResponseWriter, r *http.Request) { + body, err := readBody(r, 1<<20) + if err != nil { + fail(w, err.Error()) + return + } + var req struct { + Action string `json:"action"` + } + if err := json.Unmarshal(body, &req); err != nil || req.Action == "" { + fail(w, "action is required") + return + } + + switch req.Action { + case "create_key": + key := "sk-" + randomToken(24) + s.cfg.Lock() + s.cfg.Security.APIKeys = append(s.cfg.Security.APIKeys, key) + s.cfg.Unlock() + s.save(w) + case "delete_key": + var p struct { + Key string `json:"key"` + } + _ = json.Unmarshal(body, &p) + s.cfg.Lock() + out := s.cfg.Security.APIKeys[:0] + for _, k := range s.cfg.Security.APIKeys { + if k != p.Key { + out = append(out, k) + } + } + s.cfg.Security.APIKeys = out + s.cfg.Unlock() + s.save(w) + + case "toggle_model", "toggle_site": + var p struct { + ID string `json:"id"` + Enabled bool `json:"enabled"` + } + _ = json.Unmarshal(body, &p) + s.cfg.Lock() + var e error + if req.Action == "toggle_model" { + if m, ok := s.cfg.Models[p.ID]; ok { + m.Enabled = p.Enabled + } else { + e = fmt.Errorf("model %q not found", p.ID) + } + } else { + if st, ok := s.cfg.Sites[p.ID]; ok { + st.Enabled = p.Enabled + } else { + e = fmt.Errorf("site %q not found", p.ID) + } + } + s.cfg.Unlock() + if e != nil { + fail(w, e.Error()) + return + } + s.save(w) + + case "update_model": + var p struct { + ID string `json:"id"` + Label string `json:"label"` + PagePath string `json:"page_path"` + BotID int `json:"bot_id"` + PostID int `json:"post_id"` + } + _ = json.Unmarshal(body, &p) + s.cfg.Lock() + m, ok := s.cfg.Models[p.ID] + if ok { + if p.Label != "" { + m.Label = p.Label + } + if p.PagePath != "" { + m.PagePath = p.PagePath + } + m.BotID, m.PostID = p.BotID, p.PostID + } + s.cfg.Unlock() + if !ok { + fail(w, "model not found") + return + } + s.up.InvalidateAll() + s.save(w) + + case "update_site": + var p struct { + Code string `json:"code"` + BaseURL string `json:"base_url"` + AJAXURL string `json:"ajax_url"` + Language string `json:"language"` + SiteKey string `json:"sitekey"` + } + _ = json.Unmarshal(body, &p) + s.cfg.Lock() + st, ok := s.cfg.Sites[p.Code] + if ok { + if p.BaseURL != "" { + st.BaseURL = strings.TrimRight(p.BaseURL, "/") + if p.AJAXURL == "" { + st.AJAXURL = st.BaseURL + "/wp-admin/admin-ajax.php" + } + } + if p.AJAXURL != "" { + st.AJAXURL = p.AJAXURL + } + if p.Language != "" { + st.Language = p.Language + } + if p.SiteKey != "" { + st.SiteKey = p.SiteKey + } + } + s.cfg.Unlock() + if !ok { + fail(w, "site not found") + return + } + s.up.InvalidateAll() + s.save(w) + + case "set_primary": + var p struct { + URL string `json:"url"` + } + _ = json.Unmarshal(body, &p) + s.cfg.Lock() + s.cfg.Proxy.URL = strings.TrimSpace(p.URL) + s.cfg.Unlock() + s.save(w) + + case "add_fallback": + var p struct { + URL string `json:"url"` + } + _ = json.Unmarshal(body, &p) + p.URL = strings.TrimSpace(p.URL) + if p.URL == "" { + fail(w, "url is required") + return + } + s.cfg.Lock() + for _, u := range s.cfg.Proxy.FallbackURLs { + if u == p.URL { + s.cfg.Unlock() + fail(w, "fallback already exists") + return + } + } + s.cfg.Proxy.FallbackURLs = append(s.cfg.Proxy.FallbackURLs, p.URL) + s.cfg.Unlock() + s.save(w) + + case "remove_fallback": + var p struct { + URL string `json:"url"` + } + _ = json.Unmarshal(body, &p) + s.cfg.Lock() + out := s.cfg.Proxy.FallbackURLs[:0] + for _, u := range s.cfg.Proxy.FallbackURLs { + if u != p.URL { + out = append(out, u) + } + } + s.cfg.Proxy.FallbackURLs = out + s.cfg.Unlock() + s.save(w) + + case "set_limits": + var p struct { + SlowStart float64 `json:"slow_start"` + MaxConcurrent int `json:"max_concurrent"` + RatePerMinute float64 `json:"rate_per_minute"` + CrossSite *bool `json:"cross_site_failover"` + AutoRefresh *bool `json:"auto_refresh"` + RefreshRetries *int `json:"refresh_retries"` + } + _ = json.Unmarshal(body, &p) + s.cfg.Lock() + if p.SlowStart >= 0 { + s.cfg.Proxy.SlowStartSeconds = p.SlowStart + } + s.cfg.Limits.MaxConcurrentPerSite = p.MaxConcurrent + s.cfg.Limits.RatePerMinute = p.RatePerMinute + if p.CrossSite != nil { + s.cfg.Upstream.CrossSiteFailover = *p.CrossSite + } + if p.AutoRefresh != nil { + s.cfg.Upstream.AutoRefresh = *p.AutoRefresh + } + if p.RefreshRetries != nil { + s.cfg.Upstream.RefreshRetries = *p.RefreshRetries + } + s.cfg.Unlock() + s.save(w) + + case "set_turnstile": + var p struct { + Enabled bool `json:"enabled"` + Provider *string `json:"provider"` + APIURL string `json:"api_url"` + APIKey string `json:"api_key"` + SiteKey string `json:"sitekey"` + Challenge string `json:"challenge_action"` + Legacy string `json:"ts_action"` + CookieTT int `json:"cookie_ttl_seconds"` + + BrowserPath *string `json:"browser_path"` + BrowserHeadless *bool `json:"browser_headless"` + BrowserUserDataDir *string `json:"browser_user_data_dir"` + BrowserTimezone *string `json:"browser_timezone"` + BrowserLocale *string `json:"browser_locale"` + } + _ = json.Unmarshal(body, &p) + // Build the candidate config first: a rejected save must not leave + // half-applied fields behind in memory. + s.cfg.Lock() + tc := s.cfg.Turnstile + if p.Provider != nil { + tc.Provider = strings.ToLower(strings.TrimSpace(*p.Provider)) + } + if p.BrowserPath != nil { + tc.BrowserPath = strings.TrimSpace(*p.BrowserPath) + } + if p.BrowserHeadless != nil { + tc.BrowserHeadless = *p.BrowserHeadless + } + if p.BrowserUserDataDir != nil { + tc.BrowserUserDataDir = strings.TrimSpace(*p.BrowserUserDataDir) + } + if p.BrowserTimezone != nil { + tc.BrowserTimezone = strings.TrimSpace(*p.BrowserTimezone) + } + if p.BrowserLocale != nil { + tc.BrowserLocale = strings.TrimSpace(*p.BrowserLocale) + } + if strings.TrimSpace(p.APIURL) != "" { + tc.APIURL = strings.TrimSpace(p.APIURL) + } + if p.APIKey != "" { + tc.APIKey = p.APIKey + } + if p.SiteKey != "" { + tc.SiteKey = p.SiteKey + } + if act := firstNonEmpty(p.Challenge, p.Legacy); act != "" { + tc.Action = act + } + if p.CookieTT > 0 { + tc.CookieTTLSeconds = p.CookieTT + } + tc.Enabled = p.Enabled + if tc.Enabled { + // provider-specific requirements mirror config.Validate() + switch tc.ProviderValue() { + case config.ProviderAPI: + if strings.TrimSpace(tc.APIURL) == "" { + s.cfg.Unlock() + fail(w, "turnstile.api_url is empty — enter your solver endpoint, or switch provider to browser/manual") + return + } + case config.ProviderBrowser: + if tc.BrowserPath == "" { + s.cfg.Unlock() + fail(w, "turnstile.browser_path is empty — point it at Chrome/Edge, or switch provider") + return + } + case config.ProviderManual: + default: + prov := tc.Provider + s.cfg.Unlock() + fail(w, fmt.Sprintf("unknown provider %q (api|browser|manual)", prov)) + return + } + } + s.cfg.Turnstile = tc + s.cfg.Unlock() + s.ts.SetConfig(tc) + s.save(w) + + case "refresh_cookies": + var p struct { + Site string `json:"site"` + } + _ = json.Unmarshal(body, &p) + if !s.ts.Enabled() { + fail(w, "turnstile solver is disabled — enable it first, or import cookies manually") + return + } + if s.ts.Config().ProviderValue() == config.ProviderManual { + fail(w, `provider is "manual" — re-import cookies below instead of forcing a refresh`) + return + } + for _, code := range s.siteCodes(p.Site) { + s.ts.Invalidate(code) + } + writeJSON(w, http.StatusOK, map[string]any{"ok": true}) + + case "clear_cookies": + var p struct { + Site string `json:"site"` + } + _ = json.Unmarshal(body, &p) + for _, code := range s.siteCodes(p.Site) { + s.ts.Invalidate(code) + } + writeJSON(w, http.StatusOK, map[string]any{"ok": true}) + + case "import_cookies": + var p struct { + Site string `json:"site"` + Cookies string `json:"cookies"` + } + _ = json.Unmarshal(body, &p) + s.cfg.RLock() + site, ok := s.cfg.Sites[p.Site] + proxy := s.cfg.Proxy.URL + s.cfg.RUnlock() + if !ok || site == nil { + fail(w, "unknown site: "+p.Site) + return + } + exp, n, err := s.ts.ImportCookies(site.Code, p.Cookies, proxy) + if err != nil { + fail(w, err.Error()) + return + } + s.log.Info("turnstile cookies imported", "site", site.Code, "count", n, "expires_at", exp) + writeJSON(w, http.StatusOK, map[string]any{ + "ok": true, "site": site.Code, "cookie_count": n, "expires_at": exp.Unix(), + }) + + case "invalidate": + s.up.InvalidateAll() + writeJSON(w, http.StatusOK, map[string]any{"ok": true}) + + case "reset_metrics": + s.met.Reset() + writeJSON(w, http.StatusOK, map[string]any{"ok": true}) + + case "test_proxy": + var p struct { + URL string `json:"url"` + } + _ = json.Unmarshal(body, &p) + result := s.testProxy(p.URL) + writeJSON(w, http.StatusOK, result) + + case "probe_site": + var p struct { + Site string `json:"site"` + } + _ = json.Unmarshal(body, &p) + writeJSON(w, http.StatusOK, s.probeSite(p.Site)) + + default: + fail(w, "unknown action: "+req.Action) + } +} + +func (s *Server) save(w http.ResponseWriter) { + if err := s.cfg.Save(); err != nil { + fail(w, err.Error()) + return + } + writeJSON(w, http.StatusOK, map[string]any{"ok": true}) +} + +func (s *Server) testProxy(url string) map[string]any { + url = strings.TrimSpace(url) + target := "https://deepseek.fr/" + start := time.Now() + cli, err := httpx.StdClient(url, 20*time.Second) + if err != nil { + return map[string]any{"ok": false, "error": err.Error()} + } + req, _ := http.NewRequest(http.MethodGet, target, nil) + req.Header.Set("User-Agent", httpx.DefaultUserAgent) + resp, err := cli.Do(req) + if err != nil { + return map[string]any{"ok": false, "error": err.Error(), "ms": time.Since(start).Milliseconds()} + } + defer resp.Body.Close() + io.Copy(io.Discard, io.LimitReader(resp.Body, 4096)) + return map[string]any{"ok": resp.StatusCode < 400, "status": resp.StatusCode, "ms": time.Since(start).Milliseconds()} +} + +func (s *Server) probeSite(code string) map[string]any { + s.cfg.RLock() + st, ok := s.cfg.Sites[code] + proxy := s.cfg.Proxy.URL + var site config.Site + if ok { + site = *st + } + s.cfg.RUnlock() + if !ok { + return map[string]any{"ok": false, "error": "site not found"} + } + + start := time.Now() + sess, err := httpx.NewSession(httpx.Options{ProxyURL: proxy, Timeout: 20 * time.Second}) + if err != nil { + return map[string]any{"ok": false, "error": err.Error()} + } + defer sess.Close() + resp, err := sess.Do(nil, httpx.Request{ + Method: "GET", URL: site.BaseURL + "/", + Header: map[string]string{ + "User-Agent": httpx.DefaultUserAgent, + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": site.Language, + }, + Timeout: 20 * time.Second, + }) + if err != nil { + return map[string]any{"ok": false, "error": err.Error(), "ms": time.Since(start).Milliseconds()} + } + hasContainer := bytes.Contains(resp.Body, []byte("aipkit_chat_container")) + return map[string]any{ + "ok": resp.Status < 400, "status": resp.Status, + "ms": time.Since(start).Milliseconds(), "chat_container": hasContainer, + "bytes": len(resp.Body), + } +} + +// ── playground ─────────────────────────────────────────────────── + +func (s *Server) handlePlayground(w http.ResponseWriter, r *http.Request) { + body, err := readBody(r, 4<<20) + if err != nil { + fail(w, err.Error()) + return + } + var req struct { + Model string `json:"model"` + Messages []openai.ChatMessage `json:"messages"` + Stream bool `json:"stream"` + Tools []openai.ToolDef `json:"tools"` + } + if err := json.Unmarshal(body, &req); err != nil { + fail(w, "invalid request: "+err.Error()) + return + } + if req.Model == "" || len(req.Messages) == 0 { + fail(w, "model and messages are required") + return + } + s.cfg.RLock() + _, ok := s.cfg.Models[req.Model] + s.cfg.RUnlock() + if !ok { + fail(w, "unknown model") + return + } + prompt := openai.BuildPrompt(req.Messages, openai.PromptOptions{Tools: req.Tools}) + + start := time.Now() + info := &upstream.ServeInfo{} + + s.cfg.RLock() + deadline := time.Duration(s.cfg.Upstream.StreamTimeout * float64(time.Second)) + s.cfg.RUnlock() + if deadline <= 0 { + deadline = 5 * time.Minute + } + ctx, cancel := context.WithTimeout(r.Context(), deadline) + defer cancel() + + if !req.Stream { + var sb strings.Builder + err := s.up.Chat(ctx, req.Model, prompt, info, func(ev upstream.Event) error { + if ev.Kind == upstream.KindDelta { + sb.WriteString(ev.Value) + } + return nil + }) + if err != nil { + writeJSON(w, http.StatusBadGateway, map[string]any{"error": err.Error()}) + return + } + _, site, route := info.Get() + writeJSON(w, http.StatusOK, map[string]any{ + "text": sb.String(), "ms": time.Since(start).Milliseconds(), + "site": site, "route": route, "prompt_chars": len(prompt), + }) + return + } + + flusher, _ := w.(http.Flusher) + w.Header().Set("Content-Type", "text/event-stream; charset=utf-8") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("X-Accel-Buffering", "no") + send := func(v any) { + raw, _ := json.Marshal(v) + fmt.Fprintf(w, "data: %s\n\n", raw) + if flusher != nil { + flusher.Flush() + } + } + send(map[string]any{"status": "connecting"}) + err = s.up.Chat(ctx, req.Model, prompt, info, func(ev upstream.Event) error { + if ev.Kind == upstream.KindDelta { + send(map[string]any{"delta": ev.Value}) + } + return nil + }) + _, site, route := info.Get() + if err != nil { + send(map[string]any{"error": err.Error()}) + } else { + send(map[string]any{"done": true, "ms": time.Since(start).Milliseconds(), "site": site, "route": route}) + } + fmt.Fprint(w, "data: [DONE]\n\n") + if flusher != nil { + flusher.Flush() + } +} + +// ── helpers ────────────────────────────────────────────────────── + +func readBody(r *http.Request, max int64) ([]byte, error) { + defer r.Body.Close() + return io.ReadAll(io.LimitReader(r.Body, max)) +} + +func writeJSON(w http.ResponseWriter, code int, v any) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(code) + _ = json.NewEncoder(w).Encode(v) +} + +func fail(w http.ResponseWriter, msg string) { + writeJSON(w, http.StatusBadRequest, map[string]any{"error": msg}) +} + +func firstNonEmpty(vals ...string) string { + for _, v := range vals { + if strings.TrimSpace(v) != "" { + return v + } + } + return "" +} + +// siteCodes resolves a requested site code to a list; an empty request means +// every configured site. +func (s *Server) siteCodes(want string) []string { + s.cfg.RLock() + defer s.cfg.RUnlock() + if want != "" { + return []string{want} + } + out := make([]string, 0, len(s.cfg.Sites)) + for code := range s.cfg.Sites { + out = append(out, code) + } + return out +} + +func randomToken(n int) string { + b := make([]byte, n) + if _, err := rand.Read(b); err != nil { + return fmt.Sprintf("%x", time.Now().UnixNano()) + } + return hex.EncodeToString(b) +} diff --git a/internal/admin/logs.go b/internal/admin/logs.go new file mode 100644 index 0000000..1292f30 --- /dev/null +++ b/internal/admin/logs.go @@ -0,0 +1,101 @@ +package admin + +import ( + "crypto/rand" + "encoding/json" + "fmt" + "net/http" + "strconv" + "time" + + "github.com/nyoungo/dsfree2api/internal/logbuf" +) + +// handleLogs serves the ring buffer either as a one-shot JSON payload or as a +// live SSE stream (?stream=1). +func (s *Server) handleLogs(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + level := q.Get("level") + if level == "" { + level = "INFO" + } + limit := 200 + if v := q.Get("limit"); v != "" { + if n, err := strconv.Atoi(v); err == nil && n > 0 && n <= 500 { + limit = n + } + } + + if q.Get("stream") != "1" { + writeJSON(w, http.StatusOK, map[string]any{"entries": s.logs.Recent(level, limit)}) + return + } + + flusher, ok := w.(http.Flusher) + if !ok { + writeJSON(w, http.StatusInternalServerError, map[string]any{"error": "streaming unsupported"}) + return + } + w.Header().Set("Content-Type", "text/event-stream; charset=utf-8") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("X-Accel-Buffering", "no") + + for _, e := range s.logs.Recent(level, limit) { + writeLog(w, e) + } + flusher.Flush() + + ch, unsub := s.logs.Subscribe() + defer unsub() + minLevel := level + ticker := time.NewTicker(15 * time.Second) + defer ticker.Stop() + + for { + select { + case <-r.Context().Done(): + return + case e, ok := <-ch: + if !ok { + return + } + if !levelOK(e.Level, minLevel) { + continue + } + writeLog(w, e) + flusher.Flush() + case <-ticker.C: + fmt.Fprint(w, ": ping\n\n") + flusher.Flush() + } + } +} + +func writeLog(w http.ResponseWriter, e logbuf.Entry) { + raw, err := json.Marshal(e) + if err != nil { + return + } + fmt.Fprintf(w, "data: %s\n\n", raw) +} + +func levelOK(actual, min string) bool { + return levelVal(actual) >= levelVal(min) +} + +func levelVal(l string) int { + switch l { + case "DEBUG": + return -4 + case "INFO": + return 0 + case "WARN": + return 4 + case "ERROR": + return 8 + default: + return -100 + } +} + +func randRead(b []byte) (int, error) { return rand.Read(b) } diff --git a/internal/admin/server.go b/internal/admin/server.go new file mode 100644 index 0000000..269eaa2 --- /dev/null +++ b/internal/admin/server.go @@ -0,0 +1,278 @@ +package admin + +import ( + "embed" + "encoding/json" + "io/fs" + "log/slog" + "net/http" + "os" + "runtime" + "strings" + "sync" + "time" + + "github.com/nyoungo/dsfree2api/internal/config" + "github.com/nyoungo/dsfree2api/internal/logbuf" + "github.com/nyoungo/dsfree2api/internal/metrics" + "github.com/nyoungo/dsfree2api/internal/turnstile" + "github.com/nyoungo/dsfree2api/internal/upstream" +) + +// Version is reported by the console and /api/overview. +var Version = "0.3.0" + +//go:embed web/* +var webFS embed.FS + +type Server struct { + cfg *config.Config + up *upstream.Client + met *metrics.Recorder + logs *logbuf.Buffer + ts *turnstile.Solver + log *slog.Logger + start time.Time + + mu sync.Mutex + tokens map[string]time.Time + password string +} + +func New(cfg *config.Config, up *upstream.Client, met *metrics.Recorder, logs *logbuf.Buffer, ts *turnstile.Solver, log *slog.Logger) *Server { + if log == nil { + log = slog.Default() + } + pw := cfg.Admin.Password + generated := false + if pw == "" { + pw = randomToken(12) + generated = true + } + s := &Server{ + cfg: cfg, up: up, met: met, logs: logs, ts: ts, log: log, + start: time.Now(), tokens: map[string]time.Time{}, password: pw, + } + if generated { + s.log.Warn("admin password was not configured — generated a one-time password", + "password", pw, "hint", "set admin.password in config.toml or ADMIN_PASSWORD to make it stable") + } + return s +} + +func (s *Server) Handler() http.Handler { + sub, _ := fs.Sub(webFS, "web") + static := http.FileServer(http.FS(sub)) + + mux := http.NewServeMux() + mux.HandleFunc("POST /api/login", s.handleLogin) + mux.HandleFunc("GET /api/session", s.handleSession) + mux.HandleFunc("POST /api/logout", s.handleLogout) + mux.HandleFunc("GET /api/overview", s.authed(s.handleOverview)) + mux.HandleFunc("GET /api/config", s.authed(s.handleGetConfig)) + mux.HandleFunc("PUT /api/config", s.authed(s.handlePutConfig)) + mux.HandleFunc("POST /api/actions", s.authed(s.handleActions)) + mux.HandleFunc("GET /api/logs", s.authed(s.handleLogs)) + mux.HandleFunc("POST /api/playground", s.authed(s.handlePlayground)) + mux.Handle("GET /", static) + return mux +} + +// ── auth ───────────────────────────────────────────────────────── + +func (s *Server) authed(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + tok := r.Header.Get("X-Admin-Token") + if tok == "" { + if c, err := r.Cookie("dsfr_admin"); err == nil { + tok = c.Value + } + } + if tok == "" { + if scheme, value, ok := strings.Cut(r.Header.Get("Authorization"), " "); ok && + strings.EqualFold(strings.TrimSpace(scheme), "bearer") { + tok = strings.TrimSpace(value) + } + } + if !s.validToken(tok) { + writeJSON(w, http.StatusUnauthorized, map[string]any{"error": "unauthorized"}) + return + } + next(w, r) + } +} + +func (s *Server) validToken(tok string) bool { + if tok == "" { + return false + } + s.mu.Lock() + defer s.mu.Unlock() + exp, ok := s.tokens[tok] + if !ok { + return false + } + if time.Now().After(exp) { + delete(s.tokens, tok) + return false + } + return true +} + +func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) { + var body struct { + Password string `json:"password"` + } + _ = json.NewDecoder(r.Body).Decode(&body) + if body.Password != s.password { + time.Sleep(300 * time.Millisecond) + writeJSON(w, http.StatusUnauthorized, map[string]any{"error": "invalid password"}) + return + } + tok := randomToken(24) + s.mu.Lock() + for k, exp := range s.tokens { + if time.Now().After(exp) { + delete(s.tokens, k) + } + } + s.tokens[tok] = time.Now().Add(24 * time.Hour) + s.mu.Unlock() + + http.SetCookie(w, &http.Cookie{ + Name: "dsfr_admin", Value: tok, Path: "/", HttpOnly: true, + SameSite: http.SameSiteStrictMode, MaxAge: 86400, + }) + writeJSON(w, http.StatusOK, map[string]any{"ok": true, "token": tok}) +} + +func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) { + if c, err := r.Cookie("dsfr_admin"); err == nil { + s.mu.Lock() + delete(s.tokens, c.Value) + s.mu.Unlock() + } + http.SetCookie(w, &http.Cookie{Name: "dsfr_admin", Value: "", Path: "/", MaxAge: -1}) + writeJSON(w, http.StatusOK, map[string]any{"ok": true}) +} + +func (s *Server) handleSession(w http.ResponseWriter, r *http.Request) { + tok := "" + if c, err := r.Cookie("dsfr_admin"); err == nil { + tok = c.Value + } + if h := r.Header.Get("X-Admin-Token"); h != "" { + tok = h + } + writeJSON(w, http.StatusOK, map[string]any{"authenticated": s.validToken(tok)}) +} + +// ── overview ───────────────────────────────────────────────────── + +func (s *Server) handleOverview(w http.ResponseWriter, _ *http.Request) { + s.cfg.RLock() + models := make([]map[string]any, 0, len(s.cfg.Models)) + for id, m := range s.cfg.Models { + models = append(models, map[string]any{ + "id": id, "site": m.Site, "label": m.Label, "upstream_id": m.UpstreamID, + "page_path": m.PagePath, "bot_id": m.BotID, "post_id": m.PostID, "enabled": m.Enabled, + }) + } + sites := make([]map[string]any, 0, len(s.cfg.Sites)) + siteCodes := make([]string, 0, len(s.cfg.Sites)) + for code, st := range s.cfg.Sites { + siteCodes = append(siteCodes, code) + sites = append(sites, map[string]any{ + "code": code, "base_url": st.BaseURL, "ajax_url": st.AJAXURL, + "language": st.Language, "sitekey": st.SiteKey, "enabled": st.Enabled, + "verify_action": st.VerifyAction, + }) + } + proxyCfg := struct { + URL string `json:"url"` + Fallbacks []string `json:"fallbacks"` + SlowStart float64 `json:"slow_start"` + MaxConc int `json:"max_concurrent"` + Rate float64 `json:"rate_per_minute"` + CrossSite bool `json:"cross_site_failover"` + }{s.cfg.Proxy.URL, s.cfg.Proxy.FallbackURLs, s.cfg.Proxy.SlowStartSeconds, + s.cfg.Limits.MaxConcurrentPerSite, s.cfg.Limits.RatePerMinute, s.cfg.Upstream.CrossSiteFailover} + turnstileCfg := s.cfg.Turnstile + keys := append([]string(nil), s.cfg.Security.APIKeys...) + s.cfg.RUnlock() + + writeJSON(w, http.StatusOK, map[string]any{ + "metrics": s.met.Snapshot(), + "models": models, + "sites": sites, + "proxy": proxyCfg, + "turnstile": map[string]any{"config": turnstileCfg, "status": s.ts.Status(siteCodes), "history": s.ts.History()}, + "keys": keys, + "cache": map[string]any{"chat_config_entries": s.up.ConfigCacheSize(), "routes": routeList(s.up)}, + "system": map[string]any{ + "version": Version, "go": runtime.Version(), "os": runtime.GOOS, "arch": runtime.GOARCH, + "uptime_s": int(time.Since(s.start).Seconds()), "pid": os.Getpid(), + "config_path": s.cfg.Path(), + }, + }) +} + +func routeList(c *upstream.Client) []map[string]string { + routes := c.Routes() + out := make([]map[string]string, 0, len(routes)) + for _, r := range routes { + label := r.Proxy + if label == "" { + label = "direct" + } + out = append(out, map[string]string{"name": r.Name, "proxy": label}) + } + return out +} + +// ── config get/put ─────────────────────────────────────────────── + +func (s *Server) handleGetConfig(w http.ResponseWriter, _ *http.Request) { + s.cfg.RLock() + defer s.cfg.RUnlock() + writeJSON(w, http.StatusOK, s.cfg) +} + +func (s *Server) handlePutConfig(w http.ResponseWriter, r *http.Request) { + body, err := readBody(r, 1<<20) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]any{"error": err.Error()}) + return + } + s.cfg.Lock() + err = json.Unmarshal(body, s.cfg) + if err == nil { + err = s.cfg.Reindex() + } + s.cfg.Unlock() + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]any{"error": err.Error()}) + return + } + if err := s.cfg.Validate(); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]any{"error": err.Error()}) + return + } + s.applyRuntime() + if err := s.cfg.Save(); err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]any{"error": err.Error()}) + return + } + writeJSON(w, http.StatusOK, map[string]any{"ok": true}) +} + +// applyRuntime pushes a freshly edited config into the live subsystems. +func (s *Server) applyRuntime() { + s.ts.SetConfig(s.currentTurnstile()) +} + +func (s *Server) currentTurnstile() config.Turnstile { + s.cfg.RLock() + defer s.cfg.RUnlock() + return s.cfg.Turnstile +} diff --git a/internal/admin/web/app.js b/internal/admin/web/app.js new file mode 100644 index 0000000..d049e23 --- /dev/null +++ b/internal/admin/web/app.js @@ -0,0 +1,777 @@ +/* dsfree2api web console */ +(function () { +"use strict"; + +const $ = (s, r) => (r || document).querySelector(s); +const $$ = (s, r) => Array.from((r || document).querySelectorAll(s)); +const view = $("#view"); +const TITLES = { + dashboard: "仪表盘", models: "模型与站点", keys: "API Key", proxy: "代理线路", + turnstile: "Turnstile", playground: "对话调试", logs: "实时日志", settings: "设置" +}; + +let overviewTimer = null; +let logStop = null; +let lastOverview = null; + +/* ── helpers ─────────────────────────────────────────────── */ +function toast(msg, kind) { + const t = $("#toast"); + t.textContent = msg; + t.className = "toast " + (kind || ""); + clearTimeout(t._h); + t._h = setTimeout(() => t.classList.add("hidden"), 3200); +} + +async function api(path, opts) { + opts = opts || {}; + const init = { method: opts.method || (opts.body !== undefined ? "POST" : "GET"), headers: {} }; + if (opts.signal) init.signal = opts.signal; + if (opts.body !== undefined) { + init.headers["Content-Type"] = "application/json"; + init.body = typeof opts.body === "string" ? opts.body : JSON.stringify(opts.body); + } + const res = await fetch(path, init); + let data = null; + try { data = await res.json(); } catch (e) { data = null; } + if (res.status === 401 && !path.endsWith("/login")) { + showLogin(); + throw new Error("unauthorized"); + } + if (!res.ok) { + const msg = (data && (data.error || data.detail)) || res.statusText; + throw new Error(typeof msg === "string" ? msg : JSON.stringify(msg)); + } + return data; +} + +function esc(s) { + return String(s == null ? "" : s).replace(/[&<>"']/g, c => + ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c])); +} +const fmtInt = n => (n || 0).toLocaleString("en-US"); +const fmtPct = (a, b) => b ? (a * 100 / b).toFixed(1) + "%" : "0%"; +const fmtMs = ms => ms >= 1000 ? (ms / 1000).toFixed(2) + "s" : Math.round(ms) + "ms"; +const fmtBytes = n => n > 1048576 ? (n / 1048576).toFixed(1) + "MB" : n > 1024 ? (n / 1024).toFixed(1) + "KB" : n + "B"; +const timeStr = t => new Date(t).toLocaleTimeString("zh-CN", { hour12: false }); + +function switchHTML(id, checked) { + return ``; +} + +/* ── auth ────────────────────────────────────────────────── */ +async function ensureSession() { + try { + const s = await api("/api/session"); + if (s.authenticated) { $("#login").classList.add("hidden"); return true; } + } catch (e) { /* ignore */ } + showLogin(); + return false; +} +function showLogin() { + $("#login").classList.remove("hidden"); + setTimeout(() => $("#login-password").focus(), 50); +} +async function doLogin() { + const pw = $("#login-password").value; + if (!pw) return; + try { + await api("/api/login", { method: "POST", body: { password: pw } }); + $("#login-error").textContent = ""; + $("#login").classList.add("hidden"); + $("#login-password").value = ""; + route(); + } catch (e) { + $("#login-error").textContent = e.message === "invalid password" ? "密码错误" : e.message; + } +} + +/* ── router ──────────────────────────────────────────────── */ +function route() { + if (overviewTimer) { clearInterval(overviewTimer); overviewTimer = null; } + if (logStop) { logStop(); logStop = null; } + const hash = location.hash.replace(/^#\/?/, "") || "dashboard"; + const page = (hash.split("?")[0] || "dashboard"); + $$("#nav a").forEach(a => a.classList.toggle("active", a.dataset.page === page)); + $("#page-title").textContent = TITLES[page] || page; + view.innerHTML = '
加载中…
'; + const fn = PAGES[page] || PAGES.dashboard; + fn().catch(e => { view.innerHTML = `
加载失败:${esc(e.message)}
`; }); +} + +/* ── overview poll ───────────────────────────────────────── */ +async function fetchOverview() { + const ov = await api("/api/overview"); + lastOverview = ov; + renderBadges(ov); + return ov; +} +function renderBadges(ov) { + const st = (ov.turnstile.status || []).filter(s => s.valid).length; + const total = (ov.sites || []).filter(s => s.enabled).length; + $("#sys-version").textContent = "v" + (ov.system.version || "?"); + $("#sys-badges").innerHTML = ` + 运行 ${Math.floor(ov.system.uptime_s / 3600)}h${Math.floor(ov.system.uptime_s % 3600 / 60)}m + ${esc(ov.system.go)} · ${esc(ov.system.os)}/${esc(ov.system.arch)} + Turnstile ${st}/${total} + 在线 ${fmtInt(ov.metrics.in_flight)}`; +} + +/* ── pages ───────────────────────────────────────────────── */ +const PAGES = {}; + +PAGES.dashboard = async () => { + view.innerHTML = ` +
+
总请求
–
+
错误 / 空响应
–
+
Token(估算)
–
+
延迟 P50 / P95
–
+
+
+

模型用量

+ + +
模型请求错误Token平均耗时
暂无数据
+

站点状态

+
+

最近请求

+
+
+
+ + +
时间模型站点线路类型耗时Token状态
暂无请求
+
`; + + $("#btn-invalidate").onclick = async () => { await api("/api/actions", { body: { action: "invalidate" } }); toast("已清除 nonce 与 cookie 缓存", "ok"); }; + $("#btn-reset").onclick = async () => { await api("/api/actions", { body: { action: "reset_metrics" } }); toast("统计已清空", "ok"); refresh(); }; + + async function refresh() { + let ov; + try { ov = await fetchOverview(); } catch (e) { return; } + const m = ov.metrics; + $("#s-req").textContent = fmtInt(m.requests); + $("#s-rps").textContent = `${m.rps.toFixed(2)} req/s · 流式 ${fmtInt(m.streams)} · 运行中 ${fmtInt(m.in_flight)}`; + $("#s-err").textContent = fmtInt(m.errors); + $("#s-err").className = "stat-value " + (m.errors ? "err" : "ok"); + $("#s-errpct").textContent = `错误率 ${fmtPct(m.errors, m.requests)} · 空响应 ${fmtInt(m.empty)} · 拒绝 ${fmtInt(m.unauthorized)} · 限流 ${fmtInt(m.rate_limited)}`; + $("#s-tok").textContent = fmtInt(m.prompt_tokens + m.completion_tokens); + $("#s-toksub").textContent = `输入 ${fmtInt(m.prompt_tokens)} · 输出 ${fmtInt(m.completion_tokens)}`; + $("#s-lat").textContent = m.latency.count ? `${fmtMs(m.latency.p50)} / ${fmtMs(m.latency.p95)}` : "–"; + $("#s-latsub").textContent = m.latency.count ? `样本 ${m.latency.count} · 最大 ${fmtMs(m.latency.max)}` : "尚无样本"; + + const rows = Object.entries(m.models || {}); + $("#tb-models").innerHTML = rows.length ? rows.map(([id, v]) => ` + ${esc(id)} + ${fmtInt(v.requests)} + ${fmtInt(v.errors)} + ${fmtInt(v.prompt_tokens + v.completion_tokens)} + ${v.requests ? fmtMs(v.sum_ms / v.requests) : "–"}`).join("") + : '暂无数据'; + + $("#site-cards").innerHTML = (ov.sites || []).map(s => { + const ts = (ov.turnstile.status || []).find(x => x.site === s.code) || {}; + const ms = m.sites && m.sites[s.code]; + const pct = ts.remaining_s > 0 ? Math.max(0, Math.min(100, ts.remaining_s / (ov.turnstile.config.cookie_ttl_seconds || 10800) * 100)) : 0; + return `
+
+
${esc(s.code.toUpperCase())} ${s.enabled ? "启用" : "停用"} +
${esc(s.base_url)}
+
+
+
请求 ${fmtInt(ms ? ms.requests : 0)} · 错误 ${fmtInt(ms ? ms.errors : 0)} · Cookie ${ts.valid ? "有效 " + Math.round(ts.remaining_s / 60) + " 分钟" : "未建立"}
+
`; + }).join("") || '
无站点
'; + + $$("[data-probe]").forEach(b => b.onclick = async () => { + b.disabled = true; b.textContent = "…"; + try { + const r = await api("/api/actions", { body: { action: "probe_site", site: b.dataset.probe } }); + toast(r.ok ? `${b.dataset.probe}: HTTP ${r.status} · ${r.ms}ms · 对话容器 ${r.chat_container ? "有" : "无"}` : `${b.dataset.probe}: ${r.error || "失败"}`, r.ok ? "ok" : "err"); + } catch (e) { toast(e.message, "err"); } + b.disabled = false; b.textContent = "探测"; + }); + + const rec = (m.recent || []).slice(0, 30); + $("#tb-recent").innerHTML = rec.length ? rec.map(r => ` + ${timeStr(r.at)} + ${esc(r.served_by || r.model)} + ${esc(r.site)}${esc(r.route || "–")} + ${r.stream ? "流式" : "同步"} + ${fmtMs(r.duration_ms)} + ${fmtInt((r.prompt_tokens || 0) + (r.completion_tokens || 0))} + ${esc(r.status)}${r.error ? ` ${esc(r.error.slice(0, 60))}` : ""}`).join("") + : '暂无请求'; + } + await refresh(); + overviewTimer = setInterval(refresh, 5000); +}; + +PAGES.models = async () => { + const ov = await fetchOverview(); + view.innerHTML = ` +

模型

+
bot_id / post_id 修改后会自动清除该模型的 nonce 缓存
+
+ +
ID标签站点上游模型路径botpost启用
+

站点

+ +
代码Base URLAJAX URL语言启用
`; + + $("#tb").innerHTML = ov.models.map(m => ` + + ${esc(m.id)} + + ${esc(m.site)}${esc(m.upstream_id)} + + + + ${switchHTML("sw-" + m.id, m.enabled)} + + `).join(""); + + $("#tb-sites").innerHTML = ov.sites.map(s => ` + + ${esc(s.code.toUpperCase())} + + + + ${switchHTML("sws-" + s.code, s.enabled)} +
+ `).join(""); + + $$("[data-save]").forEach(b => b.onclick = async () => { + const tr = b.closest("tr"), id = b.dataset.save; + const g = f => $(`[data-f="${f}"]`, tr).value; + try { + await api("/api/actions", { body: { action: "update_model", id, label: g("label"), page_path: g("page_path"), bot_id: +g("bot_id"), post_id: +g("post_id") } }); + toast("模型已保存", "ok"); + } catch (e) { toast(e.message, "err"); } + }); + $$("[data-savesite]").forEach(b => b.onclick = async () => { + const tr = b.closest("tr"), code = b.dataset.savesite; + const g = f => $(`[data-f="${f}"]`, tr).value; + try { + await api("/api/actions", { body: { action: "update_site", code, base_url: g("base_url"), ajax_url: g("ajax_url"), language: g("language") } }); + toast("站点已保存", "ok"); + } catch (e) { toast(e.message, "err"); } + }); + ov.models.forEach(m => { + $("#sw-" + CSS.escape(m.id)).onchange = async ev => { + try { await api("/api/actions", { body: { action: "toggle_model", id: m.id, enabled: ev.target.checked } }); toast("已更新", "ok"); } + catch (e) { toast(e.message, "err"); ev.target.checked = !ev.target.checked; } + }; + }); + ov.sites.forEach(s => { + $("#sws-" + CSS.escape(s.code)).onchange = async ev => { + try { await api("/api/actions", { body: { action: "toggle_site", id: s.code, enabled: ev.target.checked } }); toast("已更新", "ok"); } + catch (e) { toast(e.message, "err"); ev.target.checked = !ev.target.checked; } + }; + }); +}; + +PAGES.keys = async () => { + const ov = await fetchOverview(); + view.innerHTML = ` +
+

下游 API Key

留空表示不鉴权;请求时使用 Authorization: Bearer <key> 或 X-Api-Key
+
+
+
Key状态
+
修改会立即生效并写入 config.toml(自动备份为 .bak)
`; + const render = keys => { + $("#tb").innerHTML = keys.length ? keys.map(k => ` + ${esc(mask(k))}启用 + + `).join("") + : '未配置任何 Key —— 当前不鉴权'; + $$("[data-copy]").forEach(b => b.onclick = () => { navigator.clipboard && navigator.clipboard.writeText(b.dataset.copy); toast("已复制", "ok"); }); + $$("[data-del]").forEach(b => b.onclick = async () => { + if (!confirm("删除该 Key?")) return; + try { await api("/api/actions", { body: { action: "delete_key", key: b.dataset.del } }); toast("已删除", "ok"); PAGES.keys(); } + catch (e) { toast(e.message, "err"); } + }); + }; + render(ov.keys || []); + $("#btn-gen").onclick = async () => { + try { + const r = await api("/api/actions", { body: { action: "create_key" } }); + if (r.ok) { toast("已生成", "ok"); PAGES.keys(); } + } catch (e) { toast(e.message, "err"); } + }; +}; +const mask = k => k.length > 16 ? k.slice(0, 7) + "…" + k.slice(-6) : k; + +PAGES.proxy = async () => { + const ov = await fetchOverview(); + const p = ov.proxy; + view.innerHTML = ` +
+

主线路

+
+
+ + +
+
支持 http:// · https:// · socks5://
+
+

降级策略

+
+
+
+
+
+
+
${switchHTML("px-cross", p.cross_site_failover)}
+ +
+
主线路在 slow-start 内无首事件,或输出内容前失败时,自动切换到备用线路。
+
+
+

备用线路

+
+
#地址
+
当前生效顺序:${ov.cache.routes.map(r => `${esc(r.name)} → ${esc(r.proxy)}`).join(" ")}
+
`; + + const renderFb = list => { + $("#tb-fb").innerHTML = list.length ? list.map((u, i) => ` + ${i + 1}${esc(u)} + `).join("") + : '无备用线路'; + $$("[data-rm]").forEach(b => b.onclick = async () => { + try { await api("/api/actions", { body: { action: "remove_fallback", url: b.dataset.rm } }); PAGES.proxy(); } + catch (e) { toast(e.message, "err"); } + }); + }; + renderFb(p.fallbacks || []); + + $("#px-save").onclick = async () => { + try { + await api("/api/config", { method: "PUT", body: await merged(c => { c.proxy.url = $("#px-primary").value.trim(); }) }); + toast("主线路已保存", "ok"); PAGES.proxy(); + } catch (e) { toast(e.message, "err"); } + }; + $("#px-test").onclick = async () => { + const b = $("#px-test"); b.disabled = true; b.textContent = "测试中…"; + try { + const r = await api("/api/actions", { body: { action: "test_proxy", url: $("#px-primary").value.trim() } }); + toast(r.ok ? `连通 · HTTP ${r.status} · ${r.ms}ms` : (r.error || "失败"), r.ok ? "ok" : "err"); + } catch (e) { toast(e.message, "err"); } + b.disabled = false; b.textContent = "连通性测试"; + }; + $("#px-savelim").onclick = async () => { + try { + await api("/api/actions", { body: { + action: "set_limits", slow_start: +$("#px-slow").value, + max_concurrent: +$("#px-conc").value, rate_per_minute: +$("#px-rate").value, + cross_site_failover: $("#px-cross").checked } }); + toast("已保存", "ok"); + } catch (e) { toast(e.message, "err"); } + }; + $("#px-add").onclick = async () => { + const v = $("#px-fb").value.trim(); if (!v) return; + try { await api("/api/actions", { body: { action: "add_fallback", url: v } }); $("#px-fb").value = ""; PAGES.proxy(); } + catch (e) { toast(e.message, "err"); } + }; +}; + +async function merged(mutate) { + const cfg = await api("/api/config"); + mutate(cfg); + return cfg; +} + +PAGES.turnstile = async () => { + const ov = await fetchOverview(); + const t = ov.turnstile.config; + view.innerHTML = ` +
+

求解器配置

+
+
+
${switchHTML("ts-en", t.enabled)}
+
+
+
方式 1 · 求解服务 API
+
+
+
+
+
+
+
+ + +
+ +
+

已验证 Cookie

+
+
+ + +
+
缓存 TTL ${t.cookie_ttl_seconds}s · 求解失败重试 ${t.retries} 次 · 退避 ${t.retry_backoff_seconds}s
+
+
+

求解历史

+ +
时间站点线路耗时结果
`; + + $("#ts-status").innerHTML = (ov.turnstile.status || []).map(s => ` +
+
${esc(s.site.toUpperCase())} ${s.valid ? "有效" : "未建立"}
+
+
${s.valid ? "剩余 " + Math.round(s.remaining_s / 60) + " 分钟 · " + s.cookie_count + " 个 Cookie" : "下次请求时自动求解"}
+
`).join(""); + + const hist = ov.turnstile.history || []; + $("#ts-hist").innerHTML = hist.length ? hist.slice().reverse().slice(0, 40).map(h => ` + ${timeStr(h.at)}${esc(h.site)}${esc(h.proxy)} + ${fmtMs(h.elapsed_ms)} + ${h.ok ? "成功" : "失败"}${h.error ? ` ${esc(h.error.slice(0, 70))}` : ""}`).join("") + : '尚无记录'; + + $$("[data-rf]").forEach(b => b.onclick = async () => { + b.disabled = true; + try { await api("/api/actions", { body: { action: "refresh_cookies", site: b.dataset.rf } }); toast("已作废,将在下次请求时重新求解", "ok"); PAGES.turnstile(); } + catch (e) { toast(e.message, "err"); } + b.disabled = false; + }); + $("#ts-provider").value = t.provider || "api"; + const TS_HINTS = { + api: ["已开启:调用求解服务 API 获取 Turnstile Token", "已关闭:跳过 Turnstile 求解,直接请求上游站点"], + browser: ["已开启:用下方配置的本地浏览器自动求解 Token", "已关闭:跳过 Turnstile 求解,直接请求上游站点"], + manual: ["手动方式:不自动求解,导入已通过验证的 Cookie 即可(TTL 内免求解)", "已关闭:跳过 Turnstile 求解,直接请求上游站点"], + }; + const syncProvider = () => { + const p = $("#ts-provider").value; + $("#ts-api").style.display = p === "api" ? "" : "none"; + $("#ts-browser").style.display = p === "browser" ? "" : "none"; + $("#ts-manual").style.display = p === "manual" ? "" : "none"; + $("#ts-refresh").style.display = p === "manual" ? "none" : ""; + const hints = TS_HINTS[p] || TS_HINTS.api; + $("#ts-hint").textContent = $("#ts-en").checked ? hints[0] : hints[1]; + }; + $("#ts-provider").onchange = syncProvider; + $("#ts-en").onchange = syncProvider; + syncProvider(); + $("#ts-save").onclick = async () => { + try { + await api("/api/actions", { body: { + action: "set_turnstile", enabled: $("#ts-en").checked, + provider: $("#ts-provider").value, + api_url: $("#ts-url").value.trim(), + api_key: $("#ts-key").value.trim(), sitekey: $("#ts-sitekey").value.trim(), + challenge_action: $("#ts-action").value.trim(), cookie_ttl_seconds: +$("#ts-ttl").value, + browser_path: $("#ts-bpath").value.trim(), + browser_headless: $("#ts-bhead").checked, + browser_user_data_dir: $("#ts-bprof").value.trim(), + browser_timezone: $("#ts-btz").value.trim(), + browser_locale: $("#ts-bloc").value.trim() } }); + toast("已保存", "ok"); PAGES.turnstile(); + } catch (e) { toast(e.message, "err"); } + }; + $("#ts-refresh").onclick = async () => { + try { await api("/api/actions", { body: { action: "invalidate" } }); toast("已作废全部 cookie", "ok"); PAGES.turnstile(); } + catch (e) { toast(e.message, "err"); } + }; + $("#ts-clear").onclick = async () => { + try { await api("/api/actions", { body: { action: "clear_cookies" } }); toast("已清空 Cookie", "ok"); PAGES.turnstile(); } + catch (e) { toast(e.message, "err"); } + }; + $("#im-save").onclick = async () => { + try { + const r = await api("/api/actions", { body: { + action: "import_cookies", site: $("#im-site").value, cookies: $("#im-cookies").value } }); + toast(`已导入 ${r.cookie_count} 个 Cookie,${timeStr(r.expires_at * 1000)} 到期`, "ok"); + PAGES.turnstile(); + } catch (e) { toast(e.message, "err"); } + }; +}; + +PAGES.playground = async () => { + const ov = await fetchOverview(); + const enabled = ov.models.filter(m => m.enabled); + view.innerHTML = ` +
+

请求

+
+
+
${switchHTML("pg-stream", true)}
+
+
+
+
+
+ + +
+
+

响应

尚未发送请求
+
`; + + let ac = null, timer = null; + const stopTimer = () => { if (timer) { clearInterval(timer); timer = null; } }; + + $("#pg-clear").onclick = () => { stopTimer(); ac = null; $("#pg-out").innerHTML = '尚未发送请求'; $("#pg-meta").textContent = ""; }; + $("#pg-cancel").onclick = () => { if (ac) ac.abort(); }; + $("#pg-send").onclick = async () => { + const input = $("#pg-input").value.trim(); + if (!input) { toast("请输入内容", "err"); return; } + let tools = null; + const raw = $("#pg-tools").value.trim(); + if (raw) { try { tools = JSON.parse(raw); } catch (e) { toast("Tools JSON 解析失败", "err"); return; } } + + const messages = [{ role: "system", content: $("#pg-system").value }, { role: "user", content: input }]; + const model = $("#pg-model").value; + const stream = $("#pg-stream").checked; + const out = $("#pg-out"); + out.innerHTML = ""; + + const status = document.createElement("span"); + status.className = "dim"; + status.textContent = "连接中… 0.0s"; + const caret = document.createElement("span"); caret.className = "caret"; + out.appendChild(status); out.appendChild(caret); + + ac = new AbortController(); + const btn = $("#pg-send"), cancel = $("#pg-cancel"); + btn.disabled = true; btn.textContent = "请求中…"; + cancel.disabled = false; + $("#pg-meta").textContent = ""; + const t0 = Date.now(); + const tick = () => { status.textContent = `连接中… ${((Date.now() - t0) / 1000).toFixed(1)}s`; }; + stopTimer(); timer = setInterval(tick, 100); + + const finish = () => { + stopTimer(); ac = null; + btn.disabled = false; btn.textContent = "发送"; + cancel.disabled = true; + if (!$("#pg-meta").textContent) $("#pg-meta").textContent = `${fmtMs(Date.now() - t0)}`; + }; + const firstText = () => { if (status.parentNode) status.remove(); }; + + try { + if (stream) { + const res = await fetch("/api/playground", { method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ model, messages, stream: true, tools }), signal: ac.signal }); + if (!res.ok) { const j = await res.json().catch(() => ({})); throw new Error(j.error || res.statusText); } + out.appendChild(caret); + const reader = res.body.getReader(), dec = new TextDecoder(); + let buf = "", first = null; + for (;;) { + const { done, value } = await reader.read(); if (done) break; + buf += dec.decode(value, { stream: true }); + let idx; + while ((idx = buf.indexOf("\n\n")) >= 0) { + const frame = buf.slice(0, idx); buf = buf.slice(idx + 2); + if (!frame.startsWith("data: ")) continue; + const payload = frame.slice(6); + if (payload === "[DONE]") continue; + let j; try { j = JSON.parse(payload); } catch (e) { continue; } + if (j.error) { firstText(); out.insertBefore(document.createTextNode("\n[错误] " + j.error), caret); continue; } + if (j.delta) { if (!first) first = Date.now(); firstText(); out.insertBefore(document.createTextNode(j.delta), caret); } + if (j.done) { $("#pg-meta").textContent = `${j.ms}ms · ${j.site} · ${j.route}`; } + } + } + caret.remove(); + } else { + const j = await api("/api/playground", { body: { model, messages, stream: false, tools }, signal: ac.signal }); + out.textContent = j.text; + $("#pg-meta").textContent = `${j.ms}ms · ${j.site} · ${j.route} · prompt ${j.prompt_chars} 字符`; + } + } catch (e) { + caret.remove(); + if (e && e.name === "AbortError") { + out.innerHTML = '已取消'; + } else { + out.innerHTML = `请求失败:${esc(e.message)}`; + } + } + finish(); + }; +}; + +PAGES.logs = async () => { + view.innerHTML = ` +
+
+
+
+ + +
+
实时连接中…
+
+
`; + + const box = $("#lg-box"); + let paused = false, es = null, gen = 0; + const minVal = { DEBUG: -4, INFO: 0, WARN: 4, ERROR: 8 }; + + function append(e) { + if (paused) return; + const sel = $("#lg-level"); + if (sel && minVal[e.level] < minVal[sel.value]) return; + const line = document.createElement("div"); + line.className = "log-line"; + const attrs = e.attrs ? Object.entries(e.attrs).map(([k, v]) => `${k}=${v}`).join(" ") : ""; + line.innerHTML = `${timeStr(e.ts)} ${e.level} ${esc(e.msg)}${attrs ? ' ' + esc(attrs) + "" : ""}`; + box.appendChild(line); + while (box.children.length > 600) box.removeChild(box.firstChild); + box.scrollTop = box.scrollHeight; + } + + function stopES() { if (es) { es.close(); es = null; } } + + // (Re)load history and reopen the SSE stream with the currently selected + // minimum level. Never re-render the page here: rebuilding the select would + // reset it to INFO before the value is read, and would leak the old stream. + // History comes solely from the server replay on connect (and on every + // automatic reconnect), so the box is cleared in onopen instead of fetching + // the JSON endpoint separately — otherwise entries show up twice. + async function load() { + const my = ++gen; + const level = $("#lg-level").value; + stopES(); + box.innerHTML = ""; + es = new EventSource("/api/logs?stream=1&level=" + level); + es.onmessage = ev => { if (my !== gen) return; try { append(JSON.parse(ev.data)); } catch (e) {} }; + es.onerror = () => { if (my !== gen) return; const st = $("#lg-state"); if (st) st.textContent = "连接中断,自动重连中…"; }; + es.onopen = () => { + if (my !== gen) return; + box.innerHTML = ""; + const st = $("#lg-state"); if (st) st.textContent = "实时连接中…"; + }; + logStop = () => { gen++; stopES(); }; + } + + $("#lg-toggle").onclick = e => { paused = !paused; e.target.textContent = paused ? "继续" : "暂停"; }; + $("#lg-clear").onclick = () => { box.innerHTML = ""; }; + $("#lg-level").onchange = load; + await load(); +}; + +PAGES.settings = async () => { + const cfg = await api("/api/config"); + view.innerHTML = ` +
+

服务

+
+
+
+
+
+
+
+
+
+
+ +
+

上游

+
+
+
+
+
+
+
+
+
+
+
${switchHTML("st-ar", cfg.upstream.auto_refresh)}
+
+
+
+ +
+
+

危险操作

+
+ + +
+
配置保存会自动备份原文件为 config.toml.bak;修改端口/密码需重启服务生效。
+
`; + + const put = async mutate => { + try { + const c = await api("/api/config"); + mutate(c); + await api("/api/config", { method: "PUT", body: c }); + toast("已保存并写入 config.toml", "ok"); + return true; + } catch (e) { toast(e.message, "err"); return false; } + }; + $("#st-save").onclick = () => put(c => { + c.server.host = $("#st-host").value; + c.server.port = +$("#st-port").value; + c.server.log_level = $("#st-log").value; + c.admin.host = $("#st-ah").value; + c.admin.port = +$("#st-ap").value; + if ($("#st-pw").value) c.admin.password = $("#st-pw").value; + }); + $("#st-save2").onclick = () => put(c => { + c.upstream.timeout = +$("#st-to").value; + c.upstream.stream_timeout = +$("#st-sto").value; + c.upstream.config_ttl_seconds = +$("#st-ttl").value; + c.upstream.refresh_retries = +$("#st-retry").value; + c.upstream.retry_backoff_seconds = +$("#st-back").value; + c.upstream.auto_refresh = $("#st-ar").checked; + c.upstream.user_agent = $("#st-ua").value; + c.runtime.data_dir = $("#st-dir").value; + }); + $("#st-reset").onclick = async () => { try { await api("/api/actions", { body: { action: "reset_metrics" } }); toast("已清空", "ok"); } catch (e) { toast(e.message, "err"); } }; + $("#st-inv").onclick = async () => { try { await api("/api/actions", { body: { action: "invalidate" } }); toast("已作废", "ok"); } catch (e) { toast(e.message, "err"); } }; +}; + +/* ── boot ────────────────────────────────────────────────── */ +window.addEventListener("hashchange", route); +$("#login-btn").onclick = doLogin; +$("#login-password").addEventListener("keydown", e => { if (e.key === "Enter") doLogin(); }); +$("#logout-btn").onclick = async () => { try { await api("/api/logout", { method: "POST" }); } catch (e) {} location.hash = "#/dashboard"; showLogin(); }; + +(async function init() { + if (await ensureSession()) route(); +})(); +})(); diff --git a/internal/admin/web/index.html b/internal/admin/web/index.html new file mode 100644 index 0000000..e3c34cc --- /dev/null +++ b/internal/admin/web/index.html @@ -0,0 +1,58 @@ + + + + + +dsfree2api 控制台 + + + + + + + +
+ + +
+
+
仪表盘
+
+
+
+
+
+ + + + + + diff --git a/internal/admin/web/style.css b/internal/admin/web/style.css new file mode 100644 index 0000000..6b368ff --- /dev/null +++ b/internal/admin/web/style.css @@ -0,0 +1,125 @@ +:root{ + --bg:#0d1117;--bg2:#11161d;--panel:#161b22;--panel2:#1c2230; + --line:#262c36;--text:#e6edf3;--muted:#8b949e;--dim:#6e7681; + --accent:#4c8dff;--accent2:#2f6feb;--ok:#3fb950;--warn:#d29922;--err:#f85149; + --mono:ui-monospace,SFMono-Regular,Menlo,Consolas,"Liberation Mono",monospace; +} +*{box-sizing:border-box} +html,body{height:100%} +body{margin:0;background:var(--bg);color:var(--text);font:14px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Microsoft YaHei",sans-serif} +code,pre,.mono{font-family:var(--mono)} +a{color:inherit;text-decoration:none} +button{font:inherit} +.hidden{display:none!important} + +/* layout */ +.app{display:flex;min-height:100vh} +.sidebar{width:216px;flex:0 0 216px;background:var(--bg2);border-right:1px solid var(--line);display:flex;flex-direction:column;position:sticky;top:0;height:100vh} +.brand{display:flex;align-items:center;gap:8px;padding:18px 16px;font-weight:700;font-size:16px;letter-spacing:.3px} +.brand-mark{filter:grayscale(.2)} +nav{display:flex;flex-direction:column;padding:6px 8px;gap:2px;flex:1} +nav a{padding:9px 12px;border-radius:8px;color:var(--muted);transition:.12s} +nav a:hover{background:#1b2230;color:var(--text)} +nav a.active{background:linear-gradient(180deg,var(--accent2),#1f4fbb);color:#fff;box-shadow:0 2px 10px rgba(47,111,235,.35)} +.sidebar-foot{padding:12px;border-top:1px solid var(--line);color:var(--dim);font-size:12px;display:flex;flex-direction:column;gap:8px} +main{flex:1;min-width:0;display:flex;flex-direction:column} +.topbar{display:flex;align-items:center;justify-content:space-between;padding:14px 22px;border-bottom:1px solid var(--line);background:rgba(13,17,23,.85);backdrop-filter:blur(8px);position:sticky;top:0;z-index:5} +.page-title{font-size:17px;font-weight:650} +.badges{display:flex;gap:8px;flex-wrap:wrap} +.badge{font-size:11px;padding:3px 9px;border-radius:20px;background:var(--panel);border:1px solid var(--line);color:var(--muted)} +.badge.ok{color:var(--ok);border-color:#1f6a34;background:#0f2318} +.badge.err{color:var(--err);border-color:#6e2220;background:#2a1315} +.badge.warn{color:var(--warn);border-color:#6b5417;background:#2a2210} +.view{padding:22px;display:flex;flex-direction:column;gap:18px;max-width:1400px;width:100%} + +/* cards */ +.grid{display:grid;gap:14px} +.g4{grid-template-columns:repeat(auto-fit,minmax(190px,1fr))} +.g2{grid-template-columns:repeat(auto-fit,minmax(340px,1fr))} +.card{background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:16px} +.card h3{margin:0 0 12px;font-size:13px;color:var(--muted);font-weight:600;text-transform:uppercase;letter-spacing:.6px} +.stat-label{font-size:12px;color:var(--muted);margin-bottom:6px} +.stat-value{font-size:26px;font-weight:700;letter-spacing:-.5px} +.stat-sub{font-size:11px;color:var(--dim);margin-top:4px} +.stat-value.ok{color:var(--ok)}.stat-value.err{color:var(--err)}.stat-value.warn{color:var(--warn)} + +/* turnstile mode panel */ +.ts-panel{background:var(--panel2);border:1px solid var(--line);border-radius:10px;padding:13px 14px;margin-top:12px} +.ts-panel-title{font-size:12px;font-weight:650;color:var(--muted);margin-bottom:10px;letter-spacing:.3px} + +/* table */ +table{width:100%;border-collapse:collapse;font-size:13px} +th{text-align:left;color:var(--muted);font-weight:600;padding:8px 10px;border-bottom:1px solid var(--line);font-size:12px} +td{padding:9px 10px;border-bottom:1px solid #1c222b;vertical-align:middle} +tr:last-child td{border-bottom:none} +tbody tr:hover{background:#191f29} +td.num,th.num{text-align:right;font-family:var(--mono)} +.table-wrap{overflow-x:auto} + +/* controls */ +.btn{border:1px solid var(--line);background:var(--panel2);color:var(--text);padding:7px 13px;border-radius:8px;cursor:pointer;transition:.12s;white-space:nowrap} +.btn:hover{border-color:#3a4454;background:#232b3a} +.btn.primary{background:var(--accent2);border-color:#3a72e0;color:#fff} +.btn.primary:hover{background:#3a78f0} +.btn.danger{background:#3b1519;border-color:#7c2a2e;color:#ff8f92} +.btn.ghost{background:transparent} +.btn.small{padding:4px 9px;font-size:12px;border-radius:6px} +.btn.block{width:100%} +.btn:disabled{opacity:.5;cursor:not-allowed} +input,select,textarea{background:#0e131a;border:1px solid var(--line);color:var(--text);padding:8px 10px;border-radius:8px;font:inherit;width:100%;outline:none} +input:focus,select:focus,textarea:focus{border-color:var(--accent);box-shadow:0 0 0 3px rgba(76,141,255,.15)} +textarea{resize:vertical;min-height:96px;font-family:var(--mono);font-size:13px} +label{display:block;font-size:12px;color:var(--muted);margin-bottom:5px} +.field{margin-bottom:12px} +.row{display:flex;gap:10px;align-items:flex-end;flex-wrap:wrap} +.row>.field{flex:1;min-width:150px;margin-bottom:0} +.toolbar{display:flex;gap:10px;align-items:center;flex-wrap:wrap;justify-content:space-between} +.switch{position:relative;display:inline-block;width:38px;height:21px;flex:0 0 38px} +.switch input{opacity:0;width:0;height:0} +.slider{position:absolute;inset:0;background:#2a3140;border-radius:20px;transition:.15s;cursor:pointer} +.slider:before{content:"";position:absolute;width:15px;height:15px;left:3px;top:3px;background:#8b949e;border-radius:50%;transition:.15s} +.switch input:checked+.slider{background:var(--accent2)} +.switch input:checked+.slider:before{transform:translateX(17px);background:#fff} +.pill{display:inline-block;padding:2px 8px;border-radius:20px;font-size:11px;border:1px solid var(--line);color:var(--muted)} +.pill.on{color:var(--ok);border-color:#1f6a34;background:#0f2318} +.pill.off{color:var(--dim)} + +/* console / logs */ +.log-box{background:#0a0e14;border:1px solid var(--line);border-radius:10px;padding:10px;height:56vh;overflow:auto;font-family:var(--mono);font-size:12.5px;line-height:1.55} +.log-line{white-space:pre-wrap;word-break:break-word;padding:1px 0} +.log-line .t{color:var(--dim)} +.log-line .lv{font-weight:700} +.lv-DEBUG{color:#7d8590}.lv-INFO{color:#58a6ff}.lv-WARN{color:#d29922}.lv-ERROR{color:#f85149} +.out-box{background:#0a0e14;border:1px solid var(--line);border-radius:10px;padding:14px;min-height:220px;white-space:pre-wrap;word-break:break-word;font-family:var(--mono);font-size:13px} +.caret{display:inline-block;width:7px;height:15px;background:var(--accent);animation:blink 1s steps(2) infinite;vertical-align:-2px} +@keyframes blink{50%{opacity:0}} + +/* misc */ +.section-title{font-size:14px;font-weight:650;margin:4px 0 2px;display:flex;justify-content:space-between;align-items:center} +.muted{color:var(--muted)} +.dim{color:var(--dim);font-size:12px} +.right{margin-left:auto} +.mt{margin-top:12px} +.tag{font-family:var(--mono);font-size:12px;background:#1b2230;border:1px solid var(--line);padding:2px 7px;border-radius:6px} +.empty{padding:34px;text-align:center;color:var(--dim)} +.bar{height:6px;background:#1b2230;border-radius:6px;overflow:hidden;margin-top:8px} +.bar>i{display:block;height:100%;background:linear-gradient(90deg,var(--accent2),#63a4ff)} +.toast{position:fixed;right:20px;bottom:20px;background:#1b2432;border:1px solid #33405a;color:var(--text);padding:11px 16px;border-radius:10px;z-index:50;box-shadow:0 8px 30px rgba(0,0,0,.5);max-width:420px} +.toast.err{border-color:#7c2a2e;background:#2a1315;color:#ffb3b6} +.toast.ok{border-color:#1f6a34;background:#0f2318;color:#9be8ac} + +/* login */ +.overlay{position:fixed;inset:0;background:rgba(5,8,12,.9);display:flex;align-items:center;justify-content:center;z-index:100;backdrop-filter:blur(6px)} +.login-card{background:var(--panel);border:1px solid var(--line);border-radius:16px;padding:32px;width:360px;box-shadow:0 20px 70px rgba(0,0,0,.6)} +.login-brand{font-size:22px;font-weight:750;letter-spacing:-.4px} +.login-sub{color:var(--muted);margin-bottom:22px;font-size:13px} +.login-card input{margin-bottom:12px} +.login-error{color:var(--err);font-size:12.5px;min-height:18px;margin-top:8px} +.login-hint{color:var(--dim);font-size:11.5px;margin-top:16px;line-height:1.6} +.login-hint code{color:var(--muted);background:#0e131a;padding:1px 5px;border-radius:4px} + +@media(max-width:860px){ + .sidebar{position:fixed;z-index:20;transform:translateX(-100%);transition:.2s} + .app{padding-bottom:60px} + .view{padding:14px} +} diff --git a/internal/anthropic/convert.go b/internal/anthropic/convert.go new file mode 100644 index 0000000..4404e5f --- /dev/null +++ b/internal/anthropic/convert.go @@ -0,0 +1,213 @@ +package anthropic + +import ( + "bytes" + "crypto/rand" + "encoding/hex" + "encoding/json" + "strings" + + "github.com/nyoungo/dsfree2api/internal/openai" +) + +// ToOpenAI converts an Anthropic Messages request into OpenAI-style chat +// messages, function tools and a tool_choice value usable by BuildPrompt. +func ToOpenAI(req *Request) ([]openai.ChatMessage, []openai.ToolDef, json.RawMessage, error) { + var out []openai.ChatMessage + if len(req.System) > 0 { + if s := systemText(req.System); s != "" { + out = append(out, openai.ChatMessage{Role: "system", Content: openai.MessageContent{Text: s}}) + } + } + for _, m := range req.Messages { + role := m.Role + if role == "" { + role = "user" + } + if len(m.Content.Blocks) == 0 { + out = append(out, openai.ChatMessage{Role: role, Content: openai.MessageContent{Text: m.Content.Text}}) + continue + } + if role == "assistant" { + text, calls := splitAssistant(m.Content.Blocks) + out = append(out, openai.ChatMessage{ + Role: "assistant", Content: openai.MessageContent{Text: text}, ToolCalls: calls, + }) + continue + } + // user (or unknown): tool_result blocks become tool messages, text + // runs become user messages, preserving block order. + var run []string + flush := func() { + if len(run) > 0 { + out = append(out, openai.ChatMessage{Role: "user", Content: openai.MessageContent{Text: strings.Join(run, "\n")}}) + run = nil + } + } + for _, b := range m.Content.Blocks { + switch b.Type { + case "text": + if b.Text != "" { + run = append(run, b.Text) + } + case "tool_result": + flush() + out = append(out, openai.ChatMessage{ + Role: "tool", + ToolCallID: b.ToolUseID, + Content: openai.MessageContent{Text: rawToText(b.Content)}, + }) + default: + // image / thinking / unknown block types are not representable + // upstream as plain text — dropped. + } + } + flush() + } + tools := make([]openai.ToolDef, 0, len(req.Tools)) + for _, t := range req.Tools { + if t.Name == "" { + continue + } + schema := t.InputSchema + if len(schema) == 0 { + schema = json.RawMessage(`{"type":"object","properties":{}}`) + } + tools = append(tools, openai.ToolDef{ + Type: "function", + Function: openai.Function{Name: t.Name, Description: t.Description, Parameters: schema}, + }) + } + var tc json.RawMessage + if req.ToolChoice != nil { + tc = toolChoiceToOpenAI(req.ToolChoice) + } + return out, tools, tc, nil +} + +// ContentFrom builds response content blocks from plain prose plus tool calls +// parsed out of the model output. +func ContentFrom(text string, calls []openai.ToolCall) []Block { + blocks := make([]Block, 0, len(calls)+1) + if text != "" { + blocks = append(blocks, Block{Type: "text", Text: text}) + } + for _, c := range calls { + id := c.ID + if id == "" { + id = NewID("toolu_") + } + input := json.RawMessage("{}") + if s := strings.TrimSpace(c.Function.Arguments); s != "" && json.Valid([]byte(s)) { + input = json.RawMessage(s) + } + blocks = append(blocks, Block{Type: "tool_use", ID: id, Name: c.Function.Name, Input: input}) + } + return blocks +} + +// StopReasonFor maps the presence of tool calls onto Anthropic stop reasons. +func StopReasonFor(calls []openai.ToolCall) string { + if len(calls) > 0 { + return "tool_use" + } + return "end_turn" +} + +// NewID returns a random hex id with the given prefix (e.g. "toolu_"). +func NewID(prefix string) string { + b := make([]byte, 12) + _, _ = rand.Read(b) + return prefix + hex.EncodeToString(b) +} + +func splitAssistant(blocks []Block) (string, []openai.ToolCall) { + var texts []string + var calls []openai.ToolCall + for _, b := range blocks { + switch b.Type { + case "text": + if b.Text != "" { + texts = append(texts, b.Text) + } + case "tool_use": + args := "{}" + if len(b.Input) > 0 && string(b.Input) != "null" { + args = string(b.Input) + } + id := b.ID + if id == "" { + id = NewID("toolu_") + } + calls = append(calls, openai.ToolCall{ + ID: id, Type: "function", + Function: openai.FunctionCall{Name: b.Name, Arguments: args}, + }) + } + } + return strings.Join(texts, "\n"), calls +} + +// systemText accepts either a plain string or an array of text blocks. +func systemText(raw json.RawMessage) string { + raw = trimSpace(raw) + if len(raw) == 0 || string(raw) == "null" { + return "" + } + if raw[0] == '"' { + var s string + if json.Unmarshal(raw, &s) == nil { + return s + } + return "" + } + return rawToText(raw) +} + +// rawToText flattens a string or an array of text blocks into plain text. +func rawToText(raw json.RawMessage) string { + raw = trimSpace(raw) + if len(raw) == 0 || string(raw) == "null" { + return "" + } + if raw[0] == '"' { + var s string + if json.Unmarshal(raw, &s) == nil { + return s + } + return "" + } + if raw[0] == '[' { + var blocks []Block + if json.Unmarshal(raw, &blocks) == nil { + var parts []string + for _, b := range blocks { + if b.Type == "text" && b.Text != "" { + parts = append(parts, b.Text) + } + } + return strings.Join(parts, "\n") + } + return "" + } + return string(raw) +} + +func toolChoiceToOpenAI(tc *ToolChoice) json.RawMessage { + switch tc.Type { + case "tool": + if tc.Name == "" { + return nil + } + raw, _ := json.Marshal(map[string]any{"type": "function", "function": map[string]string{"name": tc.Name}}) + return raw + case "any": + return json.RawMessage(`"required"`) + case "none": + return json.RawMessage(`"none"`) + default: + return json.RawMessage(`"auto"`) + } +} + +func trimSpace(b []byte) []byte { return bytes.TrimSpace(b) } diff --git a/internal/anthropic/convert_test.go b/internal/anthropic/convert_test.go new file mode 100644 index 0000000..039c708 --- /dev/null +++ b/internal/anthropic/convert_test.go @@ -0,0 +1,157 @@ +package anthropic + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/nyoungo/dsfree2api/internal/openai" +) + +func TestToOpenAIFullRequest(t *testing.T) { + var req Request + body := `{ + "model": "deepseek-v4-flash-de", + "max_tokens": 1024, + "system": "Be terse.", + "messages": [ + {"role": "user", "content": "What is the weather in Beijing?"}, + {"role": "assistant", "content": [ + {"type": "text", "text": "Let me check."}, + {"type": "tool_use", "id": "toolu_1", "name": "get_weather", + "input": {"city": "Beijing"}} + ]}, + {"role": "user", "content": [ + {"type": "tool_result", "tool_use_id": "toolu_1", "content": "Sunny, 25C"} + ]} + ], + "tools": [ + {"name": "get_weather", "description": "Get weather", + "input_schema": {"type": "object", "properties": {"city": {"type": "string"}}}} + ], + "tool_choice": {"type": "tool", "name": "get_weather"} + }` + if err := json.Unmarshal([]byte(body), &req); err != nil { + t.Fatalf("decode: %v", err) + } + msgs, tools, tc, err := ToOpenAI(&req) + if err != nil { + t.Fatalf("to_openai: %v", err) + } + if len(msgs) != 4 { + t.Fatalf("len(msgs) = %d, want 4: %+v", len(msgs), msgs) + } + roles := []string{msgs[0].Role, msgs[1].Role, msgs[2].Role, msgs[3].Role} + want := []string{"system", "user", "assistant", "tool"} + for i, r := range want { + if roles[i] != r { + t.Errorf("msgs[%d].Role = %q, want %q", i, roles[i], r) + } + } + if msgs[0].Content.Text != "Be terse." { + t.Errorf("system = %q", msgs[0].Content.Text) + } + asst := msgs[2] + if len(asst.ToolCalls) != 1 || asst.ToolCalls[0].Function.Name != "get_weather" { + t.Fatalf("assistant tool_calls = %+v", asst.ToolCalls) + } + if asst.ToolCalls[0].ID != "toolu_1" { + t.Errorf("tool call id = %q", asst.ToolCalls[0].ID) + } + if !strings.Contains(asst.ToolCalls[0].Function.Arguments, `"city"`) || + !strings.Contains(asst.ToolCalls[0].Function.Arguments, "Beijing") { + t.Errorf("arguments = %q", asst.ToolCalls[0].Function.Arguments) + } + if msgs[3].ToolCallID != "toolu_1" || msgs[3].Content.Text != "Sunny, 25C" { + t.Errorf("tool result = %+v", msgs[3]) + } + if len(tools) != 1 || tools[0].Function.Name != "get_weather" { + t.Fatalf("tools = %+v", tools) + } + if !strings.Contains(string(tools[0].Function.Parameters), "city") { + t.Errorf("parameters = %s", tools[0].Function.Parameters) + } + var forced map[string]any + if err := json.Unmarshal(tc, &forced); err != nil { + t.Fatalf("tool_choice: %v", err) + } + if forced["type"] != "function" { + t.Errorf("tool_choice = %s", tc) + } +} + +func TestToOpenAIStringContentAndSystemArray(t *testing.T) { + var req Request + body := `{ + "model": "m", + "system": [{"type": "text", "text": "Rule A"}, {"type": "text", "text": "Rule B"}], + "messages": [{"role": "user", "content": "Hi"}] + }` + if err := json.Unmarshal([]byte(body), &req); err != nil { + t.Fatalf("decode: %v", err) + } + msgs, _, _, err := ToOpenAI(&req) + if err != nil { + t.Fatalf("to_openai: %v", err) + } + if len(msgs) != 2 { + t.Fatalf("len(msgs) = %d, want 2", len(msgs)) + } + if !strings.Contains(msgs[0].Content.Text, "Rule A") || !strings.Contains(msgs[0].Content.Text, "Rule B") { + t.Errorf("system = %q", msgs[0].Content.Text) + } + if msgs[1].Role != "user" || msgs[1].Content.Text != "Hi" { + t.Errorf("user = %+v", msgs[1]) + } +} + +func TestToOpenAIToolChoiceVariants(t *testing.T) { + for _, tc := range []struct { + in string + want string + }{ + {`{"type":"auto"}`, `"auto"`}, + {`{"type":"any"}`, `"required"`}, + {`{"type":"none"}`, `"none"`}, + } { + choice := &ToolChoice{} + if err := json.Unmarshal([]byte(tc.in), choice); err != nil { + t.Fatalf("decode %s: %v", tc.in, err) + } + raw := toolChoiceToOpenAI(choice) + if string(raw) != tc.want { + t.Errorf("%s -> %s, want %s", tc.in, raw, tc.want) + } + } +} + +func TestContentFrom(t *testing.T) { + calls := []openai.ToolCall{{ + ID: "call_1", Type: "function", + Function: openai.FunctionCall{Name: "get_weather", Arguments: `{"city":"Paris"}`}, + }} + blocks := ContentFrom("Checking now.", calls) + if len(blocks) != 2 || blocks[0].Type != "text" || blocks[1].Type != "tool_use" { + t.Fatalf("blocks = %+v", blocks) + } + if blocks[1].ID != "call_1" || blocks[1].Name != "get_weather" { + t.Errorf("tool_use = %+v", blocks[1]) + } + if string(blocks[1].Input) != `{"city":"Paris"}` { + t.Errorf("input = %s", blocks[1].Input) + } + if StopReasonFor(calls) != "tool_use" || StopReasonFor(nil) != "end_turn" { + t.Errorf("stop reasons wrong") + } +} + +func TestContentFromInvalidArgumentsFallBackToEmptyObject(t *testing.T) { + calls := []openai.ToolCall{{ + ID: "c", Type: "function", + Function: openai.FunctionCall{Name: "x", Arguments: `not json`}, + }} + blocks := ContentFrom("", calls) + if len(blocks) != 1 || string(blocks[0].Input) != "{}" { + t.Fatalf("blocks = %+v", blocks) + } +} diff --git a/internal/anthropic/types.go b/internal/anthropic/types.go new file mode 100644 index 0000000..b23da3f --- /dev/null +++ b/internal/anthropic/types.go @@ -0,0 +1,105 @@ +package anthropic + +import ( + "encoding/json" +) + +// Request is the body of POST /v1/messages (Anthropic Messages API). +type Request struct { + Model string `json:"model"` + MaxTokens int `json:"max_tokens"` + System json.RawMessage `json:"system,omitempty"` + Messages []Message `json:"messages"` + Tools []ToolDef `json:"tools,omitempty"` + ToolChoice *ToolChoice `json:"tool_choice,omitempty"` + Temperature *float64 `json:"temperature,omitempty"` + TopP *float64 `json:"top_p,omitempty"` + TopK *int `json:"top_k,omitempty"` + StopSequences []string `json:"stop_sequences,omitempty"` + Stream bool `json:"stream,omitempty"` + Metadata *Metadata `json:"metadata,omitempty"` +} + +type Metadata struct { + UserID string `json:"user_id,omitempty"` +} + +type Message struct { + Role string `json:"role"` + Content Content `json:"content"` +} + +// Content is either a plain string or an array of content blocks. +type Content struct { + Text string + Blocks []Block +} + +func (c *Content) UnmarshalJSON(data []byte) error { + data = trimSpace(data) + if len(data) == 0 || string(data) == "null" { + c.Text, c.Blocks = "", nil + return nil + } + if data[0] == '"' { + var s string + if err := json.Unmarshal(data, &s); err != nil { + return err + } + c.Text, c.Blocks = s, nil + return nil + } + var blocks []Block + if err := json.Unmarshal(data, &blocks); err != nil { + return err + } + c.Blocks, c.Text = blocks, "" + return nil +} + +func (c Content) MarshalJSON() ([]byte, error) { + if c.Blocks != nil { + return json.Marshal(c.Blocks) + } + return json.Marshal(c.Text) +} + +// Block is one content block of a message, request or response side. +type Block struct { + Type string `json:"type"` + Text string `json:"text,omitempty"` + ID string `json:"id,omitempty"` + Name string `json:"name,omitempty"` + Input json.RawMessage `json:"input,omitempty"` + ToolUseID string `json:"tool_use_id,omitempty"` + Content json.RawMessage `json:"content,omitempty"` + IsError bool `json:"is_error,omitempty"` +} + +type ToolDef struct { + Name string `json:"name"` + Description string `json:"description,omitempty"` + InputSchema json.RawMessage `json:"input_schema,omitempty"` +} + +type ToolChoice struct { + Type string `json:"type"` // "auto" | "any" | "tool" | "none" + Name string `json:"name,omitempty"` +} + +// Response is the body of a completed non-streaming /v1/messages call. +type Response struct { + ID string `json:"id"` + Type string `json:"type"` + Role string `json:"role"` + Model string `json:"model"` + Content []Block `json:"content"` + StopReason string `json:"stop_reason"` + StopSequence *string `json:"stop_sequence"` + Usage Usage `json:"usage"` +} + +type Usage struct { + InputTokens int `json:"input_tokens"` + OutputTokens int `json:"output_tokens"` +} diff --git a/internal/api/api_test.go b/internal/api/api_test.go new file mode 100644 index 0000000..6561029 --- /dev/null +++ b/internal/api/api_test.go @@ -0,0 +1,236 @@ +package api + +import ( + "encoding/json" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/nyoungo/dsfree2api/internal/config" + "github.com/nyoungo/dsfree2api/internal/logbuf" + "github.com/nyoungo/dsfree2api/internal/metrics" + "github.com/nyoungo/dsfree2api/internal/turnstile" +) + +const examplePath = "../../config.example.toml" +const testKey = "sk-dsfr-local-change-me" + +func newTestServer(t *testing.T, mutate func(*config.Config)) *httptest.Server { + t.Helper() + cfg, err := config.Load(examplePath) + if err != nil { + t.Fatalf("load config: %v", err) + } + cfg.Security.APIKeys = []string{testKey} + if mutate != nil { + mutate(cfg) + } + met := metrics.New(t.TempDir()) + met.Start() + t.Cleanup(met.Close) + logs := logbuf.New(50) + ts := turnstile.New(cfg.Turnstile, "") + srv := New(cfg, nil, met, logs, ts, slog.New(slog.NewTextHandler(io.Discard, nil))) + tsrv := httptest.NewServer(srv.Handler()) + t.Cleanup(tsrv.Close) + return tsrv +} + +func get(t *testing.T, url string, headers map[string]string) (*http.Response, string) { + t.Helper() + req, err := http.NewRequest(http.MethodGet, url, nil) + if err != nil { + t.Fatalf("new request: %v", err) + } + for k, v := range headers { + req.Header.Set(k, v) + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("do: %v", err) + } + body, _ := io.ReadAll(resp.Body) + resp.Body.Close() + return resp, string(body) +} + +func TestModelsRequiresAuth(t *testing.T) { + srv := newTestServer(t, nil) + resp, _ := get(t, srv.URL+"/v1/models", nil) + if resp.StatusCode != http.StatusUnauthorized { + t.Fatalf("status = %d, want 401", resp.StatusCode) + } +} + +func TestModelsRejectsWrongKey(t *testing.T) { + srv := newTestServer(t, nil) + resp, _ := get(t, srv.URL+"/v1/models", map[string]string{"Authorization": "Bearer sk-wrong"}) + if resp.StatusCode != http.StatusUnauthorized { + t.Fatalf("status = %d, want 401", resp.StatusCode) + } +} + +func TestModelsListsWithAuth(t *testing.T) { + srv := newTestServer(t, nil) + resp, body := get(t, srv.URL+"/v1/models", map[string]string{"Authorization": "Bearer " + testKey}) + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d body=%s", resp.StatusCode, body) + } + var out struct { + Data []struct { + ID string `json:"id"` + } `json:"data"` + } + if err := json.Unmarshal([]byte(body), &out); err != nil { + t.Fatalf("decode: %v", err) + } + if len(out.Data) != 6 { + t.Fatalf("len(data) = %d, want 6", len(out.Data)) + } + ids := map[string]bool{} + for _, m := range out.Data { + ids[m.ID] = true + } + for _, want := range []string{"deepseek-v4-flash-es", "deepseek-v4-pro-de"} { + if !ids[want] { + t.Errorf("missing model %q", want) + } + } +} + +func TestModelsWithXApiKeyHeader(t *testing.T) { + srv := newTestServer(t, nil) + resp, _ := get(t, srv.URL+"/v1/models", map[string]string{"X-Api-Key": testKey}) + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d, want 200", resp.StatusCode) + } +} + +func TestNoKeysMeansNoAuth(t *testing.T) { + srv := newTestServer(t, func(c *config.Config) { c.Security.APIKeys = nil }) + resp, _ := get(t, srv.URL+"/v1/models", nil) + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d, want 200", resp.StatusCode) + } +} + +func TestRateLimit(t *testing.T) { + srv := newTestServer(t, func(c *config.Config) { c.Limits.RatePerMinute = 1 }) + h := map[string]string{"Authorization": "Bearer " + testKey} + if resp, _ := get(t, srv.URL+"/v1/models", h); resp.StatusCode != http.StatusOK { + t.Fatalf("first status = %d, want 200", resp.StatusCode) + } + if resp, _ := get(t, srv.URL+"/v1/models", h); resp.StatusCode != http.StatusTooManyRequests { + t.Fatalf("second status = %d, want 429", resp.StatusCode) + } +} + +func TestHealthAndRoot(t *testing.T) { + srv := newTestServer(t, nil) + resp, body := get(t, srv.URL+"/health", nil) + if resp.StatusCode != http.StatusOK { + t.Fatalf("health status = %d", resp.StatusCode) + } + var health map[string]any + if err := json.Unmarshal([]byte(body), &health); err != nil { + t.Fatalf("decode: %v", err) + } + if health["status"] != "ok" { + t.Errorf("health = %v", health) + } + if _, ok := health["models"]; !ok { + t.Errorf("health missing models: %v", health) + } +} + +func TestUnknownRouteIs404(t *testing.T) { + srv := newTestServer(t, nil) + resp, _ := get(t, srv.URL+"/v1/nope", map[string]string{"Authorization": "Bearer " + testKey}) + if resp.StatusCode != http.StatusNotFound { + t.Fatalf("status = %d, want 404", resp.StatusCode) + } +} + +func post(t *testing.T, url string, headers map[string]string, body string) (*http.Response, string) { + t.Helper() + req, err := http.NewRequest(http.MethodPost, url, strings.NewReader(body)) + if err != nil { + t.Fatalf("new request: %v", err) + } + for k, v := range headers { + req.Header.Set(k, v) + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("do: %v", err) + } + raw, _ := io.ReadAll(resp.Body) + resp.Body.Close() + return resp, string(raw) +} + +func anthropicErrType(body string) string { + var out struct { + Type string `json:"type"` + Error struct { + Type string `json:"type"` + } `json:"error"` + } + _ = json.Unmarshal([]byte(body), &out) + if out.Type == "error" { + return out.Error.Type + } + return "" +} + +func TestMessagesRequiresAuthInAnthropicFormat(t *testing.T) { + srv := newTestServer(t, nil) + resp, body := post(t, srv.URL+"/v1/messages", nil, `{}`) + if resp.StatusCode != http.StatusUnauthorized { + t.Fatalf("status = %d body=%s, want 401", resp.StatusCode, body) + } + if got := anthropicErrType(body); got != "authentication_error" { + t.Errorf("error type = %q, want authentication_error; body=%s", got, body) + } +} + +func TestMessagesValidation(t *testing.T) { + srv := newTestServer(t, nil) + h := map[string]string{"Authorization": "Bearer " + testKey, "Content-Type": "application/json"} + + resp, body := post(t, srv.URL+"/v1/messages", h, `{}`) + if resp.StatusCode != http.StatusBadRequest { + t.Fatalf("empty body status = %d body=%s, want 400", resp.StatusCode, body) + } + if got := anthropicErrType(body); got != "invalid_request_error" { + t.Errorf("error type = %q, want invalid_request_error", got) + } + + resp, body = post(t, srv.URL+"/v1/messages", h, + `{"model":"no-such-model","messages":[{"role":"user","content":"hi"}]}`) + if resp.StatusCode != http.StatusNotFound { + t.Fatalf("unknown model status = %d body=%s, want 404", resp.StatusCode, body) + } + if got := anthropicErrType(body); got != "not_found_error" { + t.Errorf("error type = %q, want not_found_error", got) + } +} + +func TestResponsesValidation(t *testing.T) { + srv := newTestServer(t, nil) + h := map[string]string{"Authorization": "Bearer " + testKey, "Content-Type": "application/json"} + + resp, body := post(t, srv.URL+"/v1/responses", h, `{"model":"deepseek-v4-flash-de"}`) + if resp.StatusCode != http.StatusBadRequest { + t.Fatalf("missing input status = %d body=%s, want 400", resp.StatusCode, body) + } + + resp, body = post(t, srv.URL+"/v1/responses", h, + `{"model":"no-such-model","input":"hi"}`) + if resp.StatusCode != http.StatusNotFound { + t.Fatalf("unknown model status = %d body=%s, want 404", resp.StatusCode, body) + } +} diff --git a/internal/api/chat.go b/internal/api/chat.go new file mode 100644 index 0000000..1f3a933 --- /dev/null +++ b/internal/api/chat.go @@ -0,0 +1,413 @@ +package api + +import ( + "context" + "crypto/rand" + "encoding/hex" + "encoding/json" + "fmt" + "net/http" + "strings" + "time" + "unicode/utf8" + + "github.com/nyoungo/dsfree2api/internal/metrics" + "github.com/nyoungo/dsfree2api/internal/openai" + "github.com/nyoungo/dsfree2api/internal/upstream" +) + +func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) { + var req openai.ChatCompletionRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid JSON body: "+err.Error(), "invalid_request_error") + return + } + if req.Model == "" { + writeError(w, http.StatusBadRequest, "model is required", "invalid_request_error") + return + } + if len(req.Messages) == 0 { + writeError(w, http.StatusBadRequest, "messages is required", "invalid_request_error") + return + } + s.cfg.RLock() + m, ok := s.cfg.Models[req.Model] + site := "" + enabled := false + if ok { + site = m.Site + enabled = m.Enabled + } + s.cfg.RUnlock() + if !ok || !enabled { + writeError(w, http.StatusNotFound, "model not found: "+req.Model, "invalid_request_error") + return + } + + maxTokens := req.MaxTokens + if maxTokens == nil { + maxTokens = req.MaxCompletionTok + } + prompt := openai.BuildPrompt(req.Messages, openai.PromptOptions{ + Tools: req.Tools, + ToolChoice: req.ToolChoice, + Temperature: req.Temperature, + TopP: req.TopP, + MaxTokens: maxTokens, + Stop: req.Stop, + ResponseFormat: req.ResponseFormat, + }) + + ctx, cancel := s.upstreamCtx(r) + defer cancel() + + s.met.IncInFlight(1) + defer s.met.IncInFlight(-1) + + if req.Stream { + s.streamChat(ctx, w, req, prompt, site) + return + } + s.completeChat(ctx, w, req, prompt, site) +} + +// chatOutcome is the result of replaying one upstream conversation. +type chatOutcome struct { + body string + usage openai.Usage + servedBy string + servedSite string + duration time.Duration + ttft time.Duration + status metrics.Status + err error +} + +// collect runs a full (non-streaming) conversation against the upstream site +// and records metrics for it. +func (s *Server) collect(ctx context.Context, modelID, prompt, site string) chatOutcome { + start := time.Now() + info := &upstream.ServeInfo{} + var text strings.Builder + var firstDelta time.Time + + err := s.up.Chat(ctx, modelID, prompt, info, func(ev upstream.Event) error { + if ev.Kind == upstream.KindDelta && ev.Value != "" { + if firstDelta.IsZero() { + firstDelta = time.Now() + } + text.WriteString(ev.Value) + } + return nil + }) + + body := text.String() + servedBy, servedSite, _ := info.Get() + if servedSite == "" { + servedSite = site + } + status := metrics.StatusOK + switch { + case err != nil: + status = metrics.StatusError + case strings.TrimSpace(body) == "": + status = metrics.StatusEmpty + } + usage := estimateUsage(prompt, body) + s.met.Record(metrics.Record{ + Model: modelID, + ServedBy: servedBy, + Site: servedSite, + Stream: false, + DurationMs: time.Since(start).Milliseconds(), + TTFTMs: msSince(firstDelta, start), + PromptTokens: usage.PromptTokens, + CompletionTokens: usage.CompletionTokens, + Status: status, + Error: errString(err), + }) + return chatOutcome{ + body: body, usage: usage, servedBy: servedBy, servedSite: servedSite, + duration: time.Since(start), ttft: firstDelta.Sub(start), status: status, err: err, + } +} + +func (s *Server) completeChat(ctx context.Context, w http.ResponseWriter, req openai.ChatCompletionRequest, prompt, site string) { + out := s.collect(ctx, req.Model, prompt, site) + if out.err != nil { + writeUpstreamError(w, out.err) + return + } + if out.status == metrics.StatusEmpty { + writeError(w, http.StatusBadGateway, emptyResponseMessage, "upstream_empty_response") + return + } + writeJSON(w, http.StatusOK, buildCompletion(req, prompt, out.body)) +} + +func buildCompletion(req openai.ChatCompletionRequest, prompt, body string) *openai.ChatCompletionResponse { + message := openai.ChoiceMessage{Content: body} + if len(req.Tools) > 0 { + remaining, calls, ok := openai.TryParseToolCalls(body) + if ok { + message.Content = remaining + message.ToolCalls = calls + } + } + return &openai.ChatCompletionResponse{ + ID: "chatcmpl-" + randHex(16), + Object: "chat.completion", + Created: time.Now().Unix(), + Model: req.Model, + Choices: []openai.Choice{{ + Index: 0, + Message: message, + FinishReason: finishReason(message.ToolCalls), + }}, + Usage: estimateUsage(prompt, body), + } +} + +func finishReason(calls []openai.ToolCall) string { + if len(calls) > 0 { + return "tool_calls" + } + return "stop" +} + +func (s *Server) streamChat(ctx context.Context, w http.ResponseWriter, req openai.ChatCompletionRequest, prompt, site string) { + flusher, _ := w.(http.Flusher) + w.Header().Set("Content-Type", "text/event-stream; charset=utf-8") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") + w.Header().Set("X-Accel-Buffering", "no") + + start := time.Now() + id := "chatcmpl-" + randHex(16) + created := time.Now().Unix() + info := &upstream.ServeInfo{} + var text strings.Builder + var firstDelta time.Time + + // When tools are requested the whole reply is buffered so the raw + // tool-call JSON is never streamed to the client twice. + buffered := len(req.Tools) > 0 + + write := func(v any) error { + raw, err := json.Marshal(v) + if err != nil { + return err + } + if _, err := fmt.Fprintf(w, "data: %s\n\n", raw); err != nil { + return err + } + if flusher != nil { + flusher.Flush() + } + return nil + } + chunk := func(delta openai.ChoiceDelta, finish *string) openai.ChatCompletionChunk { + return openai.ChatCompletionChunk{ + ID: id, + Object: "chat.completion.chunk", + Created: created, + Model: req.Model, + Choices: []openai.StreamChoice{{Delta: delta, FinishReason: finish}}, + } + } + + _ = write(chunk(openai.ChoiceDelta{Role: "assistant"}, nil)) + + s.met.IncInFlight(1) + defer s.met.IncInFlight(-1) + + err := s.up.Chat(ctx, req.Model, prompt, info, func(ev upstream.Event) error { + if ev.Kind != upstream.KindDelta || ev.Value == "" { + return nil + } + if firstDelta.IsZero() { + firstDelta = time.Now() + } + text.WriteString(ev.Value) + if buffered { + return nil + } + return write(chunk(openai.ChoiceDelta{Content: ev.Value}, nil)) + }) + + duration := time.Since(start) + body := text.String() + servedBy, servedSite, _ := info.Get() + if servedSite == "" { + servedSite = site + } + + if err != nil { + s.met.Record(metrics.Record{ + Model: req.Model, ServedBy: servedBy, Site: servedSite, Stream: true, + DurationMs: duration.Milliseconds(), TTFTMs: msSince(firstDelta, start), + PromptTokens: estimateUsage(prompt, "").PromptTokens, + Status: metrics.StatusError, Error: err.Error(), + }) + s.log.Warn("stream error", "model", req.Model, "error", err) + _ = write(map[string]any{ + "error": map[string]any{"message": err.Error(), "type": "upstream_error"}, + }) + _, _ = fmt.Fprint(w, "data: [DONE]\n\n") + if flusher != nil { + flusher.Flush() + } + return + } + + if strings.TrimSpace(body) == "" { + s.met.Record(metrics.Record{ + Model: req.Model, ServedBy: servedBy, Site: servedSite, Stream: true, + DurationMs: duration.Milliseconds(), + Status: metrics.StatusEmpty, Error: "empty response", + }) + _ = write(map[string]any{ + "error": map[string]any{"message": emptyResponseMessage, "type": "upstream_empty_response"}, + }) + _, _ = fmt.Fprint(w, "data: [DONE]\n\n") + if flusher != nil { + flusher.Flush() + } + return + } + + usage := estimateUsage(prompt, body) + var toolCalls []openai.ToolCall + content := body + if buffered { + remaining, calls, ok := openai.TryParseToolCalls(body) + if ok { + content = remaining + toolCalls = calls + } + } + stop := finishReason(toolCalls) + + // Final chunk: content + tool_calls (or plain stop marker). + final := map[string]any{ + "id": id, + "object": "chat.completion.chunk", + "created": created, + "model": req.Model, + "choices": []map[string]any{{ + "index": 0, + "delta": map[string]any{}, + "finish_reason": stop, + }}, + } + deltaPayload := map[string]any{} + if content != "" { + deltaPayload["content"] = content + } + if len(toolCalls) > 0 { + deltaPayload["tool_calls"] = toolCalls + } + if len(deltaPayload) > 0 { + final["choices"] = []map[string]any{{ + "index": 0, + "delta": deltaPayload, + "finish_reason": stop, + }} + } + _ = write(final) + + includeUsage := req.StreamOptions != nil && req.StreamOptions.IncludeUsage + if includeUsage { + _ = write(map[string]any{ + "id": id, "object": "chat.completion.chunk", "created": created, "model": req.Model, + "choices": []map[string]any{}, + "usage": usage, + }) + } + _, _ = fmt.Fprint(w, "data: [DONE]\n\n") + if flusher != nil { + flusher.Flush() + } + + s.met.Record(metrics.Record{ + Model: req.Model, ServedBy: servedBy, Site: servedSite, Stream: true, + DurationMs: duration.Milliseconds(), TTFTMs: msSince(firstDelta, start), + PromptTokens: usage.PromptTokens, CompletionTokens: usage.CompletionTokens, + Status: metrics.StatusOK, + }) +} + +const emptyResponseMessage = "upstream returned empty response — the server may be rate-limiting, overloaded, or the session/nonce expired. Retry in a few seconds." + +// upstreamCtx bounds one conversation with the configured stream timeout so a +// hung upstream can never wedge a request forever. +func (s *Server) upstreamCtx(r *http.Request) (context.Context, context.CancelFunc) { + s.cfg.RLock() + d := time.Duration(s.cfg.Upstream.StreamTimeout * float64(time.Second)) + s.cfg.RUnlock() + if d <= 0 { + d = 5 * time.Minute + } + return context.WithTimeout(r.Context(), d) +} + +func writeUpstreamError(w http.ResponseWriter, err error) { + code := http.StatusBadGateway + typ := "upstream_error" + if isTimeout(err) { + code = http.StatusGatewayTimeout + typ = "upstream_timeout" + } + writeError(w, code, err.Error(), typ) +} + +func isTimeout(err error) bool { + if err == nil { + return false + } + msg := strings.ToLower(err.Error()) + return strings.Contains(msg, "timeout") || strings.Contains(msg, "deadline exceeded") +} + +func errString(err error) string { + if err == nil { + return "" + } + return err.Error() +} + +func estimateUsage(prompt, text string) openai.Usage { + var pt, ct int + if prompt != "" { + pt = utf8.RuneCountInString(prompt) / 4 + if pt < 1 { + pt = 1 + } + } + if text != "" { + ct = utf8.RuneCountInString(text) / 4 + if ct < 1 { + ct = 1 + } + } + return openai.Usage{PromptTokens: pt, CompletionTokens: ct, TotalTokens: pt + ct} +} + +func msSince(t, fallback time.Time) int64 { + if t.IsZero() { + return 0 + } + d := t.Sub(fallback) + if d < 0 { + return 0 + } + return d.Milliseconds() +} + +func randHex(n int) string { + b := make([]byte, n) + if _, err := rand.Read(b); err != nil { + return strings.Repeat("0", n*2) + } + return hex.EncodeToString(b) +} diff --git a/internal/api/messages.go b/internal/api/messages.go new file mode 100644 index 0000000..4330183 --- /dev/null +++ b/internal/api/messages.go @@ -0,0 +1,289 @@ +package api + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "strings" + "time" + + "github.com/nyoungo/dsfree2api/internal/anthropic" + "github.com/nyoungo/dsfree2api/internal/metrics" + "github.com/nyoungo/dsfree2api/internal/openai" + "github.com/nyoungo/dsfree2api/internal/upstream" +) + +func (s *Server) handleMessages(w http.ResponseWriter, r *http.Request) { + var req anthropic.Request + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeAnthropicError(w, http.StatusBadRequest, "invalid JSON body: "+err.Error(), "invalid_request_error") + return + } + if req.Model == "" { + writeAnthropicError(w, http.StatusBadRequest, "model is required", "invalid_request_error") + return + } + if len(req.Messages) == 0 { + writeAnthropicError(w, http.StatusBadRequest, "messages is required", "invalid_request_error") + return + } + s.cfg.RLock() + m, ok := s.cfg.Models[req.Model] + site := "" + enabled := false + if ok { + site = m.Site + enabled = m.Enabled + } + s.cfg.RUnlock() + if !ok || !enabled { + writeAnthropicError(w, http.StatusNotFound, "model not found: "+req.Model, "not_found_error") + return + } + + messages, tools, toolChoice, err := anthropic.ToOpenAI(&req) + if err != nil { + writeAnthropicError(w, http.StatusBadRequest, err.Error(), "invalid_request_error") + return + } + var maxTokens *int + if req.MaxTokens > 0 { + maxTokens = &req.MaxTokens + } + prompt := openai.BuildPrompt(messages, openai.PromptOptions{ + Tools: tools, + ToolChoice: toolChoice, + Temperature: req.Temperature, + TopP: req.TopP, + MaxTokens: maxTokens, + Stop: req.StopSequences, + }) + + ctx, cancel := s.upstreamCtx(r) + defer cancel() + + s.met.IncInFlight(1) + defer s.met.IncInFlight(-1) + + if req.Stream { + s.streamMessages(ctx, w, req, prompt, site, tools) + return + } + s.completeMessages(ctx, w, req, prompt, site, tools) +} + +func (s *Server) completeMessages(ctx context.Context, w http.ResponseWriter, req anthropic.Request, prompt, site string, tools []openai.ToolDef) { + out := s.collect(ctx, req.Model, prompt, site) + if out.err != nil { + writeAnthropicUpstreamError(w, out.err) + return + } + if out.status == metrics.StatusEmpty { + writeAnthropicError(w, http.StatusInternalServerError, emptyResponseMessage, "api_error") + return + } + text, calls := splitToolOutput(out.body, tools) + resp := anthropic.Response{ + ID: anthropic.NewID("msg_"), + Type: "message", + Role: "assistant", + Model: req.Model, + Content: anthropic.ContentFrom(text, calls), + StopReason: anthropic.StopReasonFor(calls), + Usage: anthropic.Usage{ + InputTokens: out.usage.PromptTokens, + OutputTokens: out.usage.CompletionTokens, + }, + } + writeJSON(w, http.StatusOK, resp) +} + +func (s *Server) streamMessages(ctx context.Context, w http.ResponseWriter, req anthropic.Request, prompt, site string, tools []openai.ToolDef) { + flusher, _ := w.(http.Flusher) + w.Header().Set("Content-Type", "text/event-stream; charset=utf-8") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") + w.Header().Set("X-Accel-Buffering", "no") + + writeEvent := func(ev string, v any) error { + raw, err := json.Marshal(v) + if err != nil { + return err + } + if _, err := fmt.Fprintf(w, "event: %s\ndata: %s\n\n", ev, raw); err != nil { + return err + } + if flusher != nil { + flusher.Flush() + } + return nil + } + writeErr := func(typ, msg string) { + _ = writeEvent("error", map[string]any{ + "type": "error", + "error": map[string]any{"type": typ, "message": msg}, + }) + } + + msgID := anthropic.NewID("msg_") + _ = writeEvent("message_start", map[string]any{ + "type": "message_start", + "message": map[string]any{ + "id": msgID, "type": "message", "role": "assistant", "model": req.Model, + "content": []any{}, "stop_reason": nil, "stop_sequence": nil, + "usage": map[string]any{ + "input_tokens": estimateUsage(prompt, "").PromptTokens, + "output_tokens": 0, + }, + }, + }) + + // When tools are requested the reply is buffered so the raw tool-call + // JSON is parsed once and emitted as proper tool_use blocks instead of + // being streamed to the client as prose. + buffered := len(tools) > 0 + if !buffered { + _ = writeEvent("content_block_start", map[string]any{ + "type": "content_block_start", "index": 0, + "content_block": map[string]any{"type": "text", "text": ""}, + }) + } + + start := time.Now() + info := &upstream.ServeInfo{} + var text strings.Builder + var firstDelta time.Time + + err := s.up.Chat(ctx, req.Model, prompt, info, func(ev upstream.Event) error { + if ev.Kind != upstream.KindDelta || ev.Value == "" { + return nil + } + if firstDelta.IsZero() { + firstDelta = time.Now() + } + text.WriteString(ev.Value) + if buffered { + return nil + } + return writeEvent("content_block_delta", map[string]any{ + "type": "content_block_delta", "index": 0, + "delta": map[string]any{"type": "text_delta", "text": ev.Value}, + }) + }) + + duration := time.Since(start) + body := text.String() + servedBy, servedSite, _ := info.Get() + if servedSite == "" { + servedSite = site + } + + if err != nil { + s.met.Record(metrics.Record{ + Model: req.Model, ServedBy: servedBy, Site: servedSite, Stream: true, + DurationMs: duration.Milliseconds(), TTFTMs: msSince(firstDelta, start), + PromptTokens: estimateUsage(prompt, "").PromptTokens, + Status: metrics.StatusError, Error: err.Error(), + }) + s.log.Warn("messages stream error", "model", req.Model, "error", err) + writeErr("api_error", err.Error()) + return + } + if strings.TrimSpace(body) == "" { + s.met.Record(metrics.Record{ + Model: req.Model, ServedBy: servedBy, Site: servedSite, Stream: true, + DurationMs: duration.Milliseconds(), Status: metrics.StatusEmpty, Error: "empty response", + }) + writeErr("api_error", emptyResponseMessage) + return + } + + usage := estimateUsage(prompt, body) + outText, calls := splitToolOutput(body, tools) + + index := 0 + if !buffered { + _ = writeEvent("content_block_stop", map[string]any{"type": "content_block_stop", "index": 0}) + index = 1 + } else if outText != "" { + _ = writeEvent("content_block_start", map[string]any{ + "type": "content_block_start", "index": 0, + "content_block": map[string]any{"type": "text", "text": ""}, + }) + _ = writeEvent("content_block_delta", map[string]any{ + "type": "content_block_delta", "index": 0, + "delta": map[string]any{"type": "text_delta", "text": outText}, + }) + _ = writeEvent("content_block_stop", map[string]any{"type": "content_block_stop", "index": 0}) + index = 1 + } + for _, c := range calls { + id := c.ID + if id == "" { + id = anthropic.NewID("toolu_") + } + _ = writeEvent("content_block_start", map[string]any{ + "type": "content_block_start", "index": index, + "content_block": map[string]any{ + "type": "tool_use", "id": id, "name": c.Function.Name, + "input": json.RawMessage("{}"), + }, + }) + _ = writeEvent("content_block_delta", map[string]any{ + "type": "content_block_delta", "index": index, + "delta": map[string]any{"type": "input_json_delta", "partial_json": c.Function.Arguments}, + }) + _ = writeEvent("content_block_stop", map[string]any{"type": "content_block_stop", "index": index}) + index++ + } + _ = writeEvent("message_delta", map[string]any{ + "type": "message_delta", + "delta": map[string]any{"stop_reason": anthropic.StopReasonFor(calls), "stop_sequence": nil}, + "usage": map[string]any{"output_tokens": usage.CompletionTokens}, + }) + _ = writeEvent("message_stop", map[string]any{"type": "message_stop"}) + + s.met.Record(metrics.Record{ + Model: req.Model, ServedBy: servedBy, Site: servedSite, Stream: true, + DurationMs: duration.Milliseconds(), TTFTMs: msSince(firstDelta, start), + PromptTokens: usage.PromptTokens, CompletionTokens: usage.CompletionTokens, + Status: metrics.StatusOK, + }) +} + +// splitToolOutput peels tool calls off a raw model reply when tools were +// offered; plain replies pass through unchanged. +func splitToolOutput(body string, tools []openai.ToolDef) (string, []openai.ToolCall) { + if len(tools) == 0 { + return body, nil + } + remaining, calls, ok := openai.TryParseToolCalls(body) + if !ok { + return body, nil + } + return remaining, calls +} + +func writeAnthropicError(w http.ResponseWriter, code int, msg, typ string) { + switch code { + case http.StatusUnauthorized: + typ = "authentication_error" + case http.StatusNotFound: + typ = "not_found_error" + case http.StatusTooManyRequests: + typ = "rate_limit_error" + } + writeJSON(w, code, map[string]any{ + "type": "error", + "error": map[string]any{"type": typ, "message": msg}, + }) +} + +func writeAnthropicUpstreamError(w http.ResponseWriter, err error) { + if isTimeout(err) { + writeAnthropicError(w, http.StatusGatewayTimeout, err.Error(), "api_error") + return + } + writeAnthropicError(w, http.StatusBadGateway, err.Error(), "api_error") +} diff --git a/internal/api/ratelimit.go b/internal/api/ratelimit.go new file mode 100644 index 0000000..89d9014 --- /dev/null +++ b/internal/api/ratelimit.go @@ -0,0 +1,39 @@ +package api + +import ( + "sync" + "time" +) + +// limiter is a simple fixed one-minute window counter per API key. +type limiter struct { + mu sync.Mutex + window time.Time + counts map[string]int +} + +func newLimiter() *limiter { + return &limiter{counts: map[string]int{}, window: time.Now().Truncate(time.Minute)} +} + +func (l *limiter) allow(key string) bool { + return l.allowWithLimit(key, 0) +} + +func (l *limiter) allowWithLimit(key string, limit int) bool { + if limit <= 0 { + return true + } + l.mu.Lock() + defer l.mu.Unlock() + now := time.Now().Truncate(time.Minute) + if now.After(l.window) { + l.window = now + l.counts = map[string]int{} + } + if l.counts[key] >= limit { + return false + } + l.counts[key]++ + return true +} diff --git a/internal/api/responses.go b/internal/api/responses.go new file mode 100644 index 0000000..bd10ff3 --- /dev/null +++ b/internal/api/responses.go @@ -0,0 +1,326 @@ +package api + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "strings" + "time" + + "github.com/nyoungo/dsfree2api/internal/metrics" + "github.com/nyoungo/dsfree2api/internal/openai" + "github.com/nyoungo/dsfree2api/internal/upstream" +) + +func (s *Server) handleResponses(w http.ResponseWriter, r *http.Request) { + var req openai.ResponsesRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid JSON body: "+err.Error(), "invalid_request_error") + return + } + messages, err := openai.ResponsesToMessages(&req) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error(), "invalid_request_error") + return + } + tools := make([]openai.ToolDef, 0, len(req.Tools)) + for _, t := range req.Tools { + if def, ok := t.ToToolDef(); ok { + tools = append(tools, def) + } + } + s.cfg.RLock() + m, ok := s.cfg.Models[req.Model] + site := "" + enabled := false + if ok { + site, enabled = m.Site, m.Enabled + } + s.cfg.RUnlock() + if !ok || !enabled { + writeError(w, http.StatusNotFound, "model not found: "+req.Model, "invalid_request_error") + return + } + + prompt := openai.BuildPrompt(messages, openai.PromptOptions{ + Tools: tools, + ToolChoice: req.ToolChoice, + Temperature: req.Temperature, + TopP: req.TopP, + MaxTokens: req.MaxOutputTokens, + }) + + s.met.IncInFlight(1) + defer s.met.IncInFlight(-1) + + ctx, cancel := s.upstreamCtx(r) + defer cancel() + + if req.Stream { + s.streamResponses(ctx, w, req, prompt, site, tools) + return + } + + out := s.collect(ctx, req.Model, prompt, site) + if out.err != nil { + writeUpstreamError(w, out.err) + return + } + if out.status == metrics.StatusEmpty { + writeError(w, http.StatusBadGateway, emptyResponseMessage, "upstream_empty_response") + return + } + text, calls := splitToolOutput(out.body, tools) + output := responseOutputItems(text, calls) + stopReason := "stop" + if len(calls) > 0 { + stopReason = "tool_use" + } + writeJSON(w, http.StatusOK, map[string]any{ + "id": "resp_" + randHex(16), + "object": "response", + "created_at": time.Now().Unix(), + "status": "completed", + "model": req.Model, + "output": output, + "output_text": text, + "stop_reason": stopReason, + "usage": out.usage, + }) +} + +// responseOutputItems renders a plain-text reply plus parsed tool calls as +// Responses API output items. +func responseOutputItems(text string, calls []openai.ToolCall) []map[string]any { + output := make([]map[string]any, 0, len(calls)+1) + if text != "" { + output = append(output, map[string]any{ + "id": "msg_" + randHex(12), + "type": "message", + "status": "completed", + "role": "assistant", + "content": []map[string]any{{ + "type": "output_text", + "text": text, + "annotations": []any{}, + }}, + }) + } + for _, c := range calls { + output = append(output, map[string]any{ + "id": "fc_" + randHex(12), + "type": "function_call", + "status": "completed", + "call_id": c.ID, + "name": c.Function.Name, + "arguments": c.Function.Arguments, + }) + } + return output +} + +func (s *Server) streamResponses(ctx context.Context, w http.ResponseWriter, req openai.ResponsesRequest, prompt, site string, tools []openai.ToolDef) { + flusher, _ := w.(http.Flusher) + w.Header().Set("Content-Type", "text/event-stream; charset=utf-8") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") + w.Header().Set("X-Accel-Buffering", "no") + + id := "resp_" + randHex(16) + write := func(v any) error { + raw, err := json.Marshal(v) + if err != nil { + return err + } + if _, err := fmt.Fprintf(w, "data: %s\n\n", raw); err != nil { + return err + } + if flusher != nil { + flusher.Flush() + } + return nil + } + + _ = write(map[string]any{ + "type": "response.created", + "response": map[string]any{ + "id": id, "model": req.Model, "status": "in_progress", "created_at": time.Now().Unix(), + }, + }) + + // When tools are requested the reply is buffered so the raw tool-call + // JSON is parsed once and emitted as proper function_call output items. + buffered := len(tools) > 0 + + start := time.Now() + info := &upstream.ServeInfo{} + var text strings.Builder + var firstDelta time.Time + + err := s.up.Chat(ctx, req.Model, prompt, info, func(ev upstream.Event) error { + if ev.Kind != upstream.KindDelta || ev.Value == "" { + return nil + } + if firstDelta.IsZero() { + firstDelta = time.Now() + } + text.WriteString(ev.Value) + if buffered { + return nil + } + return write(map[string]any{"type": "response.output_text.delta", "delta": ev.Value}) + }) + + duration := time.Since(start) + body := text.String() + servedBy, servedSite, _ := info.Get() + if servedSite == "" { + servedSite = site + } + + if err != nil { + s.met.Record(metrics.Record{ + Model: req.Model, ServedBy: servedBy, Site: servedSite, Stream: true, + DurationMs: duration.Milliseconds(), TTFTMs: msSince(firstDelta, start), + Status: metrics.StatusError, Error: err.Error(), + }) + // Never report a failed stream as completed. + _ = write(map[string]any{ + "type": "response.failed", + "response": map[string]any{ + "id": id, "model": req.Model, "status": "failed", "created_at": time.Now().Unix(), + "error": map[string]any{"code": "upstream_error", "message": err.Error()}, + }, + }) + _, _ = fmt.Fprint(w, "data: [DONE]\n\n") + if flusher != nil { + flusher.Flush() + } + return + } + if strings.TrimSpace(body) == "" { + s.met.Record(metrics.Record{ + Model: req.Model, ServedBy: servedBy, Site: servedSite, Stream: true, + DurationMs: duration.Milliseconds(), Status: metrics.StatusEmpty, Error: "empty response", + }) + _ = write(map[string]any{ + "type": "response.failed", + "response": map[string]any{ + "id": id, "model": req.Model, "status": "failed", "created_at": time.Now().Unix(), + "error": map[string]any{"code": "upstream_empty_response", "message": emptyResponseMessage}, + }, + }) + _, _ = fmt.Fprint(w, "data: [DONE]\n\n") + if flusher != nil { + flusher.Flush() + } + return + } + + usage := estimateUsage(prompt, body) + outText, calls := splitToolOutput(body, tools) + + stopReason := "stop" + if len(calls) > 0 { + stopReason = "tool_use" + } + + var output []map[string]any + if buffered { + // Replay the buffered reply as a proper item stream: message block + // first (when there is prose), then one function_call item per call. + // The final completed.response.output reuses the same item ids. + output = make([]map[string]any, 0, len(calls)+1) + seq := 0 + outIdx := 0 + if outText != "" { + itemID := "msg_" + randHex(12) + item := map[string]any{ + "id": itemID, "type": "message", "status": "completed", "role": "assistant", + "content": []map[string]any{{ + "type": "output_text", "text": outText, "annotations": []any{}, + }}, + } + _ = write(map[string]any{ + "type": "response.output_item.added", "output_index": outIdx, "sequence_number": seq, + "item": map[string]any{ + "id": itemID, "type": "message", "status": "in_progress", "role": "assistant", + "content": []any{}, + }, + }) + seq++ + _ = write(map[string]any{ + "type": "response.output_text.delta", "item_id": itemID, + "output_index": outIdx, "delta": outText, "content_index": 0, + }) + _ = write(map[string]any{ + "type": "response.output_text.done", "item_id": itemID, + "output_index": outIdx, "text": outText, "content_index": 0, + }) + _ = write(map[string]any{ + "type": "response.output_item.done", "output_index": outIdx, + "sequence_number": seq, "item": item, + }) + seq++ + outIdx++ + output = append(output, item) + } + for _, c := range calls { + itemID := "fc_" + randHex(12) + item := map[string]any{ + "id": itemID, "type": "function_call", "status": "completed", + "call_id": c.ID, "name": c.Function.Name, "arguments": c.Function.Arguments, + } + _ = write(map[string]any{ + "type": "response.output_item.added", "output_index": outIdx, "sequence_number": seq, + "item": map[string]any{ + "id": itemID, "type": "function_call", "status": "in_progress", + "call_id": c.ID, "name": c.Function.Name, "arguments": "", + }, + }) + seq++ + _ = write(map[string]any{ + "type": "response.function_call_arguments.delta", "item_id": itemID, + "output_index": outIdx, "delta": c.Function.Arguments, "sequence_number": seq, + }) + seq++ + _ = write(map[string]any{ + "type": "response.function_call_arguments.done", "item_id": itemID, + "output_index": outIdx, "arguments": c.Function.Arguments, "sequence_number": seq, + }) + seq++ + _ = write(map[string]any{ + "type": "response.output_item.done", "output_index": outIdx, + "sequence_number": seq, "item": item, + }) + seq++ + outIdx++ + output = append(output, item) + } + } else { + output = responseOutputItems(outText, calls) + _ = write(map[string]any{ + "type": "response.output_text.done", + "text": body, + }) + } + _ = write(map[string]any{ + "type": "response.completed", + "response": map[string]any{ + "id": id, "model": req.Model, "status": "completed", "created_at": time.Now().Unix(), + "output": output, "stop_reason": stopReason, "usage": usage, + }, + }) + _, _ = fmt.Fprint(w, "data: [DONE]\n\n") + if flusher != nil { + flusher.Flush() + } + + s.met.Record(metrics.Record{ + Model: req.Model, ServedBy: servedBy, Site: servedSite, Stream: true, + DurationMs: duration.Milliseconds(), TTFTMs: msSince(firstDelta, start), + PromptTokens: usage.PromptTokens, CompletionTokens: usage.CompletionTokens, + Status: metrics.StatusOK, + }) +} diff --git a/internal/api/server.go b/internal/api/server.go new file mode 100644 index 0000000..4e94b41 --- /dev/null +++ b/internal/api/server.go @@ -0,0 +1,240 @@ +package api + +import ( + "crypto/subtle" + "encoding/json" + "log/slog" + "net/http" + "runtime/debug" + "strings" + "time" + + "github.com/nyoungo/dsfree2api/internal/config" + "github.com/nyoungo/dsfree2api/internal/logbuf" + "github.com/nyoungo/dsfree2api/internal/metrics" + "github.com/nyoungo/dsfree2api/internal/turnstile" + "github.com/nyoungo/dsfree2api/internal/upstream" +) + +type Server struct { + cfg *config.Config + up *upstream.Client + met *metrics.Recorder + logs *logbuf.Buffer + ts *turnstile.Solver + log *slog.Logger + lim *limiter + start time.Time +} + +func New(cfg *config.Config, up *upstream.Client, met *metrics.Recorder, logs *logbuf.Buffer, ts *turnstile.Solver, log *slog.Logger) *Server { + if log == nil { + log = slog.Default() + } + return &Server{ + cfg: cfg, up: up, met: met, logs: logs, ts: ts, log: log, + lim: newLimiter(), + start: time.Now(), + } +} + +func (s *Server) Handler() http.Handler { + mux := http.NewServeMux() + mux.HandleFunc("GET /health", s.handleHealth) + mux.HandleFunc("GET /{$}", s.handleRoot) + mux.HandleFunc("GET /v1/models", s.authed(s.handleModels)) + mux.HandleFunc("POST /v1/chat/completions", s.authed(s.handleChat)) + mux.HandleFunc("POST /v1/responses", s.authed(s.handleResponses)) + mux.HandleFunc("POST /v1/messages", s.authedAnthropic(s.handleMessages)) + return s.recoverPanic(s.logRequest(mux)) +} + +func (s *Server) handleRoot(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, http.StatusOK, map[string]any{"status": "ok", "service": "dsfree2api"}) +} + +func (s *Server) handleHealth(w http.ResponseWriter, _ *http.Request) { + s.cfg.RLock() + models, sites := 0, []string{} + for id, m := range s.cfg.Models { + if m.Enabled { + models++ + } + _ = id + } + for code, site := range s.cfg.Sites { + if site.Enabled { + sites = append(sites, code) + } + } + turnstileOn := s.cfg.Turnstile.Enabled + s.cfg.RUnlock() + writeJSON(w, http.StatusOK, map[string]any{ + "status": "ok", + "models": models, + "sites": sites, + "turnstile": turnstileOn, + "uptime_s": int(time.Since(s.start).Seconds()), + }) +} + +func (s *Server) handleModels(w http.ResponseWriter, _ *http.Request) { + s.cfg.RLock() + now := time.Now().Unix() + data := make([]map[string]any, 0, len(s.cfg.Models)) + for id, m := range s.cfg.Models { + if !m.Enabled { + continue + } + data = append(data, map[string]any{ + "id": id, "object": "model", "created": now, "owned_by": "deepseek-" + m.Site, + }) + } + s.cfg.RUnlock() + sortModelInfo(data) + writeJSON(w, http.StatusOK, map[string]any{"object": "list", "data": data}) +} + +// ── middleware ─────────────────────────────────────────────────── + +// errWriter writes an auth/rate-limit failure in the wire format of the +// endpoint family it guards. +type errWriter func(w http.ResponseWriter, code int, msg, typ string) + +func (s *Server) authed(next http.HandlerFunc) http.HandlerFunc { + return s.authedWith(next, writeError) +} + +// authedAnthropic guards Anthropic-format endpoints, so failures come back as +// {"type":"error","error":{...}} instead of the OpenAI error shape. +func (s *Server) authedAnthropic(next http.HandlerFunc) http.HandlerFunc { + return s.authedWith(next, writeAnthropicError) +} + +func (s *Server) authedWith(next http.HandlerFunc, fail errWriter) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + s.cfg.RLock() + keys := append([]string(nil), s.cfg.Security.APIKeys...) + s.cfg.RUnlock() + + if len(keys) > 0 { + token := extractKey(r) + if token == "" || !matchesAny(keys, token) { + s.met.Record(metrics.Record{Status: metrics.StatusDenied}) + fail(w, http.StatusUnauthorized, "invalid api key", "invalid_request_error") + return + } + s.cfg.RLock() + rate := int(s.cfg.Limits.RatePerMinute) + s.cfg.RUnlock() + if !s.lim.allowWithLimit(token, rate) { + s.met.Record(metrics.Record{Status: metrics.StatusRateLimit}) + fail(w, http.StatusTooManyRequests, "rate limit exceeded", "rate_limit_error") + return + } + } + next(w, r) + } +} + +func extractKey(r *http.Request) string { + if v := r.Header.Get("X-Api-Key"); v != "" { + return strings.TrimSpace(v) + } + auth := r.Header.Get("Authorization") + if auth == "" { + return "" + } + scheme, value, ok := strings.Cut(auth, " ") + if !ok || !strings.EqualFold(strings.TrimSpace(scheme), "bearer") { + return "" + } + return strings.TrimSpace(value) +} + +func matchesAny(keys []string, token string) bool { + ok := false + for _, k := range keys { + if subtle.ConstantTimeCompare([]byte(k), []byte(token)) == 1 { + ok = true + } + } + return ok +} + +func (s *Server) logRequest(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/health" { + next.ServeHTTP(w, r) + return + } + start := time.Now() + sw := &statusWriter{ResponseWriter: w, status: http.StatusOK} + defer func() { + if r.URL.Path != "/" { + s.log.Debug("http", + "method", r.Method, "path", r.URL.Path, + "status", sw.status, "ms", time.Since(start).Milliseconds()) + } + }() + next.ServeHTTP(sw, r) + }) +} + +func (s *Server) recoverPanic(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + defer func() { + if rec := recover(); rec != nil { + s.log.Error("panic", "err", rec, "stack", string(debug.Stack())) + writeJSON(w, http.StatusInternalServerError, map[string]any{ + "error": map[string]any{"message": "internal server error", "type": "server_error"}, + }) + } + }() + next.ServeHTTP(w, r) + }) +} + +type statusWriter struct { + http.ResponseWriter + status int +} + +func (w *statusWriter) WriteHeader(code int) { + w.status = code + w.ResponseWriter.WriteHeader(code) +} + +func (w *statusWriter) Flush() { + if f, ok := w.ResponseWriter.(http.Flusher); ok { + f.Flush() + } +} + +// ── helpers ────────────────────────────────────────────────────── + +func writeJSON(w http.ResponseWriter, code int, v any) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(code) + _ = json.NewEncoder(w).Encode(v) +} + +func writeError(w http.ResponseWriter, code int, msg, typ string) { + writeJSON(w, code, map[string]any{ + "error": map[string]any{"message": msg, "type": typ}, + }) +} + +func sortModelInfo(data []map[string]any) { + for i := 1; i < len(data); i++ { + for j := i; j > 0; j-- { + a, _ := data[j]["id"].(string) + b, _ := data[j-1]["id"].(string) + if a < b { + data[j], data[j-1] = data[j-1], data[j] + } else { + break + } + } + } +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..da31e5a --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,525 @@ +package config + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "strconv" + "strings" + "sync" + + "github.com/BurntSushi/toml" +) + +const DefaultUserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36" + +// Turnstile token providers: api calls a hosted solver, browser drives a +// local Chrome/Edge over CDP, manual only ever uses imported cookies. +const ( + ProviderAPI = "api" + ProviderBrowser = "browser" + ProviderManual = "manual" +) + +type Turnstile struct { + Enabled bool `toml:"enabled" json:"enabled"` + Provider string `toml:"provider" json:"provider"` + APIURL string `toml:"api_url" json:"api_url"` + APIKey string `toml:"api_key" json:"api_key"` + SiteKey string `toml:"sitekey" json:"sitekey"` + Action string `toml:"action" json:"action"` + TimeoutSeconds int `toml:"timeout_seconds" json:"timeout_seconds"` + CookieTTLSeconds int `toml:"cookie_ttl_seconds" json:"cookie_ttl_seconds"` + Retries int `toml:"retries" json:"retries"` + RetryBackoffSeconds float64 `toml:"retry_backoff_seconds" json:"retry_backoff_seconds"` + + // provider = "browser" + BrowserPath string `toml:"browser_path" json:"browser_path"` + BrowserHeadless bool `toml:"browser_headless" json:"browser_headless"` + BrowserUserDataDir string `toml:"browser_user_data_dir" json:"browser_user_data_dir"` + BrowserTimezone string `toml:"browser_timezone" json:"browser_timezone"` + BrowserLocale string `toml:"browser_locale" json:"browser_locale"` +} + +// ProviderValue normalizes the token provider ("api" when unset). +func (t Turnstile) ProviderValue() string { + p := strings.ToLower(strings.TrimSpace(t.Provider)) + if p == "" { + return ProviderAPI + } + return p +} + +type Site struct { + Code string `toml:"-"` + BaseURL string `toml:"base_url" json:"base_url"` + AJAXURL string `toml:"ajax_url" json:"ajax_url"` + SiteKey string `toml:"sitekey" json:"sitekey"` + VerifyAction string `toml:"verify_action" json:"verify_action"` + Language string `toml:"language" json:"language"` + Enabled bool `toml:"enabled" json:"enabled"` +} + +type Model struct { + ID string `toml:"-"` + Site string `toml:"site" json:"site"` + UpstreamID string `toml:"upstream_id" json:"upstream_id"` + Label string `toml:"label" json:"label"` + PagePath string `toml:"page_path" json:"page_path"` + BotID int `toml:"bot_id" json:"bot_id"` + PostID int `toml:"post_id" json:"post_id"` + Enabled bool `toml:"enabled" json:"enabled"` +} + +type Config struct { + Server struct { + Host string `toml:"host" json:"host"` + Port int `toml:"port" json:"port"` + LogLevel string `toml:"log_level" json:"log_level"` + } `toml:"server" json:"server"` + + Security struct { + APIKeys []string `toml:"api_keys" json:"api_keys"` + } `toml:"security" json:"security"` + + Admin struct { + Enabled bool `toml:"enabled" json:"enabled"` + Host string `toml:"host" json:"host"` + Port int `toml:"port" json:"port"` + Password string `toml:"password" json:"password"` + } `toml:"admin" json:"admin"` + + Limits struct { + MaxConcurrentPerSite int `toml:"max_concurrent_per_site" json:"max_concurrent_per_site"` + RatePerMinute float64 `toml:"rate_per_minute" json:"rate_per_minute"` + } `toml:"limits" json:"limits"` + + Proxy struct { + URL string `toml:"url" json:"url"` + FallbackURLs []string `toml:"fallback_urls" json:"fallback_urls"` + SlowStartSeconds float64 `toml:"slow_start_seconds" json:"slow_start_seconds"` + } `toml:"proxy" json:"proxy"` + + Upstream struct { + Timeout float64 `toml:"timeout" json:"timeout"` + StreamTimeout float64 `toml:"stream_timeout" json:"stream_timeout"` + ConfigTTLSeconds int `toml:"config_ttl_seconds" json:"config_ttl_seconds"` + AutoRefresh bool `toml:"auto_refresh" json:"auto_refresh"` + RefreshRetries int `toml:"refresh_retries" json:"refresh_retries"` + RetryBackoffSeconds float64 `toml:"retry_backoff_seconds" json:"retry_backoff_seconds"` + UserAgent string `toml:"user_agent" json:"user_agent"` + CrossSiteFailover bool `toml:"cross_site_failover" json:"cross_site_failover"` + } `toml:"upstream" json:"upstream"` + + Runtime struct { + DataDir string `toml:"data_dir" json:"data_dir"` + } `toml:"runtime" json:"runtime"` + + Turnstile Turnstile `toml:"turnstile" json:"turnstile"` + Sites map[string]*Site `toml:"sites" json:"sites"` + Models map[string]*Model `toml:"models" json:"models"` + + mu sync.RWMutex `toml:"-"` + path string `toml:"-"` +} + +func DefaultTurnstile() Turnstile { + return Turnstile{ + Enabled: false, + Provider: ProviderAPI, + APIURL: "", + APIKey: "", + SiteKey: "0x4AAAAAADlLZ3ljqZP6cQwq", + Action: "chat", + TimeoutSeconds: 90, + CookieTTLSeconds: 10800, + Retries: 5, + RetryBackoffSeconds: 1.5, + } +} + +func DefaultSites(sitekey string) map[string]*Site { + return map[string]*Site{ + "de": { + Code: "de", BaseURL: "https://deepseek.de", AJAXURL: "https://deepseek.de/wp-admin/admin-ajax.php", + SiteKey: sitekey, VerifyAction: "deepseek_ts_verify", + Language: "de-DE,de;q=0.9,en-US;q=0.8,en;q=0.7", Enabled: true, + }, + "es": { + Code: "es", BaseURL: "https://deepseek.es", AJAXURL: "https://deepseek.es/wp-admin/admin-ajax.php", + SiteKey: sitekey, VerifyAction: "deepseek_ts_verify", + Language: "es-ES,es;q=0.9,en-US;q=0.8,en;q=0.7", Enabled: true, + }, + "fr": { + Code: "fr", BaseURL: "https://deepseek.fr", AJAXURL: "https://deepseek.fr/wp-admin/admin-ajax.php", + SiteKey: sitekey, VerifyAction: "deepseek_ts_verify", + Language: "fr-FR,fr;q=0.9,en-US;q=0.8,en;q=0.7", Enabled: true, + }, + } +} + +func DefaultModels() map[string]*Model { + rows := [][7]any{ + {"deepseek-v4-flash-de", "de", "deepseek-v4-flash", "DeepSeek V4-Flash DE", "/", 27487, 106}, + {"deepseek-v4-pro-de", "de", "deepseek-v4-pro", "DeepSeek V4-Pro DE", "/pro/", 27533, 27177}, + {"deepseek-v4-flash-es", "es", "deepseek-v4-flash", "DeepSeek V4-Flash ES", "/", 27623, 27568}, + {"deepseek-v4-pro-es", "es", "deepseek-v4-pro", "DeepSeek V4-Pro ES", "/pro/", 27637, 27615}, + {"deepseek-v4-flash-fr", "fr", "deepseek-v4-flash", "DeepSeek V4-Flash FR", "/", 27645, 27569}, + {"deepseek-v4-pro-fr", "fr", "deepseek-v4-pro", "DeepSeek V4-Pro FR", "/pro/", 27647, 27616}, + } + out := make(map[string]*Model, len(rows)) + for _, r := range rows { + id := r[0].(string) + out[id] = &Model{ + ID: id, Site: r[1].(string), UpstreamID: r[2].(string), Label: r[3].(string), + PagePath: r[4].(string), BotID: r[5].(int), PostID: r[6].(int), Enabled: true, + } + } + return out +} + +func newConfig() *Config { + c := &Config{} + c.Server.Host = "0.0.0.0" + c.Server.Port = 8000 + c.Server.LogLevel = "INFO" + c.Admin.Enabled = true + c.Admin.Host = "127.0.0.1" + c.Admin.Port = 8001 + c.Limits.MaxConcurrentPerSite = 4 + c.Limits.RatePerMinute = 0 + c.Proxy.SlowStartSeconds = 8 + c.Upstream.Timeout = 60 + c.Upstream.StreamTimeout = 300 + c.Upstream.ConfigTTLSeconds = 60 + c.Upstream.AutoRefresh = true + c.Upstream.RefreshRetries = 4 + c.Upstream.RetryBackoffSeconds = 1 + c.Upstream.UserAgent = DefaultUserAgent + c.Upstream.CrossSiteFailover = true + c.Runtime.DataDir = "./data" + c.Turnstile = DefaultTurnstile() + c.Sites = DefaultSites(c.Turnstile.SiteKey) + c.Models = DefaultModels() + return c +} + +// Load reads the TOML config (if present), applies defaults, then env overrides. +func Load(path string) (*Config, error) { + cfg := newConfig() + if path == "" { + path = "config.toml" + } + cfg.path = path + + var md toml.MetaData + if raw, err := os.ReadFile(path); err == nil { + md, err = toml.Decode(string(raw), cfg) + if err != nil { + return nil, fmt.Errorf("parse %s: %w", path, err) + } + cfg.applyDefaults(&md) + } else if !errors.Is(err, os.ErrNotExist) { + return nil, fmt.Errorf("read %s: %w", path, err) + } else { + cfg.applyDefaults(nil) + } + + if err := cfg.applyEnv(); err != nil { + return nil, err + } + if cfg.Sites == nil { + cfg.Sites = DefaultSites(cfg.Turnstile.SiteKey) + } + if cfg.Models == nil { + cfg.Models = DefaultModels() + } + if err := cfg.Reindex(); err != nil { + return nil, err + } + return cfg, nil +} + +// Reindex restores derived fields (site codes / model ids) and validates the +// site-model wiring. It is safe to call after mutating the config at runtime. +func (c *Config) Reindex() error { + if c.Sites == nil { + c.Sites = DefaultSites(c.Turnstile.SiteKey) + } + if c.Models == nil { + c.Models = DefaultModels() + } + for code, s := range c.Sites { + if s == nil { + return fmt.Errorf("site %q is null", code) + } + s.Code = code + if s.SiteKey == "" { + s.SiteKey = c.Turnstile.SiteKey + } + if s.VerifyAction == "" { + s.VerifyAction = "deepseek_ts_verify" + } + if s.AJAXURL == "" { + s.AJAXURL = strings.TrimRight(s.BaseURL, "/") + "/wp-admin/admin-ajax.php" + } + if s.BaseURL == "" { + return fmt.Errorf("site %q: base_url is required", code) + } + } + for id, m := range c.Models { + if m == nil { + return fmt.Errorf("model %q is null", id) + } + m.ID = id + if m.Site == "" { + return fmt.Errorf("model %q: site is required", id) + } + if _, ok := c.Sites[m.Site]; !ok { + return fmt.Errorf("model %q: unknown site %q", id, m.Site) + } + if m.PagePath == "" { + m.PagePath = "/" + } + } + return nil +} + +// applyDefaults fills booleans that were absent from the file (TOML decodes +// missing bools as false, but the project defaults are true). +func (c *Config) applyDefaults(md *toml.MetaData) { + def := func(defined bool, dst *bool, want bool) { + if !defined { + *dst = want + } + } + if md == nil { + c.Turnstile.Enabled = true + c.Upstream.AutoRefresh = true + for _, s := range c.Sites { + s.Enabled = true + } + for _, m := range c.Models { + m.Enabled = true + } + return + } + def(md.IsDefined("turnstile", "enabled"), &c.Turnstile.Enabled, true) + def(md.IsDefined("upstream", "auto_refresh"), &c.Upstream.AutoRefresh, true) + for code, s := range c.Sites { + def(md.IsDefined("sites", code, "enabled"), &s.Enabled, true) + } + for id, m := range c.Models { + def(md.IsDefined("models", id, "enabled"), &m.Enabled, true) + } +} + +func (c *Config) applyEnv() error { + str := func(key string) *string { + if v, ok := os.LookupEnv(key); ok { + return &v + } + return nil + } + if v := str("HOST"); v != nil { + c.Server.Host = *v + } + if v := str("LOG_LEVEL"); v != nil { + c.Server.LogLevel = *v + } + if v := str("PORT"); v != nil { + n, err := strconv.Atoi(strings.TrimSpace(*v)) + if err != nil || n < 1 || n > 65535 { + return fmt.Errorf("invalid PORT %q (expected 1-65535)", *v) + } + c.Server.Port = n + } + if v := str("API_KEYS"); v != nil { + c.Security.APIKeys = splitList(*v) + } + if v := str("PROXY_URL"); v != nil { + c.Proxy.URL = strings.TrimSpace(*v) + } + if v := str("PROXY_FALLBACK_URLS"); v != nil { + c.Proxy.FallbackURLs = splitList(*v) + } + if v := str("PROXY_SLOW_START_SECONDS"); v != nil { + f, err := strconv.ParseFloat(strings.TrimSpace(*v), 64) + if err != nil || f < 0 { + return fmt.Errorf("invalid PROXY_SLOW_START_SECONDS %q", *v) + } + c.Proxy.SlowStartSeconds = f + } + if v := str("TURNSTILE_API_KEY"); v != nil && strings.TrimSpace(*v) != "" { + c.Turnstile.APIKey = strings.TrimSpace(*v) + } + if v := str("TURNSTILE_PROVIDER"); v != nil && strings.TrimSpace(*v) != "" { + c.Turnstile.Provider = strings.ToLower(strings.TrimSpace(*v)) + } + if v := str("TURNSTILE_BROWSER_PATH"); v != nil && strings.TrimSpace(*v) != "" { + c.Turnstile.BrowserPath = strings.TrimSpace(*v) + } + if v := str("TURNSTILE_ENABLED"); v != nil { + b, err := strconv.ParseBool(strings.TrimSpace(*v)) + if err != nil { + return fmt.Errorf("invalid TURNSTILE_ENABLED %q", *v) + } + c.Turnstile.Enabled = b + } + if v := str("ADMIN_PASSWORD"); v != nil { + c.Admin.Password = *v + } + if v := str("ADMIN_HOST"); v != nil { + c.Admin.Host = *v + } + if v := str("ADMIN_PORT"); v != nil { + n, err := strconv.Atoi(strings.TrimSpace(*v)) + if err != nil || n < 1 || n > 65535 { + return fmt.Errorf("invalid ADMIN_PORT %q", *v) + } + c.Admin.Port = n + } + if v := str("DATA_DIR"); v != nil && strings.TrimSpace(*v) != "" { + c.Runtime.DataDir = strings.TrimSpace(*v) + } + if v := str("ADMIN_ENABLED"); v != nil { + b, err := strconv.ParseBool(strings.TrimSpace(*v)) + if err != nil { + return fmt.Errorf("invalid ADMIN_ENABLED %q", *v) + } + c.Admin.Enabled = b + } + return nil +} + +func splitList(v string) []string { + out := []string{} + for _, p := range strings.Split(v, ",") { + if p = strings.TrimSpace(p); p != "" { + out = append(out, p) + } + } + return out +} + +func (c *Config) Validate() error { + if c.Server.Port < 1 || c.Server.Port > 65535 { + return fmt.Errorf("server.port out of range: %d", c.Server.Port) + } + if c.Admin.Enabled && (c.Admin.Port < 1 || c.Admin.Port > 65535) { + return fmt.Errorf("admin.port out of range: %d", c.Admin.Port) + } + if c.Admin.Enabled && c.Admin.Port == c.Server.Port { + return errors.New("admin.port must differ from server.port") + } + if c.Upstream.Timeout <= 0 { + return errors.New("upstream.timeout must be > 0") + } + if c.Upstream.StreamTimeout <= 0 { + return errors.New("upstream.stream_timeout must be > 0") + } + if err := c.validateTurnstile(); err != nil { + return err + } + if len(c.Models) == 0 { + return errors.New("no models configured") + } + seen := map[string]bool{} + for _, k := range c.Security.APIKeys { + if seen[k] { + return fmt.Errorf("duplicate api key %q", k) + } + seen[k] = true + } + return nil +} + +// validateTurnstile checks the token provider settings while the solver is on. +// provider = "manual" needs no credentials — cookies are imported by hand. +func (c *Config) validateTurnstile() error { + if !c.Turnstile.Enabled { + return nil + } + switch c.Turnstile.ProviderValue() { + case ProviderAPI: + if c.Turnstile.APIURL == "" { + return errors.New("turnstile.api_url is empty (set config to your solver endpoint, or use provider = \"manual\")") + } + if c.Turnstile.APIKey == "" { + return errors.New("turnstile.api_key is empty (set config, or TURNSTILE_API_KEY)") + } + case ProviderBrowser: + path := strings.TrimSpace(c.Turnstile.BrowserPath) + if path == "" { + return errors.New("turnstile.browser_path is empty (path to Chrome/Edge, or use provider = \"manual\")") + } + if _, err := os.Stat(path); err != nil { + return fmt.Errorf("turnstile.browser_path %q: %w", path, err) + } + case ProviderManual: + // nothing to check — cookies arrive via the console import box + default: + return fmt.Errorf("turnstile.provider %q is invalid (api|browser|manual)", c.Turnstile.Provider) + } + return nil +} + +func (c *Config) RLock() { c.mu.RLock() } +func (c *Config) RUnlock() { c.mu.RUnlock() } +func (c *Config) Lock() { c.mu.Lock() } +func (c *Config) Unlock() { c.mu.Unlock() } + +func (c *Config) Path() string { return c.path } + +// Site returns a site by code (must hold at least a read lock). +func (c *Config) Site(code string) (*Site, bool) { + s, ok := c.Sites[code] + return s, ok +} + +// Model returns a model by id (must hold at least a read lock). +func (c *Config) Model(id string) (*Model, bool) { + m, ok := c.Models[id] + return m, ok +} + +// Save writes the config back to its file after taking a timestamped backup. +func (c *Config) Save() error { + return c.SaveTo(c.path) +} + +func (c *Config) SaveTo(path string) error { + if path == "" { + return errors.New("config path is empty") + } + if _, err := os.Stat(path); err == nil { + backup := path + ".bak" + if raw, err := os.ReadFile(path); err == nil { + _ = os.WriteFile(backup, raw, 0o644) + } + } + if dir := filepath.Dir(path); dir != "" && dir != "." { + _ = os.MkdirAll(dir, 0o755) + } + var sb strings.Builder + enc := toml.NewEncoder(&sb) + enc.Indent = "" + if err := enc.Encode(c); err != nil { + return fmt.Errorf("encode config: %w", err) + } + header := "# dsfree2api configuration\n# Managed by the web console; a .bak copy is kept next to this file.\n\n" + return os.WriteFile(path, []byte(header+sb.String()), 0o644) +} + +// DataDir resolves (and creates) the runtime data directory. +func (c *Config) DataDir() (string, error) { + dir := c.Runtime.DataDir + if dir == "" { + dir = "./data" + } + if err := os.MkdirAll(dir, 0o755); err != nil { + return "", err + } + return dir, nil +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..26dae15 --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,236 @@ +package config + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +const examplePath = "../../config.example.toml" + +func TestLoadSixModelsFromExample(t *testing.T) { + cfg, err := Load(examplePath) + if err != nil { + t.Fatalf("load: %v", err) + } + if len(cfg.Models) != 6 { + t.Fatalf("want 6 models, got %d", len(cfg.Models)) + } + for _, id := range []string{"deepseek-v4-flash-de", "deepseek-v4-pro-fr", "deepseek-v4-pro-es"} { + if _, ok := cfg.Models[id]; !ok { + t.Errorf("missing model %q", id) + } + } + if cfg.Sites["de"].SiteKey != "0x4AAAAAADlLZ3ljqZP6cQwq" { + t.Errorf("unexpected de sitekey %q", cfg.Sites["de"].SiteKey) + } + if err := cfg.Validate(); err != nil { + t.Errorf("validate: %v", err) + } +} + +func TestSecurityKeyConfigured(t *testing.T) { + cfg, err := Load(examplePath) + if err != nil { + t.Fatalf("load: %v", err) + } + if len(cfg.Security.APIKeys) != 1 || cfg.Security.APIKeys[0] != "sk-dsfr-local-change-me" { + t.Fatalf("unexpected api_keys %v", cfg.Security.APIKeys) + } +} + +func TestProxyFallbackEnvConfig(t *testing.T) { + t.Setenv("PROXY_URL", "") + t.Setenv("PROXY_FALLBACK_URLS", "socks5://one:1, socks5://two:2") + t.Setenv("PROXY_SLOW_START_SECONDS", "5") + cfg, err := Load(examplePath) + if err != nil { + t.Fatalf("load: %v", err) + } + if cfg.Proxy.URL != "" { + t.Errorf("proxy url = %q, want empty", cfg.Proxy.URL) + } + want := []string{"socks5://one:1", "socks5://two:2"} + if len(cfg.Proxy.FallbackURLs) != len(want) { + t.Fatalf("fallbacks = %v", cfg.Proxy.FallbackURLs) + } + for i, u := range want { + if cfg.Proxy.FallbackURLs[i] != u { + t.Errorf("fallback[%d] = %q, want %q", i, cfg.Proxy.FallbackURLs[i], u) + } + } + if cfg.Proxy.SlowStartSeconds != 5 { + t.Errorf("slow_start = %v, want 5", cfg.Proxy.SlowStartSeconds) + } +} + +func TestServerEnvOverrides(t *testing.T) { + t.Setenv("HOST", "127.0.0.1") + t.Setenv("PORT", "9999") + t.Setenv("API_KEYS", "sk-one, sk-two") + cfg, err := Load(examplePath) + if err != nil { + t.Fatalf("load: %v", err) + } + if cfg.Server.Host != "127.0.0.1" { + t.Errorf("host = %q", cfg.Server.Host) + } + if cfg.Server.Port != 9999 { + t.Errorf("port = %d", cfg.Server.Port) + } + if len(cfg.Security.APIKeys) != 2 || cfg.Security.APIKeys[1] != "sk-two" { + t.Errorf("api_keys = %v", cfg.Security.APIKeys) + } +} + +func TestValidateRejectsBadValues(t *testing.T) { + cfg, err := Load(examplePath) + if err != nil { + t.Fatalf("load: %v", err) + } + cfg.Turnstile.Enabled = true + cfg.Turnstile.APIURL = "" + cfg.Turnstile.APIKey = "k" + if err := cfg.Validate(); err == nil { + t.Error("expected error for empty turnstile api_url") + } + cfg.Turnstile.APIURL = "https://solver.example.com/turnstile/sync" + cfg.Turnstile.APIKey = "" + if err := cfg.Validate(); err == nil { + t.Error("expected error for empty turnstile api_key") + } + cfg.Turnstile.APIKey = "k" + cfg.Turnstile.Enabled = false + cfg.Admin.Port = cfg.Server.Port + if err := cfg.Validate(); err == nil { + t.Error("expected error for admin.port == server.port") + } +} + +func TestTurnstileDisabledByDefault(t *testing.T) { + if DefaultTurnstile().Enabled { + t.Error("turnstile should be disabled by default") + } + if DefaultTurnstile().APIURL != "" || DefaultTurnstile().APIKey != "" { + t.Error("no solver endpoint/key should be baked in by default") + } + cfg, err := Load(examplePath) + if err != nil { + t.Fatalf("load: %v", err) + } + if cfg.Turnstile.Enabled { + t.Error("example config should ship with turnstile disabled") + } + t.Setenv("TURNSTILE_ENABLED", "true") + cfg, err = Load(examplePath) + if err != nil { + t.Fatalf("load: %v", err) + } + if !cfg.Turnstile.Enabled { + t.Error("TURNSTILE_ENABLED=true should enable the solver") + } +} + +func TestTurnstileProviderValidation(t *testing.T) { + load := func(t *testing.T) *Config { + t.Helper() + cfg, err := Load(examplePath) + if err != nil { + t.Fatalf("load: %v", err) + } + cfg.Turnstile.Enabled = true + return cfg + } + + // provider unset behaves like "api": credentials are mandatory + cfg := load(t) + if p := cfg.Turnstile.ProviderValue(); p != ProviderAPI { + t.Fatalf("ProviderValue() = %q, want api", p) + } + if err := cfg.Validate(); err == nil { + t.Error("api provider without api_url should fail") + } + cfg.Turnstile.APIURL = "https://solver.example.com/turnstile/sync" + cfg.Turnstile.APIKey = "k" + if err := cfg.Validate(); err != nil { + t.Errorf("api provider configured: %v", err) + } + + // browser provider needs an existing executable instead of credentials + cfg = load(t) + cfg.Turnstile.Provider = ProviderBrowser + if err := cfg.Validate(); err == nil || !strings.Contains(err.Error(), "browser_path") { + t.Errorf("browser provider without browser_path: %v", err) + } + cfg.Turnstile.BrowserPath = filepath.Join(t.TempDir(), "msedge.exe") + if err := cfg.Validate(); err == nil { + t.Error("browser_path pointing at a missing file should fail") + } + browser := filepath.Join(t.TempDir(), "msedge.exe") + if err := os.WriteFile(browser, []byte("stub"), 0o755); err != nil { + t.Fatalf("write stub browser: %v", err) + } + cfg.Turnstile.BrowserPath = browser + if err := cfg.Validate(); err != nil { + t.Errorf("browser provider configured: %v", err) + } + + // manual provider never validates credentials + cfg = load(t) + cfg.Turnstile.Provider = ProviderManual + if err := cfg.Validate(); err != nil { + t.Errorf("manual provider: %v", err) + } + + cfg.Turnstile.Provider = "captcha" + if err := cfg.Validate(); err == nil { + t.Error("unknown provider should fail") + } +} + +func TestTurnstileProviderEnv(t *testing.T) { + t.Setenv("TURNSTILE_PROVIDER", " Manual ") + t.Setenv("TURNSTILE_BROWSER_PATH", `C:\browsers\chrome.exe`) + cfg, err := Load(examplePath) + if err != nil { + t.Fatalf("load: %v", err) + } + if cfg.Turnstile.ProviderValue() != ProviderManual { + t.Errorf("provider = %q, want manual", cfg.Turnstile.Provider) + } + if cfg.Turnstile.BrowserPath != `C:\browsers\chrome.exe` { + t.Errorf("browser_path = %q", cfg.Turnstile.BrowserPath) + } +} + +func TestSaveRoundTrip(t *testing.T) { + cfg, err := Load(examplePath) + if err != nil { + t.Fatalf("load: %v", err) + } + cfg.Security.APIKeys = []string{"sk-rotated"} + cfg.Server.Port = 8123 + dst := filepath.Join(t.TempDir(), "config.toml") + if err := cfg.SaveTo(dst); err != nil { + t.Fatalf("save: %v", err) + } + got, err := Load(dst) + if err != nil { + t.Fatalf("reload: %v", err) + } + if got.Server.Port != 8123 { + t.Errorf("port = %d", got.Server.Port) + } + if len(got.Security.APIKeys) != 1 || got.Security.APIKeys[0] != "sk-rotated" { + t.Errorf("api_keys = %v", got.Security.APIKeys) + } + if len(got.Models) != 6 { + t.Errorf("models = %d", len(got.Models)) + } + for id, m := range got.Models { + if m.ID != id { + t.Errorf("model %q has id %q", id, m.ID) + } + } +} diff --git a/internal/httpx/client.go b/internal/httpx/client.go new file mode 100644 index 0000000..1570220 --- /dev/null +++ b/internal/httpx/client.go @@ -0,0 +1,259 @@ +// Package httpx wraps a TLS-fingerprint-impersonating HTTP client behind a +// small stdlib-shaped interface, so the rest of the code base never has to +// depend on the forked net/http types used by the fingerprinting library. +package httpx + +import ( + "bytes" + "context" + "fmt" + "io" + "net/http" + "net/url" + "sort" + "sync" + "time" + + fhttp "github.com/bogdanfinn/fhttp" + tls_client "github.com/bogdanfinn/tls-client" + "github.com/bogdanfinn/tls-client/profiles" +) + +// DefaultUserAgent mirrors config.DefaultUserAgent without an import cycle. +const DefaultUserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36" + +type Request struct { + Method string + URL string + Header map[string]string + Body []byte + Timeout time.Duration // 0 = session-level timeout only +} + +type Response struct { + Status int + Header http.Header + Body []byte +} + +// Session is a browser-like cookie jar plus connection pool, bound to one +// site/proxy route. +type Session interface { + Do(ctx context.Context, r Request) (*Response, error) + Stream(ctx context.Context, r Request) (io.ReadCloser, int, error) + Cookies() map[string]string + SetCookies(map[string]string) + Close() +} + +type Options struct { + ProxyURL string + Timeout time.Duration +} + +type tlsSession struct { + inner tls_client.HttpClient + + mu sync.Mutex + cookies map[string]string + closed bool +} + +// NewSession builds a Chrome-impersonating session. +func NewSession(opts Options) (Session, error) { + timeout := opts.Timeout + if timeout <= 0 { + timeout = 60 * time.Second + } + tlsOpts := []tls_client.HttpClientOption{ + tls_client.WithClientProfile(profiles.Chrome_120), + tls_client.WithTimeoutSeconds(int(timeout.Seconds()) + 1), + tls_client.WithRandomTLSExtensionOrder(), + } + if p := opts.ProxyURL; p != "" { + tlsOpts = append(tlsOpts, tls_client.WithProxyUrl(p)) + } + inner, err := tls_client.NewHttpClient(tls_client.NewNoopLogger(), tlsOpts...) + if err != nil { + return nil, fmt.Errorf("create tls session: %w", err) + } + return &tlsSession{ + inner: inner, + cookies: map[string]string{}, + }, nil +} + +func (s *tlsSession) Cookies() map[string]string { + s.mu.Lock() + defer s.mu.Unlock() + out := make(map[string]string, len(s.cookies)) + for k, v := range s.cookies { + out[k] = v + } + return out +} + +func (s *tlsSession) SetCookies(in map[string]string) { + s.mu.Lock() + defer s.mu.Unlock() + for k, v := range in { + s.cookies[k] = v + } +} + +func (s *tlsSession) Close() { + s.mu.Lock() + if s.closed { + s.mu.Unlock() + return + } + s.closed = true + s.mu.Unlock() + s.inner.CloseIdleConnections() +} + +func (s *tlsSession) newRequest(r Request) (*fhttp.Request, error) { + method := r.Method + if method == "" { + method = http.MethodGet + } + var body io.Reader + if len(r.Body) > 0 { + body = bytes.NewReader(r.Body) + } + req, err := fhttp.NewRequest(method, r.URL, body) + if err != nil { + return nil, fmt.Errorf("build request: %w", err) + } + req.Header = fhttp.Header{} + req.Header.Set("User-Agent", DefaultUserAgent) + req.Header.Set("Accept", "*/*") + if len(r.Body) > 0 && r.Header["Content-Type"] == "" { + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + } + for k, v := range r.Header { + req.Header.Set(k, v) + } + + u, err := url.Parse(r.URL) + if err != nil { + return nil, fmt.Errorf("parse url: %w", err) + } + s.mu.Lock() + names := make([]string, 0, len(s.cookies)) + for k := range s.cookies { + names = append(names, k) + } + sort.Strings(names) + for _, n := range names { + if n == "" { + continue + } + req.AddCookie(&fhttp.Cookie{Name: n, Value: s.cookies[n], Path: "/"}) + } + s.mu.Unlock() + _ = u + return req, nil +} + +func (s *tlsSession) capture(resp *fhttp.Response) { + cookies := (&http.Response{Header: http.Header(resp.Header)}).Cookies() + if len(cookies) == 0 { + for _, line := range http.Header(resp.Header).Values("Set-Cookie") { + if c, err := http.ParseSetCookie(line); err == nil && c.Name != "" { + cookies = append(cookies, c) + } + } + } + if len(cookies) == 0 { + return + } + s.mu.Lock() + defer s.mu.Unlock() + for _, c := range cookies { + if c.Name == "" { + continue + } + if c.MaxAge < 0 { + delete(s.cookies, c.Name) + continue + } + if c.Value == "" { + continue + } + s.cookies[c.Name] = c.Value + } +} + +func (s *tlsSession) Do(ctx context.Context, r Request) (*Response, error) { + req, err := s.newRequest(r) + if err != nil { + return nil, err + } + if ctx == nil { + ctx = context.Background() + } + if r.Timeout > 0 { + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, r.Timeout) + defer cancel() + } + resp, err := s.inner.Do(req.WithContext(ctx)) + if err != nil { + return nil, err + } + defer resp.Body.Close() + s.capture(resp) + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("read body: %w", err) + } + return &Response{Status: resp.StatusCode, Header: http.Header(resp.Header), Body: body}, nil +} + +// Stream performs the request and returns the still-open body. The caller +// must Close it; closing also releases the underlying connection. +func (s *tlsSession) Stream(ctx context.Context, r Request) (io.ReadCloser, int, error) { + req, err := s.newRequest(r) + if err != nil { + return nil, 0, err + } + if ctx == nil { + ctx = context.Background() + } + resp, err := s.inner.Do(req.WithContext(ctx)) + if err != nil { + return nil, 0, err + } + s.capture(resp) + if resp.StatusCode >= 400 { + body, _ := io.ReadAll(resp.Body) + resp.Body.Close() + return nil, resp.StatusCode, fmt.Errorf("upstream status %d: %s", resp.StatusCode, truncate(string(body), 400)) + } + return resp.Body, resp.StatusCode, nil +} + +func truncate(s string, n int) string { + if len(s) <= n { + return s + } + return s[:n] + "..." +} + +// StdClient is a plain net/http client used for non-fingerprinted calls +// (Turnstile solver API, proxy health checks). +func StdClient(proxyURL string, timeout time.Duration) (*http.Client, error) { + transport := &http.Transport{Proxy: http.ProxyFromEnvironment} + if proxyURL != "" { + u, err := url.Parse(proxyURL) + if err != nil { + return nil, fmt.Errorf("invalid proxy url: %w", err) + } + transport.Proxy = http.ProxyURL(u) + } + if timeout <= 0 { + timeout = 60 * time.Second + } + return &http.Client{Transport: transport, Timeout: timeout}, nil +} diff --git a/internal/logbuf/logbuf.go b/internal/logbuf/logbuf.go new file mode 100644 index 0000000..df2354b --- /dev/null +++ b/internal/logbuf/logbuf.go @@ -0,0 +1,238 @@ +// Package logbuf keeps a ring buffer of log entries and fans them out to +// live subscribers for the web console. +package logbuf + +import ( + "context" + "fmt" + "log/slog" + "sync" + "time" +) + +type Entry struct { + TS time.Time `json:"ts"` + Level string `json:"level"` + Msg string `json:"msg"` + Attrs map[string]string `json:"attrs,omitempty"` +} + +type Buffer struct { + mu sync.Mutex + entries []Entry + subs map[int]chan Entry + nextID int + limit int +} + +func New(limit int) *Buffer { + if limit <= 0 { + limit = 500 + } + return &Buffer{subs: map[int]chan Entry{}, limit: limit} +} + +func (b *Buffer) Add(e Entry) { + b.mu.Lock() + b.entries = append(b.entries, e) + if len(b.entries) > b.limit { + b.entries = b.entries[len(b.entries)-b.limit:] + } + chans := make([]chan Entry, 0, len(b.subs)) + for _, ch := range b.subs { + chans = append(chans, ch) + } + b.mu.Unlock() + + for _, ch := range chans { + select { + case ch <- e: + default: + // slow consumer: drop rather than block the logger + } + } +} + +// Recent returns up to n entries, optionally filtered by minimum level. +func (b *Buffer) Recent(minLevel string, n int) []Entry { + min := levelValue(minLevel) + b.mu.Lock() + defer b.mu.Unlock() + out := make([]Entry, 0, n) + for i := len(b.entries) - 1; i >= 0 && len(out) < n; i-- { + if levelValue(b.entries[i].Level) >= min { + out = append(out, b.entries[i]) + } + } + // reverse to chronological order + for i, j := 0, len(out)-1; i < j; i, j = i+1, j-1 { + out[i], out[j] = out[j], out[i] + } + return out +} + +// Subscribe returns a channel of new entries plus an unsubscribe func. +func (b *Buffer) Subscribe() (<-chan Entry, func()) { + b.mu.Lock() + defer b.mu.Unlock() + id := b.nextID + b.nextID++ + ch := make(chan Entry, 128) + b.subs[id] = ch + return ch, func() { + b.mu.Lock() + if c, ok := b.subs[id]; ok { + delete(b.subs, id) + close(c) + } + b.mu.Unlock() + } +} + +func levelValue(l string) int { + switch l { + case "DEBUG", "debug": + return -4 + case "INFO", "info": + return 0 + case "WARN", "WARNING", "warn", "warning": + return 4 + case "ERROR", "error": + return 8 + default: + return -100 + } +} + +// Handler is an slog.Handler that mirrors records into the buffer. +type Handler struct { + parent slog.Handler + buf *Buffer + attrs []slog.Attr + group string +} + +func NewHandler(parent slog.Handler, buf *Buffer) *Handler { + return &Handler{parent: parent, buf: buf} +} + +func (h *Handler) Enabled(_ context.Context, l slog.Level) bool { + return h.parent.Enabled(context.Background(), l) +} + +func (h *Handler) Handle(_ context.Context, r slog.Record) error { + attrs := map[string]string{} + for _, a := range h.attrs { + attrs[a.Key] = fmtValue(a.Value) + } + r.Attrs(func(a slog.Attr) bool { + attrs[a.Key] = fmtValue(a.Value) + return true + }) + if h.group != "" { + grouped := map[string]string{} + for k, v := range attrs { + grouped[h.group+"."+k] = v + } + attrs = grouped + } + level := "INFO" + switch { + case r.Level >= slog.LevelError: + level = "ERROR" + case r.Level >= slog.LevelWarn: + level = "WARN" + case r.Level >= slog.LevelInfo: + level = "INFO" + default: + level = "DEBUG" + } + h.buf.Add(Entry{TS: r.Time, Level: level, Msg: r.Message, Attrs: attrs}) + return h.parent.Handle(context.Background(), r) +} + +func (h *Handler) WithAttrs(attrs []slog.Attr) slog.Handler { + cp := *h + cp.attrs = append(append([]slog.Attr{}, h.attrs...), attrs...) + return &cp +} + +func (h *Handler) WithGroup(name string) slog.Handler { + cp := *h + if h.group == "" { + cp.group = name + } else { + cp.group = h.group + "." + name + } + return &cp +} + +func fmtValue(v slog.Value) string { + switch v.Kind() { + case slog.KindString: + return v.String() + case slog.KindInt64: + return itoa(v.Int64()) + case slog.KindUint64: + return utoa(v.Uint64()) + case slog.KindFloat64: + return ftoa(v.Float64()) + case slog.KindBool: + if v.Bool() { + return "true" + } + return "false" + case slog.KindTime: + return v.Time().Format(time.RFC3339) + case slog.KindDuration: + return v.Duration().String() + case slog.KindAny: + return anyString(v.Any()) + default: + return v.String() + } +} + +func anyString(v any) string { + switch t := v.(type) { + case error: + if t == nil { + return "" + } + return t.Error() + case string: + return t + case nil: + return "" + default: + return fmt.Sprintf("%v", t) + } +} + +func itoa(v int64) string { + if v == 0 { + return "0" + } + neg := v < 0 + if neg { + v = -v + } + var buf [24]byte + i := len(buf) + for v > 0 { + i-- + buf[i] = byte('0' + v%10) + v /= 10 + } + if neg { + i-- + buf[i] = '-' + } + return string(buf[i:]) +} + +func utoa(v uint64) string { return itoa(int64(v)) } + +func ftoa(f float64) string { + return fmt.Sprintf("%g", f) +} diff --git a/internal/metrics/metrics.go b/internal/metrics/metrics.go new file mode 100644 index 0000000..a3d8d00 --- /dev/null +++ b/internal/metrics/metrics.go @@ -0,0 +1,399 @@ +// Package metrics collects runtime counters for the web console. +package metrics + +import ( + "encoding/json" + "os" + "path/filepath" + "sort" + "sync" + "time" +) + +type Status string + +const ( + StatusOK Status = "ok" + StatusEmpty Status = "empty" + StatusError Status = "error" + StatusDenied Status = "unauthorized" + StatusRateLimit Status = "rate_limited" +) + +type Record struct { + At time.Time `json:"at"` + Model string `json:"model"` + ServedBy string `json:"served_by,omitempty"` + Site string `json:"site"` + Stream bool `json:"stream"` + DurationMs int64 `json:"duration_ms"` + TTFTMs int64 `json:"ttft_ms,omitempty"` + PromptTokens int `json:"prompt_tokens"` + CompletionTokens int `json:"completion_tokens"` + Status Status `json:"status"` + Error string `json:"error,omitempty"` + Route string `json:"route,omitempty"` +} + +type modelStats struct { + Requests int64 `json:"requests"` + Errors int64 `json:"errors"` + Empty int64 `json:"empty"` + Prompt int64 `json:"prompt_tokens"` + Completed int64 `json:"completion_tokens"` + LastMs int64 `json:"last_ms"` + SumMs int64 `json:"sum_ms"` +} + +type siteStats struct { + Requests int64 `json:"requests"` + Errors int64 `json:"errors"` + Quota int64 `json:"quota_exhausted"` +} + +type dayStats struct { + Date string `json:"date"` + Requests int64 `json:"requests"` + Prompt int64 `json:"prompt_tokens"` + Complete int64 `json:"completion_tokens"` +} + +type snapshot struct { + StartedAt time.Time `json:"started_at"` + Requests int64 `json:"requests"` + Errors int64 `json:"errors"` + Empty int64 `json:"empty"` + Denied int64 `json:"unauthorized"` + RateLimit int64 `json:"rate_limited"` + Streams int64 `json:"streams"` + InFlight int64 `json:"in_flight"` + Prompt int64 `json:"prompt_tokens"` + Complete int64 `json:"completion_tokens"` + Models map[string]*modelStats `json:"models"` + Sites map[string]*siteStats `json:"sites"` + Days []*dayStats `json:"days"` + Recent []*Record `json:"recent"` + Latency latencyStats `json:"latency"` + RPS float64 `json:"rps"` +} + +type latencyStats struct { + Count int `json:"count"` + P50 int64 `json:"p50"` + P95 int64 `json:"p95"` + Max int64 `json:"max"` +} + +type Recorder struct { + mu sync.Mutex + started time.Time + total int64 + errs int64 + empty int64 + denied int64 + limited int64 + streams int64 + inflight int64 + prompt int64 + complete int64 + models map[string]*modelStats + sites map[string]*siteStats + days map[string]*dayStats + recent []*Record + latency []int64 + path string + stop chan struct{} + once sync.Once +} + +const ( + maxRecent = 200 + maxLatency = 500 +) + +type persisted struct { + Total int64 `json:"total"` + Errors int64 `json:"errors"` + Empty int64 `json:"empty"` + Denied int64 `json:"denied"` + Limited int64 `json:"limited"` + Streams int64 `json:"streams"` + Prompt int64 `json:"prompt"` + Complete int64 `json:"complete"` + Models map[string]*modelStats `json:"models"` + Sites map[string]*siteStats `json:"sites"` + Days map[string]*dayStats `json:"days"` + Recent []*Record `json:"recent"` +} + +func New(dataDir string) *Recorder { + r := &Recorder{ + started: time.Now(), + models: map[string]*modelStats{}, + sites: map[string]*siteStats{}, + days: map[string]*dayStats{}, + path: filepath.Join(dataDir, "metrics.json"), + stop: make(chan struct{}), + } + r.load() + return r +} + +func (r *Recorder) Start() { + go func() { + t := time.NewTicker(20 * time.Second) + defer t.Stop() + for { + select { + case <-t.C: + r.Save() + case <-r.stop: + return + } + } + }() +} + +func (r *Recorder) Close() { + r.once.Do(func() { close(r.stop) }) + r.Save() +} + +func (r *Recorder) IncInFlight(delta int64) { + r.mu.Lock() + r.inflight += delta + if r.inflight < 0 { + r.inflight = 0 + } + r.mu.Unlock() +} + +// Record stores one completed request. +func (r *Recorder) Record(rec Record) { + if rec.At.IsZero() { + rec.At = time.Now() + } + if rec.Model == "" && rec.ServedBy == "" { + rec.Model = "unknown" + } + if rec.Site == "" { + rec.Site = "unknown" + } + r.mu.Lock() + defer r.mu.Unlock() + + r.total++ + r.prompt += int64(rec.PromptTokens) + r.complete += int64(rec.CompletionTokens) + if rec.Stream { + r.streams++ + } + switch rec.Status { + case StatusError: + r.errs++ + case StatusEmpty: + r.empty++ + r.errs++ + case StatusDenied: + r.denied++ + case StatusRateLimit: + r.limited++ + } + + ms := rec.Model + if rec.ServedBy != "" { + ms = rec.ServedBy + } + m := r.models[ms] + if m == nil { + m = &modelStats{} + r.models[ms] = m + } + m.Requests++ + if rec.Status == StatusError || rec.Status == StatusEmpty { + m.Errors++ + } + if rec.Status == StatusEmpty { + m.Empty++ + } + m.Prompt += int64(rec.PromptTokens) + m.Completed += int64(rec.CompletionTokens) + m.LastMs = rec.DurationMs + m.SumMs += rec.DurationMs + + if rec.Site != "" { + s := r.sites[rec.Site] + if s == nil { + s = &siteStats{} + r.sites[rec.Site] = s + } + s.Requests++ + if rec.Status == StatusError || rec.Status == StatusEmpty { + s.Errors++ + } + } + + day := rec.At.Local().Format("2006-01-02") + d := r.days[day] + if d == nil { + d = &dayStats{Date: day} + r.days[day] = d + } + d.Requests++ + d.Prompt += int64(rec.PromptTokens) + d.Complete += int64(rec.CompletionTokens) + for len(r.days) > 31 { + keys := make([]string, 0, len(r.days)) + for k := range r.days { + keys = append(keys, k) + } + sort.Strings(keys) + delete(r.days, keys[0]) + } + + cp := rec + r.recent = append(r.recent, &cp) + if len(r.recent) > maxRecent { + r.recent = r.recent[len(r.recent)-maxRecent:] + } + if rec.DurationMs > 0 { + r.latency = append(r.latency, rec.DurationMs) + if len(r.latency) > maxLatency { + r.latency = r.latency[len(r.latency)-maxLatency:] + } + } +} + +func (r *Recorder) Snapshot() any { + r.mu.Lock() + defer r.mu.Unlock() + + var lat []int64 + lat = append(lat, r.latency...) + var p50, p95, max int64 + if n := len(lat); n > 0 { + sorted := append([]int64(nil), lat...) + sort.Slice(sorted, func(i, j int) bool { return sorted[i] < sorted[j] }) + p50 = sorted[n*50/100] + p95 = sorted[n*95/100] + if p95 == 0 { + p95 = sorted[n-1] + } + max = sorted[n-1] + } + + models := make(map[string]*modelStats, len(r.models)) + for k, v := range r.models { + cp := *v + if cp.Requests > 0 { + cp.SumMs = v.SumMs + } + models[k] = &cp + } + sites := make(map[string]*siteStats, len(r.sites)) + for k, v := range r.sites { + cp := *v + sites[k] = &cp + } + days := make([]*dayStats, 0, len(r.days)) + for _, v := range r.days { + cp := *v + days = append(days, &cp) + } + sort.Slice(days, func(i, j int) bool { return days[i].Date < days[j].Date }) + + recent := make([]*Record, 0, len(r.recent)) + for i := len(r.recent) - 1; i >= 0; i-- { + recent = append(recent, r.recent[i]) + } + + var window int64 + cutoff := time.Now().Add(-time.Minute) + for i := len(r.recent) - 1; i >= 0; i-- { + if r.recent[i].At.After(cutoff) { + window++ + } + } + + return &snapshot{ + StartedAt: r.started, + Requests: r.total, + Errors: r.errs, + Empty: r.empty, + Denied: r.denied, + RateLimit: r.limited, + Streams: r.streams, + InFlight: r.inflight, + Prompt: r.prompt, + Complete: r.complete, + Models: models, + Sites: sites, + Days: days, + Recent: recent, + Latency: latencyStats{Count: len(lat), P50: p50, P95: p95, Max: max}, + RPS: float64(window) / 60.0, + } +} + +// Reset clears all counters (web console action). +func (r *Recorder) Reset() { + r.mu.Lock() + defer r.mu.Unlock() + r.total, r.errs, r.empty, r.denied, r.limited, r.streams = 0, 0, 0, 0, 0, 0 + r.prompt, r.complete = 0, 0 + r.models = map[string]*modelStats{} + r.sites = map[string]*siteStats{} + r.days = map[string]*dayStats{} + r.recent = nil + r.latency = nil +} + +func (r *Recorder) Save() { + r.mu.Lock() + p := persisted{ + Total: r.total, Errors: r.errs, Empty: r.empty, Denied: r.denied, + Limited: r.limited, Streams: r.streams, Prompt: r.prompt, Complete: r.complete, + Models: r.models, Sites: r.sites, Days: r.days, Recent: r.recent, + } + path := r.path + r.mu.Unlock() + + if path == "" { + return + } + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return + } + raw, err := json.MarshalIndent(p, "", " ") + if err != nil { + return + } + tmp := path + ".tmp" + if err := os.WriteFile(tmp, raw, 0o644); err != nil { + return + } + _ = os.Rename(tmp, path) +} + +func (r *Recorder) load() { + raw, err := os.ReadFile(r.path) + if err != nil { + return + } + var p persisted + if err := json.Unmarshal(raw, &p); err != nil { + return + } + r.total, r.errs, r.empty, r.denied, r.limited = p.Total, p.Errors, p.Empty, p.Denied, p.Limited + r.streams, r.prompt, r.complete = p.Streams, p.Prompt, p.Complete + if p.Models != nil { + r.models = p.Models + } + if p.Sites != nil { + r.sites = p.Sites + } + if p.Days != nil { + r.days = p.Days + } + r.recent = p.Recent +} diff --git a/internal/openai/prompt.go b/internal/openai/prompt.go new file mode 100644 index 0000000..a71d2b7 --- /dev/null +++ b/internal/openai/prompt.go @@ -0,0 +1,292 @@ +package openai + +import ( + "bytes" + "encoding/json" + "fmt" + "strings" +) + +// ContentToText flattens OpenAI content parts into plain text. +func ContentToText(parts []map[string]any) string { + var out []string + for _, p := range parts { + t, _ := p["type"].(string) + switch t { + case "text", "input_text", "output_text": + if v, ok := p["text"].(string); ok && v != "" { + out = append(out, v) + } + } + } + return strings.Join(out, "\n") +} + +type PromptOptions struct { + Tools []ToolDef + ToolChoice json.RawMessage + Temperature *float64 + TopP *float64 + MaxTokens *int + Stop []string + ResponseFormat *ResponseFormat + SystemHint string +} + +// BuildPrompt flattens a chat transcript into the single prompt string the +// upstream AIPKit endpoint expects. +func BuildPrompt(messages []ChatMessage, opts PromptOptions) string { + parts := make([]string, 0, len(messages)+4) + for _, msg := range messages { + content := msg.Content.String() + switch msg.Role { + case "system", "developer": + tag := "System" + if msg.Role == "developer" { + tag = "Developer" + } + if opts.SystemHint != "" { + content = strings.TrimSpace(content + "\n\n" + opts.SystemHint) + } + parts = append(parts, fmt.Sprintf("[%s]\n%s", tag, content)) + case "user": + parts = append(parts, "[User]\n"+content) + case "assistant": + if len(msg.ToolCalls) > 0 { + raw, _ := json.Marshal(msg.ToolCalls) + parts = append(parts, fmt.Sprintf("[Assistant]\n%s\n[Tool Calls]\n%s", content, raw)) + } else { + parts = append(parts, "[Assistant]\n"+content) + } + case "tool": + parts = append(parts, fmt.Sprintf("[Tool Result (id=%s)]\n%s", msg.ToolCallID, content)) + default: + title := "Message" + if msg.Role != "" { + title = strings.ToUpper(msg.Role[:1]) + msg.Role[1:] + } + parts = append(parts, fmt.Sprintf("[%s]\n%s", title, content)) + } + } + if len(opts.Tools) > 0 { + parts = append(parts, toolsPrompt(opts.Tools, opts.ToolChoice)) + } + if tail := samplingPrompt(opts); tail != "" { + parts = append(parts, tail) + } + return strings.Join(parts, "\n\n") +} + +func samplingPrompt(opts PromptOptions) string { + var lines []string + add := func(format string, a ...any) { + lines = append(lines, fmt.Sprintf(format, a...)) + } + if opts.Temperature != nil { + add("- Temperature: %.3g (0 = most deterministic, 2 = most creative).", *opts.Temperature) + } + if opts.TopP != nil { + add("- Top-p nucleus sampling: %.3g.", *opts.TopP) + } + if opts.MaxTokens != nil { + add("- Hard limit: produce at most %d tokens of output, then stop.", *opts.MaxTokens) + } + if len(opts.Stop) > 0 { + add("- Stop sequences (do not emit them): %s", strings.Join(opts.Stop, " | ")) + } + if opts.ResponseFormat != nil && opts.ResponseFormat.Type == "json_object" { + add("- Respond with a single valid JSON object only, no prose, no markdown fences.") + } + if len(lines) == 0 { + return "" + } + return "=== GENERATION CONSTRAINTS ===\n" + strings.Join(lines, "\n") + "\n=== END GENERATION CONSTRAINTS ===" +} + +// ResponsesToMessages converts an OpenAI Responses API request into chat +// messages. Besides plain message items it understands function_call and +// function_call_output items so multi-turn tool loops round-trip. +func ResponsesToMessages(req *ResponsesRequest) ([]ChatMessage, error) { + var out []ChatMessage + if req.Instructions != "" { + out = append(out, ChatMessage{Role: "system", Content: MessageContent{Text: req.Instructions}}) + } + trimmed := strings.TrimSpace(string(req.Input)) + if trimmed == "" { + return nil, fmt.Errorf("input is required") + } + if trimmed[0] == '"' { + var s string + if err := json.Unmarshal(req.Input, &s); err != nil { + return nil, fmt.Errorf("invalid input: %w", err) + } + out = append(out, ChatMessage{Role: "user", Content: MessageContent{Text: s}}) + return out, nil + } + var items []json.RawMessage + if err := json.Unmarshal(req.Input, &items); err != nil { + return nil, fmt.Errorf("invalid input: %w", err) + } + for _, raw := range items { + var probe struct { + Type string `json:"type"` + Role string `json:"role"` + } + if err := json.Unmarshal(raw, &probe); err != nil { + continue + } + switch probe.Type { + case "function_call": + var fc struct { + CallID string `json:"call_id"` + ID string `json:"id"` + Name string `json:"name"` + Arguments json.RawMessage `json:"arguments"` + } + _ = json.Unmarshal(raw, &fc) + id := fc.CallID + if id == "" { + id = fc.ID + } + if id == "" { + id = "call_" + randomHex(8) + } + out = append(out, ChatMessage{Role: "assistant", ToolCalls: []ToolCall{{ + ID: id, Type: "function", + Function: FunctionCall{Name: fc.Name, Arguments: rawArgText(fc.Arguments)}, + }}}) + case "function_call_output": + var fco struct { + CallID string `json:"call_id"` + Output json.RawMessage `json:"output"` + } + _ = json.Unmarshal(raw, &fco) + out = append(out, ChatMessage{ + Role: "tool", + ToolCallID: fco.CallID, + Content: MessageContent{Text: rawArgText(fco.Output)}, + }) + case "message", "": + role := probe.Role + if role == "" { + role = "user" + } + var it struct { + Content json.RawMessage `json:"content"` + } + _ = json.Unmarshal(raw, &it) + content := MessageContent{} + _ = json.Unmarshal(it.Content, &content) + out = append(out, ChatMessage{Role: role, Content: content}) + default: + // reasoning and other item types have no chat representation + } + } + return out, nil +} + +// rawArgText renders a Responses field that may arrive either as a JSON string +// or as a bare JSON value into plain text. +func rawArgText(raw json.RawMessage) string { + trim := bytes.TrimSpace(raw) + if len(trim) == 0 || string(trim) == "null" { + return "" + } + if trim[0] == '"' { + var s string + if json.Unmarshal(trim, &s) == nil { + return s + } + return "" + } + return string(trim) +} + +func toolsPrompt(tools []ToolDef, toolChoice json.RawMessage) string { + lines := []string{ + "", + "=== TOOL INSTRUCTIONS ===", + "You have access to the following tools. When you need to use a tool, " + + "you MUST output ONLY a single JSON object in this exact format " + + "(no markdown, no explanations, no extra text before or after the JSON):", + "", + `{"tool_calls": [{"id": "call_xxx", "type": "function", "function": {"name": "tool_name", "arguments": "{\"param\": \"value\"}"}}]}`, + "", + "Available tools:", + } + for i, tool := range tools { + fn := tool.Function + desc := strings.TrimSpace(fn.Description) + if desc == "" { + desc = "No description" + } + paramDesc := "" + if len(fn.Parameters) > 0 { + var schema struct { + Properties map[string]struct { + Type string `json:"type"` + } `json:"properties"` + Required []string `json:"required"` + } + if err := json.Unmarshal(fn.Parameters, &schema); err == nil && len(schema.Properties) > 0 { + required := map[string]bool{} + for _, r := range schema.Required { + required[r] = true + } + var pl []string + for name, p := range schema.Properties { + ptype := p.Type + if ptype == "" { + ptype = "any" + } + mark := "" + if required[name] { + mark = " (required)" + } + pl = append(pl, fmt.Sprintf(" - %s: %s%s", name, ptype, mark)) + } + // Keep the schema order stable across runs. + sortStrings(pl) + paramDesc = "\n" + strings.Join(pl, "\n") + } + } + lines = append(lines, fmt.Sprintf("%d. %s: %s%s", i+1, fn.Name, desc, paramDesc)) + } + + forced := "" + if len(toolChoice) > 0 { + var tc struct { + Function struct { + Name string `json:"name"` + } `json:"function"` + Type string `json:"type"` + Name string `json:"name"` + } + if err := json.Unmarshal(toolChoice, &tc); err == nil && (tc.Type == "function" || tc.Function.Name != "") { + forced = tc.Function.Name + if forced == "" { + forced = tc.Name + } + } else if s := strings.Trim(string(toolChoice), `"`); s != "auto" && s != "none" && s != "" && s != "required" && !strings.HasPrefix(s, "{") { + forced = s + } + } + lines = append(lines, "") + if forced != "" { + lines = append(lines, + fmt.Sprintf("IMPORTANT: You MUST use the tool '%s' for this request. Output ONLY the JSON object above, nothing else.", forced)) + } else { + lines = append(lines, + "If no tool is needed, reply normally. If a tool is needed, output ONLY the JSON object above.") + } + lines = append(lines, "=== END TOOL INSTRUCTIONS ===", "") + return strings.Join(lines, "\n") +} + +func sortStrings(s []string) { + for i := 1; i < len(s); i++ { + for j := i; j > 0 && s[j] < s[j-1]; j-- { + s[j], s[j-1] = s[j-1], s[j] + } + } +} diff --git a/internal/openai/prompt_test.go b/internal/openai/prompt_test.go new file mode 100644 index 0000000..f629609 --- /dev/null +++ b/internal/openai/prompt_test.go @@ -0,0 +1,185 @@ +package openai + +import ( + "encoding/json" + "strings" + "testing" +) + +func TestBuildPromptKeepsRolesAndTextParts(t *testing.T) { + msgs := []ChatMessage{ + {Role: "system", Content: MessageContent{Text: "Rules"}}, + {Role: "user", Content: MessageContent{Parts: []map[string]any{ + {"type": "text", "text": "Hello"}, + }}}, + } + prompt := BuildPrompt(msgs, PromptOptions{}) + for _, want := range []string{"[System]", "Rules", "[User]", "Hello"} { + if !strings.Contains(prompt, want) { + t.Errorf("prompt missing %q:\n%s", want, prompt) + } + } +} + +func TestBuildPromptWithTools(t *testing.T) { + prompt := BuildPrompt([]ChatMessage{ + {Role: "user", Content: MessageContent{Text: "Hi"}}, + }, PromptOptions{Tools: []ToolDef{{ + Type: "function", + Function: Function{Name: "search", Description: "Search the web"}, + }}}) + if !strings.Contains(prompt, "=== TOOL INSTRUCTIONS ===") { + t.Error("missing tool instructions marker") + } + if !strings.Contains(prompt, "search") { + t.Error("missing tool name") + } +} + +func TestBuildPromptAppendsSamplingConstraints(t *testing.T) { + temp := 0.4 + max := 512 + prompt := BuildPrompt([]ChatMessage{ + {Role: "user", Content: MessageContent{Text: "Hi"}}, + }, PromptOptions{Temperature: &temp, MaxTokens: &max, Stop: []string{"###"}}) + if !strings.Contains(prompt, "=== GENERATION CONSTRAINTS ===") { + t.Error("missing constraints marker") + } + if !strings.Contains(prompt, "0.4") || !strings.Contains(prompt, "512") || !strings.Contains(prompt, "###") { + t.Errorf("constraints missing values:\n%s", prompt) + } +} + +func TestResponsesRequestToMessages(t *testing.T) { + req := &ResponsesRequest{ + Model: "deepseek-v4-flash-de", + Instructions: "Be terse", + Input: json.RawMessage(`"Hi"`), + } + msgs, err := ResponsesToMessages(req) + if err != nil { + t.Fatalf("responses_to_messages: %v", err) + } + if len(msgs) != 2 || msgs[0].Role != "system" || msgs[1].Role != "user" { + t.Fatalf("roles = %v", []string{msgs[0].Role, msgs[1].Role}) + } +} + +func TestResponsesRequestMessageArrayInput(t *testing.T) { + req := &ResponsesRequest{Input: json.RawMessage(`[ + {"role":"user","content":"a"}, + {"role":"assistant","content":"b"} + ]`)} + msgs, err := ResponsesToMessages(req) + if err != nil { + t.Fatalf("responses_to_messages: %v", err) + } + if len(msgs) != 2 || msgs[0].Content.Text != "a" || msgs[1].Content.Text != "b" { + t.Fatalf("msgs = %+v", msgs) + } +} + +func TestResponsesRequestFunctionCallRoundTrip(t *testing.T) { + req := &ResponsesRequest{Input: json.RawMessage(`[ + {"type":"message","role":"user","content":"weather in Paris?"}, + {"type":"function_call","call_id":"call_1","name":"get_weather", + "arguments":"{\"city\":\"Paris\"}"}, + {"type":"function_call_output","call_id":"call_1","output":"Sunny"}, + {"type":"message","role":"assistant","content":[{"type":"output_text","text":"It is sunny."}]} + ]`)} + msgs, err := ResponsesToMessages(req) + if err != nil { + t.Fatalf("responses_to_messages: %v", err) + } + if len(msgs) != 4 { + t.Fatalf("len(msgs) = %d, want 4: %+v", len(msgs), msgs) + } + if msgs[1].Role != "assistant" || len(msgs[1].ToolCalls) != 1 { + t.Fatalf("assistant = %+v", msgs[1]) + } + if msgs[1].ToolCalls[0].ID != "call_1" || msgs[1].ToolCalls[0].Function.Name != "get_weather" { + t.Errorf("tool_call = %+v", msgs[1].ToolCalls[0]) + } + if msgs[1].ToolCalls[0].Function.Arguments != `{"city":"Paris"}` { + t.Errorf("arguments = %q", msgs[1].ToolCalls[0].Function.Arguments) + } + if msgs[2].Role != "tool" || msgs[2].ToolCallID != "call_1" || msgs[2].Content.Text != "Sunny" { + t.Errorf("tool = %+v", msgs[2]) + } + if msgs[3].Role != "assistant" || msgs[3].Content.String() != "It is sunny." { + t.Errorf("final assistant = %+v", msgs[3]) + } +} + +func TestResponsesToolToToolDef(t *testing.T) { + flat := ResponsesTool{Type: "function", Name: "f", Description: "d", + Parameters: json.RawMessage(`{"type":"object"}`)} + def, ok := flat.ToToolDef() + if !ok || def.Function.Name != "f" || def.Type != "function" { + t.Fatalf("flat = %+v ok=%v", def, ok) + } + nested := ResponsesTool{Type: "function", Function: &Function{Name: "g"}} + def, ok = nested.ToToolDef() + if !ok || def.Function.Name != "g" { + t.Fatalf("nested = %+v ok=%v", def, ok) + } + if _, ok := (ResponsesTool{Type: "web_search_preview"}).ToToolDef(); ok { + t.Error("non-function tool should be dropped") + } +} + +func TestToolsPromptForcesFlatToolChoiceName(t *testing.T) { + prompt := BuildPrompt([]ChatMessage{ + {Role: "user", Content: MessageContent{Text: "Hi"}}, + }, PromptOptions{ + Tools: []ToolDef{{Type: "function", Function: Function{Name: "get_weather"}}}, + ToolChoice: json.RawMessage(`{"type":"function","name":"get_weather"}`), + }) + if !strings.Contains(prompt, "MUST use the tool 'get_weather'") { + t.Errorf("forced tool missing:\n%s", prompt) + } +} + +func TestTryParseToolCallsPlainJSON(t *testing.T) { + text := `{"tool_calls":[{"id":"call_001","type":"function","function":{"name":"get_weather","arguments":"{\"city\":\"Beijing\"}"}}]}` + remaining, calls, ok := TryParseToolCalls(text) + if !ok || calls == nil { + t.Fatalf("expected tool calls, got ok=%v calls=%v", ok, calls) + } + if remaining != "" { + t.Errorf("remaining = %q", remaining) + } + if len(calls) != 1 || calls[0].Function.Name != "get_weather" { + t.Fatalf("calls = %+v", calls) + } + if calls[0].Function.Arguments != `{"city":"Beijing"}` { + t.Errorf("arguments = %q", calls[0].Function.Arguments) + } +} + +func TestTryParseToolCallsMarkdownJSONBlock(t *testing.T) { + text := "Sure, here you go:\n```json\n" + + `{"tool_calls":[{"id":"c1","type":"function","function":{"name":"search","arguments":"{\"q\":\"hello\"}"}}]}` + + "\n```\nDone." + remaining, calls, ok := TryParseToolCalls(text) + if !ok || calls == nil { + t.Fatalf("expected tool calls, got ok=%v", ok) + } + if !strings.Contains(remaining, "Sure") { + t.Errorf("remaining = %q", remaining) + } + if calls[0].Function.Name != "search" { + t.Errorf("name = %q", calls[0].Function.Name) + } +} + +func TestTryParseToolCallsNoToolCall(t *testing.T) { + text := "The weather in Beijing is sunny." + remaining, calls, ok := TryParseToolCalls(text) + if ok || calls != nil { + t.Fatalf("expected no tool calls, got ok=%v calls=%v", ok, calls) + } + if remaining != text { + t.Errorf("remaining = %q", remaining) + } +} diff --git a/internal/openai/toolcalls.go b/internal/openai/toolcalls.go new file mode 100644 index 0000000..8885980 --- /dev/null +++ b/internal/openai/toolcalls.go @@ -0,0 +1,117 @@ +package openai + +import ( + "crypto/rand" + "encoding/json" + "strings" +) + +// TryParseToolCalls extracts an OpenAI-style tool_calls object from raw model +// output. It mirrors the Python implementation: plain JSON, ```json fences and +// generic ``` fences are all candidates. +// +// Returns the remaining prose plus the parsed tool calls (ok=false when the +// text is ordinary chat). +func TryParseToolCalls(text string) (string, []ToolCall, bool) { + trimmed := strings.TrimSpace(text) + var candidates []string + if strings.HasPrefix(trimmed, "{") { + candidates = append(candidates, trimmed) + } + if strings.Contains(trimmed, "```json") { + for _, part := range strings.Split(trimmed, "```json")[1:] { + code := strings.TrimSpace(firstBlock(part)) + if strings.HasPrefix(code, "{") { + candidates = append(candidates, code) + } + } + } else if strings.Contains(trimmed, "```") { + for _, part := range strings.Split(trimmed, "```")[1:] { + code := strings.TrimSpace(firstBlock(part)) + if strings.HasPrefix(code, "{") { + candidates = append(candidates, code) + } + } + } + + for _, cand := range candidates { + var payload struct { + ToolCalls []struct { + ID string `json:"id"` + Type string `json:"type"` + Function json.RawMessage `json:"function"` + } `json:"tool_calls"` + } + if err := json.Unmarshal([]byte(cand), &payload); err != nil { + continue + } + if payload.ToolCalls == nil { + continue + } + calls := make([]ToolCall, 0, len(payload.ToolCalls)) + for i, tc := range payload.ToolCalls { + var fn struct { + Name string `json:"name"` + Arguments json.RawMessage `json:"arguments"` + } + _ = json.Unmarshal(tc.Function, &fn) + args := "{}" + if len(fn.Arguments) > 0 { + var s string + if err := json.Unmarshal(fn.Arguments, &s); err == nil { + args = s + } else { + // arguments already delivered as an object + var obj any + if json.Unmarshal(fn.Arguments, &obj) == nil { + raw, _ := json.Marshal(obj) + args = string(raw) + } + } + } + id := tc.ID + if id == "" { + id = "call_" + randomHex(8) + } + typ := tc.Type + if typ == "" { + typ = "function" + } + _ = i + calls = append(calls, ToolCall{ + ID: id, + Type: typ, + Function: FunctionCall{ + Name: fn.Name, + Arguments: args, + }, + }) + } + remaining := strings.ReplaceAll(text, cand, "") + remaining = strings.ReplaceAll(remaining, "```json", "") + remaining = strings.ReplaceAll(remaining, "```", "") + return strings.TrimSpace(remaining), calls, true + } + return text, nil, false +} + +func firstBlock(s string) string { + if i := strings.Index(s, "```"); i >= 0 { + return s[:i] + } + return s +} + +func randomHex(n int) string { + b := make([]byte, n) + if _, err := rand.Read(b); err != nil { + return "00000000"[:n] + } + const hexdigits = "0123456789abcdef" + out := make([]byte, n*2) + for i, v := range b { + out[i*2] = hexdigits[v>>4] + out[i*2+1] = hexdigits[v&0x0f] + } + return string(out) +} diff --git a/internal/openai/types.go b/internal/openai/types.go new file mode 100644 index 0000000..87adc2e --- /dev/null +++ b/internal/openai/types.go @@ -0,0 +1,210 @@ +package openai + +import "encoding/json" + +type ChatMessage struct { + Role string `json:"role"` + Content MessageContent `json:"content,omitempty"` + Name string `json:"name,omitempty"` + ToolCalls []ToolCall `json:"tool_calls,omitempty"` + ToolCallID string `json:"tool_call_id,omitempty"` +} + +// MessageContent accepts either a plain string or the OpenAI content-part +// array, unmarshalling into whichever shape arrives on the wire. +type MessageContent struct { + Text string + Parts []map[string]any + IsRaw bool +} + +func (m *MessageContent) UnmarshalJSON(data []byte) error { + if len(data) > 0 && data[0] == '"' { + var s string + if err := json.Unmarshal(data, &s); err != nil { + return err + } + m.Text = s + m.Parts = nil + m.IsRaw = false + return nil + } + if string(data) == "null" { + m.Text = "" + m.Parts = nil + return nil + } + var parts []map[string]any + if err := json.Unmarshal(data, &parts); err != nil { + return err + } + m.Parts = parts + m.Text = "" + m.IsRaw = true + return nil +} + +func (m MessageContent) MarshalJSON() ([]byte, error) { + if m.IsRaw && m.Parts != nil { + return json.Marshal(m.Parts) + } + return json.Marshal(m.Text) +} + +func (m MessageContent) String() string { + if m.IsRaw || (m.Text == "" && len(m.Parts) > 0) { + return ContentToText(m.Parts) + } + return m.Text +} + +type ResponseFormat struct { + Type string `json:"type,omitempty"` + JSONSchema json.RawMessage `json:"json_schema,omitempty"` +} + +type StreamOptions struct { + IncludeUsage bool `json:"include_usage,omitempty"` +} + +type ChatCompletionRequest struct { + Model string `json:"model"` + Messages []ChatMessage `json:"messages"` + Stream bool `json:"stream,omitempty"` + Temperature *float64 `json:"temperature,omitempty"` + TopP *float64 `json:"top_p,omitempty"` + MaxTokens *int `json:"max_tokens,omitempty"` + MaxCompletionTok *int `json:"max_completion_tokens,omitempty"` + Tools []ToolDef `json:"tools,omitempty"` + ToolChoice json.RawMessage `json:"tool_choice,omitempty"` + User string `json:"user,omitempty"` + Stop []string `json:"stop,omitempty"` + ResponseFormat *ResponseFormat `json:"response_format,omitempty"` + StreamOptions *StreamOptions `json:"stream_options,omitempty"` + N *int `json:"n,omitempty"` + Seed *int `json:"seed,omitempty"` +} + +type ToolDef struct { + Type string `json:"type,omitempty"` + Function Function `json:"function"` +} + +type Function struct { + Name string `json:"name"` + Description string `json:"description,omitempty"` + Parameters json.RawMessage `json:"parameters,omitempty"` +} + +type ToolCall struct { + ID string `json:"id"` + Type string `json:"type"` + Function FunctionCall `json:"function"` +} + +type FunctionCall struct { + Name string `json:"name"` + Arguments string `json:"arguments"` +} + +type ResponsesRequest struct { + Model string `json:"model"` + Input json.RawMessage `json:"input"` + Stream bool `json:"stream,omitempty"` + Instructions string `json:"instructions,omitempty"` + Temperature *float64 `json:"temperature,omitempty"` + TopP *float64 `json:"top_p,omitempty"` + MaxOutputTokens *int `json:"max_output_tokens,omitempty"` + Tools []ResponsesTool `json:"tools,omitempty"` + ToolChoice json.RawMessage `json:"tool_choice,omitempty"` +} + +// ResponsesTool is a function tool in the flat Responses API shape; the nested +// Chat Completions shape is tolerated as well. Non-function tools (web search +// etc.) are dropped because the upstream cannot execute them. +type ResponsesTool struct { + Type string `json:"type"` + Name string `json:"name,omitempty"` + Description string `json:"description,omitempty"` + Parameters json.RawMessage `json:"parameters,omitempty"` + Strict bool `json:"strict,omitempty"` + Function *Function `json:"function,omitempty"` +} + +// ToToolDef normalizes the tool onto the Chat Completions function shape. +func (t ResponsesTool) ToToolDef() (ToolDef, bool) { + if t.Function != nil && t.Function.Name != "" { + return ToolDef{Type: "function", Function: *t.Function}, true + } + if t.Name == "" || (t.Type != "" && t.Type != "function") { + return ToolDef{}, false + } + return ToolDef{Type: "function", Function: Function{ + Name: t.Name, Description: t.Description, Parameters: t.Parameters, + }}, true +} + +type ChoiceMessage struct { + Role string `json:"role"` + Content string `json:"content"` + ToolCalls []ToolCall `json:"tool_calls,omitempty"` +} + +type Choice struct { + Index int `json:"index"` + Message ChoiceMessage `json:"message"` + FinishReason string `json:"finish_reason"` +} + +type Usage struct { + PromptTokens int `json:"prompt_tokens"` + CompletionTokens int `json:"completion_tokens"` + TotalTokens int `json:"total_tokens"` +} + +type ChatCompletionResponse struct { + ID string `json:"id"` + Object string `json:"object"` + Created int64 `json:"created"` + Model string `json:"model"` + Choices []Choice `json:"choices"` + Usage Usage `json:"usage"` +} + +type ChoiceDelta struct { + Role string `json:"role,omitempty"` + Content string `json:"content,omitempty"` + ToolCalls []ToolCall `json:"tool_calls,omitempty"` +} + +type StreamChoice struct { + Index int `json:"index"` + Delta ChoiceDelta `json:"delta"` + FinishReason *string `json:"finish_reason"` +} + +type ChatCompletionChunk struct { + ID string `json:"id"` + Object string `json:"object"` + Created int64 `json:"created"` + Model string `json:"model"` + Choices []StreamChoice `json:"choices"` + Usage *Usage `json:"usage,omitempty"` +} + +type ModelInfo struct { + ID string `json:"id"` + Object string `json:"object"` + Created int64 `json:"created"` + OwnedBy string `json:"owned_by"` +} + +type ErrorBody struct { + Message string `json:"message"` + Type string `json:"type"` + Code any `json:"code,omitempty"` +} + +type ErrorResponse struct { + Error ErrorBody `json:"error"` +} diff --git a/internal/turnstile/browser.go b/internal/turnstile/browser.go new file mode 100644 index 0000000..88c4994 --- /dev/null +++ b/internal/turnstile/browser.go @@ -0,0 +1,1098 @@ +package turnstile + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "net/http" + "net/url" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "sync" + "time" + + "github.com/nyoungo/dsfree2api/internal/config" +) + +// browserDebugHook, when set (tests / troubleshooting), receives a trace of +// every solver step. Production leaves it nil. +var browserDebug func(format string, args ...any) + +// browserDiagHook, when set (tests / troubleshooting), receives the CDP +// connection and page session once a browser attempt is attached. +// Production leaves it nil. +var browserDiagHook func(conn *cdp, sid string) + +// browserSetupHook, when set (tests / troubleshooting), runs after setup and +// before the target navigation — a place to inject extra test scripts. +// Production leaves it nil. +var browserSetupHook func(conn *cdp, sid string) + +func debugf(format string, args ...any) { + if browserDebug != nil { + browserDebug(format, args...) + } +} + +// solveTokenBrowser gets a Turnstile token by driving the local browser named +// in [turnstile].browser_path over the Chrome DevTools Protocol — the same +// recipe as heartmore/cloudflare-solver (FlareSolverr): open the site, inject +// the widget, click it with trusted input and poll the callback. No Playwright +// driver, no Python, no Docker — the binary talks CDP directly. +func (s *Solver) solveTokenBrowser(ctx context.Context, site *config.Site, sitekey string, c *core) (string, error) { + cfg := s.Config() + if strings.TrimSpace(cfg.BrowserPath) == "" { + return "", &Error{ + Msg: `turnstile.browser_path is empty — point it at Chrome/Edge (provider = "browser")`, + Retryable: false, + } + } + if _, err := os.Stat(cfg.BrowserPath); err != nil { + return "", &Error{Msg: "turnstile.browser_path: " + err.Error(), Retryable: false} + } + + // one browser window at a time, whatever the sites do + s.browserMu.Lock() + defer s.browserMu.Unlock() + + attempts := cfg.Retries + if attempts < 1 { + attempts = 1 + } + var last error + for attempt := 0; attempt < attempts; attempt++ { + token, err := s.browserAttempt(ctx, site, sitekey, cfg, c) + if err == nil { + return token, nil + } + if ctx.Err() != nil { + return "", ctx.Err() + } + last = err + if !Retryable(last) { + return "", last + } + backoff := time.Duration(cfg.RetryBackoffSeconds*float64(attempt+1)) * time.Second + if backoff <= 0 { + backoff = time.Second + } + if !sleepCtx(ctx, backoff) { + return "", ctx.Err() + } + } + return "", last +} + +func (s *Solver) browserAttempt(ctx context.Context, site *config.Site, sitekey string, cfg config.Turnstile, c *core) (string, error) { + proxy := "" + if c.proxy != "" && c.proxy != "direct" { + if u, err := url.Parse(c.proxy); err == nil && u.User != nil { + return "", &Error{ + Msg: "browser solver: proxy credentials are not supported by Chrome — use an IP allow-list proxy", + Retryable: false, + } + } + proxy = c.proxy + } + locale := strings.TrimSpace(cfg.BrowserLocale) + if locale == "" { + locale = firstLocale(site.Language) + } + + br, err := launchBrowser(ctx, strings.TrimSpace(cfg.BrowserPath), cfg, locale, proxy) + if err != nil { + return "", &Error{Msg: "browser launch failed: " + err.Error(), Retryable: true} + } + conn, err := dialCDP(ctx, br.wsURL, fmt.Sprintf("http://127.0.0.1:%d", br.port)) + if err != nil { + br.Close() + return "", &Error{Msg: "cdp dial failed: " + err.Error(), Retryable: true} + } + defer func() { + shutCtx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + _, _ = conn.call(shutCtx, "", "Browser.close", nil) + cancel() + conn.Close() + br.Close() + }() + + sid, err := attachPage(ctx, conn) + if err != nil { + return "", &Error{Msg: "cdp attach failed: " + err.Error(), Retryable: true} + } + if h := browserDiagHook; h != nil { + h(conn, sid) + } + + // Everything below runs before Page.navigate so the stealth script is in + // place before the site's own scripts execute. + metrics := map[string]any{ + "width": 1280, "height": 900, "deviceScaleFactor": 1, "mobile": false, + } + if cfg.BrowserHeadless { + // headless reports a 800x600 screen — smaller than our own viewport, + // an instant bot tell; present a normal desktop screen instead. + metrics["screenWidth"] = 1920 + metrics["screenHeight"] = 1080 + } + setup := []struct { + method string + params any + fatal bool + }{ + {"Page.enable", nil, false}, + {"Runtime.enable", nil, false}, + {"Network.enable", nil, false}, + {"Emulation.setDeviceMetricsOverride", metrics, false}, + {"Page.addScriptToEvaluateOnNewDocument", map[string]any{"source": stealthJS}, false}, + } + if tz := strings.TrimSpace(cfg.BrowserTimezone); tz != "" { + setup = append(setup, struct { + method string + params any + fatal bool + }{"Emulation.setTimezoneOverride", map[string]any{"timezoneId": tz}, true}) + } + for _, step := range setup { + if _, err := conn.call(ctx, sid, step.method, step.params); err != nil && step.fatal { + return "", &Error{Msg: step.method + ": " + err.Error(), Retryable: false} + } + } + if h := browserSetupHook; h != nil { + h(conn, sid) + } + var uaParams map[string]any + if cfg.BrowserHeadless { + // Chromium reports "HeadlessChrome" (bot tell). Fix it via CDP before + // the real navigation: UA-CH metadata is only readable in a secure + // context, so hop to a neutral non-Cloudflare HTTPS endpoint first. + var uerr error + uaParams, uerr = fixHeadlessUA(ctx, conn, sid) + if uerr != nil { + debugf("headless UA fix skipped: %v", uerr) + } + // The Turnstile challenge runs inside a cross-origin iframe (OOPIF) + // whose renderer does NOT inherit the main frame's device emulation — + // the headless screen stays at 800x600 in there, an instant bot tell + // (600010). Auto-attach to child frames and equip each one before it + // is allowed to run. + stopSub := watchSubTargets(ctx, conn, sid, uaParams, strings.TrimSpace(cfg.BrowserTimezone)) + defer stopSub() + } + navCtx, cancelNav := context.WithTimeout(ctx, 30*time.Second) + target := strings.TrimRight(site.BaseURL, "/") + "/" + _, err = conn.call(navCtx, sid, "Page.navigate", map[string]any{"url": target}) + cancelNav() + if err != nil { + return "", &Error{Msg: "navigate to " + target + ": " + err.Error(), Retryable: true} + } + return s.pollForToken(ctx, conn, sid, sitekey, cfg) +} + +// fixHeadlessUA applies the UA rewrite only when the browser actually +// reports HeadlessChrome, bootstrapping via a neutral HTTPS origin first so +// navigator.userAgentData (secure-context only) becomes readable. It returns +// the Network.setUserAgentOverride params that were applied (nil when no +// rewrite was needed) so sub-frames can reuse them. +func fixHeadlessUA(ctx context.Context, conn *cdp, sid string) (map[string]any, error) { + raw, err := conn.evaluate(ctx, sid, `navigator.userAgent`) + if err != nil { + return nil, err + } + var ua string + if json.Unmarshal(raw, &ua) != nil { + return nil, fmt.Errorf("bad userAgent probe: %s", raw) + } + if !strings.Contains(ua, "HeadlessChrome") { + return nil, nil + } + // Read UA-CH metadata in a SEPARATE bootstrap tab: a secure context is + // required for navigator.userAgentData, but the main tab must stay on + // about:blank so the real navigation gets history.length=2 (headed + // parity) and an empty document.referrer. + for _, boot := range []string{ + "https://www.baidu.com/", + "https://www.microsoft.com/", + } { + bootSid, closeBoot, err := openBootstrap(ctx, conn, boot, 8*time.Second) + if err != nil { + debugf("ua bootstrap %s: %v", boot, err) + continue + } + params, err := overrideHeadlessUA(ctx, conn, sid, bootSid) + closeBoot() + if err != nil { + return nil, err + } + return params, nil + } + return nil, errors.New("no secure bootstrap origin reachable") +} + +// openBootstrap opens url in a throwaway tab, waits for its https document +// to commit and returns the attached session id plus a closer func. +func openBootstrap(ctx context.Context, conn *cdp, url string, wait time.Duration) (string, func(), error) { + tctx, cancel := context.WithTimeout(ctx, 15*time.Second) + defer cancel() + raw, err := conn.call(tctx, "", "Target.createTarget", map[string]any{"url": url}) + if err != nil { + return "", nil, err + } + var ct struct { + TargetID string `json:"targetId"` + } + if json.Unmarshal(raw, &ct) != nil || ct.TargetID == "" { + return "", nil, fmt.Errorf("bad createTarget reply: %s", raw) + } + closeBoot := func() { + cctx, ccancel := context.WithTimeout(context.Background(), 5*time.Second) + conn.call(cctx, "", "Target.closeTarget", map[string]any{"targetId": ct.TargetID}) + ccancel() + } + raw, err = conn.call(tctx, "", "Target.attachToTarget", map[string]any{ + "targetId": ct.TargetID, "flatten": true, + }) + if err != nil { + closeBoot() + return "", nil, err + } + var at struct { + SessionID string `json:"sessionId"` + } + if json.Unmarshal(raw, &at) != nil || at.SessionID == "" { + closeBoot() + return "", nil, fmt.Errorf("bad attachToTarget reply: %s", raw) + } + deadline := time.Now().Add(wait) + for time.Now().Before(deadline) { + if raw, err := conn.evaluate(ctx, at.SessionID, `location.href`); err == nil { + var href string + if json.Unmarshal(raw, &href) == nil && strings.HasPrefix(href, "https://") { + return at.SessionID, closeBoot, nil + } + } + if !sleepCtx(ctx, 200*time.Millisecond) { + closeBoot() + return "", nil, ctx.Err() + } + } + closeBoot() + return "", nil, errors.New("document did not commit") +} + +// overrideHeadlessUA rewrites "HeadlessChrome" to "Chrome" in the page's +// navigator.userAgent and in outgoing HTTP headers (via CDP), because +// Cloudflare treats the headless token as a bot signal. The Client-Hint +// metadata must be carried over explicitly: a bare UA override makes Chrome +// drop the sec-ch-ua* headers entirely, which is itself a bot tell. +// The applied override params are returned (nil when no rewrite happened). +func overrideHeadlessUA(ctx context.Context, conn *cdp, sid, bootSid string) (map[string]any, error) { + raw, err := conn.evaluate(ctx, sid, `navigator.userAgent`) + if err != nil { + return nil, err + } + var ua string + if json.Unmarshal(raw, &ua) != nil { + return nil, fmt.Errorf("bad userAgent probe: %s", raw) + } + if !strings.Contains(ua, "HeadlessChrome") { + return nil, nil + } + fixed := strings.Replace(ua, "HeadlessChrome", "Chrome", 1) + params := map[string]any{"userAgent": fixed} + if mraw, err := conn.evaluate(ctx, bootSid, uaMetadataExpr); err == nil { + debugf("ua metadata raw: %s", mraw) + var s string + if json.Unmarshal(mraw, &s) == nil { + var meta map[string]any + if json.Unmarshal([]byte(s), &meta) == nil { + params["userAgentMetadata"] = meta + } + } + } else { + debugf("ua metadata expr failed: %v", err) + } + if _, err := conn.call(ctx, sid, "Network.setUserAgentOverride", params); err != nil { + delete(params, "userAgentMetadata") + if _, err2 := conn.call(ctx, sid, "Network.setUserAgentOverride", params); err2 != nil { + return nil, err + } + debugf("headless UA metadata override rejected (%v), plain override applied", err) + } + debugf("headless UA rewritten: %s", fixed) + return params, nil +} + +// screenPatchJS gives the challenge iframe a desktop-sized screen. CDP's +// device-metrics override is top-level-only ("Command can only be executed +// on top-level targets"), so the cross-origin OOPIF keeps the headless +// native 800x600 — an instant bot tell (600010). Values mirror what the same +// iframe reads headed: 1920x1080 with a 48px taskbar. +const screenPatchJS = `(() => { + const fix = (name, val) => { + try { + const d = Object.getOwnPropertyDescriptor(Screen.prototype, name); + if (d && d.get) { + Object.defineProperty(Screen.prototype, name, { + get: function() { return val; }, + configurable: true + }); + } + } catch (e) {} + }; + fix("width", 1920); + fix("height", 1080); + fix("availWidth", 1920); + fix("availHeight", 1032); + try { window.__dsSp = 1; } catch (e) {} +})()` + +// watchSubTargets auto-attaches to targets related to the page session — +// chiefly the cross-origin Turnstile challenge iframe — with +// waitForDebuggerOnStart so each child pauses BEFORE its first document +// script runs. The handler then applies what the main frame setup gives the +// top-level document (screen, UA, TZ — the OOPIF's own renderer never sees +// the main frame's emulation) and resumes it via runIfWaitingForDebugger. +// Returns a stop func. +func watchSubTargets(ctx context.Context, conn *cdp, sid string, uaParams map[string]any, tz string) func() { + ch := make(chan subTargetEvent, 64) + conn.setSubTargets(ch) + if _, err := conn.call(ctx, sid, "Target.setAutoAttach", map[string]any{ + "autoAttach": true, "waitForDebuggerOnStart": true, "flatten": true, + }); err != nil { + debugf("sub-target auto-attach: %v", err) + conn.setSubTargets(nil) + return func() {} + } + done := make(chan struct{}) + go func() { + for { + select { + case <-done: + return + case ev := <-ch: + if ev.method != "Target.attachedToTarget" { + continue + } + var p struct { + SessionID string `json:"sessionId"` + WaitingForDebugger bool `json:"waitingForDebugger"` + TargetInfo struct { + Type string `json:"type"` + URL string `json:"url"` + } `json:"targetInfo"` + } + if json.Unmarshal(ev.params, &p) != nil || p.SessionID == "" { + continue + } + cctx, cancel := context.WithTimeout(context.Background(), 6*time.Second) + if p.TargetInfo.Type == "iframe" { + steps := []struct { + method string + params any + }{ + {"Runtime.enable", nil}, + {"Network.enable", nil}, + {"Page.enable", nil}, + {"Page.addScriptToEvaluateOnNewDocument", map[string]any{"source": screenPatchJS, "runImmediately": true}}, + } + if uaParams != nil { + steps = append(steps, struct { + method string + params any + }{"Network.setUserAgentOverride", uaParams}) + } + if tz != "" { + steps = append(steps, struct { + method string + params any + }{"Emulation.setTimezoneOverride", map[string]any{"timezoneId": tz}}) + } + for _, st := range steps { + if _, err := conn.call(cctx, p.SessionID, st.method, st.params); err != nil { + debugf("sub-frame %s: %v", st.method, err) + } + } + debugf("sub-frame patched (url=%.64s)", p.TargetInfo.URL) + } + if p.WaitingForDebugger { + if _, err := conn.call(cctx, p.SessionID, "Runtime.runIfWaitingForDebugger", nil); err != nil { + debugf("sub-target resume: %v", err) + } else { + debugf("sub-target resumed (type=%s)", p.TargetInfo.Type) + } + } + cancel() + } + } + }() + return func() { + conn.setSubTargets(nil) + close(done) + } +} + +// uaMetadataExpr snapshots navigator.userAgentData (incl. high-entropy +// values) in the shape CDP expects for Emulation.UserAgentMetadata. +const uaMetadataExpr = `(() => { + const base = {brands: [], fullVersionList: [], platform: "", platformVersion: "", + architecture: "", bitness: "", model: "", mobile: false, wow64: false}; + const pick = h => JSON.stringify({ + brands: (h.brands || []).map(b => ({brand: String(b.brand), version: String(b.version)})), + fullVersionList: (h.fullVersionList || []).map(b => ({brand: String(b.brand), version: String(b.version)})), + platform: String(h.platform || ""), platformVersion: String(h.platformVersion || ""), + architecture: String(h.architecture || ""), bitness: String(h.bitness || ""), + model: String(h.model || ""), mobile: !!h.mobile, wow64: !!h.wow64}); + try { + const uad = navigator.userAgentData; + if (!uad) return JSON.stringify(base); + if (!uad.getHighEntropyValues) { + return JSON.stringify(Object.assign({}, base, {brands: uad.brands || [], mobile: !!uad.mobile})); + } + return uad.getHighEntropyValues(["architecture", "bitness", "model", "platform", + "platformVersion", "fullVersionList", "wow64"]) + .then(h => pick(Object.assign({}, h, {brands: h.brands || uad.brands || [], mobile: !!uad.mobile}))) + .catch(() => JSON.stringify(Object.assign({}, base, {brands: uad.brands || [], mobile: !!uad.mobile}))); + } catch (e) { return JSON.stringify(base); } +})()` + +// pollForToken waits for page load, deals with a Cloudflare interstitial, +// warms the page like a human, renders the widget and clicks until a token +// lands or the deadline passes. +func (s *Solver) pollForToken(ctx context.Context, conn *cdp, sid, sitekey string, cfg config.Turnstile) (string, error) { + timeout := time.Duration(cfg.TimeoutSeconds) * time.Second + if timeout <= 0 { + timeout = 90 * time.Second + } + deadline := time.Now().Add(timeout) + + var preDone, inited bool + var clicks, reinitsUsed int + var lastClick, lastReinit, loadingSince time.Time + var lastState, lastTitle string + var lastErr error // last poll/evaluate failure, surfaced on timeout + + const ( + firstClickDelay = 3500 * time.Millisecond + clickSpacing = 2500 * time.Millisecond + maxClicks = 6 + maxReinits = 3 + ) + + for { + if err := ctx.Err(); err != nil { + return "", err + } + if time.Now().After(deadline) { + return "", &Error{ + Msg: fmt.Sprintf("browser solver timed out after %s (state=%q title=%q clicks=%d last_err=%v)", + timeout, lastState, lastTitle, clicks, lastErr), + Retryable: true, + } + } + + raw, err := conn.evaluate(ctx, sid, pollExpr) + if err != nil { + // frame detach / navigation races are normal — keep polling + lastErr = err + if !sleepCtx(ctx, 400*time.Millisecond) { + return "", ctx.Err() + } + continue + } + var st widgetState + if json.Unmarshal(raw, &st) != nil { + if !sleepCtx(ctx, 400*time.Millisecond) { + return "", ctx.Err() + } + continue + } + lastState, lastTitle = st.State, st.Title + debugf("poll state=%q ready=%q url=%q challenge=%v hasInit=%v renderedAt=%d clicks=%d focus=%v vw=%d vh=%d rect=%+v err=%v lastErr=%v gl=%q", + st.State, st.Ready, st.URL, st.Challenge, st.HasInit, st.RenderedAt, clicks, st.Focus, st.VW, st.VH, st.Rect, st.Error, lastErr, st.GL) + + // a token is definitive — check it before any other gate + if st.Token != "" { + return st.Token, nil + } + + // Cloudflare interstitial: a clean browser walks through it alone. + if st.Challenge { + if !sleepCtx(ctx, 500*time.Millisecond) { + return "", ctx.Err() + } + continue + } + // about:blank (the launch tab) is not the target page yet + if !strings.HasPrefix(st.URL, "http") { + if !sleepCtx(ctx, 300*time.Millisecond) { + return "", ctx.Err() + } + continue + } + // A document stuck on "loading" (slow proxy resources) should not + // stall the solve forever — proceed once the DOM had 20s. + if st.Ready != "interactive" && st.Ready != "complete" { + if loadingSince.IsZero() { + loadingSince = time.Now() + } + if time.Since(loadingSince) < 20*time.Second { + if !sleepCtx(ctx, 300*time.Millisecond) { + return "", ctx.Err() + } + continue + } + } else { + loadingSince = time.Time{} + } + if !preDone { + debugf("pre-interaction warm-up") + preInteraction(ctx, conn, sid) + preDone = true + continue + } + if !inited || !st.HasInit { + if _, err := conn.evaluate(ctx, sid, initExpr(sitekey, cfg.Action)); err == nil { + if !inited { + inited = true + lastErr = nil + } else { + lastErr = errors.New("init evaluated but window.__tsInitDone is not visible in poll") + } + } else { + lastErr = fmt.Errorf("init: %w", err) + } + if !sleepCtx(ctx, 500*time.Millisecond) { + return "", ctx.Err() + } + continue + } + + // Widget rejected the challenge — render a fresh one a few times. + if st.State == "error" || st.State == "render_failed" { + if time.Since(lastReinit) < 1500*time.Millisecond { + if !sleepCtx(ctx, 400*time.Millisecond) { + return "", ctx.Err() + } + continue + } + ok, rerr := conn.evaluate(ctx, sid, `window.__tsReinit ? window.__tsReinit() : false`) + if rerr == nil && string(ok) == "true" { + reinitsUsed++ + lastReinit = time.Now() + } else if st.State == "render_failed" || reinitsUsed >= maxReinits { + return "", &Error{ + Msg: fmt.Sprintf("turnstile widget failed (state=%q error=%q)", st.State, st.Error), + Retryable: true, + } + } else { + lastReinit = time.Now() + } + if !sleepCtx(ctx, 500*time.Millisecond) { + return "", ctx.Err() + } + continue + } + + // Trusted click on the checkbox, throttled so we never fight the widget. + if st.Rect != nil && clicks < maxClicks && st.RenderedAt > 0 { + sinceRender := time.Since(time.UnixMilli(st.RenderedAt)) + visible := st.Rect.X >= 0 && st.Rect.Y >= 0 && st.VW > 0 && + st.Rect.X+st.Rect.Width <= float64(st.VW)+2 && + st.Rect.Y+st.Rect.Height <= float64(st.VH)+2 + if visible && sinceRender >= firstClickDelay && time.Since(lastClick) >= clickSpacing { + if err := trustedClick(ctx, conn, sid, st.Rect.X+30, st.Rect.Y+st.Rect.Height/2); err == nil { + clicks++ + lastClick = time.Now() + } + } + } + if !sleepCtx(ctx, 400*time.Millisecond) { + return "", ctx.Err() + } + } +} + +type widgetState struct { + Challenge bool `json:"challenge"` + Ready string `json:"ready"` + HasInit bool `json:"hasInit"` + State string `json:"state"` + Token string `json:"token"` + Error string `json:"error"` + Title string `json:"title"` + URL string `json:"url"` + VW int `json:"vw"` + VH int `json:"vh"` + Focus bool `json:"focus"` + GL string `json:"gl"` + RenderedAt int64 `json:"renderedAt"` + Rect *struct { + X float64 `json:"x"` + Y float64 `json:"y"` + Width float64 `json:"width"` + Height float64 `json:"height"` + } `json:"rect"` +} + +// pollExpr reads widget state; it also drives __tsEnsure so the widget is +// (re)created after a challenge redirect wiped the document body. +const pollExpr = `(() => { + const o = {challenge:false, ready:"", hasInit:false, state:"", token:"", error:"", + title:document.title, url:location.href, vw:window.innerWidth|0, vh:window.innerHeight|0, + renderedAt:0, rect:null, focus:false}; + try { + // interstitial markers only — the widget's own DOM must not trip this + o.challenge = !!window._cf_chl_opt || !!document.querySelector('#challenge-form'); + o.ready = document.readyState; + o.focus = document.hasFocus(); + try { + const gc = document.createElement("canvas").getContext("webgl"); + if (gc) { + const dbg = gc.getExtension("WEBGL_debug_renderer_info"); + o.gl = dbg ? String(gc.getParameter(dbg.UNMASKED_RENDERER_WEBGL)) : ""; + } + } catch (e) { o.gl = "err"; } + o.hasInit = !!window.__tsInitDone; + if (o.hasInit) { + o.state = String(window.__tsState || ""); + o.token = String(window.__tsToken || ""); + o.error = String(window.__tsError || ""); + o.renderedAt = window.__tsRenderedAt || 0; + const el = document.getElementById("__ds_ts_widget"); + if (el) { + const r = el.getBoundingClientRect(); + o.rect = {x:r.x, y:r.y, width:r.width, height:r.height}; + } + if (!o.challenge && !o.token && o.state !== "error" && o.state !== "render_failed" && + o.ready !== "loading" && typeof window.__tsEnsure === "function") { + window.__tsEnsure(); + } + } + } catch (e) { o.state = "poll_failed"; o.error = String(e); } + return o; +})()` + +// initExpr defines __tsEnsure/__tsReinit and renders the widget once +// challenges.cloudflare.com is loaded. +func initExpr(sitekey, action string) string { + sk, _ := json.Marshal(sitekey) + act, _ := json.Marshal(action) + return strings.NewReplacer("__SITEKEY__", string(sk), "__ACTION__", string(act)).Replace(initTpl) +} + +const initTpl = `(() => { + if (window.__tsInitDone) return {ok:true, already:true}; + window.__tsInitDone = true; + window.__tsState = "init"; + window.__tsToken = ""; + window.__tsError = ""; + window.__tsReinits = 0; + window.__tsScript = null; + window.__tsWidget = null; + window.__tsWidgetReady = false; + window.__tsApiJsFailedAt = 0; + window.__tsRenderedAt = 0; + window.__tsEnsure = function() { + try { + if (!window.turnstile) { + const failedAt = window.__tsApiJsFailedAt || 0; + const retried = failedAt && (Date.now() - failedAt > 2000); + if (!window.__tsScript || retried) { + if (window.__tsScript && window.__tsScript.parentNode) { + window.__tsScript.parentNode.removeChild(window.__tsScript); + } + window.__tsScript = null; + window.__tsApiJsFailedAt = 0; + const s = document.createElement("script"); + s.src = "https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit"; + s.async = true; + s.onerror = function() { window.__tsApiJsFailedAt = Date.now(); window.__tsState = "apijs_failed"; }; + (document.head || document.documentElement).appendChild(s); + window.__tsScript = s; + window.__tsState = "loading_apijs"; + } + return; + } + if (window.__tsWidgetReady && !document.getElementById("__ds_ts_widget")) { + window.__tsWidgetReady = false; + window.__tsWidget = null; + window.__tsState = "init"; + } + if (window.__tsWidgetReady || !document.body) return; + const holder = document.createElement("div"); + holder.id = "__ds_ts_widget"; + holder.style.cssText = "position:fixed;top:12px;left:12px;z-index:2147483647;" + + "width:300px;height:65px;background:#fff;"; + document.body.appendChild(holder); + window.__tsWidget = window.turnstile.render(holder, { + sitekey: __SITEKEY__, + action: __ACTION__, + callback: function(t) { window.__tsToken = t; window.__tsState = "success"; }, + "error-callback": function(e) { window.__tsError = String(e); window.__tsState = "error"; }, + "expired-callback": function() { window.__tsToken = ""; window.__tsState = "expired"; } + }); + window.__tsWidgetReady = true; + window.__tsState = "rendered"; + window.__tsRenderedAt = Date.now(); + } catch (e) { + window.__tsError = String(e); + window.__tsState = "render_failed"; + } + }; + window.__tsReinit = function() { + if (window.__tsReinits >= 3) return false; + window.__tsReinits++; + try { + if (window.__tsWidget != null && window.turnstile) window.turnstile.remove(window.__tsWidget); + } catch (e) {} + const old = document.getElementById("__ds_ts_widget"); + if (old && old.parentNode) old.parentNode.removeChild(old); + window.__tsWidget = null; + window.__tsWidgetReady = false; + window.__tsToken = ""; + window.__tsError = ""; + window.__tsState = "init"; + window.__tsEnsure(); + return true; + }; + window.__tsEnsure(); + return {ok:true}; +})()` + +// stealthJS runs before any page script (Page.addScriptToEvaluateOnNewDocument). +const stealthJS = `(() => { + try { + Object.defineProperty(Navigator.prototype, "webdriver", + {get: function() { return undefined; }, configurable: true}); + } catch (e) {} + try { + if (!window.chrome) window.chrome = {}; + if (!window.chrome.runtime) window.chrome.runtime = {}; + } catch (e) {} + try { + const q = navigator.permissions && navigator.permissions.query; + if (q) { + navigator.permissions.query = function(desc) { + return q.call(navigator.permissions, desc).then(function(r) { + try { + if (r && r.state === "prompt" && desc && desc.name === "notifications") r.state = "granted"; + } catch (e) {} + return r; + }); + }; + } + } catch (e) {} + try { + // headless/screen-emulated environments report availHeight === height; + // a real desktop has a taskbar, so emulate one (no-op when present). + const avDesc = Object.getOwnPropertyDescriptor(Screen.prototype, "availHeight"); + if (avDesc && avDesc.get) { + Object.defineProperty(Screen.prototype, "availHeight", { + get: function() { + const av = avDesc.get.call(this); + const h = this.height; + return (h && av >= h) ? h - 48 : av; + }, + configurable: true + }); + } + } catch (e) {} + })()` + +// preInteraction moves the pointer and nudges the scroll wheel before the +// widget appears, so the page sees a real interaction history first. +func preInteraction(ctx context.Context, conn *cdp, sid string) { + for _, p := range [][2]float64{{640, 320}, {420, 470}, {790, 255}} { + if ctx.Err() != nil { + return + } + _ = dispatchMouse(conn.call, ctx, sid, "mouseMoved", p[0], p[1], "none", 0, 0) + if !sleepCtx(ctx, 150*time.Millisecond) { + return + } + } + _ = dispatchMouseWheelY(ctx, conn, sid, 640, 450, 200) + if !sleepCtx(ctx, 250*time.Millisecond) { + return + } + _ = dispatchMouseWheelY(ctx, conn, sid, 640, 450, -200) +} + +func trustedClick(ctx context.Context, conn *cdp, sid string, x, y float64) error { + if err := dispatchMouse(conn.call, ctx, sid, "mouseMoved", x, y, "none", 0, 0); err != nil { + return err + } + if !sleepCtx(ctx, 80*time.Millisecond) { + return ctx.Err() + } + if err := dispatchMouse(conn.call, ctx, sid, "mousePressed", x, y, "left", 1, 1); err != nil { + return err + } + if !sleepCtx(ctx, 60*time.Millisecond) { + return ctx.Err() + } + return dispatchMouse(conn.call, ctx, sid, "mouseReleased", x, y, "left", 0, 1) +} + +type cdpCaller func(context.Context, string, string, any) (json.RawMessage, error) + +func dispatchMouse(call cdpCaller, ctx context.Context, sid, kind string, x, y float64, button string, buttons, clickCount int) error { + params := map[string]any{"type": kind, "x": x, "y": y, "pointerType": "mouse"} + if kind != "mouseWheel" { + params["button"] = button + params["buttons"] = buttons + params["clickCount"] = clickCount + } + _, err := call(ctx, sid, "Input.dispatchMouseEvent", params) + return err +} + +func dispatchMouseWheelY(ctx context.Context, conn *cdp, sid string, x, y float64, deltaY float64) error { + _, err := conn.call(ctx, sid, "Input.dispatchMouseEvent", map[string]any{ + "type": "mouseWheel", "x": x, "y": y, "deltaX": 0, "deltaY": deltaY, "pointerType": "mouse", + }) + return err +} + +// attachPage finds the browser's page target and attaches a flattened session. +func attachPage(ctx context.Context, conn *cdp) (string, error) { + deadline := time.Now().Add(15 * time.Second) + var lastErr error + for time.Now().Before(deadline) { + raw, err := conn.call(ctx, "", "Target.getTargets", nil) + if err != nil { + return "", err + } + var out struct { + TargetInfos []struct { + TargetID string `json:"targetId"` + Type string `json:"type"` + } `json:"targetInfos"` + } + if err := json.Unmarshal(raw, &out); err == nil { + for _, t := range out.TargetInfos { + if t.Type != "page" { + continue + } + attached, err := conn.call(ctx, "", "Target.attachToTarget", map[string]any{ + "targetId": t.TargetID, "flatten": true, + }) + if err != nil { + return "", err + } + var res struct { + SessionID string `json:"sessionId"` + } + if json.Unmarshal(attached, &res) == nil && res.SessionID != "" { + return res.SessionID, nil + } + } + } + lastErr = errors.New("no page target yet") + if !sleepCtx(ctx, 300*time.Millisecond) { + return "", ctx.Err() + } + } + if lastErr == nil { + lastErr = errors.New("no page target appeared") + } + return "", lastErr +} + +// ── browser process ──────────────────────────────────────────────── + +type browserProc struct { + cmd *exec.Cmd + port int + wsURL string + tempDir string + exited chan struct{} + waitErr error + stderr *tailBuffer +} + +func launchBrowser(ctx context.Context, exe string, cfg config.Turnstile, locale, proxy string) (*browserProc, error) { + port, err := freePort() + if err != nil { + return nil, err + } + profile := strings.TrimSpace(cfg.BrowserUserDataDir) + tempDir := "" + if profile == "" { + tempDir = filepath.Join(os.TempDir(), fmt.Sprintf("dsfree2api-ts-%d-%d", os.Getpid(), time.Now().UnixNano())) + if err := os.MkdirAll(tempDir, 0o700); err != nil { + return nil, err + } + profile = tempDir + } + args := []string{ + "--remote-debugging-port=" + strconv.Itoa(port), + // Chrome ≥ 136 rejects the DevTools WebSocket handshake unless the + // caller's origin is allow-listed (otherwise: "bad status" 403). + "--remote-allow-origins=http://127.0.0.1:" + strconv.Itoa(port), + "--user-data-dir=" + profile, + "--no-first-run", + "--no-default-browser-check", + "--disable-blink-features=AutomationControlled", + "--disable-background-networking", + "--disable-sync", + // IsolateOrigins/site-per-process: without site isolation the + // cross-origin Turnstile iframe may share the top-level renderer, + // inheriting its device emulation (headless OOPIFs otherwise keep a + // native 800x600 screen that Cloudflare reads as a bot tell). + "--disable-features=Translate,MediaRouter,OptimizationHints,IsolateOrigins,site-per-process", + "--disable-session-crashed-bubble", + "--disable-restore-session-state", + "--window-size=1280,900", + } + if locale != "" { + args = append(args, "--lang="+locale) + } + if cfg.BrowserHeadless { + args = append(args, "--headless=new") + } + if proxy != "" { + args = append(args, "--proxy-server="+proxy) + } + args = append(args, "about:blank") + + cmd := exec.Command(exe, args...) + stderr := &tailBuffer{max: 4096} + cmd.Stdout = io.Discard + cmd.Stderr = stderr + if err := cmd.Start(); err != nil { + if tempDir != "" { + _ = os.RemoveAll(tempDir) + } + return nil, fmt.Errorf("start %q: %w", exe, err) + } + b := &browserProc{cmd: cmd, port: port, tempDir: tempDir, exited: make(chan struct{}), stderr: stderr} + go func() { + b.waitErr = cmd.Wait() + close(b.exited) + }() + if err := b.waitDevTools(ctx); err != nil { + b.Close() + return nil, err + } + return b, nil +} + +func (b *browserProc) waitDevTools(ctx context.Context) error { + endpoint := fmt.Sprintf("http://127.0.0.1:%d/json/version", b.port) + client := &http.Client{Timeout: 2 * time.Second} + deadline := time.Now().Add(30 * time.Second) + var lastErr error + for time.Now().Before(deadline) { + select { + case <-b.exited: + return fmt.Errorf("browser exited early (%v): %s", b.waitErr, b.stderr.String()) + case <-ctx.Done(): + return ctx.Err() + default: + } + resp, err := client.Get(endpoint) + if err == nil { + body, _ := io.ReadAll(resp.Body) + resp.Body.Close() + var info struct { + WebSocketDebuggerURL string `json:"webSocketDebuggerUrl"` + } + if resp.StatusCode == http.StatusOK && json.Unmarshal(body, &info) == nil && info.WebSocketDebuggerURL != "" { + b.wsURL = info.WebSocketDebuggerURL + return nil + } + lastErr = fmt.Errorf("devtools http %d", resp.StatusCode) + } else { + lastErr = err + } + if !sleepCtx(ctx, 300*time.Millisecond) { + return ctx.Err() + } + } + return fmt.Errorf("devtools never came up: %v; stderr: %s", lastErr, b.stderr.String()) +} + +// Close kills the browser if it is still alive and drops the temp profile. +func (b *browserProc) Close() { + select { + case <-b.exited: + default: + if b.cmd.Process != nil { + _ = b.cmd.Process.Kill() + } + select { + case <-b.exited: + case <-time.After(3 * time.Second): + } + } + if b.tempDir != "" { + for i := 0; i < 3; i++ { + if err := os.RemoveAll(b.tempDir); err == nil { + break + } + time.Sleep(300 * time.Millisecond) + } + } +} + +func freePort() (int, error) { + l, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + return 0, err + } + defer l.Close() + return l.Addr().(*net.TCPAddr).Port, nil +} + +func firstLocale(acceptLanguage string) string { + lang := strings.TrimSpace(strings.SplitN(acceptLanguage, ",", 2)[0]) + return lang +} + +func sleepCtx(ctx context.Context, d time.Duration) bool { + select { + case <-ctx.Done(): + return false + case <-time.After(d): + return true + } +} + +// tailBuffer keeps the browser's stderr bounded for error messages. +type tailBuffer struct { + mu sync.Mutex + buf bytes.Buffer + max int +} + +func (t *tailBuffer) Write(p []byte) (int, error) { + t.mu.Lock() + defer t.mu.Unlock() + if room := t.max - t.buf.Len(); room > 0 { + if len(p) > room { + t.buf.Write(p[:room]) + } else { + t.buf.Write(p) + } + } + return len(p), nil +} + +func (t *tailBuffer) String() string { + t.mu.Lock() + defer t.mu.Unlock() + s := strings.Join(strings.Fields(t.buf.String()), " ") + if len(s) > 600 { + s = "..." + s[len(s)-600:] + } + return s +} diff --git a/internal/turnstile/browser_test.go b/internal/turnstile/browser_test.go new file mode 100644 index 0000000..d5aedfd --- /dev/null +++ b/internal/turnstile/browser_test.go @@ -0,0 +1,898 @@ +package turnstile + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + "sync" + "testing" + "time" + + "github.com/nyoungo/dsfree2api/internal/config" + "github.com/nyoungo/dsfree2api/internal/httpx" +) + +// fpExpr dumps an exhaustive JS-environment fingerprint (main world) so +// headless vs headed runs can be diffed field by field. +const fpExpr = `(() => { + if (!location.href.startsWith("https://deepseek.")) return ""; + const out = {}; + const j = (v) => { try { const s = JSON.stringify(v); return s === undefined ? "undef" : s; } catch (e) { return "ERR"; } }; + const n = navigator; + out.nav = {}; + ["appCodeName","appName","platform","vendor","vendorSub","product","productSub","language","webdriver","hardwareConcurrency","deviceMemory","maxTouchPoints","cookieEnabled","doNotTrack","pdfViewerEnabled","onLine","userAgent"].forEach(function(k){ + try { out.nav[k] = j(n[k]); } catch (e) { out.nav[k] = "ERR"; } + }); + try { out.nav.languages = j(Array.from(n.languages || [])); } catch (e) {} + try { out.nav.plugins = Array.from(n.plugins || []).map(function(p){ return p.name + "|" + p.filename; }).join(";"); } catch (e) { out.nav.plugins = "ERR"; } + try { out.nav.mimeTypes = Array.from(n.mimeTypes || []).map(function(m){ return m.type + "|" + m.suffixes; }).join(";"); } catch (e) { out.nav.mimeTypes = "ERR"; } + try { out.nav.connection = j(n.connection ? {e:n.connection.effectiveType, rtt:n.connection.rtt, dl:n.connection.downlink} : null); } catch (e) {} + try { out.nav.getBattery = typeof n.getBattery; } catch (e) {} + out.screen = {}; + ["width","height","availWidth","availHeight","colorDepth","pixelDepth"].forEach(function(k){ + try { out.screen[k] = j(screen[k]); } catch (e) {} + }); + try { out.screen.orientation = j(screen.orientation && screen.orientation.type); } catch (e) {} + out.win = { + dpr: j(window.devicePixelRatio), iw: j(innerWidth), ih: j(innerHeight), ow: j(outerWidth), oh: j(outerHeight), + sx: j(screenX), sy: j(screenY), chrome: j(typeof window.chrome), + chromeRuntime: j(window.chrome ? typeof window.chrome.runtime : "none"), + notif: (typeof Notification !== "undefined") ? Notification.permission : "none", + histo: j(history.length), ref: j(document.referrer.slice(0,60)), + vis: j(document.visibilityState), isTop: j(window.top === window), + docMode: j(document.documentMode), compat: j(document.compatMode) + }; + try { out.intl = j(Intl.DateTimeFormat().resolvedOptions()); } catch (e) { out.intl = "ERR"; } + try { out.tzOffset = j(new Date().getTimezoneOffset()); } catch (e) {} + try { + const c = document.createElement("canvas"); + c.width = 32; c.height = 32; + const g = c.getContext("2d"); + g.fillStyle = "#f0f"; g.fillRect(0,0,32,32); + g.font = "14px Arial"; g.fillText("fp", 2, 20); + out.canvas = c.toDataURL(); + } catch (e) { out.canvas = "ERR"; } + try { + const gc = document.createElement("canvas").getContext("webgl"); + const dbg = gc.getExtension("WEBGL_debug_renderer_info"); + out.gl = { + vendor: String(gc.getParameter(dbg.UNMASKED_VENDOR_WEBGL)), + rend: String(gc.getParameter(dbg.UNMASKED_RENDERER_WEBGL)), + ver: String(gc.getParameter(gc.VERSION)), + rendStr: String(gc.getParameter(gc.RENDERER)), + maxTex: j(gc.getParameter(gc.MAX_TEXTURE_SIZE)), + maxVB: j(gc.getParameter(gc.MAX_VERTEX_ATTRIBS)), + exts: j((gc.getSupportedExtensions() || []).length) + }; + } catch (e) { out.gl = "ERR"; } + try { + const ac = new (window.AudioContext || window.webkitAudioContext)(); + out.audio = { sr: j(ac.sampleRate), base: j(ac.baseLatency), out: j(ac.outputLatency) }; + ac.close(); + } catch (e) { out.audio = "ERR"; } + try { out.fonts = j(document.fonts.size); } catch (e) {} + try { out.voices = j((window.speechSynthesis.getVoices() || []).length); } catch (e) {} + try { + out.mm = { + coarse: matchMedia("(pointer: coarse)").matches, + anyCoarse: matchMedia("(any-pointer: coarse)").matches, + dark: matchMedia("(prefers-color-scheme: dark)").matches, + reduce: matchMedia("(prefers-reduced-motion: reduce)").matches, + hover: matchMedia("(any-hover: hover)").matches + }; + } catch (e) { out.mm = "ERR"; } + try { + out.leaks = Object.getOwnPropertyNames(window).filter(function(k){ + return /_cdc_|cdc_|webdriver|selenium|puppeteer|phantom|playwright|__nightmare|callPhantom|domAutomation|__selenium/.test(k); + }); + } catch (e) { out.leaks = "ERR"; } + try { out.cookie = j(document.cookie.slice(0,80)); } catch (e) {} + try { out.lsLen = j(localStorage.length); } catch (e) { out.lsLen = "ERR"; } + return JSON.stringify(out); +})()` + +// netTapJS logs challenge-platform request/response bodies to the console so +// the TRACE hook can print the full rch conversation. +const netTapJS = `(() => { + const ok = u => /cdn-cgi|challenges\.cloudflare/.test(String(u)); + const log = (tag, u, extra) => { try { console.log("NET|" + tag + "|" + u + "|" + extra); } catch (e) {} }; + try { + const odce = Document.prototype.createElement; + Document.prototype.createElement = function(t) { + const r = odce.apply(this, arguments); + try { + const tt = String(t).toLowerCase(); + if (tt === "iframe" || tt === "object" || tt === "embed") { + const src = (typeof arguments[1] === "object" && arguments[1] && arguments[1].src) || ""; + log("create" + tt, src, new Error().stack ? String(new Error().stack).split("\n").slice(1,4).join(" <- ") : ""); + if (tt === "iframe") { + const d = Object.getOwnPropertyDescriptor(HTMLIFrameElement.prototype, "src"); + if (d && d.set) { + Object.defineProperty(r, "src", { + configurable: true, + get: function() { return d.get.call(this); }, + set: function(v) { try { log("iframesrc", String(v).slice(0,140), ""); } catch (e) {} d.set.call(this, v); } + }); + } + } + } + } catch (e) {} + return r; + }; + } catch (e) {} + try { + const oal = window.addEventListener; + window.addEventListener = function(type, fn, opts) { + if (String(type) === "message" && typeof fn === "function") { + const wrapped = function(ev) { + try { + const d = ev.data; + if (d && typeof d === "object") { + log("msg", (d.event || d.type || "obj") + "|" + JSON.stringify(d).slice(0, 600), String(ev.origin || "").slice(0, 80)); + } else if (typeof d === "string" && d.length < 400) { + log("msg", d.slice(0, 400), String(ev.origin || "").slice(0, 80)); + } + } catch (e) {} + return fn.apply(this, arguments); + }; + return oal.call(this, type, wrapped, opts); + } + return oal.apply(this, arguments); + }; + } catch (e) {} + try { + const of = window.fetch; + if (of) { + window.fetch = function(input, init) { + const u = (typeof input === "string") ? input : (input && input.url) || ""; + if (!ok(u)) return of.apply(this, arguments); + try { + let hdr = ""; + if (init && init.headers) { hdr = JSON.stringify(init.headers); } + let body = ""; + if (init && init.body) { body = String(init.body).slice(0, 700); } + log("freq", (init && init.method || "GET") + "|" + hdr.slice(0, 400) + "|" + body, u); + } catch (e) {} + const p = of.apply(this, arguments); + try { + p.then(resp => { + const c = resp.clone(); + c.text().then(t => log("fresp", resp.status + "|" + t.slice(0, 900)), () => {}); + try { log("fhdr", String(c.headers).slice(0, 300), u); } catch (e) {} + }, () => {}); + } catch (e) {} + return p; + }; + } + } catch (e) {} + try { + const XP = XMLHttpRequest.prototype; + const oopen = XP.open, osend = XP.send; + XP.open = function(m, u) { this.__tm = m; this.__tu = u; return oopen.apply(this, arguments); }; + XP.send = function(b) { + if (ok(this.__tu)) { + try { + this.setRequestHeader = function(k, v) { + try { log("xhdr", this.__tu, k + ": " + v); } catch (e) {} + return XMLHttpRequest.prototype.setRequestHeader.apply(this, arguments); + }; + } catch (e) {} + try { + this.addEventListener("load", () => { + try { + log("xresp", this.status + "|" + this.getAllResponseHeaders().replace(/\n/g, ";").slice(0, 300), this.__tu); + log("xbody", String(this.responseText).slice(0, 900), this.__tu); + } catch (e) {} + }); + log("xreq", this.__tm + "|" + (b ? String(b).slice(0, 700) : ""), this.__tu); + } catch (e) {} + } + return osend.apply(this, arguments); + }; + } catch (e) {} +})()` + +// domExpr reports the widget/iframe situation in the main document (shadow +// DOM included) so we can tell where the challenge actually executes. +const domExpr = `(() => { + if (!location.href.startsWith("https://deepseek.")) return ""; + const out = { winLen: window.length, iframes: [], shadowRoots: [], body: "" }; + const walkRoot = (root, depth) => { + if (depth > 6 || !root.querySelectorAll) return; + root.querySelectorAll("iframe").forEach(f => { + let u = f.src || ""; + if (!u) { try { u = f.contentWindow.location.href; } catch (e) { u = "no-src-xo"; } } + out.iframes.push(String(u).slice(0, 120)); + }); + root.querySelectorAll("*").forEach(el => { + if (el.shadowRoot) { + out.shadowRoots.push((el.tagName + (el.id ? "#" + el.id : "")) + ":open"); + walkRoot(el.shadowRoot, depth + 1); + } else if (el.attachShadow && Object.keys(el).some(k => k.indexOf("shadow") >= 0)) { + out.shadowRoots.push((el.tagName + (el.id ? "#" + el.id : "")) + ":closed?"); + } + }); + }; + walkRoot(document, 0); + try { + const w = document.getElementById("__ds_ts_widget"); + if (w) { + const own = Object.getOwnPropertyNames(w).filter(k => /shadow/i.test(k)); + out.widget = (w.shadowRoot ? "open" : (own.length ? own.join(",") : "none")); + } else { out.widget = "no-widget"; } + } catch (e) { out.widget = "err:" + e; } + try { out.body = Array.from(document.body.children).map(e => e.tagName + (e.id ? "#" + e.id : "")).join(","); } catch (e) {} + return JSON.stringify(out); +})()` + +func providerSite() *config.Site { + return &config.Site{ + Code: "de", BaseURL: "https://deepseek.de", + Language: "de-DE,de;q=0.9,en-US;q=0.8,en;q=0.7", + } +} + +func TestSolveTokenProviderManual(t *testing.T) { + solver := New(config.Turnstile{Enabled: true, Provider: "manual", Retries: 1}, httpx.DefaultUserAgent) + _, err := solver.solveToken(context.Background(), providerSite(), "0xdead", solver.coreFor("")) + if err == nil { + t.Fatal("manual provider must not solve") + } + if Retryable(err) { + t.Errorf("manual failure should be terminal, got: %v", err) + } + if !strings.Contains(err.Error(), "manual") { + t.Errorf("error should mention the provider: %v", err) + } +} + +func TestSolveTokenProviderBrowserNeedsPath(t *testing.T) { + solver := New(config.Turnstile{Enabled: true, Provider: "browser", Retries: 1}, httpx.DefaultUserAgent) + _, err := solver.solveToken(context.Background(), providerSite(), "0xdead", solver.coreFor("")) + if err == nil || Retryable(err) { + t.Fatalf("empty browser_path should fail fast, got: %v", err) + } + if !strings.Contains(err.Error(), "browser_path") { + t.Errorf("error should mention browser_path: %v", err) + } + + solver.SetConfig(config.Turnstile{ + Enabled: true, Provider: "browser", Retries: 1, + BrowserPath: filepath.Join(t.TempDir(), "missing.exe"), + }) + _, err = solver.solveToken(context.Background(), providerSite(), "0xdead", solver.coreFor("")) + if err == nil || Retryable(err) { + t.Fatalf("missing browser executable should fail fast, got: %v", err) + } +} + +func TestSolveTokenProviderUnknown(t *testing.T) { + solver := New(config.Turnstile{Enabled: true, Provider: "captcha", Retries: 1}, httpx.DefaultUserAgent) + _, err := solver.solveToken(context.Background(), providerSite(), "0xdead", solver.coreFor("")) + if err == nil || Retryable(err) { + t.Fatalf("unknown provider should fail fast, got: %v", err) + } + if !strings.Contains(err.Error(), "captcha") { + t.Errorf("error should echo the provider: %v", err) + } +} + +// TestBrowserSolveLive drives the real browser against deepseek.de. Opt in: +// +// DSFREE_BROWSER_E2E=1 go test ./internal/turnstile -run Live -v +// +// Optional: DSFREE_BROWSER_E2E_EXE (defaults to Edge, then Chrome) and the +// proxy/site come from ../../config.toml. +func TestBrowserSolveLive(t *testing.T) { + if os.Getenv("DSFREE_BROWSER_E2E") == "" { + t.Skip("set DSFREE_BROWSER_E2E=1 to run the live browser solve") + } + cfg, err := config.Load("../../config.toml") + if err != nil { + t.Fatalf("load config: %v", err) + } + exe := os.Getenv("DSFREE_BROWSER_E2E_EXE") + if exe == "" { + exe = cfg.Turnstile.BrowserPath + } + for _, candidate := range []string{ + exe, + `C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe`, + `C:\Program Files\Microsoft\Edge\Application\msedge.exe`, + `C:\Program Files\Google\Chrome\Application\chrome.exe`, + } { + if candidate != "" { + if _, err := os.Stat(candidate); err == nil { + exe = candidate + break + } + } + } + if exe == "" { + t.Skip("no browser executable found — set DSFREE_BROWSER_E2E_EXE") + } + + ts := cfg.Turnstile + ts.Enabled = true + ts.Provider = config.ProviderBrowser + ts.BrowserPath = exe + ts.Retries = 1 + ts.TimeoutSeconds = 30 // faster feedback while diagnosing + ts.BrowserHeadless = os.Getenv("DSFREE_BROWSER_E2E_HEADLESS") != "" + solver := New(ts, cfg.Upstream.UserAgent) + + browserDebug = func(format string, args ...any) { + t.Logf(format, args...) + } + defer func() { browserDebug = nil }() + + if os.Getenv("DSFREE_BROWSER_E2E_TRACE") != "" { + type diag struct { + c *cdp + sid string + } + var logMu sync.Mutex + finished := false + logf := func(format string, args ...any) { + logMu.Lock() + defer logMu.Unlock() + if finished { + return + } + t.Logf(format, args...) + } + defer func() { + logMu.Lock() + finished = true + logMu.Unlock() + }() + done := make(chan struct{}) + defer close(done) + type respRef struct{ id, url, sess string } + pendingResp := make(chan respRef, 16) + type fetchRef struct{ id, url, sess, method string } + pendingFetch := make(chan fetchRef, 32) + rchFrames := make(chan string, 8) + + diagCh := make(chan diag, 1) + browserDiagHook = func(c *cdp, s string) { + select { + case diagCh <- diag{c: c, sid: s}: + default: + } + } + defer func() { browserDiagHook = nil }() + browserSetupHook = func(c *cdp, s string) { + cctx, ccancel := context.WithTimeout(context.Background(), 5*time.Second) + c.call(cctx, s, "Page.addScriptToEvaluateOnNewDocument", map[string]any{"source": netTapJS}) + _, ferr := c.call(cctx, s, "Fetch.enable", map[string]any{ + "patterns": []any{ + map[string]any{"urlPattern": "*cdn-cgi/challenge-platform*", "requestStage": "Response"}, + }, + }) + if ferr != nil { + logf("Fetch.enable err=%v", ferr) + } + ccancel() + } + defer func() { browserSetupHook = nil }() + seenCtx := sync.Map{} + cfCtx := make(chan int64, 16) + cdpEventHook = func(session, method string, params json.RawMessage) { + switch method { + case "Network.requestWillBeSent": + var p struct { + Request struct { + URL string `json:"url"` + Method string `json:"method"` + Headers map[string]string `json:"headers"` + } `json:"request"` + Type string `json:"type"` + FrameID string `json:"frameId"` + DocumentURL string `json:"documentURL"` + Initiator struct { + Type string `json:"type"` + } `json:"initiator"` + } + if json.Unmarshal(params, &p) == nil { + ua := p.Request.Headers["User-Agent"] + if strings.Contains(p.Request.URL, "cdn-cgi/challenge-platform") || strings.Contains(ua, "HeadlessChrome") { + keys := make([]string, 0, len(p.Request.Headers)) + for k := range p.Request.Headers { + keys = append(keys, k) + } + sort.Strings(keys) + var b strings.Builder + for _, k := range keys { + fmt.Fprintf(&b, "%s=%q ", k, p.Request.Headers[k]) + } + logf("CHAL %s %s headlessUA=%v type=%s init=%s frame=%s doc=%s %s", + p.Request.Method, p.Request.URL, strings.Contains(ua, "HeadlessChrome"), + p.Type, p.Initiator.Type, p.FrameID, p.DocumentURL, b.String()) + if p.Type == "Document" && p.FrameID != "" && strings.Contains(p.Request.URL, "/rch/") { + select { + case rchFrames <- p.FrameID: + default: + } + } + } + } + case "Network.responseReceived": + var p struct { + RequestID string `json:"requestId"` + Response struct { + URL string `json:"url"` + Status int `json:"status"` + Protocol string `json:"protocol"` + ConnectionID any `json:"connectionId"` + Headers map[string]string `json:"headers"` + SetCookie string `json:"setCookie"` + } `json:"response"` + } + if json.Unmarshal(params, &p) == nil && (strings.Contains(p.Response.URL, "cdn-cgi") || strings.Contains(p.Response.URL, "challenges.cloudflare")) { + logf("RESP %d proto=%s conn=%v sess=%s setCookie=%q %s", p.Response.Status, p.Response.Protocol, p.Response.ConnectionID, session, p.Response.SetCookie, p.Response.URL) + keys := make([]string, 0, len(p.Response.Headers)) + for k := range p.Response.Headers { + if !strings.EqualFold(k, "Content-Security-Policy") && !strings.EqualFold(k, "Date") { + keys = append(keys, k) + } + } + sort.Strings(keys) + var b strings.Builder + for _, k := range keys { + v := p.Response.Headers[k] + if len(v) > 200 { + v = v[:200] + "..." + } + fmt.Fprintf(&b, "%s=%q ", k, v) + } + logf("RESPH %s %s", p.Response.URL, b.String()) + select { + case pendingResp <- respRef{id: p.RequestID, url: p.Response.URL, sess: session}: + default: + } + } + case "Runtime.executionContextCreated": + var p struct { + ExecutionContextID int64 `json:"executionContextId"` + Origin string `json:"origin"` + } + if json.Unmarshal(params, &p) == nil && strings.Contains(p.Origin, "cloudflare") { + if _, loaded := seenCtx.LoadOrStore(p.ExecutionContextID, true); !loaded { + logf("CFCTX id=%d origin=%s", p.ExecutionContextID, p.Origin) + select { + case cfCtx <- p.ExecutionContextID: + default: + } + } + } + case "Fetch.requestPaused": + var p struct { + RequestID string `json:"requestId"` + Request struct { + URL string `json:"url"` + Method string `json:"method"` + } `json:"request"` + ResponseStatusCode int `json:"responseStatusCode"` + } + if json.Unmarshal(params, &p) == nil && p.RequestID != "" { + select { + case pendingFetch <- fetchRef{id: p.RequestID, url: p.Request.URL, sess: session, method: p.Request.Method}: + default: + logf("FETCH channel full, dropping %s", p.Request.URL) + } + } + case "Runtime.consoleAPICalled": + var p struct { + Type string `json:"type"` + Args []struct { + Value json.RawMessage `json:"value"` + } `json:"args"` + } + if json.Unmarshal(params, &p) == nil && len(p.Args) > 0 { + var s string + if json.Unmarshal(p.Args[0].Value, &s) == nil && strings.HasPrefix(s, "NET|") { + logf("CONSOLE %s", s) + } + } + case "Target.attachedToTarget": + logf("ATTACH params=%s", params) + } + } + defer func() { cdpEventHook = nil }() + type frT struct { + Frame struct { + ID string `json:"id"` + URL string `json:"url"` + } `json:"frame"` + ChildFrames []frT `json:"childFrames"` + } + go func() { + var d diag + select { + case d = <-diagCh: + case <-done: + return + } + probed := map[string]bool{} + logf("MAINSID %s", d.sid) + ticker := time.NewTicker(time.Second) + defer ticker.Stop() + for { + select { + case <-done: + return + case f := <-pendingFetch: + func() { + fctx, fcancel := context.WithTimeout(context.Background(), 5*time.Second) + defer fcancel() + raw, err := d.c.call(fctx, f.sess, "Fetch.getResponseBody", map[string]any{"requestId": f.id}) + if err != nil { + logf("FCHBODY %s %s err=%v", f.method, f.url, err) + } else { + var bo struct { + Body string `json:"body"` + Base64 bool `json:"base64Encoded"` + MIMEType string `json:"mimeType"` + } + if json.Unmarshal(raw, &bo) == nil { + body := bo.Body + if bo.Base64 { + body = "(b64)" + body + } + if strings.Contains(f.url, "/rch/") && bo.Body != "" && !probed["rchjs:"+f.url] { + probed["rchjs:"+f.url] = true + n := 0 + for k := range probed { + if strings.HasPrefix(k, "rchfile:") { + n++ + } + } + probed[fmt.Sprintf("rchfile:%d", n)] = true + dst := filepath.Join(os.TempDir(), fmt.Sprintf("turnstile_rch_%s_%02d.txt", map[bool]string{true: "hl", false: "hd"}[os.Getenv("DSFREE_BROWSER_E2E_HEADLESS") != ""], n)) + if err := os.WriteFile(dst, []byte(bo.Body), 0o644); err == nil { + logf("RCHFILE %d bytes -> %s", len(bo.Body), dst) + } + } + logf("FCHBODY mime=%s %s %s %s", bo.MIMEType, f.method, f.url, body[:min(600, len(body))]) + } + } + rctx, rcancel := context.WithTimeout(context.Background(), 5*time.Second) + defer rcancel() + if _, err := d.c.call(rctx, f.sess, "Fetch.continueResponse", map[string]any{"requestId": f.id}); err != nil { + if _, err2 := d.c.call(rctx, f.sess, "Fetch.continueRequest", map[string]any{"requestId": f.id}); err2 != nil { + logf("FETCH continue failed id=%s: %v / %v", f.id, err, err2) + } + } + }() + case r := <-pendingResp: + time.Sleep(300 * time.Millisecond) + useSid := r.sess + if useSid == "" { + useSid = d.sid + } + bctx, bcancel := context.WithTimeout(context.Background(), 5*time.Second) + braw, berr := d.c.call(bctx, useSid, "Network.getResponseBody", map[string]any{"requestId": r.id}) + bcancel() + if berr != nil { + logf("RCHBODY %s err=%v", r.url, berr) + continue + } + var bo struct { + Body string `json:"body"` + Base64 bool `json:"base64Encoded"` + MIMEType string `json:"mimeType"` + } + if json.Unmarshal(braw, &bo) == nil { + if strings.Contains(r.url, "api.js") && !probed["apijs"] && bo.Body != "" { + probed["apijs"] = true + dst := filepath.Join(os.TempDir(), "turnstile_api_js.txt") + if err := os.WriteFile(dst, []byte(bo.Body), 0o644); err == nil { + logf("APIJS saved %d bytes -> %s", len(bo.Body), dst) + } + } + body := bo.Body + if bo.Base64 { + body = "(b64)" + body[:min(400, len(body))] + } + logf("RCHBODY mime=%s %s %s", bo.MIMEType, r.url, body[:min(900, len(body))]) + } + case fid := <-rchFrames: + go func() { + var last string + for i := 0; i < 15; i++ { + select { + case <-done: + return + default: + } + time.Sleep(700 * time.Millisecond) + func() { + wctx, wcancel := context.WithTimeout(context.Background(), 5*time.Second) + defer wcancel() + raw, err := d.c.call(wctx, d.sid, "Page.createIsolatedWorld", map[string]any{ + "frameId": fid, "worldName": "dsrchprobe", + }) + if err != nil { + if i == 0 { + logf("RCHFRAME %s world err=%v", fid, err) + } + return + } + var w struct { + ExecutionContextID int64 `json:"executionContextId"` + } + if json.Unmarshal(raw, &w) != nil || w.ExecutionContextID == 0 { + return + } + eraw, eerr := d.c.call(wctx, d.sid, "Runtime.evaluate", map[string]any{ + "expression": `JSON.stringify({href:(location.href||"").slice(0,95), rs:document.readyState, + focus:document.hasFocus(), vis:document.visibilityState, + inner:[innerWidth,innerHeight], win:[window.outerWidth,window.outerHeight], + wd:String(navigator.webdriver), ua:navigator.userAgent.slice(-24)})`, + "returnByValue": true, + "contextId": w.ExecutionContextID, + }) + if eerr != nil { + return + } + var out struct { + Result struct { + Value json.RawMessage `json:"value"` + } `json:"result"` + } + if json.Unmarshal(eraw, &out) == nil { + s := string(out.Result.Value) + if s != last { + last = s + logf("RCHFRAME i=%d %s -> %s", i, fid, s) + } + } + }() + } + }() + case id := <-cfCtx: + cctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + raw, err := d.c.call(cctx, d.sid, "Runtime.evaluate", map[string]any{ + "expression": `JSON.stringify({ua:navigator.userAgent, wd:String(navigator.webdriver), focus:document.hasFocus(), sl:screen.height+"x"+screen.availHeight, cw:document.documentElement.clientWidth, href:window.location.href.slice(0,70)})`, + "returnByValue": true, + "contextId": id, + }) + cancel() + if err != nil { + logf("CFCTX id=%d evaluate err=%v", id, err) + continue + } + var out struct { + Result struct { + Value json.RawMessage `json:"value"` + } `json:"result"` + } + if json.Unmarshal(raw, &out) == nil { + logf("CFCTX id=%d -> %s", id, out.Result.Value) + } + case <-ticker.C: + func() { + rctx2, rcancel2 := context.WithTimeout(context.Background(), 4*time.Second) + defer rcancel2() + traw, terr := d.c.call(rctx2, "", "Target.getTargets", nil) + if terr != nil { + return + } + var tl struct { + TargetInfos []struct { + TargetID string `json:"targetId"` + URL string `json:"url"` + Type string `json:"type"` + } `json:"targetInfos"` + } + if json.Unmarshal(traw, &tl) != nil { + return + } + for _, ti := range tl.TargetInfos { + if !strings.Contains(ti.URL, "challenges.cloudflare.com") || probed["tg:"+ti.TargetID] { + continue + } + if probed["tga:"+ti.TargetID] { + // already attached+probed once; only retry if still about:blank + } + alog, aerr := d.c.call(rctx2, "", "Target.attachToTarget", map[string]any{ + "targetId": ti.TargetID, "flatten": true, + }) + if aerr != nil { + probed["tg:"+ti.TargetID] = true + logf("RCHTGT attach err=%v", aerr) + continue + } + var atg struct { + SessionID string `json:"sessionId"` + } + if json.Unmarshal(alog, &atg) != nil || atg.SessionID == "" { + probed["tg:"+ti.TargetID] = true + continue + } + eraw, eerr := d.c.call(rctx2, atg.SessionID, "Runtime.evaluate", map[string]any{ + "expression": `JSON.stringify({href:(location.href||"").slice(0,95), rs:document.readyState, + focus:document.hasFocus(), vis:document.visibilityState, hist:history.length, + ref:(document.referrer||"").slice(0,60), + inner:[innerWidth,innerHeight], outer:[outerWidth,outerHeight], dpr:devicePixelRatio, + wd:String(navigator.webdriver), ua:navigator.userAgent, + tz:Intl.DateTimeFormat().resolvedOptions().timeZone, + dsp:String(window.__dsSp), + swd:String((Object.getOwnPropertyDescriptor(Screen.prototype,"width")||{}).get||"none"), + screen:[screen.width,screen.height,screen.availWidth,screen.availHeight], + chrome:typeof window.chrome, plugs:(navigator.plugins||[]).length, + topWin:(function(){try{return window.top===window}catch(e){return "xo"}})(), + leaks:Object.getOwnPropertyNames(window).filter(function(k){return /cdc_|webdriver|selenium|phantom/i.test(k)}).join(",")})`, + "returnByValue": true, + }) + if eerr != nil { + logf("RCHTGT eval err=%v", eerr) + continue + } + var out struct { + Result struct { + Value json.RawMessage `json:"value"` + } `json:"result"` + } + if json.Unmarshal(eraw, &out) == nil { + val := string(out.Result.Value) + if !strings.Contains(val, "about:blank") { + probed["tg:"+ti.TargetID] = true + } + logf("RCHTGT type=%s %s -> %s", ti.Type, ti.URL[:min(70, len(ti.URL))], val) + } + } + }() + rctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + raw, err := d.c.call(rctx, d.sid, "Page.getFrameTree", nil) + if err != nil { + cancel() + logf("FRAMETREE err=%v", err) + return + } + cancel() + var ftRes struct { + FrameTree frT `json:"frameTree"` + } + if json.Unmarshal(raw, &ftRes) != nil { + continue + } + if !probed["_log"] { + probed["_log"] = true + var n int + var collect func(f frT) + collect = func(f frT) { + n++ + for _, c := range f.ChildFrames { + collect(c) + } + } + collect(ftRes.FrameTree) + logf("FRAMETREE n=%d", n) + } + var walk func(f frT) + walk = func(f frT) { + if f.Frame.URL != "" { + ukey := "u:" + f.Frame.URL + if !probed[ukey] { + probed[ukey] = true + logf("FRAME %s", f.Frame.URL[:min(70, len(f.Frame.URL))]) + } + } + if strings.Contains(f.Frame.URL, "challenges.cloudflare") && f.Frame.ID != "" && !probed[f.Frame.ID] { + probed[f.Frame.ID] = true + wctx, wcancel := context.WithTimeout(context.Background(), 5*time.Second) + wraw, werr := d.c.call(wctx, d.sid, "Page.createIsolatedWorld", map[string]any{ + "frameId": f.Frame.ID, "worldName": "dsprobe", + }) + if werr == nil { + var w struct { + ExecutionContextID int64 `json:"executionContextId"` + } + if json.Unmarshal(wraw, &w) == nil && w.ExecutionContextID != 0 { + eraw, eerr := d.c.call(wctx, d.sid, "Runtime.evaluate", map[string]any{ + "expression": `JSON.stringify({ua:navigator.userAgent, wd:String(navigator.webdriver), focus:document.hasFocus(), sl:screen.height+"x"+screen.availHeight, cw:document.documentElement.clientWidth, href:location.href.slice(0,70)})`, + "returnByValue": true, + "contextId": w.ExecutionContextID, + }) + if eerr == nil { + var out struct { + Result struct { + Value json.RawMessage `json:"value"` + } `json:"result"` + } + if json.Unmarshal(eraw, &out) == nil { + logf("CFFRAME %s -> %s", f.Frame.URL[:min(60, len(f.Frame.URL))], out.Result.Value) + } + } else { + logf("CFFRAME eval err=%v", eerr) + } + } + } else { + logf("CFFRAME world err=%v", werr) + } + wcancel() + } + for _, c := range f.ChildFrames { + walk(c) + } + } + walk(ftRes.FrameTree) + dctx, dcancel := context.WithTimeout(context.Background(), 5*time.Second) + draw, derr := d.c.call(dctx, d.sid, "Runtime.evaluate", map[string]any{ + "expression": domExpr, "returnByValue": true, + }) + dcancel() + if derr == nil { + var dout struct { + Result struct { + Value json.RawMessage `json:"value"` + } `json:"result"` + } + if json.Unmarshal(draw, &dout) == nil { + var s string + if json.Unmarshal(dout.Result.Value, &s) == nil && s != "" && !probed["dom:"+s] { + probed["dom:"+s] = true + logf("DOM %s", s) + } + } + } + if !probed["fp"] { + fctx, fcancel := context.WithTimeout(context.Background(), 5*time.Second) + fraw, ferr := d.c.call(fctx, d.sid, "Runtime.evaluate", map[string]any{ + "expression": fpExpr, "returnByValue": true, + }) + fcancel() + if ferr == nil { + var fout struct { + Result struct { + Value json.RawMessage `json:"value"` + } `json:"result"` + } + if json.Unmarshal(fraw, &fout) == nil { + var s string + if json.Unmarshal(fout.Result.Value, &s) == nil && s != "" { + probed["fp"] = true + logf("FP %s", s) + } + } + } + } + } + } + }() + } + + site := cfg.Sites["de"] + c := solver.coreFor(cfg.Proxy.URL) + t.Logf("browser=%s proxy=%s site=%s", exe, cfg.Proxy.URL, site.BaseURL) + + ctx := context.Background() + token, err := solver.solveToken(ctx, site, site.SiteKey, c) + if err != nil { + t.Fatalf("solve: %v", err) + } + if len(token) < 20 { + t.Fatalf("token too short (%d chars): %q", len(token), token) + } + t.Logf("token ok: %d chars, prefix %q", len(token), token[:min(12, len(token))]) + + // the token is only worth something if the site accepts it + sess, err := httpx.NewSession(httpx.Options{ProxyURL: cfg.Proxy.URL, Timeout: 30 * time.Second}) + if err != nil { + t.Fatalf("session: %v", err) + } + defer sess.Close() + if err := solver.verifyToken(ctx, sess, site, token); err != nil { + t.Fatalf("verify: %v", err) + } + t.Logf("verify ok: %d cookies for %s", len(sess.Cookies()), site.Code) + for name := range sess.Cookies() { + t.Logf(" cookie: %s", name) + } +} diff --git a/internal/turnstile/cdp.go b/internal/turnstile/cdp.go new file mode 100644 index 0000000..abe48f4 --- /dev/null +++ b/internal/turnstile/cdp.go @@ -0,0 +1,230 @@ +package turnstile + +import ( + "context" + "encoding/json" + "fmt" + "sync" + "time" + + "golang.org/x/net/websocket" +) + +// cdpEventHook, when set (tests / troubleshooting), receives every protocol +// event. Production leaves it nil and events are dropped. +var cdpEventHook func(session, method string, params json.RawMessage) + +// subTargetEvent is a Target.attachedToTarget / Target.detachedFromTarget +// notification routed to a subscriber (the headless sub-frame screen fix). +type subTargetEvent struct { + session string + method string + params json.RawMessage +} + +// cdp is a minimal Chrome DevTools Protocol client: JSON request/response over +// a single WebSocket. The solver only issues calls and polls, which keeps the +// client small and race-free. +type cdp struct { + conn *websocket.Conn + writeMu sync.Mutex + + mu sync.Mutex + seq int64 + pending map[int64]chan cdpResult + err error + subCh chan subTargetEvent +} + +type cdpResult struct { + Result json.RawMessage + Error *cdpCallError +} + +type cdpCallError struct { + Code int `json:"code"` + Message string `json:"message"` +} + +func (e *cdpCallError) Error() string { + return fmt.Sprintf("cdp error %d: %s", e.Code, e.Message) +} + +// dialCDP opens the DevTools WebSocket (origin must match the browser host, +// otherwise Chrome rejects the handshake). +func dialCDP(ctx context.Context, wsURL, origin string) (*cdp, error) { + cfg, err := websocket.NewConfig(wsURL, origin) + if err != nil { + return nil, err + } + conn, err := cfg.DialContext(ctx) + if err != nil { + return nil, err + } + c := &cdp{conn: conn, pending: map[int64]chan cdpResult{}} + go c.readLoop() + return c, nil +} + +func (c *cdp) readLoop() { + for { + var raw string + if err := websocket.Message.Receive(c.conn, &raw); err != nil { + c.fail(fmt.Errorf("cdp connection lost: %w", err)) + return + } + var msg struct { + ID int64 `json:"id"` + Method string `json:"method"` + Params json.RawMessage `json:"params"` + SessionID string `json:"sessionId"` + Result json.RawMessage `json:"result"` + Error *cdpCallError `json:"error"` + } + if err := json.Unmarshal([]byte(raw), &msg); err != nil { + continue + } + if msg.ID == 0 { + if msg.Method != "" { + if h := cdpEventHook; h != nil { + h(msg.SessionID, msg.Method, msg.Params) + } + if msg.Method == "Target.attachedToTarget" || msg.Method == "Target.detachedFromTarget" { + c.mu.Lock() + ch := c.subCh + c.mu.Unlock() + if ch != nil { + select { + case ch <- subTargetEvent{msg.SessionID, msg.Method, msg.Params}: + default: // subscriber is behind; never block the read loop + } + } + } + } + continue // events and target-scoped notifications + } + c.mu.Lock() + ch, ok := c.pending[msg.ID] + delete(c.pending, msg.ID) + c.mu.Unlock() + if ok { + ch <- cdpResult{Result: msg.Result, Error: msg.Error} + } + } +} + +func (c *cdp) fail(err error) { + c.mu.Lock() + if c.err == nil { + c.err = err + } + pending := c.pending + c.pending = map[int64]chan cdpResult{} + c.mu.Unlock() + for _, ch := range pending { + ch <- cdpResult{Error: &cdpCallError{Message: err.Error()}} + } +} + +// call sends one CDP command, optionally scoped to an attached target session. +func (c *cdp) call(ctx context.Context, session, method string, params any) (json.RawMessage, error) { + c.mu.Lock() + if c.err != nil { + err := c.err + c.mu.Unlock() + return nil, err + } + c.seq++ + id := c.seq + ch := make(chan cdpResult, 1) + c.pending[id] = ch + c.mu.Unlock() + + msg := map[string]any{"id": id, "method": method} + if params != nil { + msg["params"] = params + } + if session != "" { + msg["sessionId"] = session + } + buf, err := json.Marshal(msg) + if err != nil { + c.drop(id) + return nil, err + } + c.writeMu.Lock() + sendErr := websocket.Message.Send(c.conn, string(buf)) + c.writeMu.Unlock() + if sendErr != nil { + c.drop(id) + return nil, fmt.Errorf("cdp send %s: %w", method, sendErr) + } + + select { + case <-ctx.Done(): + c.drop(id) + return nil, ctx.Err() + case res := <-ch: + if res.Error != nil { + return nil, res.Error + } + return res.Result, nil + } +} + +func (c *cdp) drop(id int64) { + c.mu.Lock() + delete(c.pending, id) + c.mu.Unlock() +} + +// setSubTargets installs (nil clears) the sub-target event sink. Install it +// before Target.setAutoAttach so no attach notification can be lost. +func (c *cdp) setSubTargets(ch chan subTargetEvent) { + c.mu.Lock() + c.subCh = ch + c.mu.Unlock() +} + +func (c *cdp) Close() { _ = c.conn.Close() } + +type evalResult struct { + Result struct { + Type string `json:"type"` + Value json.RawMessage `json:"value"` + } `json:"result"` + ExceptionDetails *struct { + Text string `json:"text"` + Exception *struct { + Description string `json:"description"` + } `json:"exception"` + } `json:"exceptionDetails"` +} + +// evaluate runs an expression in the page and returns its JSON value. The +// 15s cap keeps a wedged browser from blocking a chat request forever. +func (c *cdp) evaluate(ctx context.Context, session, expr string) (json.RawMessage, error) { + ctx, cancel := context.WithTimeout(ctx, 15*time.Second) + defer cancel() + raw, err := c.call(ctx, session, "Runtime.evaluate", map[string]any{ + "expression": expr, + "returnByValue": true, + "awaitPromise": true, + "userGesture": true, + }) + if err != nil { + return nil, err + } + var out evalResult + if err := json.Unmarshal(raw, &out); err != nil { + return nil, fmt.Errorf("runtime.evaluate bad json: %w", err) + } + if d := out.ExceptionDetails; d != nil { + msg := d.Text + if d.Exception != nil && d.Exception.Description != "" { + msg = d.Exception.Description + } + return nil, fmt.Errorf("javascript error: %s", msg) + } + return out.Result.Value, nil +} diff --git a/internal/turnstile/probe_test.go b/internal/turnstile/probe_test.go new file mode 100644 index 0000000..a4958ca --- /dev/null +++ b/internal/turnstile/probe_test.go @@ -0,0 +1,139 @@ +package turnstile + +import ( + "context" + "encoding/json" + "fmt" + "os" + "strings" + "testing" + "time" + + "github.com/nyoungo/dsfree2api/internal/config" +) + +// TestHeadlessProbe prints the environment signals Cloudflare can use to +// detect a headless browser, mirroring the production setup path. +// Opt in: DSFREE_PROBE=1 go test ./internal/turnstile -run Probe -v +// (DSFREE_PROBE_HEADED=1 runs the headed baseline instead) +func TestHeadlessProbe(t *testing.T) { + if os.Getenv("DSFREE_PROBE") == "" { + t.Skip("set DSFREE_PROBE=1 to probe the headless environment") + } + cfg, err := config.Load("../../config.toml") + if err != nil { + t.Fatalf("load config: %v", err) + } + headless := os.Getenv("DSFREE_PROBE_HEADED") == "" + exe := `C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe` + + ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) + defer cancel() + + br, err := launchBrowser(ctx, exe, config.Turnstile{BrowserHeadless: headless}, "de-DE,de;q=0.9", cfg.Proxy.URL) + if err != nil { + t.Fatalf("launch: %v", err) + } + defer br.Close() + conn, err := dialCDP(ctx, br.wsURL, fmt.Sprintf("http://127.0.0.1:%d", br.port)) + if err != nil { + t.Fatalf("dial: %v", err) + } + defer conn.Close() + sid, err := attachPage(ctx, conn) + if err != nil { + t.Fatalf("attach: %v", err) + } + metrics := map[string]any{"width": 1280, "height": 900, "deviceScaleFactor": 1, "mobile": false} + if headless { + metrics["screenWidth"] = 1920 + metrics["screenHeight"] = 1080 + } + for _, step := range [][2]any{ + {"Page.enable", nil}, + {"Runtime.enable", nil}, + {"Emulation.setDeviceMetricsOverride", metrics}, + {"Page.addScriptToEvaluateOnNewDocument", map[string]any{"source": stealthJS}}, + } { + if _, err := conn.call(ctx, sid, step[0].(string), step[1]); err != nil { + t.Fatalf("%v: %v", step[0], err) + } + } + if headless { + bootSid, closeBoot, err := openBootstrap(ctx, conn, "https://www.baidu.com/", 8*time.Second) + if err != nil { + t.Logf("bootstrap: %v", err) + } else { + if _, err := overrideHeadlessUA(ctx, conn, sid, bootSid); err != nil { + t.Logf("overrideHeadlessUA: %v", err) + } + closeBoot() + } + } + if _, err := conn.call(ctx, sid, "Page.navigate", map[string]any{"url": "https://deepseek.de/"}); err != nil { + t.Fatalf("navigate: %v", err) + } + deadline := time.Now().Add(30 * time.Second) + for { + raw, err := conn.evaluate(ctx, sid, `document.readyState`) + if err == nil && string(raw) == `"complete"` { + break + } + if time.Now().After(deadline) { + t.Fatalf("page never completed: raw=%s err=%v", raw, err) + } + time.Sleep(500 * time.Millisecond) + } + probe := `(() => { + const o = {headless: %(HEADLESS)s, ua: navigator.userAgent, + screen: [screen.width, screen.height, screen.availWidth, screen.availHeight, screen.colorDepth, screen.pixelDepth], + inner: [window.innerWidth, window.innerHeight], + outer: [window.outerWidth, window.outerHeight, window.screenX, window.screenY], + docClientW: document.documentElement.clientWidth, + mq: {hover: matchMedia("(hover: hover)").matches, anyHover: matchMedia("(any-hover: hover)").matches, + pfine: matchMedia("(pointer: fine)").matches, pcoarse: matchMedia("(pointer: coarse)").matches, + pnone: matchMedia("(pointer: none)").matches, anyFine: matchMedia("(any-pointer: fine)").matches, + srgb: matchMedia("(color-gamut: srgb)").matches, dark: matchMedia("(prefers-color-scheme: dark)").matches}, + chrome: Object.keys(window.chrome || {}).sort(), + notif: (typeof Notification !== "undefined") ? Notification.permission : "no-Notification", + plugins: navigator.plugins.length, + conn: navigator.connection ? [navigator.connection.effectiveType, navigator.connection.downlink, navigator.connection.rtt] : null, + orient: screen.orientation ? [screen.orientation.type, screen.orientation.angle] : null, + vv: window.visualViewport ? [window.visualViewport.width, window.visualViewport.height, window.visualViewport.scale] : null, + audio: null, voices: null, kbd: null, raf: null, gamepads: null}; + return (async () => { + try { const ac = new AudioContext(); o.audio = [ac.sampleRate, ac.state, ac.baseLatency || 0]; } catch (e) { o.audio = "err:" + e; } + try { + o.voices = await new Promise(res => { + const ss = window.speechSynthesis; if (!ss) return res(-1); + const v = ss.getVoices(); if (v.length) return res(v.length); + const t = setTimeout(() => res(ss.getVoices().length), 1500); + ss.addEventListener("voiceschanged", () => { clearTimeout(t); res(ss.getVoices().length); }, {once:true}); + }); + } catch (e) { o.voices = "err:" + e; } + try { + const m = await navigator.keyboard.getLayoutMap(); + o.kbd = [m.size, Array.from(m.values()).slice(0, 8).join("")]; + } catch (e) { o.kbd = "err:" + e; } + try { + o.raf = await new Promise(res => { + let n = 0, t0 = 0; + const f = t => { if (!n) { t0 = t; } if (++n >= 5) { res(Math.round((t - t0) / 4)); } else requestAnimationFrame(f); }; + requestAnimationFrame(f); + setTimeout(() => res("timeout"), 2000); + }); + } catch (e) { o.raf = "err:" + e; } + try { o.gamepads = (navigator.getGamepads ? Array.from(navigator.getGamepads()).length : -1); } catch (e) { o.gamepads = "err"; } + return JSON.stringify(o); + })(); + })()` + raw, err := conn.evaluate(ctx, sid, strings.Replace(probe, "%(HEADLESS)s", map[bool]string{true: "true", false: "false"}[headless], 1)) + if err != nil { + t.Fatalf("evaluate: %v", err) + } + var payload string + if json.Unmarshal(raw, &payload) != nil { + payload = string(raw) + } + fmt.Printf("PROBE(headless=%v): %s\n", headless, payload) +} diff --git a/internal/turnstile/solver.go b/internal/turnstile/solver.go new file mode 100644 index 0000000..cd2e588 --- /dev/null +++ b/internal/turnstile/solver.go @@ -0,0 +1,527 @@ +// Package turnstile solves Cloudflare Turnstile challenges for the upstream +// sites and caches the resulting verified cookies per proxy route + site. +package turnstile + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strings" + "sync" + "time" + + "github.com/nyoungo/dsfree2api/internal/config" + "github.com/nyoungo/dsfree2api/internal/httpx" +) + +// Error is a Turnstile solver failure. Retryable marks transient (network) +// failures; solver-side rejections such as a bad API key or exhausted quota +// return Retryable=false so callers fail fast instead of burning retries. +type Error struct { + Msg string + Retryable bool +} + +func (e *Error) Error() string { return e.Msg } + +// Retryable reports whether err is worth retrying. Non-solver errors are +// assumed transient. +func Retryable(err error) bool { + var e *Error + if errors.As(err, &e) { + return e.Retryable + } + return true +} + +type Record struct { + Site string `json:"site"` + Proxy string `json:"proxy"` + At time.Time `json:"at"` + ElapsedMs int64 `json:"elapsed_ms"` + OK bool `json:"ok"` + Error string `json:"error,omitempty"` +} + +type SiteStatus struct { + Site string `json:"site"` + Valid bool `json:"valid"` + ExpiresAt int64 `json:"expires_at"` + RemainingS int64 `json:"remaining_s"` + CookieNum int `json:"cookie_count"` +} + +type cookieState struct { + cookies map[string]string + expiresAt time.Time +} + +type core struct { + proxy string + client *http.Client + mu sync.Mutex + cache map[string]*cookieState + locks map[string]*sync.Mutex +} + +func (c *core) lockFor(site string) *sync.Mutex { + c.mu.Lock() + defer c.mu.Unlock() + l, ok := c.locks[site] + if !ok { + l = &sync.Mutex{} + c.locks[site] = l + } + return l +} + +type Solver struct { + cfg config.Turnstile + ua string + mu sync.Mutex + cores map[string]*core + history []Record + now func() time.Time + + // browserMu serializes browser solves: one Chrome window at a time. + browserMu sync.Mutex + + // refreshOverride is a test seam; nil in production. + refreshOverride func(ctx context.Context, sess httpx.Session, site *config.Site, c *core) error +} + +func New(cfg config.Turnstile, userAgent string) *Solver { + if userAgent == "" { + userAgent = httpx.DefaultUserAgent + } + return &Solver{ + cfg: cfg, + ua: userAgent, + cores: map[string]*core{}, + now: time.Now, + } +} + +func (s *Solver) Enabled() bool { return s.cfg.Enabled } + +func (s *Solver) Config() config.Turnstile { + s.mu.Lock() + defer s.mu.Unlock() + return s.cfg +} + +// SetConfig swaps the solver configuration (used by the web console). Cached +// cookies survive it, otherwise saving an unrelated field would throw away a +// cookie import. +func (s *Solver) SetConfig(cfg config.Turnstile) { + s.mu.Lock() + defer s.mu.Unlock() + s.cfg = cfg +} + +func (s *Solver) coreFor(proxy string) *core { + key := proxy + if key == "" { + key = "direct" + } + s.mu.Lock() + defer s.mu.Unlock() + c, ok := s.cores[key] + if !ok { + client, _ := httpx.StdClient(proxy, time.Duration(s.cfg.TimeoutSeconds+30)*time.Second) + c = &core{ + proxy: key, + client: client, + cache: map[string]*cookieState{}, + locks: map[string]*sync.Mutex{}, + } + s.cores[key] = c + } + return c +} + +func (s *Solver) Invalidate(siteCode string) { + s.mu.Lock() + cores := make([]*core, 0, len(s.cores)) + for _, c := range s.cores { + cores = append(cores, c) + } + s.mu.Unlock() + for _, c := range cores { + c.mu.Lock() + delete(c.cache, siteCode) + c.mu.Unlock() + } +} + +// ImportCookies stores cookies exported from a browser session that already +// passed the Turnstile check, so chat works while [turnstile] is off or the +// solver service is down. Cookies are bound to the IP/UA that obtained them, +// so they must be copied from a browser using the same proxy route. +func (s *Solver) ImportCookies(siteCode, raw, proxy string) (time.Time, int, error) { + pairs := ParseCookiePairs(raw) + if len(pairs) == 0 { + return time.Time{}, 0, errors.New("no cookies parsed — expected `name=value; name2=value2`") + } + exp := time.Now().Add(time.Duration(s.Config().CookieTTLSeconds) * time.Second) + c := s.coreFor(proxy) + c.mu.Lock() + c.cache[siteCode] = &cookieState{cookies: pairs, expiresAt: exp} + c.mu.Unlock() + return exp, len(pairs), nil +} + +// cookieAttrs are Set-Cookie attributes and never part of the session. +var cookieAttrs = map[string]bool{ + "path": true, "domain": true, "expires": true, "max-age": true, + "samesite": true, "httponly": true, "secure": true, "priority": true, + "partitioned": true, +} + +// ParseCookiePairs accepts `a=b; c=d`, `document.cookie` output or one pair +// per line. Values may themselves contain '='. +func ParseCookiePairs(raw string) map[string]string { + out := map[string]string{} + for _, chunk := range strings.Split(strings.ReplaceAll(raw, "\n", ";"), ";") { + chunk = strings.TrimSpace(chunk) + if chunk == "" { + continue + } + i := strings.Index(chunk, "=") + if i <= 0 { + continue + } + k := strings.ToLower(strings.Trim(strings.TrimSpace(chunk[:i]), `"`)) + v := strings.Trim(strings.TrimSpace(chunk[i+1:]), `"`) + if cookieAttrs[k] || v == "" { + continue + } + out[k] = v + } + return out +} + +// ApplyValidCookies ensures the session carries fresh verified cookies. +// Concurrent callers for the same site coalesce behind a per-site lock. +// Cached cookies (including a manual import) are applied even when the +// [turnstile] switch is off; only the solving step needs the switch. +func (s *Solver) ApplyValidCookies(ctx context.Context, sess httpx.Session, site *config.Site, proxy string) error { + c := s.coreFor(proxy) + if st := c.load(site.Code); st != nil { + sess.SetCookies(st.cookies) + return nil + } + if !s.Enabled() { + return nil + } + l := c.lockFor(site.Code) + l.Lock() + defer l.Unlock() + if st := c.load(site.Code); st != nil { + sess.SetCookies(st.cookies) + return nil + } + return s.refreshLocked(ctx, sess, site, c) +} + +// Refresh forces a fresh solve → verify cycle. +func (s *Solver) Refresh(ctx context.Context, sess httpx.Session, site *config.Site, proxy string) error { + if !s.cfg.Enabled { + return nil + } + c := s.coreFor(proxy) + l := c.lockFor(site.Code) + l.Lock() + defer l.Unlock() + return s.refreshLocked(ctx, sess, site, c) +} + +func (c *core) load(site string) *cookieState { + c.mu.Lock() + defer c.mu.Unlock() + st, ok := c.cache[site] + if !ok || time.Now().After(st.expiresAt) { + return nil + } + return st +} + +func (s *Solver) refreshLocked(ctx context.Context, sess httpx.Session, site *config.Site, c *core) error { + if s.refreshOverride != nil { + return s.refreshOverride(ctx, sess, site, c) + } + start := time.Now() + cfg := s.Config() + siteKey := site.SiteKey + if siteKey == "" { + siteKey = cfg.SiteKey + } + token, err := s.solveToken(ctx, site, siteKey, c) + if err != nil { + s.record(site.Code, c.proxy, start, err) + return err + } + // Warm the page the way a browser would before posting the verdict. + if _, err := sess.Do(ctx, httpx.Request{ + Method: "GET", + URL: site.BaseURL + "/", + Header: map[string]string{ + "User-Agent": s.ua, + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8", + "Accept-Language": site.Language, + "Referer": site.BaseURL + "/", + }, + Timeout: 30 * time.Second, + }); err != nil { + s.record(site.Code, c.proxy, start, err) + return &Error{Msg: "turnstile warm-up failed: " + err.Error(), Retryable: true} + } + if err := s.verifyToken(ctx, sess, site, token); err != nil { + s.record(site.Code, c.proxy, start, err) + return err + } + c.mu.Lock() + c.cache[site.Code] = &cookieState{ + cookies: sess.Cookies(), + expiresAt: time.Now().Add(time.Duration(cfg.CookieTTLSeconds) * time.Second), + } + c.mu.Unlock() + s.record(site.Code, c.proxy, start, nil) + return nil +} + +// solveToken obtains a Turnstile token through the configured provider. +// 1: api — POST the site to a hosted solver service. +// 2: browser — drive the local Chrome/Edge from [turnstile].browser_path. +// 3: manual — never solves; cookies only arrive via the console import box. +func (s *Solver) solveToken(ctx context.Context, site *config.Site, sitekey string, c *core) (string, error) { + cfg := s.Config() + switch cfg.ProviderValue() { + case config.ProviderAPI: + return s.solveTokenAPI(ctx, site.BaseURL, sitekey, c) + case config.ProviderBrowser: + return s.solveTokenBrowser(ctx, site, sitekey, c) + case config.ProviderManual: + return "", &Error{ + Msg: `turnstile provider is "manual" — import cookies in the web console (Turnstile page)`, + Retryable: false, + } + default: + return "", &Error{ + Msg: fmt.Sprintf("turnstile provider %q is invalid (api|browser|manual)", cfg.Provider), + Retryable: false, + } + } +} + +func (s *Solver) solveTokenAPI(ctx context.Context, baseURL, sitekey string, c *core) (string, error) { + cfg := s.Config() + payload, _ := json.Marshal(map[string]any{ + "url": baseURL, + "sitekey": sitekey, + "action": cfg.Action, + "cdata": "", + "timeoutSeconds": cfg.TimeoutSeconds, + }) + var last error + for attempt := 0; attempt < cfg.Retries; attempt++ { + req, err := http.NewRequestWithContext(ctx, http.MethodPost, cfg.APIURL, bytes.NewReader(payload)) + if err != nil { + return "", err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+cfg.APIKey) + resp, err := c.client.Do(req) + if err != nil { + last = &Error{Msg: "turnstile solver request failed: " + err.Error(), Retryable: true} + } else { + body, _ := io.ReadAll(resp.Body) + resp.Body.Close() + if resp.StatusCode >= 400 { + last = &Error{ + Msg: fmt.Sprintf("turnstile solver http %d: %s (check [turnstile].api_key / api_url)", + resp.StatusCode, trim(body)), + Retryable: retryableStatus(resp.StatusCode), + } + } else { + var out struct { + ErrorID int `json:"errorId"` + Status string `json:"status"` + Solution struct { + Token string `json:"token"` + } `json:"solution"` + Elapsed float64 `json:"elapsedTime"` + } + if err := json.Unmarshal(body, &out); err != nil { + last = &Error{Msg: "turnstile solver bad json: " + err.Error()} + } else if out.ErrorID != 0 || (out.Status != "" && out.Status != "ready") { + last = &Error{Msg: fmt.Sprintf("turnstile solver error: %s", trim(body))} + } else if out.Solution.Token == "" { + last = &Error{Msg: "turnstile solver returned no token"} + } else { + return out.Solution.Token, nil + } + } + } + if !Retryable(last) { + return "", last + } + select { + case <-ctx.Done(): + return "", ctx.Err() + case <-time.After(time.Duration(cfg.RetryBackoffSeconds*float64(attempt+1)) * time.Second): + } + } + return "", last +} + +// retryableStatus decides which solver HTTP statuses are worth another attempt. +// Solver-side rejections (bad key, exhausted quota, challenge rejected) return +// the same answer every time, so they are not retried. +func retryableStatus(code int) bool { + switch code { + case http.StatusRequestTimeout, http.StatusTooManyRequests, + http.StatusBadGateway, http.StatusServiceUnavailable, http.StatusGatewayTimeout: + return true + } + return false +} + +func (s *Solver) verifyToken(ctx context.Context, sess httpx.Session, site *config.Site, token string) error { + cfg := s.Config() + body := fmt.Sprintf("action=%s&token=%s", urlQueryEscape(site.VerifyAction), urlQueryEscape(token)) + headers := map[string]string{ + "User-Agent": s.ua, + "Accept": "application/json, text/javascript, */*; q=0.01", + "Accept-Language": site.Language, + "X-Requested-With": "XMLHttpRequest", + "Origin": site.BaseURL, + "Referer": site.BaseURL + "/", + "Content-Type": "application/x-www-form-urlencoded", + } + var last error + for attempt := 0; attempt < cfg.Retries; attempt++ { + resp, err := sess.Do(ctx, httpx.Request{ + Method: "POST", + URL: site.AJAXURL, + Header: headers, + Body: []byte(body), + Timeout: 30 * time.Second, + }) + if err == nil { + var data map[string]any + if jerr := json.Unmarshal(resp.Body, &data); jerr != nil { + last = &Error{Msg: "turnstile verify bad json: " + jerr.Error()} + } else if ok, _ := data["ok"].(bool); ok { + return nil + } else { + last = &Error{Msg: fmt.Sprintf("turnstile verify failed: %s", trim(resp.Body))} + } + } else { + last = &Error{Msg: "turnstile verify request failed: " + err.Error(), Retryable: true} + } + if !Retryable(last) { + return last + } + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(time.Duration(cfg.RetryBackoffSeconds*float64(attempt+1)) * time.Second): + } + } + return last +} + +func (s *Solver) record(site, proxy string, start time.Time, err error) { + rec := Record{ + Site: site, + Proxy: proxy, + At: start, + ElapsedMs: time.Since(start).Milliseconds(), + OK: err == nil, + } + if err != nil { + rec.Error = err.Error() + } + s.mu.Lock() + s.history = append(s.history, rec) + if len(s.history) > 100 { + s.history = s.history[len(s.history)-100:] + } + s.mu.Unlock() +} + +// Status reports the cached cookie lifetime for every configured site. +func (s *Solver) Status(codes []string) []SiteStatus { + s.mu.Lock() + cores := make([]*core, 0, len(s.cores)) + for _, c := range s.cores { + cores = append(cores, c) + } + s.mu.Unlock() + + out := make([]SiteStatus, 0, len(codes)) + now := time.Now() + for _, code := range codes { + st := SiteStatus{Site: code} + for _, c := range cores { + c.mu.Lock() + cs := c.cache[code] + c.mu.Unlock() + if cs != nil { + st.Valid = now.Before(cs.expiresAt) + st.ExpiresAt = cs.expiresAt.Unix() + st.RemainingS = int64(cs.expiresAt.Sub(now).Seconds()) + st.CookieNum = len(cs.cookies) + break + } + } + out = append(out, st) + } + sortStatus(out) + return out +} + +func (s *Solver) History() []Record { + s.mu.Lock() + defer s.mu.Unlock() + out := make([]Record, len(s.history)) + copy(out, s.history) + return out +} + +func sortStatus(s []SiteStatus) { + for i := 1; i < len(s); i++ { + for j := i; j > 0 && s[j].Site < s[j-1].Site; j-- { + s[j], s[j-1] = s[j-1], s[j] + } + } +} + +func urlQueryEscape(s string) string { + var b bytes.Buffer + for i := 0; i < len(s); i++ { + c := s[i] + switch { + case c >= 'a' && c <= 'z', c >= 'A' && c <= 'Z', c >= '0' && c <= '9', + c == '-', c == '_', c == '.', c == '~': + b.WriteByte(c) + default: + fmt.Fprintf(&b, "%%%02X", c) + } + } + return b.String() +} + +func trim(b []byte) string { + const max = 300 + s := string(b) + if len(s) > max { + return s[:max] + "..." + } + return s +} diff --git a/internal/turnstile/solver_test.go b/internal/turnstile/solver_test.go new file mode 100644 index 0000000..50b3b21 --- /dev/null +++ b/internal/turnstile/solver_test.go @@ -0,0 +1,194 @@ +package turnstile + +import ( + "context" + "io" + "reflect" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/nyoungo/dsfree2api/internal/config" + "github.com/nyoungo/dsfree2api/internal/httpx" +) + +const examplePath = "../../config.example.toml" + +type fakeSession struct { + mu sync.Mutex + cookies map[string]string +} + +func (f *fakeSession) Do(context.Context, httpx.Request) (*httpx.Response, error) { + return &httpx.Response{Status: 200}, nil +} +func (f *fakeSession) Stream(context.Context, httpx.Request) (io.ReadCloser, int, error) { + return io.NopCloser(strings.NewReader("")), 200, nil +} +func (f *fakeSession) Cookies() map[string]string { + f.mu.Lock() + defer f.mu.Unlock() + out := map[string]string{} + for k, v := range f.cookies { + out[k] = v + } + return out +} +func (f *fakeSession) SetCookies(m map[string]string) { + f.mu.Lock() + defer f.mu.Unlock() + if f.cookies == nil { + f.cookies = map[string]string{} + } + for k, v := range m { + f.cookies[k] = v + } +} +func (f *fakeSession) Close() {} + +func TestApplyValidCookiesCoalescesConcurrentRefreshes(t *testing.T) { + cfg, err := config.Load(examplePath) + if err != nil { + t.Fatalf("load config: %v", err) + } + site := cfg.Sites["de"] + cfg.Turnstile.Enabled = true + solver := New(cfg.Turnstile, httpx.DefaultUserAgent) + + var calls int32 + solver.refreshOverride = func(_ context.Context, sess httpx.Session, s *config.Site, c *core) error { + atomic.AddInt32(&calls, 1) + time.Sleep(20 * time.Millisecond) + state := map[string]string{"dsts": "ok"} + sess.SetCookies(state) + c.mu.Lock() + c.cache[s.Code] = &cookieState{cookies: state, expiresAt: time.Now().Add(time.Hour)} + c.mu.Unlock() + return nil + } + + const n = 5 + sessions := make([]*fakeSession, n) + var wg sync.WaitGroup + errs := make([]error, n) + for i := 0; i < n; i++ { + sessions[i] = &fakeSession{} + wg.Add(1) + go func(i int) { + defer wg.Done() + errs[i] = solver.ApplyValidCookies(context.Background(), sessions[i], site, "") + }(i) + } + wg.Wait() + + if got := atomic.LoadInt32(&calls); got != 1 { + t.Errorf("refresh calls = %d, want 1", got) + } + for i, sess := range sessions { + if sess.Cookies()["dsts"] != "ok" { + t.Errorf("session %d missing dsts cookie: %v", i, sess.Cookies()) + } + if errs[i] != nil { + t.Errorf("session %d error: %v", i, errs[i]) + } + } +} + +func TestImportCookiesWorkWithSolverDisabled(t *testing.T) { + cfg, err := config.Load(examplePath) + if err != nil { + t.Fatalf("load config: %v", err) + } + if cfg.Turnstile.Enabled { + t.Fatal("example config should be disabled — this test depends on it") + } + solver := New(cfg.Turnstile, httpx.DefaultUserAgent) + + exp, n, err := solver.ImportCookies("de", "cf_clearance=abc123; deepseek_ts=xyz; Path=/; Secure", "") + if err != nil { + t.Fatalf("import: %v", err) + } + if n != 2 { + t.Errorf("imported %d cookies, want 2", n) + } + if exp.IsZero() || !time.Now().Before(exp) { + t.Errorf("expiry = %v, want in the future", exp) + } + + site := cfg.Sites["de"] + sess := &fakeSession{} + if err := solver.ApplyValidCookies(context.Background(), sess, site, ""); err != nil { + t.Fatalf("apply: %v", err) + } + if got := sess.Cookies()["deepseek_ts"]; got != "xyz" { + t.Errorf("deepseek_ts = %q, want xyz (imported cookies must apply while disabled)", got) + } + if _, ok := sess.Cookies()["cf_clearance"]; !ok { + t.Error("cf_clearance not applied") + } + + st := solver.Status([]string{"de"}) + if len(st) != 1 || !st[0].Valid { + t.Fatalf("status = %+v, want one valid entry", st) + } + if st[0].CookieNum != 2 { + t.Errorf("cookie_count = %d, want 2", st[0].CookieNum) + } + if len(solver.History()) != 0 { + t.Error("import must not show up as a solved record") + } +} + +func TestParseCookiePairs(t *testing.T) { + got := ParseCookiePairs("a=1; b=2=3\nPath=/\nExpires=Wed, 21 Oct 2015 07:28:00 GMT; \"c\"=4") + want := map[string]string{"a": "1", "b": "2=3", "c": "4"} + if !reflect.DeepEqual(got, want) { + t.Errorf("parsed = %v, want %v", got, want) + } + if len(ParseCookiePairs(" ;;; \n")) != 0 { + t.Error("empty input should parse to nothing") + } +} + +func TestStatusReportsEveryRequestedSite(t *testing.T) { + cfg, err := config.Load(examplePath) + if err != nil { + t.Fatalf("load config: %v", err) + } + solver := New(cfg.Turnstile, httpx.DefaultUserAgent) + st := solver.Status([]string{"de", "es", "fr"}) + if len(st) != 3 { + t.Fatalf("len(status) = %d", len(st)) + } + for _, s := range st { + if s.Valid { + t.Errorf("site %s should not be valid before any solve", s.Site) + } + if s.CookieNum != 0 { + t.Errorf("site %s cookie_count = %d", s.Site, s.CookieNum) + } + } +} + +func TestHistoryIsCappedAndRecorded(t *testing.T) { + cfg, err := config.Load(examplePath) + if err != nil { + t.Fatalf("load config: %v", err) + } + solver := New(cfg.Turnstile, httpx.DefaultUserAgent) + for i := 0; i < 250; i++ { + solver.record("de", "direct", time.Now().Add(-time.Second), nil) + } + h := solver.History() + if len(h) == 0 { + t.Fatal("history is empty") + } + if len(h) > 100 { + t.Errorf("history len = %d, want <= 100", len(h)) + } + if !h[len(h)-1].OK { + t.Error("last record should be OK") + } +} diff --git a/internal/upstream/client.go b/internal/upstream/client.go new file mode 100644 index 0000000..3bc0fe8 --- /dev/null +++ b/internal/upstream/client.go @@ -0,0 +1,868 @@ +package upstream + +import ( + "context" + "crypto/rand" + "encoding/hex" + "encoding/json" + "fmt" + "html" + "log/slog" + "regexp" + "strings" + "sync" + "time" + + "github.com/nyoungo/dsfree2api/internal/config" + "github.com/nyoungo/dsfree2api/internal/httpx" + "github.com/nyoungo/dsfree2api/internal/turnstile" +) + +type Route struct { + Name string + Proxy string // "" means direct +} + +func (r Route) Key() string { + if r.Proxy == "" { + return "direct" + } + return r.Proxy +} + +type chatConfig struct { + BotID int + PostID int + Nonce string + AJAXURL string + FetchedAt time.Time +} + +type Client struct { + cfg *config.Config + ts *turnstile.Solver + log *slog.Logger + + stateMu sync.Mutex + chatCfg map[string]chatConfig + cfgLocks map[string]*sync.Mutex + gates map[string]chan struct{} + + // chatOnceOverride is a test seam; nil in production. + chatOnceOverride func(ctx context.Context, site config.Site, modelID string, model config.Model, + prompt string, route Route, info *ServeInfo, yield func(Event) error) error +} + +func New(cfg *config.Config, ts *turnstile.Solver, log *slog.Logger) *Client { + if log == nil { + log = slog.Default() + } + return &Client{ + cfg: cfg, + ts: ts, + log: log, + chatCfg: map[string]chatConfig{}, + cfgLocks: map[string]*sync.Mutex{}, + gates: map[string]chan struct{}{}, + } +} + +// Routes returns the primary proxy followed by the fallbacks, deduplicated. +func (c *Client) Routes() []Route { + c.cfg.RLock() + primary := strings.TrimSpace(c.cfg.Proxy.URL) + fallbacks := append([]string(nil), c.cfg.Proxy.FallbackURLs...) + c.cfg.RUnlock() + + type cand struct { + name string + proxy string + } + list := []cand{{"primary", primary}} + for i, u := range fallbacks { + list = append(list, cand{fmt.Sprintf("fallback-%d", i+1), strings.TrimSpace(u)}) + } + seen := map[string]bool{} + out := make([]Route, 0, len(list)) + for _, it := range list { + key := it.proxy + if key == "" { + key = "direct" + } + if seen[key] { + continue + } + seen[key] = true + out = append(out, Route{Name: it.name, Proxy: it.proxy}) + } + return out +} + +// Chat streams the prompt through the upstream site, honouring route +// failover, slow-start detection, session refresh and cross-site failover. +func (c *Client) Chat(ctx context.Context, modelID string, prompt string, info *ServeInfo, yield func(Event) error) error { + model, ok := c.modelCopy(modelID) + if !ok { + return errf("unknown model %s", modelID) + } + if !model.Enabled { + return errf("model %s is disabled", modelID) + } + + candidates := []string{modelID} + if c.cfg.Upstream.CrossSiteFailover { + for _, alt := range c.alternates(modelID, model) { + candidates = append(candidates, alt) + } + } + + var last error + for i, id := range candidates { + m := model + if i > 0 { + mc, ok := c.modelCopy(id) + if !ok || !mc.Enabled { + continue + } + m = mc + c.log.Warn("cross-site failover", "from", modelID, "to", id) + } + err := c.chatWithRetries(ctx, id, m, prompt, info, yield) + if err == nil { + return nil + } + last = err + if contentStarted(err) || !isQuota(err) { + return AsUpstream(err) + } + } + return AsUpstream(last) +} + +// alternates lists other enabled sites hosting the same upstream model. +func (c *Client) alternates(modelID string, m config.Model) []string { + c.cfg.RLock() + defer c.cfg.RUnlock() + var out []string + for id, other := range c.cfg.Models { + if id == modelID || other.Site == m.Site { + continue + } + if other.UpstreamID != m.UpstreamID || !other.Enabled { + continue + } + if s, ok := c.cfg.Sites[other.Site]; !ok || !s.Enabled { + continue + } + out = append(out, id) + } + return out +} + +func (c *Client) modelCopy(id string) (config.Model, bool) { + c.cfg.RLock() + defer c.cfg.RUnlock() + m, ok := c.cfg.Models[id] + if !ok { + return config.Model{}, false + } + return *m, true +} + +func (c *Client) siteCopy(code string) (config.Site, bool) { + c.cfg.RLock() + defer c.cfg.RUnlock() + s, ok := c.cfg.Sites[code] + if !ok { + return config.Site{}, false + } + return *s, true +} + +func (c *Client) userAgent() string { + c.cfg.RLock() + defer c.cfg.RUnlock() + return c.cfg.Upstream.UserAgent +} + +func (c *Client) chatWithRetries(ctx context.Context, modelID string, model config.Model, prompt string, info *ServeInfo, yield func(Event) error) error { + site, ok := c.siteCopy(model.Site) + if !ok { + return errf("unknown site %s", model.Site) + } + if !site.Enabled { + return errf("site %s is disabled", model.Site) + } + routes := c.Routes() + + var lastErr error + for attempt := 0; ; attempt++ { + var failed []Route + for i, route := range routes { + allowSlow := i == 0 && len(routes) > 1 + err := c.chatOnceSlowStart(ctx, site, modelID, model, prompt, route, allowSlow, info, yield) + if err == nil { + return nil + } + lastErr = err + if contentStarted(err) { + return AsUpstream(err) + } + // A solver-side rejection (bad key, rejected challenge) will fail + // identically on every attempt — stop instead of burning retries. + if !turnstile.Retryable(err) { + return AsUpstream(err) + } + // The site demands Turnstile verification while the solver is + // switched off: refreshing cookies cannot help, say why instead. + if isSession(err) && (c.ts == nil || !c.ts.Enabled()) { + return &UpstreamError{ + Msg: "site requires Turnstile verification — import cookies on the console " + + "Turnstile page, or enable [turnstile] to solve automatically", + Cause: err, + } + } + if isConfigProblem(err) { + return AsUpstream(err) + } + failed = append(failed, route) + c.log.Warn("upstream error", + "model", modelID, "attempt", attempt+1, "route", route.Name, "error", err) + if i < len(routes)-1 { + continue + } + break + } + + c.cfg.RLock() + autoRefresh := c.cfg.Upstream.AutoRefresh + backoff := c.cfg.Upstream.RetryBackoffSeconds + retries := c.cfg.Upstream.RefreshRetries + c.cfg.RUnlock() + if !autoRefresh || attempt >= retries { + break + } + if len(failed) > 0 { + wait := time.Duration(float64(attempt+1) * backoff * float64(time.Second)) + select { + case <-ctx.Done(): + return AsUpstream(ctx.Err()) + case <-time.After(wait): + } + forceCookie := isQuota(lastErr) || isSession(lastErr) + for _, route := range failed { + if err := c.refreshSession(ctx, site, modelID, route, forceCookie); err != nil { + c.log.Warn("session refresh failed", "model", modelID, "error", err) + } + } + } + } + return AsUpstream(lastErr) +} + +// chatOnceSlowStart bounds the time the primary route may take to produce its +// first upstream event before a fallback route is tried instead. Errors that +// happen after the first event are marked ContentStarted so callers never +// restart a response the client already partially received. +func (c *Client) chatOnceSlowStart( + ctx context.Context, + site config.Site, + modelID string, + model config.Model, + prompt string, + route Route, + allowSlow bool, + info *ServeInfo, + yield func(Event) error, +) error { + ctx, cancel := context.WithCancel(ctx) + defer cancel() + + events := make(chan Event, 16) + errCh := make(chan error, 1) + gotFirst := false + + go func() { + fn := c.chatOnce + if c.chatOnceOverride != nil { + fn = c.chatOnceOverride + } + err := fn(ctx, site, modelID, model, prompt, route, info, func(ev Event) error { + select { + case events <- ev: + return nil + case <-ctx.Done(): + return ctx.Err() + } + }) + close(events) + errCh <- err + }() + + mark := func(err error) error { + if err == nil || !gotFirst { + return err + } + ue := AsUpstream(err) + ue.ContentStarted = true + return ue + } + take := func(ev Event) error { + gotFirst = true + if err := yield(ev); err != nil { + return mark(err) + } + return nil + } + + if allowSlow { + var slow float64 + c.cfg.RLock() + slow = c.cfg.Proxy.SlowStartSeconds + c.cfg.RUnlock() + if slow > 0 { + timer := time.NewTimer(time.Duration(slow * float64(time.Second))) + defer timer.Stop() + select { + case ev, ok := <-events: + if !ok { + return mark(<-errCh) + } + if err := take(ev); err != nil { + return err + } + case err := <-errCh: + for ev := range events { + if e := take(ev); e != nil { + return e + } + } + return mark(err) + case <-timer.C: + cancel() + <-errCh + return errf("primary route produced no upstream event within %gs", slow) + } + } + } + + for { + ev, ok := <-events + if !ok { + return mark(<-errCh) + } + if err := take(ev); err != nil { + return err + } + } +} + +func (c *Client) chatOnce( + ctx context.Context, + site config.Site, + modelID string, + model config.Model, + prompt string, + route Route, + info *ServeInfo, + yield func(Event) error, +) error { + if err := c.acquire(ctx, site.Code); err != nil { + return AsUpstream(err) + } + defer c.release(site.Code) + + if info != nil { + info.Set(modelID, site.Code, route.Name) + } + + c.cfg.RLock() + streamTimeout := time.Duration(c.cfg.Upstream.StreamTimeout * float64(time.Second)) + timeout := time.Duration(c.cfg.Upstream.Timeout * float64(time.Second)) + ua := c.cfg.Upstream.UserAgent + c.cfg.RUnlock() + + sess, err := httpx.NewSession(httpx.Options{ProxyURL: route.Proxy, Timeout: streamTimeout}) + if err != nil { + return errf("create session: %v", err) + } + defer sess.Close() + + if err := c.ts.ApplyValidCookies(ctx, sess, &site, route.Proxy); err != nil { + return AsUpstream(err) + } + + cc, err := c.chatConfig(ctx, sess, site, modelID, model, route, ua) + if err != nil { + return err + } + + referer := site.BaseURL + model.PagePath + sessionID := newUUID() + convUUID := newUUID() + clientMsgID := fmt.Sprintf("aipkit-client-msg-%d-%d-%s", cc.BotID, time.Now().UnixMilli(), randHex(3)) + + resp, err := sess.Do(ctx, httpx.Request{ + Method: "POST", + URL: cc.AJAXURL, + Header: ajaxHeaders(site, referer, ua), + Body: []byte(encodeForm(map[string]string{ + "action": "aipkit_cache_sse_message", + "message": prompt, + "_ajax_nonce": cc.Nonce, + "bot_id": itoa(cc.BotID), + "session_id": sessionID, + "conversation_uuid": convUUID, + "user_client_message_id": clientMsgID, + })), + Timeout: timeout, + }) + if err != nil { + return errf("cache message request failed: %v", err) + } + if resp.Status >= 400 { + if resp.Status == 403 || resp.Status == 401 { + return newSession(fmt.Sprintf("cache message http %d", resp.Status)) + } + return errf("cache message http %d: %s", resp.Status, truncate(string(resp.Body), 400)) + } + + var init struct { + Success bool `json:"success"` + Data json.RawMessage `json:"data"` + } + if err := json.Unmarshal(resp.Body, &init); err != nil { + return errf("cache message bad json: %v", err) + } + if !init.Success { + if code := extractCode(init.Data); code != "" && strings.Contains(strings.ToLower(code), "nonce") { + c.dropChatCfg(modelID, route) + } + payload := map[string]any{} + _ = json.Unmarshal(resp.Body, &payload) + if isSessionPayload(payload) { + return newSession("cache message session failed: " + truncate(string(resp.Body), 400)) + } + return errf("cache message failed: %s", truncate(string(resp.Body), 400)) + } + var dataObj struct { + CacheKey string `json:"cache_key"` + } + if err := json.Unmarshal(init.Data, &dataObj); err != nil || dataObj.CacheKey == "" { + return errf("cache message missing cache_key: %s", truncate(string(resp.Body), 400)) + } + + params := encodeForm(map[string]string{ + "action": "aipkit_frontend_chat_stream", + "cache_key": dataObj.CacheKey, + "bot_id": itoa(cc.BotID), + "session_id": sessionID, + "conversation_uuid": convUUID, + "post_id": itoa(cc.PostID), + "_ts": itoa64(time.Now().UnixMilli()), + "_ajax_nonce": cc.Nonce, + }) + + streamCtx, streamCancel := context.WithTimeout(ctx, streamTimeout) + defer streamCancel() + + headers := ajaxHeaders(site, referer, ua) + headers["Accept"] = "text/event-stream" + + rc, _, err := sess.Stream(streamCtx, httpx.Request{ + Method: "GET", + URL: cc.AJAXURL + "?" + params, + Header: headers, + }) + if err != nil { + if streamCtx.Err() != nil && ctx.Err() == nil { + return errf("upstream stream timeout: %v", err) + } + return AsUpstream(err) + } + defer rc.Close() + + return readSSE(rc, func(event string, lines []string) error { + events, err := translateEvent(event, lines) + if err != nil { + return err + } + for _, ev := range events { + if err := yield(ev); err != nil { + return err + } + } + return nil + }) +} + +// chatConfig fetches (and caches) the page-embedded nonce for a model. +func (c *Client) chatConfig( + ctx context.Context, + sess httpx.Session, + site config.Site, + modelID string, + model config.Model, + route Route, + ua string, +) (chatConfig, error) { + key := modelID + "|" + route.Key() + if v, ok := c.loadChatCfg(key); ok { + return v, nil + } + lock := c.lockFor(key) + lock.Lock() + defer lock.Unlock() + if v, ok := c.loadChatCfg(key); ok { + return v, nil + } + + pageURL := site.BaseURL + model.PagePath + resp, err := sess.Do(ctx, httpx.Request{ + Method: "GET", + URL: pageURL, + Header: browserHeaders(site, ua), + Timeout: 60 * time.Second, + }) + if err != nil { + return chatConfig{}, errf("chat config fetch failed: %v", err) + } + if resp.Status >= 400 { + return chatConfig{}, errf("chat config http %d", resp.Status) + } + + parsed, found := parseChatConfig(string(resp.Body), site, model) + if !found { + if model.BotID <= 0 || model.PostID <= 0 { + return chatConfig{}, errf("no chat data-config found for %s", modelID) + } + nonce, err := c.fetchNonce(ctx, sess, site, model, ua) + if err != nil { + return chatConfig{}, err + } + parsed = chatConfig{ + BotID: model.BotID, PostID: model.PostID, Nonce: nonce, + AJAXURL: site.AJAXURL, FetchedAt: time.Now(), + } + } + parsed.FetchedAt = time.Now() + c.storeChatCfg(key, parsed) + c.log.Info("loaded chat config", "model", modelID, "route", route.Name, + "bot_id", parsed.BotID, "post_id", parsed.PostID) + return parsed, nil +} + +var containerRe = regexp.MustCompile(`id="aipkit_chat_container_(\d+)"[^>]*data-config='([^']+)'`) + +func parseChatConfig(page string, site config.Site, model config.Model) (chatConfig, bool) { + for _, m := range containerRe.FindAllStringSubmatch(page, -1) { + botID := atoi(m[1]) + raw := html.UnescapeString(m[2]) + var cfg struct { + BotID any `json:"botId"` + PostID any `json:"postId"` + Nonce string `json:"nonce"` + AjaxURL string `json:"ajaxUrl"` + } + if err := json.Unmarshal([]byte(raw), &cfg); err != nil { + continue + } + if model.BotID > 0 && botID != model.BotID { + continue + } + out := chatConfig{ + BotID: toInt(cfg.BotID, botID), + PostID: toInt(cfg.PostID, model.PostID), + Nonce: cfg.Nonce, + AJAXURL: cfg.AjaxURL, + FetchedAt: time.Now(), + } + if out.AJAXURL == "" { + out.AJAXURL = site.AJAXURL + } + if out.Nonce != "" { + return out, true + } + } + return chatConfig{}, false +} + +func (c *Client) fetchNonce(ctx context.Context, sess httpx.Session, site config.Site, model config.Model, ua string) (string, error) { + resp, err := sess.Do(ctx, httpx.Request{ + Method: "POST", + URL: site.AJAXURL, + Header: ajaxHeaders(site, site.BaseURL+model.PagePath, ua), + Body: []byte(encodeForm(map[string]string{"action": "aipkit_get_frontend_chat_nonce", "bot_id": itoa(model.BotID)})), + Timeout: 30 * time.Second, + }) + if err != nil { + return "", errf("nonce fetch failed: %v", err) + } + var out struct { + Success bool `json:"success"` + Data struct { + Nonce string `json:"nonce"` + } `json:"data"` + } + if err := json.Unmarshal(resp.Body, &out); err != nil || !out.Success || out.Data.Nonce == "" { + return "", errf("nonce fetch failed for %s: %s", model.ID, truncate(string(resp.Body), 400)) + } + return out.Data.Nonce, nil +} + +// refreshSession drops cached state for a route and optionally re-solves Turnstile. +func (c *Client) refreshSession( + ctx context.Context, + site config.Site, + modelID string, + route Route, + invalidateCookies bool, +) error { + c.dropChatCfg(modelID, route) + if !invalidateCookies { + return nil + } + c.log.Info("invalidate verified cookie cache", "site", site.Code, "route", route.Name) + c.ts.Invalidate(site.Code) + + sess, err := httpx.NewSession(httpx.Options{ProxyURL: route.Proxy, Timeout: 60 * time.Second}) + if err != nil { + return err + } + defer sess.Close() + return c.ts.Refresh(ctx, sess, &site, route.Proxy) +} + +// InvalidateAll clears cached nonces and cookies (web console action). +func (c *Client) InvalidateAll() { + c.stateMu.Lock() + c.chatCfg = map[string]chatConfig{} + c.stateMu.Unlock() + c.cfg.RLock() + codes := make([]string, 0, len(c.cfg.Sites)) + for code := range c.cfg.Sites { + codes = append(codes, code) + } + c.cfg.RUnlock() + for _, code := range codes { + c.ts.Invalidate(code) + } +} + +func (c *Client) ConfigCacheSize() int { + c.stateMu.Lock() + defer c.stateMu.Unlock() + return len(c.chatCfg) +} + +// ── cache helpers ──────────────────────────────────────────────── + +func (c *Client) loadChatCfg(key string) (chatConfig, bool) { + c.stateMu.Lock() + defer c.stateMu.Unlock() + v, ok := c.chatCfg[key] + if !ok { + return chatConfig{}, false + } + c.cfg.RLock() + ttl := time.Duration(c.cfg.Upstream.ConfigTTLSeconds) * time.Second + c.cfg.RUnlock() + if ttl <= 0 || time.Since(v.FetchedAt) > ttl { + return chatConfig{}, false + } + return v, true +} + +func (c *Client) storeChatCfg(key string, v chatConfig) { + c.stateMu.Lock() + defer c.stateMu.Unlock() + c.chatCfg[key] = v +} + +func (c *Client) dropChatCfg(modelID string, route Route) { + suffix := "|" + route.Key() + c.stateMu.Lock() + defer c.stateMu.Unlock() + for k := range c.chatCfg { + if strings.HasSuffix(k, suffix) && strings.TrimSuffix(k, suffix) == modelID { + delete(c.chatCfg, k) + } + } +} + +func (c *Client) lockFor(key string) *sync.Mutex { + c.stateMu.Lock() + defer c.stateMu.Unlock() + l, ok := c.cfgLocks[key] + if !ok { + l = &sync.Mutex{} + c.cfgLocks[key] = l + } + return l +} + +// ── concurrency gate ──────────────────────────────────────────── + +func (c *Client) acquire(ctx context.Context, site string) error { + c.cfg.RLock() + max := c.cfg.Limits.MaxConcurrentPerSite + c.cfg.RUnlock() + if max <= 0 { + return nil + } + c.stateMu.Lock() + g, ok := c.gates[site] + if !ok { + g = make(chan struct{}, max) + c.gates[site] = g + } + c.stateMu.Unlock() + select { + case g <- struct{}{}: + return nil + case <-ctx.Done(): + return fmt.Errorf("site %s busy: %w", site, ctx.Err()) + } +} + +func (c *Client) release(site string) { + c.stateMu.Lock() + g, ok := c.gates[site] + c.stateMu.Unlock() + if !ok { + return + } + select { + case <-g: + default: + } +} + +// ── helpers ───────────────────────────────────────────────────── + +func browserHeaders(site config.Site, ua string) map[string]string { + if ua == "" { + ua = httpx.DefaultUserAgent + } + return map[string]string{ + "User-Agent": ua, + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8", + "Accept-Language": site.Language, + "Origin": site.BaseURL, + "Referer": site.BaseURL + "/", + } +} + +func ajaxHeaders(site config.Site, referer, ua string) map[string]string { + if ua == "" { + ua = httpx.DefaultUserAgent + } + return map[string]string{ + "User-Agent": ua, + "Accept": "application/json, text/javascript, */*; q=0.01", + "Accept-Language": site.Language, + "X-Requested-With": "XMLHttpRequest", + "Origin": site.BaseURL, + "Referer": referer, + } +} + +func encodeForm(v map[string]string) string { + keys := make([]string, 0, len(v)) + for k := range v { + keys = append(keys, k) + } + sortStrings(keys) + var b strings.Builder + for i, k := range keys { + if i > 0 { + b.WriteByte('&') + } + b.WriteString(urlEncode(k)) + b.WriteByte('=') + b.WriteString(urlEncode(v[k])) + } + return b.String() +} + +func urlEncode(s string) string { + var b strings.Builder + for i := 0; i < len(s); i++ { + ch := s[i] + switch { + case ch >= 'a' && ch <= 'z', ch >= 'A' && ch <= 'Z', ch >= '0' && ch <= '9', + ch == '-', ch == '_', ch == '.', ch == '~': + b.WriteByte(ch) + case ch == ' ': + b.WriteByte('+') + default: + fmt.Fprintf(&b, "%%%02X", ch) + } + } + return b.String() +} + +func sortStrings(s []string) { + for i := 1; i < len(s); i++ { + for j := i; j > 0 && s[j] < s[j-1]; j-- { + s[j], s[j-1] = s[j-1], s[j] + } + } +} + +func newUUID() string { return randHex(16) } + +func randHex(n int) string { + b := make([]byte, n) + if _, err := rand.Read(b); err != nil { + return strings.Repeat("0", n*2) + } + return hex.EncodeToString(b) +} + +func itoa(v int) string { return fmt.Sprintf("%d", v) } +func itoa64(v int64) string { return fmt.Sprintf("%d", v) } + +func truncate(s string, n int) string { + if len(s) <= n { + return s + } + return s[:n] + "..." +} + +func atoi(s string) int { + n := 0 + for _, ch := range s { + if ch < '0' || ch > '9' { + return n + } + n = n*10 + int(ch-'0') + } + return n +} + +func extractCode(data json.RawMessage) string { + var obj struct { + Code string `json:"code"` + } + if err := json.Unmarshal(data, &obj); err == nil { + return obj.Code + } + return "" +} + +func toInt(v any, def int) int { + switch t := v.(type) { + case float64: + return int(t) + case int: + return t + case string: + return atoi(t) + case json.Number: + if n, err := t.Int64(); err == nil { + return int(n) + } + } + return def +} diff --git a/internal/upstream/client_test.go b/internal/upstream/client_test.go new file mode 100644 index 0000000..e1f7d88 --- /dev/null +++ b/internal/upstream/client_test.go @@ -0,0 +1,270 @@ +package upstream + +import ( + "context" + "encoding/json" + "errors" + "reflect" + "strings" + "testing" + "time" + + "github.com/nyoungo/dsfree2api/internal/config" +) + +const examplePath = "../../config.example.toml" + +func testConfig(t *testing.T) *config.Config { + t.Helper() + cfg, err := config.Load(examplePath) + if err != nil { + t.Fatalf("load config: %v", err) + } + cfg.Upstream.AutoRefresh = false + cfg.Upstream.RefreshRetries = 0 + return cfg +} + +func TestRoutesKeepPrimaryFirstAndDedupe(t *testing.T) { + cfg := testConfig(t) + cfg.Proxy.URL = "" + cfg.Proxy.FallbackURLs = []string{"socks5://fallback:1", "socks5://fallback:1"} + + c := New(cfg, nil, nil) + got := c.Routes() + want := []Route{{Name: "primary", Proxy: ""}, {Name: "fallback-1", Proxy: "socks5://fallback:1"}} + if !reflect.DeepEqual(got, want) { + t.Fatalf("routes = %+v, want %+v", got, want) + } +} + +func TestSlowPrimarySwitchesToFallbackBeforeContent(t *testing.T) { + cfg := testConfig(t) + cfg.Proxy.URL = "" + cfg.Proxy.FallbackURLs = []string{"socks5://fallback:1"} + cfg.Proxy.SlowStartSeconds = 0.02 + + var calls []string + c := New(cfg, nil, nil) + c.chatOnceOverride = func(ctx context.Context, site config.Site, modelID string, model config.Model, + prompt string, route Route, info *ServeInfo, yield func(Event) error) error { + calls = append(calls, route.Name) + if route.Name == "primary" { + select { + case <-time.After(5 * time.Second): + case <-ctx.Done(): + return ctx.Err() + } + yield(Event{Kind: KindDelta, Value: "primary"}) + return nil + } + yield(Event{Kind: KindDelta, Value: "fallback"}) + return nil + } + + var got []string + if err := c.Chat(context.Background(), "deepseek-v4-flash-de", "hello", &ServeInfo{}, + func(ev Event) error { + got = append(got, ev.Value) + return nil + }); err != nil { + t.Fatalf("chat: %v", err) + } + if want := []string{"primary", "fallback-1"}; !reflect.DeepEqual(calls, want) { + t.Errorf("calls = %v, want %v", calls, want) + } + if want := []string{"fallback"}; !reflect.DeepEqual(got, want) { + t.Errorf("events = %v, want %v", got, want) + } +} + +func TestPrimaryErrorSwitchesToFallbackBeforeContent(t *testing.T) { + cfg := testConfig(t) + cfg.Proxy.URL = "" + cfg.Proxy.FallbackURLs = []string{"socks5://fallback:1"} + + var calls []string + c := New(cfg, nil, nil) + c.chatOnceOverride = func(ctx context.Context, site config.Site, modelID string, model config.Model, + prompt string, route Route, info *ServeInfo, yield func(Event) error) error { + calls = append(calls, route.Name) + if route.Name == "primary" { + return errors.New("primary failed") + } + yield(Event{Kind: KindDelta, Value: "fallback"}) + return nil + } + + var got []string + if err := c.Chat(context.Background(), "deepseek-v4-flash-de", "hello", &ServeInfo{}, + func(ev Event) error { + got = append(got, ev.Value) + return nil + }); err != nil { + t.Fatalf("chat: %v", err) + } + if want := []string{"primary", "fallback-1"}; !reflect.DeepEqual(calls, want) { + t.Errorf("calls = %v, want %v", calls, want) + } + if want := []string{"fallback"}; !reflect.DeepEqual(got, want) { + t.Errorf("events = %v, want %v", got, want) + } +} + +func TestNoFallbackAfterContentStarted(t *testing.T) { + cfg := testConfig(t) + cfg.Proxy.URL = "" + cfg.Proxy.FallbackURLs = []string{"socks5://fallback:1"} + + var calls []string + c := New(cfg, nil, nil) + c.chatOnceOverride = func(ctx context.Context, site config.Site, modelID string, model config.Model, + prompt string, route Route, info *ServeInfo, yield func(Event) error) error { + calls = append(calls, route.Name) + yield(Event{Kind: KindDelta, Value: "partial"}) + return errors.New("stream failed") + } + + err := c.Chat(context.Background(), "deepseek-v4-flash-de", "hello", &ServeInfo{}, + func(Event) error { return nil }) + if err == nil { + t.Fatal("expected an error") + } + var ue *UpstreamError + if !errors.As(err, &ue) { + t.Fatalf("error type = %T, want *UpstreamError", err) + } + if !ue.ContentStarted { + t.Error("expected ContentStarted to be set") + } + if want := []string{"primary"}; !reflect.DeepEqual(calls, want) { + t.Errorf("calls = %v, want %v", calls, want) + } +} + +func TestQuotaNoticeTriggersQuotaError(t *testing.T) { + payload := map[string]any{ + "error": "Dein heutiges Guthaben von 10.000 Token (V4-Pro) ist aufgebraucht.", + "quota_notice": map[string]any{ + "type": "quota_notice", + "message": "Dein heutiges Guthaben von 10.000 Token (V4-Pro) ist aufgebraucht.", + }, + } + raw, _ := json.Marshal(payload) + _, err := translateEvent("error", []string{string(raw)}) + if err == nil { + t.Fatal("expected error") + } + if !isQuota(err) { + t.Fatalf("want QuotaExhaustedError, got %T: %v", err, err) + } + if isSession(err) { + t.Error("quota error must not be a session error") + } +} + +func TestSessionErrorTriggersSessionError(t *testing.T) { + raw, _ := json.Marshal(map[string]any{"error": "deepseek_ts_required"}) + _, err := translateEvent("error", []string{string(raw)}) + if err == nil { + t.Fatal("expected error") + } + if !isSession(err) { + t.Fatalf("want SessionVerificationError, got %T: %v", err, err) + } + if isQuota(err) { + t.Error("session error must not be a quota error") + } +} + +func TestPlainSSEErrorDoesNotForceRefreshPath(t *testing.T) { + raw, _ := json.Marshal(map[string]any{"error": "temporary upstream failure"}) + _, err := translateEvent("error", []string{string(raw)}) + if err == nil { + t.Fatal("expected error") + } + if isQuota(err) || isSession(err) { + t.Fatalf("plain error must not be quota/session, got %T", err) + } +} + +func TestGermanTsRequiredPayloadIsSessionError(t *testing.T) { + raw, _ := json.Marshal(map[string]any{ + "error": "Sicherheitspruefung erforderlich.", + "ts_required": true, + }) + _, err := translateEvent("error", []string{string(raw)}) + if err == nil { + t.Fatal("expected error") + } + if !isSession(err) { + t.Fatalf("want SessionVerificationError, got %T: %v", err, err) + } +} + +func TestTurnstileRequiredWhileDisabledFailsFast(t *testing.T) { + cfg := testConfig(t) + cfg.Proxy.URL = "" + cfg.Upstream.AutoRefresh = true + cfg.Upstream.RefreshRetries = 4 + + var calls int + c := New(cfg, nil, nil) + c.chatOnceOverride = func(ctx context.Context, site config.Site, modelID string, model config.Model, + prompt string, route Route, info *ServeInfo, yield func(Event) error) error { + calls++ + return newSession(`sse error: {"error":"Sicherheitspruefung erforderlich.","ts_required":true}`) + } + + err := c.Chat(context.Background(), "deepseek-v4-flash-de", "hello", &ServeInfo{}, func(Event) error { return nil }) + if err == nil { + t.Fatal("expected an error") + } + if !strings.Contains(err.Error(), "requires Turnstile verification") { + t.Errorf("error = %q, want a Turnstile hint", err.Error()) + } + if calls != 1 { + t.Errorf("upstream calls = %d, want 1 (no blind refresh retries)", calls) + } +} + +func TestNonceFetchFailureFailsFast(t *testing.T) { + cfg := testConfig(t) + cfg.Proxy.URL = "" + cfg.Upstream.AutoRefresh = true + cfg.Upstream.RefreshRetries = 4 + + var calls int + c := New(cfg, nil, nil) + c.chatOnceOverride = func(ctx context.Context, site config.Site, modelID string, model config.Model, + prompt string, route Route, info *ServeInfo, yield func(Event) error) error { + calls++ + return errf("nonce fetch failed for %s: ", modelID) + } + + err := c.Chat(context.Background(), "deepseek-v4-flash-de", "hello", &ServeInfo{}, func(Event) error { return nil }) + if err == nil { + t.Fatal("expected an error") + } + if calls != 1 { + t.Errorf("upstream calls = %d, want 1 (config problems are not retried)", calls) + } + if !isConfigProblem(err) { + t.Errorf("err = %v, want isConfigProblem", err) + } +} + +func TestSSEDeltaAndDoneEvents(t *testing.T) { + raw, _ := json.Marshal(map[string]any{"delta": "hello world"}) + evs, err := translateEvent("message", []string{string(raw)}) + if err != nil { + t.Fatalf("translate: %v", err) + } + if len(evs) != 1 || evs[0].Kind != KindDelta || evs[0].Value != "hello world" { + t.Fatalf("events = %+v", evs) + } + evs, err = translateEvent("message", []string{"[DONE]"}) + if err != nil || len(evs) != 1 || evs[0].Kind != KindDone { + t.Fatalf("done event = %+v err=%v", evs, err) + } +} diff --git a/internal/upstream/errors.go b/internal/upstream/errors.go new file mode 100644 index 0000000..a15f2c3 --- /dev/null +++ b/internal/upstream/errors.go @@ -0,0 +1,169 @@ +package upstream + +import ( + "errors" + "fmt" + "strings" +) + +// UpstreamError is any failure talking to the deepseek.* sites. +type UpstreamError struct { + Msg string + ContentStarted bool + Cause error +} + +func (e *UpstreamError) Error() string { + if e.Msg != "" { + return e.Msg + } + if e.Cause != nil { + return e.Cause.Error() + } + return "upstream failed" +} + +func (e *UpstreamError) Unwrap() error { return e.Cause } + +// QuotaExhaustedError means the site ran out of free quota for this session. +type QuotaExhaustedError struct{ UpstreamError } + +// SessionVerificationError means Turnstile/nonce verification must redone. +type SessionVerificationError struct{ UpstreamError } + +func errf(format string, a ...any) *UpstreamError { + return &UpstreamError{Msg: fmt.Sprintf(format, a...)} +} + +func newQuota(msg string) *QuotaExhaustedError { + return &QuotaExhaustedError{UpstreamError: UpstreamError{Msg: msg}} +} + +func newSession(msg string) *SessionVerificationError { + return &SessionVerificationError{UpstreamError: UpstreamError{Msg: msg}} +} + +// AsUpstream unwraps any error into an *UpstreamError. +func AsUpstream(err error) *UpstreamError { + if err == nil { + return nil + } + var ue *UpstreamError + if errors.As(err, &ue) { + return ue + } + return &UpstreamError{Msg: err.Error(), Cause: err} +} + +func contentStarted(err error) bool { + var ue *UpstreamError + if errors.As(err, &ue) { + return ue.ContentStarted + } + return false +} + +func isQuota(err error) bool { + var q *QuotaExhaustedError + return errors.As(err, &q) +} + +func isSession(err error) bool { + var s *SessionVerificationError + return errors.As(err, &s) +} + +// isSessionPayload classifies upstream JSON payloads that indicate the +// Turnstile cookie / nonce has to be re-established. It deliberately matches +// only explicit markers so a randomly embedded "nonce" string in echoed +// request data cannot trigger a costly re-solve. +func isSessionPayload(payload map[string]any) bool { + var texts []string + for _, k := range []string{"error", "message", "code"} { + if v, ok := payload[k]; ok && v != nil { + texts = append(texts, stringify(v)) + } + } + if data, ok := payload["data"].(map[string]any); ok { + for _, k := range []string{"code", "message", "error"} { + if v, ok := data[k]; ok && v != nil { + texts = append(texts, stringify(v)) + } + } + } + if notice, ok := payload["quota_notice"].(map[string]any); ok { + if v, ok := notice["message"]; ok { + texts = append(texts, stringify(v)) + } + } + text := strings.ToLower(strings.Join(texts, " ")) + for _, m := range []string{ + "deepseek_ts_required", + "ts_required", + "nonce_failure", + "session_expired", + "forbidden", + "unauthorized", + "csrf", + "verification_required", + "sicherheitspruefung", + } { + if strings.Contains(text, m) { + return true + } + } + if v, ok := payload["ts_required"]; ok { + switch t := v.(type) { + case bool: + return t + case string: + return strings.TrimSpace(t) != "" && t != "0" && !strings.EqualFold(t, "false") + default: + return true + } + } + return false +} + +// isConfigProblem reports errors that retrying cannot fix: the site stopped +// serving the AIPKit chat page (it now answers with a redirect or HTML), or +// the config points at something that does not exist. +func isConfigProblem(err error) bool { + return strings.Contains(strings.ToLower(err.Error()), "nonce fetch failed") +} + +// isQuotaPayload mirrors the German/English quota notices used upstream. +func isQuotaPayload(payload map[string]any) bool { + if _, ok := payload["quota_notice"]; ok { + return true + } + var texts []string + for _, k := range []string{"error", "message"} { + if v, ok := payload[k]; ok && v != nil { + texts = append(texts, stringify(v)) + } + } + if notice, ok := payload["quota_notice"].(map[string]any); ok { + if v, ok := notice["message"]; ok { + texts = append(texts, stringify(v)) + } + } + text := strings.ToLower(strings.Join(texts, " ")) + if strings.Contains(text, "guthaben") || strings.Contains(text, "quota") { + return true + } + return strings.Contains(text, "token") && strings.Contains(text, "aufgebraucht") +} + +func stringify(v any) string { + switch t := v.(type) { + case string: + return t + case float64: + return fmt.Sprintf("%v", t) + case bool: + return fmt.Sprintf("%v", t) + default: + return fmt.Sprintf("%v", t) + } +} diff --git a/internal/upstream/serveinfo.go b/internal/upstream/serveinfo.go new file mode 100644 index 0000000..cfebd2b --- /dev/null +++ b/internal/upstream/serveinfo.go @@ -0,0 +1,32 @@ +package upstream + +import "sync" + +// ServeInfo records which model/site/proxy route actually answered a request. +// It is safe to read from the request goroutine while the producer goroutine +// is still writing. +type ServeInfo struct { + mu sync.Mutex + modelID string + site string + route string +} + +func (s *ServeInfo) Set(modelID, site, route string) { + if s == nil { + return + } + s.mu.Lock() + s.modelID, s.site, s.route = modelID, site, route + s.mu.Unlock() +} + +// Get returns a snapshot of the fields recorded so far. +func (s *ServeInfo) Get() (modelID, site, route string) { + if s == nil { + return "", "", "" + } + s.mu.Lock() + defer s.mu.Unlock() + return s.modelID, s.site, s.route +} diff --git a/internal/upstream/sse.go b/internal/upstream/sse.go new file mode 100644 index 0000000..fc5aba6 --- /dev/null +++ b/internal/upstream/sse.go @@ -0,0 +1,182 @@ +package upstream + +import ( + "bufio" + "encoding/json" + "fmt" + "io" + "strings" +) + +// Event is one translated upstream SSE message. +type Event struct { + Kind string // "start" | "delta" | "done" + Value string +} + +const ( + KindStart = "start" + KindDelta = "delta" + KindDone = "done" +) + +// readSSE parses a text/event-stream body. Blank lines dispatch the pending +// event, ":" comment lines are ignored. +func readSSE(r io.Reader, handle func(event string, dataLines []string) error) error { + sc := bufio.NewScanner(r) + sc.Buffer(make([]byte, 0, 64*1024), 4*1024*1024) + + eventType := "message" + var dataLines []string + flush := func() error { + if len(dataLines) == 0 { + return nil + } + ev, dl := eventType, dataLines + eventType, dataLines = "message", nil + return handle(ev, dl) + } + + for sc.Scan() { + line := strings.TrimSpace(sc.Text()) + if line == "" { + if err := flush(); err != nil { + return err + } + continue + } + if strings.HasPrefix(line, ":") { + continue + } + if strings.HasPrefix(line, "event:") { + eventType = strings.TrimSpace(line[len("event:"):]) + continue + } + if strings.HasPrefix(line, "data:") { + dataLines = append(dataLines, strings.TrimSpace(line[len("data:"):])) + } + } + if err := sc.Err(); err != nil { + return fmt.Errorf("sse read: %w", err) + } + return flush() +} + +// translateEvent converts one upstream SSE event into zero or more events for +// the downstream client. It returns an error for upstream failure payloads. +func translateEvent(eventType string, dataLines []string) ([]Event, error) { + var payload map[string]any + for _, line := range dataLines { + if line == "[DONE]" { + return []Event{{Kind: KindDone}}, nil + } + var p map[string]any + if err := json.Unmarshal([]byte(line), &p); err == nil { + payload = p + } + } + if payload == nil { + return nil, nil + } + + switch eventType { + case "message_start": + return []Event{{Kind: KindStart, Value: scalarString(payload["message_id"])}}, nil + case "done", "complete": + return []Event{{Kind: KindDone}}, nil + } + + if eventType == "error" || hasErrorValue(payload) { + if isQuotaPayload(payload) { + return nil, newQuota("sse quota exhausted: " + shortJSON(payload)) + } + if isSessionPayload(payload) { + return nil, newSession("sse session failed: " + shortJSON(payload)) + } + return nil, errf("sse error: %s", shortJSON(payload)) + } + + delta := firstTruthy(payload, "delta", "message", "content") + if delta == nil { + return nil, nil + } + text := deltaText(delta) + if text == "" { + return nil, nil + } + return []Event{{Kind: KindDelta, Value: text}}, nil +} + +func hasErrorValue(payload map[string]any) bool { + v, ok := payload["error"] + return ok && v != nil +} + +func firstTruthy(payload map[string]any, keys ...string) any { + for _, k := range keys { + if v, ok := payload[k]; ok && v != nil && !isFalsy(v) { + return v + } + } + return nil +} + +func isFalsy(v any) bool { + switch t := v.(type) { + case nil: + return true + case string: + return t == "" + case bool: + return !t + case float64: + return t == 0 + case map[string]any: + return len(t) == 0 + case []any: + return len(t) == 0 + } + return false +} + +func deltaText(v any) string { + switch t := v.(type) { + case string: + return t + case map[string]any: + for _, k := range []string{"text", "content", "message", "delta"} { + if s, ok := t[k].(string); ok && s != "" { + return s + } + } + raw, err := json.Marshal(t) + if err != nil { + return "" + } + return string(raw) + default: + return fmt.Sprintf("%v", t) + } +} + +func scalarString(v any) string { + if v == nil { + return "" + } + if s, ok := v.(string); ok { + return s + } + return fmt.Sprintf("%v", v) +} + +func shortJSON(payload map[string]any) string { + raw, err := json.Marshal(payload) + if err != nil { + return fmt.Sprintf("%v", payload) + } + s := string(raw) + if len(s) > 400 { + return s[:400] + "..." + } + return s +}