From d4dd3a0f1b21527fe67bc54f04864fe8aa0922aa Mon Sep 17 00:00:00 2001 From: M1saka Date: Wed, 8 Jul 2026 09:13:45 +0800 Subject: [PATCH] Initial zhanlu OpenAI proxy --- .gitea/workflows/build.yml | 89 ++++ .gitignore | 9 + README.md | 171 ++++++++ cmd/zhanlu-proxy/main.go | 23 + go.mod | 3 + internal/auth/aes.go | 85 ++++ internal/auth/credentials.go | 58 +++ internal/auth/rsa.go | 37 ++ internal/auth/sso.go | 90 ++++ internal/config/config.go | 104 +++++ internal/openai/types.go | 65 +++ internal/server/server.go | 792 +++++++++++++++++++++++++++++++++++ internal/sign/signer.go | 77 ++++ internal/zhanlu/client.go | 70 ++++ 14 files changed, 1673 insertions(+) create mode 100644 .gitea/workflows/build.yml create mode 100644 .gitignore create mode 100644 README.md create mode 100644 cmd/zhanlu-proxy/main.go create mode 100644 go.mod create mode 100644 internal/auth/aes.go create mode 100644 internal/auth/credentials.go create mode 100644 internal/auth/rsa.go create mode 100644 internal/auth/sso.go create mode 100644 internal/config/config.go create mode 100644 internal/openai/types.go create mode 100644 internal/server/server.go create mode 100644 internal/sign/signer.go create mode 100644 internal/zhanlu/client.go diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml new file mode 100644 index 0000000..bf483ee --- /dev/null +++ b/.gitea/workflows/build.yml @@ -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 }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ca08a48 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +credentials.json +extension/ +*.exe +*.log +.env +.env.* +!.env.example +tmp/ +temp/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..e1e8d72 --- /dev/null +++ b/README.md @@ -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 +``` diff --git a/cmd/zhanlu-proxy/main.go b/cmd/zhanlu-proxy/main.go new file mode 100644 index 0000000..7ab502e --- /dev/null +++ b/cmd/zhanlu-proxy/main.go @@ -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) + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..f438d87 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module git.misaka.ren/M1saka/zhanlu_proxy + +go 1.22 diff --git a/internal/auth/aes.go b/internal/auth/aes.go new file mode 100644 index 0000000..ab4ad68 --- /dev/null +++ b/internal/auth/aes.go @@ -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 +} diff --git a/internal/auth/credentials.go b/internal/auth/credentials.go new file mode 100644 index 0000000..f9049de --- /dev/null +++ b/internal/auth/credentials.go @@ -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) +} diff --git a/internal/auth/rsa.go b/internal/auth/rsa.go new file mode 100644 index 0000000..d3814cd --- /dev/null +++ b/internal/auth/rsa.go @@ -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 +} diff --git a/internal/auth/sso.go b/internal/auth/sso.go new file mode 100644 index 0000000..40bf60f --- /dev/null +++ b/internal/auth/sso.go @@ -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" +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..3c93c5f --- /dev/null +++ b/internal/config/config.go @@ -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-----` diff --git a/internal/openai/types.go b/internal/openai/types.go new file mode 100644 index 0000000..88d9576 --- /dev/null +++ b/internal/openai/types.go @@ -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"` +} diff --git a/internal/server/server.go b/internal/server/server.go new file mode 100644 index 0000000..6445164 --- /dev/null +++ b/internal/server/server.go @@ -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] + "" + s[valueEnd:] + } +} + +var loginTemplate = template.Must(template.New("login").Parse(` + + + + + 湛卢代理登录 + + + +
+
+
+

湛卢代理登录

+

输入手机号获取验证码,按插件默认的移动云登录接口换取凭据。服务会保存凭据,后续 OpenAI 兼容接口自动使用。

+
+
保存位置:
{{.CredentialsPath}}
+
+ +
+ + +`)) + +var loginResultTemplate = template.Must(template.New("login-result").Parse(` + +湛卢登录结果 +
{{if .Success}}

登录成功

{{else}}

登录失败

{{end}}

{{.Message}}

返回登录页
`)) diff --git a/internal/sign/signer.go b/internal/sign/signer.go new file mode 100644 index 0000000..a753822 --- /dev/null +++ b/internal/sign/signer.go @@ -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:]) +} diff --git a/internal/zhanlu/client.go b/internal/zhanlu/client.go new file mode 100644 index 0000000..94565ab --- /dev/null +++ b/internal/zhanlu/client.go @@ -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:]) +}