Major changes: - Add Go backend (backend/) with microservices architecture - Enhanced master-agents-svc: reranker, content-classifier, stealth-crawler, proxy-manager, media-search, fastClassifier, language detection - New web-svc widgets: KnowledgeCard, ProductCard, ProfileCard, VideoCard, UnifiedCard, CardGallery, InlineImageGallery, SourcesPanel, RelatedQuestions - Improved discover-svc with discover-db integration - Docker deployment improvements (Caddyfile, vendor.sh, BUILD.md) - Library-svc: project_id schema migration - Remove deprecated finance-svc and travel-svc - Localization improvements across services Made-with: Cursor
403 lines
10 KiB
Go
403 lines
10 KiB
Go
package llm
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
type TimewebClient struct {
|
|
baseClient
|
|
httpClient *http.Client
|
|
baseURL string
|
|
agentAccessID string
|
|
apiKey string
|
|
proxySource string
|
|
}
|
|
|
|
type TimewebConfig struct {
|
|
ProviderID string
|
|
ModelKey string
|
|
BaseURL string
|
|
AgentAccessID string
|
|
APIKey string
|
|
ProxySource string
|
|
}
|
|
|
|
func NewTimewebClient(cfg TimewebConfig) (*TimewebClient, error) {
|
|
if cfg.AgentAccessID == "" {
|
|
return nil, errors.New("agent_access_id is required for Timeweb")
|
|
}
|
|
if cfg.APIKey == "" {
|
|
return nil, errors.New("api_key is required for Timeweb")
|
|
}
|
|
|
|
baseURL := cfg.BaseURL
|
|
if baseURL == "" {
|
|
baseURL = "https://api.timeweb.cloud"
|
|
}
|
|
|
|
proxySource := cfg.ProxySource
|
|
if proxySource == "" {
|
|
proxySource = "gooseek"
|
|
}
|
|
|
|
return &TimewebClient{
|
|
baseClient: baseClient{
|
|
providerID: cfg.ProviderID,
|
|
modelKey: cfg.ModelKey,
|
|
},
|
|
httpClient: &http.Client{
|
|
Timeout: 120 * time.Second,
|
|
},
|
|
baseURL: baseURL,
|
|
agentAccessID: cfg.AgentAccessID,
|
|
apiKey: cfg.APIKey,
|
|
proxySource: proxySource,
|
|
}, nil
|
|
}
|
|
|
|
type timewebChatRequest struct {
|
|
Model string `json:"model,omitempty"`
|
|
Messages []timewebMessage `json:"messages"`
|
|
Stream bool `json:"stream,omitempty"`
|
|
Temperature float64 `json:"temperature,omitempty"`
|
|
MaxTokens int `json:"max_tokens,omitempty"`
|
|
TopP float64 `json:"top_p,omitempty"`
|
|
Tools []timewebTool `json:"tools,omitempty"`
|
|
Stop []string `json:"stop,omitempty"`
|
|
}
|
|
|
|
type timewebMessage struct {
|
|
Role string `json:"role"`
|
|
Content interface{} `json:"content"`
|
|
Name string `json:"name,omitempty"`
|
|
ToolCalls []timewebToolCall `json:"tool_calls,omitempty"`
|
|
ToolCallID string `json:"tool_call_id,omitempty"`
|
|
}
|
|
|
|
type timewebTool struct {
|
|
Type string `json:"type"`
|
|
Function timewebFunction `json:"function"`
|
|
}
|
|
|
|
type timewebFunction struct {
|
|
Name string `json:"name"`
|
|
Description string `json:"description"`
|
|
Parameters interface{} `json:"parameters"`
|
|
}
|
|
|
|
type timewebToolCall struct {
|
|
ID string `json:"id"`
|
|
Type string `json:"type"`
|
|
Function struct {
|
|
Name string `json:"name"`
|
|
Arguments string `json:"arguments"`
|
|
} `json:"function"`
|
|
}
|
|
|
|
type timewebChatResponse struct {
|
|
ID string `json:"id"`
|
|
Object string `json:"object"`
|
|
Created int64 `json:"created"`
|
|
Model string `json:"model"`
|
|
Choices []struct {
|
|
Index int `json:"index"`
|
|
Message struct {
|
|
Role string `json:"role"`
|
|
Content string `json:"content"`
|
|
ToolCalls []timewebToolCall `json:"tool_calls,omitempty"`
|
|
} `json:"message"`
|
|
FinishReason string `json:"finish_reason"`
|
|
} `json:"choices"`
|
|
Usage struct {
|
|
PromptTokens int `json:"prompt_tokens"`
|
|
CompletionTokens int `json:"completion_tokens"`
|
|
TotalTokens int `json:"total_tokens"`
|
|
} `json:"usage"`
|
|
}
|
|
|
|
type timewebStreamResponse struct {
|
|
ID string `json:"id"`
|
|
Object string `json:"object"`
|
|
Created int64 `json:"created"`
|
|
Model string `json:"model"`
|
|
Choices []struct {
|
|
Index int `json:"index"`
|
|
Delta struct {
|
|
Role string `json:"role,omitempty"`
|
|
Content string `json:"content,omitempty"`
|
|
ToolCalls []timewebToolCall `json:"tool_calls,omitempty"`
|
|
} `json:"delta"`
|
|
FinishReason string `json:"finish_reason,omitempty"`
|
|
} `json:"choices"`
|
|
}
|
|
|
|
func (c *TimewebClient) StreamText(ctx context.Context, req StreamRequest) (<-chan StreamChunk, error) {
|
|
messages := make([]timewebMessage, 0, len(req.Messages))
|
|
for _, m := range req.Messages {
|
|
msg := timewebMessage{
|
|
Role: string(m.Role),
|
|
Content: m.Content,
|
|
}
|
|
if m.Name != "" {
|
|
msg.Name = m.Name
|
|
}
|
|
if m.ToolCallID != "" {
|
|
msg.ToolCallID = m.ToolCallID
|
|
}
|
|
if len(m.ToolCalls) > 0 {
|
|
msg.ToolCalls = make([]timewebToolCall, len(m.ToolCalls))
|
|
for i, tc := range m.ToolCalls {
|
|
args, _ := json.Marshal(tc.Arguments)
|
|
msg.ToolCalls[i] = timewebToolCall{
|
|
ID: tc.ID,
|
|
Type: "function",
|
|
}
|
|
msg.ToolCalls[i].Function.Name = tc.Name
|
|
msg.ToolCalls[i].Function.Arguments = string(args)
|
|
}
|
|
}
|
|
messages = append(messages, msg)
|
|
}
|
|
|
|
chatReq := timewebChatRequest{
|
|
Model: c.modelKey,
|
|
Messages: messages,
|
|
Stream: true,
|
|
}
|
|
|
|
if req.Options.MaxTokens > 0 {
|
|
chatReq.MaxTokens = req.Options.MaxTokens
|
|
}
|
|
if req.Options.Temperature > 0 {
|
|
chatReq.Temperature = req.Options.Temperature
|
|
}
|
|
if req.Options.TopP > 0 {
|
|
chatReq.TopP = req.Options.TopP
|
|
}
|
|
if len(req.Options.StopWords) > 0 {
|
|
chatReq.Stop = req.Options.StopWords
|
|
}
|
|
|
|
if len(req.Tools) > 0 {
|
|
chatReq.Tools = make([]timewebTool, len(req.Tools))
|
|
for i, t := range req.Tools {
|
|
chatReq.Tools[i] = timewebTool{
|
|
Type: "function",
|
|
Function: timewebFunction{
|
|
Name: t.Name,
|
|
Description: t.Description,
|
|
Parameters: t.Schema,
|
|
},
|
|
}
|
|
}
|
|
}
|
|
|
|
body, err := json.Marshal(chatReq)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to marshal request: %w", err)
|
|
}
|
|
|
|
url := fmt.Sprintf("%s/api/v1/cloud-ai/agents/%s/v1/chat/completions", c.baseURL, c.agentAccessID)
|
|
httpReq, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create request: %w", err)
|
|
}
|
|
|
|
httpReq.Header.Set("Content-Type", "application/json")
|
|
httpReq.Header.Set("Authorization", "Bearer "+c.apiKey)
|
|
httpReq.Header.Set("x-proxy-source", c.proxySource)
|
|
|
|
resp, err := c.httpClient.Do(httpReq)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("request failed: %w", err)
|
|
}
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
defer resp.Body.Close()
|
|
body, _ := io.ReadAll(resp.Body)
|
|
return nil, fmt.Errorf("Timeweb API error: status %d, body: %s", resp.StatusCode, string(body))
|
|
}
|
|
|
|
ch := make(chan StreamChunk, 100)
|
|
go func() {
|
|
defer close(ch)
|
|
defer resp.Body.Close()
|
|
|
|
toolCalls := make(map[int]*ToolCall)
|
|
reader := bufio.NewReader(resp.Body)
|
|
|
|
for {
|
|
line, err := reader.ReadString('\n')
|
|
if err != nil {
|
|
if err != io.EOF {
|
|
return
|
|
}
|
|
if len(toolCalls) > 0 {
|
|
calls := make([]ToolCall, 0, len(toolCalls))
|
|
for _, tc := range toolCalls {
|
|
calls = append(calls, *tc)
|
|
}
|
|
ch <- StreamChunk{ToolCallChunk: calls}
|
|
}
|
|
return
|
|
}
|
|
|
|
line = strings.TrimSpace(line)
|
|
if line == "" {
|
|
continue
|
|
}
|
|
|
|
if !strings.HasPrefix(line, "data: ") {
|
|
continue
|
|
}
|
|
|
|
data := strings.TrimPrefix(line, "data: ")
|
|
if data == "[DONE]" {
|
|
if len(toolCalls) > 0 {
|
|
calls := make([]ToolCall, 0, len(toolCalls))
|
|
for _, tc := range toolCalls {
|
|
calls = append(calls, *tc)
|
|
}
|
|
ch <- StreamChunk{ToolCallChunk: calls}
|
|
}
|
|
return
|
|
}
|
|
|
|
var streamResp timewebStreamResponse
|
|
if err := json.Unmarshal([]byte(data), &streamResp); err != nil {
|
|
continue
|
|
}
|
|
|
|
if len(streamResp.Choices) == 0 {
|
|
continue
|
|
}
|
|
|
|
delta := streamResp.Choices[0].Delta
|
|
|
|
if delta.Content != "" {
|
|
ch <- StreamChunk{ContentChunk: delta.Content}
|
|
}
|
|
|
|
for _, tc := range delta.ToolCalls {
|
|
idx := 0
|
|
if _, ok := toolCalls[idx]; !ok {
|
|
toolCalls[idx] = &ToolCall{
|
|
ID: tc.ID,
|
|
Name: tc.Function.Name,
|
|
Arguments: make(map[string]interface{}),
|
|
}
|
|
}
|
|
|
|
if tc.Function.Arguments != "" {
|
|
existing := toolCalls[idx]
|
|
var args map[string]interface{}
|
|
if err := json.Unmarshal([]byte(tc.Function.Arguments), &args); err == nil {
|
|
for k, v := range args {
|
|
existing.Arguments[k] = v
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if streamResp.Choices[0].FinishReason != "" {
|
|
ch <- StreamChunk{FinishReason: streamResp.Choices[0].FinishReason}
|
|
}
|
|
}
|
|
}()
|
|
|
|
return ch, nil
|
|
}
|
|
|
|
func (c *TimewebClient) GenerateText(ctx context.Context, req StreamRequest) (string, error) {
|
|
messages := make([]timewebMessage, 0, len(req.Messages))
|
|
for _, m := range req.Messages {
|
|
msg := timewebMessage{
|
|
Role: string(m.Role),
|
|
Content: m.Content,
|
|
}
|
|
if m.Name != "" {
|
|
msg.Name = m.Name
|
|
}
|
|
if m.ToolCallID != "" {
|
|
msg.ToolCallID = m.ToolCallID
|
|
}
|
|
messages = append(messages, msg)
|
|
}
|
|
|
|
chatReq := timewebChatRequest{
|
|
Model: c.modelKey,
|
|
Messages: messages,
|
|
Stream: false,
|
|
}
|
|
|
|
if req.Options.MaxTokens > 0 {
|
|
chatReq.MaxTokens = req.Options.MaxTokens
|
|
}
|
|
if req.Options.Temperature > 0 {
|
|
chatReq.Temperature = req.Options.Temperature
|
|
}
|
|
if req.Options.TopP > 0 {
|
|
chatReq.TopP = req.Options.TopP
|
|
}
|
|
|
|
if len(req.Tools) > 0 {
|
|
chatReq.Tools = make([]timewebTool, len(req.Tools))
|
|
for i, t := range req.Tools {
|
|
chatReq.Tools[i] = timewebTool{
|
|
Type: "function",
|
|
Function: timewebFunction{
|
|
Name: t.Name,
|
|
Description: t.Description,
|
|
Parameters: t.Schema,
|
|
},
|
|
}
|
|
}
|
|
}
|
|
|
|
body, err := json.Marshal(chatReq)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to marshal request: %w", err)
|
|
}
|
|
|
|
url := fmt.Sprintf("%s/api/v1/cloud-ai/agents/%s/v1/chat/completions", c.baseURL, c.agentAccessID)
|
|
httpReq, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body))
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to create request: %w", err)
|
|
}
|
|
|
|
httpReq.Header.Set("Content-Type", "application/json")
|
|
httpReq.Header.Set("Authorization", "Bearer "+c.apiKey)
|
|
httpReq.Header.Set("x-proxy-source", c.proxySource)
|
|
|
|
resp, err := c.httpClient.Do(httpReq)
|
|
if err != nil {
|
|
return "", fmt.Errorf("request failed: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
body, _ := io.ReadAll(resp.Body)
|
|
return "", fmt.Errorf("Timeweb API error: status %d, body: %s", resp.StatusCode, string(body))
|
|
}
|
|
|
|
var chatResp timewebChatResponse
|
|
if err := json.NewDecoder(resp.Body).Decode(&chatResp); err != nil {
|
|
return "", fmt.Errorf("failed to decode response: %w", err)
|
|
}
|
|
|
|
if len(chatResp.Choices) == 0 {
|
|
return "", errors.New("no choices in response")
|
|
}
|
|
|
|
return chatResp.Choices[0].Message.Content, nil
|
|
}
|