Files
ds2api/internal/httpapi/openai/history/current_input_file.go
2026-05-10 16:17:46 +08:00

247 lines
7.7 KiB
Go

package history
import (
"context"
"errors"
"fmt"
"strings"
"ds2api/internal/auth"
"ds2api/internal/config"
dsclient "ds2api/internal/deepseek/client"
"ds2api/internal/httpapi/openai/shared"
"ds2api/internal/promptcompat"
)
const (
currentInputFilename = promptcompat.CurrentInputContextFilename
currentToolsFilename = promptcompat.CurrentToolsContextFilename
currentInputContentType = "text/plain; charset=utf-8"
currentInputPurpose = "assistants"
)
type CurrentInputConfigReader interface {
CurrentInputFileEnabled() bool
CurrentInputFileMinChars() int
}
type CurrentInputUploader interface {
UploadFile(ctx context.Context, a *auth.RequestAuth, req dsclient.UploadFileRequest, maxAttempts int) (*dsclient.UploadFileResult, error)
}
type Service struct {
Store CurrentInputConfigReader
DS CurrentInputUploader
}
func (s Service) ApplyCurrentInputFile(ctx context.Context, a *auth.RequestAuth, stdReq promptcompat.StandardRequest) (promptcompat.StandardRequest, error) {
if stdReq.CurrentInputFileApplied || s.DS == nil || s.Store == nil || a == nil || !s.Store.CurrentInputFileEnabled() {
return stdReq, nil
}
threshold := s.Store.CurrentInputFileMinChars()
index, text := latestUserInputForFile(stdReq.Messages)
if index < 0 {
return stdReq, nil
}
if len([]rune(text)) < threshold {
return stdReq, nil
}
fileText := promptcompat.BuildOpenAICurrentInputContextTranscript(stdReq.Messages)
if strings.TrimSpace(fileText) == "" {
return stdReq, errors.New("current user input file produced empty transcript")
}
toolsText, _ := promptcompat.BuildOpenAIToolsContextTranscript(stdReq.ToolsRaw, stdReq.ToolChoice)
modelType := "default"
if resolvedType, ok := config.GetModelType(stdReq.ResolvedModel); ok {
modelType = resolvedType
}
result, err := s.DS.UploadFile(ctx, a, dsclient.UploadFileRequest{
Filename: currentInputFilename,
ContentType: currentInputContentType,
Purpose: currentInputPurpose,
ModelType: modelType,
Data: []byte(fileText),
}, 3)
if err != nil {
return stdReq, fmt.Errorf("upload current user input file: %w", err)
}
fileID := strings.TrimSpace(result.ID)
if fileID == "" {
return stdReq, errors.New("upload current user input file returned empty file id")
}
toolFileID := ""
if strings.TrimSpace(toolsText) != "" {
result, err := s.DS.UploadFile(ctx, a, dsclient.UploadFileRequest{
Filename: currentToolsFilename,
ContentType: currentInputContentType,
Purpose: currentInputPurpose,
ModelType: modelType,
Data: []byte(toolsText),
}, 3)
if err != nil {
return stdReq, fmt.Errorf("upload current tools file: %w", err)
}
toolFileID = strings.TrimSpace(result.ID)
if toolFileID == "" {
return stdReq, errors.New("upload current tools file returned empty file id")
}
}
messages := []any{
map[string]any{
"role": "user",
"content": currentInputFilePrompt(toolFileID != ""),
},
}
stdReq.Messages = messages
stdReq.HistoryText = fileText
stdReq.CurrentInputFileApplied = true
stdReq.CurrentInputFileID = fileID
stdReq.CurrentToolsFileID = toolFileID
stdReq.RefFileIDs = prependUniqueRefFileIDs(stdReq.RefFileIDs, fileID, toolFileID)
stdReq.FinalPrompt, stdReq.ToolNames = promptcompat.BuildOpenAIPromptWithToolInstructionsOnly(messages, stdReq.ToolsRaw, "", stdReq.ToolChoice, stdReq.Thinking)
// Token accounting must reflect the actual downstream context:
// uploaded context files + the continuation live prompt.
tokenParts := []string{fileText}
if strings.TrimSpace(toolsText) != "" {
tokenParts = append(tokenParts, toolsText)
}
tokenParts = append(tokenParts, stdReq.FinalPrompt)
stdReq.PromptTokenText = strings.Join(tokenParts, "\n")
return stdReq, nil
}
func (s Service) ReuploadAppliedCurrentInputFile(ctx context.Context, a *auth.RequestAuth, stdReq promptcompat.StandardRequest) (promptcompat.StandardRequest, error) {
if !stdReq.CurrentInputFileApplied || s.DS == nil || a == nil {
return stdReq, nil
}
fileText := strings.TrimSpace(stdReq.HistoryText)
if fileText == "" {
return stdReq, nil
}
modelType := "default"
if resolvedType, ok := config.GetModelType(stdReq.ResolvedModel); ok {
modelType = resolvedType
}
result, err := s.DS.UploadFile(ctx, a, dsclient.UploadFileRequest{
Filename: currentInputFilename,
ContentType: currentInputContentType,
Purpose: currentInputPurpose,
ModelType: modelType,
Data: []byte(stdReq.HistoryText),
}, 3)
if err != nil {
return stdReq, fmt.Errorf("upload current user input file: %w", err)
}
fileID := strings.TrimSpace(result.ID)
if fileID == "" {
return stdReq, errors.New("upload current user input file returned empty file id")
}
toolsText, _ := promptcompat.BuildOpenAIToolsContextTranscript(stdReq.ToolsRaw, stdReq.ToolChoice)
toolFileID := ""
if strings.TrimSpace(toolsText) != "" {
result, err := s.DS.UploadFile(ctx, a, dsclient.UploadFileRequest{
Filename: currentToolsFilename,
ContentType: currentInputContentType,
Purpose: currentInputPurpose,
ModelType: modelType,
Data: []byte(toolsText),
}, 3)
if err != nil {
return stdReq, fmt.Errorf("upload current tools file: %w", err)
}
toolFileID = strings.TrimSpace(result.ID)
if toolFileID == "" {
return stdReq, errors.New("upload current tools file returned empty file id")
}
}
stdReq.RefFileIDs = replaceGeneratedCurrentInputRefs(stdReq.RefFileIDs, stdReq.CurrentInputFileID, stdReq.CurrentToolsFileID, fileID, toolFileID)
stdReq.CurrentInputFileID = fileID
stdReq.CurrentToolsFileID = toolFileID
return stdReq, nil
}
func latestUserInputForFile(messages []any) (int, string) {
for i := len(messages) - 1; i >= 0; i-- {
msg, ok := messages[i].(map[string]any)
if !ok {
continue
}
role := strings.ToLower(strings.TrimSpace(shared.AsString(msg["role"])))
if role != "user" {
continue
}
text := promptcompat.NormalizeOpenAIContentForPrompt(msg["content"])
if strings.TrimSpace(text) == "" {
return -1, ""
}
return i, text
}
return -1, ""
}
func currentInputFilePrompt(hasToolsFile bool) string {
prompt := "Continue from the latest state in the attached DS2API_HISTORY.txt context. Treat it as the current working state and answer the latest user request directly."
if hasToolsFile {
prompt += " Available tool descriptions and parameter schemas are attached in DS2API_TOOLS.txt; use only those tools and follow the tool-call format rules in this prompt."
}
return prompt
}
func prependUniqueRefFileIDs(existing []string, fileIDs ...string) []string {
out := make([]string, 0, len(existing)+len(fileIDs))
seen := map[string]struct{}{}
for _, fileID := range fileIDs {
trimmed := strings.TrimSpace(fileID)
if trimmed == "" {
continue
}
key := strings.ToLower(trimmed)
if _, ok := seen[key]; ok {
continue
}
out = append(out, trimmed)
seen[key] = struct{}{}
}
for _, id := range existing {
trimmed := strings.TrimSpace(id)
if trimmed == "" {
continue
}
key := strings.ToLower(trimmed)
if _, ok := seen[key]; ok {
continue
}
out = append(out, trimmed)
seen[key] = struct{}{}
}
return out
}
func replaceGeneratedCurrentInputRefs(existing []string, oldHistoryID, oldToolsID, newHistoryID, newToolsID string) []string {
filtered := make([]string, 0, len(existing))
old := map[string]struct{}{}
for _, id := range []string{oldHistoryID, oldToolsID} {
trimmed := strings.ToLower(strings.TrimSpace(id))
if trimmed != "" {
old[trimmed] = struct{}{}
}
}
for _, id := range existing {
trimmed := strings.TrimSpace(id)
if trimmed == "" {
continue
}
if _, ok := old[strings.ToLower(trimmed)]; ok {
continue
}
filtered = append(filtered, trimmed)
}
return prependUniqueRefFileIDs(filtered, newHistoryID, newToolsID)
}