initial import

- Go service bridging deepseek.de/es/fr V4 models behind an OpenAI-compatible API
- /v1/chat/completions, /v1/responses, /v1/messages (Anthropic) with tool calling
- web admin console: dashboard, models/sites, keys, proxies, Turnstile, playground, live logs
- Turnstile solvers: solver API / local browser via CDP / manual cookie import
- MIT license, README with screenshots, Dockerfile and Makefile
This commit is contained in:
nyoungo committed 2026-10-07 15:46:45 +08:00
1 parent d259d15992
commit 18b2c04006
50 files changed
+11882 -1

No files matched your search

+6
View File
@@ -0,0 +1,6 @@
.git
bin/
data/
*.log
*.exe
config.toml
+7
View File
@@ -0,0 +1,7 @@
bin/
data/
*.log
*.exe
config.toml
config.toml.bak
.freebuff/
+22
View File
@@ -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"]
+21
View File
@@ -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.
+43
View File
@@ -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
+365 -1
View File
@@ -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 <token>`
- 生产环境请将管理台置于反向代理后(默认只绑定 `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 官方无关。使用者需自行承担因使用本项目产生的一切责任与后果。
+159
View File
@@ -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
}
}
+137
View File
@@ -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
+23
View File
@@ -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
Binary file not shown.

After

Width:  |  Height:  |  Size: 268 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 216 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 176 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 266 KiB

