feat(tools): implement universal tool calling and multi-turn resolution for generic Gradio spaces
This commit is contained in:
@@ -171,7 +171,7 @@ curl -N http://localhost:8080/v1/chat/completions \
|
||||
}'
|
||||
```
|
||||
|
||||
### Tool calling
|
||||
### Tool calling (turn 1)
|
||||
|
||||
```bash
|
||||
curl http://localhost:8080/v1/chat/completions \
|
||||
@@ -233,6 +233,76 @@ Response:
|
||||
}
|
||||
```
|
||||
|
||||
### Tool response submission (turn 2)
|
||||
|
||||
```bash
|
||||
curl http://localhost:8080/v1/chat/completions \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"model": "openai/gpt-oss-120b",
|
||||
"messages": [
|
||||
{"role": "user", "content": "What is the weather in Tokyo?"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "call_3d4c016a",
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "get_weather",
|
||||
"arguments": "{\"location\":\"Tokyo\"}"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "tool",
|
||||
"tool_call_id": "call_3d4c016a",
|
||||
"content": "{\"temperature\": 20, \"condition\": \"sunny\"}"
|
||||
}
|
||||
],
|
||||
"tools": [{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "get_weather",
|
||||
"description": "Get current weather in a location",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"location": {"type": "string", "description": "City name"}
|
||||
},
|
||||
"required": ["location"]
|
||||
}
|
||||
}
|
||||
}]
|
||||
}'
|
||||
```
|
||||
|
||||
Response:
|
||||
```json
|
||||
{
|
||||
"id": "chatcmpl-4903ba12-f12b-4cd3-a801-7290bc91a421",
|
||||
"object": "chat.completion",
|
||||
"created": 1788756335,
|
||||
"model": "openai/gpt-oss-120b",
|
||||
"choices": [
|
||||
{
|
||||
"index": 0,
|
||||
"message": {
|
||||
"role": "assistant",
|
||||
"content": "The current weather in Tokyo is sunny with a temperature of 20 °C."
|
||||
},
|
||||
"finish_reason": "stop"
|
||||
}
|
||||
],
|
||||
"usage": {
|
||||
"prompt_tokens": 0,
|
||||
"completion_tokens": 0,
|
||||
"total_tokens": 0
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Dynamic target space override
|
||||
|
||||
Override the target space per request without restarting the server:
|
||||
|
||||
@@ -438,18 +438,33 @@ func BuildToolInstruction(tools []Tool) string {
|
||||
return ""
|
||||
}
|
||||
toolsBytes, _ := json.MarshalIndent(tools, "", " ")
|
||||
return fmt.Sprintf("\n\n# Tool Calling Instructions\n\nYou have access to the following functions:\n<tools>\n%s\n</tools>\n\nWhen you need to call a function, respond ONLY with a <tool_call> block formatted exactly as follows:\n<tool_call>\n{\"name\": \"<function-name>\", \"arguments\": {<args-json-object>}}\n</tool_call>\n\nDo not include conversational filler before or after the tool call.", string(toolsBytes))
|
||||
return fmt.Sprintf("\n\n# Tool Calling Instructions\n\nYou have access to the following functions:\n<tools>\n%s\n</tools>\n\nWhen you need to call a function, respond ONLY with a <tool_call> block formatted exactly as follows:\n<tool_call>\n{\"name\": \"<function-name>\", \"arguments\": {<args-json-object>}}\n</tool_call>\n\nWhen you receive a <tool_response>, use the provided information to answer the user's request, or call further tools if needed.\nDo not include conversational filler before or after the tool call.", string(toolsBytes))
|
||||
}
|
||||
|
||||
func TransformMessages(req ChatCompletionRequest) (processed []ChatMessage, toolInstruction string, hasSystem bool) {
|
||||
toolInstruction = BuildToolInstruction(req.Tools)
|
||||
// 1. Build lookup map from tool_call_id to function name across all assistant messages
|
||||
toolIDToName := make(map[string]string)
|
||||
for _, msg := range req.Messages {
|
||||
for _, tc := range msg.ToolCalls {
|
||||
if tc.ID != "" && tc.Function.Name != "" {
|
||||
toolIDToName[tc.ID] = tc.Function.Name
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
toolInstruction = BuildToolInstruction(req.Tools)
|
||||
|
||||
// 2. Process and coalesce messages preserving turn parity
|
||||
var staged []ChatMessage
|
||||
for i := 0; i < len(req.Messages); i++ {
|
||||
msg := req.Messages[i]
|
||||
contentStr := msg.GetContentString()
|
||||
m := ChatMessage{Role: msg.Role, Content: contentStr}
|
||||
|
||||
switch msg.Role {
|
||||
case "system":
|
||||
hasSystem = true
|
||||
m.Content = contentStr
|
||||
staged = append(staged, ChatMessage{Role: "system", Content: contentStr})
|
||||
|
||||
case "assistant":
|
||||
var sb strings.Builder
|
||||
if contentStr != "" {
|
||||
@@ -465,40 +480,79 @@ func TransformMessages(req ChatCompletionRequest) (processed []ChatMessage, tool
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("<tool_call>\n{\"name\": %q, \"arguments\": %s}\n</tool_call>", tc.Function.Name, args))
|
||||
}
|
||||
m.Content = sb.String()
|
||||
staged = append(staged, ChatMessage{
|
||||
Role: "assistant",
|
||||
Content: sb.String(),
|
||||
ReasoningContent: msg.ReasoningContent,
|
||||
ToolCalls: msg.ToolCalls,
|
||||
})
|
||||
|
||||
case "tool", "function":
|
||||
m.Role = "user"
|
||||
toolName := msg.Name
|
||||
if toolName == "" {
|
||||
toolName = msg.ToolCallID
|
||||
// Gather consecutive tool returns into a coalesced turn
|
||||
var toolResponses []string
|
||||
j := i
|
||||
for j < len(req.Messages) && (req.Messages[j].Role == "tool" || req.Messages[j].Role == "function") {
|
||||
tMsg := req.Messages[j]
|
||||
tContent := tMsg.GetContentString()
|
||||
tName := tMsg.Name
|
||||
if tName == "" && tMsg.ToolCallID != "" {
|
||||
if mapped, ok := toolIDToName[tMsg.ToolCallID]; ok {
|
||||
tName = mapped
|
||||
} else {
|
||||
tName = tMsg.ToolCallID
|
||||
}
|
||||
}
|
||||
var contentJSON []byte
|
||||
if json.Valid([]byte(tContent)) {
|
||||
contentJSON = []byte(tContent)
|
||||
} else {
|
||||
contentJSON, _ = json.Marshal(tContent)
|
||||
}
|
||||
toolResponses = append(toolResponses, fmt.Sprintf("<tool_response>\n{\"name\": %q, \"content\": %s}\n</tool_response>", tName, string(contentJSON)))
|
||||
j++
|
||||
}
|
||||
var contentJSON []byte
|
||||
if json.Valid([]byte(contentStr)) {
|
||||
contentJSON = []byte(contentStr)
|
||||
} else {
|
||||
contentJSON, _ = json.Marshal(contentStr)
|
||||
i = j - 1 // advance loop
|
||||
|
||||
promptSuffix := "Please answer the user's request based on the tool result."
|
||||
if len(toolResponses) > 1 {
|
||||
promptSuffix = "Please answer the user's request based on the tool results."
|
||||
}
|
||||
m.Content = fmt.Sprintf("<tool_response>\n{\"name\": %q, \"content\": %s}\n</tool_response>", toolName, string(contentJSON))
|
||||
coalesced := strings.Join(toolResponses, "\n") + "\n\n" + promptSuffix
|
||||
staged = append(staged, ChatMessage{
|
||||
Role: "user",
|
||||
Content: coalesced,
|
||||
})
|
||||
|
||||
default: // "user" or other roles
|
||||
staged = append(staged, ChatMessage{Role: msg.Role, Content: contentStr})
|
||||
}
|
||||
processed = append(processed, m)
|
||||
}
|
||||
|
||||
// 3. Inject tool instructions into system prompt
|
||||
if toolInstruction != "" {
|
||||
if hasSystem {
|
||||
for i, m := range processed {
|
||||
systemInjected := false
|
||||
for i, m := range staged {
|
||||
if m.Role == "system" {
|
||||
processed[i].Content = m.GetContentString() + "\n" + strings.TrimSpace(toolInstruction)
|
||||
staged[i].Content = m.GetContentString() + "\n\n" + strings.TrimSpace(toolInstruction)
|
||||
systemInjected = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !systemInjected {
|
||||
staged = append([]ChatMessage{
|
||||
{Role: "system", Content: strings.TrimSpace(toolInstruction)},
|
||||
}, staged...)
|
||||
}
|
||||
} else {
|
||||
processed = append([]ChatMessage{
|
||||
{Role: "user", Content: strings.TrimSpace(toolInstruction)},
|
||||
}, processed...)
|
||||
staged = append([]ChatMessage{
|
||||
{Role: "system", Content: strings.TrimSpace(toolInstruction)},
|
||||
}, staged...)
|
||||
hasSystem = true
|
||||
}
|
||||
}
|
||||
|
||||
return processed, toolInstruction, hasSystem
|
||||
return staged, toolInstruction, hasSystem
|
||||
}
|
||||
|
||||
func cleanJSONBlock(input string) string {
|
||||
@@ -538,6 +592,35 @@ func sanitizeJSONValue(v interface{}) interface{} {
|
||||
}
|
||||
}
|
||||
|
||||
type ToolTagPair struct {
|
||||
Start string
|
||||
End string
|
||||
}
|
||||
|
||||
var ToolTagPairs = []ToolTagPair{
|
||||
{Start: "<tool_call>", End: "</tool_call>"},
|
||||
{Start: "<tool_calls>", End: "</tool_calls>"},
|
||||
{Start: "<function_call>", End: "</function_call>"},
|
||||
{Start: "[TOOL_CALLS]", End: "[/TOOL_CALLS]"},
|
||||
}
|
||||
|
||||
func getToolStartPrefixes() []string {
|
||||
seen := make(map[string]bool)
|
||||
var prefixes []string
|
||||
for _, pair := range ToolTagPairs {
|
||||
for i := 1; i <= len(pair.Start); i++ {
|
||||
pref := pair.Start[:i]
|
||||
if !seen[pref] {
|
||||
seen[pref] = true
|
||||
prefixes = append(prefixes, pref)
|
||||
}
|
||||
}
|
||||
}
|
||||
return prefixes
|
||||
}
|
||||
|
||||
var toolStartPrefixes = getToolStartPrefixes()
|
||||
|
||||
func repairToolCallJSON(input string) (ToolCall, bool) {
|
||||
s := strings.TrimSpace(input)
|
||||
reName := regexp.MustCompile(`"(?:name|function|action|call)"\s*:\s*"([^"]+)"`)
|
||||
@@ -547,8 +630,8 @@ func repairToolCallJSON(input string) (ToolCall, bool) {
|
||||
}
|
||||
fnName := matches[1]
|
||||
|
||||
reArgs := regexp.MustCompile(`"(?:arguments|parameters|args|input)"\s*:\s*(\{[\s\S]*\})`)
|
||||
argMatches := reArgs.FindStringSubmatch(s)
|
||||
reArgsObj := regexp.MustCompile(`"(?:arguments|parameters|args|input)"\s*:\s*(\{[\s\S]*\})`)
|
||||
argMatches := reArgsObj.FindStringSubmatch(s)
|
||||
argsStr := "{}"
|
||||
if len(argMatches) >= 2 {
|
||||
candidate := argMatches[1]
|
||||
@@ -556,6 +639,15 @@ func repairToolCallJSON(input string) (ToolCall, bool) {
|
||||
if json.Unmarshal([]byte(candidate), &dummy) == nil {
|
||||
argsStr = candidate
|
||||
}
|
||||
} else {
|
||||
reArgsStr := regexp.MustCompile(`"(?:arguments|parameters|args|input)"\s*:\s*"((?:\\.|[^"\\])*)"`)
|
||||
strMatches := reArgsStr.FindStringSubmatch(s)
|
||||
if len(strMatches) >= 2 {
|
||||
var unescaped string
|
||||
if json.Unmarshal([]byte(`"`+strMatches[1]+`"`), &unescaped) == nil {
|
||||
argsStr = unescaped
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ToolCall{
|
||||
@@ -671,18 +763,68 @@ func parseSingleToolCall(jsonStr string) (ToolCall, bool) {
|
||||
return repairToolCallJSON(cleaned)
|
||||
}
|
||||
|
||||
func parseXMLToolCall(block string) (ToolCall, bool) {
|
||||
inner := strings.TrimSpace(block)
|
||||
if strings.HasPrefix(inner, "<tool_call>") {
|
||||
inner = strings.TrimPrefix(inner, "<tool_call>")
|
||||
func parseMultipleToolCalls(raw string) ([]ToolCall, bool) {
|
||||
cleaned := cleanJSONBlock(raw)
|
||||
if cleaned == "" {
|
||||
return nil, false
|
||||
}
|
||||
if strings.HasSuffix(inner, "</tool_call>") {
|
||||
inner = strings.TrimSuffix(inner, "</tool_call>")
|
||||
|
||||
// 1. Direct JSON array: [{"name":...}, ...]
|
||||
var rawList []interface{}
|
||||
if err := json.Unmarshal([]byte(cleaned), &rawList); err == nil {
|
||||
var calls []ToolCall
|
||||
for _, item := range rawList {
|
||||
b, err := json.Marshal(item)
|
||||
if err == nil {
|
||||
if tc, ok := parseSingleToolCall(string(b)); ok {
|
||||
calls = append(calls, tc)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(calls) > 0 {
|
||||
return calls, true
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Wrapper object with tool_calls / calls array
|
||||
var rawMap map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(cleaned), &rawMap); err == nil {
|
||||
for _, listKey := range []string{"tool_calls", "calls", "functions"} {
|
||||
if subArr, ok := rawMap[listKey].([]interface{}); ok && len(subArr) > 0 {
|
||||
var calls []ToolCall
|
||||
for _, item := range subArr {
|
||||
b, err := json.Marshal(item)
|
||||
if err == nil {
|
||||
if tc, ok := parseSingleToolCall(string(b)); ok {
|
||||
calls = append(calls, tc)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(calls) > 0 {
|
||||
return calls, true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Single tool call
|
||||
if tc, ok := parseSingleToolCall(cleaned); ok {
|
||||
return []ToolCall{tc}, true
|
||||
}
|
||||
|
||||
return nil, false
|
||||
}
|
||||
|
||||
func parseXMLToolCall(block string) ([]ToolCall, bool) {
|
||||
inner := strings.TrimSpace(block)
|
||||
for _, pair := range ToolTagPairs {
|
||||
inner = strings.ReplaceAll(inner, pair.Start, "")
|
||||
inner = strings.ReplaceAll(inner, pair.End, "")
|
||||
}
|
||||
inner = cleanJSONBlock(inner)
|
||||
|
||||
if tc, ok := parseSingleToolCall(inner); ok {
|
||||
return tc, true
|
||||
if calls, ok := parseMultipleToolCalls(inner); ok && len(calls) > 0 {
|
||||
return calls, true
|
||||
}
|
||||
|
||||
var fnName string
|
||||
@@ -707,88 +849,58 @@ func parseXMLToolCall(block string) (ToolCall, bool) {
|
||||
if argsStr == "" {
|
||||
argsStr = "{}"
|
||||
}
|
||||
return ToolCall{
|
||||
return []ToolCall{{
|
||||
ID: "call_" + GenerateUUID()[:8],
|
||||
Type: "function",
|
||||
Function: ToolCallFunction{
|
||||
Name: fnName,
|
||||
Arguments: argsStr,
|
||||
},
|
||||
}, true
|
||||
}}, true
|
||||
}
|
||||
|
||||
return ToolCall{}, false
|
||||
return nil, false
|
||||
}
|
||||
|
||||
func ExtractToolCallBlocks(content string) (blocks []string, remaining string) {
|
||||
s := content
|
||||
remaining = content
|
||||
|
||||
for strings.Contains(s, "<tool_call>") {
|
||||
sIdx := strings.Index(s, "<tool_call>")
|
||||
rest := s[sIdx+len("<tool_call>"):]
|
||||
for _, pair := range ToolTagPairs {
|
||||
for strings.Contains(remaining, pair.Start) {
|
||||
sIdx := strings.Index(remaining, pair.Start)
|
||||
rest := remaining[sIdx+len(pair.Start):]
|
||||
|
||||
relNextSIdx := strings.Index(rest, "<tool_call>")
|
||||
var nextSIdx int
|
||||
if relNextSIdx != -1 {
|
||||
nextSIdx = sIdx + len("<tool_call>") + relNextSIdx
|
||||
} else {
|
||||
nextSIdx = -1
|
||||
}
|
||||
relNextSIdx := strings.Index(rest, pair.Start)
|
||||
var nextSIdx int
|
||||
if relNextSIdx != -1 {
|
||||
nextSIdx = sIdx + len(pair.Start) + relNextSIdx
|
||||
} else {
|
||||
nextSIdx = -1
|
||||
}
|
||||
|
||||
relEIdx := strings.Index(rest, "</tool_call>")
|
||||
var eIdx int
|
||||
if relEIdx != -1 {
|
||||
eIdx = sIdx + len("<tool_call>") + relEIdx
|
||||
} else {
|
||||
eIdx = -1
|
||||
}
|
||||
relEIdx := strings.Index(rest, pair.End)
|
||||
var eIdx int
|
||||
if relEIdx != -1 {
|
||||
eIdx = sIdx + len(pair.Start) + relEIdx
|
||||
} else {
|
||||
eIdx = -1
|
||||
}
|
||||
|
||||
var blockText string
|
||||
var blockEndPos int
|
||||
var blockText string
|
||||
if eIdx != -1 && (nextSIdx == -1 || eIdx < nextSIdx) {
|
||||
blockEndPos := eIdx + len(pair.End)
|
||||
blockText = remaining[sIdx:blockEndPos]
|
||||
remaining = strings.TrimSpace(remaining[:sIdx] + remaining[blockEndPos:])
|
||||
} else if nextSIdx != -1 {
|
||||
blockEndPos := nextSIdx
|
||||
blockText = remaining[sIdx:blockEndPos]
|
||||
remaining = strings.TrimSpace(remaining[:sIdx] + remaining[blockEndPos:])
|
||||
} else {
|
||||
blockText = remaining[sIdx:]
|
||||
remaining = strings.TrimSpace(remaining[:sIdx])
|
||||
}
|
||||
|
||||
if eIdx != -1 && (nextSIdx == -1 || eIdx < nextSIdx) {
|
||||
blockEndPos = eIdx + len("</tool_call>")
|
||||
blockText = s[sIdx:blockEndPos]
|
||||
s = s[blockEndPos:]
|
||||
} else if nextSIdx != -1 {
|
||||
blockEndPos = nextSIdx
|
||||
blockText = s[sIdx:blockEndPos]
|
||||
s = s[blockEndPos:]
|
||||
} else {
|
||||
blockText = s[sIdx:]
|
||||
s = ""
|
||||
}
|
||||
|
||||
blocks = append(blocks, blockText)
|
||||
}
|
||||
|
||||
for strings.Contains(remaining, "<tool_call>") {
|
||||
st := strings.Index(remaining, "<tool_call>")
|
||||
rest := remaining[st+len("<tool_call>"):]
|
||||
|
||||
relNext := strings.Index(rest, "<tool_call>")
|
||||
var nextSt int
|
||||
if relNext != -1 {
|
||||
nextSt = st + len("<tool_call>") + relNext
|
||||
} else {
|
||||
nextSt = -1
|
||||
}
|
||||
|
||||
relEn := strings.Index(rest, "</tool_call>")
|
||||
var en int
|
||||
if relEn != -1 {
|
||||
en = st + len("<tool_call>") + relEn
|
||||
} else {
|
||||
en = -1
|
||||
}
|
||||
|
||||
if en != -1 && (nextSt == -1 || en < nextSt) {
|
||||
remaining = strings.TrimSpace(remaining[:st] + remaining[en+len("</tool_call>"):])
|
||||
} else if nextSt != -1 {
|
||||
remaining = strings.TrimSpace(remaining[:st] + remaining[nextSt:])
|
||||
} else {
|
||||
remaining = strings.TrimSpace(remaining[:st])
|
||||
blocks = append(blocks, blockText)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -800,8 +912,8 @@ func DetectToolCalls(content string) ([]ToolCall, string, bool) {
|
||||
var calls []ToolCall
|
||||
|
||||
for _, block := range blocks {
|
||||
if toolCall, ok := parseXMLToolCall(block); ok {
|
||||
calls = append(calls, toolCall)
|
||||
if tcs, ok := parseXMLToolCall(block); ok {
|
||||
calls = append(calls, tcs...)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -809,8 +921,8 @@ func DetectToolCalls(content string) ([]ToolCall, string, bool) {
|
||||
return calls, remaining, true
|
||||
}
|
||||
|
||||
if tc, ok := parseSingleToolCall(strings.TrimSpace(content)); ok {
|
||||
return []ToolCall{tc}, "", true
|
||||
if tcs, ok := parseMultipleToolCalls(strings.TrimSpace(content)); ok && len(tcs) > 0 {
|
||||
return tcs, "", true
|
||||
}
|
||||
|
||||
return nil, content, false
|
||||
@@ -955,12 +1067,24 @@ func NewStreamThinkingFilter() *StreamThinkingFilter {
|
||||
}
|
||||
|
||||
func hasPrefixOf(target string, prefixes []string) int {
|
||||
maxMatch := 0
|
||||
for _, p := range prefixes {
|
||||
if strings.HasSuffix(target, p) {
|
||||
return len(p)
|
||||
if strings.HasSuffix(target, p) && len(p) > maxMatch {
|
||||
maxMatch = len(p)
|
||||
}
|
||||
}
|
||||
return 0
|
||||
return maxMatch
|
||||
}
|
||||
|
||||
func hasSuffixPrefixOf(target string, tag string) int {
|
||||
maxMatch := 0
|
||||
for i := 1; i < len(tag); i++ {
|
||||
p := tag[:i]
|
||||
if strings.HasSuffix(target, p) && len(p) > maxMatch {
|
||||
maxMatch = len(p)
|
||||
}
|
||||
}
|
||||
return maxMatch
|
||||
}
|
||||
|
||||
func (f *StreamThinkingFilter) Feed(chunk string, onContent func(string), onReasoning func(string)) {
|
||||
@@ -1034,11 +1158,13 @@ func (f *StreamThinkingFilter) Flush(onContent func(string), onReasoning func(st
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type StreamToolCallFilter struct {
|
||||
inToolCall bool
|
||||
buf string
|
||||
toolCallBuf string
|
||||
toolIndex int
|
||||
emittedCall bool
|
||||
inToolCall bool
|
||||
buf string
|
||||
toolCallBuf string
|
||||
toolIndex int
|
||||
emittedCall bool
|
||||
activePair ToolTagPair
|
||||
activeEndTag string
|
||||
}
|
||||
|
||||
func NewStreamToolCallFilter() *StreamToolCallFilter {
|
||||
@@ -1047,22 +1173,31 @@ func NewStreamToolCallFilter() *StreamToolCallFilter {
|
||||
|
||||
func (f *StreamToolCallFilter) Feed(chunk string, onContent func(string), onToolCall func(ToolCall)) {
|
||||
f.buf += chunk
|
||||
toolStartTag := "<tool_call>"
|
||||
toolEndTag := "</tool_call>"
|
||||
|
||||
startPrefixes := []string{"<", "<t", "<to", "<too", "<tool", "<tool_", "<tool_c", "<tool_ca", "<tool_cal", "<tool_call"}
|
||||
endPrefixes := []string{"<", "</", "</t", "</to", "</too", "</tool", "</tool_", "</tool_c", "</tool_ca", "</tool_cal", "</tool_call"}
|
||||
|
||||
for len(f.buf) > 0 {
|
||||
if !f.inToolCall {
|
||||
if idx := strings.Index(f.buf, toolStartTag); idx != -1 {
|
||||
before := f.buf[:idx]
|
||||
earliestIdx := -1
|
||||
var matchedPair ToolTagPair
|
||||
|
||||
for _, pair := range ToolTagPairs {
|
||||
if idx := strings.Index(f.buf, pair.Start); idx != -1 {
|
||||
if earliestIdx == -1 || idx < earliestIdx {
|
||||
earliestIdx = idx
|
||||
matchedPair = pair
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if earliestIdx != -1 {
|
||||
before := f.buf[:earliestIdx]
|
||||
if before != "" {
|
||||
onContent(before)
|
||||
}
|
||||
f.inToolCall = true
|
||||
f.buf = f.buf[idx+len(toolStartTag):]
|
||||
} else if matchLen := hasPrefixOf(f.buf, startPrefixes); matchLen > 0 {
|
||||
f.activePair = matchedPair
|
||||
f.activeEndTag = matchedPair.End
|
||||
f.buf = f.buf[earliestIdx+len(matchedPair.Start):]
|
||||
} else if matchLen := hasPrefixOf(f.buf, toolStartPrefixes); matchLen > 0 {
|
||||
safe := f.buf[:len(f.buf)-matchLen]
|
||||
if safe != "" {
|
||||
onContent(safe)
|
||||
@@ -1075,28 +1210,32 @@ func (f *StreamToolCallFilter) Feed(chunk string, onContent func(string), onTool
|
||||
break
|
||||
}
|
||||
} else {
|
||||
if idx := strings.Index(f.buf, toolEndTag); idx != -1 {
|
||||
if idx := strings.Index(f.buf, f.activeEndTag); idx != -1 {
|
||||
f.toolCallBuf += f.buf[:idx]
|
||||
f.buf = f.buf[idx+len(toolEndTag):]
|
||||
f.buf = f.buf[idx+len(f.activeEndTag):]
|
||||
f.inToolCall = false
|
||||
|
||||
if tc, ok := parseSingleToolCall(f.toolCallBuf); ok {
|
||||
idxCopy := f.toolIndex
|
||||
tc.Index = &idxCopy
|
||||
f.toolIndex++
|
||||
f.emittedCall = true
|
||||
onToolCall(tc)
|
||||
} else if tc2, ok2 := parseXMLToolCall("<tool_call>" + f.toolCallBuf + "</tool_call>"); ok2 {
|
||||
idxCopy := f.toolIndex
|
||||
tc2.Index = &idxCopy
|
||||
f.toolIndex++
|
||||
f.emittedCall = true
|
||||
onToolCall(tc2)
|
||||
if tcs, ok := parseMultipleToolCalls(f.toolCallBuf); ok && len(tcs) > 0 {
|
||||
for _, tc := range tcs {
|
||||
idxCopy := f.toolIndex
|
||||
tc.Index = &idxCopy
|
||||
f.toolIndex++
|
||||
f.emittedCall = true
|
||||
onToolCall(tc)
|
||||
}
|
||||
} else if tcs2, ok2 := parseXMLToolCall(f.activePair.Start + f.toolCallBuf + f.activePair.End); ok2 && len(tcs2) > 0 {
|
||||
for _, tc := range tcs2 {
|
||||
idxCopy := f.toolIndex
|
||||
tc.Index = &idxCopy
|
||||
f.toolIndex++
|
||||
f.emittedCall = true
|
||||
onToolCall(tc)
|
||||
}
|
||||
} else {
|
||||
onContent("<tool_call>" + f.toolCallBuf + "</tool_call>")
|
||||
onContent(f.activePair.Start + f.toolCallBuf + f.activePair.End)
|
||||
}
|
||||
f.toolCallBuf = ""
|
||||
} else if matchLen := hasPrefixOf(f.buf, endPrefixes); matchLen > 0 {
|
||||
} else if matchLen := hasSuffixPrefixOf(f.buf, f.activeEndTag); matchLen > 0 {
|
||||
safe := f.buf[:len(f.buf)-matchLen]
|
||||
f.toolCallBuf += safe
|
||||
f.buf = f.buf[len(f.buf)-matchLen:]
|
||||
@@ -1112,18 +1251,24 @@ func (f *StreamToolCallFilter) Feed(chunk string, onContent func(string), onTool
|
||||
|
||||
func (f *StreamToolCallFilter) Flush(onContent func(string), onToolCall func(ToolCall)) {
|
||||
if f.inToolCall && len(f.toolCallBuf) > 0 {
|
||||
if tc, ok := parseSingleToolCall(f.toolCallBuf); ok {
|
||||
idxCopy := f.toolIndex
|
||||
tc.Index = &idxCopy
|
||||
f.emittedCall = true
|
||||
onToolCall(tc)
|
||||
} else if tc2, ok2 := parseXMLToolCall("<tool_call>" + f.toolCallBuf + "</tool_call>"); ok2 {
|
||||
idxCopy := f.toolIndex
|
||||
tc2.Index = &idxCopy
|
||||
f.emittedCall = true
|
||||
onToolCall(tc2)
|
||||
if tcs, ok := parseMultipleToolCalls(f.toolCallBuf); ok && len(tcs) > 0 {
|
||||
for _, tc := range tcs {
|
||||
idxCopy := f.toolIndex
|
||||
tc.Index = &idxCopy
|
||||
f.toolIndex++
|
||||
f.emittedCall = true
|
||||
onToolCall(tc)
|
||||
}
|
||||
} else if tcs2, ok2 := parseXMLToolCall(f.activePair.Start + f.toolCallBuf + f.activePair.End); ok2 && len(tcs2) > 0 {
|
||||
for _, tc := range tcs2 {
|
||||
idxCopy := f.toolIndex
|
||||
tc.Index = &idxCopy
|
||||
f.toolIndex++
|
||||
f.emittedCall = true
|
||||
onToolCall(tc)
|
||||
}
|
||||
} else {
|
||||
onContent("<tool_call>" + f.toolCallBuf)
|
||||
onContent(f.activePair.Start + f.toolCallBuf)
|
||||
}
|
||||
f.toolCallBuf = ""
|
||||
}
|
||||
@@ -1268,21 +1413,18 @@ func (d *SpaceDiscovery) GetModelList() []ModelItem {
|
||||
return items
|
||||
}
|
||||
|
||||
// InspectSpace queries Gradio's /gradio_api/info, /config, and HuggingFace Space APIs
|
||||
// to build an adaptive schema mapping for any Gradio space.
|
||||
func InspectSpace(client *http.Client, rawURL, userAgent string) (*SpaceDiscovery, error) {
|
||||
cleanURL := strings.TrimRight(rawURL, "/")
|
||||
if !strings.HasPrefix(cleanURL, "http://") && !strings.HasPrefix(cleanURL, "https://") {
|
||||
func NewDefaultSpaceDiscovery(spaceURL string) *SpaceDiscovery {
|
||||
cleanURL := strings.TrimRight(spaceURL, "/")
|
||||
if cleanURL != "" && !strings.HasPrefix(cleanURL, "http://") && !strings.HasPrefix(cleanURL, "https://") {
|
||||
cleanURL = "https://" + cleanURL
|
||||
}
|
||||
|
||||
discovery := &SpaceDiscovery{
|
||||
SpaceURL: cleanURL,
|
||||
APIPrefix: "/gradio_api",
|
||||
Endpoint: "/chat_fn",
|
||||
CleanEndpoint: "chat_fn",
|
||||
Protocol: "call",
|
||||
TotalInputs: 1,
|
||||
return &SpaceDiscovery{
|
||||
SpaceURL: cleanURL,
|
||||
APIPrefix: "/gradio_api",
|
||||
Endpoint: "/chat_fn",
|
||||
CleanEndpoint: "chat_fn",
|
||||
Protocol: "call",
|
||||
TotalInputs: 1,
|
||||
HistoryIndex: -1,
|
||||
MessageIndex: 0,
|
||||
SystemIndex: -1,
|
||||
@@ -1295,6 +1437,17 @@ func InspectSpace(client *http.Client, rawURL, userAgent string) (*SpaceDiscover
|
||||
HistoryFormat: "messages",
|
||||
LastDiscovered: time.Now(),
|
||||
}
|
||||
}
|
||||
|
||||
// InspectSpace queries Gradio's /gradio_api/info, /config, and HuggingFace Space APIs
|
||||
// to build an adaptive schema mapping for any Gradio space.
|
||||
func InspectSpace(client *http.Client, rawURL, userAgent string) (*SpaceDiscovery, error) {
|
||||
cleanURL := strings.TrimRight(rawURL, "/")
|
||||
if !strings.HasPrefix(cleanURL, "http://") && !strings.HasPrefix(cleanURL, "https://") {
|
||||
cleanURL = "https://" + cleanURL
|
||||
}
|
||||
|
||||
discovery := NewDefaultSpaceDiscovery(cleanURL)
|
||||
|
||||
// 1. Try fetching /gradio_api/info or /info
|
||||
var infoResp GradioAPIInfoResponse
|
||||
@@ -1532,16 +1685,19 @@ func InspectSpace(client *http.Client, rawURL, userAgent string) (*SpaceDiscover
|
||||
}
|
||||
}
|
||||
|
||||
// Check parameters in bestEndpointInfo for history support
|
||||
// Check parameters in bestEndpointInfo for input indices and history support
|
||||
if bestEndpointInfo != nil {
|
||||
if discovery.TotalInputs < len(bestEndpointInfo.Parameters) {
|
||||
discovery.TotalInputs = len(bestEndpointInfo.Parameters)
|
||||
}
|
||||
for idx, p := range bestEndpointInfo.Parameters {
|
||||
pName := strings.ToLower(p.ParameterName)
|
||||
if strings.Contains(pName, "message") && discovery.MessageIndex == 0 {
|
||||
discovery.MessageIndex = idx
|
||||
} else if strings.Contains(pName, "history") {
|
||||
discovery.HistoryIndex = idx
|
||||
} else if strings.Contains(pName, "system") {
|
||||
if strings.Contains(pName, "system") {
|
||||
discovery.SystemIndex = idx
|
||||
} else if strings.Contains(pName, "history") || strings.Contains(pName, "chat") {
|
||||
discovery.HistoryIndex = idx
|
||||
} else if strings.Contains(pName, "message") || (strings.Contains(pName, "prompt") && !strings.Contains(pName, "system")) || strings.Contains(pName, "text") {
|
||||
discovery.MessageIndex = idx
|
||||
} else if strings.Contains(pName, "think_level") || strings.Contains(pName, "thinking_level") {
|
||||
discovery.ThinkLevelIndex = idx
|
||||
} else if strings.Contains(pName, "functions") || strings.Contains(pName, "tools") {
|
||||
@@ -1660,20 +1816,10 @@ func (g *GradioGateway) GetDiscovery(spaceURL, userAgent string) *SpaceDiscovery
|
||||
}
|
||||
|
||||
// Fallback discovery
|
||||
fallback := &SpaceDiscovery{
|
||||
SpaceURL: cleanTarget,
|
||||
APIPrefix: "/gradio_api",
|
||||
Endpoint: "/chat_fn",
|
||||
CleanEndpoint: "chat_fn",
|
||||
Protocol: "call",
|
||||
TotalInputs: 2,
|
||||
HistoryIndex: -1,
|
||||
MessageIndex: 0,
|
||||
SystemIndex: -1,
|
||||
PrimaryModel: "gradio-chat",
|
||||
Models: []string{"gradio-chat"},
|
||||
LastDiscovered: time.Now(),
|
||||
}
|
||||
fallback := NewDefaultSpaceDiscovery(cleanTarget)
|
||||
fallback.TotalInputs = 2
|
||||
fallback.PrimaryModel = "gradio-chat"
|
||||
fallback.Models = []string{"gradio-chat"}
|
||||
g.discoveries[cleanTarget] = fallback
|
||||
return fallback
|
||||
}
|
||||
@@ -1703,13 +1849,27 @@ func (g *GradioGateway) BuildGradioPayload(disc *SpaceDiscovery, req ChatComplet
|
||||
var nonSystem []ChatMessage
|
||||
for _, m := range transformed {
|
||||
cStr := m.GetContentString()
|
||||
if m.Role == "system" && systemPromptStr == "" {
|
||||
systemPromptStr = cStr
|
||||
if m.Role == "system" {
|
||||
if systemPromptStr == "" {
|
||||
systemPromptStr = cStr
|
||||
} else {
|
||||
systemPromptStr += "\n\n" + cStr
|
||||
}
|
||||
} else {
|
||||
nonSystem = append(nonSystem, m)
|
||||
}
|
||||
}
|
||||
|
||||
// If the space has NO native system prompt input (disc.SystemIndex == -1),
|
||||
// but we have system instructions (from system message or tool instructions):
|
||||
if disc.SystemIndex == -1 && systemPromptStr != "" && len(nonSystem) > 0 {
|
||||
// If the space supports conversation history, prepend system instructions to the first turn
|
||||
if disc.HistoryIndex != -1 {
|
||||
nonSystem[0].Content = systemPromptStr + "\n\n" + nonSystem[0].GetContentString()
|
||||
systemPromptStr = ""
|
||||
}
|
||||
}
|
||||
|
||||
if len(nonSystem) > 0 {
|
||||
for i := 0; i < len(nonSystem)-1; i++ {
|
||||
m := nonSystem[i]
|
||||
@@ -1803,7 +1963,15 @@ func (g *GradioGateway) BuildGradioPayload(disc *SpaceDiscovery, req ChatComplet
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("%s: %s\n\n", roleLabel, m.GetContentString()))
|
||||
}
|
||||
sb.WriteString(lastUserMessage)
|
||||
lastRoleLabel := "User"
|
||||
if len(nonSystem) > 0 && nonSystem[len(nonSystem)-1].Role == "assistant" {
|
||||
lastRoleLabel = "Assistant"
|
||||
}
|
||||
if sb.Len() > 0 {
|
||||
sb.WriteString(fmt.Sprintf("%s: %s", lastRoleLabel, lastUserMessage))
|
||||
} else {
|
||||
sb.WriteString(lastUserMessage)
|
||||
}
|
||||
promptMessageText = sb.String()
|
||||
}
|
||||
}
|
||||
|
||||
+405
@@ -492,3 +492,408 @@ func TestHunyuan3MockServerCompletion(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestUniversalToolCallingTransformMessages(t *testing.T) {
|
||||
req := ChatCompletionRequest{
|
||||
Tools: []Tool{
|
||||
{
|
||||
Type: "function",
|
||||
Function: map[string]interface{}{
|
||||
"name": "get_weather",
|
||||
"description": "Get current weather",
|
||||
},
|
||||
},
|
||||
},
|
||||
Messages: []ChatMessage{
|
||||
{Role: "user", Content: "What is the weather in Tokyo and Paris?"},
|
||||
{
|
||||
Role: "assistant",
|
||||
ToolCalls: []ToolCall{
|
||||
{ID: "call_tokyo", Type: "function", Function: ToolCallFunction{Name: "get_weather", Arguments: `{"city":"Tokyo"}`}},
|
||||
{ID: "call_paris", Type: "function", Function: ToolCallFunction{Name: "get_weather", Arguments: `{"city":"Paris"}`}},
|
||||
},
|
||||
},
|
||||
{Role: "tool", ToolCallID: "call_tokyo", Content: `{"temp": 20}`},
|
||||
{Role: "tool", ToolCallID: "call_paris", Content: `{"temp": 15}`},
|
||||
},
|
||||
}
|
||||
|
||||
processed, toolInstruction, hasSystem := TransformMessages(req)
|
||||
if !hasSystem {
|
||||
t.Errorf("expected hasSystem to be true after injecting tool instructions")
|
||||
}
|
||||
if toolInstruction == "" {
|
||||
t.Errorf("expected non-empty toolInstruction")
|
||||
}
|
||||
|
||||
// Expect:
|
||||
// [0] System message with tool instructions
|
||||
// [1] User message: "What is the weather in Tokyo and Paris?"
|
||||
// [2] Assistant message with <tool_call> blocks
|
||||
// [3] User message with coalesced <tool_response> blocks
|
||||
if len(processed) != 4 {
|
||||
t.Fatalf("expected 4 processed messages, got %d", len(processed))
|
||||
}
|
||||
|
||||
if processed[0].Role != "system" || !strings.Contains(processed[0].GetContentString(), "Tool Calling Instructions") {
|
||||
t.Errorf("unexpected message 0: %+v", processed[0])
|
||||
}
|
||||
|
||||
if processed[1].Role != "user" || processed[1].GetContentString() != "What is the weather in Tokyo and Paris?" {
|
||||
t.Errorf("unexpected message 1: %+v", processed[1])
|
||||
}
|
||||
|
||||
if processed[2].Role != "assistant" || !strings.Contains(processed[2].GetContentString(), "get_weather") {
|
||||
t.Errorf("unexpected message 2: %+v", processed[2])
|
||||
}
|
||||
|
||||
respContent := processed[3].GetContentString()
|
||||
if processed[3].Role != "user" {
|
||||
t.Errorf("expected coalesced message 3 to have role user, got %q", processed[3].Role)
|
||||
}
|
||||
if !strings.Contains(respContent, `{"name": "get_weather", "content": {"temp": 20}}`) {
|
||||
t.Errorf("expected resolved function name get_weather for tokyo, got:\n%s", respContent)
|
||||
}
|
||||
if !strings.Contains(respContent, `{"name": "get_weather", "content": {"temp": 15}}`) {
|
||||
t.Errorf("expected resolved function name get_weather for paris, got:\n%s", respContent)
|
||||
}
|
||||
if !strings.Contains(respContent, "Please answer the user's request based on the tool results.") {
|
||||
t.Errorf("expected continuation prompt in coalesced message, got:\n%s", respContent)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUniversalToolCallDetectionVariants(t *testing.T) {
|
||||
// 1. Array of tool calls inside <tool_calls> tag
|
||||
multiXML := `<tool_calls>
|
||||
[
|
||||
{"name": "get_weather", "arguments": {"city": "Tokyo"}},
|
||||
{"name": "get_weather", "arguments": {"city": "Paris"}}
|
||||
]
|
||||
</tool_calls>`
|
||||
calls1, rem1, ok1 := DetectToolCalls(multiXML)
|
||||
if !ok1 || len(calls1) != 2 {
|
||||
t.Fatalf("expected 2 tool calls from <tool_calls>, got %d", len(calls1))
|
||||
}
|
||||
if calls1[0].Function.Name != "get_weather" || calls1[1].Function.Name != "get_weather" {
|
||||
t.Errorf("unexpected function names: %+v", calls1)
|
||||
}
|
||||
if rem1 != "" {
|
||||
t.Errorf("expected empty remaining, got %q", rem1)
|
||||
}
|
||||
|
||||
// 2. <function_call> tag
|
||||
fnCallXML := `Some preamble before call.
|
||||
<function_call>
|
||||
{"name": "search", "arguments": {"q": "golang"}}
|
||||
</function_call>
|
||||
Some postamble.`
|
||||
calls2, rem2, ok2 := DetectToolCalls(fnCallXML)
|
||||
if !ok2 || len(calls2) != 1 {
|
||||
t.Fatalf("expected 1 tool call from <function_call>, got %d", len(calls2))
|
||||
}
|
||||
if calls2[0].Function.Name != "search" {
|
||||
t.Errorf("expected function search, got %q", calls2[0].Function.Name)
|
||||
}
|
||||
if strings.Contains(rem2, "function_call") {
|
||||
t.Errorf("expected tag stripped from remaining, got %q", rem2)
|
||||
}
|
||||
if !strings.Contains(rem2, "Some preamble") || !strings.Contains(rem2, "Some postamble") {
|
||||
t.Errorf("expected surrounding text preserved in remaining, got %q", rem2)
|
||||
}
|
||||
|
||||
// 3. [TOOL_CALLS] bracket syntax
|
||||
bracketXML := `[TOOL_CALLS]
|
||||
{"name": "calculate", "arguments": {"x": 42}}
|
||||
[/TOOL_CALLS]`
|
||||
calls3, rem3, ok3 := DetectToolCalls(bracketXML)
|
||||
if !ok3 || len(calls3) != 1 {
|
||||
t.Fatalf("expected 1 tool call from [TOOL_CALLS], got %d", len(calls3))
|
||||
}
|
||||
if calls3[0].Function.Name != "calculate" {
|
||||
t.Errorf("expected function calculate, got %q", calls3[0].Function.Name)
|
||||
}
|
||||
if rem3 != "" {
|
||||
t.Errorf("expected empty remaining, got %q", rem3)
|
||||
}
|
||||
|
||||
// 4. Raw JSON array without tags
|
||||
rawArray := `[{"name": "f1", "arguments": {}}, {"name": "f2", "arguments": {}}]`
|
||||
calls4, rem4, ok4 := DetectToolCalls(rawArray)
|
||||
if !ok4 || len(calls4) != 2 {
|
||||
t.Fatalf("expected 2 calls from raw array, got %d", len(calls4))
|
||||
}
|
||||
if calls4[0].Function.Name != "f1" || calls4[1].Function.Name != "f2" {
|
||||
t.Errorf("unexpected names from raw array: %+v", calls4)
|
||||
}
|
||||
if rem4 != "" {
|
||||
t.Errorf("expected empty remaining, got %q", rem4)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUniversalStreamToolCallFilterVariants(t *testing.T) {
|
||||
filter := NewStreamToolCallFilter()
|
||||
var contentParts []string
|
||||
var toolCalls []ToolCall
|
||||
|
||||
onContent := func(s string) { contentParts = append(contentParts, s) }
|
||||
onToolCall := func(tc ToolCall) { toolCalls = append(toolCalls, tc) }
|
||||
|
||||
// Stream using [TOOL_CALLS] across multiple chunk boundaries
|
||||
chunks := []string{
|
||||
"Preamble text: ",
|
||||
"[TOOL_",
|
||||
"CALLS]\n{\"name\": \"browse\", \"arguments\": {\"url\": \"example.com\"}}\n[/TOOL_",
|
||||
"CALLS]",
|
||||
" Completed.",
|
||||
}
|
||||
|
||||
for _, c := range chunks {
|
||||
filter.Feed(c, onContent, onToolCall)
|
||||
}
|
||||
filter.Flush(onContent, onToolCall)
|
||||
|
||||
if len(toolCalls) != 1 {
|
||||
t.Fatalf("expected 1 tool call from stream filter, got %d", len(toolCalls))
|
||||
}
|
||||
if toolCalls[0].Function.Name != "browse" {
|
||||
t.Errorf("expected function browse, got %q", toolCalls[0].Function.Name)
|
||||
}
|
||||
if !filter.emittedCall {
|
||||
t.Errorf("expected emittedCall to be true")
|
||||
}
|
||||
fullContent := strings.Join(contentParts, "")
|
||||
if strings.Contains(fullContent, "TOOL_CALLS") {
|
||||
t.Errorf("tag leaked into stream content: %q", fullContent)
|
||||
}
|
||||
if fullContent != "Preamble text: Completed." {
|
||||
t.Errorf("unexpected streamed content: %q", fullContent)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildGradioPayloadGenericSpaces(t *testing.T) {
|
||||
gw := &GradioGateway{}
|
||||
|
||||
req := ChatCompletionRequest{
|
||||
Tools: []Tool{
|
||||
{Type: "function", Function: map[string]interface{}{"name": "lookup"}},
|
||||
},
|
||||
Messages: []ChatMessage{
|
||||
{Role: "user", Content: "What is 10+10?"},
|
||||
{
|
||||
Role: "assistant",
|
||||
ToolCalls: []ToolCall{
|
||||
{ID: "c1", Type: "function", Function: ToolCallFunction{Name: "lookup", Arguments: `{"q":"10+10"}`}},
|
||||
},
|
||||
},
|
||||
{Role: "tool", ToolCallID: "c1", Content: `{"result": 20}`},
|
||||
},
|
||||
}
|
||||
|
||||
// 1. Space with native system prompt input (SystemIndex: 0, MessageIndex: 1, HistoryIndex: 2)
|
||||
discWithSystem := NewDefaultSpaceDiscovery("https://space-1.hf.space")
|
||||
discWithSystem.TotalInputs = 3
|
||||
discWithSystem.SystemIndex = 0
|
||||
discWithSystem.MessageIndex = 1
|
||||
discWithSystem.HistoryIndex = 2
|
||||
discWithSystem.HistoryFormat = "pairs"
|
||||
|
||||
data1, err := gw.BuildGradioPayload(discWithSystem, req)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to build payload 1: %v", err)
|
||||
}
|
||||
sysStr, ok := data1[0].(string)
|
||||
if !ok || !strings.Contains(sysStr, "Tool Calling Instructions") {
|
||||
t.Errorf("expected system prompt at index 0, got %v", data1[0])
|
||||
}
|
||||
msgStr, ok := data1[1].(string)
|
||||
if !ok || !strings.Contains(msgStr, "Please answer the user's request based on the tool result.") {
|
||||
t.Errorf("expected coalesced tool prompt at index 1, got %v", data1[1])
|
||||
}
|
||||
pairs1, ok := data1[2].([][]string)
|
||||
if !ok || len(pairs1) != 1 {
|
||||
t.Fatalf("expected 1 history pair at index 2, got %T (%v)", data1[2], data1[2])
|
||||
}
|
||||
if pairs1[0][0] != "What is 10+10?" || !strings.Contains(pairs1[0][1], "lookup") {
|
||||
t.Errorf("unexpected history pair: %+v", pairs1[0])
|
||||
}
|
||||
|
||||
// 2. Space without system prompt (SystemIndex: -1, MessageIndex: 0, HistoryIndex: 1)
|
||||
discNoSystem := NewDefaultSpaceDiscovery("https://space-2.hf.space")
|
||||
discNoSystem.TotalInputs = 2
|
||||
discNoSystem.SystemIndex = -1
|
||||
discNoSystem.MessageIndex = 0
|
||||
discNoSystem.HistoryIndex = 1
|
||||
discNoSystem.HistoryFormat = "pairs"
|
||||
|
||||
data2, err := gw.BuildGradioPayload(discNoSystem, req)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to build payload 2: %v", err)
|
||||
}
|
||||
pairs2, ok := data2[1].([][]string)
|
||||
if !ok || len(pairs2) != 1 {
|
||||
t.Fatalf("expected 1 history pair at index 1, got %T (%v)", data2[1], data2[1])
|
||||
}
|
||||
// Instructions prepended to the first user turn:
|
||||
if !strings.Contains(pairs2[0][0], "Tool Calling Instructions") || !strings.Contains(pairs2[0][0], "What is 10+10?") {
|
||||
t.Errorf("expected system instructions prepended to first pair user message, got: %q", pairs2[0][0])
|
||||
}
|
||||
if !strings.Contains(pairs2[0][1], "lookup") {
|
||||
t.Errorf("expected assistant tool call in pair bot turn, got: %q", pairs2[0][1])
|
||||
}
|
||||
|
||||
// 3. Single-textbox space (SystemIndex: -1, MessageIndex: 0, HistoryIndex: -1)
|
||||
discSingleInput := NewDefaultSpaceDiscovery("https://space-3.hf.space")
|
||||
discSingleInput.TotalInputs = 1
|
||||
discSingleInput.SystemIndex = -1
|
||||
discSingleInput.MessageIndex = 0
|
||||
discSingleInput.HistoryIndex = -1
|
||||
|
||||
data3, err := gw.BuildGradioPayload(discSingleInput, req)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to build payload 3: %v", err)
|
||||
}
|
||||
transcript, ok := data3[0].(string)
|
||||
if !ok {
|
||||
t.Fatalf("expected string transcript, got %T", data3[0])
|
||||
}
|
||||
if !strings.Contains(transcript, "System: ") || !strings.Contains(transcript, "User: What is 10+10?") || !strings.Contains(transcript, "Assistant: <tool_call>") {
|
||||
t.Errorf("unexpected single-input transcript: %s", transcript)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenericSpaceMockServerToolCalling(t *testing.T) {
|
||||
var lastReceivedData []interface{}
|
||||
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/gradio_api/info" {
|
||||
resp := GradioAPIInfoResponse{
|
||||
NamedEndpoints: map[string]GradioEndpointInfo{
|
||||
"/chat_fn": {
|
||||
Parameters: []GradioParamInfo{
|
||||
{ParameterName: "system_prompt"},
|
||||
{ParameterName: "message"},
|
||||
{ParameterName: "history"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(resp)
|
||||
return
|
||||
}
|
||||
|
||||
if r.URL.Path == "/gradio_api/call/chat_fn" {
|
||||
var body map[string]interface{}
|
||||
json.NewDecoder(r.Body).Decode(&body)
|
||||
if dataSlice, ok := body["data"].([]interface{}); ok {
|
||||
lastReceivedData = dataSlice
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(GradioJoinResponse{EventID: "evt_generic"})
|
||||
return
|
||||
}
|
||||
|
||||
if r.URL.Path == "/gradio_api/call/chat_fn/evt_generic" {
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
flusher, ok := w.(http.Flusher)
|
||||
if !ok {
|
||||
t.Fatal("expected flusher")
|
||||
}
|
||||
msgStr := ""
|
||||
if len(lastReceivedData) > 1 {
|
||||
msgStr, _ = lastReceivedData[1].(string)
|
||||
}
|
||||
|
||||
if strings.Contains(msgStr, "<tool_response>") {
|
||||
// Turn 2: answer
|
||||
fmt.Fprintf(w, "event: generating\ndata: [\"The weather in Tokyo is 20 C.\", null]\n\n")
|
||||
flusher.Flush()
|
||||
fmt.Fprintf(w, "event: complete\ndata: [\"The weather in Tokyo is 20 C.\", null]\n\n")
|
||||
flusher.Flush()
|
||||
} else {
|
||||
// Turn 1: tool call
|
||||
fmt.Fprintf(w, "event: generating\ndata: [\"<tool_call>\\n{\\\"name\\\": \\\"get_weather\\\", \\\"arguments\\\": {\\\"city\\\": \\\"Tokyo\\\"}}\\n</tool_call>\", null]\n\n")
|
||||
flusher.Flush()
|
||||
fmt.Fprintf(w, "event: complete\ndata: [\"<tool_call>\\n{\\\"name\\\": \\\"get_weather\\\", \\\"arguments\\\": {\\\"city\\\": \\\"Tokyo\\\"}}\\n</tool_call>\", null]\n\n")
|
||||
flusher.Flush()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
http.NotFound(w, r)
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
gw := NewGradioGateway(ts.URL, "", 10*time.Second)
|
||||
|
||||
// Turn 1: User question with tools
|
||||
req1 := ChatCompletionRequest{
|
||||
Model: "generic-bot",
|
||||
Tools: []Tool{
|
||||
{Type: "function", Function: map[string]interface{}{"name": "get_weather"}},
|
||||
},
|
||||
Messages: []ChatMessage{
|
||||
{Role: "user", Content: "Weather in Tokyo?"},
|
||||
},
|
||||
Stream: false,
|
||||
}
|
||||
|
||||
b1, _ := json.Marshal(req1)
|
||||
httpReq1 := httptest.NewRequest("POST", "/v1/chat/completions", bytes.NewBuffer(b1))
|
||||
rec1 := httptest.NewRecorder()
|
||||
|
||||
err := gw.ExecuteChatCompletion(rec1, httpReq1, req1)
|
||||
if err != nil {
|
||||
t.Fatalf("Turn 1 execution failed: %v", err)
|
||||
}
|
||||
|
||||
var resp1 ChatCompletionResponse
|
||||
if err := json.NewDecoder(rec1.Body).Decode(&resp1); err != nil {
|
||||
t.Fatalf("Turn 1 decode failed: %v", err)
|
||||
}
|
||||
if resp1.Choices[0].FinishReason != "tool_calls" {
|
||||
t.Fatalf("expected finish_reason 'tool_calls', got %q", resp1.Choices[0].FinishReason)
|
||||
}
|
||||
if len(resp1.Choices[0].Message.ToolCalls) != 1 {
|
||||
t.Fatalf("expected 1 tool call, got %d", len(resp1.Choices[0].Message.ToolCalls))
|
||||
}
|
||||
tc := resp1.Choices[0].Message.ToolCalls[0]
|
||||
if tc.Function.Name != "get_weather" {
|
||||
t.Fatalf("expected function name get_weather, got %q", tc.Function.Name)
|
||||
}
|
||||
|
||||
// Turn 2: Send tool response
|
||||
req2 := ChatCompletionRequest{
|
||||
Model: "generic-bot",
|
||||
Tools: []Tool{
|
||||
{Type: "function", Function: map[string]interface{}{"name": "get_weather"}},
|
||||
},
|
||||
Messages: []ChatMessage{
|
||||
{Role: "user", Content: "Weather in Tokyo?"},
|
||||
resp1.Choices[0].Message,
|
||||
{Role: "tool", ToolCallID: tc.ID, Content: `{"temp": 20}`},
|
||||
},
|
||||
Stream: false,
|
||||
}
|
||||
|
||||
b2, _ := json.Marshal(req2)
|
||||
httpReq2 := httptest.NewRequest("POST", "/v1/chat/completions", bytes.NewBuffer(b2))
|
||||
rec2 := httptest.NewRecorder()
|
||||
|
||||
err = gw.ExecuteChatCompletion(rec2, httpReq2, req2)
|
||||
if err != nil {
|
||||
t.Fatalf("Turn 2 execution failed: %v", err)
|
||||
}
|
||||
|
||||
var resp2 ChatCompletionResponse
|
||||
if err := json.NewDecoder(rec2.Body).Decode(&resp2); err != nil {
|
||||
t.Fatalf("Turn 2 decode failed: %v", err)
|
||||
}
|
||||
if resp2.Choices[0].FinishReason != "stop" {
|
||||
t.Errorf("expected finish_reason 'stop', got %q", resp2.Choices[0].FinishReason)
|
||||
}
|
||||
if resp2.Choices[0].Message.GetContentString() != "The weather in Tokyo is 20 C." {
|
||||
t.Errorf("expected final answer, got %q", resp2.Choices[0].Message.GetContentString())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user