feat: add OpenAI-compatible API channel
This commit is contained in:
parent
e6ebeaed13
commit
270959cb89
19 changed files with 1282 additions and 27 deletions
|
|
@ -497,6 +497,7 @@ func (cb *ContextBuilder) BuildMessages(
|
||||||
currentMessage string,
|
currentMessage string,
|
||||||
media []string,
|
media []string,
|
||||||
channel, chatID, senderID, senderDisplayName string,
|
channel, chatID, senderID, senderDisplayName string,
|
||||||
|
extraSystemPrompt string,
|
||||||
) []providers.Message {
|
) []providers.Message {
|
||||||
messages := []providers.Message{}
|
messages := []providers.Message{}
|
||||||
|
|
||||||
|
|
@ -539,6 +540,15 @@ func (cb *ContextBuilder) BuildMessages(
|
||||||
contentBlocks = append(contentBlocks, providers.ContentBlock{Type: "text", Text: summaryText})
|
contentBlocks = append(contentBlocks, providers.ContentBlock{Type: "text", Text: summaryText})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if strings.TrimSpace(extraSystemPrompt) != "" {
|
||||||
|
extraText := fmt.Sprintf(
|
||||||
|
"CLIENT_SYSTEM_INSTRUCTIONS: The following instructions were provided by the external client for this request only.\n\n%s",
|
||||||
|
extraSystemPrompt,
|
||||||
|
)
|
||||||
|
stringParts = append(stringParts, extraText)
|
||||||
|
contentBlocks = append(contentBlocks, providers.ContentBlock{Type: "text", Text: extraText})
|
||||||
|
}
|
||||||
|
|
||||||
fullSystemPrompt := strings.Join(stringParts, "\n\n---\n\n")
|
fullSystemPrompt := strings.Join(stringParts, "\n\n---\n\n")
|
||||||
|
|
||||||
// Log system prompt summary for debugging (debug mode only).
|
// Log system prompt summary for debugging (debug mode only).
|
||||||
|
|
|
||||||
|
|
@ -82,7 +82,7 @@ func TestSingleSystemMessage(t *testing.T) {
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
msgs := cb.BuildMessages(tt.history, tt.summary, tt.message, nil, "test", "chat1", "", "")
|
msgs := cb.BuildMessages(tt.history, tt.summary, tt.message, nil, "test", "chat1", "", "", "")
|
||||||
|
|
||||||
systemCount := 0
|
systemCount := 0
|
||||||
for _, m := range msgs {
|
for _, m := range msgs {
|
||||||
|
|
@ -168,7 +168,7 @@ func TestBuildMessages_CurrentSenderDynamicContext(t *testing.T) {
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
msgs := cb.BuildMessages(nil, "", "hello", nil, "discord", "chat1", tt.senderID, tt.senderDisplayName)
|
msgs := cb.BuildMessages(nil, "", "hello", nil, "discord", "chat1", tt.senderID, tt.senderDisplayName, "")
|
||||||
sys := msgs[0].Content
|
sys := msgs[0].Content
|
||||||
|
|
||||||
if tt.wantSection {
|
if tt.wantSection {
|
||||||
|
|
@ -638,7 +638,7 @@ func TestConcurrentBuildSystemPromptWithCache(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Also exercise BuildMessages concurrently
|
// Also exercise BuildMessages concurrently
|
||||||
msgs := cb.BuildMessages(nil, "", "hello", nil, "test", "chat", "", "")
|
msgs := cb.BuildMessages(nil, "", "hello", nil, "test", "chat", "", "", "")
|
||||||
if len(msgs) < 2 {
|
if len(msgs) < 2 {
|
||||||
errs <- "BuildMessages returned fewer than 2 messages"
|
errs <- "BuildMessages returned fewer than 2 messages"
|
||||||
return
|
return
|
||||||
|
|
@ -726,6 +726,6 @@ func BenchmarkBuildMessagesWithCache(b *testing.B) {
|
||||||
|
|
||||||
b.ResetTimer()
|
b.ResetTimer()
|
||||||
for i := 0; i < b.N; i++ {
|
for i := 0; i < b.N; i++ {
|
||||||
_ = cb.BuildMessages(history, "summary", "new message", nil, "cli", "test", "", "")
|
_ = cb.BuildMessages(history, "summary", "new message", nil, "cli", "test", "", "", "")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -62,20 +62,27 @@ type processOptions struct {
|
||||||
SenderDisplayName string // Current sender display name for dynamic context
|
SenderDisplayName string // Current sender display name for dynamic context
|
||||||
UserMessage string // User message content (may include prefix)
|
UserMessage string // User message content (may include prefix)
|
||||||
Media []string // media:// refs from inbound message
|
Media []string // media:// refs from inbound message
|
||||||
DefaultResponse string // Response when LLM returns empty
|
InjectedHistory []providers.Message
|
||||||
EnableSummary bool // Whether to trigger summarization
|
ExtraSystemPrompt string
|
||||||
SendResponse bool // Whether to send response via bus
|
RequestedModel string
|
||||||
NoHistory bool // If true, don't load session history (for heartbeat)
|
DefaultResponse string // Response when LLM returns empty
|
||||||
|
EnableSummary bool // Whether to trigger summarization
|
||||||
|
SendResponse bool // Whether to send response via bus
|
||||||
|
NoHistory bool // If true, don't load or persist session history
|
||||||
}
|
}
|
||||||
|
|
||||||
const (
|
const (
|
||||||
defaultResponse = "I've completed processing but have no response to give. Increase `max_tool_iterations` in config.json."
|
defaultResponse = "I've completed processing but have no response to give. Increase `max_tool_iterations` in config.json."
|
||||||
sessionKeyAgentPrefix = "agent:"
|
sessionKeyAgentPrefix = "agent:"
|
||||||
metadataKeyAccountID = "account_id"
|
metadataKeyAccountID = "account_id"
|
||||||
metadataKeyGuildID = "guild_id"
|
metadataKeyGuildID = "guild_id"
|
||||||
metadataKeyTeamID = "team_id"
|
metadataKeyTeamID = "team_id"
|
||||||
metadataKeyParentPeerKind = "parent_peer_kind"
|
metadataKeyParentPeerKind = "parent_peer_kind"
|
||||||
metadataKeyParentPeerID = "parent_peer_id"
|
metadataKeyParentPeerID = "parent_peer_id"
|
||||||
|
metadataKeyNoHistory = "no_history"
|
||||||
|
metadataKeyRequestedModel = "requested_model"
|
||||||
|
metadataKeyInjectedHistory = "injected_history"
|
||||||
|
metadataKeyExtraSystemPrompt = "extra_system_prompt"
|
||||||
)
|
)
|
||||||
|
|
||||||
func NewAgentLoop(
|
func NewAgentLoop(
|
||||||
|
|
@ -758,6 +765,14 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage)
|
||||||
SendResponse: false,
|
SendResponse: false,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if metadataEnabled(msg, metadataKeyNoHistory) {
|
||||||
|
opts.NoHistory = true
|
||||||
|
opts.EnableSummary = false
|
||||||
|
}
|
||||||
|
opts.RequestedModel = inboundMetadata(msg, metadataKeyRequestedModel)
|
||||||
|
opts.ExtraSystemPrompt = inboundMetadata(msg, metadataKeyExtraSystemPrompt)
|
||||||
|
opts.InjectedHistory = decodeInjectedHistory(msg)
|
||||||
|
|
||||||
// context-dependent commands check their own Runtime fields and report
|
// context-dependent commands check their own Runtime fields and report
|
||||||
// "unavailable" when the required capability is nil.
|
// "unavailable" when the required capability is nil.
|
||||||
if response, handled := al.handleCommand(ctx, msg, agent, &opts); handled {
|
if response, handled := al.handleCommand(ctx, msg, agent, &opts); handled {
|
||||||
|
|
@ -884,7 +899,9 @@ func (al *AgentLoop) runAgentLoop(
|
||||||
// 1. Build messages (skip history for heartbeat)
|
// 1. Build messages (skip history for heartbeat)
|
||||||
var history []providers.Message
|
var history []providers.Message
|
||||||
var summary string
|
var summary string
|
||||||
if !opts.NoHistory {
|
if len(opts.InjectedHistory) > 0 {
|
||||||
|
history = append([]providers.Message(nil), opts.InjectedHistory...)
|
||||||
|
} else if !opts.NoHistory {
|
||||||
history = agent.Sessions.GetHistory(opts.SessionKey)
|
history = agent.Sessions.GetHistory(opts.SessionKey)
|
||||||
summary = agent.Sessions.GetSummary(opts.SessionKey)
|
summary = agent.Sessions.GetSummary(opts.SessionKey)
|
||||||
}
|
}
|
||||||
|
|
@ -897,6 +914,7 @@ func (al *AgentLoop) runAgentLoop(
|
||||||
opts.ChatID,
|
opts.ChatID,
|
||||||
opts.SenderID,
|
opts.SenderID,
|
||||||
opts.SenderDisplayName,
|
opts.SenderDisplayName,
|
||||||
|
opts.ExtraSystemPrompt,
|
||||||
)
|
)
|
||||||
|
|
||||||
// Resolve media:// refs: images→base64 data URLs, non-images→local paths in content
|
// Resolve media:// refs: images→base64 data URLs, non-images→local paths in content
|
||||||
|
|
@ -905,7 +923,9 @@ func (al *AgentLoop) runAgentLoop(
|
||||||
messages = resolveMediaRefs(messages, al.mediaStore, maxMediaSize)
|
messages = resolveMediaRefs(messages, al.mediaStore, maxMediaSize)
|
||||||
|
|
||||||
// 2. Save user message to session
|
// 2. Save user message to session
|
||||||
agent.Sessions.AddMessage(opts.SessionKey, "user", opts.UserMessage)
|
if !opts.NoHistory {
|
||||||
|
agent.Sessions.AddMessage(opts.SessionKey, "user", opts.UserMessage)
|
||||||
|
}
|
||||||
|
|
||||||
// 3. Run LLM iteration loop
|
// 3. Run LLM iteration loop
|
||||||
finalContent, iteration, err := al.runLLMIteration(ctx, agent, messages, opts)
|
finalContent, iteration, err := al.runLLMIteration(ctx, agent, messages, opts)
|
||||||
|
|
@ -922,11 +942,13 @@ func (al *AgentLoop) runAgentLoop(
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. Save final assistant message to session
|
// 5. Save final assistant message to session
|
||||||
agent.Sessions.AddMessage(opts.SessionKey, "assistant", finalContent)
|
if !opts.NoHistory {
|
||||||
agent.Sessions.Save(opts.SessionKey)
|
agent.Sessions.AddMessage(opts.SessionKey, "assistant", finalContent)
|
||||||
|
agent.Sessions.Save(opts.SessionKey)
|
||||||
|
}
|
||||||
|
|
||||||
// 6. Optional: summarization
|
// 6. Optional: summarization
|
||||||
if opts.EnableSummary {
|
if opts.EnableSummary && !opts.NoHistory {
|
||||||
al.maybeSummarize(agent, opts.SessionKey, opts.Channel, opts.ChatID)
|
al.maybeSummarize(agent, opts.SessionKey, opts.Channel, opts.ChatID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1022,7 +1044,10 @@ func (al *AgentLoop) runLLMIteration(
|
||||||
// selectCandidates evaluates routing once and the decision is sticky for
|
// selectCandidates evaluates routing once and the decision is sticky for
|
||||||
// all tool-follow-up iterations within the same turn so that a multi-step
|
// all tool-follow-up iterations within the same turn so that a multi-step
|
||||||
// tool chain doesn't switch models mid-way through.
|
// tool chain doesn't switch models mid-way through.
|
||||||
activeCandidates, activeModel := al.selectCandidates(agent, opts.UserMessage, messages)
|
activeCandidates, activeModel, err := al.selectCandidates(agent, opts.RequestedModel, opts.UserMessage, messages)
|
||||||
|
if err != nil {
|
||||||
|
return "", iteration, err
|
||||||
|
}
|
||||||
|
|
||||||
for iteration < agent.MaxIterations {
|
for iteration < agent.MaxIterations {
|
||||||
iteration++
|
iteration++
|
||||||
|
|
@ -1162,7 +1187,7 @@ func (al *AgentLoop) runLLMIteration(
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if isContextError && retry < maxRetries {
|
if isContextError && retry < maxRetries && !opts.NoHistory {
|
||||||
logger.WarnCF(
|
logger.WarnCF(
|
||||||
"agent",
|
"agent",
|
||||||
"Context window error detected, attempting compression",
|
"Context window error detected, attempting compression",
|
||||||
|
|
@ -1185,7 +1210,7 @@ func (al *AgentLoop) runLLMIteration(
|
||||||
newSummary := agent.Sessions.GetSummary(opts.SessionKey)
|
newSummary := agent.Sessions.GetSummary(opts.SessionKey)
|
||||||
messages = agent.ContextBuilder.BuildMessages(
|
messages = agent.ContextBuilder.BuildMessages(
|
||||||
newHistory, newSummary, "",
|
newHistory, newSummary, "",
|
||||||
nil, opts.Channel, opts.ChatID, opts.SenderID, opts.SenderDisplayName,
|
nil, opts.Channel, opts.ChatID, opts.SenderID, opts.SenderDisplayName, opts.ExtraSystemPrompt,
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
@ -1446,11 +1471,16 @@ func (al *AgentLoop) runLLMIteration(
|
||||||
// that a multi-step tool chain doesn't switch models mid-way.
|
// that a multi-step tool chain doesn't switch models mid-way.
|
||||||
func (al *AgentLoop) selectCandidates(
|
func (al *AgentLoop) selectCandidates(
|
||||||
agent *AgentInstance,
|
agent *AgentInstance,
|
||||||
|
requestedModel string,
|
||||||
userMsg string,
|
userMsg string,
|
||||||
history []providers.Message,
|
history []providers.Message,
|
||||||
) (candidates []providers.FallbackCandidate, model string) {
|
) (candidates []providers.FallbackCandidate, model string, err error) {
|
||||||
|
if strings.TrimSpace(requestedModel) != "" {
|
||||||
|
return al.resolveRequestedModelCandidates(agent, requestedModel)
|
||||||
|
}
|
||||||
|
|
||||||
if agent.Router == nil || len(agent.LightCandidates) == 0 {
|
if agent.Router == nil || len(agent.LightCandidates) == 0 {
|
||||||
return agent.Candidates, agent.Model
|
return agent.Candidates, agent.Model, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
_, usedLight, score := agent.Router.SelectModel(userMsg, history, agent.Model)
|
_, usedLight, score := agent.Router.SelectModel(userMsg, history, agent.Model)
|
||||||
|
|
@ -1461,7 +1491,7 @@ func (al *AgentLoop) selectCandidates(
|
||||||
"score": score,
|
"score": score,
|
||||||
"threshold": agent.Router.Threshold(),
|
"threshold": agent.Router.Threshold(),
|
||||||
})
|
})
|
||||||
return agent.Candidates, agent.Model
|
return agent.Candidates, agent.Model, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.InfoCF("agent", "Model routing: light model selected",
|
logger.InfoCF("agent", "Model routing: light model selected",
|
||||||
|
|
@ -1471,7 +1501,66 @@ func (al *AgentLoop) selectCandidates(
|
||||||
"score": score,
|
"score": score,
|
||||||
"threshold": agent.Router.Threshold(),
|
"threshold": agent.Router.Threshold(),
|
||||||
})
|
})
|
||||||
return agent.LightCandidates, agent.Router.LightModel()
|
return agent.LightCandidates, agent.Router.LightModel(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (al *AgentLoop) resolveRequestedModelCandidates(
|
||||||
|
agent *AgentInstance,
|
||||||
|
requestedModel string,
|
||||||
|
) ([]providers.FallbackCandidate, string, error) {
|
||||||
|
requestedModel = strings.TrimSpace(requestedModel)
|
||||||
|
if requestedModel == "" {
|
||||||
|
return agent.Candidates, agent.Model, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg := al.GetConfig()
|
||||||
|
resolveFromModelList := func(raw string) (string, bool) {
|
||||||
|
ensureProtocol := func(model string) string {
|
||||||
|
if model == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if strings.Contains(model, "/") {
|
||||||
|
return model
|
||||||
|
}
|
||||||
|
return "openai/" + model
|
||||||
|
}
|
||||||
|
|
||||||
|
raw = strings.TrimSpace(raw)
|
||||||
|
if raw == "" || cfg == nil {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
if mc, err := cfg.GetModelConfig(raw); err == nil && mc != nil && strings.TrimSpace(mc.Model) != "" {
|
||||||
|
return ensureProtocol(mc.Model), true
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := range cfg.ModelList {
|
||||||
|
fullModel := strings.TrimSpace(cfg.ModelList[i].Model)
|
||||||
|
if fullModel == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if fullModel == raw {
|
||||||
|
return ensureProtocol(fullModel), true
|
||||||
|
}
|
||||||
|
_, modelID := providers.ExtractProtocol(fullModel)
|
||||||
|
if modelID == raw {
|
||||||
|
return ensureProtocol(fullModel), true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
candidates := providers.ResolveCandidatesWithLookup(
|
||||||
|
providers.ModelConfig{Primary: requestedModel},
|
||||||
|
al.cfg.Agents.Defaults.Provider,
|
||||||
|
resolveFromModelList,
|
||||||
|
)
|
||||||
|
if len(candidates) == 0 {
|
||||||
|
return nil, "", fmt.Errorf("requested model %q not found in model_list", requestedModel)
|
||||||
|
}
|
||||||
|
|
||||||
|
return candidates, requestedModel, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// maybeSummarize triggers summarization if the session history exceeds thresholds.
|
// maybeSummarize triggers summarization if the session history exceeds thresholds.
|
||||||
|
|
@ -1983,6 +2072,27 @@ func inboundMetadata(msg bus.InboundMessage, key string) string {
|
||||||
return msg.Metadata[key]
|
return msg.Metadata[key]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func metadataEnabled(msg bus.InboundMessage, key string) bool {
|
||||||
|
value := strings.ToLower(strings.TrimSpace(inboundMetadata(msg, key)))
|
||||||
|
return value == "1" || value == "true" || value == "yes" || value == "on"
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeInjectedHistory(msg bus.InboundMessage) []providers.Message {
|
||||||
|
raw := inboundMetadata(msg, metadataKeyInjectedHistory)
|
||||||
|
if strings.TrimSpace(raw) == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var history []providers.Message
|
||||||
|
if err := json.Unmarshal([]byte(raw), &history); err != nil {
|
||||||
|
logger.WarnCF("agent", "Failed to decode injected history", map[string]any{
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return history
|
||||||
|
}
|
||||||
|
|
||||||
// extractParentPeer extracts the parent peer (reply-to) from inbound message metadata.
|
// extractParentPeer extracts the parent peer (reply-to) from inbound message metadata.
|
||||||
func extractParentPeer(msg bus.InboundMessage) *routing.RoutePeer {
|
func extractParentPeer(msg bus.InboundMessage) *routing.RoutePeer {
|
||||||
parentKind := inboundMetadata(msg, metadataKeyParentPeerKind)
|
parentKind := inboundMetadata(msg, metadataKeyParentPeerKind)
|
||||||
|
|
|
||||||
82
pkg/agent/openai_request_test.go
Normal file
82
pkg/agent/openai_request_test.go
Normal file
|
|
@ -0,0 +1,82 @@
|
||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/bus"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/providers"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/routing"
|
||||||
|
)
|
||||||
|
|
||||||
|
type modelRecordingProvider struct {
|
||||||
|
lastModel string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *modelRecordingProvider) Chat(
|
||||||
|
ctx context.Context,
|
||||||
|
messages []providers.Message,
|
||||||
|
tools []providers.ToolDefinition,
|
||||||
|
model string,
|
||||||
|
opts map[string]any,
|
||||||
|
) (*providers.LLMResponse, error) {
|
||||||
|
p.lastModel = model
|
||||||
|
return &providers.LLMResponse{
|
||||||
|
Content: "ok",
|
||||||
|
ToolCalls: nil,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *modelRecordingProvider) GetDefaultModel() string {
|
||||||
|
return "gpt-5.4"
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProcessMessage_OpenAIRequestDoesNotPersistHistoryAndOverridesModel(t *testing.T) {
|
||||||
|
tmpDir, err := os.MkdirTemp("", "agent-openai-*")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("MkdirTemp() error = %v", err)
|
||||||
|
}
|
||||||
|
defer os.RemoveAll(tmpDir)
|
||||||
|
|
||||||
|
cfg := config.DefaultConfig()
|
||||||
|
cfg.Agents.Defaults.Workspace = tmpDir
|
||||||
|
cfg.Agents.Defaults.Model = "gpt-5.4"
|
||||||
|
cfg.Agents.Defaults.Provider = "openai"
|
||||||
|
cfg.Agents.Defaults.MaxTokens = 4096
|
||||||
|
cfg.Agents.Defaults.MaxToolIterations = 4
|
||||||
|
|
||||||
|
messageBus := bus.NewMessageBus()
|
||||||
|
provider := &modelRecordingProvider{}
|
||||||
|
loop := NewAgentLoop(cfg, messageBus, provider)
|
||||||
|
|
||||||
|
_, err = loop.processMessage(context.Background(), bus.InboundMessage{
|
||||||
|
Channel: "openai_api",
|
||||||
|
SenderID: "client-1",
|
||||||
|
ChatID: "chat-1",
|
||||||
|
Content: "hello",
|
||||||
|
Metadata: map[string]string{
|
||||||
|
"no_history": "true",
|
||||||
|
"requested_model": "deepseek-chat",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("processMessage() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if provider.lastModel != "deepseek-chat" {
|
||||||
|
t.Fatalf("provider model = %q, want %q", provider.lastModel, "deepseek-chat")
|
||||||
|
}
|
||||||
|
|
||||||
|
agent := loop.GetRegistry().GetDefaultAgent()
|
||||||
|
if agent == nil {
|
||||||
|
t.Fatal("expected default agent")
|
||||||
|
}
|
||||||
|
|
||||||
|
sessionKey := routing.BuildAgentMainSessionKey(agent.ID)
|
||||||
|
history := agent.Sessions.GetHistory(sessionKey)
|
||||||
|
if len(history) != 0 {
|
||||||
|
t.Fatalf("expected no persisted history, got %d entries", len(history))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -303,6 +303,10 @@ func (m *Manager) initChannels() error {
|
||||||
m.initChannel("pico", "Pico")
|
m.initChannel("pico", "Pico")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if m.config.Channels.OpenAIAPI.Enabled && m.config.Channels.OpenAIAPI.APIKey != "" {
|
||||||
|
m.initChannel("openai_api", "OpenAI API")
|
||||||
|
}
|
||||||
|
|
||||||
if m.config.Channels.IRC.Enabled && m.config.Channels.IRC.Server != "" {
|
if m.config.Channels.IRC.Enabled && m.config.Channels.IRC.Server != "" {
|
||||||
m.initChannel("irc", "IRC")
|
m.initChannel("irc", "IRC")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
13
pkg/channels/openai_api/init.go
Normal file
13
pkg/channels/openai_api/init.go
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
package openai_api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/sipeed/picoclaw/pkg/bus"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/channels"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
channels.RegisterFactory("openai_api", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) {
|
||||||
|
return NewOpenAIAPIChannel(cfg, b)
|
||||||
|
})
|
||||||
|
}
|
||||||
750
pkg/channels/openai_api/openai_api.go
Normal file
750
pkg/channels/openai_api/openai_api.go
Normal file
|
|
@ -0,0 +1,750 @@
|
||||||
|
package openai_api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/subtle"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/bus"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/channels"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/logger"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/providers"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
maxRequestBodySize = 4 << 20
|
||||||
|
responseIdleWindow = 250 * time.Millisecond
|
||||||
|
responseWaitTimeout = 5 * time.Minute
|
||||||
|
)
|
||||||
|
|
||||||
|
type responseTask struct {
|
||||||
|
ctx context.Context
|
||||||
|
cancel context.CancelFunc
|
||||||
|
updates chan string
|
||||||
|
}
|
||||||
|
|
||||||
|
type chatCompletionRequest struct {
|
||||||
|
Model string `json:"model"`
|
||||||
|
Messages []chatCompletionMessage `json:"messages"`
|
||||||
|
Stream bool `json:"stream,omitempty"`
|
||||||
|
User string `json:"user,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type chatCompletionMessage struct {
|
||||||
|
Role string `json:"role"`
|
||||||
|
Content any `json:"content"`
|
||||||
|
ToolCallID string `json:"tool_call_id,omitempty"`
|
||||||
|
ToolCalls []chatToolCall `json:"tool_calls,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type chatToolCall struct {
|
||||||
|
ID string `json:"id,omitempty"`
|
||||||
|
Type string `json:"type,omitempty"`
|
||||||
|
Function *chatToolFunction `json:"function,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type chatToolFunction struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Arguments string `json:"arguments"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type translatedConversation struct {
|
||||||
|
CurrentMessage string
|
||||||
|
InjectedHistory []providers.Message
|
||||||
|
ExtraSystemPrompt string
|
||||||
|
}
|
||||||
|
|
||||||
|
type OpenAIAPIChannel struct {
|
||||||
|
*channels.BaseChannel
|
||||||
|
config config.OpenAIAPIConfig
|
||||||
|
listenHost string
|
||||||
|
models []config.ModelConfig
|
||||||
|
messageBus *bus.MessageBus
|
||||||
|
server *http.Server
|
||||||
|
listener net.Listener
|
||||||
|
ctx context.Context
|
||||||
|
cancel context.CancelFunc
|
||||||
|
taskMu sync.RWMutex
|
||||||
|
tasks map[string]*responseTask
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewOpenAIAPIChannel(cfg *config.Config, messageBus *bus.MessageBus) (*OpenAIAPIChannel, error) {
|
||||||
|
if cfg == nil {
|
||||||
|
return nil, fmt.Errorf("config is required")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(cfg.Channels.OpenAIAPI.APIKey) == "" {
|
||||||
|
return nil, fmt.Errorf("openai_api api_key is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
listenHost := strings.TrimSpace(cfg.Gateway.Host)
|
||||||
|
if listenHost == "" {
|
||||||
|
listenHost = "127.0.0.1"
|
||||||
|
}
|
||||||
|
|
||||||
|
base := channels.NewBaseChannel("openai_api", cfg.Channels.OpenAIAPI, messageBus, nil)
|
||||||
|
|
||||||
|
return &OpenAIAPIChannel{
|
||||||
|
BaseChannel: base,
|
||||||
|
config: cfg.Channels.OpenAIAPI,
|
||||||
|
listenHost: listenHost,
|
||||||
|
models: append([]config.ModelConfig(nil), cfg.ModelList...),
|
||||||
|
messageBus: messageBus,
|
||||||
|
tasks: make(map[string]*responseTask),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *OpenAIAPIChannel) Start(ctx context.Context) error {
|
||||||
|
addr := net.JoinHostPort(c.listenHost, strconv.Itoa(c.config.Port))
|
||||||
|
listener, err := net.Listen("tcp", addr)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("openai_api listen %s: %w", addr, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
c.ctx, c.cancel = context.WithCancel(ctx)
|
||||||
|
c.listener = listener
|
||||||
|
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("OPTIONS /v1/models", c.handleOptions)
|
||||||
|
mux.HandleFunc("GET /v1/models", c.handleModels)
|
||||||
|
mux.HandleFunc("OPTIONS /v1/chat/completions", c.handleOptions)
|
||||||
|
mux.HandleFunc("POST /v1/chat/completions", c.handleChatCompletions)
|
||||||
|
mux.HandleFunc("GET /health", c.handleHealth)
|
||||||
|
|
||||||
|
c.server = &http.Server{
|
||||||
|
Handler: mux,
|
||||||
|
ReadTimeout: 30 * time.Second,
|
||||||
|
// Streaming responses stay open until the request finishes.
|
||||||
|
WriteTimeout: 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
c.SetRunning(true)
|
||||||
|
logger.InfoCF("openai_api", "OpenAI API channel listening", map[string]any{
|
||||||
|
"addr": addr,
|
||||||
|
})
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
if err := c.server.Serve(listener); err != nil && err != http.ErrServerClosed {
|
||||||
|
logger.ErrorCF("openai_api", "OpenAI API server stopped unexpectedly", map[string]any{
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *OpenAIAPIChannel) Stop(ctx context.Context) error {
|
||||||
|
c.SetRunning(false)
|
||||||
|
|
||||||
|
if c.cancel != nil {
|
||||||
|
c.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
c.taskMu.Lock()
|
||||||
|
for chatID, task := range c.tasks {
|
||||||
|
if task.cancel != nil {
|
||||||
|
task.cancel()
|
||||||
|
}
|
||||||
|
delete(c.tasks, chatID)
|
||||||
|
}
|
||||||
|
c.taskMu.Unlock()
|
||||||
|
|
||||||
|
if c.server != nil {
|
||||||
|
if err := c.server.Shutdown(ctx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *OpenAIAPIChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
|
||||||
|
if !c.IsRunning() {
|
||||||
|
return channels.ErrNotRunning
|
||||||
|
}
|
||||||
|
content := strings.TrimSpace(msg.Content)
|
||||||
|
if content == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
task := c.getTask(msg.ChatID)
|
||||||
|
if task == nil {
|
||||||
|
logger.DebugCF("openai_api", "Dropping outbound response with no waiting request", map[string]any{
|
||||||
|
"chat_id": msg.ChatID,
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case task.updates <- content:
|
||||||
|
return nil
|
||||||
|
case <-task.ctx.Done():
|
||||||
|
return nil
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *OpenAIAPIChannel) handleOptions(w http.ResponseWriter, r *http.Request) {
|
||||||
|
setCORSHeaders(w)
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *OpenAIAPIChannel) handleHealth(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(map[string]any{"status": "ok"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *OpenAIAPIChannel) handleModels(w http.ResponseWriter, r *http.Request) {
|
||||||
|
setCORSHeaders(w)
|
||||||
|
if !c.authenticate(r) {
|
||||||
|
writeOpenAIError(w, http.StatusUnauthorized, "Invalid API key", "invalid_request_error", "invalid_api_key")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
type modelObject struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Object string `json:"object"`
|
||||||
|
Created int64 `json:"created"`
|
||||||
|
OwnedBy string `json:"owned_by"`
|
||||||
|
}
|
||||||
|
|
||||||
|
seen := make(map[string]bool)
|
||||||
|
items := make([]modelObject, 0, len(c.models))
|
||||||
|
for _, model := range c.models {
|
||||||
|
id := strings.TrimSpace(model.ModelName)
|
||||||
|
if id == "" || seen[id] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[id] = true
|
||||||
|
items = append(items, modelObject{
|
||||||
|
ID: id,
|
||||||
|
Object: "model",
|
||||||
|
Created: 0,
|
||||||
|
OwnedBy: "picoclaw",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.Slice(items, func(i, j int) bool {
|
||||||
|
return items[i].ID < items[j].ID
|
||||||
|
})
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(map[string]any{
|
||||||
|
"object": "list",
|
||||||
|
"data": items,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *OpenAIAPIChannel) handleChatCompletions(w http.ResponseWriter, r *http.Request) {
|
||||||
|
setCORSHeaders(w)
|
||||||
|
if !c.authenticate(r) {
|
||||||
|
writeOpenAIError(w, http.StatusUnauthorized, "Invalid API key", "invalid_request_error", "invalid_api_key")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req chatCompletionRequest
|
||||||
|
decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, maxRequestBodySize))
|
||||||
|
if err := decoder.Decode(&req); err != nil {
|
||||||
|
writeOpenAIError(w, http.StatusBadRequest, "Invalid JSON request body", "invalid_request_error", "invalid_json")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.TrimSpace(req.Model) == "" {
|
||||||
|
writeOpenAIError(w, http.StatusBadRequest, "model is required", "invalid_request_error", "missing_model")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !c.supportsModel(req.Model) {
|
||||||
|
writeOpenAIError(w, http.StatusBadRequest, fmt.Sprintf("model %q is not configured", req.Model), "invalid_request_error", "model_not_found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(req.Messages) == 0 {
|
||||||
|
writeOpenAIError(w, http.StatusBadRequest, "messages must not be empty", "invalid_request_error", "missing_messages")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
translated, err := translateConversation(req.Messages)
|
||||||
|
if err != nil {
|
||||||
|
writeOpenAIError(w, http.StatusBadRequest, err.Error(), "invalid_request_error", "invalid_messages")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
reqCtx, cancel := context.WithTimeout(r.Context(), responseWaitTimeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
chatID := "openai_api:" + uuid.NewString()
|
||||||
|
task := &responseTask{
|
||||||
|
ctx: reqCtx,
|
||||||
|
cancel: cancel,
|
||||||
|
updates: make(chan string, 32),
|
||||||
|
}
|
||||||
|
c.setTask(chatID, task)
|
||||||
|
defer c.deleteTask(chatID)
|
||||||
|
|
||||||
|
metadata := map[string]string{
|
||||||
|
"no_history": "true",
|
||||||
|
"requested_model": req.Model,
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(translated.ExtraSystemPrompt) != "" {
|
||||||
|
metadata["extra_system_prompt"] = translated.ExtraSystemPrompt
|
||||||
|
}
|
||||||
|
if len(translated.InjectedHistory) > 0 {
|
||||||
|
rawHistory, err := json.Marshal(translated.InjectedHistory)
|
||||||
|
if err != nil {
|
||||||
|
writeOpenAIError(w, http.StatusInternalServerError, "Failed to encode conversation history", "server_error", "history_encode_failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
metadata["injected_history"] = string(rawHistory)
|
||||||
|
}
|
||||||
|
|
||||||
|
senderID := strings.TrimSpace(req.User)
|
||||||
|
if senderID == "" {
|
||||||
|
senderID = "openai-client"
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.messageBus.PublishInbound(reqCtx, bus.InboundMessage{
|
||||||
|
Channel: c.Name(),
|
||||||
|
SenderID: senderID,
|
||||||
|
Sender: bus.SenderInfo{
|
||||||
|
Platform: c.Name(),
|
||||||
|
PlatformID: senderID,
|
||||||
|
CanonicalID: c.Name() + ":" + senderID,
|
||||||
|
DisplayName: senderID,
|
||||||
|
},
|
||||||
|
ChatID: chatID,
|
||||||
|
Content: translated.CurrentMessage,
|
||||||
|
Peer: bus.Peer{Kind: "direct", ID: senderID},
|
||||||
|
Metadata: metadata,
|
||||||
|
}); err != nil {
|
||||||
|
writeOpenAIError(w, http.StatusBadGateway, fmt.Sprintf("Failed to submit request: %v", err), "server_error", "publish_failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
firstChunk, err := waitForFirstChunk(reqCtx, task)
|
||||||
|
if err != nil {
|
||||||
|
writeOpenAIError(w, http.StatusGatewayTimeout, "Timed out waiting for assistant response", "server_error", "response_timeout")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
completionID := "chatcmpl_" + strings.ReplaceAll(uuid.NewString(), "-", "")
|
||||||
|
createdAt := time.Now().Unix()
|
||||||
|
|
||||||
|
if req.Stream {
|
||||||
|
flusher, ok := w.(http.Flusher)
|
||||||
|
if !ok {
|
||||||
|
writeOpenAIError(w, http.StatusInternalServerError, "Streaming is not supported by this server", "server_error", "stream_not_supported")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "text/event-stream")
|
||||||
|
w.Header().Set("Cache-Control", "no-cache")
|
||||||
|
w.Header().Set("Connection", "keep-alive")
|
||||||
|
|
||||||
|
if err := writeChatCompletionChunk(w, completionID, createdAt, req.Model, firstChunk, true, false); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
flusher.Flush()
|
||||||
|
|
||||||
|
if err := streamRemainingChunks(reqCtx, task, w, flusher, completionID, createdAt, req.Model); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = writeChatCompletionChunk(w, completionID, createdAt, req.Model, "", false, true)
|
||||||
|
_, _ = fmt.Fprint(w, "data: [DONE]\n\n")
|
||||||
|
flusher.Flush()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
chunks, err := collectRemainingChunks(reqCtx, task, []string{firstChunk})
|
||||||
|
if err != nil {
|
||||||
|
writeOpenAIError(w, http.StatusGatewayTimeout, "Timed out waiting for assistant response", "server_error", "response_timeout")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(map[string]any{
|
||||||
|
"id": completionID,
|
||||||
|
"object": "chat.completion",
|
||||||
|
"created": createdAt,
|
||||||
|
"model": req.Model,
|
||||||
|
"choices": []map[string]any{
|
||||||
|
{
|
||||||
|
"index": 0,
|
||||||
|
"message": map[string]any{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": strings.Join(chunks, "\n\n"),
|
||||||
|
},
|
||||||
|
"finish_reason": "stop",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func translateConversation(messages []chatCompletionMessage) (translatedConversation, error) {
|
||||||
|
var out translatedConversation
|
||||||
|
var nonSystem []providers.Message
|
||||||
|
var systemPrompts []string
|
||||||
|
|
||||||
|
for _, message := range messages {
|
||||||
|
role := strings.ToLower(strings.TrimSpace(message.Role))
|
||||||
|
content := strings.TrimSpace(extractMessageContent(message))
|
||||||
|
|
||||||
|
switch role {
|
||||||
|
case "system", "developer":
|
||||||
|
if content != "" {
|
||||||
|
systemPrompts = append(systemPrompts, content)
|
||||||
|
}
|
||||||
|
case "user", "assistant", "tool":
|
||||||
|
pm := providers.Message{
|
||||||
|
Role: role,
|
||||||
|
Content: content,
|
||||||
|
}
|
||||||
|
if role == "tool" {
|
||||||
|
pm.ToolCallID = strings.TrimSpace(message.ToolCallID)
|
||||||
|
}
|
||||||
|
nonSystem = append(nonSystem, pm)
|
||||||
|
default:
|
||||||
|
if content == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
nonSystem = append(nonSystem, providers.Message{
|
||||||
|
Role: "user",
|
||||||
|
Content: fmt.Sprintf("[%s]\n%s", role, content),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(nonSystem) == 0 {
|
||||||
|
return translatedConversation{}, fmt.Errorf("at least one non-system message is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
out.ExtraSystemPrompt = strings.Join(systemPrompts, "\n\n")
|
||||||
|
last := nonSystem[len(nonSystem)-1]
|
||||||
|
|
||||||
|
if last.Role == "user" && strings.TrimSpace(last.Content) != "" {
|
||||||
|
out.CurrentMessage = last.Content
|
||||||
|
out.InjectedHistory = append([]providers.Message(nil), nonSystem[:len(nonSystem)-1]...)
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
out.InjectedHistory = append([]providers.Message(nil), nonSystem...)
|
||||||
|
switch last.Role {
|
||||||
|
case "assistant":
|
||||||
|
out.CurrentMessage = "Continue the conversation with the next assistant response."
|
||||||
|
case "tool":
|
||||||
|
out.CurrentMessage = "Continue the conversation after the tool result above."
|
||||||
|
default:
|
||||||
|
out.CurrentMessage = "Continue the conversation based on the previous messages."
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractMessageContent(message chatCompletionMessage) string {
|
||||||
|
content := strings.TrimSpace(extractContentText(message.Content))
|
||||||
|
toolCalls := strings.TrimSpace(renderToolCalls(message.ToolCalls))
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case content == "":
|
||||||
|
return toolCalls
|
||||||
|
case toolCalls == "":
|
||||||
|
return content
|
||||||
|
default:
|
||||||
|
return content + "\n\n" + toolCalls
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderToolCalls(toolCalls []chatToolCall) string {
|
||||||
|
if len(toolCalls) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
lines := make([]string, 0, len(toolCalls))
|
||||||
|
for _, toolCall := range toolCalls {
|
||||||
|
if toolCall.Function == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
name := strings.TrimSpace(toolCall.Function.Name)
|
||||||
|
args := strings.TrimSpace(toolCall.Function.Arguments)
|
||||||
|
if name == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if args == "" {
|
||||||
|
lines = append(lines, fmt.Sprintf("[assistant_tool_call] %s()", name))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
lines = append(lines, fmt.Sprintf("[assistant_tool_call] %s(%s)", name, args))
|
||||||
|
}
|
||||||
|
return strings.Join(lines, "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractContentText(content any) string {
|
||||||
|
switch value := content.(type) {
|
||||||
|
case nil:
|
||||||
|
return ""
|
||||||
|
case string:
|
||||||
|
return value
|
||||||
|
case []any:
|
||||||
|
parts := make([]string, 0, len(value))
|
||||||
|
for _, rawPart := range value {
|
||||||
|
part, ok := rawPart.(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
partType, _ := part["type"].(string)
|
||||||
|
switch partType {
|
||||||
|
case "text", "input_text", "output_text":
|
||||||
|
if text, ok := stringValue(part["text"]); ok && text != "" {
|
||||||
|
parts = append(parts, text)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if text, ok := stringValue(part["input_text"]); ok && text != "" {
|
||||||
|
parts = append(parts, text)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if text, ok := stringValue(part["output_text"]); ok && text != "" {
|
||||||
|
parts = append(parts, text)
|
||||||
|
}
|
||||||
|
case "image_url", "input_image":
|
||||||
|
if url := extractImageURL(part["image_url"]); url != "" {
|
||||||
|
parts = append(parts, "[image] "+url)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
if text, ok := stringValue(part["text"]); ok && text != "" {
|
||||||
|
parts = append(parts, text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.Join(parts, "\n")
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractImageURL(value any) string {
|
||||||
|
switch v := value.(type) {
|
||||||
|
case string:
|
||||||
|
return strings.TrimSpace(v)
|
||||||
|
case map[string]any:
|
||||||
|
if url, ok := stringValue(v["url"]); ok {
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func stringValue(value any) (string, bool) {
|
||||||
|
text, ok := value.(string)
|
||||||
|
if !ok {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
text = strings.TrimSpace(text)
|
||||||
|
if text == "" {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return text, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func waitForFirstChunk(ctx context.Context, task *responseTask) (string, error) {
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case chunk := <-task.updates:
|
||||||
|
if strings.TrimSpace(chunk) != "" {
|
||||||
|
return chunk, nil
|
||||||
|
}
|
||||||
|
case <-ctx.Done():
|
||||||
|
return "", ctx.Err()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func collectRemainingChunks(ctx context.Context, task *responseTask, initial []string) ([]string, error) {
|
||||||
|
chunks := append([]string(nil), initial...)
|
||||||
|
timer := time.NewTimer(responseIdleWindow)
|
||||||
|
defer timer.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case chunk := <-task.updates:
|
||||||
|
if strings.TrimSpace(chunk) == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
chunks = append(chunks, chunk)
|
||||||
|
if !timer.Stop() {
|
||||||
|
select {
|
||||||
|
case <-timer.C:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
timer.Reset(responseIdleWindow)
|
||||||
|
case <-timer.C:
|
||||||
|
return chunks, nil
|
||||||
|
case <-ctx.Done():
|
||||||
|
return nil, ctx.Err()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func streamRemainingChunks(
|
||||||
|
ctx context.Context,
|
||||||
|
task *responseTask,
|
||||||
|
w http.ResponseWriter,
|
||||||
|
flusher http.Flusher,
|
||||||
|
completionID string,
|
||||||
|
createdAt int64,
|
||||||
|
model string,
|
||||||
|
) error {
|
||||||
|
timer := time.NewTimer(responseIdleWindow)
|
||||||
|
defer timer.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case chunk := <-task.updates:
|
||||||
|
if strings.TrimSpace(chunk) == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := writeChatCompletionChunk(w, completionID, createdAt, model, chunk, false, false); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
flusher.Flush()
|
||||||
|
if !timer.Stop() {
|
||||||
|
select {
|
||||||
|
case <-timer.C:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
timer.Reset(responseIdleWindow)
|
||||||
|
case <-timer.C:
|
||||||
|
return nil
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeChatCompletionChunk(
|
||||||
|
w http.ResponseWriter,
|
||||||
|
completionID string,
|
||||||
|
createdAt int64,
|
||||||
|
model string,
|
||||||
|
content string,
|
||||||
|
includeRole bool,
|
||||||
|
finished bool,
|
||||||
|
) error {
|
||||||
|
delta := map[string]any{}
|
||||||
|
if includeRole {
|
||||||
|
delta["role"] = "assistant"
|
||||||
|
}
|
||||||
|
if content != "" {
|
||||||
|
delta["content"] = content
|
||||||
|
}
|
||||||
|
|
||||||
|
finishReason := any(nil)
|
||||||
|
if finished {
|
||||||
|
finishReason = "stop"
|
||||||
|
}
|
||||||
|
|
||||||
|
payload, err := json.Marshal(map[string]any{
|
||||||
|
"id": completionID,
|
||||||
|
"object": "chat.completion.chunk",
|
||||||
|
"created": createdAt,
|
||||||
|
"model": model,
|
||||||
|
"choices": []map[string]any{
|
||||||
|
{
|
||||||
|
"index": 0,
|
||||||
|
"delta": delta,
|
||||||
|
"finish_reason": finishReason,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = fmt.Fprintf(w, "data: %s\n\n", payload)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeOpenAIError(w http.ResponseWriter, status int, message, errorType, code string) {
|
||||||
|
setCORSHeaders(w)
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(status)
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||||
|
"error": map[string]any{
|
||||||
|
"message": message,
|
||||||
|
"type": errorType,
|
||||||
|
"code": code,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func setCORSHeaders(w http.ResponseWriter) {
|
||||||
|
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||||
|
w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type")
|
||||||
|
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *OpenAIAPIChannel) authenticate(r *http.Request) bool {
|
||||||
|
token := strings.TrimSpace(strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer "))
|
||||||
|
if token == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
expected := strings.TrimSpace(c.config.APIKey)
|
||||||
|
if expected == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return subtle.ConstantTimeCompare([]byte(token), []byte(expected)) == 1
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *OpenAIAPIChannel) supportsModel(requested string) bool {
|
||||||
|
requested = strings.TrimSpace(requested)
|
||||||
|
if requested == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, model := range c.models {
|
||||||
|
if strings.TrimSpace(model.ModelName) == requested {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(model.Model) == requested {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
_, modelID := providers.ExtractProtocol(model.Model)
|
||||||
|
if modelID == requested {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *OpenAIAPIChannel) setTask(chatID string, task *responseTask) {
|
||||||
|
c.taskMu.Lock()
|
||||||
|
defer c.taskMu.Unlock()
|
||||||
|
c.tasks[chatID] = task
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *OpenAIAPIChannel) getTask(chatID string) *responseTask {
|
||||||
|
c.taskMu.RLock()
|
||||||
|
defer c.taskMu.RUnlock()
|
||||||
|
return c.tasks[chatID]
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *OpenAIAPIChannel) deleteTask(chatID string) {
|
||||||
|
c.taskMu.Lock()
|
||||||
|
defer c.taskMu.Unlock()
|
||||||
|
if task, ok := c.tasks[chatID]; ok && task.cancel != nil {
|
||||||
|
task.cancel()
|
||||||
|
}
|
||||||
|
delete(c.tasks, chatID)
|
||||||
|
}
|
||||||
166
pkg/channels/openai_api/openai_api_test.go
Normal file
166
pkg/channels/openai_api/openai_api_test.go
Normal file
|
|
@ -0,0 +1,166 @@
|
||||||
|
package openai_api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/bus"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newTestChannel(t *testing.T) (*OpenAIAPIChannel, *bus.MessageBus) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
cfg := config.DefaultConfig()
|
||||||
|
cfg.Channels.OpenAIAPI.APIKey = "test-key"
|
||||||
|
cfg.Channels.OpenAIAPI.Port = 0
|
||||||
|
|
||||||
|
messageBus := bus.NewMessageBus()
|
||||||
|
channel, err := NewOpenAIAPIChannel(cfg, messageBus)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewOpenAIAPIChannel() error = %v", err)
|
||||||
|
}
|
||||||
|
channel.SetRunning(true)
|
||||||
|
|
||||||
|
return channel, messageBus
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleChatCompletions_NonStreaming(t *testing.T) {
|
||||||
|
channel, messageBus := newTestChannel(t)
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
msg := <-messageBus.InboundChan()
|
||||||
|
if msg.Content != "Final user question" {
|
||||||
|
t.Errorf("inbound content = %q, want %q", msg.Content, "Final user question")
|
||||||
|
}
|
||||||
|
if got := msg.Metadata["requested_model"]; got != "gpt-5.4" {
|
||||||
|
t.Errorf("requested_model = %q, want %q", got, "gpt-5.4")
|
||||||
|
}
|
||||||
|
if got := msg.Metadata["no_history"]; got != "true" {
|
||||||
|
t.Errorf("no_history = %q, want %q", got, "true")
|
||||||
|
}
|
||||||
|
if msg.Metadata["extra_system_prompt"] == "" {
|
||||||
|
t.Error("expected extra_system_prompt metadata to be populated")
|
||||||
|
}
|
||||||
|
if msg.Metadata["injected_history"] == "" {
|
||||||
|
t.Error("expected injected_history metadata to be populated")
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = channel.Send(context.Background(), bus.OutboundMessage{
|
||||||
|
Channel: channel.Name(),
|
||||||
|
ChatID: msg.ChatID,
|
||||||
|
Content: "Assistant response",
|
||||||
|
})
|
||||||
|
}()
|
||||||
|
|
||||||
|
body := `{
|
||||||
|
"model": "gpt-5.4",
|
||||||
|
"messages": [
|
||||||
|
{"role": "system", "content": "Be concise."},
|
||||||
|
{"role": "user", "content": "Earlier question"},
|
||||||
|
{"role": "assistant", "content": "Earlier answer"},
|
||||||
|
{"role": "user", "content": "Final user question"}
|
||||||
|
]
|
||||||
|
}`
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(body))
|
||||||
|
req.Header.Set("Authorization", "Bearer test-key")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
|
channel.handleChatCompletions(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var response struct {
|
||||||
|
Model string `json:"model"`
|
||||||
|
Choices []struct {
|
||||||
|
Message struct {
|
||||||
|
Role string `json:"role"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
} `json:"message"`
|
||||||
|
} `json:"choices"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &response); err != nil {
|
||||||
|
t.Fatalf("Unmarshal() error = %v", err)
|
||||||
|
}
|
||||||
|
if response.Model != "gpt-5.4" {
|
||||||
|
t.Fatalf("model = %q, want %q", response.Model, "gpt-5.4")
|
||||||
|
}
|
||||||
|
if len(response.Choices) != 1 {
|
||||||
|
t.Fatalf("len(choices) = %d, want 1", len(response.Choices))
|
||||||
|
}
|
||||||
|
if response.Choices[0].Message.Role != "assistant" {
|
||||||
|
t.Fatalf("role = %q, want %q", response.Choices[0].Message.Role, "assistant")
|
||||||
|
}
|
||||||
|
if response.Choices[0].Message.Content != "Assistant response" {
|
||||||
|
t.Fatalf("content = %q, want %q", response.Choices[0].Message.Content, "Assistant response")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleChatCompletions_Streaming(t *testing.T) {
|
||||||
|
channel, messageBus := newTestChannel(t)
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
msg := <-messageBus.InboundChan()
|
||||||
|
_ = channel.Send(context.Background(), bus.OutboundMessage{
|
||||||
|
Channel: channel.Name(),
|
||||||
|
ChatID: msg.ChatID,
|
||||||
|
Content: "part one",
|
||||||
|
})
|
||||||
|
time.Sleep(50 * time.Millisecond)
|
||||||
|
_ = channel.Send(context.Background(), bus.OutboundMessage{
|
||||||
|
Channel: channel.Name(),
|
||||||
|
ChatID: msg.ChatID,
|
||||||
|
Content: "part two",
|
||||||
|
})
|
||||||
|
}()
|
||||||
|
|
||||||
|
body := `{
|
||||||
|
"model": "gpt-5.4",
|
||||||
|
"stream": true,
|
||||||
|
"messages": [
|
||||||
|
{"role": "user", "content": "Stream this please"}
|
||||||
|
]
|
||||||
|
}`
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(body))
|
||||||
|
req.Header.Set("Authorization", "Bearer test-key")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
|
channel.handleChatCompletions(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
bodyText := rec.Body.String()
|
||||||
|
if !strings.Contains(bodyText, `"content":"part one"`) {
|
||||||
|
t.Fatalf("stream body missing first chunk: %s", bodyText)
|
||||||
|
}
|
||||||
|
if !strings.Contains(bodyText, `"content":"part two"`) {
|
||||||
|
t.Fatalf("stream body missing second chunk: %s", bodyText)
|
||||||
|
}
|
||||||
|
if !strings.Contains(bodyText, "data: [DONE]") {
|
||||||
|
t.Fatalf("stream body missing [DONE]: %s", bodyText)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleModels_RequiresAuth(t *testing.T) {
|
||||||
|
channel, _ := newTestChannel(t)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/v1/models", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
|
channel.handleModels(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusUnauthorized {
|
||||||
|
t.Fatalf("status = %d, want %d", rec.Code, http.StatusUnauthorized)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -272,6 +272,7 @@ type ChannelsConfig struct {
|
||||||
WeComApp WeComAppConfig `json:"wecom_app"`
|
WeComApp WeComAppConfig `json:"wecom_app"`
|
||||||
WeComAIBot WeComAIBotConfig `json:"wecom_aibot"`
|
WeComAIBot WeComAIBotConfig `json:"wecom_aibot"`
|
||||||
Pico PicoConfig `json:"pico"`
|
Pico PicoConfig `json:"pico"`
|
||||||
|
OpenAIAPI OpenAIAPIConfig `json:"openai_api"`
|
||||||
IRC IRCConfig `json:"irc"`
|
IRC IRCConfig `json:"irc"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -473,6 +474,12 @@ type PicoConfig struct {
|
||||||
Placeholder PlaceholderConfig `json:"placeholder,omitempty"`
|
Placeholder PlaceholderConfig `json:"placeholder,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type OpenAIAPIConfig struct {
|
||||||
|
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_OPENAI_API_ENABLED"`
|
||||||
|
Port int `json:"port" env:"PICOCLAW_CHANNELS_OPENAI_API_PORT"`
|
||||||
|
APIKey string `json:"api_key" env:"PICOCLAW_CHANNELS_OPENAI_API_API_KEY"`
|
||||||
|
}
|
||||||
|
|
||||||
type IRCConfig struct {
|
type IRCConfig struct {
|
||||||
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_IRC_ENABLED"`
|
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_IRC_ENABLED"`
|
||||||
Server string `json:"server" env:"PICOCLAW_CHANNELS_IRC_SERVER"`
|
Server string `json:"server" env:"PICOCLAW_CHANNELS_IRC_SERVER"`
|
||||||
|
|
|
||||||
|
|
@ -175,6 +175,11 @@ func DefaultConfig() *Config {
|
||||||
MaxConnections: 100,
|
MaxConnections: 100,
|
||||||
AllowFrom: FlexibleStringSlice{},
|
AllowFrom: FlexibleStringSlice{},
|
||||||
},
|
},
|
||||||
|
OpenAIAPI: OpenAIAPIConfig{
|
||||||
|
Enabled: false,
|
||||||
|
Port: 18794,
|
||||||
|
APIKey: "",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
Providers: ProvidersConfig{
|
Providers: ProvidersConfig{
|
||||||
OpenAI: OpenAIProviderConfig{WebSearch: true},
|
OpenAI: OpenAIProviderConfig{WebSearch: true},
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ import (
|
||||||
_ "github.com/sipeed/picoclaw/pkg/channels/maixcam"
|
_ "github.com/sipeed/picoclaw/pkg/channels/maixcam"
|
||||||
_ "github.com/sipeed/picoclaw/pkg/channels/matrix"
|
_ "github.com/sipeed/picoclaw/pkg/channels/matrix"
|
||||||
_ "github.com/sipeed/picoclaw/pkg/channels/onebot"
|
_ "github.com/sipeed/picoclaw/pkg/channels/onebot"
|
||||||
|
_ "github.com/sipeed/picoclaw/pkg/channels/openai_api"
|
||||||
_ "github.com/sipeed/picoclaw/pkg/channels/pico"
|
_ "github.com/sipeed/picoclaw/pkg/channels/pico"
|
||||||
_ "github.com/sipeed/picoclaw/pkg/channels/qq"
|
_ "github.com/sipeed/picoclaw/pkg/channels/qq"
|
||||||
_ "github.com/sipeed/picoclaw/pkg/channels/slack"
|
_ "github.com/sipeed/picoclaw/pkg/channels/slack"
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ var channelCatalog = []channelCatalogItem{
|
||||||
{Name: "whatsapp", ConfigKey: "whatsapp", Variant: "bridge"},
|
{Name: "whatsapp", ConfigKey: "whatsapp", Variant: "bridge"},
|
||||||
{Name: "whatsapp_native", ConfigKey: "whatsapp", Variant: "native"},
|
{Name: "whatsapp_native", ConfigKey: "whatsapp", Variant: "native"},
|
||||||
{Name: "pico", ConfigKey: "pico"},
|
{Name: "pico", ConfigKey: "pico"},
|
||||||
|
{Name: "openai_api", ConfigKey: "openai_api"},
|
||||||
{Name: "maixcam", ConfigKey: "maixcam"},
|
{Name: "maixcam", ConfigKey: "maixcam"},
|
||||||
{Name: "matrix", ConfigKey: "matrix"},
|
{Name: "matrix", ConfigKey: "matrix"},
|
||||||
{Name: "irc", ConfigKey: "irc"},
|
{Name: "irc", ConfigKey: "irc"},
|
||||||
|
|
|
||||||
|
|
@ -179,6 +179,20 @@ func validateConfig(cfg *config.Config) []string {
|
||||||
errs = append(errs, "channels.pico.token is required when pico channel is enabled")
|
errs = append(errs, "channels.pico.token is required when pico channel is enabled")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if cfg.Channels.OpenAIAPI.Enabled && (cfg.Channels.OpenAIAPI.Port < 1 || cfg.Channels.OpenAIAPI.Port > 65535) {
|
||||||
|
errs = append(errs, fmt.Sprintf("channels.openai_api.port %d is out of valid range (1-65535)", cfg.Channels.OpenAIAPI.Port))
|
||||||
|
} else if cfg.Channels.OpenAIAPI.Port != 0 && (cfg.Channels.OpenAIAPI.Port < 1 || cfg.Channels.OpenAIAPI.Port > 65535) {
|
||||||
|
errs = append(errs, fmt.Sprintf("channels.openai_api.port %d is out of valid range (1-65535)", cfg.Channels.OpenAIAPI.Port))
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.Channels.OpenAIAPI.Enabled && cfg.Channels.OpenAIAPI.APIKey == "" {
|
||||||
|
errs = append(errs, "channels.openai_api.api_key is required when openai_api channel is enabled")
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.Channels.OpenAIAPI.Enabled && cfg.Channels.OpenAIAPI.Port == cfg.Gateway.Port {
|
||||||
|
errs = append(errs, "channels.openai_api.port must differ from gateway.port")
|
||||||
|
}
|
||||||
|
|
||||||
// Telegram: token required when enabled
|
// Telegram: token required when enabled
|
||||||
if cfg.Channels.Telegram.Enabled && cfg.Channels.Telegram.Token == "" {
|
if cfg.Channels.Telegram.Enabled && cfg.Channels.Telegram.Token == "" {
|
||||||
errs = append(errs, "channels.telegram.token is required when telegram channel is enabled")
|
errs = append(errs, "channels.telegram.token is required when telegram channel is enabled")
|
||||||
|
|
|
||||||
|
|
@ -165,3 +165,64 @@ func TestHandlePatchConfig_AllowsInvalidDenyRegexPatternsWhenDenyPatternsDisable
|
||||||
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
|
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestHandlePatchConfig_RejectsOpenAIAPIWithoutKeyWhenEnabled(t *testing.T) {
|
||||||
|
configPath, cleanup := setupOAuthTestEnv(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
h := NewHandler(configPath)
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
h.RegisterRoutes(mux)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPatch, "/api/config", bytes.NewBufferString(`{
|
||||||
|
"channels": {
|
||||||
|
"openai_api": {
|
||||||
|
"enabled": true,
|
||||||
|
"port": 18794,
|
||||||
|
"api_key": ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
mux.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusBadRequest, rec.Body.String())
|
||||||
|
}
|
||||||
|
if !bytes.Contains(rec.Body.Bytes(), []byte("channels.openai_api.api_key")) {
|
||||||
|
t.Fatalf("expected openai_api api_key validation error, body=%s", rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandlePatchConfig_RejectsOpenAIAPIPortConflictWithGateway(t *testing.T) {
|
||||||
|
configPath, cleanup := setupOAuthTestEnv(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
h := NewHandler(configPath)
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
h.RegisterRoutes(mux)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPatch, "/api/config", bytes.NewBufferString(`{
|
||||||
|
"gateway": {
|
||||||
|
"port": 18790
|
||||||
|
},
|
||||||
|
"channels": {
|
||||||
|
"openai_api": {
|
||||||
|
"enabled": true,
|
||||||
|
"port": 18790,
|
||||||
|
"api_key": "test-key"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
mux.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusBadRequest, rec.Body.String())
|
||||||
|
}
|
||||||
|
if !bytes.Contains(rec.Body.Bytes(), []byte("channels.openai_api.port must differ from gateway.port")) {
|
||||||
|
t.Fatalf("expected openai_api port conflict validation error, body=%s", rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,7 @@ const SECRET_FIELD_MAP: Record<string, string> = {
|
||||||
access_token: "_access_token",
|
access_token: "_access_token",
|
||||||
bot_token: "_bot_token",
|
bot_token: "_bot_token",
|
||||||
app_token: "_app_token",
|
app_token: "_app_token",
|
||||||
|
api_key: "_api_key",
|
||||||
encoding_aes_key: "_encoding_aes_key",
|
encoding_aes_key: "_encoding_aes_key",
|
||||||
encrypt_key: "_encrypt_key",
|
encrypt_key: "_encrypt_key",
|
||||||
verification_token: "_verification_token",
|
verification_token: "_verification_token",
|
||||||
|
|
@ -156,6 +157,8 @@ function isConfigured(
|
||||||
return asBool(config.use_native)
|
return asBool(config.use_native)
|
||||||
case "pico":
|
case "pico":
|
||||||
return asString(config.token) !== ""
|
return asString(config.token) !== ""
|
||||||
|
case "openai_api":
|
||||||
|
return asString(config.api_key) !== ""
|
||||||
case "maixcam":
|
case "maixcam":
|
||||||
return asString(config.host) !== ""
|
return asString(config.host) !== ""
|
||||||
case "matrix":
|
case "matrix":
|
||||||
|
|
@ -199,6 +202,8 @@ function getRequiredFieldKeys(channelName: string): string[] {
|
||||||
return ["bridge_url"]
|
return ["bridge_url"]
|
||||||
case "pico":
|
case "pico":
|
||||||
return ["token"]
|
return ["token"]
|
||||||
|
case "openai_api":
|
||||||
|
return ["api_key", "port"]
|
||||||
case "maixcam":
|
case "maixcam":
|
||||||
return ["host"]
|
return ["host"]
|
||||||
case "matrix":
|
case "matrix":
|
||||||
|
|
@ -229,6 +234,7 @@ function getChannelDocSlug(channelName: string): string {
|
||||||
|
|
||||||
const CHANNELS_WITHOUT_DOCS = new Set([
|
const CHANNELS_WITHOUT_DOCS = new Set([
|
||||||
"pico",
|
"pico",
|
||||||
|
"openai_api",
|
||||||
"wecom",
|
"wecom",
|
||||||
"matrix",
|
"matrix",
|
||||||
"irc",
|
"irc",
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ interface GenericFormProps {
|
||||||
// Secret field names that should use masked input.
|
// Secret field names that should use masked input.
|
||||||
const SECRET_FIELDS = new Set([
|
const SECRET_FIELDS = new Set([
|
||||||
"token",
|
"token",
|
||||||
|
"api_key",
|
||||||
"app_secret",
|
"app_secret",
|
||||||
"client_secret",
|
"client_secret",
|
||||||
"corp_secret",
|
"corp_secret",
|
||||||
|
|
@ -47,6 +48,23 @@ const OBJECT_FIELDS = new Set([
|
||||||
])
|
])
|
||||||
|
|
||||||
function formatLabel(key: string): string {
|
function formatLabel(key: string): string {
|
||||||
|
const overrides: Record<string, string> = {
|
||||||
|
api_key: "API Key",
|
||||||
|
ws_url: "WS URL",
|
||||||
|
app_id: "App ID",
|
||||||
|
user_id: "User ID",
|
||||||
|
device_id: "Device ID",
|
||||||
|
client_id: "Client ID",
|
||||||
|
corp_id: "Corp ID",
|
||||||
|
agent_id: "Agent ID",
|
||||||
|
webhook_url: "Webhook URL",
|
||||||
|
webhook_host: "Webhook Host",
|
||||||
|
webhook_port: "Webhook Port",
|
||||||
|
webhook_path: "Webhook Path",
|
||||||
|
}
|
||||||
|
if (overrides[key]) {
|
||||||
|
return overrides[key]
|
||||||
|
}
|
||||||
return key
|
return key
|
||||||
.split("_")
|
.split("_")
|
||||||
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
|
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
|
||||||
|
|
@ -104,6 +122,7 @@ export function GenericForm({
|
||||||
|
|
||||||
const buildHint = (key: string): string => {
|
const buildHint = (key: string): string => {
|
||||||
const descriptions: Record<string, string> = {
|
const descriptions: Record<string, string> = {
|
||||||
|
api_key: t("channels.form.desc.apiKey"),
|
||||||
ws_url: t("channels.form.desc.wsUrl"),
|
ws_url: t("channels.form.desc.wsUrl"),
|
||||||
reconnect_interval: t("channels.form.desc.reconnectInterval"),
|
reconnect_interval: t("channels.form.desc.reconnectInterval"),
|
||||||
bridge_url: t("channels.form.desc.bridgeUrl"),
|
bridge_url: t("channels.form.desc.bridgeUrl"),
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,7 @@ const CHANNEL_IMPORTANCE_ORDER = [
|
||||||
"onebot",
|
"onebot",
|
||||||
"matrix",
|
"matrix",
|
||||||
"pico",
|
"pico",
|
||||||
|
"openai_api",
|
||||||
"maixcam",
|
"maixcam",
|
||||||
"irc",
|
"irc",
|
||||||
"whatsapp",
|
"whatsapp",
|
||||||
|
|
@ -84,6 +85,7 @@ const CHANNEL_ICON_MAP: Record<
|
||||||
maixcam: IconCamera,
|
maixcam: IconCamera,
|
||||||
onebot: IconRobot,
|
onebot: IconRobot,
|
||||||
pico: IconBrandChrome,
|
pico: IconBrandChrome,
|
||||||
|
openai_api: IconPlug,
|
||||||
irc: IconMessages,
|
irc: IconMessages,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -238,6 +238,7 @@
|
||||||
"whatsapp": "WhatsApp",
|
"whatsapp": "WhatsApp",
|
||||||
"whatsapp_native": "WhatsApp Native",
|
"whatsapp_native": "WhatsApp Native",
|
||||||
"pico": "Web",
|
"pico": "Web",
|
||||||
|
"openai_api": "OpenAI API",
|
||||||
"maixcam": "MaixCam",
|
"maixcam": "MaixCam",
|
||||||
"matrix": "Matrix",
|
"matrix": "Matrix",
|
||||||
"irc": "IRC"
|
"irc": "IRC"
|
||||||
|
|
@ -277,6 +278,7 @@
|
||||||
"form": {
|
"form": {
|
||||||
"desc": {
|
"desc": {
|
||||||
"token": "Bot access token used to connect to the platform API.",
|
"token": "Bot access token used to connect to the platform API.",
|
||||||
|
"apiKey": "Authentication key used by external clients to access the OpenAI-compatible endpoint.",
|
||||||
"botToken": "Bot token used to send and receive messages.",
|
"botToken": "Bot token used to send and receive messages.",
|
||||||
"appToken": "App token used for Socket Mode connections.",
|
"appToken": "App token used for Socket Mode connections.",
|
||||||
"appId": "Unique application ID used for authentication.",
|
"appId": "Unique application ID used for authentication.",
|
||||||
|
|
|
||||||
|
|
@ -238,6 +238,7 @@
|
||||||
"whatsapp": "WhatsApp",
|
"whatsapp": "WhatsApp",
|
||||||
"whatsapp_native": "WhatsApp Native",
|
"whatsapp_native": "WhatsApp Native",
|
||||||
"pico": "Web",
|
"pico": "Web",
|
||||||
|
"openai_api": "OpenAI API",
|
||||||
"maixcam": "MaixCam",
|
"maixcam": "MaixCam",
|
||||||
"matrix": "Matrix",
|
"matrix": "Matrix",
|
||||||
"irc": "IRC"
|
"irc": "IRC"
|
||||||
|
|
@ -277,6 +278,7 @@
|
||||||
"form": {
|
"form": {
|
||||||
"desc": {
|
"desc": {
|
||||||
"token": "机器人访问令牌,用于连接平台 API。",
|
"token": "机器人访问令牌,用于连接平台 API。",
|
||||||
|
"apiKey": "用于访问 OpenAI 兼容接口的鉴权密钥。",
|
||||||
"botToken": "Bot Token,用于发送与接收消息。",
|
"botToken": "Bot Token,用于发送与接收消息。",
|
||||||
"appToken": "App Token,用于 Socket 模式连接。",
|
"appToken": "App Token,用于 Socket 模式连接。",
|
||||||
"appId": "应用唯一标识,用于平台鉴权。",
|
"appId": "应用唯一标识,用于平台鉴权。",
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue