initial commit

This commit is contained in:
2026-07-31 14:21:48 +03:00
commit 92ceb8bec3
22 changed files with 3104 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
.idea/
.agents/
.codex/
+178
View File
@@ -0,0 +1,178 @@
# AGENTS.md
## Purpose
This repository uses AI coding agents for full-project Go code review and implementation work.
When asked to review code, inspect the entire repository and use repository-wide context. Do not limit analysis to the latest commit, pull request diff, or recently changed files unless the prompt explicitly narrows the scope.
## Operating modes
Treat review and implementation as separate modes:
- A request to review, audit, explain, or diagnose authorizes read-only inspection and non-mutating checks. Report findings, but do not edit files.
- A request to fix, change, implement, or build authorizes focused edits and the tests needed to verify them.
- A request to review and fix authorizes both activities.
- Do not turn review-only feedback into edits without explicit user authorization.
## Review priorities
Review the codebase with focus on:
1. correctness and reliability;
2. security and privacy;
3. concurrency, cancellation, and resource safety;
4. public API compatibility;
5. maintainability and architecture;
6. idiomatic Go;
7. testability;
8. performance when supported by code evidence or measurements;
9. documentation quality.
## Scope rules
- Review the whole repository unless the prompt explicitly narrows scope.
- Check cross-package interactions, public APIs, package boundaries, and shared patterns.
- Prefer concrete, actionable recommendations over generic advice.
- During implementation, make minimal, high-confidence changes within the requested scope.
- Preserve unrelated user changes and do not rewrite pre-existing work merely to make it consistent with the current task.
- When uncertain, state the confidence level, evidence, and assumptions.
## Project-specific constraints
- The project uses Go with CGO and a C++17 PoW implementation.
- Building and testing the PoW package requires a C++17 compiler and an x86-64 CPU with AVX2 support.
- Do not make live requests to the DeepSeek service during tests or routine verification.
- Never use real credentials, access tokens, device identifiers, or cookies in tests, examples, logs, or fixtures. PoW test vectors must be synthetic or explicitly public and non-sensitive.
- Use `httptest.Server` or a custom `http.RoundTripper` for HTTP tests.
- Do not log authorization headers or raw request and response bodies. If diagnostic body logging is ever explicitly required, redact secrets before the data reaches the logger.
- Keep network-dependent or hardware-specific integration tests separate from the default unit-test suite and document how to opt in.
## Go review expectations
Check for:
- bugs, fragile logic, invalid assumptions, nil handling issues, and resource leaks;
- weak error handling or errors that lose useful context;
- misuse of context, cancellation, timeouts, retries, and cleanup;
- race risks, deadlocks, blocking hazards, and unsafe shared state;
- non-idiomatic naming, APIs, interfaces, package structure, and error patterns;
- unnecessary complexity, duplication, or weak abstractions;
- performance problems supported by the code or benchmarks;
- unsafe input handling, secret leakage, insecure logging, injection risks, and risky file or network operations;
- portability problems introduced by CGO, compiler flags, CPU features, or platform assumptions.
## Documentation rules
Review comments where they affect the public API or explain non-obvious behavior.
### Exported declarations
Exported packages, types, funcs, methods, vars, and consts should have useful doc comments. A declaration may be documented by an appropriate group comment when that is clearer than repeating a comment for every member.
Each exported doc comment should:
- start with the identifier name when documenting a single declaration;
- explain purpose, behavior, constraints, or side effects;
- be concise without omitting important meaning;
- avoid mechanically repeating the signature;
- mention errors, ownership, concurrency, or lifecycle requirements when they are part of the contract.
### Unexported declarations
Do not require doc comments for every unexported declaration. Preserve comments that explain algorithms, protocol details, invariants, unsafe operations, performance decisions, or non-obvious constraints.
Report or rewrite comments only when they are missing from an important public contract, inaccurate, stale, misleading, redundant, or materially harder to understand than the code requires.
## Testing expectations
Treat tests as a required part of implementation and as an explicit review topic.
- Assess test quality, not only test presence.
- Add focused tests for changed behavior and regression tests for bugs being fixed.
- During review-only work, propose the smallest useful set of missing tests; do not add them.
- Prioritize public APIs, critical flows, negative paths, boundary conditions, cancellation, cleanup, and concurrency-sensitive logic.
- Prefer table-driven tests when they improve clarity; do not force them for one-off cases.
- If a case is hard to test directly, explain the gap and the most practical test strategy.
- Avoid tests that depend on timing, external services, real secrets, or unspecified machine state.
## Verification commands
For Go changes, run the relevant commands before finalizing:
```text
go test ./...
go vet ./...
go build ./...
```
Run `go test -race ./...` when the change affects concurrency and the current CGO/platform environment supports the race detector. Use repository-documented lint or formatting commands when present. If a command cannot run because the compiler, AVX2, CGO, network, or sandbox environment is unavailable, report the limitation instead of presenting the check as successful.
## Versioning and changelog
- Update `CHANGELOG.md` for user-visible behavior changes, public API changes, bug fixes, security fixes, and significant performance changes.
- Do not add changelog entries for tests, internal refactoring, formatting, routine comment cleanup, or `AGENTS.md`-only changes unless they change user-visible behavior.
- Add entries to an existing unreleased or next-version section. If no suitable section exists and choosing a version would require a product decision, ask the user rather than guessing.
- When Git history and release tags are available, verify changelog claims against the diff from the latest relevant release tag.
- Describe only changes that actually exist in the worktree. Do not rewrite or remove unrelated pre-existing changelog entries without user authorization.
- If the repository later adds a canonical version file, ensure its version agrees with the changelog section being edited.
- If `TODO.md` exists and the requested work completes one of its items, update that item using the file's existing format. Do not invent backlog files or modify an external wiki unless the user explicitly requests and authorizes it.
- `AGENTS.md`-only edits must never be added to `CHANGELOG.md`.
## Breaking changes policy
Treat the following as public API unless the repository explicitly documents otherwise:
- exported Go identifiers and their signatures;
- documented behavior, error semantics, ownership, and concurrency guarantees;
- request and response wire formats;
- configuration formats and supported command-line behavior.
- Detect potential breaking changes before editing a public API.
- Breaking changes require explicit user approval and a release version that permits them under the repository's versioning policy.
- For stable `v1+` releases, breaking changes require a new major version.
- If the current target version does not permit the change, stop and offer: keep existing behavior, introduce a small backward-compatible alternative, or select an appropriate release version.
- Prefer additive compatibility when it remains small, clear, and maintainable; do not accumulate compatibility layers that obscure the API.
## Commit message format
When the user asks for a commit message, output only a directly copyable plain multiline block:
- Use one to four short lines.
- Format every line as `(<kind>): <text>`.
- Use a concise kind such as `new`, `fix`, `refactor`, `ci/cd`, `tests`, or `doc`.
- Keep `<text>` concise and high-signal; do not turn it into a changelog.
- When multiple lines are needed, order kinds as: `new`, `fix`, `refactor`, `ci/cd`, `tests`, `doc`.
## Commit signing
- Create commits only when the user explicitly requests one.
- All commits created by an agent must be GPG-signed.
- If signing or pushing requires leaving the sandbox, request escalation before running the command.
- If a signed commit cannot be created successfully, report the failure and stop instead of creating an unsigned fallback.
## Review severity
- **Critical:** likely credential compromise, data loss, remote code execution, or broad production outage requiring immediate action.
- **Major:** a reproducible correctness, security, compatibility, resource, or concurrency defect that can affect normal use.
- **Minor:** a localized robustness, maintainability, documentation, or testability issue with limited immediate impact.
- Do not inflate severity for style preferences. If impact depends on an unverified assumption, state that explicitly.
## Review output
For repo-wide reviews:
- Lead with findings, ordered by severity and then by impact.
- For each finding include location, issue, impact, evidence, and recommended fix.
- Separate confirmed defects from suggestions and open questions.
- Include test gaps, documentation issues, and good decisions only when there is useful content to report.
- Omit empty sections. If there are no findings, say so explicitly and list the checks performed and any verification limitations.
- Include a summary of concrete changes only when implementation was authorized and changes were actually made.
## Working style
- Be direct, specific, and action-oriented.
- Do not stop at style-only feedback.
- Use full repository context before drawing conclusions.
- Prefer minimal, high-confidence patches.
- Preserve behavior unless intentionally fixing a confirmed bug or implementing an approved behavior change.
View File
+49
View File
@@ -0,0 +1,49 @@
# GoDeepSeek
## Requirements:
- CGO enabled
- x86_64 CPU with AVX2 (Intel Haswell 2013+, AMD Ryzen 2017+)
- C++17 compiler (GCC 7+, Clang 5+, MSVC 2017+)
## Quick start
```go
package main
import (
"context"
"deepseek"
"log"
)
func main() {
ctx := context.Background()
ds := deepseek.NewClient()
ds.SetDeviceIDProvider(deepseek.RandomDeviceIDProvider())
// Flow #1
err := ds.Login("email", "password")
if err != nil {
panic(err)
}
// Flow #2
ds.SetToken("TOKEN")
chatRes, err := ds.CreateChatWithContext(ctx)
if err != nil {
panic(err)
}
res, err := ds.CompletionWithContext(ctx, *deepseek.NewCompletionReq(chatRes.ID).SetPrompt("Hello"))
if err != nil {
panic(err)
}
str, err := ds.ReadStreamAsString(ctx, res)
if err != nil {
panic(err)
}
log.Println(str)
}
```
+124
View File
@@ -0,0 +1,124 @@
package deepseek
import (
"crypto/rand"
"encoding/base64"
"errors"
"fmt"
"net/http"
"net/url"
"strings"
)
// DeviceIDFromJar returns the base64-encoded value of the first DeepSeek
// thumbcache cookie in jar.
func (api *Client) DeviceIDFromJar(jar http.CookieJar) (string, error) {
if jar == nil {
return "", errors.New("cookie jar is nil")
}
u, err := url.Parse("https://chat.deepseek.com")
if err != nil {
return "", fmt.Errorf("parse DeepSeek URL: %w", err)
}
for _, cookie := range jar.Cookies(u) {
if strings.HasPrefix(cookie.Name, ".thumbcache_") {
if cookie.Value == "" {
return "", errors.New("thumbcache cookie is empty")
}
deviceID := base64.StdEncoding.EncodeToString(
[]byte(cookie.Value),
)
return deviceID, nil
}
}
return "", errors.New("thumbcache cookie not found")
}
// LoginReq is the request body used to authenticate a user.
type LoginReq struct {
DeviceID string `json:"device_id"`
OS string `json:"os"`
Email string `json:"email"`
Password string `json:"password"`
}
// LoginRes contains the user returned by a successful login.
type LoginRes struct {
User User `json:"user"`
}
// User describes an authenticated DeepSeek user.
type User struct {
ID string `json:"id"`
Token string `json:"token"`
Email string `json:"email"`
}
// Login authenticates with email and password and stores the returned bearer
// token on api. It uses a random device ID when no provider is configured.
func (api *Client) Login(email, password string) error {
var deviceID string
var err error
if api.deviceIDProvider != nil {
deviceID, err = api.deviceIDProvider.Get()
} else {
deviceID, err = RandomDeviceIDProvider().Get()
}
if err != nil {
return err
}
body := LoginReq{
DeviceID: deviceID,
OS: "ios",
Email: email,
Password: password,
}
req := NewRequest[LoginRes]("POST", "users/login", body)
resp, err := req.Do(api)
if err != nil {
return err
}
defer resp.Body.Close()
response, err := req.unmarshallBizResponse(resp)
if err != nil {
return err
}
api.SetToken(response.Data.Data.User.Token)
return nil
}
// DeviceIDProvider supplies the device ID sent during login.
type DeviceIDProvider interface {
Get() (string, error)
}
type randomDeviceIDProvider struct{}
// RandomDeviceIDProvider returns a provider that generates a random device ID
// for every call to Get.
func RandomDeviceIDProvider() randomDeviceIDProvider {
return randomDeviceIDProvider{}
}
func (p randomDeviceIDProvider) Get() (string, error) {
raw := make([]byte, 64)
if _, err := rand.Read(raw); err != nil {
return "", err
}
return base64.StdEncoding.EncodeToString(raw), nil
}
type staticDeviceIDProvider struct {
id string
}
// StaticDeviceIDProvider returns a provider that always returns id.
func StaticDeviceIDProvider(id string) staticDeviceIDProvider {
return staticDeviceIDProvider{id: id}
}
func (p staticDeviceIDProvider) Get() (string, error) { return p.id, nil }
+123
View File
@@ -0,0 +1,123 @@
package deepseek
import (
"encoding/base64"
"encoding/json"
"errors"
"net/http"
"net/url"
"testing"
)
type cookieJarStub struct {
cookies []*http.Cookie
}
func (j cookieJarStub) SetCookies(*url.URL, []*http.Cookie) {}
func (j cookieJarStub) Cookies(*url.URL) []*http.Cookie { return j.cookies }
type errorDeviceIDProvider struct {
err error
}
func (p errorDeviceIDProvider) Get() (string, error) { return "", p.err }
func TestDeviceIDFromJar(t *testing.T) {
tests := []struct {
name string
jar http.CookieJar
want string
wantErr string
}{
{name: "nil jar", wantErr: "cookie jar is nil"},
{name: "missing cookie", jar: cookieJarStub{}, wantErr: "thumbcache cookie not found"},
{name: "empty cookie", jar: cookieJarStub{cookies: []*http.Cookie{{Name: ".thumbcache_test"}}}, wantErr: "thumbcache cookie is empty"},
{
name: "matching cookie",
jar: cookieJarStub{cookies: []*http.Cookie{
{Name: "other", Value: "ignored"},
{Name: ".thumbcache_test", Value: "device-cookie"},
}},
want: base64.StdEncoding.EncodeToString([]byte("device-cookie")),
},
}
client := NewClient()
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := client.DeviceIDFromJar(tt.jar)
if tt.wantErr != "" {
if err == nil || err.Error() != tt.wantErr {
t.Fatalf("DeviceIDFromJar() error = %v, want %q", err, tt.wantErr)
}
return
}
if err != nil {
t.Fatalf("DeviceIDFromJar() error = %v", err)
}
if got != tt.want {
t.Fatalf("DeviceIDFromJar() = %q, want %q", got, tt.want)
}
})
}
}
func TestLogin(t *testing.T) {
client := testAPI(func(r *http.Request) (*http.Response, error) {
if r.URL.Path != "/users/login" {
t.Errorf("path = %q, want /users/login", r.URL.Path)
}
var body LoginReq
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Errorf("decode request: %v", err)
}
if body.DeviceID != "device-id" || body.Email != "user@example.test" || body.Password != "password" || body.OS != "ios" {
t.Errorf("request body = %#v", body)
}
return testHTTPResponse(`{"code":0,"data":{"biz_code":0,"biz_data":{"user":{"id":"user-id","token":"api-token","email":"user@example.test"}}}}`), nil
}).SetDeviceIDProvider(StaticDeviceIDProvider("device-id"))
if err := client.Login("user@example.test", "password"); err != nil {
t.Fatalf("Login() error = %v", err)
}
if client.token != "api-token" {
t.Fatalf("token = %q, want api-token", client.token)
}
}
func TestLoginUsesRandomProviderByDefault(t *testing.T) {
client := testAPI(func(r *http.Request) (*http.Response, error) {
var body LoginReq
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Errorf("decode request: %v", err)
}
raw, err := base64.StdEncoding.DecodeString(body.DeviceID)
if err != nil {
t.Errorf("decode device ID: %v", err)
} else if len(raw) != 64 {
t.Errorf("device ID has %d decoded bytes, want 64", len(raw))
}
return testHTTPResponse(`{"code":0,"data":{"biz_code":0,"biz_data":{"user":{"token":"api-token"}}}}`), nil
})
if err := client.Login("user@example.test", "password"); err != nil {
t.Fatalf("Login() error = %v", err)
}
}
func TestLoginReturnsProviderErrorWithoutRequest(t *testing.T) {
wantErr := errors.New("device ID unavailable")
requests := 0
client := testAPI(func(*http.Request) (*http.Response, error) {
requests++
return testHTTPResponse(`{}`), nil
}).SetDeviceIDProvider(errorDeviceIDProvider{err: wantErr})
err := client.Login("user@example.test", "password")
if !errors.Is(err, wantErr) {
t.Fatalf("Login() error = %v, want %v", err, wantErr)
}
if requests != 0 {
t.Fatalf("transport received %d requests, want 0", requests)
}
}
+215
View File
@@ -0,0 +1,215 @@
package deepseek
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"net/http/cookiejar"
)
const baseURL = "https://chat.deepseek.com/api/v0"
// NoParams is an empty JSON object used by endpoints without request fields.
var NoParams struct{}
// Response is the outer response envelope returned by the DeepSeek API.
type Response[T any] struct {
Code int `json:"code"`
Message string `json:"msg"`
Data T `json:"data"`
}
// BizResponse is the business-level response envelope nested inside Response.
type BizResponse[T any] struct {
Code int `json:"biz_code"`
Message string `json:"biz_msg"`
Data T `json:"biz_data"`
}
// Client is a configurable client for the DeepSeek chat API.
//
// A client may be reused for multiple requests. Callers must not mutate its
// configuration concurrently with active requests.
type Client struct {
httpClient *http.Client
token string
baseURL string
deviceIDProvider DeviceIDProvider
}
// NewClient creates a client with the default endpoint and a cookie jar.
func NewClient() *Client {
jar, err := cookiejar.New(nil)
if err != nil {
log.Println(err)
}
return &Client{httpClient: &http.Client{Jar: jar}, baseURL: baseURL}
}
// SetToken sets the bearer token used by subsequent requests.
func (api *Client) SetToken(token string) *Client {
api.token = token
return api
}
// SetDeviceIDProvider sets the source used to obtain login device IDs.
// Passing nil restores the random provider behavior used by Login.
func (api *Client) SetDeviceIDProvider(p DeviceIDProvider) *Client {
api.deviceIDProvider = p
return api
}
// SetHTTPClient replaces the underlying HTTP client when client is non-nil.
func (api *Client) SetHTTPClient(client *http.Client) *Client {
if client != nil {
api.httpClient = client
}
return api
}
// SetBaseURL replaces the API base URL when url is non-empty.
func (api *Client) SetBaseURL(url string) *Client {
if url != "" {
api.baseURL = url
}
return api
}
// Request describes an HTTP request with parameter type P and response data
// type R.
type Request[P, R any] struct {
params P
method string
path string
powAnswer string
}
// NewRequest creates a typed API request.
func NewRequest[R, P any](method, path string, params P) *Request[P, R] {
return &Request[P, R]{params: params, method: method, path: path}
}
func (r *Request[P, R]) marshallRequest() ([]byte, error) {
data, err := json.Marshal(r.params)
if err != nil {
return nil, err
}
return data, err
}
func (r *Request[P, R]) unmarshallResponse(resp *http.Response) (Response[R], error) {
var zero Response[R]
data, err := io.ReadAll(resp.Body)
if err != nil {
return zero, err
}
err = json.Unmarshal(data, &zero)
if err != nil {
return zero, err
}
if zero.Code != 0 {
return zero, fmt.Errorf("unknown error: %s (%d)", zero.Message, zero.Code)
}
return zero, err
}
func (r *Request[P, R]) unmarshallBizResponse(resp *http.Response) (Response[BizResponse[R]], error) {
var zero Response[BizResponse[R]]
data, err := io.ReadAll(resp.Body)
if err != nil {
return zero, err
}
err = json.Unmarshal(data, &zero)
if err != nil {
return zero, err
}
if zero.Code != 0 {
return zero, fmt.Errorf("unknown error: %s (%d)", zero.Message, zero.Code)
}
if zero.Data.Code != 0 {
return zero, fmt.Errorf("unknown error: %s (%d)", zero.Data.Message, zero.Data.Code)
}
return zero, err
}
// SolvePow requests and solves a proof-of-work challenge for r.
func (r *Request[P, R]) SolvePow(ctx context.Context, api *Client) error {
res, err := api.CreatePowChallengeWithContext(ctx)
if err != nil {
return err
}
data, err := res.Challenge.ToBase64()
if err != nil {
return err
}
r.powAnswer = string(data)
return nil
}
// DoWithContext sends r using api and returns the raw HTTP response.
// The caller owns and must close the response body.
func (r *Request[P, R]) DoWithContext(ctx context.Context, api *Client) (*http.Response, error) {
data, err := r.marshallRequest()
if err != nil {
return nil, err
}
req, err := http.NewRequestWithContext(ctx, r.method, api.baseURL+"/"+r.path, bytes.NewBuffer(data))
if err != nil {
return nil, err
}
log.Printf(
"request: method=%s scheme=%s host=%s path=%s rawQuery=%s",
req.Method,
req.URL.Scheme,
req.URL.Host,
req.URL.Path,
req.URL.RawQuery,
)
headers := getHeaders()
if r.powAnswer != "" {
headers["x-ds-pow-response"] = r.powAnswer
}
if api.token != "" {
headers["authorization"] = "Bearer " + api.token
}
for k, v := range headers {
req.Header.Add(k, v)
}
resp, err := api.httpClient.Do(req)
if err != nil {
return nil, err
}
log.Printf("response: method=%s status=%s code=%d",
req.Method,
resp.Status,
resp.StatusCode,
)
return resp, err
}
// Do sends r using a background context.
// The caller owns and must close the response body.
func (r *Request[P, R]) Do(api *Client) (*http.Response, error) {
return r.DoWithContext(context.Background(), api)
}
func getHeaders() map[string]string {
return map[string]string{
"Content-Type": "application/json",
"Accept": "application/json",
"User-Agent": "DeepSeek/2 CFNetwork/1568.100.1 Darwin/24.0.0",
"x-client-platform": "ios",
"x-client-version": "2.0.4",
"x-client-bundle-id": "com.deepseek.chat",
"x-client-locale": "en_US",
"x-client-timezone-offset": "3600",
}
}
+123
View File
@@ -0,0 +1,123 @@
package deepseek
import (
"context"
"io"
"net/http"
"strings"
"testing"
)
type roundTripFunc func(*http.Request) (*http.Response, error)
func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) }
func testAPI(handler roundTripFunc) *Client {
return NewClient().
SetBaseURL("https://api.example.test").
SetHTTPClient(&http.Client{Transport: handler})
}
func testHTTPResponse(body string) *http.Response {
return &http.Response{
StatusCode: http.StatusOK,
Status: "200 OK",
Header: make(http.Header),
Body: io.NopCloser(strings.NewReader(body)),
}
}
func TestRequestUnmarshallBizResponse(t *testing.T) {
tests := []struct {
name string
body string
wantValue string
wantErr string
}{
{name: "success", body: `{"code":0,"data":{"biz_code":0,"biz_data":{"value":"ok"}}}`, wantValue: "ok"},
{name: "outer error", body: `{"code":401,"msg":"unauthorized"}`, wantErr: "unauthorized (401)"},
{name: "business error", body: `{"code":0,"data":{"biz_code":23,"biz_msg":"denied"}}`, wantErr: "denied (23)"},
{name: "invalid JSON", body: `{`, wantErr: "unexpected end of JSON input"},
}
type result struct {
Value string `json:"value"`
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := NewRequest[result](http.MethodGet, "test", NoParams)
resp := &http.Response{Body: io.NopCloser(strings.NewReader(tt.body))}
got, err := req.unmarshallBizResponse(resp)
if tt.wantErr != "" {
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("unmarshallBizResponse() error = %v, want containing %q", err, tt.wantErr)
}
return
}
if err != nil {
t.Fatalf("unmarshallBizResponse() error = %v", err)
}
if got.Data.Data.Value != tt.wantValue {
t.Fatalf("value = %q, want %q", got.Data.Data.Value, tt.wantValue)
}
})
}
}
func TestRequestDoWithContext(t *testing.T) {
client := testAPI(func(r *http.Request) (*http.Response, error) {
if r.Method != http.MethodPost {
t.Errorf("method = %q, want POST", r.Method)
}
if r.URL.Path != "/items" {
t.Errorf("path = %q, want /items", r.URL.Path)
}
if got := r.Header.Get("Authorization"); got != "Bearer test-token" {
t.Errorf("Authorization = %q", got)
}
if got := r.Header.Get("x-ds-pow-response"); got != "pow-answer" {
t.Errorf("x-ds-pow-response = %q", got)
}
if got := r.Header.Get("Content-Type"); got != "application/json" {
t.Errorf("Content-Type = %q", got)
}
body, err := io.ReadAll(r.Body)
if err != nil {
t.Errorf("read body: %v", err)
}
if got := string(body); got != `{"name":"value"}` {
t.Errorf("body = %s", got)
}
return testHTTPResponse(`{}`), nil
}).SetToken("test-token")
req := NewRequest[struct{}](http.MethodPost, "items", map[string]string{"name": "value"})
req.powAnswer = "pow-answer"
resp, err := req.DoWithContext(context.Background(), client)
if err != nil {
t.Fatalf("DoWithContext() error = %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want 200", resp.StatusCode)
}
}
func TestRequestDoWithCanceledContext(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel()
client := testAPI(func(r *http.Request) (*http.Response, error) {
if err := r.Context().Err(); err != context.Canceled {
t.Errorf("request context error = %v, want context canceled", err)
}
return nil, r.Context().Err()
})
req := NewRequest[struct{}](http.MethodGet, "test", NoParams)
_, err := req.DoWithContext(ctx, client)
if err == nil || !strings.Contains(err.Error(), context.Canceled.Error()) {
t.Fatalf("DoWithContext() error = %v, want context canceled", err)
}
}
+3
View File
@@ -0,0 +1,3 @@
// Package api provides a client for the DeepSeek chat HTTP API and helpers for
// decoding its server-sent event streams.
package deepseek
+3
View File
@@ -0,0 +1,3 @@
module deepseek
go 1.26.5
+4
View File
@@ -0,0 +1,4 @@
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
+198
View File
@@ -0,0 +1,198 @@
package deepseek
import (
"context"
"errors"
"io"
"net/http"
"strings"
)
// ChatSession describes a DeepSeek chat session.
type ChatSession struct {
ID string `json:"id"`
SeqID uint64 `json:"seq_id"`
Agent string `json:"agent"`
ModelType string `json:"model_type"`
InsertedAt float64 `json:"inserted_at"`
UpdatedAt float64 `json:"updated_at"`
}
// CreateChatRes is the data returned when a chat session is created.
type CreateChatRes struct {
ChatSession ChatSession `json:"chat_session"`
TTLSeconds uint64 `json:"ttl_seconds"`
}
// CreateChat creates a chat session using a background context.
func (api *Client) CreateChat() (ChatSession, error) {
return api.CreateChatWithContext(context.Background())
}
// CreateChatWithContext creates a chat session and honors ctx cancellation.
func (api *Client) CreateChatWithContext(ctx context.Context) (ChatSession, error) {
var zero ChatSession
req := NewRequest[CreateChatRes]("POST", "chat_session/create", NoParams)
resp, err := req.DoWithContext(ctx, api)
if err != nil {
return zero, err
}
defer func() {
_ = resp.Body.Close()
}()
data, err := req.unmarshallBizResponse(resp)
if err != nil {
return zero, err
}
return data.Data.Data.ChatSession, nil
}
// CompletionReq configures a chat completion request.
type CompletionReq struct {
ChatSessionID string `json:"chat_session_id"`
ParentMessageID *uint64 `json:"parent_message_id"`
ModelType ModelType `json:"model_type"`
promptBuilder strings.Builder
Prompt string `json:"prompt"`
RefFileIDs []string `json:"ref_file_ids"`
ThinkingEnabled bool `json:"thinking_enabled"`
SearchEnabled bool `json:"search_enabled"`
}
// NewCompletionReq creates a completion request for chatID using the default
// model type.
func NewCompletionReq(chatID string) *CompletionReq {
return &CompletionReq{
ChatSessionID: chatID, ModelType: ModelTypeDefault,
}
}
// SetParentMessageID sets the parent message for a continued conversation.
func (req *CompletionReq) SetParentMessageID(id uint64) *CompletionReq {
req.ParentMessageID = new(id)
return req
}
// SetModelType selects the model used for the completion.
func (req *CompletionReq) SetModelType(t ModelType) *CompletionReq {
req.ModelType = t
return req
}
// AddPrompt appends s to the prompt under construction.
func (req *CompletionReq) AddPrompt(s string) *CompletionReq {
_, _ = req.promptBuilder.WriteString(s)
return req
}
// BuildPrompt stores the accumulated prompt and resets the internal builder.
func (req *CompletionReq) BuildPrompt() *CompletionReq {
req.Prompt = req.promptBuilder.String()
req.promptBuilder = strings.Builder{}
return req
}
// SetPrompt replaces the request prompt.
func (req *CompletionReq) SetPrompt(prompt string) *CompletionReq {
req.Prompt = prompt
return req
}
// SetThinkingEnabled enables or disables model thinking output.
func (req *CompletionReq) SetThinkingEnabled(b bool) *CompletionReq {
req.ThinkingEnabled = b
return req
}
// SetSearchEnabled enables or disables web search for the completion.
func (req *CompletionReq) SetSearchEnabled(b bool) *CompletionReq {
req.SearchEnabled = b
return req
}
// ModelType identifies a DeepSeek chat model mode.
type ModelType string
const (
// ModelTypeDefault selects the default model mode.
ModelTypeDefault ModelType = "default"
// ModelTypeExpert selects the expert model mode.
ModelTypeExpert ModelType = "expert"
)
// Completion starts a streaming completion using a background context.
// The caller owns and must either close the response body or pass the response
// to ReadStream or ReadStreamAsString.
func (api *Client) Completion(body CompletionReq) (*http.Response, error) {
return api.CompletionWithContext(context.Background(), body)
}
// CompletionWithContext starts a streaming completion and honors ctx
// cancellation. The caller owns and must either close the response body or
// pass the response to ReadStream or ReadStreamAsString.
func (api *Client) CompletionWithContext(ctx context.Context, body CompletionReq) (*http.Response, error) {
req := NewRequest[CreatePowChallengeRes]("POST", "chat/completion", body)
err := req.SolvePow(ctx, api)
if err != nil {
return nil, err
}
resp, err := req.DoWithContext(ctx, api)
if err != nil {
return nil, err
}
return resp, nil
}
// ReadStream consumes and closes resp.Body, updates stream state, and calls
// onText for newly appended response text. A nil callback is allowed.
func (api *Client) ReadStream(ctx context.Context, resp *http.Response, onText func(string)) (*StreamState, error) {
defer resp.Body.Close()
reader := NewSSEReader(resp.Body)
state := &StreamState{}
var previousLength int
for {
event, err := reader.Next(ctx)
if errors.Is(err, io.EOF) {
break
}
if err != nil {
return nil, err
}
if err := state.Apply(event); err != nil {
return nil, err
}
current := state.Content.String()
if len(current) > previousLength {
chunk := current[previousLength:]
if onText != nil {
onText(chunk)
}
previousLength = len(current)
}
if event.Event == "close" {
break
}
}
return state, nil
}
// ReadStreamAsString consumes and closes resp.Body and returns the final
// response content.
func (api *Client) ReadStreamAsString(ctx context.Context, resp *http.Response) (string, error) {
state, err := api.ReadStream(ctx, resp, nil)
if err != nil {
return "", err
}
return state.Content.String(), nil
}
+194
View File
@@ -0,0 +1,194 @@
package deepseek
import (
"context"
"encoding/base64"
"encoding/json"
"io"
"net/http"
"reflect"
"strings"
"testing"
)
func TestCompletionReqBuilder(t *testing.T) {
req := NewCompletionReq("chat-id").
SetParentMessageID(42).
SetModelType(ModelTypeExpert).
AddPrompt("hello").
AddPrompt(" world").
BuildPrompt().
SetThinkingEnabled(true).
SetSearchEnabled(true)
req.RefFileIDs = []string{"file-id"}
if req.ChatSessionID != "chat-id" || req.ModelType != ModelTypeExpert || req.Prompt != "hello world" {
t.Fatalf("request = %#v", req)
}
if req.ParentMessageID == nil || *req.ParentMessageID != 42 {
t.Fatalf("parent message ID = %v", req.ParentMessageID)
}
if !req.ThinkingEnabled || !req.SearchEnabled || !reflect.DeepEqual(req.RefFileIDs, []string{"file-id"}) {
t.Fatalf("request options = %#v", req)
}
req.AddPrompt("new").BuildPrompt()
if req.Prompt != "new" {
t.Fatalf("rebuilt prompt = %q, want new", req.Prompt)
}
}
func TestCreateChatWithContext(t *testing.T) {
client := testAPI(func(r *http.Request) (*http.Response, error) {
if r.URL.Path != "/chat_session/create" {
t.Errorf("path = %q", r.URL.Path)
}
body, err := io.ReadAll(r.Body)
if err != nil {
t.Errorf("read body: %v", err)
}
if string(body) != `{}` {
t.Errorf("body = %s, want {}", body)
}
return testHTTPResponse(`{"code":0,"data":{"biz_code":0,"biz_data":{"chat_session":{"id":"chat-id","seq_id":7,"model_type":"default"},"ttl_seconds":60}}}`), nil
})
got, err := client.CreateChatWithContext(context.Background())
if err != nil {
t.Fatalf("CreateChatWithContext() error = %v", err)
}
if got.ID != "chat-id" || got.SeqID != 7 || got.ModelType != "default" {
t.Fatalf("session = %#v", got)
}
}
func TestCompletionWithContextPreservesRequestOptions(t *testing.T) {
const challenge = "2f90572ad390d758b5e55b3bb74f14722166388023b3b28876d056a358591197"
const salt = "2eeb8f3a703002bfca70"
const expectedAnswer = uint64(61830)
client := testAPI(func(r *http.Request) (*http.Response, error) {
switch r.URL.Path {
case "/chat/create_pow_challenge":
return testHTTPResponse(`{"code":0,"data":{"biz_code":0,"biz_data":{"challenge":{"algorithm":"DeepSeekHashV1","challenge":"` + challenge + `","salt":"` + salt + `","signature":"signature","difficulty":144000,"expire_at":1785483643587,"target_path":"/api/v0/chat/completion"}}}}`), nil
case "/chat/completion":
encoded := r.Header.Get("x-ds-pow-response")
raw, err := base64.URLEncoding.DecodeString(encoded)
if err != nil {
t.Errorf("decode PoW header: %v", err)
}
var proof struct {
Answer uint64 `json:"answer"`
Signature string `json:"signature"`
TargetPath string `json:"target_path"`
}
if err := json.Unmarshal(raw, &proof); err != nil {
t.Errorf("decode PoW JSON: %v", err)
}
if proof.Answer != expectedAnswer || proof.Signature != "signature" || proof.TargetPath != "/api/v0/chat/completion" {
t.Errorf("proof = %#v", proof)
}
var body CompletionReq
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Errorf("decode completion: %v", err)
}
if body.ChatSessionID != "chat-id" || body.ParentMessageID == nil || *body.ParentMessageID != 9 || body.ModelType != ModelTypeExpert || body.Prompt != "prompt" || !body.ThinkingEnabled || !body.SearchEnabled || !reflect.DeepEqual(body.RefFileIDs, []string{"file-id"}) {
t.Errorf("completion body = %#v", body)
}
resp := testHTTPResponse("event: close\ndata: {}\n\n")
resp.Header.Set("Content-Type", "text/event-stream")
return resp, nil
default:
t.Fatalf("unexpected path %q", r.URL.Path)
return nil, nil
}
})
parentID := uint64(9)
body := CompletionReq{
ChatSessionID: "chat-id",
ParentMessageID: &parentID,
ModelType: ModelTypeExpert,
Prompt: "prompt",
RefFileIDs: []string{"file-id"},
ThinkingEnabled: true,
SearchEnabled: true,
}
resp, err := client.CompletionWithContext(context.Background(), body)
if err != nil {
t.Fatalf("CompletionWithContext() error = %v", err)
}
defer resp.Body.Close()
}
type trackingReadCloser struct {
io.Reader
closed bool
}
func (r *trackingReadCloser) Close() error {
r.closed = true
return nil
}
func TestReadStream(t *testing.T) {
stream := strings.Join([]string{
`event: ready`,
`data: {"request_message_id":1,"response_message_id":2,"model_type":"default"}`,
``,
`data: {"p":"response/content","o":"APPEND","v":"hel"}`,
``,
`data: {"v":"lo"}`,
``,
`event: close`,
`data: {}`,
``,
}, "\n")
body := &trackingReadCloser{Reader: strings.NewReader(stream)}
resp := &http.Response{Body: body}
var chunks []string
state, err := NewClient().ReadStream(context.Background(), resp, func(s string) {
chunks = append(chunks, s)
})
if err != nil {
t.Fatalf("ReadStream() error = %v", err)
}
if !body.closed {
t.Fatal("response body was not closed")
}
if got := state.Content.String(); got != "hello" {
t.Fatalf("content = %q, want hello", got)
}
if !reflect.DeepEqual(chunks, []string{"hel", "lo"}) {
t.Fatalf("chunks = %#v", chunks)
}
if state.RequestMessageID != 1 || state.ResponseMessageID != 2 || !state.Closed {
t.Fatalf("state = %#v", state)
}
}
func TestReadStreamAsStringReturnsFinalSetValue(t *testing.T) {
stream := strings.Join([]string{
`data: {"p":"response/content","o":"APPEND","v":"obsolete value"}`,
``,
`data: {"p":"response/content","o":"SET","v":"final"}`,
``,
`event: close`,
`data: {}`,
``,
}, "\n")
body := &trackingReadCloser{Reader: strings.NewReader(stream)}
got, err := NewClient().ReadStreamAsString(context.Background(), &http.Response{Body: body})
if err != nil {
t.Fatalf("ReadStreamAsString() error = %v", err)
}
if got != "final" {
t.Fatalf("ReadStreamAsString() = %q, want final", got)
}
if !body.closed {
t.Fatal("response body was not closed")
}
}
+102
View File
@@ -0,0 +1,102 @@
package deepseek
import (
"context"
"deepseek/pow"
"encoding/base64"
"encoding/json"
"log"
)
// CreatePowChallengeReq is the request body used to obtain a PoW challenge.
type CreatePowChallengeReq struct {
TargetPath string `json:"target_path"`
}
// CreatePowChallengeRes contains a server-issued PoW challenge.
type CreatePowChallengeRes struct {
Challenge PowChallenge `json:"challenge"`
}
// PowChallenge describes a signed DeepSeek proof-of-work challenge.
type PowChallenge struct {
Algorithm string `json:"algorithm"`
Challenge string `json:"challenge"`
Salt string `json:"salt"`
Signature string `json:"signature"`
Difficulty uint64 `json:"difficulty"`
ExpireAt uint64 `json:"expire_at"`
ExpireAfter int `json:"expire_after"`
TargetPath string `json:"target_path"`
}
func (c PowChallenge) solve() (uint64, error) {
answer, err := pow.Challenge{
Algorithm: c.Algorithm,
Challenge: c.Challenge,
Salt: c.Salt,
Difficulty: c.Difficulty,
ExpireAt: c.ExpireAt,
}.Solve()
if err != nil {
return 0, err
}
log.Printf("PoW answer: %d", answer)
return answer, nil
}
// ToBase64 solves c and returns the URL-safe base64-encoded JSON response sent
// in the x-ds-pow-response header.
func (c PowChallenge) ToBase64() ([]byte, error) {
type s struct {
Algorithm string `json:"algorithm"`
Challenge string `json:"challenge"`
Salt string `json:"salt"`
Answer uint64 `json:"answer"`
Signature string `json:"signature"`
TargetPath string `json:"target_path"`
}
answer, err := c.solve()
if err != nil {
return nil, err
}
data, err := json.Marshal(s{
Algorithm: c.Algorithm,
Challenge: c.Challenge,
Salt: c.Salt,
Answer: answer,
Signature: c.Signature,
TargetPath: c.TargetPath,
})
if err != nil {
return nil, err
}
return []byte(base64.URLEncoding.EncodeToString(data)), nil
}
// CreatePowChallenge requests a PoW challenge using a background context.
func (api *Client) CreatePowChallenge() (CreatePowChallengeRes, error) {
return api.CreatePowChallengeWithContext(context.Background())
}
// CreatePowChallengeWithContext requests a PoW challenge and honors ctx
// cancellation.
func (api *Client) CreatePowChallengeWithContext(ctx context.Context) (CreatePowChallengeRes, error) {
var zero CreatePowChallengeRes
body := CreatePowChallengeReq{TargetPath: "/api/v0/chat/completion"}
req := NewRequest[CreatePowChallengeRes]("POST", "chat/create_pow_challenge", body)
resp, err := req.DoWithContext(ctx, api)
if err != nil {
return zero, err
}
defer resp.Body.Close()
response, err := req.unmarshallBizResponse(resp)
if err != nil {
return zero, err
}
return response.Data.Data, nil
}
+3
View File
@@ -0,0 +1,3 @@
// Package pow solves DeepSeekHashV1 proof-of-work challenges using a CGO-backed
// AVX2 implementation.
package pow
+776
View File
@@ -0,0 +1,776 @@
// Originally by: https://github.com/boykopovar/aiodeepseek
#include "pow.h"
#include <algorithm>
#include <atomic>
#include <cstdint>
#include <cstring>
#include <exception>
#include <immintrin.h>
#include <string>
#include <thread>
#include <vector>
static constexpr size_t RATE = 136;
static constexpr size_t RATE_W = RATE / 8;
static constexpr size_t MAX_NONCE_DEC = 20;
/*
* Динамическая область должна учитывать:
*
* base_len % 8
* + nonce до 20 символов
* + байт padding 0x06
*
* 24 байт в исходном варианте может оказаться недостаточно.
*/
static constexpr size_t DYN_SIZE = 32;
static const uint64_t RC[24] = {
0x0000000000000001ULL,
0x0000000000008082ULL,
0x800000000000808AULL,
0x8000000080008000ULL,
0x000000000000808BULL,
0x0000000080000001ULL,
0x8000000080008081ULL,
0x8000000000008009ULL,
0x000000000000008AULL,
0x0000000000000088ULL,
0x0000000080008009ULL,
0x000000008000000AULL,
0x000000008000808BULL,
0x800000000000008BULL,
0x8000000000008089ULL,
0x8000000000008003ULL,
0x8000000000008002ULL,
0x8000000000000080ULL,
0x000000000000800AULL,
0x800000008000000AULL,
0x8000000080008081ULL,
0x8000000000008080ULL,
0x0000000080000001ULL,
0x8000000080008008ULL,
};
static constexpr char DIGITS_00_99[] =
"00010203040506070809"
"10111213141516171819"
"20212223242526272829"
"30313233343536373839"
"40414243444546474849"
"50515253545556575859"
"60616263646566676869"
"70717273747576777879"
"80818283848586878889"
"90919293949596979899";
static inline int hex_nibble(char c) {
if (c >= '0' && c <= '9') {
return c - '0';
}
if (c >= 'a' && c <= 'f') {
return c - 'a' + 10;
}
if (c >= 'A' && c <= 'F') {
return c - 'A' + 10;
}
return -1;
}
static bool valid_hex_digest(const std::string& value) {
if (value.size() != 64) {
return false;
}
for (char c : value) {
if (hex_nibble(c) < 0) {
return false;
}
}
return true;
}
static inline int fast_u64_to_dec(uint64_t value, char* out) {
char tmp[20];
char* p = tmp + sizeof(tmp);
while (value >= 100) {
const uint64_t quotient = value / 100;
const uint64_t remainder = value - quotient * 100;
p -= 2;
p[0] = DIGITS_00_99[remainder * 2];
p[1] = DIGITS_00_99[remainder * 2 + 1];
value = quotient;
}
if (value < 10) {
*--p = static_cast<char>('0' + value);
} else {
p -= 2;
p[0] = DIGITS_00_99[value * 2];
p[1] = DIGITS_00_99[value * 2 + 1];
}
const int len = static_cast<int>(
tmp + sizeof(tmp) - p
);
std::memcpy(
out,
p,
static_cast<size_t>(len)
);
return len;
}
struct PowCtx {
uint64_t static_state[25];
uint64_t target4[4];
uint8_t dyn_tpl[DYN_SIZE];
size_t base_len;
size_t dyn_word_start;
size_t dyn_word_count;
size_t dyn_offset;
};
static PowCtx build_ctx(
const std::string& base,
const std::string& challenge_hex
) {
PowCtx ctx = {};
ctx.base_len = base.size();
for (int word = 0; word < 4; ++word) {
uint64_t value = 0;
for (int byte = 0; byte < 8; ++byte) {
const int position = (word * 8 + byte) * 2;
const auto high = static_cast<uint8_t>(
hex_nibble(challenge_hex[position])
);
const auto low = static_cast<uint8_t>(
hex_nibble(challenge_hex[position + 1])
);
const uint8_t decoded = static_cast<uint8_t>(
(high << 4) | low
);
value |= static_cast<uint64_t>(decoded)
<< (byte * 8);
}
ctx.target4[word] = value;
}
ctx.dyn_word_start = ctx.base_len / 8;
ctx.dyn_offset = ctx.base_len % 8;
const size_t dyn_last =
ctx.base_len +
MAX_NONCE_DEC +
1;
const size_t dyn_word_end =
dyn_last / 8 + 1;
ctx.dyn_word_count =
dyn_word_end - ctx.dyn_word_start;
if (
ctx.dyn_word_start +
ctx.dyn_word_count >
RATE_W
) {
ctx.dyn_word_count =
RATE_W - ctx.dyn_word_start;
}
std::memset(
ctx.dyn_tpl,
0,
sizeof(ctx.dyn_tpl)
);
const size_t dyn_byte_start =
ctx.dyn_word_start * 8;
if (ctx.base_len > dyn_byte_start) {
const size_t prefix_len =
ctx.base_len - dyn_byte_start;
std::memcpy(
ctx.dyn_tpl,
base.data() + dyn_byte_start,
prefix_len
);
}
alignas(8) uint8_t block[RATE] = {};
std::memcpy(
block,
base.data(),
ctx.base_len
);
/*
* Последний SHA3 padding-бит.
* Первый байт padding 0x06 добавляется после nonce.
*/
block[RATE - 1] = 0x80;
std::memset(
ctx.static_state,
0,
sizeof(ctx.static_state)
);
for (size_t word = 0; word < RATE_W; ++word) {
const bool dynamic =
word >= ctx.dyn_word_start &&
word <
ctx.dyn_word_start +
ctx.dyn_word_count;
if (dynamic) {
continue;
}
uint64_t value;
std::memcpy(
&value,
&block[word * 8],
sizeof(value)
);
ctx.static_state[word] ^= value;
}
return ctx;
}
#define R4(x, n) \
_mm256_or_si256( \
_mm256_slli_epi64((x), (n)), \
_mm256_srli_epi64((x), 64 - (n)) \
)
#define KF_ROUND4(i) \
do { \
C[0] = _mm256_xor_si256( \
_mm256_xor_si256( \
_mm256_xor_si256( \
_mm256_xor_si256(A[0], A[5]), \
A[10] \
), \
A[15] \
), \
A[20] \
); \
C[1] = _mm256_xor_si256( \
_mm256_xor_si256( \
_mm256_xor_si256( \
_mm256_xor_si256(A[1], A[6]), \
A[11] \
), \
A[16] \
), \
A[21] \
); \
C[2] = _mm256_xor_si256( \
_mm256_xor_si256( \
_mm256_xor_si256( \
_mm256_xor_si256(A[2], A[7]), \
A[12] \
), \
A[17] \
), \
A[22] \
); \
C[3] = _mm256_xor_si256( \
_mm256_xor_si256( \
_mm256_xor_si256( \
_mm256_xor_si256(A[3], A[8]), \
A[13] \
), \
A[18] \
), \
A[23] \
); \
C[4] = _mm256_xor_si256( \
_mm256_xor_si256( \
_mm256_xor_si256( \
_mm256_xor_si256(A[4], A[9]), \
A[14] \
), \
A[19] \
), \
A[24] \
); \
\
D[0] = _mm256_xor_si256(C[4], R4(C[1], 1)); \
D[1] = _mm256_xor_si256(C[0], R4(C[2], 1)); \
D[2] = _mm256_xor_si256(C[1], R4(C[3], 1)); \
D[3] = _mm256_xor_si256(C[2], R4(C[4], 1)); \
D[4] = _mm256_xor_si256(C[3], R4(C[0], 1)); \
\
for (int j = 0; j < 25; ++j) { \
A[j] = _mm256_xor_si256(A[j], D[j % 5]); \
} \
\
B[0] = A[0]; \
B[1] = R4(A[6], 44); \
B[2] = R4(A[12], 43); \
B[3] = R4(A[18], 21); \
B[4] = R4(A[24], 14); \
B[5] = R4(A[3], 28); \
B[6] = R4(A[9], 20); \
B[7] = R4(A[10], 3); \
B[8] = R4(A[16], 45); \
B[9] = R4(A[22], 61); \
B[10] = R4(A[1], 1); \
B[11] = R4(A[7], 6); \
B[12] = R4(A[13], 25); \
B[13] = R4(A[19], 8); \
B[14] = R4(A[20], 18); \
B[15] = R4(A[4], 27); \
B[16] = R4(A[5], 36); \
B[17] = R4(A[11], 10); \
B[18] = R4(A[17], 15); \
B[19] = R4(A[23], 56); \
B[20] = R4(A[2], 62); \
B[21] = R4(A[8], 55); \
B[22] = R4(A[14], 39); \
B[23] = R4(A[15], 41); \
B[24] = R4(A[21], 2); \
\
A[0] = _mm256_xor_si256(B[0], _mm256_andnot_si256(B[1], B[2])); \
A[1] = _mm256_xor_si256(B[1], _mm256_andnot_si256(B[2], B[3])); \
A[2] = _mm256_xor_si256(B[2], _mm256_andnot_si256(B[3], B[4])); \
A[3] = _mm256_xor_si256(B[3], _mm256_andnot_si256(B[4], B[0])); \
A[4] = _mm256_xor_si256(B[4], _mm256_andnot_si256(B[0], B[1])); \
\
A[5] = _mm256_xor_si256(B[5], _mm256_andnot_si256(B[6], B[7])); \
A[6] = _mm256_xor_si256(B[6], _mm256_andnot_si256(B[7], B[8])); \
A[7] = _mm256_xor_si256(B[7], _mm256_andnot_si256(B[8], B[9])); \
A[8] = _mm256_xor_si256(B[8], _mm256_andnot_si256(B[9], B[5])); \
A[9] = _mm256_xor_si256(B[9], _mm256_andnot_si256(B[5], B[6])); \
\
A[10] = _mm256_xor_si256(B[10], _mm256_andnot_si256(B[11], B[12])); \
A[11] = _mm256_xor_si256(B[11], _mm256_andnot_si256(B[12], B[13])); \
A[12] = _mm256_xor_si256(B[12], _mm256_andnot_si256(B[13], B[14])); \
A[13] = _mm256_xor_si256(B[13], _mm256_andnot_si256(B[14], B[10])); \
A[14] = _mm256_xor_si256(B[14], _mm256_andnot_si256(B[10], B[11])); \
\
A[15] = _mm256_xor_si256(B[15], _mm256_andnot_si256(B[16], B[17])); \
A[16] = _mm256_xor_si256(B[16], _mm256_andnot_si256(B[17], B[18])); \
A[17] = _mm256_xor_si256(B[17], _mm256_andnot_si256(B[18], B[19])); \
A[18] = _mm256_xor_si256(B[18], _mm256_andnot_si256(B[19], B[15])); \
A[19] = _mm256_xor_si256(B[19], _mm256_andnot_si256(B[15], B[16])); \
\
A[20] = _mm256_xor_si256(B[20], _mm256_andnot_si256(B[21], B[22])); \
A[21] = _mm256_xor_si256(B[21], _mm256_andnot_si256(B[22], B[23])); \
A[22] = _mm256_xor_si256(B[22], _mm256_andnot_si256(B[23], B[24])); \
A[23] = _mm256_xor_si256(B[23], _mm256_andnot_si256(B[24], B[20])); \
A[24] = _mm256_xor_si256(B[24], _mm256_andnot_si256(B[20], B[21])); \
\
A[0] = _mm256_xor_si256( \
A[0], \
_mm256_set1_epi64x(static_cast<int64_t>(RC[i])) \
); \
} while (0)
static void keccak_f_4way(__m256i A[25]) {
__m256i C[5];
__m256i D[5];
__m256i B[25];
/*
* DeepSeekHashV1 использует раунды 1..23.
* Стандартный Keccak использовал бы также RC[0].
*/
KF_ROUND4(1);
KF_ROUND4(2);
KF_ROUND4(3);
KF_ROUND4(4);
KF_ROUND4(5);
KF_ROUND4(6);
KF_ROUND4(7);
KF_ROUND4(8);
KF_ROUND4(9);
KF_ROUND4(10);
KF_ROUND4(11);
KF_ROUND4(12);
KF_ROUND4(13);
KF_ROUND4(14);
KF_ROUND4(15);
KF_ROUND4(16);
KF_ROUND4(17);
KF_ROUND4(18);
KF_ROUND4(19);
KF_ROUND4(20);
KF_ROUND4(21);
KF_ROUND4(22);
KF_ROUND4(23);
}
#undef KF_ROUND4
#undef R4
static void worker_avx2(
const PowCtx& ctx,
uint64_t from,
uint64_t to,
std::atomic<int64_t>& result
) {
const __m256i target0 = _mm256_set1_epi64x(
static_cast<int64_t>(ctx.target4[0])
);
const __m256i target1 = _mm256_set1_epi64x(
static_cast<int64_t>(ctx.target4[1])
);
const __m256i target2 = _mm256_set1_epi64x(
static_cast<int64_t>(ctx.target4[2])
);
const __m256i target3 = _mm256_set1_epi64x(
static_cast<int64_t>(ctx.target4[3])
);
alignas(32) uint8_t dynamic[4][DYN_SIZE];
char nonce_buffer[4][21];
for (uint64_t nonce = from; nonce < to; nonce += 4) {
if (
(nonce & 0xFFFu) == 0 &&
result.load(std::memory_order_relaxed) >= 0
) {
return;
}
uint64_t lanes = to - nonce;
if (lanes > 4) {
lanes = 4;
}
for (uint64_t lane = 0; lane < lanes; ++lane) {
std::memcpy(
dynamic[lane],
ctx.dyn_tpl,
sizeof(ctx.dyn_tpl)
);
const int nonce_len = fast_u64_to_dec(
nonce + lane,
nonce_buffer[lane]
);
const size_t padding_position =
ctx.dyn_offset +
static_cast<size_t>(nonce_len);
if (padding_position >= DYN_SIZE) {
return;
}
std::memcpy(
dynamic[lane] + ctx.dyn_offset,
nonce_buffer[lane],
static_cast<size_t>(nonce_len)
);
dynamic[lane][padding_position] = 0x06;
}
for (uint64_t lane = lanes; lane < 4; ++lane) {
std::memcpy(
dynamic[lane],
dynamic[0],
sizeof(dynamic[0])
);
}
alignas(32) __m256i state[25];
for (int word = 0; word < 25; ++word) {
state[word] = _mm256_set1_epi64x(
static_cast<int64_t>(
ctx.static_state[word]
)
);
}
for (
size_t word = 0;
word < ctx.dyn_word_count;
++word
) {
uint64_t value0;
uint64_t value1;
uint64_t value2;
uint64_t value3;
std::memcpy(
&value0,
&dynamic[0][word * 8],
sizeof(value0)
);
std::memcpy(
&value1,
&dynamic[1][word * 8],
sizeof(value1)
);
std::memcpy(
&value2,
&dynamic[2][word * 8],
sizeof(value2)
);
std::memcpy(
&value3,
&dynamic[3][word * 8],
sizeof(value3)
);
const __m256i values = _mm256_set_epi64x(
static_cast<int64_t>(value3),
static_cast<int64_t>(value2),
static_cast<int64_t>(value1),
static_cast<int64_t>(value0)
);
state[
ctx.dyn_word_start + word
] = _mm256_xor_si256(
state[ctx.dyn_word_start + word],
values
);
}
keccak_f_4way(state);
int mask = _mm256_movemask_pd(
_mm256_castsi256_pd(
_mm256_cmpeq_epi64(
state[0],
target0
)
)
);
if (!mask) {
continue;
}
mask &= _mm256_movemask_pd(
_mm256_castsi256_pd(
_mm256_cmpeq_epi64(
state[1],
target1
)
)
);
if (!mask) {
continue;
}
mask &= _mm256_movemask_pd(
_mm256_castsi256_pd(
_mm256_cmpeq_epi64(
state[2],
target2
)
)
);
if (!mask) {
continue;
}
mask &= _mm256_movemask_pd(
_mm256_castsi256_pd(
_mm256_cmpeq_epi64(
state[3],
target3
)
)
);
if (!mask) {
continue;
}
mask &= static_cast<int>(
(1u << lanes) - 1u
);
for (
int lane = 0;
lane < static_cast<int>(lanes);
++lane
) {
if (!(mask & (1 << lane))) {
continue;
}
int64_t expected = -1;
result.compare_exchange_strong(
expected,
static_cast<int64_t>(nonce) + lane,
std::memory_order_relaxed
);
return;
}
}
}
static int64_t solve_internal(
const std::string& base,
const std::string& challenge_hex,
int64_t difficulty
) {
if (!valid_hex_digest(challenge_hex)) {
return -3;
}
if (difficulty <= 0) {
return -3;
}
/*
* Solver рассчитан на один SHA3-256 rate block.
*/
if (base.size() > RATE - MAX_NONCE_DEC - 1) {
return -3;
}
PowCtx ctx = build_ctx(
base,
challenge_hex
);
std::atomic<int64_t> result{-1};
unsigned thread_count =
std::thread::hardware_concurrency();
if (thread_count == 0) {
thread_count = 1;
}
if (
static_cast<int64_t>(thread_count) >
difficulty
) {
thread_count =
static_cast<unsigned>(difficulty);
}
const uint64_t unsigned_difficulty =
static_cast<uint64_t>(difficulty);
const uint64_t chunk =
(
unsigned_difficulty +
thread_count -
1
) / thread_count;
std::vector<std::thread> threads;
threads.reserve(thread_count);
for (
unsigned thread = 0;
thread < thread_count;
++thread
) {
const uint64_t from =
static_cast<uint64_t>(thread) *
chunk;
const uint64_t to = std::min(
from + chunk,
unsigned_difficulty
);
if (from >= to) {
break;
}
threads.emplace_back(
worker_avx2,
std::cref(ctx),
from,
to,
std::ref(result)
);
}
for (auto& thread : threads) {
thread.join();
}
return result.load(
std::memory_order_relaxed
);
}
extern "C" int64_t deepseek_pow_solve(
const char* base,
size_t base_len,
const char* challenge_hex,
size_t challenge_hex_len,
int64_t difficulty
) {
if (
base == nullptr ||
challenge_hex == nullptr
) {
return -2;
}
try {
const std::string base_string(
base,
base_len
);
const std::string challenge_string(
challenge_hex,
challenge_hex_len
);
return solve_internal(
base_string,
challenge_string,
difficulty
);
} catch (const std::exception&) {
return -4;
} catch (...) {
return -4;
}
}
+141
View File
@@ -0,0 +1,141 @@
package pow
/*
#cgo CXXFLAGS: -std=c++17 -O3 -mavx2 -pthread
#cgo LDFLAGS: -lstdc++ -pthread
#include <stdlib.h>
#include "pow.h"
*/
import "C"
import (
"errors"
"fmt"
"strconv"
"unsafe"
)
var (
// ErrNotFound indicates that no nonce satisfies the challenge difficulty.
ErrNotFound = errors.New("PoW solution not found")
// ErrInvalidArgument indicates malformed or unsupported solver input.
ErrInvalidArgument = errors.New("invalid PoW argument")
// ErrInternalCppError indicates an exception in the native solver.
ErrInternalCppError = errors.New("internal C++ solver error")
)
// Challenge contains the inputs required to solve a DeepSeekHashV1 challenge.
type Challenge struct {
Algorithm string `json:"algorithm"`
Challenge string `json:"challenge"`
Salt string `json:"salt"`
Difficulty uint64 `json:"difficulty"`
ExpireAt uint64 `json:"expire_at"`
Signature string `json:"signature"`
TargetPath string `json:"target_path"`
}
// Solve validates and solves c, returning a nonce smaller than Difficulty.
func (c Challenge) Solve() (uint64, error) {
if c.Algorithm != "" && c.Algorithm != "DeepSeekHashV1" {
return 0, fmt.Errorf(
"unsupported PoW algorithm %q",
c.Algorithm,
)
}
if len(c.Challenge) != 64 {
return 0, fmt.Errorf(
"%w: challenge must contain 64 hexadecimal characters",
ErrInvalidArgument,
)
}
if c.Salt == "" {
return 0, fmt.Errorf(
"%w: salt is empty",
ErrInvalidArgument,
)
}
if c.Difficulty <= 0 {
return 0, fmt.Errorf(
"%w: difficulty must be positive",
ErrInvalidArgument,
)
}
base := c.Salt +
"_" +
strconv.FormatUint(c.ExpireAt, 10) +
"_"
return Solve(base, c.Challenge, c.Difficulty)
}
// Solve searches the range [0, difficulty) for a nonce whose DeepSeekHashV1
// digest equals challengeHex.
func Solve(
base string,
challengeHex string,
difficulty uint64,
) (uint64, error) {
if base == "" {
return 0, fmt.Errorf(
"%w: base is empty",
ErrInvalidArgument,
)
}
if len(challengeHex) != 64 {
return 0, fmt.Errorf(
"%w: challenge must contain 64 hexadecimal characters",
ErrInvalidArgument,
)
}
if difficulty <= 0 {
return 0, fmt.Errorf(
"%w: difficulty must be positive",
ErrInvalidArgument,
)
}
baseBytes := []byte(base)
challengeBytes := []byte(challengeHex)
result := C.deepseek_pow_solve(
(*C.char)(unsafe.Pointer(&baseBytes[0])),
C.size_t(len(baseBytes)),
(*C.char)(unsafe.Pointer(&challengeBytes[0])),
C.size_t(len(challengeBytes)),
C.int64_t(difficulty),
)
switch {
case result >= 0:
return uint64(result), nil
case result == -1:
return 0, ErrNotFound
case result == -2:
return 0, fmt.Errorf(
"%w: null pointer passed to C++",
ErrInvalidArgument,
)
case result == -3:
return 0, ErrInvalidArgument
case result == -4:
return 0, ErrInternalCppError
default:
return 0, fmt.Errorf(
"unexpected C++ solver result: %d",
int64(result),
)
}
}
+32
View File
@@ -0,0 +1,32 @@
#ifndef DEEPSEEK_POW_H
#define DEEPSEEK_POW_H
#include <stddef.h>
#include <stdint.h>
#ifdef __cplusplus
extern "C" {
#endif
/*
* Возвращает:
*
* >= 0 — найденный nonce
* -1 — решение не найдено
* -2 — передан NULL
* -3 — некорректные аргументы
* -4 — внутреннее исключение C++
*/
int64_t deepseek_pow_solve(
const char* base,
size_t base_len,
const char* challenge_hex,
size_t challenge_hex_len,
int64_t difficulty
);
#ifdef __cplusplus
}
#endif
#endif
+88
View File
@@ -0,0 +1,88 @@
package pow
import (
"errors"
"strings"
"testing"
)
func TestKnownChallenge(t *testing.T) {
challenge := Challenge{
Algorithm: "DeepSeekHashV1",
Challenge: "2f90572ad390d758b5e55b3bb74f14722166388023b3b28876d056a358591197",
Salt: "2eeb8f3a703002bfca70",
Difficulty: 144000,
ExpireAt: 1785483643587,
}
answer, err := challenge.Solve()
if err != nil {
t.Fatalf("Solve(): %v", err)
}
t.Logf("answer=%d", answer)
/*
* Для этого challenge ожидается 61830.
*/
const expected uint64 = 61830
if answer != expected {
t.Fatalf(
"unexpected answer: got %d, want %d",
answer,
expected,
)
}
}
func TestInvalidChallenge(t *testing.T) {
validDigest := "2f90572ad390d758b5e55b3bb74f14722166388023b3b28876d056a358591197"
tests := []struct {
name string
base string
challenge string
difficulty uint64
}{
{name: "empty base", challenge: validDigest, difficulty: 1},
{name: "short digest", base: "salt_123_", challenge: "not-hex", difficulty: 1},
{name: "non-hex digest", base: "salt_123_", challenge: strings.Repeat("z", 64), difficulty: 1},
{name: "zero difficulty", base: "salt_123_", challenge: validDigest},
{name: "base exceeds one block", base: strings.Repeat("x", 116), challenge: validDigest, difficulty: 1},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := Solve(tt.base, tt.challenge, tt.difficulty)
if !errors.Is(err, ErrInvalidArgument) {
t.Fatalf("Solve() error = %v, want ErrInvalidArgument", err)
}
})
}
}
func TestChallengeValidation(t *testing.T) {
validDigest := "2f90572ad390d758b5e55b3bb74f14722166388023b3b28876d056a358591197"
tests := []struct {
name string
challenge Challenge
wantMatch bool
}{
{name: "unsupported algorithm", challenge: Challenge{Algorithm: "other", Challenge: validDigest, Salt: "salt", Difficulty: 1}},
{name: "invalid digest", challenge: Challenge{Challenge: "short", Salt: "salt", Difficulty: 1}, wantMatch: true},
{name: "empty salt", challenge: Challenge{Challenge: validDigest, Difficulty: 1}, wantMatch: true},
{name: "zero difficulty", challenge: Challenge{Challenge: validDigest, Salt: "salt"}, wantMatch: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := tt.challenge.Solve()
if err == nil {
t.Fatal("Challenge.Solve() error = nil")
}
if tt.wantMatch && !errors.Is(err, ErrInvalidArgument) {
t.Fatalf("Challenge.Solve() error = %v, want ErrInvalidArgument", err)
}
})
}
}
+20
View File
@@ -0,0 +1,20 @@
package deepseek
import (
"errors"
"testing"
deeppow "dsp/pow"
)
func TestPowChallengeToBase64PropagatesSolverError(t *testing.T) {
_, err := (PowChallenge{
Algorithm: "DeepSeekHashV1",
Challenge: "invalid",
Salt: "salt",
Difficulty: 1,
}).ToBase64()
if !errors.Is(err, deeppow.ErrInvalidArgument) {
t.Fatalf("ToBase64() error = %v, want ErrInvalidArgument", err)
}
}
+582
View File
@@ -0,0 +1,582 @@
package deepseek
import (
"bufio"
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"strings"
)
// SSEEvent is one event decoded from a server-sent event stream.
type SSEEvent struct {
ID string
Event string
Data []byte
Retry string
}
// SSEReader parses server-sent events from an input stream.
type SSEReader struct {
reader *bufio.Reader
}
// NewSSEReader creates an SSE reader for r.
func NewSSEReader(r io.Reader) *SSEReader {
return &SSEReader{
reader: bufio.NewReaderSize(r, 64*1024),
}
}
// Next reads and returns the next SSE event. It returns io.EOF when the input
// ends without another event and checks ctx between blocking reads.
func (r *SSEReader) Next(ctx context.Context) (SSEEvent, error) {
var event SSEEvent
var dataLines []string
for {
if err := ctx.Err(); err != nil {
return SSEEvent{}, err
}
line, err := r.reader.ReadString('\n')
if err != nil && !errors.Is(err, io.EOF) {
return SSEEvent{}, fmt.Errorf(
"read SSE stream: %w",
err,
)
}
line = strings.TrimSuffix(line, "\n")
line = strings.TrimSuffix(line, "\r")
if line == "" {
if len(dataLines) == 0 &&
event.ID == "" &&
event.Event == "" &&
event.Retry == "" {
if errors.Is(err, io.EOF) {
return SSEEvent{}, io.EOF
}
continue
}
event.Data = []byte(
strings.Join(dataLines, "\n"),
)
return event, nil
}
// Строки-комментарии SSE.
if strings.HasPrefix(line, ":") {
if errors.Is(err, io.EOF) {
return SSEEvent{}, io.EOF
}
continue
}
field, value, found := strings.Cut(line, ":")
if found && strings.HasPrefix(value, " ") {
value = value[1:]
}
switch field {
case "event":
event.Event = value
case "data":
dataLines = append(dataLines, value)
case "id":
event.ID = value
case "retry":
event.Retry = value
}
if errors.Is(err, io.EOF) {
if len(dataLines) > 0 ||
event.ID != "" ||
event.Event != "" ||
event.Retry != "" {
event.Data = []byte(
strings.Join(dataLines, "\n"),
)
return event, nil
}
return SSEEvent{}, io.EOF
}
}
}
// ReadyEvent identifies the request and response messages for a stream.
type ReadyEvent struct {
RequestMessageID int64 `json:"request_message_id"`
ResponseMessageID int64 `json:"response_message_id"`
ModelType string `json:"model_type"`
}
// UpdateSessionEvent reports the latest session update timestamp.
type UpdateSessionEvent struct {
UpdatedAt float64 `json:"updated_at"`
}
// TitleEvent contains a generated chat title.
type TitleEvent struct {
Content string `json:"content"`
}
// CloseEvent describes how a completed stream should be closed or resumed.
type CloseEvent struct {
ClickBehavior string `json:"click_behavior"`
AutoResume bool `json:"auto_resume"`
}
// PatchEvent describes a compact patch in the DeepSeek stream protocol.
//
// Pointer fields distinguish:
//
// {"v":"text"}
//
// from:
//
// {"p":"","o":"","v":"text"}
//
// This distinction is required to inherit the previous path and operation.
type PatchEvent struct {
Path *string `json:"p,omitempty"`
Operation *string `json:"o,omitempty"`
Value json.RawMessage `json:"v"`
}
// ResponseFragment is one typed content fragment in a response snapshot.
type ResponseFragment struct {
ID int64 `json:"id"`
Type string `json:"type"`
Content string `json:"content"`
References []json.RawMessage `json:"references"`
StageID int64 `json:"stage_id"`
}
// ResponseSnapshot is a complete response state delivered by the stream.
type ResponseSnapshot struct {
MessageID int64 `json:"message_id"`
ParentID int64 `json:"parent_id"`
Model string `json:"model"`
Role string `json:"role"`
ThinkingEnabled bool `json:"thinking_enabled"`
Status string `json:"status"`
QuasiStatus string `json:"quasi_status"`
AccumulatedTokenUsage int64 `json:"accumulated_token_usage"`
Content string `json:"content"`
ThinkingContent *string `json:"thinking_content"`
Fragments []ResponseFragment `json:"fragments"`
}
// SnapshotEvent wraps a complete response snapshot.
type SnapshotEvent struct {
Response *ResponseSnapshot `json:"response"`
}
// StreamState is the accumulated state of a completion stream.
// Content and ThinkingContent must not be copied after their first use.
type StreamState struct {
RequestMessageID int64
ResponseMessageID int64
Content strings.Builder
ThinkingContent strings.Builder
Status string
TokenUsage int64
Title string
SessionUpdated float64
Finished bool
Closed bool
lastPath string
lastOp string
}
// Apply incorporates event into s.
func (s *StreamState) Apply(event SSEEvent) error {
switch event.Event {
case "ready":
return s.applyReady(event.Data)
case "update_session":
return s.applyUpdateSession(event.Data)
case "title":
return s.applyTitle(event.Data)
case "finish":
s.Finished = true
return nil
case "close":
s.Closed = true
return nil
}
if len(bytes.TrimSpace(event.Data)) == 0 {
return nil
}
var patch PatchEvent
if err := json.Unmarshal(event.Data, &patch); err != nil {
return fmt.Errorf(
"decode stream patch: %w; data=%s",
err,
event.Data,
)
}
if len(patch.Value) == 0 {
return nil
}
/*
Snapshot имеет вид:
{
"v": {
"response": {
...
}
}
}
У него отсутствуют и p, и o. Проверять snapshot нужно до
наследования lastPath/lastOp, иначе он может быть принят
за продолжение предыдущего patch.
*/
if patch.Path == nil && patch.Operation == nil {
isSnapshot, err := s.tryApplySnapshot(patch.Value)
if err != nil {
return err
}
if isSnapshot {
return nil
}
}
path, operation := s.resolvePatchLocation(patch)
return s.applyPatch(
path,
operation,
patch.Value,
)
}
func (s *StreamState) applyReady(data []byte) error {
var value ReadyEvent
if err := json.Unmarshal(data, &value); err != nil {
return fmt.Errorf("decode ready: %w", err)
}
s.RequestMessageID = value.RequestMessageID
s.ResponseMessageID = value.ResponseMessageID
return nil
}
func (s *StreamState) applyUpdateSession(data []byte) error {
var value UpdateSessionEvent
if err := json.Unmarshal(data, &value); err != nil {
return fmt.Errorf(
"decode update_session: %w",
err,
)
}
s.SessionUpdated = value.UpdatedAt
return nil
}
func (s *StreamState) applyTitle(data []byte) error {
var value TitleEvent
if err := json.Unmarshal(data, &value); err != nil {
return fmt.Errorf("decode title: %w", err)
}
s.Title = value.Content
return nil
}
func (s *StreamState) resolvePatchLocation(
patch PatchEvent,
) (path string, operation string) {
switch {
/*
Пришёл новый путь.
Если операция отсутствует, она не должна наследовать старый
APPEND. Для этого сохраняем lastOp как пустую строку.
*/
case patch.Path != nil:
path = *patch.Path
s.lastPath = path
if patch.Operation != nil {
operation = *patch.Operation
} else {
operation = ""
}
s.lastOp = operation
/*
Операция изменилась, а путь остался прежним.
*/
case patch.Operation != nil:
path = s.lastPath
operation = *patch.Operation
s.lastOp = operation
/*
Сокращённое событие:
{"v":"текст"}
Наследует и путь, и операцию.
*/
default:
path = s.lastPath
operation = s.lastOp
}
return path, operation
}
func (s *StreamState) tryApplySnapshot(
value json.RawMessage,
) (bool, error) {
var snapshot SnapshotEvent
if err := json.Unmarshal(value, &snapshot); err != nil {
// Значение может быть обычной строкой, числом и т. п.
// В таком случае это не snapshot.
return false, nil
}
if snapshot.Response == nil {
return false, nil
}
s.applyResponseSnapshot(*snapshot.Response)
return true, nil
}
func (s *StreamState) applyResponseSnapshot(
response ResponseSnapshot,
) {
if response.MessageID != 0 {
s.ResponseMessageID = response.MessageID
}
s.Status = response.Status
s.TokenUsage = response.AccumulatedTokenUsage
s.Content.Reset()
s.ThinkingContent.Reset()
if response.Content != "" {
s.Content.WriteString(response.Content)
}
if response.ThinkingContent != nil {
s.ThinkingContent.WriteString(
*response.ThinkingContent,
)
}
for _, fragment := range response.Fragments {
switch fragment.Type {
case "RESPONSE":
s.Content.WriteString(fragment.Content)
case "THINKING":
s.ThinkingContent.WriteString(fragment.Content)
}
}
if response.QuasiStatus != "" {
s.Status = response.QuasiStatus
}
}
func (s *StreamState) applyPatch(
path string,
operation string,
value json.RawMessage,
) error {
switch path {
case "response/content",
"response/fragments/-1/content":
return applyStringPatch(
&s.Content,
operation,
value,
)
case "response/thinking_content",
"response/fragments/-1/thinking_content":
return applyStringPatch(
&s.ThinkingContent,
operation,
value,
)
case "response/accumulated_token_usage":
if err := json.Unmarshal(value, &s.TokenUsage); err != nil {
return fmt.Errorf(
"decode token usage: %w",
err,
)
}
case "response/status",
"response/quasi_status":
if err := json.Unmarshal(value, &s.Status); err != nil {
return fmt.Errorf(
"decode response status: %w",
err,
)
}
if s.Status == "FINISHED" {
s.Finished = true
}
case "response":
if operation != "BATCH" {
log.Printf(
"unknown response operation: op=%q value=%s",
operation,
value,
)
return nil
}
if err := s.applyBatch(value); err != nil {
return err
}
default:
log.Printf(
"unknown patch: path=%q op=%q value=%s",
path,
operation,
value,
)
}
return nil
}
func (s *StreamState) applyBatch(
value json.RawMessage,
) error {
var batch []PatchEvent
if err := json.Unmarshal(value, &batch); err != nil {
return fmt.Errorf("decode batch: %w", err)
}
for _, item := range batch {
if len(item.Value) == 0 {
continue
}
itemPath := ""
if item.Path != nil {
itemPath = *item.Path
}
itemOperation := ""
if item.Operation != nil {
itemOperation = *item.Operation
}
/*
Внутри response/BATCH пути относительные:
accumulated_token_usage
quasi_status
Преобразуем их в абсолютные.
*/
if itemPath != "" &&
!strings.HasPrefix(itemPath, "response/") {
itemPath = "response/" + itemPath
}
if err := s.applyPatch(
itemPath,
itemOperation,
item.Value,
); err != nil {
return err
}
}
return nil
}
func applyStringPatch(
builder *strings.Builder,
operation string,
value json.RawMessage,
) error {
var text string
if err := json.Unmarshal(value, &text); err != nil {
return fmt.Errorf(
"decode string patch: %w",
err,
)
}
switch operation {
case "", "APPEND":
builder.WriteString(text)
case "SET":
builder.Reset()
builder.WriteString(text)
default:
return fmt.Errorf(
"unsupported string patch operation %q",
operation,
)
}
return nil
}
+143
View File
@@ -0,0 +1,143 @@
package deepseek
import (
"context"
"errors"
"io"
"reflect"
"strings"
"testing"
)
type failingReader struct {
err error
}
func (r failingReader) Read([]byte) (int, error) { return 0, r.err }
func TestSSEReaderNext(t *testing.T) {
input := strings.Join([]string{
`: keepalive`,
`id: event-id`,
`event: update`,
`retry: 1000`,
`data: first`,
`data: second`,
`unknown: ignored`,
``,
}, "\r\n")
event, err := NewSSEReader(strings.NewReader(input)).Next(context.Background())
if err != nil {
t.Fatalf("Next() error = %v", err)
}
want := SSEEvent{ID: "event-id", Event: "update", Retry: "1000", Data: []byte("first\nsecond")}
if !reflect.DeepEqual(event, want) {
t.Fatalf("event = %#v, want %#v", event, want)
}
}
func TestSSEReaderNextReturnsFinalEventWithoutBlankLine(t *testing.T) {
event, err := NewSSEReader(strings.NewReader("event: close\ndata: {}")).Next(context.Background())
if err != nil {
t.Fatalf("Next() error = %v", err)
}
if event.Event != "close" || string(event.Data) != "{}" {
t.Fatalf("event = %#v", event)
}
}
func TestSSEReaderNextEOF(t *testing.T) {
_, err := NewSSEReader(strings.NewReader(": comment without event")).Next(context.Background())
if !errors.Is(err, io.EOF) {
t.Fatalf("Next() error = %v, want io.EOF", err)
}
}
func TestSSEReaderNextContextCanceled(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel()
_, err := NewSSEReader(strings.NewReader("data: ignored\n\n")).Next(ctx)
if !errors.Is(err, context.Canceled) {
t.Fatalf("Next() error = %v, want context canceled", err)
}
}
func TestSSEReaderNextReadError(t *testing.T) {
wantErr := errors.New("read failed")
_, err := NewSSEReader(failingReader{err: wantErr}).Next(context.Background())
if !errors.Is(err, wantErr) {
t.Fatalf("Next() error = %v, want %v", err, wantErr)
}
}
func TestStreamStateApplyMetadataEvents(t *testing.T) {
state := &StreamState{}
events := []SSEEvent{
{Event: "ready", Data: []byte(`{"request_message_id":11,"response_message_id":12,"model_type":"default"}`)},
{Event: "update_session", Data: []byte(`{"updated_at":123.5}`)},
{Event: "title", Data: []byte(`{"content":"Chat title"}`)},
{Event: "finish"},
{Event: "close"},
}
for _, event := range events {
if err := state.Apply(event); err != nil {
t.Fatalf("Apply(%q) error = %v", event.Event, err)
}
}
if state.RequestMessageID != 11 || state.ResponseMessageID != 12 || state.SessionUpdated != 123.5 || state.Title != "Chat title" || !state.Finished || !state.Closed {
t.Fatalf("state = %#v", state)
}
}
func TestStreamStateApplySnapshotAndPatches(t *testing.T) {
thinking := "initial thought"
state := &StreamState{}
snapshot := `{"v":{"response":{"message_id":7,"status":"PENDING","quasi_status":"STREAMING","accumulated_token_usage":3,"thinking_content":"` + thinking + `","fragments":[{"type":"RESPONSE","content":"initial"}]}}}`
if err := state.Apply(SSEEvent{Data: []byte(snapshot)}); err != nil {
t.Fatalf("apply snapshot: %v", err)
}
if state.ResponseMessageID != 7 || state.Status != "STREAMING" || state.TokenUsage != 3 || state.Content.String() != "initial" || state.ThinkingContent.String() != thinking {
t.Fatalf("snapshot state = %#v", state)
}
patches := []string{
`{"p":"response/content","o":"APPEND","v":" one"}`,
`{"v":" two"}`,
`{"p":"response/thinking_content","o":"SET","v":"replaced"}`,
`{"p":"response","o":"BATCH","v":[{"p":"accumulated_token_usage","o":"SET","v":9},{"p":"quasi_status","o":"SET","v":"FINISHED"}]}`,
}
for _, patch := range patches {
if err := state.Apply(SSEEvent{Data: []byte(patch)}); err != nil {
t.Fatalf("Apply(%s) error = %v", patch, err)
}
}
if state.Content.String() != "initial one two" {
t.Fatalf("content = %q", state.Content.String())
}
if state.ThinkingContent.String() != "replaced" || state.TokenUsage != 9 || state.Status != "FINISHED" || !state.Finished {
t.Fatalf("patched state = %#v", state)
}
}
func TestStreamStateApplyErrors(t *testing.T) {
tests := []struct {
name string
event SSEEvent
want string
}{
{name: "invalid patch JSON", event: SSEEvent{Data: []byte(`{`)}, want: "decode stream patch"},
{name: "invalid ready", event: SSEEvent{Event: "ready", Data: []byte(`{`)}, want: "decode ready"},
{name: "invalid string", event: SSEEvent{Data: []byte(`{"p":"response/content","o":"SET","v":1}`)}, want: "decode string patch"},
{name: "unsupported operation", event: SSEEvent{Data: []byte(`{"p":"response/content","o":"DELETE","v":"x"}`)}, want: "unsupported string patch operation"},
{name: "invalid batch", event: SSEEvent{Data: []byte(`{"p":"response","o":"BATCH","v":{}}`)}, want: "decode batch"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := (&StreamState{}).Apply(tt.event)
if err == nil || !strings.Contains(err.Error(), tt.want) {
t.Fatalf("Apply() error = %v, want containing %q", err, tt.want)
}
})
}
}