Initial zhanlu OpenAI proxy
build / build (push) Successful in 53s

This commit is contained in:
M1saka
2026-07-08 09:13:45 +08:00
commit d4dd3a0f1b
14 changed files with 1673 additions and 0 deletions
+89
View File
@@ -0,0 +1,89 @@
name: build
on:
push:
branches:
- "**"
tags:
- "v*"
jobs:
build:
runs-on: debian-arm64
steps:
- name: Install toolchain (git, curl, tar, go)
run: |
set -e
export DEBIAN_FRONTEND=noninteractive
if ! command -v git >/dev/null || ! command -v curl >/dev/null || ! command -v tar >/dev/null; then
apt-get update
apt-get install -y --no-install-recommends git curl ca-certificates tar
fi
GO_VERSION=1.22.12
case "$(uname -m)" in
x86_64) HOST_ARCH=amd64 ;;
aarch64) HOST_ARCH=arm64 ;;
*) echo "unsupported host arch: $(uname -m)" >&2; exit 1 ;;
esac
curl -fsSL "https://go.dev/dl/go${GO_VERSION}.linux-${HOST_ARCH}.tar.gz" -o /tmp/go.tar.gz
rm -rf /usr/local/go
tar -C /usr/local -xzf /tmp/go.tar.gz
/usr/local/go/bin/go version
- name: Clone repository
run: |
set -e
git init -q .
git remote add origin "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git" 2>/dev/null || \
git remote set-url origin "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git"
git -c http.extraHeader="Authorization: basic $(printf 'x-access-token:%s' "${GITHUB_TOKEN}" | base64 -w0)" \
fetch --depth 1 origin "${GITHUB_SHA}"
git checkout -q FETCH_HEAD
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Build binaries
run: |
set -e
export PATH="/usr/local/go/bin:${PATH}"
if [ "${GITHUB_REF_TYPE}" = "tag" ]; then
VERSION="${GITHUB_REF_NAME}"
else
VERSION="${GITHUB_REF_NAME}-$(echo "${GITHUB_SHA}" | cut -c1-7)"
fi
echo "version: ${VERSION}"
LDFLAGS="-s -w"
for arch in amd64 arm64; do
echo "building linux/${arch}"
CGO_ENABLED=0 GOOS=linux GOARCH="${arch}" \
go build -trimpath -ldflags "${LDFLAGS}" \
-o "zhanlu-proxy-linux-${arch}" ./cmd/zhanlu-proxy
done
ls -lh zhanlu-proxy-linux-*
- name: Publish release assets
if: startsWith(github.ref, 'refs/tags/v')
run: |
set -e
API="${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}"
TAG="${GITHUB_REF_NAME}"
AUTH="Authorization: token ${GITHUB_TOKEN}"
RELEASE_ID=$(curl -fsSL -H "${AUTH}" "${API}/releases/tags/${TAG}" | \
grep -o '"id":[0-9]*' | head -n1 | cut -d: -f2 || true)
if [ -z "${RELEASE_ID}" ]; then
RELEASE_ID=$(curl -fsSL -X POST -H "${AUTH}" -H "Content-Type: application/json" \
"${API}/releases" \
-d "{\"tag_name\":\"${TAG}\",\"name\":\"${TAG}\"}" | \
grep -o '"id":[0-9]*' | head -n1 | cut -d: -f2)
fi
echo "release id: ${RELEASE_ID}"
for arch in amd64 arm64; do
f="zhanlu-proxy-linux-${arch}"
curl -fsSL -X POST -H "${AUTH}" \
-F "attachment=@${f};filename=${f}" \
"${API}/releases/${RELEASE_ID}/assets?name=${f}"
done
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+9
View File
@@ -0,0 +1,9 @@
credentials.json
extension/
*.exe
*.log
.env
.env.*
!.env.example
tmp/
temp/
+171
View File
@@ -0,0 +1,171 @@
# Zhanlu Proxy
一个本地 Go 代理服务,用于读取湛卢插件凭据,按插件认证签名规则请求湛卢上游,并暴露 OpenAI 兼容接口。
当前实现包含:
- 登录页支持插件默认的移动云手机号验证码登录,成功后自动保存凭据到本地 JSON。
- OpenAI 兼容接口:`/v1/models``/v1/chat/completions`
- 湛卢签名逻辑:RSA `authorization`、SHA-256 query hash、HMAC-SHA1 `Signature`
- 湛卢加密 SSE 响应解密并转换为 OpenAI SSE;非流式请求在本地聚合为 OpenAI Chat Completion JSON。
- OpenAI 函数/工具调用:支持 `tools``tool_choice`、流式 `delta.tool_calls`、非流式 `message.tool_calls` 以及 `role: tool` 结果续传。
## 运行
```powershell
go run ./cmd/zhanlu-proxy
```
默认监听:
```text
http://127.0.0.1:8080
```
打开登录页:
```text
http://127.0.0.1:8080/login
```
## 登录与凭据
### 移动云手机号验证码登录
打开 `/login` 后输入手机号并点击“获取验证码”。实现按插件默认登录分支工作:
- 生成 16 位一次性 `secret`
- 使用插件内置 RSA 公钥加密手机号和 `secret`
- 调用公网接口 `/api/query/acepilot-h5/manager/code/getAuthCode` 发送验证码。
- 输入验证码后调用 `/api/query/acepilot-h5/manager/code/checkCode`
- 使用本次 `secret` AES 解密响应中的 `ak``sk``license`,得到 `AccessKey``SecretKey``Token`
- 凭据会写入 JSON 文件,后续 OpenAI 兼容接口自动读取。
默认保存到当前执行目录:
```text
credentials.json
```
可以通过环境变量覆盖:
```powershell
$env:ZHANLU_CREDENTIALS_FILE="E:\path\to\credentials.json"
```
手机号验证码登录使用 `ZHANLU_SERVER_BASE_URL`,默认公网地址来自插件配置:
```powershell
$env:ZHANLU_SERVER_BASE_URL="https://api-wuxi-1.cmecloud.cn:8443"
```
四共 SSO 的 `/auth/start``/auth/callback` 仍保留为备用接口,但不是 `/login` 的默认主流程。
## OpenAI 兼容接口
### 健康检查
```powershell
curl http://127.0.0.1:8080/healthz
```
### 模型列表
```powershell
curl http://127.0.0.1:8080/v1/models
```
### Chat Completions
非流式:
```powershell
curl http://127.0.0.1:8080/v1/chat/completions `
-H "Content-Type: application/json" `
-d '{"model":"minimax-m2.5","messages":[{"role":"user","content":"hello"}],"stream":false}'
```
流式:
```powershell
curl -N http://127.0.0.1:8080/v1/chat/completions `
-H "Content-Type: application/json" `
-d '{"model":"minimax-m2.5","messages":[{"role":"user","content":"hello"}],"stream":true}'
```
### 工具调用
请求中的 `tools``tool_choice` 会传给湛卢模型。流式响应返回增量 `delta.tool_calls`;非流式响应会把分片聚合为完整的 `message.tool_calls`,并保留 `finish_reason: "tool_calls"`。执行工具后,将 assistant 的 `tool_calls``role: "tool"` 结果放回 `messages` 再发起请求即可得到最终回答。
如果设置了本地 OpenAI 兼容 API Key,需要带 `Authorization`
```powershell
$env:OPENAI_COMPAT_API_KEY="local-secret"
```
```powershell
curl http://127.0.0.1:8080/v1/models `
-H "Authorization: Bearer local-secret"
```
## 配置项
| 环境变量 | 默认值 | 说明 |
| --- | --- | --- |
| `ZHANLU_LISTEN_ADDR` | `:8080` | 本地监听地址 |
| `ZHANLU_SERVER_BASE_URL` | `https://api-wuxi-1.cmecloud.cn:8443` | 湛卢上游 Base URL |
| `ZHANLU_UPSTREAM_PATH` | `/api/acepilot/zhanlu/aiDeveloper/chat` | 湛卢聊天接口路径,按插件 `createZhanluRequest` 默认分支设置 |
| `ZHANLU_CREDENTIALS_FILE` | `credentials.json` | 凭据 JSON 路径,默认当前执行目录 |
| `ZHANLU_ACCESS_KEY` | 空 | 直接从环境变量提供 AccessKey |
| `ZHANLU_SECRET_KEY` | 空 | 直接从环境变量提供 SecretKey |
| `ZHANLU_TOKEN` | 空 | 直接从环境变量提供 Token |
| `ZHANLU_SSO_BASE_URL` | `http://rdcloud.4c.hq.cmcc` | 四共 SSO 备用页面 Base URL,非默认手机号登录流程 |
| `ZHANLU_SSO_EXCHANGE_URL` | `https://api-wuxi-1.cmecloud.cn:8443/cmdevops-aiplus-agent-gateway/api/acepilot/zhanlu/checkoutCode` | 四共 SSO code 换 token 的备用接口 |
| `ZHANLU_TOKEN_DECRYPT_KEY` | 空 | 解密四共 SSO 返回 `ak/sk/token` 的 AES key;手机号登录不需要设置 |
| `ZHANLU_PUBLIC_KEY_PEM` | 插件内置签名公钥 | 签名 URL 中 `authorization` 使用的 RSA 公钥,通常不需要设置 |
| `ZHANLU_PHONE_PUBLIC_KEY_PEM` | 插件内置手机号登录公钥 | 手机号验证码登录加密手机号和一次性 secret 使用的 RSA 公钥,通常不需要设置 |
| `ZHANLU_MODELS` | `glm47,minimax-m25` | `/v1/models` 返回的模型列表,逗号分隔 |
| `ZHANLU_DEFAULT_MODEL` | `minimax-m25` | 请求未传 `model` 时使用的默认模型 |
| `ZHANLU_UPSTREAM_TIMEOUT` | `120s` | 上游请求超时 |
| `ZHANLU_STREAM_IDLE_TIMEOUT` | `300s` | 预留的流式空闲超时配置 |
| `ZHANLU_DEBUG` | `false` | 调试模式,错误信息更详细但会脱敏敏感 query |
| `OPENAI_COMPAT_API_KEY` | 空 | 本地 OpenAI 兼容接口鉴权 key |
## 凭据优先级
服务启动时按以下优先级加载凭据:
1. 环境变量 `ZHANLU_ACCESS_KEY``ZHANLU_SECRET_KEY``ZHANLU_TOKEN`
2. `ZHANLU_CREDENTIALS_FILE` 指向的 JSON 文件。
登录页面保存后,运行中的服务会立即使用新凭据。
## 安全说明
- `credentials.json` 包含明文 `AccessKey``SecretKey``Token`,请不要提交到仓库。
- 默认保存在当前执行目录的 `credentials.json`
- 错误响应默认不会返回签名 URL,避免泄露 `AccessKey``authorization``Signature`
- `ZHANLU_DEBUG=true` 时会返回更详细错误,但仍会对敏感 query 参数脱敏。
## 已知限制
- `ZHANLU_UPSTREAM_PATH` 当前默认值是根据插件分析给出的候选路径,真实环境如果返回 404 或上游错误,需要用实际路径覆盖。
- 手机号验证码接口可能有风控或频率限制;请按正常登录频率使用。
- 湛卢上游必须使用 `stream:true`;代理对 OpenAI `stream:false` 请求负责聚合流式响应。
- UI 模型名 `glm4.7``minimax-m2.5` 会按插件逻辑映射为上游 `glm47``minimax-m25`
- `zhanlu3` 使用独立的内网 VL Gateway 和 `ZHANLU_VL_API_KEY`,当前代理未接入该特殊分支。
## 验证
```powershell
go test ./...
go build ./cmd/zhanlu-proxy
```
本地端点验证:
```powershell
go run ./cmd/zhanlu-proxy
curl http://127.0.0.1:8080/healthz
curl http://127.0.0.1:8080/v1/models
```
+23
View File
@@ -0,0 +1,23 @@
package main
import (
"log"
"net/http"
"git.misaka.ren/M1saka/zhanlu_proxy/internal/config"
"git.misaka.ren/M1saka/zhanlu_proxy/internal/server"
)
func main() {
cfg, err := config.Load()
if err != nil {
log.Fatal(err)
}
h := server.New(cfg)
log.Printf("zhanlu proxy listening on %s", cfg.ListenAddr)
log.Printf("login page: http://127.0.0.1%s/login", cfg.ListenAddr)
if err := http.ListenAndServe(cfg.ListenAddr, h); err != nil {
log.Fatal(err)
}
}
+3
View File
@@ -0,0 +1,3 @@
module git.misaka.ren/M1saka/zhanlu_proxy
go 1.22
+85
View File
@@ -0,0 +1,85 @@
package auth
import (
"crypto/aes"
"encoding/base64"
"errors"
"fmt"
)
func DecryptCredential(ciphertextBase64, key string) (string, error) {
ciphertext, err := base64.StdEncoding.DecodeString(ciphertextBase64)
if err != nil {
return "", err
}
if len(ciphertext) == 0 || len(ciphertext)%aes.BlockSize != 0 {
return "", errors.New("invalid AES ciphertext length")
}
block, err := aes.NewCipher(repeatKey(key, aes.BlockSize))
if err != nil {
return "", err
}
plain := make([]byte, len(ciphertext))
for start := 0; start < len(ciphertext); start += aes.BlockSize {
block.Decrypt(plain[start:start+aes.BlockSize], ciphertext[start:start+aes.BlockSize])
}
plain, err = unpadPKCS7(plain, aes.BlockSize)
if err != nil {
return "", err
}
return string(plain), nil
}
func EncryptCredential(plaintext, key string) (string, error) {
block, err := aes.NewCipher(repeatKey(key, aes.BlockSize))
if err != nil {
return "", err
}
plain := padPKCS7([]byte(plaintext), aes.BlockSize)
out := make([]byte, len(plain))
for start := 0; start < len(plain); start += aes.BlockSize {
block.Encrypt(out[start:start+aes.BlockSize], plain[start:start+aes.BlockSize])
}
return base64.StdEncoding.EncodeToString(out), nil
}
func repeatKey(key string, size int) []byte {
src := []byte(key)
out := make([]byte, size)
if len(src) == 0 {
return out
}
for i := range out {
out[i] = src[i%len(src)]
}
return out
}
func unpadPKCS7(in []byte, blockSize int) ([]byte, error) {
if len(in) == 0 || len(in)%blockSize != 0 {
return nil, errors.New("invalid padded data length")
}
pad := int(in[len(in)-1])
if pad == 0 || pad > blockSize || pad > len(in) {
return nil, fmt.Errorf("invalid padding size %d", pad)
}
for _, b := range in[len(in)-pad:] {
if int(b) != pad {
return nil, errors.New("invalid padding bytes")
}
}
return in[:len(in)-pad], nil
}
func padPKCS7(in []byte, blockSize int) []byte {
pad := blockSize - len(in)%blockSize
out := make([]byte, len(in)+pad)
copy(out, in)
for i := len(in); i < len(out); i++ {
out[i] = byte(pad)
}
return out
}
+58
View File
@@ -0,0 +1,58 @@
package auth
import (
"encoding/json"
"errors"
"os"
"path/filepath"
"strings"
"time"
)
type Credentials struct {
AccessKey string `json:"access_key"`
SecretKey string `json:"secret_key"`
Token string `json:"token"`
BaseURL string `json:"base_url,omitempty"`
SavedAt time.Time `json:"saved_at"`
}
func (c Credentials) Validate() error {
if strings.TrimSpace(c.AccessKey) == "" {
return errors.New("access_key is required")
}
if strings.TrimSpace(c.SecretKey) == "" {
return errors.New("secret_key is required")
}
if strings.TrimSpace(c.Token) == "" {
return errors.New("token is required")
}
return nil
}
func LoadCredentials(path string) (Credentials, error) {
b, err := os.ReadFile(path)
if err != nil {
return Credentials{}, err
}
var c Credentials
if err := json.Unmarshal(b, &c); err != nil {
return Credentials{}, err
}
return c, c.Validate()
}
func SaveCredentials(path string, c Credentials) error {
if err := c.Validate(); err != nil {
return err
}
c.SavedAt = time.Now()
b, err := json.MarshalIndent(c, "", " ")
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return err
}
return os.WriteFile(path, b, 0o600)
}
+37
View File
@@ -0,0 +1,37 @@
package auth
import (
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"encoding/base64"
"encoding/pem"
"errors"
)
func ParsePublicKey(pemText string) (*rsa.PublicKey, error) {
block, _ := pem.Decode([]byte(pemText))
if block == nil {
return nil, errors.New("invalid public key PEM")
}
pub, err := x509.ParsePKIXPublicKey(block.Bytes)
if err != nil {
return nil, err
}
rsaPub, ok := pub.(*rsa.PublicKey)
if !ok {
return nil, errors.New("public key is not RSA")
}
return rsaPub, nil
}
func EncryptAuthorization(pub *rsa.PublicKey, plaintext string) (string, error) {
if pub == nil {
return "", errors.New("public key is required")
}
out, err := rsa.EncryptPKCS1v15(rand.Reader, pub, []byte(plaintext))
if err != nil {
return "", err
}
return base64.StdEncoding.EncodeToString(out), nil
}
+90
View File
@@ -0,0 +1,90 @@
package auth
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"
"time"
)
type ExchangeResponse struct {
ErrorCode string `json:"errorCode"`
ErrorMsg string `json:"errorMsg"`
Message string `json:"message"`
Body map[string]any `json:"body"`
}
func ExchangeCode(client *http.Client, endpoint string, code string, decryptKey string) (Credentials, error) {
if strings.TrimSpace(endpoint) == "" {
return Credentials{}, errors.New("exchange endpoint is required")
}
if strings.TrimSpace(code) == "" {
return Credentials{}, errors.New("code is required")
}
if strings.TrimSpace(decryptKey) == "" {
return Credentials{}, errors.New("decrypt key is required")
}
if client == nil {
client = &http.Client{Timeout: 60 * time.Second}
}
body, _ := json.Marshal(map[string]string{"code": code})
req, err := http.NewRequest(http.MethodPost, endpoint, bytes.NewReader(body))
if err != nil {
return Credentials{}, err
}
req.Header.Set("Content-Type", "application/json")
resp, err := client.Do(req)
if err != nil {
return Credentials{}, err
}
defer resp.Body.Close()
var exchange ExchangeResponse
if err := json.NewDecoder(resp.Body).Decode(&exchange); err != nil {
return Credentials{}, err
}
if exchange.ErrorCode != "Success" {
msg := firstNonEmpty(exchange.ErrorMsg, exchange.Message, exchange.ErrorCode)
return Credentials{}, fmt.Errorf("exchange failed: %s", msg)
}
ak, err := decryptBodyField(exchange.Body, "ak", decryptKey)
if err != nil {
return Credentials{}, err
}
sk, err := decryptBodyField(exchange.Body, "sk", decryptKey)
if err != nil {
return Credentials{}, err
}
token, err := decryptBodyField(exchange.Body, "token", decryptKey)
if err != nil {
return Credentials{}, err
}
return Credentials{AccessKey: ak, SecretKey: sk, Token: token, SavedAt: time.Now()}, nil
}
func decryptBodyField(body map[string]any, key string, decryptKey string) (string, error) {
v, ok := body[key]
if !ok {
return "", fmt.Errorf("response body missing %s", key)
}
s, ok := v.(string)
if !ok || strings.TrimSpace(s) == "" {
return "", fmt.Errorf("response body %s is not a string", key)
}
return DecryptCredential(strings.TrimSpace(s), decryptKey)
}
func firstNonEmpty(values ...string) string {
for _, v := range values {
if strings.TrimSpace(v) != "" {
return v
}
}
return "unknown error"
}
+104
View File
@@ -0,0 +1,104 @@
package config
import (
"os"
"path/filepath"
"strings"
"time"
"git.misaka.ren/M1saka/zhanlu_proxy/internal/auth"
)
type Config struct {
ListenAddr string
ServerBaseURL string
UpstreamPath string
CredentialsPath string
SSOExchangeURL string
SSOBaseURL string
TokenDecryptKey string
PublicKeyPEM string
PhonePublicKeyPEM string
Models []string
DefaultModel string
OpenAIAPIKey string
UpstreamTimeout time.Duration
StreamIdleTimout time.Duration
Debug bool
Credentials auth.Credentials
}
func Load() (Config, error) {
cfg := Config{
ListenAddr: getenv("ZHANLU_LISTEN_ADDR", ":8080"),
ServerBaseURL: getenv("ZHANLU_SERVER_BASE_URL", "https://api-wuxi-1.cmecloud.cn:8443"),
UpstreamPath: getenv("ZHANLU_UPSTREAM_PATH", "/api/acepilot/zhanlu/aiDeveloper/chat"),
CredentialsPath: getenv("ZHANLU_CREDENTIALS_FILE", defaultCredentialsPath()),
SSOBaseURL: getenv("ZHANLU_SSO_BASE_URL", "http://rdcloud.4c.hq.cmcc"),
SSOExchangeURL: getenv("ZHANLU_SSO_EXCHANGE_URL", "https://api-wuxi-1.cmecloud.cn:8443/cmdevops-aiplus-agent-gateway/api/acepilot/zhanlu/checkoutCode"),
TokenDecryptKey: os.Getenv("ZHANLU_TOKEN_DECRYPT_KEY"),
PublicKeyPEM: getenv("ZHANLU_PUBLIC_KEY_PEM", defaultPublicKeyPEM),
PhonePublicKeyPEM: getenv("ZHANLU_PHONE_PUBLIC_KEY_PEM", defaultPhonePublicKeyPEM),
DefaultModel: getenv("ZHANLU_DEFAULT_MODEL", "minimax-m25"),
OpenAIAPIKey: os.Getenv("OPENAI_COMPAT_API_KEY"),
UpstreamTimeout: durationEnv("ZHANLU_UPSTREAM_TIMEOUT", 120*time.Second),
StreamIdleTimout: durationEnv("ZHANLU_STREAM_IDLE_TIMEOUT", 300*time.Second),
Debug: strings.EqualFold(os.Getenv("ZHANLU_DEBUG"), "true"),
}
cfg.Models = splitCSV(getenv("ZHANLU_MODELS", "glm47,minimax-m25"))
cfg.Credentials = auth.Credentials{
AccessKey: os.Getenv("ZHANLU_ACCESS_KEY"),
SecretKey: os.Getenv("ZHANLU_SECRET_KEY"),
Token: os.Getenv("ZHANLU_TOKEN"),
}
if cfg.Credentials.Validate() == nil {
return cfg, nil
}
if creds, err := auth.LoadCredentials(cfg.CredentialsPath); err == nil {
cfg.Credentials = creds
}
return cfg, nil
}
func getenv(key, fallback string) string {
if v := strings.TrimSpace(os.Getenv(key)); v != "" {
return v
}
return fallback
}
func splitCSV(s string) []string {
parts := strings.Split(s, ",")
out := make([]string, 0, len(parts))
for _, p := range parts {
p = strings.TrimSpace(p)
if p != "" {
out = append(out, p)
}
}
return out
}
func durationEnv(key string, fallback time.Duration) time.Duration {
v := strings.TrimSpace(os.Getenv(key))
if v == "" {
return fallback
}
d, err := time.ParseDuration(v)
if err != nil {
return fallback
}
return d
}
func defaultCredentialsPath() string {
return filepath.Join(".", "credentials.json")
}
const defaultPublicKeyPEM = `-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAhxudxTewPgljUHEZHkusP7m3I+zA4/RGvuUMt6TtII/m4zwUOm/Y31zHBTmkCCt8k5vj9y+AmO0TsGmHooNQuMebakdmEWdcA5h7YAHHFbF2w5LcxIXjib08vgVpA+m3R5xPbLK+vfHe2aAX36b5nHReDNncY5vAl3U4CgIEBGPqyG67vJytRWqP+sfEdw5+m192Rf4SCGyiBzRmjiVlH3zeEBjdbOrkAnzKOVz6AHBl2q7LPLJKIzxjoAyhEp5qnDjHUFo5VZUgFwUOt83A/jbGMyzmjRoxBuvKcs9tBuorZyUwIsZN6E+rtQk2YqMPj4RkDsZ7LRmj6on8sN2rHQIDAQAB
-----END PUBLIC KEY-----`
const defaultPhonePublicKeyPEM = `-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAnqiA2qP9BNvKw5DnVnrBVBhd+5gJDVn3mDemCfq/AN1cdaHV57hQo6R1ufp45mOkSwLaJcTE82zFKmgKoEAKwD1SR10rp0xJC7x3yvx2FbpEsiW9TeZlvJdri1BYKUMS8OP8ykjHSJoy0oMaV6e95R2rsu4DEH7JuA9+Bt0sOoLewvHx/fs1e28tH+928uUEKdLug+cv/XTKjLudpLjiSMPZU6EHFqrUhA9zmEasOMmg9Dj0j4sChBooCeCGnh/pYHJaosH5amhlSQ8FnEG0BQBrQbZ+qhRH4LYyqGYN8grDNeSnPj7vPDcwiEm++85i5AngZfEMnGWZg5jYDhO9+QIDAQAB
-----END PUBLIC KEY-----`
+65
View File
@@ -0,0 +1,65 @@
package openai
import "encoding/json"
type ChatCompletionRequest struct {
Model string `json:"model"`
Messages []map[string]any `json:"messages"`
Stream bool `json:"stream,omitempty"`
Extra map[string]json.RawMessage `json:"-"`
}
func (r *ChatCompletionRequest) UnmarshalJSON(data []byte) error {
type alias ChatCompletionRequest
var a alias
if err := json.Unmarshal(data, &a); err != nil {
return err
}
var raw map[string]json.RawMessage
if err := json.Unmarshal(data, &raw); err != nil {
return err
}
delete(raw, "model")
delete(raw, "messages")
delete(raw, "stream")
*r = ChatCompletionRequest(a)
r.Extra = raw
return nil
}
func (r ChatCompletionRequest) MarshalForUpstream() ([]byte, error) {
model := map[string]string{
"minimax-m2.5": "minimax-m25",
"glm4.7": "glm47",
}[r.Model]
if model == "" {
model = r.Model
}
m := map[string]any{
"model": model,
"messages": r.Messages,
"temperature": 0,
"stream": r.Stream,
"stream_options": map[string]any{"include_usage": true},
"max_tokens": 16000,
"inputs": map[string]any{"aiDevQuestion": ""},
}
for k, v := range r.Extra {
var anyValue any
if err := json.Unmarshal(v, &anyValue); err == nil {
m[k] = anyValue
}
}
return json.Marshal(m)
}
type ErrorResponse struct {
Error ErrorBody `json:"error"`
}
type ErrorBody struct {
Message string `json:"message"`
Type string `json:"type"`
Param any `json:"param"`
Code string `json:"code"`
}
+792
View File
@@ -0,0 +1,792 @@
package server
import (
"bufio"
"encoding/json"
"errors"
"fmt"
"html/template"
"io"
"math/rand"
"net/http"
"net/url"
"strings"
"time"
"git.misaka.ren/M1saka/zhanlu_proxy/internal/auth"
"git.misaka.ren/M1saka/zhanlu_proxy/internal/config"
"git.misaka.ren/M1saka/zhanlu_proxy/internal/openai"
"git.misaka.ren/M1saka/zhanlu_proxy/internal/sign"
"git.misaka.ren/M1saka/zhanlu_proxy/internal/zhanlu"
)
type Server struct {
cfg config.Config
mux *http.ServeMux
}
func New(cfg config.Config) http.Handler {
s := &Server{cfg: cfg, mux: http.NewServeMux()}
s.routes()
return s.mux
}
func (s *Server) routes() {
s.mux.HandleFunc("GET /healthz", s.healthz)
s.mux.HandleFunc("GET /login", s.loginPage)
s.mux.HandleFunc("GET /auth/start", s.startSSO)
s.mux.HandleFunc("GET /auth/callback", s.ssoCallback)
s.mux.HandleFunc("POST /api/auth/code", s.requestPhoneCode)
s.mux.HandleFunc("POST /api/auth/login", s.loginWithPhoneCode)
s.mux.HandleFunc("GET /api/credentials", s.getCredentials)
s.mux.HandleFunc("POST /api/credentials", s.saveCredentials)
s.mux.HandleFunc("POST /api/sso/exchange", s.exchangeSSOCode)
s.mux.HandleFunc("GET /v1/models", s.withAPIKey(s.models))
s.mux.HandleFunc("POST /v1/chat/completions", s.withAPIKey(s.chatCompletions))
}
func (s *Server) healthz(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
func (s *Server) withAPIKey(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if s.cfg.OpenAIAPIKey != "" {
got := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
if got != s.cfg.OpenAIAPIKey {
writeOpenAIError(w, http.StatusUnauthorized, "invalid api key", "auth_error", "invalid_api_key")
return
}
}
next(w, r)
}
}
func (s *Server) loginPage(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_ = loginTemplate.Execute(w, map[string]any{"CredentialsPath": s.cfg.CredentialsPath, "SSOBaseURL": s.cfg.SSOBaseURL})
}
func (s *Server) startSSO(w http.ResponseWriter, r *http.Request) {
ssoBaseURL := strings.TrimSpace(r.URL.Query().Get("sso_base_url"))
if ssoBaseURL == "" {
ssoBaseURL = s.cfg.SSOBaseURL
}
if err := validateHTTPBaseURL(ssoBaseURL); err != nil {
s.renderLoginResult(w, false, err.Error())
return
}
callback := callbackURL(r)
loginURL := strings.TrimRight(ssoBaseURL, "/") + "/moss/micrologin/#/sso/getauthorizecode" +
"?redirectUri=" + url.QueryEscape(callback) +
"&sourceid=7192038465&moss_sso_account=1"
http.Redirect(w, r, loginURL, http.StatusFound)
}
func (s *Server) ssoCallback(w http.ResponseWriter, r *http.Request) {
code := strings.TrimSpace(r.URL.Query().Get("code"))
if code == "" {
s.renderLoginResult(w, false, "回调中没有授权 code,请重新登录")
return
}
creds, err := auth.ExchangeCode(&http.Client{Timeout: s.cfg.UpstreamTimeout}, s.cfg.SSOExchangeURL, code, s.cfg.TokenDecryptKey)
if err != nil {
s.renderLoginResult(w, false, err.Error())
return
}
creds.BaseURL = s.cfg.ServerBaseURL
if err := auth.SaveCredentials(s.cfg.CredentialsPath, creds); err != nil {
s.renderLoginResult(w, false, err.Error())
return
}
s.cfg.Credentials = creds
s.renderLoginResult(w, true, "凭据已保存,可以关闭此页面并使用 OpenAI 兼容接口")
}
func (s *Server) renderLoginResult(w http.ResponseWriter, success bool, message string) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusOK)
_ = loginResultTemplate.Execute(w, map[string]any{"Success": success, "Message": message})
}
func callbackURL(r *http.Request) string {
host := r.Host
if colon := strings.LastIndex(host, ":"); colon >= 0 {
host = "127.0.0.1" + host[colon:]
} else {
host = "127.0.0.1"
}
return "http://" + host + "/auth/callback"
}
func validateHTTPBaseURL(raw string) error {
u, err := url.Parse(raw)
if err != nil || u.Scheme == "" || u.Host == "" {
return fmt.Errorf("SSO Base URL 无效:%s", raw)
}
if u.Scheme != "http" && u.Scheme != "https" {
return fmt.Errorf("SSO Base URL 只支持 http/https%s", raw)
}
return nil
}
func (s *Server) requestPhoneCode(w http.ResponseWriter, r *http.Request) {
var in struct {
Telephone string `json:"telephone"`
}
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]any{"ok": false, "error": err.Error()})
return
}
telephone := strings.TrimSpace(in.Telephone)
if !validChineseMobile(telephone) {
writeJSON(w, http.StatusBadRequest, map[string]any{"ok": false, "error": "请输入有效的 11 位手机号"})
return
}
secret := randomSecret16()
pub, err := auth.ParsePublicKey(s.cfg.PhonePublicKeyPEM)
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]any{"ok": false, "error": err.Error()})
return
}
telephoneCipher, err := auth.EncryptAuthorization(pub, telephone)
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]any{"ok": false, "error": err.Error()})
return
}
secretCipher, err := auth.EncryptAuthorization(pub, secret)
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]any{"ok": false, "error": err.Error()})
return
}
endpoint := strings.TrimRight(s.cfg.ServerBaseURL, "/") + "/api/query/acepilot-h5/manager/code/getAuthCode"
var out phoneAPIResponse
if err := s.postPhoneAPI(endpoint, map[string]string{"telephone": telephoneCipher, "secret": secretCipher}, &out); err != nil {
writeJSON(w, http.StatusBadGateway, map[string]any{"ok": false, "error": err.Error()})
return
}
if out.State != "OK" {
writeJSON(w, http.StatusBadRequest, map[string]any{"ok": false, "error": firstNonEmpty(out.ErrorMessage, "验证码发送失败")})
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "secret": secret})
}
func (s *Server) loginWithPhoneCode(w http.ResponseWriter, r *http.Request) {
var in struct {
Telephone string `json:"telephone"`
Code string `json:"code"`
Secret string `json:"secret"`
}
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]any{"ok": false, "error": err.Error()})
return
}
telephone := strings.TrimSpace(in.Telephone)
code := strings.TrimSpace(in.Code)
secret := strings.TrimSpace(in.Secret)
if !validChineseMobile(telephone) || len(code) != 6 || len(secret) != 16 {
writeJSON(w, http.StatusBadRequest, map[string]any{"ok": false, "error": "手机号、验证码或登录 secret 无效"})
return
}
pub, err := auth.ParsePublicKey(s.cfg.PhonePublicKeyPEM)
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]any{"ok": false, "error": err.Error()})
return
}
telephoneCipher, err := auth.EncryptAuthorization(pub, telephone)
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]any{"ok": false, "error": err.Error()})
return
}
endpoint := strings.TrimRight(s.cfg.ServerBaseURL, "/") + "/api/query/acepilot-h5/manager/code/checkCode"
var out phoneAPIResponse
if err := s.postPhoneAPI(endpoint, map[string]string{"telephone": telephoneCipher, "code": code}, &out); err != nil {
writeJSON(w, http.StatusBadGateway, map[string]any{"ok": false, "error": err.Error()})
return
}
if !out.Body.Result {
writeJSON(w, http.StatusBadRequest, map[string]any{"ok": false, "error": firstNonEmpty(out.ErrorMessage, "验证码校验失败")})
return
}
creds, err := decryptPhoneCredentials(out.Body, secret)
if err != nil {
writeJSON(w, http.StatusBadGateway, map[string]any{"ok": false, "error": err.Error()})
return
}
creds.BaseURL = s.cfg.ServerBaseURL
if err := auth.SaveCredentials(s.cfg.CredentialsPath, creds); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]any{"ok": false, "error": err.Error()})
return
}
s.cfg.Credentials = creds
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "path": s.cfg.CredentialsPath, "access_key": mask(creds.AccessKey)})
}
type phoneAPIResponse struct {
State string `json:"state"`
ErrorMessage string `json:"errorMessage"`
Body struct {
Result bool `json:"result"`
AK string `json:"ak"`
SK string `json:"sk"`
License string `json:"license"`
} `json:"body"`
}
func (s *Server) postPhoneAPI(endpoint string, payload map[string]string, out *phoneAPIResponse) error {
body, err := json.Marshal(payload)
if err != nil {
return err
}
req, err := http.NewRequest(http.MethodPost, endpoint, strings.NewReader(string(body)))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("plugin_type", "vscode")
req.Header.Set("plugin_version", "2.8.0")
req.Header.Set("request", randomRequestID())
resp, err := (&http.Client{Timeout: s.cfg.UpstreamTimeout}).Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
b, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
return fmt.Errorf("phone auth upstream returned %d: %s", resp.StatusCode, string(b))
}
return json.NewDecoder(resp.Body).Decode(out)
}
func decryptPhoneCredentials(body struct {
Result bool `json:"result"`
AK string `json:"ak"`
SK string `json:"sk"`
License string `json:"license"`
}, secret string) (auth.Credentials, error) {
ak, err := auth.DecryptCredential(strings.TrimSpace(body.AK), secret)
if err != nil {
return auth.Credentials{}, fmt.Errorf("decrypt access key: %w", err)
}
sk, err := auth.DecryptCredential(strings.TrimSpace(body.SK), secret)
if err != nil {
return auth.Credentials{}, fmt.Errorf("decrypt secret key: %w", err)
}
token, err := auth.DecryptCredential(strings.TrimSpace(body.License), secret)
if err != nil {
return auth.Credentials{}, fmt.Errorf("decrypt token: %w", err)
}
return auth.Credentials{AccessKey: ak, SecretKey: sk, Token: token}, nil
}
func validChineseMobile(s string) bool {
if len(s) != 11 || s[0] != '1' || s[1] < '3' || s[1] > '9' {
return false
}
for _, ch := range s {
if ch < '0' || ch > '9' {
return false
}
}
return true
}
func randomSecret16() string {
const letters = "0123456789abcdef"
r := rand.New(rand.NewSource(time.Now().UnixNano()))
b := make([]byte, 16)
for i := range b {
b[i] = letters[r.Intn(len(letters))]
}
return string(b)
}
func randomRequestID() string {
return fmt.Sprintf("%d-%d", time.Now().UnixNano(), rand.Int63())
}
func (s *Server) getCredentials(w http.ResponseWriter, r *http.Request) {
c, err := auth.LoadCredentials(s.cfg.CredentialsPath)
if err != nil {
writeJSON(w, http.StatusOK, map[string]any{"configured": false, "path": s.cfg.CredentialsPath})
return
}
writeJSON(w, http.StatusOK, map[string]any{
"configured": true,
"path": s.cfg.CredentialsPath,
"access_key": mask(c.AccessKey),
"base_url": c.BaseURL,
"saved_at": c.SavedAt,
})
}
func (s *Server) saveCredentials(w http.ResponseWriter, r *http.Request) {
var c auth.Credentials
if err := json.NewDecoder(r.Body).Decode(&c); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]any{"ok": false, "error": err.Error()})
return
}
if err := auth.SaveCredentials(s.cfg.CredentialsPath, c); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]any{"ok": false, "error": err.Error()})
return
}
s.cfg.Credentials = c
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "path": s.cfg.CredentialsPath})
}
func (s *Server) exchangeSSOCode(w http.ResponseWriter, r *http.Request) {
var in struct {
Code string `json:"code"`
Endpoint string `json:"endpoint"`
DecryptKey string `json:"decrypt_key"`
BaseURL string `json:"base_url"`
}
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]any{"ok": false, "error": err.Error()})
return
}
endpoint := firstNonEmpty(in.Endpoint, s.cfg.SSOExchangeURL)
decryptKey := firstNonEmpty(in.DecryptKey, s.cfg.TokenDecryptKey)
creds, err := auth.ExchangeCode(&http.Client{Timeout: s.cfg.UpstreamTimeout}, endpoint, in.Code, decryptKey)
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]any{"ok": false, "error": err.Error()})
return
}
creds.BaseURL = in.BaseURL
if err := auth.SaveCredentials(s.cfg.CredentialsPath, creds); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]any{"ok": false, "error": err.Error()})
return
}
s.cfg.Credentials = creds
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "path": s.cfg.CredentialsPath})
}
func (s *Server) models(w http.ResponseWriter, r *http.Request) {
data := make([]map[string]any, 0, len(s.cfg.Models))
for _, model := range s.cfg.Models {
data = append(data, map[string]any{"id": model, "object": "model", "created": 0, "owned_by": "zhanlu"})
}
writeJSON(w, http.StatusOK, map[string]any{"object": "list", "data": data})
}
func (s *Server) chatCompletions(w http.ResponseWriter, r *http.Request) {
creds, err := s.currentCredentials()
if err != nil {
writeOpenAIError(w, http.StatusUnauthorized, "zhanlu credentials are not configured; open /login first", "auth_error", "missing_credentials")
return
}
var req openai.ChatCompletionRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeOpenAIError(w, http.StatusBadRequest, err.Error(), "invalid_request_error", "bad_json")
return
}
if req.Model == "" {
req.Model = s.cfg.DefaultModel
}
clientWantsStream := req.Stream
// The Zhanlu gateway always expects streaming responses. Sending stream=false
// makes its Java adapter read choice.delta from a non-streaming choice.
req.Stream = true
body, err := req.MarshalForUpstream()
if err != nil {
writeOpenAIError(w, http.StatusBadRequest, err.Error(), "invalid_request_error", "bad_body")
return
}
signer, err := s.signer()
if err != nil {
writeOpenAIError(w, http.StatusInternalServerError, err.Error(), "sign_error", "signer_init_failed")
return
}
client := zhanlu.NewClient(s.cfg.ServerBaseURL, s.cfg.UpstreamPath, creds, signer, s.cfg.UpstreamTimeout)
resp, err := client.ChatCompletions(r.Context(), body)
if err != nil {
msg := "zhanlu upstream request failed"
if s.cfg.Debug {
msg = redactSensitive(err.Error())
}
writeOpenAIError(w, http.StatusBadGateway, msg, "upstream_error", "zhanlu_request_failed")
return
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
b, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
msg := fmt.Sprintf("zhanlu upstream returned %d", resp.StatusCode)
if s.cfg.Debug && len(b) > 0 {
msg += ": " + string(b)
}
writeOpenAIError(w, http.StatusBadGateway, msg, "upstream_error", "zhanlu_bad_status")
return
}
if clientWantsStream {
s.proxyDecryptedStream(w, resp, creds.Token)
return
}
s.aggregateDecryptedStream(w, resp, creds.Token, req.Model)
}
func (s *Server) proxyDecryptedStream(w http.ResponseWriter, resp *http.Response, token string) {
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")
w.WriteHeader(http.StatusOK)
flusher, _ := w.(http.Flusher)
err := forEachDecryptedChunk(resp.Body, token, func(chunk []byte) error {
_, err := fmt.Fprintf(w, "data: %s\n\n", chunk)
if flusher != nil {
flusher.Flush()
}
return err
})
if err != nil {
b, _ := json.Marshal(map[string]any{"error": map[string]any{"message": err.Error(), "type": "upstream_error", "code": "zhanlu_stream_error"}})
_, _ = fmt.Fprintf(w, "data: %s\n\n", b)
}
_, _ = io.WriteString(w, "data: [DONE]\n\n")
}
func (s *Server) aggregateDecryptedStream(w http.ResponseWriter, resp *http.Response, token, model string) {
var content, reasoning, id string
var usage any
finishReason := "stop"
type toolCall struct {
ID string `json:"id"`
Type string `json:"type"`
Function struct {
Name string `json:"name"`
Arguments string `json:"arguments"`
} `json:"function"`
}
toolCalls := map[int]*toolCall{}
err := forEachDecryptedChunk(resp.Body, token, func(chunk []byte) error {
var event struct {
ID string `json:"id"`
Choices []struct {
Delta struct {
Content string `json:"content"`
ReasoningContent string `json:"reasoning_content"`
Reasoning string `json:"reasoning"`
ToolCalls []struct {
Index int `json:"index"`
ID string `json:"id"`
Type string `json:"type"`
Function struct {
Name string `json:"name"`
Arguments string `json:"arguments"`
} `json:"function"`
} `json:"tool_calls"`
} `json:"delta"`
FinishReason *string `json:"finish_reason"`
} `json:"choices"`
Usage any `json:"usage"`
}
if err := json.Unmarshal(chunk, &event); err != nil {
return err
}
if event.ID != "" {
id = event.ID
}
if event.Usage != nil {
usage = event.Usage
}
if len(event.Choices) > 0 {
content += event.Choices[0].Delta.Content
reasoning += event.Choices[0].Delta.ReasoningContent + event.Choices[0].Delta.Reasoning
for _, part := range event.Choices[0].Delta.ToolCalls {
call := toolCalls[part.Index]
if call == nil {
call = &toolCall{Type: "function"}
toolCalls[part.Index] = call
}
if part.ID != "" {
call.ID = part.ID
}
if part.Type != "" {
call.Type = part.Type
}
call.Function.Name += part.Function.Name
call.Function.Arguments += part.Function.Arguments
}
if event.Choices[0].FinishReason != nil {
finishReason = *event.Choices[0].FinishReason
}
}
return nil
})
if err != nil {
writeOpenAIError(w, http.StatusBadGateway, err.Error(), "upstream_error", "zhanlu_stream_error")
return
}
if id == "" {
id = "chatcmpl-" + randomRequestID()
}
message := map[string]any{"role": "assistant", "content": content}
if len(toolCalls) > 0 {
ordered := make([]*toolCall, 0, len(toolCalls))
for i := 0; i < len(toolCalls); i++ {
if call := toolCalls[i]; call != nil {
ordered = append(ordered, call)
}
}
message["tool_calls"] = ordered
if content == "" {
message["content"] = nil
}
}
if reasoning != "" {
message["reasoning_content"] = reasoning
}
result := map[string]any{
"id": id, "object": "chat.completion", "created": time.Now().Unix(), "model": model,
"choices": []map[string]any{{"index": 0, "message": message, "finish_reason": finishReason}},
}
if usage != nil {
result["usage"] = usage
}
writeJSON(w, http.StatusOK, result)
}
func forEachDecryptedChunk(r io.Reader, token string, fn func([]byte) error) error {
scanner := bufio.NewScanner(r)
scanner.Buffer(make([]byte, 64*1024), 2*1024*1024)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" {
continue
}
if !strings.HasPrefix(line, "data:") {
var upstreamError map[string]any
if json.Unmarshal([]byte(line), &upstreamError) == nil && upstreamError["state"] == "ERROR" {
return fmt.Errorf("zhanlu upstream error: %v", upstreamError["errorMessage"])
}
continue
}
ciphertext := strings.TrimSpace(strings.TrimPrefix(line, "data:"))
if ciphertext == "" || ciphertext == "[DONE]" {
continue
}
plain, err := auth.DecryptCredential(ciphertext, token)
if err != nil {
return fmt.Errorf("decrypt zhanlu stream: %w", err)
}
if !json.Valid([]byte(plain)) {
return errors.New("zhanlu stream contained invalid decrypted JSON")
}
if err := fn([]byte(plain)); err != nil {
return err
}
}
return scanner.Err()
}
func (s *Server) currentCredentials() (auth.Credentials, error) {
if s.cfg.Credentials.Validate() == nil {
return s.cfg.Credentials, nil
}
c, err := auth.LoadCredentials(s.cfg.CredentialsPath)
if err != nil {
return auth.Credentials{}, err
}
return c, c.Validate()
}
func (s *Server) signer() (sign.Signer, error) {
if strings.TrimSpace(s.cfg.PublicKeyPEM) == "" {
return sign.Signer{Encryptor: func(text string) (string, error) {
return "", errors.New("ZHANLU_PUBLIC_KEY_PEM is required for signed upstream requests")
}}, nil
}
pub, err := auth.ParsePublicKey(s.cfg.PublicKeyPEM)
if err != nil {
return sign.Signer{}, err
}
return sign.Signer{PublicKey: pub}, nil
}
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 writeOpenAIError(w http.ResponseWriter, code int, message, typ, errCode string) {
writeJSON(w, code, openai.ErrorResponse{Error: openai.ErrorBody{Message: message, Type: typ, Param: nil, Code: errCode}})
}
func mask(s string) string {
if len(s) <= 8 {
return "****"
}
return s[:4] + "****" + s[len(s)-4:]
}
func firstNonEmpty(a, b string) string {
if a != "" {
return a
}
return b
}
func redactSensitive(s string) string {
for _, key := range []string{"AccessKey", "authorization", "Signature"} {
s = redactQueryValue(s, key)
}
return s
}
func redactQueryValue(s, key string) string {
needle := key + "="
for {
start := strings.Index(s, needle)
if start < 0 {
return s
}
valueStart := start + len(needle)
valueEnd := len(s)
if amp := strings.Index(s[valueStart:], "&"); amp >= 0 {
valueEnd = valueStart + amp
}
s = s[:valueStart] + "<redacted>" + s[valueEnd:]
}
}
var loginTemplate = template.Must(template.New("login").Parse(`<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>湛卢代理登录</title>
<style>
:root { color-scheme: light dark; font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
body { margin: 0; min-height: 100vh; display: grid; place-items: center; background: radial-gradient(circle at top left, #dde7ff, transparent 34rem), linear-gradient(135deg, #101828, #1f2937); color: #e5e7eb; }
main { width: min(760px, calc(100vw - 32px)); display: grid; grid-template-columns: 1fr 1fr; gap: 24px; align-items: stretch; }
.hero, form { border: 1px solid rgba(255,255,255,.14); background: rgba(15,23,42,.78); backdrop-filter: blur(18px); border-radius: 24px; box-shadow: 0 24px 80px rgba(0,0,0,.28); }
.hero { padding: 30px; display: flex; flex-direction: column; justify-content: space-between; }
h1 { margin: 0; font-size: clamp(28px, 4vw, 44px); letter-spacing: -0.04em; }
p { color: #b6c2d9; line-height: 1.7; }
code { color: #bfdbfe; word-break: break-all; }
.login-card { padding: 28px; display: grid; gap: 16px; border: 1px solid rgba(255,255,255,.14); background: rgba(15,23,42,.78); backdrop-filter: blur(18px); border-radius: 24px; box-shadow: 0 24px 80px rgba(0,0,0,.28); }
label { display: grid; gap: 8px; font-size: 14px; color: #cbd5e1; }
input, textarea { width: 100%; box-sizing: border-box; border: 1px solid rgba(148,163,184,.35); border-radius: 14px; padding: 12px 14px; background: rgba(2,6,23,.55); color: #f8fafc; outline: none; font: inherit; }
input:focus, textarea:focus { border-color: #60a5fa; box-shadow: 0 0 0 4px rgba(96,165,250,.16); }
.row { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.login-button { display: block; text-align: center; text-decoration: none; border: 0; border-radius: 14px; padding: 14px 16px; background: linear-gradient(135deg, #3b82f6, #8b5cf6); color: white; font-weight: 700; cursor: pointer; font: inherit; }
.login-button:hover { filter: brightness(1.08); }
.status { min-height: 22px; color: #93c5fd; }
.muted { font-size: 13px; color: #94a3b8; }
@media (max-width: 760px) { main { grid-template-columns: 1fr; padding: 18px 0; } .row { grid-template-columns: 1fr; } }
</style>
</head>
<body>
<main>
<section class="hero">
<div>
<h1>湛卢代理登录</h1>
<p>输入手机号获取验证码,按插件默认的移动云登录接口换取凭据。服务会保存凭据,后续 OpenAI 兼容接口自动使用。</p>
</div>
<div class="muted">保存位置:<br><code>{{.CredentialsPath}}</code></div>
</section>
<section class="login-card">
<h2>手机号验证码登录</h2>
<p>手机号和一次性 secret 会按插件逻辑用 RSA 加密后提交到移动云公网接口。</p>
<form id="phone-form">
<label>手机号<input name="telephone" inputmode="numeric" autocomplete="tel" placeholder="请输入 11 位手机号" required></label>
<label>验证码
<div class="row">
<input name="code" inputmode="numeric" autocomplete="one-time-code" placeholder="6 位验证码" required>
<button class="login-button" id="code-button" type="button">获取验证码</button>
</div>
</label>
<button class="login-button" type="submit">登录并保存凭据</button>
</form>
<div class="status" id="status">正在检查登录状态...</div>
<div class="muted">凭据保存到 JSON;验证码本身不会保存。</div>
</section>
</main>
<script>
const statusEl = document.getElementById('status');
const form = document.getElementById('phone-form');
const codeButton = document.getElementById('code-button');
let secret = '';
let countdown = 0;
let countdownTimer = null;
function setStatus(text) {
statusEl.textContent = text;
}
function startCountdown() {
countdown = 60;
codeButton.disabled = true;
countdownTimer && clearInterval(countdownTimer);
countdownTimer = setInterval(() => {
if (countdown <= 0) {
clearInterval(countdownTimer);
codeButton.disabled = false;
codeButton.textContent = '获取验证码';
return;
}
codeButton.textContent = countdown + 's';
countdown--;
}, 1000);
}
fetch('/api/credentials').then(r => r.json()).then(data => {
statusEl.textContent = data.configured ? ('已登录:' + (data.access_key || '')) : '当前未登录';
});
codeButton.addEventListener('click', async () => {
const telephone = form.telephone.value.trim();
if (!/^1[3-9]\d{9}$/.test(telephone)) {
setStatus('请输入有效的 11 位手机号');
return;
}
codeButton.disabled = true;
setStatus('正在发送验证码...');
const res = await fetch('/api/auth/code', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ telephone })
});
const data = await res.json();
if (!res.ok || !data.ok) {
codeButton.disabled = false;
setStatus(data.error || '验证码发送失败');
return;
}
secret = data.secret;
setStatus('验证码已发送');
startCountdown();
});
form.addEventListener('submit', async (event) => {
event.preventDefault();
const telephone = form.telephone.value.trim();
const code = form.code.value.trim();
if (!secret) {
setStatus('请先获取验证码');
return;
}
setStatus('正在登录并保存凭据...');
const res = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ telephone, code, secret })
});
const data = await res.json();
if (!res.ok || !data.ok) {
setStatus(data.error || '登录失败');
return;
}
setStatus('登录成功,已保存凭据:' + (data.access_key || '') + 'JSON' + (data.path || ''));
});
</script>
</body>
</html>`))
var loginResultTemplate = template.Must(template.New("login-result").Parse(`<!doctype html>
<html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>湛卢登录结果</title><style>body{margin:0;min-height:100vh;display:grid;place-items:center;background:#0f172a;color:#e2e8f0;font-family:system-ui}.card{max-width:560px;margin:24px;padding:32px;border:1px solid #334155;border-radius:22px;background:#1e293b;text-align:center}a{color:#93c5fd}</style></head>
<body><main class="card">{{if .Success}}<h1>登录成功</h1>{{else}}<h1>登录失败</h1>{{end}}<p>{{.Message}}</p><a href="/login">返回登录页</a></main></body></html>`))
+77
View File
@@ -0,0 +1,77 @@
package sign
import (
"crypto/hmac"
"crypto/rand"
"crypto/rsa"
"crypto/sha1"
"crypto/sha256"
"encoding/hex"
"fmt"
"net/url"
"strings"
"time"
"git.misaka.ren/M1saka/zhanlu_proxy/internal/auth"
)
type Signer struct {
PublicKey *rsa.PublicKey
Now func() time.Time
Nonce func() string
Encryptor func(string) (string, error)
}
func (s Signer) BuildOpURL(path string, creds auth.Credentials, baseURL string, method string) (string, error) {
if err := creds.Validate(); err != nil {
return "", err
}
if method == "" {
method = "POST"
}
now := time.Now
if s.Now != nil {
now = s.Now
}
nonce := randomUUID
if s.Nonce != nil {
nonce = s.Nonce
}
encryptor := s.Encryptor
if encryptor == nil {
encryptor = func(text string) (string, error) { return auth.EncryptAuthorization(s.PublicKey, text) }
}
base64Auth, err := encryptor(fmt.Sprintf("%d:%s", now().UnixMilli(), creds.Token))
if err != nil {
return "", err
}
timestamp := now().Format("2006-01-02T15:04:05Z")
query := "AccessKey=" + creds.AccessKey +
"&SignatureMethod=HmacSHA1" +
"&SignatureNonce=" + nonce() +
"&SignatureVersion=V2.0" +
"&Timestamp=" + timestamp +
"&Version=2016-12-05" +
"&authorization=" + url.QueryEscape(base64Auth)
canonicalQuery := strings.ReplaceAll(query, ":", "%3A")
queryHash := sha256.Sum256([]byte(canonicalQuery))
stringToSign := strings.ToUpper(method) + "\n" + strings.ReplaceAll(path, "/", "%2F") + "\n" + hex.EncodeToString(queryHash[:])
mac := hmac.New(sha1.New, []byte("BC_SIGNATURE&"+creds.SecretKey))
mac.Write([]byte(stringToSign))
signature := hex.EncodeToString(mac.Sum(nil))
return strings.TrimRight(baseURL, "/") + path + "?" + query + "&Signature=" + signature, nil
}
func randomUUID() string {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
return fmt.Sprintf("%d", time.Now().UnixNano())
}
b[6] = (b[6] & 0x0f) | 0x40
b[8] = (b[8] & 0x3f) | 0x80
return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:])
}
+70
View File
@@ -0,0 +1,70 @@
package zhanlu
import (
"bytes"
"context"
"crypto/rand"
"fmt"
"net/http"
"time"
"git.misaka.ren/M1saka/zhanlu_proxy/internal/auth"
"git.misaka.ren/M1saka/zhanlu_proxy/internal/sign"
)
type Client struct {
BaseURL string
Path string
Creds auth.Credentials
Signer sign.Signer
HTTPClient *http.Client
}
func NewClient(baseURL, path string, creds auth.Credentials, signer sign.Signer, timeout time.Duration) *Client {
return &Client{
BaseURL: baseURL,
Path: path,
Creds: creds,
Signer: signer,
HTTPClient: &http.Client{
Timeout: timeout,
},
}
}
func (c *Client) ChatCompletions(ctx context.Context, body []byte) (*http.Response, error) {
baseURL := c.BaseURL
if c.Creds.BaseURL != "" {
baseURL = c.Creds.BaseURL
}
signedURL, err := c.Signer.BuildOpURL(c.Path, c.Creds, baseURL, http.MethodPost)
if err != nil {
return nil, err
}
encryptedBody, err := auth.EncryptCredential(string(body), c.Creds.Token)
if err != nil {
return nil, err
}
wrappedBody := []byte(fmt.Sprintf(`{"data":%q}`, encryptedBody))
req, err := http.NewRequestWithContext(ctx, http.MethodPost, signedURL, bytes.NewReader(wrappedBody))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "text/event-stream, application/json")
req.Header.Set("plugin_type", "vscode")
req.Header.Set("plugin_version", "2.8.0")
req.Header.Set("service_type", "code")
req.Header.Set("request", randomRequestID())
return c.HTTPClient.Do(req)
}
func randomRequestID() string {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
return fmt.Sprintf("%d", time.Now().UnixNano())
}
b[6] = (b[6] & 0x0f) | 0x40
b[8] = (b[8] & 0x3f) | 0x80
return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:])
}