REPOSITORY / ScuroNeko/go-deepseek
Compare commits
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1bb05f0938
|
@@ -0,0 +1,25 @@
|
|||||||
|
## v0.2.0
|
||||||
|
|
||||||
|
### Breaking changes
|
||||||
|
|
||||||
|
- Changed the module path from `deepseek` to `git.scuroneko.dev/scuroneko/go-deepseek`.
|
||||||
|
- Replaced the `Client.ReadStream` and `Client.ReadStreamAsString` methods with package-level `ReadStream` and `ReadStreamAsString` functions.
|
||||||
|
- Removed the unused `CloseEvent` protocol type.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added `Client.DeleteChat` and `Client.DeleteChatWithContext` for deleting chat sessions.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Updated the PoW package import to use the canonical module path.
|
||||||
|
- Propagated API business errors from chat deletion and ensured response bodies are closed.
|
||||||
|
- Stopped logging raw values from unsupported SSE patches and operations.
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
|
||||||
|
- Updated the package documentation and quick-start example for the `deepseek` package and the canonical import path.
|
||||||
|
|
||||||
|
## v0.1.0
|
||||||
|
|
||||||
|
- Initial release.
|
||||||
|
|||||||
@@ -13,8 +13,9 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"deepseek"
|
|
||||||
"log"
|
"log"
|
||||||
|
|
||||||
|
"git.scuroneko.dev/scuroneko/go-deepseek"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
@@ -40,7 +41,7 @@ func main() {
|
|||||||
panic(err)
|
panic(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
str, err := ds.ReadStreamAsString(ctx, res)
|
str, err := deepseek.ReadStreamAsString(ctx, res)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
panic(err)
|
panic(err)
|
||||||
}
|
}
|
||||||
|
|||||||
+159
@@ -0,0 +1,159 @@
|
|||||||
|
package deepseek
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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 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 ReadStreamAsString(ctx context.Context, resp *http.Response) (string, error) {
|
||||||
|
state, err := ReadStream(ctx, resp, nil)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return state.Content.String(), nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
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 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 := 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 := 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,3 @@
|
|||||||
// Package api provides a client for the DeepSeek chat HTTP API and helpers for
|
// Package deepseek provides a client for the DeepSeek chat HTTP API and helpers for
|
||||||
// decoding its server-sent event streams.
|
// decoding its server-sent event streams.
|
||||||
package deepseek
|
package deepseek
|
||||||
|
|||||||
+11
-142
@@ -2,10 +2,6 @@ package deepseek
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"strings"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// ChatSession describes a DeepSeek chat session.
|
// ChatSession describes a DeepSeek chat session.
|
||||||
@@ -47,152 +43,25 @@ func (api *Client) CreateChatWithContext(ctx context.Context) (ChatSession, erro
|
|||||||
return data.Data.Data.ChatSession, nil
|
return data.Data.Data.ChatSession, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// CompletionReq configures a chat completion request.
|
type deleteChatReq struct {
|
||||||
type CompletionReq struct {
|
|
||||||
ChatSessionID string `json:"chat_session_id"`
|
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
|
// DeleteChat deletes a chat session using a background context.
|
||||||
// model type.
|
func (api *Client) DeleteChat(chatID string) error {
|
||||||
func NewCompletionReq(chatID string) *CompletionReq {
|
return api.DeleteChatWithContext(context.Background(), chatID)
|
||||||
return &CompletionReq{
|
|
||||||
ChatSessionID: chatID, ModelType: ModelTypeDefault,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetParentMessageID sets the parent message for a continued conversation.
|
// DeleteChatWithContext deletes a chat session and honors ctx cancellation.
|
||||||
func (req *CompletionReq) SetParentMessageID(id uint64) *CompletionReq {
|
func (api *Client) DeleteChatWithContext(ctx context.Context, chatID string) error {
|
||||||
req.ParentMessageID = new(id)
|
data := deleteChatReq{ChatSessionID: chatID}
|
||||||
return req
|
req := NewRequest[any]("POST", "chat_session/delete", data)
|
||||||
}
|
|
||||||
|
|
||||||
// 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)
|
resp, err := req.DoWithContext(ctx, api)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return 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()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
reader := NewSSEReader(resp.Body)
|
_, err = req.unmarshallBizResponse(resp)
|
||||||
state := &StreamState{}
|
return err
|
||||||
|
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|||||||
+39
-143
@@ -2,42 +2,13 @@ package deepseek
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/base64"
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"reflect"
|
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"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) {
|
func TestCreateChatWithContext(t *testing.T) {
|
||||||
client := testAPI(func(r *http.Request) (*http.Response, error) {
|
client := testAPI(func(r *http.Request) (*http.Response, error) {
|
||||||
if r.URL.Path != "/chat_session/create" {
|
if r.URL.Path != "/chat_session/create" {
|
||||||
@@ -62,133 +33,58 @@ func TestCreateChatWithContext(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCompletionWithContextPreservesRequestOptions(t *testing.T) {
|
func TestDeleteChatWithContext(t *testing.T) {
|
||||||
const challenge = "2f90572ad390d758b5e55b3bb74f14722166388023b3b28876d056a358591197"
|
tests := []struct {
|
||||||
const salt = "2eeb8f3a703002bfca70"
|
name string
|
||||||
const expectedAnswer = uint64(61830)
|
body string
|
||||||
|
wantErr string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "success",
|
||||||
|
body: `{"code":0,"data":{"biz_code":0,"biz_data":{}}}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "business error",
|
||||||
|
body: `{"code":0,"data":{"biz_code":42,"biz_msg":"delete denied"}}`,
|
||||||
|
wantErr: "delete denied (42)",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
responseBody := &trackingReadCloser{Reader: strings.NewReader(tt.body)}
|
||||||
client := testAPI(func(r *http.Request) (*http.Response, error) {
|
client := testAPI(func(r *http.Request) (*http.Response, error) {
|
||||||
switch r.URL.Path {
|
if r.Method != http.MethodPost {
|
||||||
case "/chat/create_pow_challenge":
|
t.Errorf("method = %q, want POST", r.Method)
|
||||||
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 {
|
if r.URL.Path != "/chat_session/delete" {
|
||||||
Answer uint64 `json:"answer"`
|
t.Errorf("path = %q, want /chat_session/delete", r.URL.Path)
|
||||||
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
|
var body deleteChatReq
|
||||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
t.Errorf("decode completion: %v", err)
|
t.Errorf("decode request: %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"}) {
|
if body.ChatSessionID != "chat-id" {
|
||||||
t.Errorf("completion body = %#v", body)
|
t.Errorf("chat_session_id = %q, want chat-id", body.ChatSessionID)
|
||||||
}
|
}
|
||||||
resp := testHTTPResponse("event: close\ndata: {}\n\n")
|
|
||||||
resp.Header.Set("Content-Type", "text/event-stream")
|
resp := testHTTPResponse("")
|
||||||
|
resp.Body = responseBody
|
||||||
return resp, nil
|
return resp, nil
|
||||||
default:
|
|
||||||
t.Fatalf("unexpected path %q", r.URL.Path)
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
parentID := uint64(9)
|
err := client.DeleteChatWithContext(context.Background(), "chat-id")
|
||||||
body := CompletionReq{
|
if tt.wantErr != "" {
|
||||||
ChatSessionID: "chat-id",
|
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
|
||||||
ParentMessageID: &parentID,
|
t.Fatalf("DeleteChatWithContext() error = %v, want containing %q", err, tt.wantErr)
|
||||||
ModelType: ModelTypeExpert,
|
|
||||||
Prompt: "prompt",
|
|
||||||
RefFileIDs: []string{"file-id"},
|
|
||||||
ThinkingEnabled: true,
|
|
||||||
SearchEnabled: true,
|
|
||||||
}
|
}
|
||||||
resp, err := client.CompletionWithContext(context.Background(), body)
|
} else if err != nil {
|
||||||
if err != nil {
|
t.Fatalf("DeleteChatWithContext() error = %v", err)
|
||||||
t.Fatalf("CompletionWithContext() error = %v", err)
|
}
|
||||||
|
if !responseBody.closed {
|
||||||
|
t.Fatal("response body was not closed")
|
||||||
}
|
}
|
||||||
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")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
deeppow "dsp/pow"
|
deeppow "git.scuroneko.dev/scuroneko/go-deepseek/pow"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestPowChallengeToBase64PropagatesSolverError(t *testing.T) {
|
func TestPowChallengeToBase64PropagatesSolverError(t *testing.T) {
|
||||||
|
|||||||
@@ -137,12 +137,6 @@ type TitleEvent struct {
|
|||||||
Content string `json:"content"`
|
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.
|
// PatchEvent describes a compact patch in the DeepSeek stream protocol.
|
||||||
//
|
//
|
||||||
// Pointer fields distinguish:
|
// Pointer fields distinguish:
|
||||||
@@ -475,9 +469,8 @@ func (s *StreamState) applyPatch(
|
|||||||
case "response":
|
case "response":
|
||||||
if operation != "BATCH" {
|
if operation != "BATCH" {
|
||||||
log.Printf(
|
log.Printf(
|
||||||
"unknown response operation: op=%q value=%s",
|
"unknown response operation: op=%q",
|
||||||
operation,
|
operation,
|
||||||
value,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
@@ -489,10 +482,9 @@ func (s *StreamState) applyPatch(
|
|||||||
|
|
||||||
default:
|
default:
|
||||||
log.Printf(
|
log.Printf(
|
||||||
"unknown patch: path=%q op=%q value=%s",
|
"unknown patch: path=%q op=%q",
|
||||||
path,
|
path,
|
||||||
operation,
|
operation,
|
||||||
value,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user