Skip to content

Commit 6cbd008

Browse files
feat: add token estimation for chat input
1 parent 5c6f60b commit 6cbd008

1 file changed

Lines changed: 196 additions & 15 deletions

File tree

‎main.go‎

Lines changed: 196 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -37,14 +37,17 @@ const (
3737
colorError = "\033[38;5;203m"
3838
colorStrong = "\033[1m"
3939

40-
maxAIContextChars = 16000
41-
maxGroupingContextChars = 14000
42-
maxDiffExcerptChars = 3500
43-
minDiffExcerptChars = 600
44-
diffHeadLines = 24
45-
diffTailLines = 14
46-
maxSemanticItems = 12
47-
maxSampleLines = 8
40+
maxAIContextChars = 16000
41+
maxCommitRequestTokens = 1400
42+
maxGroupingRequestTokens = 1200
43+
maxDiffExcerptChars = 3500
44+
minDiffExcerptChars = 600
45+
diffHeadLines = 24
46+
diffTailLines = 14
47+
maxSemanticItems = 12
48+
maxSampleLines = 8
49+
estimatedCharsPerToken = 4
50+
maxGroqAttempts = 4
4851
)
4952

5053
type FileChange struct {
@@ -754,11 +757,11 @@ func generateCommitGroups(apiKey, model string, changes []FileChange) (string, e
754757
payload := chatCompletionRequest{
755758
Model: model,
756759
Temperature: 0.1,
757-
MaxTokens: 900,
760+
MaxTokens: 500,
758761
Messages: []chatMessage{
759762
{
760763
Role: "system",
761-
Content: "You group changed files into logical git commits. Return JSON only. " +
764+
Content: "You group changed file paths into logical git commits. Use only path, status, and area metadata; do not assume diff contents. Return JSON only. " +
762765
`Use this schema: [{"label":"short category label","files":["path1","path2"]}]. ` +
763766
"Each file must appear at most once.",
764767
},
@@ -769,18 +772,60 @@ func generateCommitGroups(apiKey, model string, changes []FileChange) (string, e
769772
},
770773
}
771774

775+
payload = limitChatPayload(payload, maxGroupingRequestTokens)
772776
return sendGroqChat(apiKey, payload)
773777
}
774778

775779
func buildGroupingPrompt(changes []FileChange) string {
776780
var builder strings.Builder
777-
builder.WriteString("Group these changed files into logical commits.\n")
778-
builder.WriteString("Prefer a small number of clear categories. Keep unrelated files separate.\n\n")
779-
builder.WriteString(buildManagedChangeContext(changes, maxGroupingContextChars))
781+
builder.WriteString("Group these changed files into logical commits from the file graph only.\n")
782+
builder.WriteString("Use directory structure, path names, extensions, status, and inferred area. Keep unrelated paths separate.\n\n")
783+
builder.WriteString(buildChangedFilesGraph(changes))
780784

781785
return builder.String()
782786
}
783787

788+
func buildChangedFilesGraph(changes []FileChange) string {
789+
var builder strings.Builder
790+
builder.WriteString("Changed file graph:\n")
791+
for _, change := range changes {
792+
builder.WriteString(formatGraphPath(change.FileName))
793+
builder.WriteString(" [")
794+
builder.WriteString(change.Status)
795+
builder.WriteString(", ")
796+
builder.WriteString(classifyFileArea(change.FileName))
797+
builder.WriteString("]")
798+
builder.WriteString("\n")
799+
}
800+
return builder.String()
801+
}
802+
803+
func formatGraphPath(path string) string {
804+
parts := strings.Split(filepath.ToSlash(path), "/")
805+
if len(parts) == 0 {
806+
return "- " + path
807+
}
808+
809+
var builder strings.Builder
810+
for index, part := range parts {
811+
if part == "" {
812+
continue
813+
}
814+
if builder.Len() == 0 {
815+
builder.WriteString("- ")
816+
} else {
817+
builder.WriteString(" > ")
818+
}
819+
builder.WriteString(part)
820+
if index == len(parts)-1 {
821+
builder.WriteString(" (")
822+
builder.WriteString(path)
823+
builder.WriteString(")")
824+
}
825+
}
826+
return builder.String()
827+
}
828+
784829
func parseCommitGroups(content string) ([]commitGroupProposal, error) {
785830
cleaned := strings.TrimSpace(content)
786831
cleaned = strings.TrimPrefix(cleaned, "```json")
@@ -875,6 +920,7 @@ func generateCommitMessage(apiKey, model string, changes []FileChange) (string,
875920
},
876921
}
877922

923+
payload = limitChatPayload(payload, maxCommitRequestTokens)
878924
message, err := sendGroqChat(apiKey, payload)
879925
if err != nil {
880926
return "", err
@@ -888,6 +934,7 @@ func generateCommitMessage(apiKey, model string, changes []FileChange) (string,
888934
chatMessage{Role: "assistant", Content: message},
889935
chatMessage{Role: "user", Content: "That subject is too vague or malformed. Generate one specific conventional commit subject using the file summaries and changed symbols. Do not include null, generic filler, or explanations."},
890936
)
937+
payload = limitChatPayload(payload, maxCommitRequestTokens)
891938
message, err = sendGroqChat(apiKey, payload)
892939
if err != nil {
893940
return "", err
@@ -1507,7 +1554,141 @@ func truncateContext(text string, limit int) string {
15071554
return text[:limit-len(marker)] + marker
15081555
}
15091556

1557+
func limitChatPayload(payload chatCompletionRequest, requestTokenBudget int) chatCompletionRequest {
1558+
if requestTokenBudget <= 0 {
1559+
return payload
1560+
}
1561+
1562+
availableInputTokens := requestTokenBudget - payload.MaxTokens - 80
1563+
if availableInputTokens < 300 {
1564+
availableInputTokens = 300
1565+
}
1566+
1567+
for estimateChatInputTokens(payload.Messages) > availableInputTokens {
1568+
userIndex := largestUserMessageIndex(payload.Messages)
1569+
if userIndex < 0 {
1570+
break
1571+
}
1572+
1573+
current := payload.Messages[userIndex].Content
1574+
overTokens := estimateChatInputTokens(payload.Messages) - availableInputTokens
1575+
cutChars := overTokens * estimatedCharsPerToken
1576+
target := len(current) - cutChars
1577+
if target < 500 {
1578+
target = 500
1579+
}
1580+
if target >= len(current) {
1581+
target = len(current) - estimatedCharsPerToken
1582+
}
1583+
if target <= 0 {
1584+
break
1585+
}
1586+
1587+
payload.Messages[userIndex].Content = truncateContext(current, target)
1588+
}
1589+
1590+
return payload
1591+
}
1592+
1593+
func largestUserMessageIndex(messages []chatMessage) int {
1594+
largestIndex := -1
1595+
largestSize := 0
1596+
for index, message := range messages {
1597+
if message.Role == "user" && len(message.Content) > largestSize {
1598+
largestIndex = index
1599+
largestSize = len(message.Content)
1600+
}
1601+
}
1602+
return largestIndex
1603+
}
1604+
1605+
func estimateChatInputTokens(messages []chatMessage) int {
1606+
total := 0
1607+
for _, message := range messages {
1608+
total += estimateTokens(message.Role)
1609+
total += estimateTokens(message.Content)
1610+
total += 4
1611+
}
1612+
return total + 8
1613+
}
1614+
1615+
func estimateTokens(text string) int {
1616+
if text == "" {
1617+
return 0
1618+
}
1619+
1620+
tokens := len(text) / estimatedCharsPerToken
1621+
if len(text)%estimatedCharsPerToken != 0 {
1622+
tokens++
1623+
}
1624+
return tokens
1625+
}
1626+
15101627
func sendGroqChat(apiKey string, payload chatCompletionRequest) (string, error) {
1628+
var lastErr error
1629+
for attempt := 1; attempt <= maxGroqAttempts; attempt++ {
1630+
content, err := sendGroqChatOnce(apiKey, payload)
1631+
if err == nil {
1632+
return content, nil
1633+
}
1634+
1635+
lastErr = err
1636+
wait, ok := groqRetryDelay(err)
1637+
if !ok || attempt == maxGroqAttempts {
1638+
return "", err
1639+
}
1640+
1641+
if wait < 500*time.Millisecond {
1642+
wait = 500 * time.Millisecond
1643+
}
1644+
if wait > 30*time.Second {
1645+
wait = 30 * time.Second
1646+
}
1647+
time.Sleep(wait)
1648+
}
1649+
1650+
return "", lastErr
1651+
}
1652+
1653+
type groqHTTPError struct {
1654+
statusCode int
1655+
status string
1656+
message string
1657+
}
1658+
1659+
func (err groqHTTPError) Error() string {
1660+
if err.message != "" {
1661+
return err.message
1662+
}
1663+
return "groq request failed with status " + err.status
1664+
}
1665+
1666+
func groqRetryDelay(err error) (time.Duration, bool) {
1667+
var httpErr groqHTTPError
1668+
if !errors.As(err, &httpErr) {
1669+
return 0, false
1670+
}
1671+
if httpErr.statusCode != http.StatusTooManyRequests && !strings.Contains(strings.ToLower(httpErr.message), "rate limit") {
1672+
return 0, false
1673+
}
1674+
1675+
lower := strings.ToLower(httpErr.message)
1676+
marker := "try again in "
1677+
if index := strings.Index(lower, marker); index >= 0 {
1678+
remainder := httpErr.message[index+len(marker):]
1679+
fields := strings.Fields(remainder)
1680+
if len(fields) > 0 {
1681+
rawDuration := strings.TrimRight(fields[0], ".,;")
1682+
if delay, parseErr := time.ParseDuration(rawDuration); parseErr == nil {
1683+
return delay + 250*time.Millisecond, true
1684+
}
1685+
}
1686+
}
1687+
1688+
return 5 * time.Second, true
1689+
}
1690+
1691+
func sendGroqChatOnce(apiKey string, payload chatCompletionRequest) (string, error) {
15111692
body, err := json.Marshal(payload)
15121693
if err != nil {
15131694
return "", err
@@ -1540,9 +1721,9 @@ func sendGroqChat(apiKey string, payload chatCompletionRequest) (string, error)
15401721

15411722
if resp.StatusCode >= 400 {
15421723
if completion.Error != nil && completion.Error.Message != "" {
1543-
return "", errors.New(completion.Error.Message)
1724+
return "", groqHTTPError{statusCode: resp.StatusCode, status: resp.Status, message: completion.Error.Message}
15441725
}
1545-
return "", fmt.Errorf("groq request failed with status %s", resp.Status)
1726+
return "", groqHTTPError{statusCode: resp.StatusCode, status: resp.Status}
15461727
}
15471728

15481729
if len(completion.Choices) == 0 {

0 commit comments

Comments
 (0)