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-15 03:38:24 +02:00
"regexp"
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-14 17:08:45 +02:00
Done bool ` json:"done,omitempty" `
2025-08-14 03:53:14 +02:00
}
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-16 13:53:55 +02:00
func ( t * ToolCall ) AsAssistantToolCall ( content string ) openrouter . ChatCompletionMessage {
// Some models require there to be content
if content == "" {
content = " "
}
return openrouter . ChatCompletionMessage {
Role : openrouter . ChatMessageRoleAssistant ,
Content : openrouter . Content {
Text : content ,
} ,
ToolCalls : [ ] openrouter . ToolCall {
{
ID : t . ID ,
Type : openrouter . ToolTypeFunction ,
Function : openrouter . FunctionCall {
Name : t . Name ,
Arguments : t . Args ,
} ,
} ,
2025-08-14 03:53:14 +02:00
} ,
}
}
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-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-16 15:15:06 +02:00
if model . Tools && r . Search && env . Tokens . Exa != "" {
2025-08-16 13:53:55 +02:00
request . Tools = GetSearchTools ( )
request . ToolChoice = "auto"
request . Messages = append ( request . Messages , openrouter . SystemMessage ( "You have access to web search tools. Use `search_web` with `query` (string) and `num_results` (1-10) to find current information and get result summaries. Use `fetch_contents` with `urls` (array) to read full page content. Always specify all parameters for each tool call. Call only one tool per response." ) )
}
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 {
2025-08-15 03:38:24 +02:00
case "system" :
2025-08-14 03:53:14 +02:00
request . Messages = append ( request . Messages , openrouter . ChatCompletionMessage {
Role : message . Role ,
Content : openrouter . Content {
Text : message . Text ,
} ,
} )
2025-08-15 03:38:24 +02:00
case "user" :
var content openrouter . Content
if model . Vision && strings . Contains ( message . Text , "![" ) {
content . Multi = SplitImagePairs ( message . Text )
} else {
content . Text = message . Text
}
request . Messages = append ( request . Messages , openrouter . ChatCompletionMessage {
Role : message . Role ,
Content : content ,
} )
2025-08-14 03:53:14 +02:00
case "assistant" :
msg := openrouter . ChatCompletionMessage {
Role : openrouter . ChatMessageRoleAssistant ,
Content : openrouter . Content {
Text : message . Text ,
} ,
}
tool := message . Tool
if tool != nil {
2025-08-16 13:53:55 +02:00
msg = tool . AsAssistantToolCall ( message . Text )
2025-08-14 03:53:14 +02:00
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 ) {
2025-08-15 03:38:24 +02:00
debug ( "parsing chat" )
2025-08-14 17:08:45 +02:00
2025-08-05 03:56:23 +02:00
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-15 03:38:24 +02:00
debug ( "preparing stream" )
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 17:08:45 +02:00
debug ( "handling request" )
2025-08-14 03:53:14 +02:00
ctx := r . Context ( )
2025-08-05 03:56:23 +02:00
2025-08-16 15:15:06 +02:00
for iteration := range env . Settings . MaxIterations {
debug ( "iteration %d of %d" , iteration + 1 , env . Settings . MaxIterations )
2025-08-14 17:08:45 +02:00
2025-08-16 15:15:06 +02:00
if iteration == env . Settings . MaxIterations - 1 {
2025-08-14 17:08:45 +02:00
debug ( "no more tool calls" )
2025-08-14 03:53:14 +02:00
request . Tools = nil
request . ToolChoice = ""
2025-08-14 17:08:45 +02:00
request . Messages = append ( request . Messages , openrouter . SystemMessage ( "You have reached the maximum number of tool calls for this conversation. Provide your final response based on the information you have gathered." ) )
2025-08-14 03:53:14 +02:00
}
2025-08-05 03:56:23 +02:00
2025-08-16 13:53:55 +02:00
dump ( "debug.json" , request )
2025-08-14 03:53:14 +02:00
tool , message , err := RunCompletion ( ctx , response , request )
if err != nil {
response . Send ( ErrorChunk ( err ) )
return
}
2025-08-14 17:08:45 +02:00
if tool == nil {
debug ( "no tool call, done" )
2025-08-14 03:53:14 +02:00
return
}
2025-08-14 17:08:45 +02:00
debug ( "got %q tool call" , tool . Name )
2025-08-14 03:53:14 +02:00
response . Send ( ToolChunk ( tool ) )
2025-08-14 17:08:45 +02:00
switch tool . Name {
case "search_web" :
err = HandleSearchWebTool ( ctx , tool )
if err != nil {
response . Send ( ErrorChunk ( err ) )
2025-08-14 03:53:14 +02:00
2025-08-14 17:08:45 +02:00
return
}
case "fetch_contents" :
err = HandleFetchContentsTool ( ctx , tool )
if err != nil {
response . Send ( ErrorChunk ( err ) )
return
}
default :
2025-08-14 03:53:14 +02:00
return
}
2025-08-14 17:08:45 +02:00
tool . Done = true
debug ( "finished tool call" )
2025-08-14 03:53:14 +02:00
response . Send ( ToolChunk ( tool ) )
request . Messages = append ( request . Messages ,
2025-08-16 13:53:55 +02:00
tool . AsAssistantToolCall ( message ) ,
2025-08-14 03:53:14 +02:00
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 ]
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
}
2025-08-15 03:38:24 +02:00
func SplitImagePairs ( text string ) [ ] openrouter . ChatMessagePart {
rgx := regexp . MustCompile ( ` (?m)!\[[^\]]*]\((\S+?)\) ` )
var (
index int
parts [ ] openrouter . ChatMessagePart
)
push := func ( str , end int ) {
rest := text [ str : end ]
if rest == "" {
return
}
total := len ( parts )
if total > 0 && parts [ total - 1 ] . Type == openrouter . ChatMessagePartTypeText {
parts [ total - 1 ] . Text += rest
return
}
parts = append ( parts , openrouter . ChatMessagePart {
Type : openrouter . ChatMessagePartTypeText ,
Text : rest ,
} )
}
for {
location := rgx . FindStringSubmatchIndex ( text [ index : ] )
if location == nil {
push ( index , len ( text ) - 1 )
break
}
start := index + location [ 0 ]
end := index + location [ 1 ]
urlStart := index + location [ 2 ]
urlEnd := index + location [ 3 ]
url := text [ urlStart : urlEnd ]
if ! strings . HasPrefix ( url , "https://" ) && ! strings . HasPrefix ( url , "http://" ) {
push ( index , end )
index = end
continue
}
if start > index {
push ( index , start )
}
parts = append ( parts , openrouter . ChatMessagePart {
Type : openrouter . ChatMessagePartTypeImageURL ,
ImageURL : & openrouter . ChatMessageImageURL {
Detail : openrouter . ImageURLDetailAuto ,
URL : url ,
} ,
} )
index = end
}
return parts
}