2025-08-05 03:56:23 +02:00
|
|
|
package main
|
|
|
|
|
|
|
|
import (
|
2025-08-14 03:53:14 +02:00
|
|
|
"context"
|
2025-08-05 03:56:23 +02:00
|
|
|
"encoding/json"
|
|
|
|
"errors"
|
|
|
|
"fmt"
|
|
|
|
"io"
|
|
|
|
"net/http"
|
2025-08-14 03:53:14 +02:00
|
|
|
"strings"
|
2025-08-05 03:56:23 +02:00
|
|
|
|
|
|
|
"github.com/revrost/go-openrouter"
|
|
|
|
)
|
|
|
|
|
2025-08-14 03:53:14 +02:00
|
|
|
type ToolCall struct {
|
|
|
|
ID string `json:"id"`
|
|
|
|
Name string `json:"name"`
|
|
|
|
Args string `json:"args"`
|
|
|
|
Result string `json:"result,omitempty"`
|
|
|
|
}
|
|
|
|
|
2025-08-05 03:56:23 +02:00
|
|
|
type Message struct {
|
2025-08-14 03:53:14 +02:00
|
|
|
Role string `json:"role"`
|
|
|
|
Text string `json:"text"`
|
|
|
|
Tool *ToolCall `json:"tool"`
|
2025-08-05 03:56:23 +02:00
|
|
|
}
|
|
|
|
|
2025-08-10 22:32:40 +02:00
|
|
|
type Reasoning struct {
|
|
|
|
Effort string `json:"effort"`
|
|
|
|
Tokens int `json:"tokens"`
|
|
|
|
}
|
|
|
|
|
2025-08-05 03:56:23 +02:00
|
|
|
type Request struct {
|
2025-08-07 22:09:08 +02:00
|
|
|
Prompt string `json:"prompt"`
|
2025-08-05 03:56:23 +02:00
|
|
|
Model string `json:"model"`
|
|
|
|
Temperature float64 `json:"temperature"`
|
2025-08-11 00:15:58 +02:00
|
|
|
JSON bool `json:"json"`
|
|
|
|
Search bool `json:"search"`
|
2025-08-10 22:32:40 +02:00
|
|
|
Reasoning Reasoning `json:"reasoning"`
|
2025-08-05 03:56:23 +02:00
|
|
|
Messages []Message `json:"messages"`
|
|
|
|
}
|
|
|
|
|
2025-08-14 03:53:14 +02:00
|
|
|
func (t *ToolCall) AsToolCall() openrouter.ToolCall {
|
|
|
|
return openrouter.ToolCall{
|
|
|
|
ID: t.ID,
|
|
|
|
Type: openrouter.ToolTypeFunction,
|
|
|
|
Function: openrouter.FunctionCall{
|
|
|
|
Name: t.Name,
|
|
|
|
Arguments: t.Args,
|
|
|
|
},
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
func (t *ToolCall) AsToolMessage() openrouter.ChatCompletionMessage {
|
|
|
|
return openrouter.ChatCompletionMessage{
|
|
|
|
Role: openrouter.ChatMessageRoleTool,
|
|
|
|
ToolCallID: t.ID,
|
|
|
|
Content: openrouter.Content{
|
|
|
|
Text: t.Result,
|
|
|
|
},
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2025-08-05 03:56:23 +02:00
|
|
|
func (r *Request) Parse() (*openrouter.ChatCompletionRequest, error) {
|
|
|
|
var request openrouter.ChatCompletionRequest
|
|
|
|
|
2025-08-07 22:09:08 +02:00
|
|
|
model, ok := ModelMap[r.Model]
|
|
|
|
if !ok {
|
2025-08-05 03:56:23 +02:00
|
|
|
return nil, fmt.Errorf("unknown model: %q", r.Model)
|
|
|
|
}
|
|
|
|
|
|
|
|
request.Model = r.Model
|
|
|
|
|
2025-08-10 22:39:18 +02:00
|
|
|
if r.Temperature < 0 || r.Temperature > 2 {
|
|
|
|
return nil, fmt.Errorf("invalid temperature (0-2): %f", r.Temperature)
|
2025-08-05 03:56:23 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
request.Temperature = float32(r.Temperature)
|
|
|
|
|
2025-08-10 22:32:40 +02:00
|
|
|
if model.Reasoning {
|
|
|
|
request.Reasoning = &openrouter.ChatCompletionReasoning{}
|
|
|
|
|
|
|
|
switch r.Reasoning.Effort {
|
|
|
|
case "high", "medium", "low":
|
|
|
|
request.Reasoning.Effort = &r.Reasoning.Effort
|
|
|
|
default:
|
|
|
|
if r.Reasoning.Tokens <= 0 || r.Reasoning.Tokens > 1024*1024 {
|
|
|
|
return nil, fmt.Errorf("invalid reasoning tokens (1-1048576): %d", r.Reasoning.Tokens)
|
|
|
|
}
|
|
|
|
|
|
|
|
request.Reasoning.MaxTokens = &r.Reasoning.Tokens
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2025-08-11 00:15:58 +02:00
|
|
|
if model.JSON && r.JSON {
|
|
|
|
request.ResponseFormat = &openrouter.ChatCompletionResponseFormat{
|
|
|
|
Type: openrouter.ChatCompletionResponseFormatTypeJSONObject,
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2025-08-14 03:53:14 +02:00
|
|
|
if model.Tools && r.Search {
|
|
|
|
request.Tools = GetSearchTool()
|
|
|
|
request.ToolChoice = "auto"
|
2025-08-11 00:15:58 +02:00
|
|
|
}
|
|
|
|
|
2025-08-07 22:09:08 +02:00
|
|
|
prompt, err := BuildPrompt(r.Prompt, model)
|
|
|
|
if err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
|
|
|
|
if prompt != "" {
|
|
|
|
request.Messages = append(request.Messages, openrouter.SystemMessage(prompt))
|
|
|
|
}
|
|
|
|
|
2025-08-05 03:56:23 +02:00
|
|
|
for index, message := range r.Messages {
|
2025-08-14 03:53:14 +02:00
|
|
|
switch message.Role {
|
|
|
|
case "system", "user":
|
|
|
|
request.Messages = append(request.Messages, openrouter.ChatCompletionMessage{
|
|
|
|
Role: message.Role,
|
|
|
|
Content: openrouter.Content{
|
|
|
|
Text: message.Text,
|
|
|
|
},
|
|
|
|
})
|
|
|
|
case "assistant":
|
|
|
|
msg := openrouter.ChatCompletionMessage{
|
|
|
|
Role: openrouter.ChatMessageRoleAssistant,
|
|
|
|
Content: openrouter.Content{
|
|
|
|
Text: message.Text,
|
|
|
|
},
|
|
|
|
}
|
|
|
|
|
|
|
|
tool := message.Tool
|
|
|
|
if tool != nil {
|
|
|
|
msg.ToolCalls = []openrouter.ToolCall{tool.AsToolCall()}
|
|
|
|
|
|
|
|
request.Messages = append(request.Messages, msg)
|
|
|
|
|
|
|
|
msg = tool.AsToolMessage()
|
|
|
|
}
|
|
|
|
|
|
|
|
request.Messages = append(request.Messages, msg)
|
|
|
|
default:
|
2025-08-05 03:56:23 +02:00
|
|
|
return nil, fmt.Errorf("[%d] invalid role: %q", index+1, message.Role)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
return &request, nil
|
|
|
|
}
|
|
|
|
|
|
|
|
func HandleChat(w http.ResponseWriter, r *http.Request) {
|
|
|
|
var raw Request
|
|
|
|
|
|
|
|
if err := json.NewDecoder(r.Body).Decode(&raw); err != nil {
|
|
|
|
RespondJson(w, http.StatusBadRequest, map[string]any{
|
|
|
|
"error": err.Error(),
|
|
|
|
})
|
|
|
|
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
request, err := raw.Parse()
|
|
|
|
if err != nil {
|
|
|
|
RespondJson(w, http.StatusBadRequest, map[string]any{
|
|
|
|
"error": err.Error(),
|
|
|
|
})
|
|
|
|
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
request.Stream = true
|
|
|
|
|
2025-08-07 22:09:08 +02:00
|
|
|
// DEBUG
|
2025-08-11 01:16:52 +02:00
|
|
|
dump(request)
|
2025-08-07 22:09:08 +02:00
|
|
|
|
2025-08-14 03:53:14 +02:00
|
|
|
response, err := NewStream(w)
|
2025-08-05 03:56:23 +02:00
|
|
|
if err != nil {
|
|
|
|
RespondJson(w, http.StatusBadRequest, map[string]any{
|
2025-08-14 03:53:14 +02:00
|
|
|
"error": err.Error(),
|
2025-08-05 03:56:23 +02:00
|
|
|
})
|
|
|
|
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
2025-08-14 03:53:14 +02:00
|
|
|
ctx := r.Context()
|
2025-08-05 03:56:23 +02:00
|
|
|
|
2025-08-14 03:53:14 +02:00
|
|
|
for iteration := range MaxIterations {
|
|
|
|
if iteration == MaxIterations-1 {
|
|
|
|
request.Tools = nil
|
|
|
|
request.ToolChoice = ""
|
|
|
|
}
|
2025-08-05 03:56:23 +02:00
|
|
|
|
2025-08-14 03:53:14 +02:00
|
|
|
tool, message, err := RunCompletion(ctx, response, request)
|
|
|
|
if err != nil {
|
|
|
|
response.Send(ErrorChunk(err))
|
|
|
|
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
if tool == nil || tool.Name != "search_internet" {
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
response.Send(ToolChunk(tool))
|
|
|
|
|
|
|
|
err = HandleSearchTool(ctx, tool)
|
|
|
|
if err != nil {
|
|
|
|
response.Send(ErrorChunk(err))
|
|
|
|
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
response.Send(ToolChunk(tool))
|
|
|
|
|
|
|
|
request.Messages = append(request.Messages,
|
|
|
|
openrouter.ChatCompletionMessage{
|
|
|
|
Role: openrouter.ChatMessageRoleAssistant,
|
|
|
|
Content: openrouter.Content{
|
|
|
|
Text: message,
|
|
|
|
},
|
|
|
|
ToolCalls: []openrouter.ToolCall{tool.AsToolCall()},
|
|
|
|
},
|
|
|
|
tool.AsToolMessage(),
|
|
|
|
)
|
2025-08-05 03:56:23 +02:00
|
|
|
}
|
2025-08-14 03:53:14 +02:00
|
|
|
}
|
2025-08-05 03:56:23 +02:00
|
|
|
|
2025-08-14 03:53:14 +02:00
|
|
|
func RunCompletion(ctx context.Context, response *Stream, request *openrouter.ChatCompletionRequest) (*ToolCall, string, error) {
|
|
|
|
stream, err := OpenRouterStartStream(ctx, *request)
|
|
|
|
if err != nil {
|
|
|
|
return nil, "", err
|
|
|
|
}
|
|
|
|
|
|
|
|
defer stream.Close()
|
|
|
|
|
|
|
|
var (
|
|
|
|
id string
|
|
|
|
result strings.Builder
|
|
|
|
tool *ToolCall
|
|
|
|
)
|
2025-08-11 15:43:00 +02:00
|
|
|
|
2025-08-05 03:56:23 +02:00
|
|
|
for {
|
|
|
|
chunk, err := stream.Recv()
|
|
|
|
if err != nil {
|
|
|
|
if errors.Is(err, io.EOF) {
|
2025-08-14 03:53:14 +02:00
|
|
|
break
|
2025-08-05 03:56:23 +02:00
|
|
|
}
|
|
|
|
|
2025-08-14 03:53:14 +02:00
|
|
|
return nil, "", err
|
2025-08-05 03:56:23 +02:00
|
|
|
}
|
|
|
|
|
2025-08-11 15:43:00 +02:00
|
|
|
if id == "" {
|
|
|
|
id = chunk.ID
|
|
|
|
|
|
|
|
response.Send(IDChunk(id))
|
|
|
|
}
|
|
|
|
|
2025-08-05 03:56:23 +02:00
|
|
|
if len(chunk.Choices) == 0 {
|
|
|
|
continue
|
|
|
|
}
|
|
|
|
|
|
|
|
choice := chunk.Choices[0]
|
|
|
|
|
2025-08-11 01:16:52 +02:00
|
|
|
// DEBUG
|
|
|
|
debug(choice)
|
|
|
|
|
2025-08-05 03:56:23 +02:00
|
|
|
if choice.FinishReason == openrouter.FinishReasonContentFilter {
|
|
|
|
response.Send(ErrorChunk(errors.New("stopped due to content_filter")))
|
|
|
|
|
2025-08-14 03:53:14 +02:00
|
|
|
return nil, "", nil
|
|
|
|
}
|
|
|
|
|
|
|
|
calls := choice.Delta.ToolCalls
|
|
|
|
|
|
|
|
if len(calls) > 0 {
|
|
|
|
call := calls[0]
|
|
|
|
|
|
|
|
if tool == nil {
|
|
|
|
tool = &ToolCall{}
|
|
|
|
}
|
|
|
|
|
|
|
|
tool.ID += call.ID
|
|
|
|
tool.Name += call.Function.Name
|
|
|
|
tool.Args += call.Function.Arguments
|
|
|
|
} else if tool != nil {
|
|
|
|
break
|
2025-08-05 03:56:23 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
content := choice.Delta.Content
|
|
|
|
|
|
|
|
if content != "" {
|
2025-08-14 03:53:14 +02:00
|
|
|
result.WriteString(content)
|
|
|
|
|
2025-08-05 03:56:23 +02:00
|
|
|
response.Send(TextChunk(content))
|
|
|
|
} else if choice.Delta.Reasoning != nil {
|
|
|
|
response.Send(ReasoningChunk(*choice.Delta.Reasoning))
|
|
|
|
}
|
|
|
|
}
|
2025-08-14 03:53:14 +02:00
|
|
|
|
|
|
|
return tool, result.String(), nil
|
2025-08-05 03:56:23 +02:00
|
|
|
}
|