diff --git a/pkg/agent/context.go b/pkg/agent/context.go index 830edf875..492074bed 100644 --- a/pkg/agent/context.go +++ b/pkg/agent/context.go @@ -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). diff --git a/pkg/agent/context_cache_test.go b/pkg/agent/context_cache_test.go index c26976c3c..deea931f8 100644 --- a/pkg/agent/context_cache_test.go +++ b/pkg/agent/context_cache_test.go @@ -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", "", "", "") } } diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index 86994c360..ee5fc30bf 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -62,20 +62,27 @@ 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 - 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) + 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 or persist session history } const ( - defaultResponse = "I've completed processing but have no response to give. Increase `max_tool_iterations` in config.json." - sessionKeyAgentPrefix = "agent:" - metadataKeyAccountID = "account_id" - metadataKeyGuildID = "guild_id" - metadataKeyTeamID = "team_id" - metadataKeyParentPeerKind = "parent_peer_kind" - metadataKeyParentPeerID = "parent_peer_id" + defaultResponse = "I've completed processing but have no response to give. Increase `max_tool_iterations` in config.json." + sessionKeyAgentPrefix = "agent:" + metadataKeyAccountID = "account_id" + metadataKeyGuildID = "guild_id" + 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 - agent.Sessions.AddMessage(opts.SessionKey, "user", opts.UserMessage) + 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 - agent.Sessions.AddMessage(opts.SessionKey, "assistant", finalContent) - agent.Sessions.Save(opts.SessionKey) + 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) diff --git a/pkg/agent/openai_request_test.go b/pkg/agent/openai_request_test.go new file mode 100644 index 000000000..f2b7f47be --- /dev/null +++ b/pkg/agent/openai_request_test.go @@ -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)) + } +} diff --git a/pkg/channels/manager.go b/pkg/channels/manager.go index aed815399..ac6718ec9 100644 --- a/pkg/channels/manager.go +++ b/pkg/channels/manager.go @@ -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") } diff --git a/pkg/channels/openai_api/init.go b/pkg/channels/openai_api/init.go new file mode 100644 index 000000000..57439ca0a --- /dev/null +++ b/pkg/channels/openai_api/init.go @@ -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) + }) +} diff --git a/pkg/channels/openai_api/openai_api.go b/pkg/channels/openai_api/openai_api.go new file mode 100644 index 000000000..4d7604e5c --- /dev/null +++ b/pkg/channels/openai_api/openai_api.go @@ -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) +} diff --git a/pkg/channels/openai_api/openai_api_test.go b/pkg/channels/openai_api/openai_api_test.go new file mode 100644 index 000000000..122dfe85b --- /dev/null +++ b/pkg/channels/openai_api/openai_api_test.go @@ -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) + } +} diff --git a/pkg/config/config.go b/pkg/config/config.go index 49fb3679f..f1cafb638 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -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"` diff --git a/pkg/config/defaults.go b/pkg/config/defaults.go index 9e8668779..314f8b6e8 100644 --- a/pkg/config/defaults.go +++ b/pkg/config/defaults.go @@ -175,6 +175,11 @@ func DefaultConfig() *Config { MaxConnections: 100, AllowFrom: FlexibleStringSlice{}, }, + OpenAIAPI: OpenAIAPIConfig{ + Enabled: false, + Port: 18794, + APIKey: "", + }, }, Providers: ProvidersConfig{ OpenAI: OpenAIProviderConfig{WebSearch: true}, diff --git a/pkg/gateway/gateway.go b/pkg/gateway/gateway.go index 6745d1748..b95fb0e09 100644 --- a/pkg/gateway/gateway.go +++ b/pkg/gateway/gateway.go @@ -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" diff --git a/web/backend/api/channels.go b/web/backend/api/channels.go index 507882823..33a8745c8 100644 --- a/web/backend/api/channels.go +++ b/web/backend/api/channels.go @@ -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"}, diff --git a/web/backend/api/config.go b/web/backend/api/config.go index a7d5b3c5d..6bc2dff41 100644 --- a/web/backend/api/config.go +++ b/web/backend/api/config.go @@ -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") diff --git a/web/backend/api/config_test.go b/web/backend/api/config_test.go index 54ec8e857..e7cb0e4d3 100644 --- a/web/backend/api/config_test.go +++ b/web/backend/api/config_test.go @@ -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()) + } +} diff --git a/web/frontend/src/components/channels/channel-config-page.tsx b/web/frontend/src/components/channels/channel-config-page.tsx index b19d11e6a..b3c1fe20f 100644 --- a/web/frontend/src/components/channels/channel-config-page.tsx +++ b/web/frontend/src/components/channels/channel-config-page.tsx @@ -36,6 +36,7 @@ const SECRET_FIELD_MAP: Record = { 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", diff --git a/web/frontend/src/components/channels/channel-forms/generic-form.tsx b/web/frontend/src/components/channels/channel-forms/generic-form.tsx index fc5a0a7fd..6ae6eecd1 100644 --- a/web/frontend/src/components/channels/channel-forms/generic-form.tsx +++ b/web/frontend/src/components/channels/channel-forms/generic-form.tsx @@ -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 = { + 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 = { + 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"), diff --git a/web/frontend/src/hooks/use-sidebar-channels.ts b/web/frontend/src/hooks/use-sidebar-channels.ts index 5579a955b..2fbfd70b5 100644 --- a/web/frontend/src/hooks/use-sidebar-channels.ts +++ b/web/frontend/src/hooks/use-sidebar-channels.ts @@ -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, } diff --git a/web/frontend/src/i18n/locales/en.json b/web/frontend/src/i18n/locales/en.json index 0b9d8c614..7900e88b7 100644 --- a/web/frontend/src/i18n/locales/en.json +++ b/web/frontend/src/i18n/locales/en.json @@ -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.", diff --git a/web/frontend/src/i18n/locales/zh.json b/web/frontend/src/i18n/locales/zh.json index c0aa158a2..969070394 100644 --- a/web/frontend/src/i18n/locales/zh.json +++ b/web/frontend/src/i18n/locales/zh.json @@ -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": "应用唯一标识,用于平台鉴权。",