+26
View File
@@ -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
)
+52
View File
@@ -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=
+596
View File
@@ -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)
}
+101
View File
@@ -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) }
+278
View File
@@ -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
}
+777
View File
@@ -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 =>
({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[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 `<label class="switch"><input type="checkbox" id="${id}" ${checked ? "checked" : ""}><span class="slider"></span></label>`;
}
/* ── 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 = '<div class="empty">加载中…</div>';
const fn = PAGES[page] || PAGES.dashboard;
fn().catch(e => { view.innerHTML = `<div class="empty">加载失败:${esc(e.message)}</div>`; });
}
/* ── 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 = `
<span class="badge ok">运行 ${Math.floor(ov.system.uptime_s / 3600)}h${Math.floor(ov.system.uptime_s % 3600 / 60)}m</span>
<span class="badge">${esc(ov.system.go)} · ${esc(ov.system.os)}/${esc(ov.system.arch)}</span>
<span class="badge ${st ? "ok" : "warn"}">Turnstile ${st}/${total}</span>
<span class="badge">在线 ${fmtInt(ov.metrics.in_flight)}</span>`;
}
/* ── pages ───────────────────────────────────────────────── */
const PAGES = {};
PAGES.dashboard = async () => {
view.innerHTML = `
<div class="grid g4">
<div class="card"><div class="stat-label">总请求</div><div class="stat-value" id="s-req">–</div><div class="stat-sub" id="s-rps"></div></div>
<div class="card"><div class="stat-label">错误 / 空响应</div><div class="stat-value" id="s-err">–</div><div class="stat-sub" id="s-errpct"></div></div>
<div class="card"><div class="stat-label">Token(估算)</div><div class="stat-value" id="s-tok">–</div><div class="stat-sub" id="s-toksub"></div></div>
<div class="card"><div class="stat-label">延迟 P50 / P95</div><div class="stat-value" id="s-lat">–</div><div class="stat-sub" id="s-latsub"></div></div>
</div>
<div class="grid g2">
<div class="card"><h3>模型用量</h3><div class="table-wrap"><table>
<thead><tr><th>模型</th><th class="num">请求</th><th class="num">错误</th><th class="num">Token</th><th class="num">平均耗时</th></tr></thead>
<tbody id="tb-models"><tr><td colspan="5" class="empty">暂无数据</td></tr></tbody>
</table></div></div>
<div class="card"><h3>站点状态</h3><div id="site-cards" class="grid" style="gap:10px"></div></div>
</div>
<div class="card"><div class="toolbar" style="margin-bottom:10px"><h3 style="margin:0">最近请求</h3>
<div><button class="btn small" id="btn-invalidate">刷新会话缓存</button>
<button class="btn small danger" id="btn-reset">清空统计</button></div></div>
<div class="table-wrap"><table>
<thead><tr><th>时间</th><th>模型</th><th>站点</th><th>线路</th><th>类型</th><th class="num">耗时</th><th class="num">Token</th><th>状态</th></tr></thead>
<tbody id="tb-recent"><tr><td colspan="8" class="empty">暂无请求</td></tr></tbody>
</table></div>
</div>`;
$("#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]) => `
<tr><td><span class="tag">${esc(id)}</span></td>
<td class="num">${fmtInt(v.requests)}</td>
<td class="num" style="color:${v.errors ? "var(--err)" : "inherit"}">${fmtInt(v.errors)}</td>
<td class="num">${fmtInt(v.prompt_tokens + v.completion_tokens)}</td>
<td class="num">${v.requests ? fmtMs(v.sum_ms / v.requests) : "–"}</td></tr>`).join("")
: '<tr><td colspan="5" class="empty">暂无数据</td></tr>';
$("#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 `<div class="card" style="padding:12px">
<div class="toolbar">
<div><b>${esc(s.code.toUpperCase())}</b> <span class="pill ${s.enabled ? "on" : "off"}">${s.enabled ? "启用" : "停用"}</span>
<div class="dim">${esc(s.base_url)}</div></div>
<div class="right"><button class="btn small" data-probe="${esc(s.code)}">探测</button></div>
</div>
<div class="dim" style="margin-top:8px">请求 ${fmtInt(ms ? ms.requests : 0)} · 错误 ${fmtInt(ms ? ms.errors : 0)} · Cookie ${ts.valid ? "有效 " + Math.round(ts.remaining_s / 60) + " 分钟" : "未建立"}</div>
<div class="bar"><i style="width:${pct}%"></i></div></div>`;
}).join("") || '<div class="empty">无站点</div>';
$$("[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 => `
<tr><td class="dim">${timeStr(r.at)}</td>
<td><span class="tag">${esc(r.served_by || r.model)}</span></td>
<td>${esc(r.site)}</td><td class="dim">${esc(r.route || "–")}</td>
<td>${r.stream ? "流式" : "同步"}</td>
<td class="num">${fmtMs(r.duration_ms)}</td>
<td class="num">${fmtInt((r.prompt_tokens || 0) + (r.completion_tokens || 0))}</td>
<td><span class="pill ${r.status === "ok" ? "on" : "off"}">${esc(r.status)}</span>${r.error ? ` <span class="dim">${esc(r.error.slice(0, 60))}</span>` : ""}</td></tr>`).join("")
: '<tr><td colspan="8" class="empty">暂无请求</td></tr>';
}
await refresh();
overviewTimer = setInterval(refresh, 5000);
};
PAGES.models = async () => {
const ov = await fetchOverview();
view.innerHTML = `
<div class="card"><div class="toolbar"><h3 style="margin:0">模型</h3>
<div class="dim">bot_id / post_id 修改后会自动清除该模型的 nonce 缓存</div></div>
<div class="table-wrap mt"><table>
<thead><tr><th>ID</th><th>标签</th><th>站点</th><th>上游模型</th><th>路径</th><th class="num">bot</th><th class="num">post</th><th>启用</th><th></th></tr></thead>
<tbody id="tb"></tbody></table></div></div>
<div class="card"><h3>站点</h3><div class="table-wrap"><table>
<thead><tr><th>代码</th><th>Base URL</th><th>AJAX URL</th><th>语言</th><th>启用</th></tr></thead>
<tbody id="tb-sites"></tbody></table></div></div>`;
$("#tb").innerHTML = ov.models.map(m => `
<tr data-id="${esc(m.id)}">
<td><span class="tag">${esc(m.id)}</span></td>
<td><input data-f="label" value="${esc(m.label)}" style="min-width:130px"></td>
<td>${esc(m.site)}</td><td class="dim">${esc(m.upstream_id)}</td>
<td><input data-f="page_path" value="${esc(m.page_path)}" style="width:80px"></td>
<td class="num"><input data-f="bot_id" type="number" value="${m.bot_id}" style="width:90px;text-align:right"></td>
<td class="num"><input data-f="post_id" type="number" value="${m.post_id}" style="width:90px;text-align:right"></td>
<td>${switchHTML("sw-" + m.id, m.enabled)}</td>
<td><button class="btn small primary" data-save="${esc(m.id)}">保存</button></td>
</tr>`).join("");
$("#tb-sites").innerHTML = ov.sites.map(s => `
<tr data-code="${esc(s.code)}">
<td><b>${esc(s.code.toUpperCase())}</b></td>
<td><input data-f="base_url" value="${esc(s.base_url)}" style="min-width:190px"></td>
<td><input data-f="ajax_url" value="${esc(s.ajax_url)}" style="min-width:230px"></td>
<td><input data-f="language" value="${esc(s.language)}" style="min-width:200px"></td>
<td>${switchHTML("sws-" + s.code, s.enabled)}
<div class="mt"><button class="btn small primary" data-savesite="${esc(s.code)}">保存</button></div></td>
</tr>`).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 = `
<div class="card"><div class="toolbar">
<div><h3 style="margin:0">下游 API Key</h3><div class="dim mt">留空表示不鉴权;请求时使用 <code>Authorization: Bearer &lt;key&gt;</code> 或 <code>X-Api-Key</code></div></div>
<button class="btn primary" id="btn-gen">生成新 Key</button></div>
<div class="table-wrap mt"><table>
<thead><tr><th>Key</th><th>状态</th><th></th></tr></thead><tbody id="tb"></tbody></table></div>
<div class="dim mt">修改会立即生效并写入 config.toml(自动备份为 .bak)</div></div>`;
const render = keys => {
$("#tb").innerHTML = keys.length ? keys.map(k => `
<tr><td><code>${esc(mask(k))}</code></td><td><span class="pill on">启用</span></td>
<td class="right"><button class="btn small" data-copy="${esc(k)}">复制</button>
<button class="btn small danger" data-del="${esc(k)}">删除</button></td></tr>`).join("")
: '<tr><td colspan="3" class="empty">未配置任何 Key —— 当前不鉴权</td></tr>';
$$("[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 = `
<div class="grid g2">
<div class="card"><h3>主线路</h3>
<div class="field"><label>PROXY_URL(留空 = 直连)</label><input id="px-primary" value="${esc(p.url)}" placeholder="socks5://user:pass@host:port"></div>
<div class="row">
<button class="btn primary" id="px-save">保存</button>
<button class="btn" id="px-test">连通性测试</button>
</div>
<div class="dim mt">支持 http:// · https:// · socks5://</div>
</div>
<div class="card"><h3>降级策略</h3>
<div class="row">
<div class="field"><label>slow-start(秒)</label><input id="px-slow" type="number" step="0.5" value="${p.slow_start}"></div>
<div class="field"><label>每站点并发</label><input id="px-conc" type="number" value="${p.max_concurrent}"></div>
<div class="field"><label>每 Key 请求/分钟(0=不限)</label><input id="px-rate" type="number" value="${p.rate_per_minute}"></div>
</div>
<div class="row mt">
<div class="field"><label>跨站配额切换</label>${switchHTML("px-cross", p.cross_site_failover)}</div>
<button class="btn primary right" id="px-savelim">保存</button>
</div>
<div class="dim mt">主线路在 slow-start 内无首事件,或输出内容前失败时,自动切换到备用线路。</div>
</div>
</div>
<div class="card"><div class="toolbar"><h3 style="margin:0">备用线路</h3>
<div class="row"><input id="px-fb" placeholder="socks5://..." style="width:280px"><button class="btn" id="px-add">添加</button></div></div>
<div class="table-wrap mt"><table><thead><tr><th>#</th><th>地址</th><th></th></tr></thead><tbody id="tb-fb"></tbody></table></div>
<div class="dim mt">当前生效顺序:${ov.cache.routes.map(r => `<span class="tag">${esc(r.name)} → ${esc(r.proxy)}</span>`).join(" ")}</div>
</div>`;
const renderFb = list => {
$("#tb-fb").innerHTML = list.length ? list.map((u, i) => `
<tr><td class="num">${i + 1}</td><td><code>${esc(u)}</code></td>
<td class="right"><button class="btn small danger" data-rm="${esc(u)}">移除</button></td></tr>`).join("")
: '<tr><td colspan="3" class="empty">无备用线路</td></tr>';
$$("[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 = `
<div class="grid g2">
<div class="card"><h3>求解器配置</h3>
<div class="row">
<div class="field"><label>Token 获取方式</label><select id="ts-provider">
<option value="api">方式 1:求解服务 API</option>
<option value="browser">方式 2:本地浏览器自动获取</option>
<option value="manual">方式 3:手动导入 Cookie</option>
</select></div>
<div class="field"><label>启用</label>${switchHTML("ts-en", t.enabled)}</div>
</div>
<div id="ts-api" class="ts-panel">
<div class="ts-panel-title">方式 1 · 求解服务 API</div>
<div class="field"><label>求解服务 API URL</label><input id="ts-url" value="${esc(t.api_url)}"></div>
<div class="field"><label>API Key(留空保持不变)</label><input id="ts-key" value="" placeholder="已配置,输入以覆盖"></div>
<div class="row">
<div class="field"><label>Sitekey</label><input id="ts-sitekey" value="${esc(t.sitekey)}"></div>
<div class="field"><label>Action</label><input id="ts-action" value="${esc(t.action)}"></div>
</div>
</div>
<div id="ts-browser" class="ts-panel" style="display:none">
<div class="ts-panel-title">方式 2 · 本地浏览器自动获取</div>
<div class="field"><label>浏览器路径(Chrome / Edge 可执行文件)</label>
<input id="ts-bpath" value="${esc(t.browser_path || "")}"
placeholder="C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe"></div>
<div class="row">
<div class="field"><label>无头模式</label>${switchHTML("ts-bhead", !!t.browser_headless)}</div>
<div class="field"><label>时区(可选,按代理出口对齐)</label>
<input id="ts-btz" value="${esc(t.browser_timezone || "")}" placeholder="Asia/Tokyo"></div>
</div>
<div class="row">
<div class="field"><label>Locale(可选)</label>
<input id="ts-bloc" value="${esc(t.browser_locale || "")}" placeholder="ja-JP"></div>
<div class="field"><label>用户数据目录(可选)</label>
<input id="ts-bprof" value="${esc(t.browser_user_data_dir || "")}" placeholder="留空 = 临时 profile"></div>
</div>
<div class="dim">CDP 直连浏览器,无需 Playwright / Docker;求解期间会短暂打开一个窗口(无头模式除外)。</div>
</div>
<div id="ts-manual" class="ts-panel" style="display:none">
<div class="ts-panel-title">方式 3 · 手动导入 Cookie</div>
<div class="row">
<div class="field"><label>Cookie TTL(秒)</label><input id="ts-ttl" type="number" value="${t.cookie_ttl_seconds}"></div>
<div class="field"><label>站点</label><select id="im-site">${
(ov.sites || []).map(s => `<option value="${esc(s.code)}">${esc(s.code.toUpperCase())} · ${esc(s.base_url)}</option>`).join("")
}</select></div>
</div>
<div class="field"><label>Cookie(name=value; name2=value2)</label><textarea id="im-cookies" style="min-height:96px"
placeholder="在已通过验证的浏览器里执行 document.cookie,或从开发者工具复制请求头 Cookie"></textarea></div>
<div class="dim">Cookie 与出口 IP / UA 绑定:请在同一代理线路的浏览器中获取。导入后在 TTL 内免求解,状态见右侧「已验证 Cookie」。</div>
<button class="btn primary mt" id="im-save">导入</button>
</div>
<div class="dim" id="ts-hint" style="margin-top:10px"></div>
<button class="btn primary mt" id="ts-save">保存配置</button>
</div>
<div class="card"><h3>已验证 Cookie</h3>
<div id="ts-status" class="grid" style="gap:10px"></div>
<div class="row mt">
<button class="btn" id="ts-refresh">强制刷新全部</button>
<button class="btn danger" id="ts-clear">清空 Cookie</button>
</div>
<div class="dim mt">缓存 TTL ${t.cookie_ttl_seconds}s · 求解失败重试 ${t.retries} 次 · 退避 ${t.retry_backoff_seconds}s</div>
</div>
</div>
<div class="card mt"><h3>求解历史</h3><div class="table-wrap"><table>
<thead><tr><th>时间</th><th>站点</th><th>线路</th><th class="num">耗时</th><th>结果</th></tr></thead>
<tbody id="ts-hist"></tbody></table></div></div>`;
$("#ts-status").innerHTML = (ov.turnstile.status || []).map(s => `
<div class="card" style="padding:12px"><div class="toolbar">
<div><b>${esc(s.site.toUpperCase())}</b> <span class="pill ${s.valid ? "on" : "off"}">${s.valid ? "有效" : "未建立"}</span></div>
<button class="btn small" data-rf="${esc(s.site)}">刷新</button></div>
<div class="dim mt">${s.valid ? "剩余 " + Math.round(s.remaining_s / 60) + " 分钟 · " + s.cookie_count + " 个 Cookie" : "下次请求时自动求解"}</div>
</div>`).join("");
const hist = ov.turnstile.history || [];
$("#ts-hist").innerHTML = hist.length ? hist.slice().reverse().slice(0, 40).map(h => `
<tr><td class="dim">${timeStr(h.at)}</td><td>${esc(h.site)}</td><td class="dim">${esc(h.proxy)}</td>
<td class="num">${fmtMs(h.elapsed_ms)}</td>
<td><span class="pill ${h.ok ? "on" : "off"}">${h.ok ? "成功" : "失败"}</span>${h.error ? ` <span class="dim">${esc(h.error.slice(0, 70))}</span>` : ""}</td></tr>`).join("")
: '<tr><td colspan="5" class="empty">尚无记录</td></tr>';
$$("[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 = `
<div class="grid g2" style="align-items:start">
<div class="card"><h3>请求</h3>
<div class="row">
<div class="field"><label>模型</label><select id="pg-model">${enabled.map(m => `<option value="${esc(m.id)}">${esc(m.id)}</option>`).join("")}</select></div>
<div class="field" style="flex:0 0 auto"><label>流式</label>${switchHTML("pg-stream", true)}</div>
</div>
<div class="field"><label>System 提示</label><textarea id="pg-system" style="min-height:60px">你是一个乐于助人的助手。</textarea></div>
<div class="field"><label>Tools 定义(JSON,可留空)</label><textarea id="pg-tools" style="min-height:80px" placeholder='[{"type":"function","function":{"name":"get_weather","description":"获取天气","parameters":{"type":"object","properties":{"city":{"type":"string"}},"required":["city"]}}}]'></textarea></div>
<div class="field"><label>输入</label><textarea id="pg-input" style="min-height:70px" placeholder="输入消息…"></textarea></div>
<div class="row"><button class="btn primary" id="pg-send">发送</button>
<button class="btn danger" id="pg-cancel" disabled>取消</button>
<button class="btn" id="pg-clear">清空输出</button>
<span class="dim" id="pg-meta"></span></div>
</div>
<div class="card"><h3>响应</h3><div class="out-box" id="pg-out"><span class="dim">尚未发送请求</span></div></div>
</div>`;
let ac = null, timer = null;
const stopTimer = () => { if (timer) { clearInterval(timer); timer = null; } };
$("#pg-clear").onclick = () => { stopTimer(); ac = null; $("#pg-out").innerHTML = '<span class="dim">尚未发送请求</span>'; $("#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 = '<span class="dim">已取消</span>';
} else {
out.innerHTML = `<span style="color:var(--err)">请求失败:${esc(e.message)}</span>`;
}
}
finish();
};
};
PAGES.logs = async () => {
view.innerHTML = `
<div class="card"><div class="toolbar">
<div class="row">
<div class="field" style="margin:0"><label>最低级别</label>
<select id="lg-level" style="width:120px"><option>DEBUG</option><option selected>INFO</option><option>WARN</option><option>ERROR</option></select></div>
<button class="btn" id="lg-toggle">暂停</button>
<button class="btn danger" id="lg-clear">清屏</button>
</div>
<div class="dim" id="lg-state">实时连接中…</div>
</div>
<div class="log-box mt" id="lg-box"></div></div>`;
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 = `<span class="t">${timeStr(e.ts)}</span> <span class="lv lv-${e.level}">${e.level}</span> ${esc(e.msg)}${attrs ? ' <span class="t">' + esc(attrs) + "</span>" : ""}`;
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 = `
<div class="grid g2">
<div class="card"><h3>服务</h3>
<div class="row">
<div class="field"><label>监听地址</label><input id="st-host" value="${esc(cfg.server.host)}"></div>
<div class="field"><label>端口</label><input id="st-port" type="number" value="${cfg.server.port}"></div>
</div>
<div class="field"><label>日志级别</label><select id="st-log">
${["DEBUG", "INFO", "WARN", "ERROR"].map(l => `<option ${cfg.server.log_level === l ? "selected" : ""}>${l}</option>`).join("")}
</select></div>
<div class="row">
<div class="field"><label>管理台地址</label><input id="st-ah" value="${esc(cfg.admin.host)}"></div>
<div class="field"><label>管理台端口</label><input id="st-ap" type="number" value="${cfg.admin.port}"></div>
</div>
<div class="field"><label>管理台密码(留空保持不变)</label><input id="st-pw" type="password" placeholder="••••••••"></div>
<button class="btn primary" id="st-save">保存服务设置</button>
</div>
<div class="card"><h3>上游</h3>
<div class="row">
<div class="field"><label>请求超时(秒)</label><input id="st-to" type="number" value="${cfg.upstream.timeout}"></div>
<div class="field"><label>流式超时(秒)</label><input id="st-sto" type="number" value="${cfg.upstream.stream_timeout}"></div>
</div>
<div class="row">
<div class="field"><label>配置缓存 TTL(秒)</label><input id="st-ttl" type="number" value="${cfg.upstream.config_ttl_seconds}"></div>
<div class="field"><label>会话刷新重试</label><input id="st-retry" type="number" value="${cfg.upstream.refresh_retries}"></div>
</div>
<div class="row">
<div class="field"><label>失败退避(秒)</label><input id="st-back" type="number" step="0.1" value="${cfg.upstream.retry_backoff_seconds}"></div>
<div class="field"><label>自动刷新会话</label>${switchHTML("st-ar", cfg.upstream.auto_refresh)}</div>
</div>
<div class="field"><label>User-Agent</label><input id="st-ua" value="${esc(cfg.upstream.user_agent)}"></div>
<div class="field"><label>数据目录</label><input id="st-dir" value="${esc(cfg.runtime.data_dir)}"></div>
<button class="btn primary" id="st-save2">保存上游设置</button>
</div>
</div>
<div class="card"><h3>危险操作</h3>
<div class="row">
<button class="btn danger" id="st-reset">清空统计</button>
<button class="btn danger" id="st-inv">作废全部会话缓存</button>
</div>
<div class="dim mt">配置保存会自动备份原文件为 <code>config.toml.bak</code>;修改端口/密码需重启服务生效。</div>
</div>`;
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();
})();
})();
+58
View File
@@ -0,0 +1,58 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>dsfree2api 控制台</title>
<link rel="stylesheet" href="/style.css">
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>⚡</text></svg>">
</head>
<body>
<div id="login" class="overlay hidden">
<div class="login-card">
<div class="login-brand">dsfree2api</div>
<div class="login-sub">管理控制台</div>
<input id="login-password" type="password" placeholder="管理员密码" autocomplete="current-password">
<button id="login-btn" class="btn primary block">登录</button>
<div id="login-error" class="login-error"></div>
<div class="login-hint">密码来自 <code>admin.password</code> 或 <code>ADMIN_PASSWORD</code>;未配置时启动日志会打印一次性密码。</div>
</div>
</div>
<div class="app">
<aside class="sidebar">
<div class="brand">
<span class="brand-mark">⚡</span>
<span>dsfree2api</span>
</div>
<nav id="nav">
<a href="#/dashboard" data-page="dashboard">仪表盘</a>
<a href="#/models" data-page="models">模型与站点</a>
<a href="#/keys" data-page="keys">API Key</a>
<a href="#/proxy" data-page="proxy">代理线路</a>
<a href="#/turnstile" data-page="turnstile">Turnstile</a>
<a href="#/playground" data-page="playground">对话调试</a>
<a href="#/logs" data-page="logs">实时日志</a>
<a href="#/settings" data-page="settings">设置</a>
</nav>
<div class="sidebar-foot">
<div id="sys-version">v—</div>
<button id="logout-btn" class="btn ghost small">退出登录</button>
</div>
</aside>
<main>
<header class="topbar">
<div id="page-title" class="page-title">仪表盘</div>
<div id="sys-badges" class="badges"></div>
</header>
<section id="view" class="view"></section>
</main>
</div>
<div id="toast" class="toast hidden"></div>
<script src="/app.js"></script>
</body>
</html>
+125
View File
@@ -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}
}
+213
View File
@@ -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) }
+157
View File
@@ -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)
}
}
+105
View File
@@ -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"`
}
+236
View File
@@ -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)
}
}
+413
View File
@@ -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)
}
+289
View File
@@ -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")
}
+39
View File
@@ -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
}
+326
View File
@@ -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,
})
}
+240
View File
@@ -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
}
}
}
}
+525
View File
@@ -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
}
+236
View File
@@ -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)
}
}
}
+259
View File
@@ -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
}
+238
View File
@@ -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)
}
+399
View File
@@ -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
}
+292
View File
@@ -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]
}
}
}
+185
View File
@@ -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)
}
}
+117
View File
@@ -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)
}
+210
View File
@@ -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"`
}
File diff suppressed because it is too large. Load diff
+898
View File
@@ -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)
}
}
+230
View File
@@ -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
}
+139
View File
@@ -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)
}
+527
View File
@@ -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
}
+194
View File
@@ -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")
}
}
+868
View File
@@ -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
}
+270
View File
@@ -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: <!DOCTYPE html>", 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)
}
}
+169
View File
@@ -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)
}
}
+32
View File
@@ -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
}
+182
View File
@@ -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
}