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 {
2025-08-30 00:25:48 +02:00
ID string ` json:"id" `
Name string ` json:"name" `
Args string ` json:"args" `
Result string ` json:"result,omitempty" `
Done bool ` json:"done,omitempty" `
Invalid bool ` json:"invalid,omitempty" `
Cost float64 ` json:"cost,omitempty" `
2025-08-14 03:53:14 +02:00
}
2025-08-18 03:47:37 +02:00
type TextFile struct {
Name string ` json:"name" `
Content string ` json:"content" `
}
2025-08-05 03:56:23 +02:00
type Message struct {
2025-08-18 03:47:37 +02:00
Role string ` json:"role" `
Text string ` json:"text" `
Tool * ToolCall ` json:"tool" `
Files [ ] TextFile ` json:"files" `
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-28 16:37:48 +02:00
type Tools struct {
JSON bool ` json:"json" `
Search bool ` json:"search" `
}
type Metadata struct {
Timezone string ` json:"timezone" `
Platform string ` json:"platform" `
}
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-23 15:19:43 +02:00
Iterations int64 ` json:"iterations" `
2025-08-28 16:37:48 +02:00
Tools Tools ` json:"tools" `
2025-08-10 22:32:40 +02:00
Reasoning Reasoning ` json:"reasoning" `
2025-08-28 16:37:48 +02:00
Metadata Metadata ` json:"metadata" `
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-23 15:19:43 +02:00
if r . Iterations < 1 || r . Iterations > 50 {
return nil , fmt . Errorf ( "invalid iterations (1-50): %d" , r . Iterations )
}
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-28 16:37:48 +02:00
if model . JSON && r . Tools . JSON {
2025-08-11 00:15:58 +02:00
request . ResponseFormat = & openrouter . ChatCompletionResponseFormat {
Type : openrouter . ChatCompletionResponseFormatTypeJSONObject ,
}
}
2025-08-28 16:37:48 +02:00
prompt , err := BuildPrompt ( r . Prompt , r . Metadata , model )
2025-08-07 22:09:08 +02:00
if err != nil {
return nil , err
}
if prompt != "" {
request . Messages = append ( request . Messages , openrouter . SystemMessage ( prompt ) )
}
2025-08-30 15:06:49 +02:00
if model . Tools && r . Tools . Search && env . Tokens . Exa != "" && r . Iterations > 1 {
2025-08-16 13:53:55 +02:00
request . Tools = GetSearchTools ( )
request . ToolChoice = "auto"
2025-08-30 15:06:49 +02:00
request . Messages = append (
request . Messages ,
openrouter . SystemMessage ( fmt . Sprintf ( InternalToolsPrompt , r . Iterations - 1 ) ) ,
)
2025-08-16 13:53:55 +02:00
}
2025-08-25 22:45:03 +02:00
for _ , message := range r . Messages {
message . Text = strings . ReplaceAll ( message . Text , "\r" , "" )
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
}
2025-08-18 03:47:37 +02:00
if len ( message . Files ) > 0 {
if content . Text != "" {
content . Multi = append ( content . Multi , openrouter . ChatMessagePart {
Type : openrouter . ChatMessagePartTypeText ,
Text : content . Text ,
} )
content . Text = ""
}
for i , file := range message . Files {
if len ( file . Name ) > 512 {
return nil , fmt . Errorf ( "file %d is invalid (name too long, max 512 characters)" , i )
} else if len ( file . Content ) > 4 * 1024 * 1024 {
return nil , fmt . Errorf ( "file %d is invalid (too big, max 4MB)" , i )
}
lines := strings . Count ( file . Content , "\n" ) + 1
content . Multi = append ( content . Multi , openrouter . ChatMessagePart {
Type : openrouter . ChatMessagePartTypeText ,
Text : fmt . Sprintf (
"FILE %q LINES %d\n<<CONTENT>>\n%s\n<<END>>" ,
file . Name ,
lines ,
file . Content ,
) ,
} )
}
}
2025-08-15 03:38:24 +02:00
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 )
2025-08-05 03:56:23 +02:00
}
}
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-29 19:26:55 +02:00
ctx := r . Context ( )
response , err := NewStream ( w , ctx )
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-23 15:19:43 +02:00
for iteration := range raw . Iterations {
debug ( "iteration %d of %d" , iteration + 1 , raw . Iterations )
2025-08-14 17:08:45 +02:00
2025-08-30 15:06:49 +02:00
if len ( request . Tools ) > 0 && iteration == raw . Iterations - 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-25 22:45:03 +02:00
dump ( "chat.json" , request )
2025-08-16 13:53:55 +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
}
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 ) )
2025-08-25 18:37:30 +02:00
return
}
case "github_repository" :
err = HandleGitHubRepositoryTool ( ctx , tool )
if err != nil {
response . Send ( ErrorChunk ( err ) )
2025-08-14 17:08:45 +02:00
return
}
default :
2025-08-30 00:25:48 +02:00
tool . Invalid = true
tool . Result = "error: invalid tool call"
2025-08-14 03:53:14 +02:00
}
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 ) {
2025-08-27 17:24:08 +02:00
if str > end {
return
}
2025-08-15 03:38:24 +02:00
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
}