Skip to content

Commit cd6389d

Browse files
authored
Create Interface for Backend AI Providers (#2572)
Created an interface in aiusechat for the backend providers. Use that interface throughout the usechat code. Isolate the backend implementations to only the new file usechat-backend.go.
1 parent d3ecf89 commit cd6389d

8 files changed

Lines changed: 508 additions & 397 deletions

File tree

pkg/aiusechat/aiutil/aiutil.go

Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
// Copyright 2025, Command Line Inc.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
package aiutil
5+
6+
import (
7+
"bytes"
8+
"crypto/sha256"
9+
"encoding/base64"
10+
"encoding/hex"
11+
"encoding/json"
12+
"fmt"
13+
"strconv"
14+
"strings"
15+
16+
"github.com/wavetermdev/waveterm/pkg/aiusechat/uctypes"
17+
"github.com/wavetermdev/waveterm/pkg/util/utilfn"
18+
)
19+
20+
// ExtractXmlAttribute extracts an attribute value from an XML-like tag.
21+
// Expects double-quoted strings where internal quotes are encoded as ".
22+
// Returns the unquoted value and true if found, or empty string and false if not found or invalid.
23+
func ExtractXmlAttribute(tag, attrName string) (string, bool) {
24+
attrStart := strings.Index(tag, attrName+"=")
25+
if attrStart == -1 {
26+
return "", false
27+
}
28+
29+
pos := attrStart + len(attrName+"=")
30+
start := strings.Index(tag[pos:], `"`)
31+
if start == -1 {
32+
return "", false
33+
}
34+
start += pos
35+
36+
end := strings.Index(tag[start+1:], `"`)
37+
if end == -1 {
38+
return "", false
39+
}
40+
end += start + 1
41+
42+
quotedValue := tag[start : end+1]
43+
value, err := strconv.Unquote(quotedValue)
44+
if err != nil {
45+
return "", false
46+
}
47+
48+
value = strings.ReplaceAll(value, """, `"`)
49+
return value, true
50+
}
51+
52+
// GenerateDeterministicSuffix creates an 8-character hash from input strings
53+
func GenerateDeterministicSuffix(inputs ...string) string {
54+
hasher := sha256.New()
55+
for _, input := range inputs {
56+
hasher.Write([]byte(input))
57+
}
58+
hash := hasher.Sum(nil)
59+
return hex.EncodeToString(hash)[:8]
60+
}
61+
62+
// ExtractImageUrl extracts an image URL from either URL field (http/https/data) or raw Data
63+
func ExtractImageUrl(data []byte, url, mimeType string) (string, error) {
64+
if url != "" {
65+
if !strings.HasPrefix(url, "data:") &&
66+
!strings.HasPrefix(url, "http://") &&
67+
!strings.HasPrefix(url, "https://") {
68+
return "", fmt.Errorf("unsupported URL protocol in file part: %s", url)
69+
}
70+
return url, nil
71+
}
72+
if len(data) > 0 {
73+
base64Data := base64.StdEncoding.EncodeToString(data)
74+
return fmt.Sprintf("data:%s;base64,%s", mimeType, base64Data), nil
75+
}
76+
return "", fmt.Errorf("file part missing both url and data")
77+
}
78+
79+
// ExtractTextData extracts text data from either Data field or URL field (data: URLs only)
80+
func ExtractTextData(data []byte, url string) ([]byte, error) {
81+
if len(data) > 0 {
82+
return data, nil
83+
}
84+
if url != "" {
85+
if strings.HasPrefix(url, "data:") {
86+
_, decodedData, err := utilfn.DecodeDataURL(url)
87+
if err != nil {
88+
return nil, fmt.Errorf("failed to decode data URL for text/plain file: %w", err)
89+
}
90+
return decodedData, nil
91+
}
92+
return nil, fmt.Errorf("dropping text/plain file with URL (must be fetched and converted to data)")
93+
}
94+
return nil, fmt.Errorf("text/plain file part missing data")
95+
}
96+
97+
// FormatAttachedTextFile formats a text file attachment with proper encoding and deterministic suffix
98+
func FormatAttachedTextFile(fileName string, textContent []byte) string {
99+
if fileName == "" {
100+
fileName = "untitled.txt"
101+
}
102+
103+
encodedFileName := strings.ReplaceAll(fileName, `"`, """)
104+
quotedFileName := strconv.Quote(encodedFileName)
105+
106+
textStr := string(textContent)
107+
deterministicSuffix := GenerateDeterministicSuffix(textStr, fileName)
108+
return fmt.Sprintf("<AttachedTextFile_%s file_name=%s>\n%s\n</AttachedTextFile_%s>", deterministicSuffix, quotedFileName, textStr, deterministicSuffix)
109+
}
110+
111+
// FormatAttachedDirectoryListing formats a directory listing attachment with proper encoding and deterministic suffix
112+
func FormatAttachedDirectoryListing(directoryName, jsonContent string) string {
113+
if directoryName == "" {
114+
directoryName = "unnamed-directory"
115+
}
116+
117+
encodedDirName := strings.ReplaceAll(directoryName, `"`, "&quot;")
118+
quotedDirName := strconv.Quote(encodedDirName)
119+
120+
deterministicSuffix := GenerateDeterministicSuffix(jsonContent, directoryName)
121+
return fmt.Sprintf("<AttachedDirectoryListing_%s directory_name=%s>\n%s\n</AttachedDirectoryListing_%s>", deterministicSuffix, quotedDirName, jsonContent, deterministicSuffix)
122+
}
123+
124+
// ConvertDataUserFile converts OpenAI attached file/directory blocks to UIMessagePart
125+
// Returns (found, part) where found indicates if the prefix was matched,
126+
// and part is the converted UIMessagePart (can be nil if parsing failed)
127+
func ConvertDataUserFile(blockText string) (bool, *uctypes.UIMessagePart) {
128+
if strings.HasPrefix(blockText, "<AttachedTextFile_") {
129+
openTagEnd := strings.Index(blockText, "\n")
130+
if openTagEnd == -1 || blockText[openTagEnd-1] != '>' {
131+
return true, nil
132+
}
133+
134+
openTag := blockText[:openTagEnd]
135+
fileName, ok := ExtractXmlAttribute(openTag, "file_name")
136+
if !ok {
137+
return true, nil
138+
}
139+
140+
return true, &uctypes.UIMessagePart{
141+
Type: "data-userfile",
142+
Data: uctypes.UIMessageDataUserFile{
143+
FileName: fileName,
144+
MimeType: "text/plain",
145+
},
146+
}
147+
}
148+
149+
if strings.HasPrefix(blockText, "<AttachedDirectoryListing_") {
150+
openTagEnd := strings.Index(blockText, "\n")
151+
if openTagEnd == -1 || blockText[openTagEnd-1] != '>' {
152+
return true, nil
153+
}
154+
155+
openTag := blockText[:openTagEnd]
156+
directoryName, ok := ExtractXmlAttribute(openTag, "directory_name")
157+
if !ok {
158+
return true, nil
159+
}
160+
161+
return true, &uctypes.UIMessagePart{
162+
Type: "data-userfile",
163+
Data: uctypes.UIMessageDataUserFile{
164+
FileName: directoryName,
165+
MimeType: "directory",
166+
},
167+
}
168+
}
169+
170+
return false, nil
171+
}
172+
173+
func JsonEncodeRequestBody(reqBody any) (bytes.Buffer, error) {
174+
var buf bytes.Buffer
175+
encoder := json.NewEncoder(&buf)
176+
encoder.SetEscapeHTML(false)
177+
err := encoder.Encode(reqBody)
178+
if err != nil {
179+
return buf, err
180+
}
181+
return buf, nil
182+
}

pkg/aiusechat/anthropic/anthropic-backend.go

Lines changed: 5 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -480,16 +480,14 @@ func RunAnthropicChatStep(
480480
if rateLimitInfo.PReq == 0 && rateLimitInfo.Req > 0 {
481481
// Premium requests exhausted, but regular requests available
482482
stopReason := &uctypes.WaveStopReason{
483-
Kind: uctypes.StopKindPremiumRateLimit,
484-
RateLimitInfo: rateLimitInfo,
483+
Kind: uctypes.StopKindPremiumRateLimit,
485484
}
486485
return stopReason, nil, rateLimitInfo, nil
487486
}
488487
if rateLimitInfo.Req == 0 {
489488
// All requests exhausted
490489
stopReason := &uctypes.WaveStopReason{
491-
Kind: uctypes.StopKindRateLimit,
492-
RateLimitInfo: rateLimitInfo,
490+
Kind: uctypes.StopKindRateLimit,
493491
}
494492
return stopReason, nil, rateLimitInfo, nil
495493
}
@@ -590,8 +588,6 @@ func handleAnthropicStreamingResp(
590588
rtnStopReason = &uctypes.WaveStopReason{
591589
Kind: uctypes.StopKindDone,
592590
RawReason: state.stopFromDelta,
593-
MessageID: state.msgID,
594-
Model: state.model,
595591
}
596592
return rtnStopReason, state.rtnMessage
597593
}
@@ -849,41 +845,30 @@ func handleAnthropicEvent(
849845
switch reason {
850846
case "tool_use":
851847
return nil, &uctypes.WaveStopReason{
852-
Kind: uctypes.StopKindToolUse,
853-
RawReason: reason,
854-
MessageID: state.msgID,
855-
Model: state.model,
856-
ToolCalls: state.toolCalls,
857-
FinishStep: true,
848+
Kind: uctypes.StopKindToolUse,
849+
RawReason: reason,
850+
ToolCalls: state.toolCalls,
858851
}
859852
case "max_tokens":
860853
return nil, &uctypes.WaveStopReason{
861854
Kind: uctypes.StopKindMaxTokens,
862855
RawReason: reason,
863-
MessageID: state.msgID,
864-
Model: state.model,
865856
}
866857
case "refusal":
867858
return nil, &uctypes.WaveStopReason{
868859
Kind: uctypes.StopKindContent,
869860
RawReason: reason,
870-
MessageID: state.msgID,
871-
Model: state.model,
872861
}
873862
case "pause_turn":
874863
return nil, &uctypes.WaveStopReason{
875864
Kind: uctypes.StopKindPauseTurn,
876865
RawReason: reason,
877-
MessageID: state.msgID,
878-
Model: state.model,
879866
}
880867
default:
881868
// end_turn, stop_sequence (treat as end of this call)
882869
return nil, &uctypes.WaveStopReason{
883870
Kind: uctypes.StopKindDone,
884871
RawReason: reason,
885-
MessageID: state.msgID,
886-
Model: state.model,
887872
}
888873
}
889874

0 commit comments

Comments
 (0)