(new): v0.2.0 release
This commit is contained in:
@@ -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 (
|
||||
"context"
|
||||
"deepseek"
|
||||
"log"
|
||||
|
||||
"git.scuroneko.dev/scuroneko/go-deepseek"
|
||||
)
|
||||
|
||||
func main() {
|
||||
@@ -40,7 +41,7 @@ func main() {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
str, err := ds.ReadStreamAsString(ctx, res)
|
||||
str, err := deepseek.ReadStreamAsString(ctx, res)
|
||||
if err != nil {
|
||||
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.
|
||||
package deepseek
|
||||
|
||||
+12
-143
@@ -2,10 +2,6 @@ package deepseek
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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"`
|
||||
type deleteChatReq struct {
|
||||
ChatSessionID string `json:"chat_session_id"`
|
||||
}
|
||||
|
||||
// NewCompletionReq creates a completion request for chatID using the default
|
||||
// model type.
|
||||
func NewCompletionReq(chatID string) *CompletionReq {
|
||||
return &CompletionReq{
|
||||
ChatSessionID: chatID, ModelType: ModelTypeDefault,
|
||||
}
|
||||
// DeleteChat deletes a chat session using a background context.
|
||||
func (api *Client) DeleteChat(chatID string) error {
|
||||
return api.DeleteChatWithContext(context.Background(), chatID)
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// DeleteChatWithContext deletes a chat session and honors ctx cancellation.
|
||||
func (api *Client) DeleteChatWithContext(ctx context.Context, chatID string) error {
|
||||
data := deleteChatReq{ChatSessionID: chatID}
|
||||
req := NewRequest[any]("POST", "chat_session/delete", data)
|
||||
resp, err := req.DoWithContext(ctx, api)
|
||||
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()
|
||||
|
||||
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
|
||||
_, err = req.unmarshallBizResponse(resp)
|
||||
return err
|
||||
}
|
||||
|
||||
+48
-152
@@ -2,42 +2,13 @@ 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" {
|
||||
@@ -62,133 +33,58 @@ func TestCreateChatWithContext(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
func TestDeleteChatWithContext(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
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)",
|
||||
},
|
||||
}
|
||||
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
|
||||
}
|
||||
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) {
|
||||
if r.Method != http.MethodPost {
|
||||
t.Errorf("method = %q, want POST", r.Method)
|
||||
}
|
||||
if r.URL.Path != "/chat_session/delete" {
|
||||
t.Errorf("path = %q, want /chat_session/delete", r.URL.Path)
|
||||
}
|
||||
|
||||
func (r *trackingReadCloser) Close() error {
|
||||
r.closed = true
|
||||
return nil
|
||||
}
|
||||
var body deleteChatReq
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
t.Errorf("decode request: %v", err)
|
||||
}
|
||||
if body.ChatSessionID != "chat-id" {
|
||||
t.Errorf("chat_session_id = %q, want chat-id", body.ChatSessionID)
|
||||
}
|
||||
|
||||
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
|
||||
resp := testHTTPResponse("")
|
||||
resp.Body = responseBody
|
||||
return resp, nil
|
||||
})
|
||||
|
||||
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")
|
||||
err := client.DeleteChatWithContext(context.Background(), "chat-id")
|
||||
if tt.wantErr != "" {
|
||||
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
|
||||
t.Fatalf("DeleteChatWithContext() error = %v, want containing %q", err, tt.wantErr)
|
||||
}
|
||||
} else if err != nil {
|
||||
t.Fatalf("DeleteChatWithContext() error = %v", err)
|
||||
}
|
||||
if !responseBody.closed {
|
||||
t.Fatal("response body was not closed")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
deeppow "dsp/pow"
|
||||
deeppow "git.scuroneko.dev/scuroneko/go-deepseek/pow"
|
||||
)
|
||||
|
||||
func TestPowChallengeToBase64PropagatesSolverError(t *testing.T) {
|
||||
|
||||
@@ -137,12 +137,6 @@ 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:
|
||||
@@ -475,9 +469,8 @@ func (s *StreamState) applyPatch(
|
||||
case "response":
|
||||
if operation != "BATCH" {
|
||||
log.Printf(
|
||||
"unknown response operation: op=%q value=%s",
|
||||
"unknown response operation: op=%q",
|
||||
operation,
|
||||
value,
|
||||
)
|
||||
|
||||
return nil
|
||||
@@ -489,10 +482,9 @@ func (s *StreamState) applyPatch(
|
||||
|
||||
default:
|
||||
log.Printf(
|
||||
"unknown patch: path=%q op=%q value=%s",
|
||||
"unknown patch: path=%q op=%q",
|
||||
path,
|
||||
operation,
|
||||
value,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user