diff --git a/.github/workflows/release-artifacts.yml b/.github/workflows/release-artifacts.yml index b964a999..17b3b74e 100644 --- a/.github/workflows/release-artifacts.yml +++ b/.github/workflows/release-artifacts.yml @@ -79,7 +79,7 @@ jobs: CGO_ENABLED=0 GOOS="${GOOS}" GOARCH="${GOARCH}" \ go build -trimpath -ldflags="-s -w -X ds2api/internal/version.BuildVersion=${BUILD_VERSION}" -o "${STAGE}/${BIN}" ./cmd/ds2api - cp config.example.json .env.example internal/deepseek/assets/sha3_wasm_bg.7b9ca65ddd.wasm LICENSE README.MD README.en.md "${STAGE}/" + cp config.example.json .env.example LICENSE README.MD README.en.md "${STAGE}/" cp -R static/admin "${STAGE}/static/admin" if [ "${GOOS}" = "windows" ]; then diff --git a/Dockerfile b/Dockerfile index 544f29d4..1a7d7005 100644 --- a/Dockerfile +++ b/Dockerfile @@ -34,7 +34,7 @@ CMD ["/usr/local/bin/ds2api"] FROM runtime-base AS runtime-from-source COPY --from=go-builder /out/ds2api /usr/local/bin/ds2api -COPY --from=go-builder /app/internal/deepseek/assets/sha3_wasm_bg.7b9ca65ddd.wasm /app/sha3_wasm_bg.7b9ca65ddd.wasm + COPY --from=go-builder /app/config.example.json /app/config.example.json COPY --from=webui-builder /app/static/admin /app/static/admin @@ -53,13 +53,13 @@ RUN set -eux; \ test -n "${PKG_DIR}"; \ mkdir -p /out/static; \ cp "${PKG_DIR}/ds2api" /out/ds2api; \ - cp "${PKG_DIR}/sha3_wasm_bg.7b9ca65ddd.wasm" /out/sha3_wasm_bg.7b9ca65ddd.wasm; \ + cp "${PKG_DIR}/config.example.json" /out/config.example.json; \ cp -R "${PKG_DIR}/static/admin" /out/static/admin FROM runtime-base AS runtime-from-dist COPY --from=dist-extract /out/ds2api /usr/local/bin/ds2api -COPY --from=dist-extract /out/sha3_wasm_bg.7b9ca65ddd.wasm /app/sha3_wasm_bg.7b9ca65ddd.wasm + COPY --from=dist-extract /out/config.example.json /app/config.example.json COPY --from=dist-extract /out/static/admin /app/static/admin diff --git a/go.mod b/go.mod index 89b9297a..1471913b 100644 --- a/go.mod +++ b/go.mod @@ -8,7 +8,6 @@ require ( github.com/google/uuid v1.6.0 github.com/refraction-networking/utls v1.8.2 github.com/router-for-me/CLIProxyAPI/v6 v6.9.14 - github.com/tetratelabs/wazero v1.11.0 ) require ( diff --git a/go.sum b/go.sum index a5fdfe18..4b47fb0b 100644 --- a/go.sum +++ b/go.sum @@ -18,8 +18,6 @@ github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= -github.com/tetratelabs/wazero v1.11.0 h1:+gKemEuKCTevU4d7ZTzlsvgd1uaToIDtlQlmNbwqYhA= -github.com/tetratelabs/wazero v1.11.0/go.mod h1:eV28rsN8Q+xwjogd7f4/Pp4xFxO7uOGbLcD/LzB1wiU= github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= diff --git a/internal/adapter/openai/handler_toolcall_test.go b/internal/adapter/openai/handler_toolcall_test.go index e071d542..e595f53c 100644 --- a/internal/adapter/openai/handler_toolcall_test.go +++ b/internal/adapter/openai/handler_toolcall_test.go @@ -275,7 +275,7 @@ func TestHandleNonStreamFencedToolCallExamplePromotesToolCall(t *testing.T) { TestHandleNonStreamFencedToolCallExampleDoesNotPromoteToolCall(t) } -func TestHandleNonStreamReturns502WhenUpstreamOutputEmpty(t *testing.T) { +func TestHandleNonStreamReturns429WhenUpstreamOutputEmpty(t *testing.T) { h := &Handler{} resp := makeSSEHTTPResponse( `data: {"p":"response/content","v":""}`, @@ -284,8 +284,8 @@ func TestHandleNonStreamReturns502WhenUpstreamOutputEmpty(t *testing.T) { rec := httptest.NewRecorder() h.handleNonStream(rec, context.Background(), resp, "cid-empty", "deepseek-chat", "prompt", false, nil) - if rec.Code != http.StatusBadGateway { - t.Fatalf("expected status 502 for empty upstream output, got %d body=%s", rec.Code, rec.Body.String()) + if rec.Code != http.StatusTooManyRequests { + t.Fatalf("expected status 429 for empty upstream output, got %d body=%s", rec.Code, rec.Body.String()) } out := decodeJSONBody(t, rec.Body.String()) errObj, _ := out["error"].(map[string]any) diff --git a/internal/adapter/openai/responses_stream_test.go b/internal/adapter/openai/responses_stream_test.go index 60abe195..138e9d00 100644 --- a/internal/adapter/openai/responses_stream_test.go +++ b/internal/adapter/openai/responses_stream_test.go @@ -627,7 +627,7 @@ func TestHandleResponsesNonStreamToolChoiceNoneStillAllowsFunctionCall(t *testin } } -func TestHandleResponsesNonStreamReturns502WhenUpstreamOutputEmpty(t *testing.T) { +func TestHandleResponsesNonStreamReturns429WhenUpstreamOutputEmpty(t *testing.T) { h := &Handler{} rec := httptest.NewRecorder() resp := &http.Response{ @@ -639,8 +639,8 @@ func TestHandleResponsesNonStreamReturns502WhenUpstreamOutputEmpty(t *testing.T) } h.handleResponsesNonStream(rec, resp, "owner-a", "resp_test", "deepseek-chat", "prompt", false, nil, util.DefaultToolChoicePolicy(), "") - if rec.Code != http.StatusBadGateway { - t.Fatalf("expected 502 for empty upstream output, got %d body=%s", rec.Code, rec.Body.String()) + if rec.Code != http.StatusTooManyRequests { + t.Fatalf("expected 429 for empty upstream output, got %d body=%s", rec.Code, rec.Body.String()) } out := decodeJSONBody(t, rec.Body.String()) errObj, _ := out["error"].(map[string]any) diff --git a/internal/adapter/openai/upstream_empty.go b/internal/adapter/openai/upstream_empty.go index f9401edc..071ffced 100644 --- a/internal/adapter/openai/upstream_empty.go +++ b/internal/adapter/openai/upstream_empty.go @@ -10,6 +10,6 @@ func writeUpstreamEmptyOutputError(w http.ResponseWriter, thinking, text string, writeOpenAIErrorWithCode(w, http.StatusBadRequest, "Upstream content filtered the response and returned no output.", "content_filter") return true } - writeOpenAIErrorWithCode(w, http.StatusBadGateway, "Upstream model returned empty output.", "upstream_empty_output") + writeOpenAIErrorWithCode(w, http.StatusTooManyRequests, "Upstream model returned empty output.", "upstream_empty_output") return true } diff --git a/internal/config/paths.go b/internal/config/paths.go index 18723a3c..99e3fde1 100644 --- a/internal/config/paths.go +++ b/internal/config/paths.go @@ -33,10 +33,6 @@ func ConfigPath() string { return ResolvePath("DS2API_CONFIG_PATH", "config.json") } -func WASMPath() string { - return ResolvePath("DS2API_WASM_PATH", "sha3_wasm_bg.7b9ca65ddd.wasm") -} - func RawStreamSampleRoot() string { return ResolvePath("DS2API_RAW_STREAM_SAMPLE_ROOT", "tests/raw_stream_samples") } diff --git a/internal/deepseek/assets/sha3_wasm_bg.7b9ca65ddd.wasm b/internal/deepseek/assets/sha3_wasm_bg.7b9ca65ddd.wasm deleted file mode 100644 index ac92b1d8..00000000 Binary files a/internal/deepseek/assets/sha3_wasm_bg.7b9ca65ddd.wasm and /dev/null differ diff --git a/internal/deepseek/client_auth.go b/internal/deepseek/client_auth.go index a510a275..3cbf3230 100644 --- a/internal/deepseek/client_auth.go +++ b/internal/deepseek/client_auth.go @@ -109,7 +109,7 @@ func (c *Client) GetPow(ctx context.Context, a *auth.RequestAuth, maxAttempts in data, _ := resp["data"].(map[string]any) bizData, _ := data["biz_data"].(map[string]any) challenge, _ := bizData["challenge"].(map[string]any) - answer, err := c.powSolver.Compute(ctx, challenge) + answer, err := ComputePow(challenge) if err != nil { attempts++ continue diff --git a/internal/deepseek/client_core.go b/internal/deepseek/client_core.go index cda6edc4..5b9417ce 100644 --- a/internal/deepseek/client_core.go +++ b/internal/deepseek/client_core.go @@ -23,7 +23,6 @@ type Client struct { stream trans.Doer fallback *http.Client fallbackS *http.Client - powSolver *PowSolver maxRetries int } @@ -36,11 +35,11 @@ func NewClient(store *config.Store, resolver *auth.Resolver) *Client { stream: trans.New(0), fallback: &http.Client{Timeout: 60 * time.Second}, fallbackS: &http.Client{Timeout: 0}, - powSolver: NewPowSolver(config.WASMPath()), maxRetries: 3, } } -func (c *Client) PreloadPow(ctx context.Context) error { - return c.powSolver.init(ctx) +// PreloadPow 保留兼容接口,纯 Go 实现无需预加载。 +func (c *Client) PreloadPow(_ context.Context) error { + return nil } diff --git a/internal/deepseek/deepseek_edge_test.go b/internal/deepseek/deepseek_edge_test.go index 92e6952b..e321954f 100644 --- a/internal/deepseek/deepseek_edge_test.go +++ b/internal/deepseek/deepseek_edge_test.go @@ -105,43 +105,16 @@ func TestBuildPowHeaderEmptyChallenge(t *testing.T) { } } -// ─── PowSolver pool size ───────────────────────────────────────────── - -func TestPowPoolSizeFromEnvDefault(t *testing.T) { - t.Setenv("DS2API_POW_POOL_SIZE", "") - got := powPoolSizeFromEnv() - if got < 1 { - t.Fatalf("expected positive default pool size, got %d", got) - } -} - -func TestPowPoolSizeFromEnvInvalid(t *testing.T) { - t.Setenv("DS2API_POW_POOL_SIZE", "abc") - got := powPoolSizeFromEnv() - if got < 1 { - t.Fatalf("expected positive default for invalid, got %d", got) - } -} - -func TestPowPoolSizeFromEnvSpecificValue(t *testing.T) { - t.Setenv("DS2API_POW_POOL_SIZE", "5") - got := powPoolSizeFromEnv() - if got != 5 { - t.Fatalf("expected 5, got %d", got) - } -} - // ─── NewClient ─────────────────────────────────────────────────────── func TestNewClientInitialState(t *testing.T) { client := NewClient(nil, nil) - if client.powSolver == nil { - t.Fatal("expected powSolver to be initialized") + if client == nil { + t.Fatal("expected non-nil client") } } func TestNewClientPreloadPowIdempotent(t *testing.T) { - t.Setenv("DS2API_POW_POOL_SIZE", "1") client := NewClient(nil, nil) if err := client.PreloadPow(context.Background()); err != nil { t.Fatalf("first preload failed: %v", err) @@ -150,16 +123,3 @@ func TestNewClientPreloadPowIdempotent(t *testing.T) { t.Fatalf("second preload failed: %v", err) } } - -// ─── PowSolver init and module pool ────────────────────────────────── - -func TestPowSolverPoolSizeMatchesEnv(t *testing.T) { - t.Setenv("DS2API_POW_POOL_SIZE", "2") - solver := NewPowSolver("test.wasm") - if err := solver.init(context.Background()); err != nil { - t.Fatalf("init failed: %v", err) - } - if cap(solver.pool) != 2 { - t.Fatalf("expected pool capacity 2, got %d", cap(solver.pool)) - } -} diff --git a/internal/deepseek/embedded_pow.go b/internal/deepseek/embedded_pow.go deleted file mode 100644 index 13dd05a1..00000000 --- a/internal/deepseek/embedded_pow.go +++ /dev/null @@ -1,6 +0,0 @@ -package deepseek - -import _ "embed" - -//go:embed assets/sha3_wasm_bg.7b9ca65ddd.wasm -var embeddedWASM []byte diff --git a/internal/deepseek/pow.go b/internal/deepseek/pow.go index 95d86b89..54f678d1 100644 --- a/internal/deepseek/pow.go +++ b/internal/deepseek/pow.go @@ -1,220 +1,28 @@ package deepseek import ( - "context" "encoding/base64" - "encoding/binary" "encoding/json" "errors" - "math" - "os" - stdruntime "runtime" - "strconv" - "sync" - "ds2api/internal/config" - - "github.com/tetratelabs/wazero" - "github.com/tetratelabs/wazero/api" + "ds2api/pow" ) -type PowSolver struct { - wasmPath string - once sync.Once - err error - - runtime wazero.Runtime - compiled wazero.CompiledModule - pool chan *pooledModule - poolSize int -} - -type pooledModule struct { - mod api.Module - stackFn api.Function - allocFn api.Function - freeFn api.Function - solveFn api.Function -} - -func NewPowSolver(wasmPath string) *PowSolver { - return &PowSolver{wasmPath: wasmPath} -} - -func (p *PowSolver) init(ctx context.Context) error { - p.once.Do(func() { - wasmBytes, err := os.ReadFile(p.wasmPath) - if err != nil { - if len(embeddedWASM) == 0 { - p.err = err - return - } - wasmBytes = embeddedWASM - } - p.runtime = wazero.NewRuntime(ctx) - p.compiled, p.err = p.runtime.CompileModule(ctx, wasmBytes) - if p.err == nil { - p.poolSize = powPoolSizeFromEnv() - p.pool = make(chan *pooledModule, p.poolSize) - for range p.poolSize { - inst, err := p.createModule(ctx) - if err != nil { - p.err = err - return - } - p.pool <- inst - } - } - }) - return p.err -} - -func (p *PowSolver) Compute(ctx context.Context, challenge map[string]any) (int64, error) { - if err := p.init(ctx); err != nil { - return 0, err - } +// ComputePow 使用纯 Go 实现求解 PoW challenge (DeepSeekHashV1)。 +func ComputePow(challenge map[string]any) (int64, error) { algo, _ := challenge["algorithm"].(string) if algo != "DeepSeekHashV1" { return 0, errors.New("unsupported algorithm") } challengeStr, _ := challenge["challenge"].(string) salt, _ := challenge["salt"].(string) - signature, _ := challenge["signature"].(string) - targetPath, _ := challenge["target_path"].(string) - _ = signature - _ = targetPath - - difficulty := toFloat64(challenge["difficulty"], 144000) expireAt := toInt64(challenge["expire_at"], 1680000000) - prefix := salt + "_" + itoa(expireAt) + "_" - - pm, err := p.acquireModule(ctx) - if err != nil { - return 0, err - } - defer p.releaseModule(pm) - - mem := pm.mod.Memory() - if mem == nil { - return 0, errors.New("wasm memory missing") - } - retPtrs, err := pm.stackFn.Call(ctx, uint64(uint32(^uint32(15)))) // -16 i32 - if err != nil || len(retPtrs) == 0 { - return 0, errors.New("stack alloc failed") - } - retptr := uint32(retPtrs[0]) - defer func() { - _, _ = pm.stackFn.Call(context.Background(), 16) - }() - - chPtr, chLen, err := writeUTF8(ctx, pm.allocFn, mem, challengeStr) - if err != nil { - return 0, err - } - defer freeUTF8(pm.freeFn, chPtr, chLen) - - prefixPtr, prefixLen, err := writeUTF8(ctx, pm.allocFn, mem, prefix) - if err != nil { - return 0, err - } - defer freeUTF8(pm.freeFn, prefixPtr, prefixLen) - - if _, err := pm.solveFn.Call(ctx, - uint64(retptr), - uint64(chPtr), uint64(chLen), - uint64(prefixPtr), uint64(prefixLen), - math.Float64bits(difficulty), - ); err != nil { - return 0, err - } - - statusBytes, ok := mem.Read(retptr, 4) - if !ok { - return 0, errors.New("read status failed") - } - status := int32(binary.LittleEndian.Uint32(statusBytes)) - valueBytes, ok := mem.Read(retptr+8, 8) - if !ok { - return 0, errors.New("read value failed") - } - value := math.Float64frombits(binary.LittleEndian.Uint64(valueBytes)) - if status == 0 { - return 0, errors.New("pow solve failed") - } - return int64(value), nil -} - -func (p *PowSolver) createModule(ctx context.Context) (*pooledModule, error) { - mod, err := p.runtime.InstantiateModule(ctx, p.compiled, wazero.NewModuleConfig()) - if err != nil { - return nil, err - } - stackFn := mod.ExportedFunction("__wbindgen_add_to_stack_pointer") - allocFn := mod.ExportedFunction("__wbindgen_export_0") - solveFn := mod.ExportedFunction("wasm_solve") - if stackFn == nil || allocFn == nil || solveFn == nil { - _ = mod.Close(context.Background()) - return nil, errors.New("required wasm exports missing") - } - return &pooledModule{ - mod: mod, - stackFn: stackFn, - allocFn: allocFn, - freeFn: mod.ExportedFunction("__wbindgen_export_2"), - solveFn: solveFn, - }, nil -} - -func (p *PowSolver) acquireModule(ctx context.Context) (*pooledModule, error) { - if p.pool != nil { - for { - select { - case pm := <-p.pool: - if pm != nil { - return pm, nil - } - case <-ctx.Done(): - return nil, ctx.Err() - } - } - } - return p.createModule(ctx) -} - -func (p *PowSolver) releaseModule(pm *pooledModule) { - if pm == nil || pm.mod == nil { - return - } - if p.pool != nil { - select { - case p.pool <- pm: - return - default: - } - } - _ = pm.mod.Close(context.Background()) -} + difficulty := toInt64FromFloat(challenge["difficulty"], 144000) -func writeUTF8(ctx context.Context, allocFn api.Function, mem api.Memory, text string) (uint32, uint32, error) { - data := []byte(text) - res, err := allocFn.Call(ctx, uint64(len(data)), 1) - if err != nil || len(res) == 0 { - return 0, 0, errors.New("alloc failed") - } - ptr := uint32(res[0]) - if !mem.Write(ptr, data) { - return 0, 0, errors.New("mem write failed") - } - return ptr, uint32(len(data)), nil -} - -func freeUTF8(freeFn api.Function, ptr, size uint32) { - if freeFn == nil || ptr == 0 || size == 0 { - return - } - _, _ = freeFn.Call(context.Background(), uint64(ptr), uint64(size), 1) + return pow.SolvePow(challengeStr, salt, expireAt, difficulty) } +// BuildPowHeader 序列化 {algorithm,challenge,salt,answer,signature,target_path} 为 base64(JSON)。 func BuildPowHeader(challenge map[string]any, answer int64) (string, error) { payload := map[string]any{ "algorithm": challenge["algorithm"], @@ -257,32 +65,7 @@ func toInt64(v any, d int64) int64 { } } -func itoa(n int64) string { - return strconv.FormatInt(n, 10) -} - -func powPoolSizeFromEnv() int { - const fallback = 4 - n := fallback - if cpus := stdruntime.GOMAXPROCS(0); cpus > 0 { - n = cpus - } - if raw := os.Getenv("DS2API_POW_POOL_SIZE"); raw != "" { - if v, err := strconv.Atoi(raw); err == nil && v > 0 { - n = v - } - } - if n > 64 { - return 64 - } - return n -} - -func PreloadWASM(wasmPath string) { - solver := NewPowSolver(wasmPath) - if err := solver.init(context.Background()); err != nil { - config.Logger.Warn("[WASM] preload failed", "error", err) - return - } - config.Logger.Info("[WASM] module preloaded", "path", wasmPath) +// toInt64FromFloat 与 toInt64 等价,仅名称区分用途。 +func toInt64FromFloat(v any, d int64) int64 { + return toInt64(v, d) } diff --git a/internal/deepseek/pow_test.go b/internal/deepseek/pow_test.go index 6ebcd2ac..3f1104c8 100644 --- a/internal/deepseek/pow_test.go +++ b/internal/deepseek/pow_test.go @@ -3,66 +3,18 @@ package deepseek import ( "context" "testing" - "time" ) -func TestPowPoolSizeFromEnv(t *testing.T) { - t.Setenv("DS2API_POW_POOL_SIZE", "3") - if got := powPoolSizeFromEnv(); got != 3 { - t.Fatalf("expected pool size 3, got %d", got) - } -} - -func TestPowSolverAcquireReleaseReusesModule(t *testing.T) { - t.Setenv("DS2API_POW_POOL_SIZE", "1") - solver := NewPowSolver("missing-file.wasm") - if err := solver.init(context.Background()); err != nil { - t.Fatalf("init failed: %v", err) - } - - pm1, err := solver.acquireModule(context.Background()) - if err != nil { - t.Fatalf("acquire first module failed: %v", err) - } - solver.releaseModule(pm1) - - pm2, err := solver.acquireModule(context.Background()) - if err != nil { - t.Fatalf("acquire second module failed: %v", err) - } - if pm1 != pm2 { - t.Fatalf("expected pooled module reuse, got different instances") - } - solver.releaseModule(pm2) -} - -func TestPowSolverAcquireHonorsContextWhenPoolExhausted(t *testing.T) { - t.Setenv("DS2API_POW_POOL_SIZE", "1") - solver := NewPowSolver("missing-file.wasm") - if err := solver.init(context.Background()); err != nil { - t.Fatalf("init failed: %v", err) - } - - held, err := solver.acquireModule(context.Background()) - if err != nil { - t.Fatalf("acquire held module failed: %v", err) - } - defer solver.releaseModule(held) - - ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond) - defer cancel() - if _, err := solver.acquireModule(ctx); err == nil { - t.Fatalf("expected context cancellation while pool is exhausted") - } -} - -func TestClientPreloadPowUsesClientSolver(t *testing.T) { - t.Setenv("DS2API_POW_POOL_SIZE", "1") +func TestPreloadPowNoOp(t *testing.T) { client := NewClient(nil, nil) if err := client.PreloadPow(context.Background()); err != nil { - t.Fatalf("preload failed: %v", err) + t.Fatalf("PreloadPow should be no-op, got error: %v", err) } - if client.powSolver.runtime == nil || client.powSolver.compiled == nil { - t.Fatalf("expected client pow solver to be initialized") +} + +func TestComputePowUnsupportedAlgorithm(t *testing.T) { + _, err := ComputePow(map[string]any{"algorithm": "unknown"}) + if err == nil { + t.Fatal("expected error for unsupported algorithm") } } diff --git a/internal/server/router.go b/internal/server/router.go index cf44bdba..ebb306a8 100644 --- a/internal/server/router.go +++ b/internal/server/router.go @@ -42,9 +42,9 @@ func NewApp() (*App, error) { }) dsClient = deepseek.NewClient(store, resolver) if err := dsClient.PreloadPow(context.Background()); err != nil { - config.Logger.Warn("[WASM] preload failed", "error", err) + config.Logger.Warn("[PoW] init failed", "error", err) } else { - config.Logger.Info("[WASM] module preloaded", "path", config.WASMPath()) + config.Logger.Info("[PoW] pure Go solver ready") } openaiHandler := &openai.Handler{Store: store, Auth: resolver, DS: dsClient} diff --git a/pow/README.md b/pow/README.md new file mode 100644 index 00000000..7467fd23 --- /dev/null +++ b/pow/README.md @@ -0,0 +1,64 @@ +# DeepSeek PoW 纯算实现 + +替代 `internal/deepseek/assets/sha3_wasm_bg.*.wasm` + wazero 运行时。 + +## 算法 + +DeepSeekHashV1 = SHA3-256 但 **Keccak-f[1600] 跳过 round 0** (只做 rounds 1..23)。其余参数不变: +rate=136, padding=0x06+0x80, output=32 字节。 + +PoW 协议:服务端选 answer ∈ [0, difficulty),计算 `challenge = hash(prefix + str(answer))`。 +客户端遍历 [0, difficulty) 找到匹配的 nonce。 + +``` +prefix = salt + "_" + str(expire_at) + "_" +input = (prefix + str(nonce)).encode("utf-8") +hash = DeepSeekHashV1(input) → 32 bytes +header = base64(json({algorithm, challenge, salt, answer, signature, target_path})) +``` + +## 性能 (Apple M4, Go 1.25) + +``` +BenchmarkHash 187.5 ns/op 0 alloc → 5.33M hash/s +BenchmarkSolve 13.4 ms/op 2 alloc → 75 道/秒/核 (difficulty=144000) +``` + +对比 wazero 调 WASM: hash 快 **5×**, solve 快 **2.8×**。 + +## 测试 + +```bash +cd pow && go test -v ./... && go test -bench=. -benchmem +``` + +## 替换 WASM + +替换 `internal/deepseek/pow.go` 中 `PowSolver.Compute`: + +```go +// 原: 调 wasm_solve(retptr, chPtr, chLen, prefixPtr, prefixLen, difficulty) +// 新: +import "ds2api/pow" + +func (c *Client) GetPow(ctx context.Context, a *auth.RequestAuth, ...) (string, error) { + // ... 省略 token/retry 逻辑,只改 compute 部分 ... + challenge, _ := bizData["challenge"].(map[string]any) + ch := &pow.Challenge{ + Algorithm: challenge["algorithm"].(string), + Challenge: challenge["challenge"].(string), + Salt: challenge["salt"].(string), + ExpireAt: int64(challenge["expire_at"].(float64)), + Difficulty: int64(challenge["difficulty"].(float64)), + Signature: challenge["signature"].(string), + TargetPath: challenge["target_path"].(string), + } + return pow.SolveAndBuildHeader(ch) +} +``` + +可删除: +- `internal/deepseek/assets/sha3_wasm_bg.*.wasm` +- `internal/deepseek/embedded_pow.go` +- `internal/deepseek/pow.go` 中 `PowSolver` 结构体、wazero 相关池化代码 +- `go.mod` 中 `github.com/tetratelabs/wazero` 依赖 diff --git a/pow/deepseek_hash.go b/pow/deepseek_hash.go new file mode 100644 index 00000000..e4cfdc99 --- /dev/null +++ b/pow/deepseek_hash.go @@ -0,0 +1,153 @@ +// Package pow 提供 DeepSeekHashV1 纯 Go 实现。 +// DeepSeekHashV1 = SHA3-256 但跳过 Keccak-f[1600] round 0 (只做 rounds 1..23)。 +package pow + +import "encoding/binary" + +var rc = [24]uint64{ + 0x0000000000000001, 0x0000000000008082, 0x800000000000808A, 0x8000000080008000, + 0x000000000000808B, 0x0000000080000001, 0x8000000080008081, 0x8000000000008009, + 0x000000000000008A, 0x0000000000000088, 0x0000000080008009, 0x000000008000000A, + 0x000000008000808B, 0x800000000000008B, 0x8000000000008089, 0x8000000000008003, + 0x8000000000008002, 0x8000000000000080, 0x000000000000800A, 0x800000008000000A, + 0x8000000080008081, 0x8000000000008080, 0x0000000080000001, 0x8000000080008008, +} + +func rotl64(v uint64, k uint) uint64 { return v<>(64-k) } + +func keccakF23(s *[25]uint64) { + a0, a1, a2, a3, a4 := s[0], s[1], s[2], s[3], s[4] + a5, a6, a7, a8, a9 := s[5], s[6], s[7], s[8], s[9] + a10, a11, a12, a13, a14 := s[10], s[11], s[12], s[13], s[14] + a15, a16, a17, a18, a19 := s[15], s[16], s[17], s[18], s[19] + a20, a21, a22, a23, a24 := s[20], s[21], s[22], s[23], s[24] + + for r := 1; r < 24; r++ { + c0 := a0 ^ a5 ^ a10 ^ a15 ^ a20 + c1 := a1 ^ a6 ^ a11 ^ a16 ^ a21 + c2 := a2 ^ a7 ^ a12 ^ a17 ^ a22 + c3 := a3 ^ a8 ^ a13 ^ a18 ^ a23 + c4 := a4 ^ a9 ^ a14 ^ a19 ^ a24 + d0 := c4 ^ rotl64(c1, 1) + d1 := c0 ^ rotl64(c2, 1) + d2 := c1 ^ rotl64(c3, 1) + d3 := c2 ^ rotl64(c4, 1) + d4 := c3 ^ rotl64(c0, 1) + a0 ^= d0 + a5 ^= d0 + a10 ^= d0 + a15 ^= d0 + a20 ^= d0 + a1 ^= d1 + a6 ^= d1 + a11 ^= d1 + a16 ^= d1 + a21 ^= d1 + a2 ^= d2 + a7 ^= d2 + a12 ^= d2 + a17 ^= d2 + a22 ^= d2 + a3 ^= d3 + a8 ^= d3 + a13 ^= d3 + a18 ^= d3 + a23 ^= d3 + a4 ^= d4 + a9 ^= d4 + a14 ^= d4 + a19 ^= d4 + a24 ^= d4 + + b0 := a0 + b10 := rotl64(a1, 1) + b20 := rotl64(a2, 62) + b5 := rotl64(a3, 28) + b15 := rotl64(a4, 27) + b16 := rotl64(a5, 36) + b1 := rotl64(a6, 44) + b11 := rotl64(a7, 6) + b21 := rotl64(a8, 55) + b6 := rotl64(a9, 20) + b7 := rotl64(a10, 3) + b17 := rotl64(a11, 10) + b2 := rotl64(a12, 43) + b12 := rotl64(a13, 25) + b22 := rotl64(a14, 39) + b23 := rotl64(a15, 41) + b8 := rotl64(a16, 45) + b18 := rotl64(a17, 15) + b3 := rotl64(a18, 21) + b13 := rotl64(a19, 8) + b14 := rotl64(a20, 18) + b24 := rotl64(a21, 2) + b9 := rotl64(a22, 61) + b19 := rotl64(a23, 56) + b4 := rotl64(a24, 14) + + a0 = b0 ^ (^b1 & b2) + a1 = b1 ^ (^b2 & b3) + a2 = b2 ^ (^b3 & b4) + a3 = b3 ^ (^b4 & b0) + a4 = b4 ^ (^b0 & b1) + a5 = b5 ^ (^b6 & b7) + a6 = b6 ^ (^b7 & b8) + a7 = b7 ^ (^b8 & b9) + a8 = b8 ^ (^b9 & b5) + a9 = b9 ^ (^b5 & b6) + a10 = b10 ^ (^b11 & b12) + a11 = b11 ^ (^b12 & b13) + a12 = b12 ^ (^b13 & b14) + a13 = b13 ^ (^b14 & b10) + a14 = b14 ^ (^b10 & b11) + a15 = b15 ^ (^b16 & b17) + a16 = b16 ^ (^b17 & b18) + a17 = b17 ^ (^b18 & b19) + a18 = b18 ^ (^b19 & b15) + a19 = b19 ^ (^b15 & b16) + a20 = b20 ^ (^b21 & b22) + a21 = b21 ^ (^b22 & b23) + a22 = b22 ^ (^b23 & b24) + a23 = b23 ^ (^b24 & b20) + a24 = b24 ^ (^b20 & b21) + + a0 ^= rc[r] + } + + s[0], s[1], s[2], s[3], s[4] = a0, a1, a2, a3, a4 + s[5], s[6], s[7], s[8], s[9] = a5, a6, a7, a8, a9 + s[10], s[11], s[12], s[13], s[14] = a10, a11, a12, a13, a14 + s[15], s[16], s[17], s[18], s[19] = a15, a16, a17, a18, a19 + s[20], s[21], s[22], s[23], s[24] = a20, a21, a22, a23, a24 +} + +// DeepSeekHashV1 返回 data 的 32 字节摘要,与 WASM wasm_deepseek_hash_v1 等价。 +func DeepSeekHashV1(data []byte) [32]byte { + const rate = 136 + var s [25]uint64 + + off := 0 + for off+rate <= len(data) { + for i := 0; i < rate/8; i++ { + s[i] ^= binary.LittleEndian.Uint64(data[off+i*8:]) + } + keccakF23(&s) + off += rate + } + + var final [rate]byte + copy(final[:], data[off:]) + final[len(data)-off] = 0x06 + final[rate-1] |= 0x80 + for i := 0; i < rate/8; i++ { + s[i] ^= binary.LittleEndian.Uint64(final[i*8:]) + } + keccakF23(&s) + + var out [32]byte + binary.LittleEndian.PutUint64(out[0:], s[0]) + binary.LittleEndian.PutUint64(out[8:], s[1]) + binary.LittleEndian.PutUint64(out[16:], s[2]) + binary.LittleEndian.PutUint64(out[24:], s[3]) + return out +} diff --git a/pow/deepseek_pow.go b/pow/deepseek_pow.go new file mode 100644 index 00000000..f6ea7def --- /dev/null +++ b/pow/deepseek_pow.go @@ -0,0 +1,139 @@ +package pow + +import ( + "encoding/base64" + "encoding/binary" + "encoding/hex" + "encoding/json" + "errors" + "strconv" +) + +// Challenge 对应 /api/v0/chat/create_pow_challenge 返回的 data.biz_data.challenge。 +type Challenge struct { + Algorithm string `json:"algorithm"` + Challenge string `json:"challenge"` + Salt string `json:"salt"` + ExpireAt int64 `json:"expire_at"` + Difficulty int64 `json:"difficulty"` + Signature string `json:"signature"` + TargetPath string `json:"target_path"` +} + +// BuildPrefix: "__" (对应 pow.go:89) +func BuildPrefix(salt string, expireAt int64) string { + return salt + "_" + strconv.FormatInt(expireAt, 10) + "_" +} + +// SolvePow 搜索 nonce ∈ [0, difficulty) 使得 DeepSeekHashV1(prefix+str(nonce)) == challenge。 +// prefix 预吸收进 state,循环内零分配。 +func SolvePow(challengeHex, salt string, expireAt, difficulty int64) (int64, error) { + if len(challengeHex) != 64 { + return 0, errors.New("pow: challenge must be 64 hex chars") + } + target, err := hex.DecodeString(challengeHex) + if err != nil { + return 0, err + } + var ta [32]byte + copy(ta[:], target) + t0 := binary.LittleEndian.Uint64(ta[0:]) + t1 := binary.LittleEndian.Uint64(ta[8:]) + t2 := binary.LittleEndian.Uint64(ta[16:]) + t3 := binary.LittleEndian.Uint64(ta[24:]) + + prefix := []byte(BuildPrefix(salt, expireAt)) + const rate = 136 + var baseState [25]uint64 + off := 0 + for off+rate <= len(prefix) { + for i := 0; i < rate/8; i++ { + baseState[i] ^= binary.LittleEndian.Uint64(prefix[off+i*8:]) + } + keccakF23(&baseState) + off += rate + } + tailLen := len(prefix) - off + var tail [rate]byte + copy(tail[:], prefix[off:]) + + var numBuf [20]byte + for n := int64(0); n < difficulty; n++ { + v := uint64(n) + pos := 20 + if v == 0 { + pos-- + numBuf[pos] = '0' + } else { + for v > 0 { + pos-- + numBuf[pos] = byte('0' + v%10) + v /= 10 + } + } + numLen := 20 - pos + s := baseState + totalTail := tailLen + numLen + if totalTail < rate { + var buf [rate]byte + copy(buf[:tailLen], tail[:tailLen]) + copy(buf[tailLen:totalTail], numBuf[pos:]) + buf[totalTail] = 0x06 + buf[rate-1] |= 0x80 + for i := 0; i < rate/8; i++ { + s[i] ^= binary.LittleEndian.Uint64(buf[i*8:]) + } + keccakF23(&s) + } else { + var buf [rate]byte + copy(buf[:tailLen], tail[:tailLen]) + copy(buf[tailLen:rate], numBuf[pos:pos+(rate-tailLen)]) + for i := 0; i < rate/8; i++ { + s[i] ^= binary.LittleEndian.Uint64(buf[i*8:]) + } + keccakF23(&s) + var buf2 [rate]byte + rem := totalTail - rate + copy(buf2[:rem], numBuf[pos+(rate-tailLen):pos+(rate-tailLen)+rem]) + buf2[rem] = 0x06 + buf2[rate-1] |= 0x80 + for i := 0; i < rate/8; i++ { + s[i] ^= binary.LittleEndian.Uint64(buf2[i*8:]) + } + keccakF23(&s) + } + if s[0] == t0 && s[1] == t1 && s[2] == t2 && s[3] == t3 { + return n, nil + } + } + return 0, errors.New("pow: no solution within difficulty") +} + +// BuildPowHeader 序列化 {algorithm,challenge,salt,answer,signature,target_path} 为 base64(JSON)。 +// 不含 difficulty/expire_at (对应 pow.go:218)。 +func BuildPowHeader(c *Challenge, answer int64) (string, error) { + b, err := json.Marshal(map[string]any{ + "algorithm": c.Algorithm, "challenge": c.Challenge, "salt": c.Salt, + "answer": answer, "signature": c.Signature, "target_path": c.TargetPath, + }) + if err != nil { + return "", err + } + return base64.StdEncoding.EncodeToString(b), nil +} + +// SolveAndBuildHeader 端到端: Challenge → x-ds-pow-response header string。 +func SolveAndBuildHeader(c *Challenge) (string, error) { + if c.Algorithm != "DeepSeekHashV1" { + return "", errors.New("pow: unsupported algorithm: " + c.Algorithm) + } + d := c.Difficulty + if d == 0 { + d = 144000 + } + answer, err := SolvePow(c.Challenge, c.Salt, c.ExpireAt, d) + if err != nil { + return "", err + } + return BuildPowHeader(c, answer) +} diff --git a/pow/deepseek_pow_test.go b/pow/deepseek_pow_test.go new file mode 100644 index 00000000..73c6f660 --- /dev/null +++ b/pow/deepseek_pow_test.go @@ -0,0 +1,79 @@ +package pow + +import ( + "encoding/base64" + "encoding/hex" + "encoding/json" + "strconv" + "testing" +) + +// 测试向量来自直接调用 DeepSeek 官方 WASM。 +func TestDeepSeekHashV1(t *testing.T) { + for _, tc := range []struct{ in, want string }{ + {"", "e594808bc5b7151ac160c6d39a02e0a8e261ed588578403099e3561dc40c26b3"}, + {"testsalt_1700000000_42", "d4a2ea58c89e40887c933484868380c6f803eaa8dc53a3b9df8e431b921a4f09"}, + {"testsalt_1700000000_100000", "abea2f35796b65486e9be1b36f7878c66cab021e96faa473fdf4decd31f9ba30"}, + {"abc123salt_1700000000_12345", "74b3b7452745b70e85eb32ee7f0a9ec0381d42dd5137b695da915e104fc390e1"}, + } { + h := DeepSeekHashV1([]byte(tc.in)) + got := hex.EncodeToString(h[:]) + if got != tc.want { + t.Errorf("hash(%q) = %s, want %s", tc.in, got, tc.want) + } + } +} + +func TestSolvePow(t *testing.T) { + for _, tc := range []struct { + salt string + expire int64 + answer int64 + diff int64 + }{ + {"testsalt", 1700000000, 42, 1000}, + {"testsalt", 1700000000, 500, 2000}, + {"abc123salt", 1700000000, 12345, 20000}, + } { + h := DeepSeekHashV1([]byte(BuildPrefix(tc.salt, tc.expire) + strconv.FormatInt(tc.answer, 10))) + got, err := SolvePow(hex.EncodeToString(h[:]), tc.salt, tc.expire, tc.diff) + if err != nil || got != tc.answer { + t.Errorf("salt=%q answer=%d: got=%d err=%v", tc.salt, tc.answer, got, err) + } + } +} + +func TestSolveAndBuildHeader(t *testing.T) { + t0 := DeepSeekHashV1([]byte("salt_1712345678_777")) + header, err := SolveAndBuildHeader(&Challenge{ + Algorithm: "DeepSeekHashV1", Challenge: hex.EncodeToString(t0[:]), + Salt: "salt", ExpireAt: 1712345678, Difficulty: 2000, + Signature: "sig", TargetPath: "/api/v0/chat/completion", + }) + if err != nil { + t.Fatal(err) + } + raw, _ := base64.StdEncoding.DecodeString(header) + var m map[string]any + if err := json.Unmarshal(raw, &m); err != nil { + t.Fatal(err) + } + if int64(m["answer"].(float64)) != 777 { + t.Errorf("answer = %v, want 777", m["answer"]) + } +} + +func BenchmarkHash(b *testing.B) { + d := []byte("realisticsalt_1712345678_12345") + for i := 0; i < b.N; i++ { + DeepSeekHashV1(d) + } +} + +func BenchmarkSolve(b *testing.B) { + h := DeepSeekHashV1([]byte("realisticsalt_1712345678_72000")) + ch := hex.EncodeToString(h[:]) + for i := 0; i < b.N; i++ { + _, _ = SolvePow(ch, "realisticsalt", 1712345678, 144000) + } +} diff --git a/vercel.json b/vercel.json index 0b47f35c..600a53c0 100644 --- a/vercel.json +++ b/vercel.json @@ -4,7 +4,6 @@ "outputDirectory": "static", "functions": { "api/chat-stream.js": { - "includeFiles": "internal/deepseek/assets/sha3_wasm_bg.7b9ca65ddd.wasm", "maxDuration": 300 }, "api/index.go": {