feat(messageutil): add SanitizeMessageName for OpenAI wire compliance
OpenAI's Chat Completions name field requires ^[a-zA-Z0-9_-]{1,64}$.
Real-world sender IDs (Discord alice#1234, numeric Telegram IDs, Slack
U07AB12C3DEF, free-form CLI names) often violate this, so a sanitizer
sits between the raw inbound ID and any wire payload.
Disallowed characters collapse into a single underscore, leading and
trailing underscores are trimmed, and the result is byte-safely
truncated to 64 (output is pure ASCII).
This helper will be wired into the agent pipeline in a later commit.
Refs #2702.
This commit is contained in:
parent
72005ae06c
commit
d1e9bb6ae3
2 changed files with 99 additions and 0 deletions
|
|
@ -1,11 +1,43 @@
|
||||||
package messageutil
|
package messageutil
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/pkg/providers/protocoltypes"
|
"github.com/sipeed/picoclaw/pkg/providers/protocoltypes"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// nameSanitizer matches characters that are not valid in the OpenAI Chat
|
||||||
|
// Completions `name` field. The OpenAI spec requires `^[a-zA-Z0-9_-]{1,64}$`.
|
||||||
|
var nameSanitizer = regexp.MustCompile(`[^a-zA-Z0-9_-]+`)
|
||||||
|
|
||||||
|
// maxMessageNameLen mirrors the OpenAI Chat Completions hard limit on the
|
||||||
|
// `name` field. Values longer than this are truncated.
|
||||||
|
const maxMessageNameLen = 64
|
||||||
|
|
||||||
|
// SanitizeMessageName normalizes a raw sender identifier into a value safe
|
||||||
|
// for the OpenAI Chat Completions `name` field and stable across providers.
|
||||||
|
// Disallowed characters are coalesced into a single underscore; leading and
|
||||||
|
// trailing underscores are trimmed; the result is truncated to 64 bytes.
|
||||||
|
//
|
||||||
|
// Returns "" when raw is empty or collapses to nothing after sanitization.
|
||||||
|
// The output is pure ASCII so the truncation is byte-safe.
|
||||||
|
func SanitizeMessageName(raw string) string {
|
||||||
|
raw = strings.TrimSpace(raw)
|
||||||
|
if raw == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
cleaned := nameSanitizer.ReplaceAllString(raw, "_")
|
||||||
|
cleaned = strings.Trim(cleaned, "_")
|
||||||
|
if cleaned == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if len(cleaned) > maxMessageNameLen {
|
||||||
|
cleaned = cleaned[:maxMessageNameLen]
|
||||||
|
}
|
||||||
|
return cleaned
|
||||||
|
}
|
||||||
|
|
||||||
// IsTransientAssistantThoughtMessage reports whether msg is an invalid
|
// IsTransientAssistantThoughtMessage reports whether msg is an invalid
|
||||||
// reasoning-only assistant history record. These "hanging" thought messages
|
// reasoning-only assistant history record. These "hanging" thought messages
|
||||||
// are not a canonical persisted format and should be discarded instead of
|
// are not a canonical persisted format and should be discarded instead of
|
||||||
|
|
|
||||||
67
pkg/providers/messageutil/messageutil_test.go
Normal file
67
pkg/providers/messageutil/messageutil_test.go
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
// PicoClaw - Ultra-lightweight personal AI agent
|
||||||
|
// License: MIT
|
||||||
|
//
|
||||||
|
// Copyright (c) 2026 PicoClaw contributors
|
||||||
|
|
||||||
|
package messageutil
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSanitizeMessageName(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
in string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"empty", "", ""},
|
||||||
|
{"whitespace only", " ", ""},
|
||||||
|
{"plain ascii", "alice", "alice"},
|
||||||
|
{"discord-style id", "alice#1234", "alice_1234"},
|
||||||
|
{"telegram numeric id", "141455495", "141455495"},
|
||||||
|
{"slack-style id", "U07AB12C3DEF", "U07AB12C3DEF"},
|
||||||
|
{"already valid mixed case", "Alice_Bob-99", "Alice_Bob-99"},
|
||||||
|
{"strips leading/trailing underscores", "@alice@", "alice"},
|
||||||
|
{"collapses runs", "a@@@b", "a_b"},
|
||||||
|
{"non-ascii becomes underscores", "李华", ""},
|
||||||
|
{"non-ascii mixed", "alice 李", "alice"},
|
||||||
|
{"all special chars collapses to empty", "@@@!!!", ""},
|
||||||
|
{"truncates to 64", strings.Repeat("a", 100), strings.Repeat("a", 64)},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := SanitizeMessageName(tt.in)
|
||||||
|
if got != tt.want {
|
||||||
|
t.Errorf("SanitizeMessageName(%q) = %q, want %q", tt.in, got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSanitizeMessageName_OutputAlwaysWireSafe(t *testing.T) {
|
||||||
|
inputs := []string{
|
||||||
|
"alice#1234", "U07AB12C3DEF", "141455495",
|
||||||
|
"@@@", "李华", "alice 李", "Alice_Bob-99",
|
||||||
|
strings.Repeat("a", 200),
|
||||||
|
}
|
||||||
|
for _, in := range inputs {
|
||||||
|
got := SanitizeMessageName(in)
|
||||||
|
if got == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if len(got) > maxMessageNameLen {
|
||||||
|
t.Errorf("SanitizeMessageName(%q) length %d exceeds max %d", in, len(got), maxMessageNameLen)
|
||||||
|
}
|
||||||
|
for _, r := range got {
|
||||||
|
ok := (r >= 'a' && r <= 'z') ||
|
||||||
|
(r >= 'A' && r <= 'Z') ||
|
||||||
|
(r >= '0' && r <= '9') ||
|
||||||
|
r == '_' || r == '-'
|
||||||
|
if !ok {
|
||||||
|
t.Errorf("SanitizeMessageName(%q) = %q contains invalid rune %q", in, got, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue