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,
|
||||
media []string,
|
||||
channel, chatID, senderID, senderDisplayName string,
|
||||
extraSystemPrompt string,
|
||||
) []providers.Message {
|
||||
messages := []providers.Message{}
|
||||
|
||||
|
|
@ -539,6 +540,15 @@ func (cb *ContextBuilder) BuildMessages(
|
|||
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")
|
||||
|
||||
// Log system prompt summary for debugging (debug mode only).
|
||||
|
|
|
|||
|
|
@ -82,7 +82,7 @@ func TestSingleSystemMessage(t *testing.T) {
|
|||
|
||||
for _, tt := range tests {
|
||||
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
|
||||
for _, m := range msgs {
|
||||
|
|
@ -168,7 +168,7 @@ func TestBuildMessages_CurrentSenderDynamicContext(t *testing.T) {
|
|||
|
||||
for _, tt := range tests {
|
||||
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
|
||||
|
||||
if tt.wantSection {
|
||||
|
|
@ -638,7 +638,7 @@ func TestConcurrentBuildSystemPromptWithCache(t *testing.T) {
|
|||
}
|
||||
|
||||
// Also exercise BuildMessages concurrently
|
||||
msgs := cb.BuildMessages(nil, "", "hello", nil, "test", "chat", "", "")
|
||||
msgs := cb.BuildMessages(nil, "", "hello", nil, "test", "chat", "", "", "")
|
||||
if len(msgs) < 2 {
|
||||
errs <- "BuildMessages returned fewer than 2 messages"
|
||||
return
|
||||
|
|
@ -726,6 +726,6 @@ func BenchmarkBuildMessagesWithCache(b *testing.B) {
|
|||
|
||||
b.ResetTimer()
|
||||
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,10 +62,13 @@ type processOptions struct {
|
|||
SenderDisplayName string // Current sender display name for dynamic context
|
||||
UserMessage string // User message content (may include prefix)
|
||||
Media []string // media:// refs from inbound message
|
||||
InjectedHistory []providers.Message
|
||||
ExtraSystemPrompt string
|
||||
RequestedModel string
|
||||
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 session history (for heartbeat)
|
||||
NoHistory bool // If true, don't load or persist session history
|
||||
}
|
||||
|
||||
const (
|
||||
|
|
@ -76,6 +79,10 @@ const (
|
|||
metadataKeyTeamID = "team_id"
|
||||
metadataKeyParentPeerKind = "parent_peer_kind"
|
||||
metadataKeyParentPeerID = "parent_peer_id"
|
||||
metadataKeyNoHistory = "no_history"
|
||||
metadataKeyRequestedModel = "requested_model"
|
||||
metadataKeyInjectedHistory = "injected_history"
|
||||
metadataKeyExtraSystemPrompt = "extra_system_prompt"
|
||||
)
|
||||
|
||||
func NewAgentLoop(
|
||||
|
|
@ -758,6 +765,14 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage)
|
|||
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
|
||||
// "unavailable" when the required capability is nil.
|
||||
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)
|
||||
var history []providers.Message
|
||||
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)
|
||||
summary = agent.Sessions.GetSummary(opts.SessionKey)
|
||||
}
|
||||
|
|
@ -897,6 +914,7 @@ func (al *AgentLoop) runAgentLoop(
|
|||
opts.ChatID,
|
||||
opts.SenderID,
|
||||
opts.SenderDisplayName,
|
||||
opts.ExtraSystemPrompt,
|
||||
)
|
||||
|
||||
// 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)
|
||||
|
||||
// 2. Save user message to session
|
||||
if !opts.NoHistory {
|
||||
agent.Sessions.AddMessage(opts.SessionKey, "user", opts.UserMessage)
|
||||
}
|
||||
|
||||
// 3. Run LLM iteration loop
|
||||
finalContent, iteration, err := al.runLLMIteration(ctx, agent, messages, opts)
|
||||
|
|
@ -922,11 +942,13 @@ func (al *AgentLoop) runAgentLoop(
|
|||
}
|
||||
|
||||
// 5. Save final assistant message to session
|
||||
if !opts.NoHistory {
|
||||
agent.Sessions.AddMessage(opts.SessionKey, "assistant", finalContent)
|
||||
agent.Sessions.Save(opts.SessionKey)
|
||||
}
|
||||
|
||||
// 6. Optional: summarization
|
||||
if opts.EnableSummary {
|
||||
if opts.EnableSummary && !opts.NoHistory {
|
||||
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
|
||||
// all tool-follow-up iterations within the same turn so that a multi-step
|
||||
// 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 {
|
||||
iteration++
|
||||
|
|
@ -1162,7 +1187,7 @@ func (al *AgentLoop) runLLMIteration(
|
|||
continue
|
||||
}
|
||||
|
||||
if isContextError && retry < maxRetries {
|
||||
if isContextError && retry < maxRetries && !opts.NoHistory {
|
||||
logger.WarnCF(
|
||||
"agent",
|
||||
"Context window error detected, attempting compression",
|
||||
|
|
@ -1185,7 +1210,7 @@ func (al *AgentLoop) runLLMIteration(
|
|||
newSummary := agent.Sessions.GetSummary(opts.SessionKey)
|
||||
messages = agent.ContextBuilder.BuildMessages(
|
||||
newHistory, newSummary, "",
|
||||
nil, opts.Channel, opts.ChatID, opts.SenderID, opts.SenderDisplayName,
|
||||
nil, opts.Channel, opts.ChatID, opts.SenderID, opts.SenderDisplayName, opts.ExtraSystemPrompt,
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
|
@ -1446,11 +1471,16 @@ func (al *AgentLoop) runLLMIteration(
|
|||
// that a multi-step tool chain doesn't switch models mid-way.
|
||||
func (al *AgentLoop) selectCandidates(
|
||||
agent *AgentInstance,
|
||||
requestedModel string,
|
||||
userMsg string,
|
||||
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 {
|
||||
return agent.Candidates, agent.Model
|
||||
return agent.Candidates, agent.Model, nil
|
||||
}
|
||||
|
||||
_, usedLight, score := agent.Router.SelectModel(userMsg, history, agent.Model)
|
||||
|
|
@ -1461,7 +1491,7 @@ func (al *AgentLoop) selectCandidates(
|
|||
"score": score,
|
||||
"threshold": agent.Router.Threshold(),
|
||||
})
|
||||
return agent.Candidates, agent.Model
|
||||
return agent.Candidates, agent.Model, nil
|
||||
}
|
||||
|
||||
logger.InfoCF("agent", "Model routing: light model selected",
|
||||
|
|
@ -1471,7 +1501,66 @@ func (al *AgentLoop) selectCandidates(
|
|||
"score": score,
|
||||
"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.
|
||||
|
|
@ -1983,6 +2072,27 @@ func inboundMetadata(msg bus.InboundMessage, key string) string {
|
|||
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.
|
||||
func extractParentPeer(msg bus.InboundMessage) *routing.RoutePeer {
|
||||
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")
|
||||
}
|
||||
|
||||
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 != "" {
|
||||
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"`
|
||||
WeComAIBot WeComAIBotConfig `json:"wecom_aibot"`
|
||||
Pico PicoConfig `json:"pico"`
|
||||
OpenAIAPI OpenAIAPIConfig `json:"openai_api"`
|
||||
IRC IRCConfig `json:"irc"`
|
||||
}
|
||||
|
||||
|
|
@ -473,6 +474,12 @@ type PicoConfig struct {
|
|||
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 {
|
||||
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_IRC_ENABLED"`
|
||||
Server string `json:"server" env:"PICOCLAW_CHANNELS_IRC_SERVER"`
|
||||
|
|
|
|||
|
|
@ -175,6 +175,11 @@ func DefaultConfig() *Config {
|
|||
MaxConnections: 100,
|
||||
AllowFrom: FlexibleStringSlice{},
|
||||
},
|
||||
OpenAIAPI: OpenAIAPIConfig{
|
||||
Enabled: false,
|
||||
Port: 18794,
|
||||
APIKey: "",
|
||||
},
|
||||
},
|
||||
Providers: ProvidersConfig{
|
||||
OpenAI: OpenAIProviderConfig{WebSearch: true},
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import (
|
|||
_ "github.com/sipeed/picoclaw/pkg/channels/maixcam"
|
||||
_ "github.com/sipeed/picoclaw/pkg/channels/matrix"
|
||||
_ "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/qq"
|
||||
_ "github.com/sipeed/picoclaw/pkg/channels/slack"
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ var channelCatalog = []channelCatalogItem{
|
|||
{Name: "whatsapp", ConfigKey: "whatsapp", Variant: "bridge"},
|
||||
{Name: "whatsapp_native", ConfigKey: "whatsapp", Variant: "native"},
|
||||
{Name: "pico", ConfigKey: "pico"},
|
||||
{Name: "openai_api", ConfigKey: "openai_api"},
|
||||
{Name: "maixcam", ConfigKey: "maixcam"},
|
||||
{Name: "matrix", ConfigKey: "matrix"},
|
||||
{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")
|
||||
}
|
||||
|
||||
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
|
||||
if cfg.Channels.Telegram.Enabled && cfg.Channels.Telegram.Token == "" {
|
||||
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())
|
||||
}
|
||||
}
|
||||
|
||||
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",
|
||||
bot_token: "_bot_token",
|
||||
app_token: "_app_token",
|
||||
api_key: "_api_key",
|
||||
encoding_aes_key: "_encoding_aes_key",
|
||||
encrypt_key: "_encrypt_key",
|
||||
verification_token: "_verification_token",
|
||||
|
|
@ -156,6 +157,8 @@ function isConfigured(
|
|||
return asBool(config.use_native)
|
||||
case "pico":
|
||||
return asString(config.token) !== ""
|
||||
case "openai_api":
|
||||
return asString(config.api_key) !== ""
|
||||
case "maixcam":
|
||||
return asString(config.host) !== ""
|
||||
case "matrix":
|
||||
|
|
@ -199,6 +202,8 @@ function getRequiredFieldKeys(channelName: string): string[] {
|
|||
return ["bridge_url"]
|
||||
case "pico":
|
||||
return ["token"]
|
||||
case "openai_api":
|
||||
return ["api_key", "port"]
|
||||
case "maixcam":
|
||||
return ["host"]
|
||||
case "matrix":
|
||||
|
|
@ -229,6 +234,7 @@ function getChannelDocSlug(channelName: string): string {
|
|||
|
||||
const CHANNELS_WITHOUT_DOCS = new Set([
|
||||
"pico",
|
||||
"openai_api",
|
||||
"wecom",
|
||||
"matrix",
|
||||
"irc",
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ interface GenericFormProps {
|
|||
// Secret field names that should use masked input.
|
||||
const SECRET_FIELDS = new Set([
|
||||
"token",
|
||||
"api_key",
|
||||
"app_secret",
|
||||
"client_secret",
|
||||
"corp_secret",
|
||||
|
|
@ -47,6 +48,23 @@ const OBJECT_FIELDS = new Set([
|
|||
])
|
||||
|
||||
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
|
||||
.split("_")
|
||||
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
|
||||
|
|
@ -104,6 +122,7 @@ export function GenericForm({
|
|||
|
||||
const buildHint = (key: string): string => {
|
||||
const descriptions: Record<string, string> = {
|
||||
api_key: t("channels.form.desc.apiKey"),
|
||||
ws_url: t("channels.form.desc.wsUrl"),
|
||||
reconnect_interval: t("channels.form.desc.reconnectInterval"),
|
||||
bridge_url: t("channels.form.desc.bridgeUrl"),
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ const CHANNEL_IMPORTANCE_ORDER = [
|
|||
"onebot",
|
||||
"matrix",
|
||||
"pico",
|
||||
"openai_api",
|
||||
"maixcam",
|
||||
"irc",
|
||||
"whatsapp",
|
||||
|
|
@ -84,6 +85,7 @@ const CHANNEL_ICON_MAP: Record<
|
|||
maixcam: IconCamera,
|
||||
onebot: IconRobot,
|
||||
pico: IconBrandChrome,
|
||||
openai_api: IconPlug,
|
||||
irc: IconMessages,
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -238,6 +238,7 @@
|
|||
"whatsapp": "WhatsApp",
|
||||
"whatsapp_native": "WhatsApp Native",
|
||||
"pico": "Web",
|
||||
"openai_api": "OpenAI API",
|
||||
"maixcam": "MaixCam",
|
||||
"matrix": "Matrix",
|
||||
"irc": "IRC"
|
||||
|
|
@ -277,6 +278,7 @@
|
|||
"form": {
|
||||
"desc": {
|
||||
"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.",
|
||||
"appToken": "App token used for Socket Mode connections.",
|
||||
"appId": "Unique application ID used for authentication.",
|
||||
|
|
|
|||
|
|
@ -238,6 +238,7 @@
|
|||
"whatsapp": "WhatsApp",
|
||||
"whatsapp_native": "WhatsApp Native",
|
||||
"pico": "Web",
|
||||
"openai_api": "OpenAI API",
|
||||
"maixcam": "MaixCam",
|
||||
"matrix": "Matrix",
|
||||
"irc": "IRC"
|
||||
|
|
@ -277,6 +278,7 @@
|
|||
"form": {
|
||||
"desc": {
|
||||
"token": "机器人访问令牌,用于连接平台 API。",
|
||||
"apiKey": "用于访问 OpenAI 兼容接口的鉴权密钥。",
|
||||
"botToken": "Bot Token,用于发送与接收消息。",
|
||||
"appToken": "App Token,用于 Socket 模式连接。",
|
||||
"appId": "应用唯一标识,用于平台鉴权。",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue