feat: add OpenAI-compatible API channel

This commit is contained in:
j4ckzh0u 2026-03-18 17:54:20 +08:00
parent e6ebeaed13
commit 270959cb89
19 changed files with 1282 additions and 27 deletions

View file

@ -497,6 +497,7 @@ func (cb *ContextBuilder) BuildMessages(
currentMessage string, currentMessage string,
media []string, media []string,
channel, chatID, senderID, senderDisplayName string, channel, chatID, senderID, senderDisplayName string,
extraSystemPrompt string,
) []providers.Message { ) []providers.Message {
messages := []providers.Message{} messages := []providers.Message{}
@ -539,6 +540,15 @@ func (cb *ContextBuilder) BuildMessages(
contentBlocks = append(contentBlocks, providers.ContentBlock{Type: "text", Text: summaryText}) contentBlocks = append(contentBlocks, providers.ContentBlock{Type: "text", Text: summaryText})
} }
if strings.TrimSpace(extraSystemPrompt) != "" {
extraText := fmt.Sprintf(
"CLIENT_SYSTEM_INSTRUCTIONS: The following instructions were provided by the external client for this request only.\n\n%s",
extraSystemPrompt,
)
stringParts = append(stringParts, extraText)
contentBlocks = append(contentBlocks, providers.ContentBlock{Type: "text", Text: extraText})
}
fullSystemPrompt := strings.Join(stringParts, "\n\n---\n\n") fullSystemPrompt := strings.Join(stringParts, "\n\n---\n\n")
// Log system prompt summary for debugging (debug mode only). // Log system prompt summary for debugging (debug mode only).

View file

@ -82,7 +82,7 @@ func TestSingleSystemMessage(t *testing.T) {
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
msgs := cb.BuildMessages(tt.history, tt.summary, tt.message, nil, "test", "chat1", "", "") msgs := cb.BuildMessages(tt.history, tt.summary, tt.message, nil, "test", "chat1", "", "", "")
systemCount := 0 systemCount := 0
for _, m := range msgs { for _, m := range msgs {
@ -168,7 +168,7 @@ func TestBuildMessages_CurrentSenderDynamicContext(t *testing.T) {
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
msgs := cb.BuildMessages(nil, "", "hello", nil, "discord", "chat1", tt.senderID, tt.senderDisplayName) msgs := cb.BuildMessages(nil, "", "hello", nil, "discord", "chat1", tt.senderID, tt.senderDisplayName, "")
sys := msgs[0].Content sys := msgs[0].Content
if tt.wantSection { if tt.wantSection {
@ -638,7 +638,7 @@ func TestConcurrentBuildSystemPromptWithCache(t *testing.T) {
} }
// Also exercise BuildMessages concurrently // Also exercise BuildMessages concurrently
msgs := cb.BuildMessages(nil, "", "hello", nil, "test", "chat", "", "") msgs := cb.BuildMessages(nil, "", "hello", nil, "test", "chat", "", "", "")
if len(msgs) < 2 { if len(msgs) < 2 {
errs <- "BuildMessages returned fewer than 2 messages" errs <- "BuildMessages returned fewer than 2 messages"
return return
@ -726,6 +726,6 @@ func BenchmarkBuildMessagesWithCache(b *testing.B) {
b.ResetTimer() b.ResetTimer()
for i := 0; i < b.N; i++ { for i := 0; i < b.N; i++ {
_ = cb.BuildMessages(history, "summary", "new message", nil, "cli", "test", "", "") _ = cb.BuildMessages(history, "summary", "new message", nil, "cli", "test", "", "", "")
} }
} }

View file

@ -62,10 +62,13 @@ type processOptions struct {
SenderDisplayName string // Current sender display name for dynamic context SenderDisplayName string // Current sender display name for dynamic context
UserMessage string // User message content (may include prefix) UserMessage string // User message content (may include prefix)
Media []string // media:// refs from inbound message Media []string // media:// refs from inbound message
InjectedHistory []providers.Message
ExtraSystemPrompt string
RequestedModel string
DefaultResponse string // Response when LLM returns empty DefaultResponse string // Response when LLM returns empty
EnableSummary bool // Whether to trigger summarization EnableSummary bool // Whether to trigger summarization
SendResponse bool // Whether to send response via bus 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 ( const (
@ -76,6 +79,10 @@ const (
metadataKeyTeamID = "team_id" metadataKeyTeamID = "team_id"
metadataKeyParentPeerKind = "parent_peer_kind" metadataKeyParentPeerKind = "parent_peer_kind"
metadataKeyParentPeerID = "parent_peer_id" metadataKeyParentPeerID = "parent_peer_id"
metadataKeyNoHistory = "no_history"
metadataKeyRequestedModel = "requested_model"
metadataKeyInjectedHistory = "injected_history"
metadataKeyExtraSystemPrompt = "extra_system_prompt"
) )
func NewAgentLoop( func NewAgentLoop(
@ -758,6 +765,14 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage)
SendResponse: false, SendResponse: false,
} }
if metadataEnabled(msg, metadataKeyNoHistory) {
opts.NoHistory = true
opts.EnableSummary = false
}
opts.RequestedModel = inboundMetadata(msg, metadataKeyRequestedModel)
opts.ExtraSystemPrompt = inboundMetadata(msg, metadataKeyExtraSystemPrompt)
opts.InjectedHistory = decodeInjectedHistory(msg)
// context-dependent commands check their own Runtime fields and report // context-dependent commands check their own Runtime fields and report
// "unavailable" when the required capability is nil. // "unavailable" when the required capability is nil.
if response, handled := al.handleCommand(ctx, msg, agent, &opts); handled { if response, handled := al.handleCommand(ctx, msg, agent, &opts); handled {
@ -884,7 +899,9 @@ func (al *AgentLoop) runAgentLoop(
// 1. Build messages (skip history for heartbeat) // 1. Build messages (skip history for heartbeat)
var history []providers.Message var history []providers.Message
var summary string var summary string
if !opts.NoHistory { if len(opts.InjectedHistory) > 0 {
history = append([]providers.Message(nil), opts.InjectedHistory...)
} else if !opts.NoHistory {
history = agent.Sessions.GetHistory(opts.SessionKey) history = agent.Sessions.GetHistory(opts.SessionKey)
summary = agent.Sessions.GetSummary(opts.SessionKey) summary = agent.Sessions.GetSummary(opts.SessionKey)
} }
@ -897,6 +914,7 @@ func (al *AgentLoop) runAgentLoop(
opts.ChatID, opts.ChatID,
opts.SenderID, opts.SenderID,
opts.SenderDisplayName, opts.SenderDisplayName,
opts.ExtraSystemPrompt,
) )
// Resolve media:// refs: images→base64 data URLs, non-images→local paths in content // Resolve media:// refs: images→base64 data URLs, non-images→local paths in content
@ -905,7 +923,9 @@ func (al *AgentLoop) runAgentLoop(
messages = resolveMediaRefs(messages, al.mediaStore, maxMediaSize) messages = resolveMediaRefs(messages, al.mediaStore, maxMediaSize)
// 2. Save user message to session // 2. Save user message to session
if !opts.NoHistory {
agent.Sessions.AddMessage(opts.SessionKey, "user", opts.UserMessage) agent.Sessions.AddMessage(opts.SessionKey, "user", opts.UserMessage)
}
// 3. Run LLM iteration loop // 3. Run LLM iteration loop
finalContent, iteration, err := al.runLLMIteration(ctx, agent, messages, opts) finalContent, iteration, err := al.runLLMIteration(ctx, agent, messages, opts)
@ -922,11 +942,13 @@ func (al *AgentLoop) runAgentLoop(
} }
// 5. Save final assistant message to session // 5. Save final assistant message to session
if !opts.NoHistory {
agent.Sessions.AddMessage(opts.SessionKey, "assistant", finalContent) agent.Sessions.AddMessage(opts.SessionKey, "assistant", finalContent)
agent.Sessions.Save(opts.SessionKey) agent.Sessions.Save(opts.SessionKey)
}
// 6. Optional: summarization // 6. Optional: summarization
if opts.EnableSummary { if opts.EnableSummary && !opts.NoHistory {
al.maybeSummarize(agent, opts.SessionKey, opts.Channel, opts.ChatID) al.maybeSummarize(agent, opts.SessionKey, opts.Channel, opts.ChatID)
} }
@ -1022,7 +1044,10 @@ func (al *AgentLoop) runLLMIteration(
// selectCandidates evaluates routing once and the decision is sticky for // selectCandidates evaluates routing once and the decision is sticky for
// all tool-follow-up iterations within the same turn so that a multi-step // all tool-follow-up iterations within the same turn so that a multi-step
// tool chain doesn't switch models mid-way through. // tool chain doesn't switch models mid-way through.
activeCandidates, activeModel := al.selectCandidates(agent, opts.UserMessage, messages) activeCandidates, activeModel, err := al.selectCandidates(agent, opts.RequestedModel, opts.UserMessage, messages)
if err != nil {
return "", iteration, err
}
for iteration < agent.MaxIterations { for iteration < agent.MaxIterations {
iteration++ iteration++
@ -1162,7 +1187,7 @@ func (al *AgentLoop) runLLMIteration(
continue continue
} }
if isContextError && retry < maxRetries { if isContextError && retry < maxRetries && !opts.NoHistory {
logger.WarnCF( logger.WarnCF(
"agent", "agent",
"Context window error detected, attempting compression", "Context window error detected, attempting compression",
@ -1185,7 +1210,7 @@ func (al *AgentLoop) runLLMIteration(
newSummary := agent.Sessions.GetSummary(opts.SessionKey) newSummary := agent.Sessions.GetSummary(opts.SessionKey)
messages = agent.ContextBuilder.BuildMessages( messages = agent.ContextBuilder.BuildMessages(
newHistory, newSummary, "", newHistory, newSummary, "",
nil, opts.Channel, opts.ChatID, opts.SenderID, opts.SenderDisplayName, nil, opts.Channel, opts.ChatID, opts.SenderID, opts.SenderDisplayName, opts.ExtraSystemPrompt,
) )
continue continue
} }
@ -1446,11 +1471,16 @@ func (al *AgentLoop) runLLMIteration(
// that a multi-step tool chain doesn't switch models mid-way. // that a multi-step tool chain doesn't switch models mid-way.
func (al *AgentLoop) selectCandidates( func (al *AgentLoop) selectCandidates(
agent *AgentInstance, agent *AgentInstance,
requestedModel string,
userMsg string, userMsg string,
history []providers.Message, history []providers.Message,
) (candidates []providers.FallbackCandidate, model string) { ) (candidates []providers.FallbackCandidate, model string, err error) {
if strings.TrimSpace(requestedModel) != "" {
return al.resolveRequestedModelCandidates(agent, requestedModel)
}
if agent.Router == nil || len(agent.LightCandidates) == 0 { if agent.Router == nil || len(agent.LightCandidates) == 0 {
return agent.Candidates, agent.Model return agent.Candidates, agent.Model, nil
} }
_, usedLight, score := agent.Router.SelectModel(userMsg, history, agent.Model) _, usedLight, score := agent.Router.SelectModel(userMsg, history, agent.Model)
@ -1461,7 +1491,7 @@ func (al *AgentLoop) selectCandidates(
"score": score, "score": score,
"threshold": agent.Router.Threshold(), "threshold": agent.Router.Threshold(),
}) })
return agent.Candidates, agent.Model return agent.Candidates, agent.Model, nil
} }
logger.InfoCF("agent", "Model routing: light model selected", logger.InfoCF("agent", "Model routing: light model selected",
@ -1471,7 +1501,66 @@ func (al *AgentLoop) selectCandidates(
"score": score, "score": score,
"threshold": agent.Router.Threshold(), "threshold": agent.Router.Threshold(),
}) })
return agent.LightCandidates, agent.Router.LightModel() return agent.LightCandidates, agent.Router.LightModel(), nil
}
func (al *AgentLoop) resolveRequestedModelCandidates(
agent *AgentInstance,
requestedModel string,
) ([]providers.FallbackCandidate, string, error) {
requestedModel = strings.TrimSpace(requestedModel)
if requestedModel == "" {
return agent.Candidates, agent.Model, nil
}
cfg := al.GetConfig()
resolveFromModelList := func(raw string) (string, bool) {
ensureProtocol := func(model string) string {
if model == "" {
return ""
}
if strings.Contains(model, "/") {
return model
}
return "openai/" + model
}
raw = strings.TrimSpace(raw)
if raw == "" || cfg == nil {
return "", false
}
if mc, err := cfg.GetModelConfig(raw); err == nil && mc != nil && strings.TrimSpace(mc.Model) != "" {
return ensureProtocol(mc.Model), true
}
for i := range cfg.ModelList {
fullModel := strings.TrimSpace(cfg.ModelList[i].Model)
if fullModel == "" {
continue
}
if fullModel == raw {
return ensureProtocol(fullModel), true
}
_, modelID := providers.ExtractProtocol(fullModel)
if modelID == raw {
return ensureProtocol(fullModel), true
}
}
return "", false
}
candidates := providers.ResolveCandidatesWithLookup(
providers.ModelConfig{Primary: requestedModel},
al.cfg.Agents.Defaults.Provider,
resolveFromModelList,
)
if len(candidates) == 0 {
return nil, "", fmt.Errorf("requested model %q not found in model_list", requestedModel)
}
return candidates, requestedModel, nil
} }
// maybeSummarize triggers summarization if the session history exceeds thresholds. // maybeSummarize triggers summarization if the session history exceeds thresholds.
@ -1983,6 +2072,27 @@ func inboundMetadata(msg bus.InboundMessage, key string) string {
return msg.Metadata[key] return msg.Metadata[key]
} }
func metadataEnabled(msg bus.InboundMessage, key string) bool {
value := strings.ToLower(strings.TrimSpace(inboundMetadata(msg, key)))
return value == "1" || value == "true" || value == "yes" || value == "on"
}
func decodeInjectedHistory(msg bus.InboundMessage) []providers.Message {
raw := inboundMetadata(msg, metadataKeyInjectedHistory)
if strings.TrimSpace(raw) == "" {
return nil
}
var history []providers.Message
if err := json.Unmarshal([]byte(raw), &history); err != nil {
logger.WarnCF("agent", "Failed to decode injected history", map[string]any{
"error": err.Error(),
})
return nil
}
return history
}
// extractParentPeer extracts the parent peer (reply-to) from inbound message metadata. // extractParentPeer extracts the parent peer (reply-to) from inbound message metadata.
func extractParentPeer(msg bus.InboundMessage) *routing.RoutePeer { func extractParentPeer(msg bus.InboundMessage) *routing.RoutePeer {
parentKind := inboundMetadata(msg, metadataKeyParentPeerKind) parentKind := inboundMetadata(msg, metadataKeyParentPeerKind)

View 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))
}
}

View file

@ -303,6 +303,10 @@ func (m *Manager) initChannels() error {
m.initChannel("pico", "Pico") m.initChannel("pico", "Pico")
} }
if m.config.Channels.OpenAIAPI.Enabled && m.config.Channels.OpenAIAPI.APIKey != "" {
m.initChannel("openai_api", "OpenAI API")
}
if m.config.Channels.IRC.Enabled && m.config.Channels.IRC.Server != "" { if m.config.Channels.IRC.Enabled && m.config.Channels.IRC.Server != "" {
m.initChannel("irc", "IRC") m.initChannel("irc", "IRC")
} }

View 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)
})
}

View 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)
}

View 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)
}
}

View file

@ -272,6 +272,7 @@ type ChannelsConfig struct {
WeComApp WeComAppConfig `json:"wecom_app"` WeComApp WeComAppConfig `json:"wecom_app"`
WeComAIBot WeComAIBotConfig `json:"wecom_aibot"` WeComAIBot WeComAIBotConfig `json:"wecom_aibot"`
Pico PicoConfig `json:"pico"` Pico PicoConfig `json:"pico"`
OpenAIAPI OpenAIAPIConfig `json:"openai_api"`
IRC IRCConfig `json:"irc"` IRC IRCConfig `json:"irc"`
} }
@ -473,6 +474,12 @@ type PicoConfig struct {
Placeholder PlaceholderConfig `json:"placeholder,omitempty"` Placeholder PlaceholderConfig `json:"placeholder,omitempty"`
} }
type OpenAIAPIConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_OPENAI_API_ENABLED"`
Port int `json:"port" env:"PICOCLAW_CHANNELS_OPENAI_API_PORT"`
APIKey string `json:"api_key" env:"PICOCLAW_CHANNELS_OPENAI_API_API_KEY"`
}
type IRCConfig struct { type IRCConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_IRC_ENABLED"` Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_IRC_ENABLED"`
Server string `json:"server" env:"PICOCLAW_CHANNELS_IRC_SERVER"` Server string `json:"server" env:"PICOCLAW_CHANNELS_IRC_SERVER"`

View file

@ -175,6 +175,11 @@ func DefaultConfig() *Config {
MaxConnections: 100, MaxConnections: 100,
AllowFrom: FlexibleStringSlice{}, AllowFrom: FlexibleStringSlice{},
}, },
OpenAIAPI: OpenAIAPIConfig{
Enabled: false,
Port: 18794,
APIKey: "",
},
}, },
Providers: ProvidersConfig{ Providers: ProvidersConfig{
OpenAI: OpenAIProviderConfig{WebSearch: true}, OpenAI: OpenAIProviderConfig{WebSearch: true},

View file

@ -21,6 +21,7 @@ import (
_ "github.com/sipeed/picoclaw/pkg/channels/maixcam" _ "github.com/sipeed/picoclaw/pkg/channels/maixcam"
_ "github.com/sipeed/picoclaw/pkg/channels/matrix" _ "github.com/sipeed/picoclaw/pkg/channels/matrix"
_ "github.com/sipeed/picoclaw/pkg/channels/onebot" _ "github.com/sipeed/picoclaw/pkg/channels/onebot"
_ "github.com/sipeed/picoclaw/pkg/channels/openai_api"
_ "github.com/sipeed/picoclaw/pkg/channels/pico" _ "github.com/sipeed/picoclaw/pkg/channels/pico"
_ "github.com/sipeed/picoclaw/pkg/channels/qq" _ "github.com/sipeed/picoclaw/pkg/channels/qq"
_ "github.com/sipeed/picoclaw/pkg/channels/slack" _ "github.com/sipeed/picoclaw/pkg/channels/slack"

View file

@ -26,6 +26,7 @@ var channelCatalog = []channelCatalogItem{
{Name: "whatsapp", ConfigKey: "whatsapp", Variant: "bridge"}, {Name: "whatsapp", ConfigKey: "whatsapp", Variant: "bridge"},
{Name: "whatsapp_native", ConfigKey: "whatsapp", Variant: "native"}, {Name: "whatsapp_native", ConfigKey: "whatsapp", Variant: "native"},
{Name: "pico", ConfigKey: "pico"}, {Name: "pico", ConfigKey: "pico"},
{Name: "openai_api", ConfigKey: "openai_api"},
{Name: "maixcam", ConfigKey: "maixcam"}, {Name: "maixcam", ConfigKey: "maixcam"},
{Name: "matrix", ConfigKey: "matrix"}, {Name: "matrix", ConfigKey: "matrix"},
{Name: "irc", ConfigKey: "irc"}, {Name: "irc", ConfigKey: "irc"},

View file

@ -179,6 +179,20 @@ func validateConfig(cfg *config.Config) []string {
errs = append(errs, "channels.pico.token is required when pico channel is enabled") errs = append(errs, "channels.pico.token is required when pico channel is enabled")
} }
if cfg.Channels.OpenAIAPI.Enabled && (cfg.Channels.OpenAIAPI.Port < 1 || cfg.Channels.OpenAIAPI.Port > 65535) {
errs = append(errs, fmt.Sprintf("channels.openai_api.port %d is out of valid range (1-65535)", cfg.Channels.OpenAIAPI.Port))
} else if cfg.Channels.OpenAIAPI.Port != 0 && (cfg.Channels.OpenAIAPI.Port < 1 || cfg.Channels.OpenAIAPI.Port > 65535) {
errs = append(errs, fmt.Sprintf("channels.openai_api.port %d is out of valid range (1-65535)", cfg.Channels.OpenAIAPI.Port))
}
if cfg.Channels.OpenAIAPI.Enabled && cfg.Channels.OpenAIAPI.APIKey == "" {
errs = append(errs, "channels.openai_api.api_key is required when openai_api channel is enabled")
}
if cfg.Channels.OpenAIAPI.Enabled && cfg.Channels.OpenAIAPI.Port == cfg.Gateway.Port {
errs = append(errs, "channels.openai_api.port must differ from gateway.port")
}
// Telegram: token required when enabled // Telegram: token required when enabled
if cfg.Channels.Telegram.Enabled && cfg.Channels.Telegram.Token == "" { if cfg.Channels.Telegram.Enabled && cfg.Channels.Telegram.Token == "" {
errs = append(errs, "channels.telegram.token is required when telegram channel is enabled") errs = append(errs, "channels.telegram.token is required when telegram channel is enabled")

View file

@ -165,3 +165,64 @@ func TestHandlePatchConfig_AllowsInvalidDenyRegexPatternsWhenDenyPatternsDisable
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
} }
} }
func TestHandlePatchConfig_RejectsOpenAIAPIWithoutKeyWhenEnabled(t *testing.T) {
configPath, cleanup := setupOAuthTestEnv(t)
defer cleanup()
h := NewHandler(configPath)
mux := http.NewServeMux()
h.RegisterRoutes(mux)
req := httptest.NewRequest(http.MethodPatch, "/api/config", bytes.NewBufferString(`{
"channels": {
"openai_api": {
"enabled": true,
"port": 18794,
"api_key": ""
}
}
}`))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusBadRequest, rec.Body.String())
}
if !bytes.Contains(rec.Body.Bytes(), []byte("channels.openai_api.api_key")) {
t.Fatalf("expected openai_api api_key validation error, body=%s", rec.Body.String())
}
}
func TestHandlePatchConfig_RejectsOpenAIAPIPortConflictWithGateway(t *testing.T) {
configPath, cleanup := setupOAuthTestEnv(t)
defer cleanup()
h := NewHandler(configPath)
mux := http.NewServeMux()
h.RegisterRoutes(mux)
req := httptest.NewRequest(http.MethodPatch, "/api/config", bytes.NewBufferString(`{
"gateway": {
"port": 18790
},
"channels": {
"openai_api": {
"enabled": true,
"port": 18790,
"api_key": "test-key"
}
}
}`))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusBadRequest, rec.Body.String())
}
if !bytes.Contains(rec.Body.Bytes(), []byte("channels.openai_api.port must differ from gateway.port")) {
t.Fatalf("expected openai_api port conflict validation error, body=%s", rec.Body.String())
}
}

View file

@ -36,6 +36,7 @@ const SECRET_FIELD_MAP: Record<string, string> = {
access_token: "_access_token", access_token: "_access_token",
bot_token: "_bot_token", bot_token: "_bot_token",
app_token: "_app_token", app_token: "_app_token",
api_key: "_api_key",
encoding_aes_key: "_encoding_aes_key", encoding_aes_key: "_encoding_aes_key",
encrypt_key: "_encrypt_key", encrypt_key: "_encrypt_key",
verification_token: "_verification_token", verification_token: "_verification_token",
@ -156,6 +157,8 @@ function isConfigured(
return asBool(config.use_native) return asBool(config.use_native)
case "pico": case "pico":
return asString(config.token) !== "" return asString(config.token) !== ""
case "openai_api":
return asString(config.api_key) !== ""
case "maixcam": case "maixcam":
return asString(config.host) !== "" return asString(config.host) !== ""
case "matrix": case "matrix":
@ -199,6 +202,8 @@ function getRequiredFieldKeys(channelName: string): string[] {
return ["bridge_url"] return ["bridge_url"]
case "pico": case "pico":
return ["token"] return ["token"]
case "openai_api":
return ["api_key", "port"]
case "maixcam": case "maixcam":
return ["host"] return ["host"]
case "matrix": case "matrix":
@ -229,6 +234,7 @@ function getChannelDocSlug(channelName: string): string {
const CHANNELS_WITHOUT_DOCS = new Set([ const CHANNELS_WITHOUT_DOCS = new Set([
"pico", "pico",
"openai_api",
"wecom", "wecom",
"matrix", "matrix",
"irc", "irc",

View file

@ -17,6 +17,7 @@ interface GenericFormProps {
// Secret field names that should use masked input. // Secret field names that should use masked input.
const SECRET_FIELDS = new Set([ const SECRET_FIELDS = new Set([
"token", "token",
"api_key",
"app_secret", "app_secret",
"client_secret", "client_secret",
"corp_secret", "corp_secret",
@ -47,6 +48,23 @@ const OBJECT_FIELDS = new Set([
]) ])
function formatLabel(key: string): string { function formatLabel(key: string): string {
const overrides: Record<string, string> = {
api_key: "API Key",
ws_url: "WS URL",
app_id: "App ID",
user_id: "User ID",
device_id: "Device ID",
client_id: "Client ID",
corp_id: "Corp ID",
agent_id: "Agent ID",
webhook_url: "Webhook URL",
webhook_host: "Webhook Host",
webhook_port: "Webhook Port",
webhook_path: "Webhook Path",
}
if (overrides[key]) {
return overrides[key]
}
return key return key
.split("_") .split("_")
.map((w) => w.charAt(0).toUpperCase() + w.slice(1)) .map((w) => w.charAt(0).toUpperCase() + w.slice(1))
@ -104,6 +122,7 @@ export function GenericForm({
const buildHint = (key: string): string => { const buildHint = (key: string): string => {
const descriptions: Record<string, string> = { const descriptions: Record<string, string> = {
api_key: t("channels.form.desc.apiKey"),
ws_url: t("channels.form.desc.wsUrl"), ws_url: t("channels.form.desc.wsUrl"),
reconnect_interval: t("channels.form.desc.reconnectInterval"), reconnect_interval: t("channels.form.desc.reconnectInterval"),
bridge_url: t("channels.form.desc.bridgeUrl"), bridge_url: t("channels.form.desc.bridgeUrl"),

View file

@ -42,6 +42,7 @@ const CHANNEL_IMPORTANCE_ORDER = [
"onebot", "onebot",
"matrix", "matrix",
"pico", "pico",
"openai_api",
"maixcam", "maixcam",
"irc", "irc",
"whatsapp", "whatsapp",
@ -84,6 +85,7 @@ const CHANNEL_ICON_MAP: Record<
maixcam: IconCamera, maixcam: IconCamera,
onebot: IconRobot, onebot: IconRobot,
pico: IconBrandChrome, pico: IconBrandChrome,
openai_api: IconPlug,
irc: IconMessages, irc: IconMessages,
} }

View file

@ -238,6 +238,7 @@
"whatsapp": "WhatsApp", "whatsapp": "WhatsApp",
"whatsapp_native": "WhatsApp Native", "whatsapp_native": "WhatsApp Native",
"pico": "Web", "pico": "Web",
"openai_api": "OpenAI API",
"maixcam": "MaixCam", "maixcam": "MaixCam",
"matrix": "Matrix", "matrix": "Matrix",
"irc": "IRC" "irc": "IRC"
@ -277,6 +278,7 @@
"form": { "form": {
"desc": { "desc": {
"token": "Bot access token used to connect to the platform API.", "token": "Bot access token used to connect to the platform API.",
"apiKey": "Authentication key used by external clients to access the OpenAI-compatible endpoint.",
"botToken": "Bot token used to send and receive messages.", "botToken": "Bot token used to send and receive messages.",
"appToken": "App token used for Socket Mode connections.", "appToken": "App token used for Socket Mode connections.",
"appId": "Unique application ID used for authentication.", "appId": "Unique application ID used for authentication.",

View file

@ -238,6 +238,7 @@
"whatsapp": "WhatsApp", "whatsapp": "WhatsApp",
"whatsapp_native": "WhatsApp Native", "whatsapp_native": "WhatsApp Native",
"pico": "Web", "pico": "Web",
"openai_api": "OpenAI API",
"maixcam": "MaixCam", "maixcam": "MaixCam",
"matrix": "Matrix", "matrix": "Matrix",
"irc": "IRC" "irc": "IRC"
@ -277,6 +278,7 @@
"form": { "form": {
"desc": { "desc": {
"token": "机器人访问令牌,用于连接平台 API。", "token": "机器人访问令牌,用于连接平台 API。",
"apiKey": "用于访问 OpenAI 兼容接口的鉴权密钥。",
"botToken": "Bot Token用于发送与接收消息。", "botToken": "Bot Token用于发送与接收消息。",
"appToken": "App Token用于 Socket 模式连接。", "appToken": "App Token用于 Socket 模式连接。",
"appId": "应用唯一标识,用于平台鉴权。", "appId": "应用唯一标识,用于平台鉴权。",