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:
1 parent
d259d15992
commit
18b2c04006
50 files changed
+11882
-1
No files matched your search
@@ -0,0 +1,6 @@
|
||||
.git
|
||||
bin/
|
||||
data/
|
||||
*.log
|
||||
*.exe
|
||||
config.toml
|
||||
@@ -0,0 +1,7 @@
|
||||
bin/
|
||||
data/
|
||||
*.log
|
||||
*.exe
|
||||
config.toml
|
||||
config.toml.bak
|
||||
.freebuff/
|
||||
+22
@@ -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"]
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -1,2 +1,366 @@
|
||||
# dsfree2api
|
||||
deepseek.de,deepseek.es,deepseek.fr 转为api
|
||||
|
||||
[](https://go.dev)
|
||||
[](./LICENSE)
|
||||
[](./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 |
|
||||
|--------|-----------|
|
||||
|  |  |
|
||||
| **对话调试** | **实时日志** |
|
||||
|  |  |
|
||||
|
||||
---
|
||||
|
||||
## 模型映射
|
||||
|
||||
共 **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 官方无关。使用者需自行承担因使用本项目产生的一切责任与后果。
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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 |
@@ -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
|
||||
)
|
||||
@@ -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=
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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) }
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,777 @@
|
||||
/* dsfree2api web console */
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
const $ = (s, r) => (r || document).querySelector(s);
|
||||
const $$ = (s, r) => Array.from((r || document).querySelectorAll(s));
|
||||
const view = $("#view");
|
||||
const TITLES = {
|
||||
dashboard: "仪表盘", models: "模型与站点", keys: "API Key", proxy: "代理线路",
|
||||
turnstile: "Turnstile", playground: "对话调试", logs: "实时日志", settings: "设置"
|
||||
};
|
||||
|
||||
let overviewTimer = null;
|
||||
let logStop = null;
|
||||
let lastOverview = null;
|
||||
|
||||
/* ── helpers ─────────────────────────────────────────────── */
|
||||
function toast(msg, kind) {
|
||||
const t = $("#toast");
|
||||
t.textContent = msg;
|
||||
t.className = "toast " + (kind || "");
|
||||
clearTimeout(t._h);
|
||||
t._h = setTimeout(() => t.classList.add("hidden"), 3200);
|
||||
}
|
||||
|
||||
async function api(path, opts) {
|
||||
opts = opts || {};
|
||||
const init = { method: opts.method || (opts.body !== undefined ? "POST" : "GET"), headers: {} };
|
||||
if (opts.signal) init.signal = opts.signal;
|
||||
if (opts.body !== undefined) {
|
||||
init.headers["Content-Type"] = "application/json";
|
||||
init.body = typeof opts.body === "string" ? opts.body : JSON.stringify(opts.body);
|
||||
}
|
||||
const res = await fetch(path, init);
|
||||
let data = null;
|
||||
try { data = await res.json(); } catch (e) { data = null; }
|
||||
if (res.status === 401 && !path.endsWith("/login")) {
|
||||
showLogin();
|
||||
throw new Error("unauthorized");
|
||||
}
|
||||
if (!res.ok) {
|
||||
const msg = (data && (data.error || data.detail)) || res.statusText;
|
||||
throw new Error(typeof msg === "string" ? msg : JSON.stringify(msg));
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
function esc(s) {
|
||||
return String(s == null ? "" : s).replace(/[&<>"']/g, c =>
|
||||
({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]));
|
||||
}
|
||||
const fmtInt = n => (n || 0).toLocaleString("en-US");
|
||||
const fmtPct = (a, b) => b ? (a * 100 / b).toFixed(1) + "%" : "0%";
|
||||
const fmtMs = ms => ms >= 1000 ? (ms / 1000).toFixed(2) + "s" : Math.round(ms) + "ms";
|
||||
const fmtBytes = n => n > 1048576 ? (n / 1048576).toFixed(1) + "MB" : n > 1024 ? (n / 1024).toFixed(1) + "KB" : n + "B";
|
||||
const timeStr = t => new Date(t).toLocaleTimeString("zh-CN", { hour12: false });
|
||||
|
||||
function switchHTML(id, checked) {
|
||||
return `<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 <key></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();
|
||||
})();
|
||||
})();
|
||||
@@ -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>
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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) }
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user