fix: address security and code quality issues in OpenAI channel

- Make CORS policy configurable with AllowOrigins config
- Default to localhost only for security
- Refactor resolveFromModelCandidates to reduce complexity
- Improve error handling consistency with proper logging
- Fix translateConversation boundary case handling
- Add comprehensive tests for CORS and message validation

Fixes review comments from @yinwm and @alexhoshina
This commit is contained in:
j4ckzh0u 2026-03-24 15:17:20 +08:00
parent 332159841a
commit 14f8b9c967
7 changed files with 499 additions and 141 deletions

View file

@ -154,47 +154,9 @@ func NewAgentInstance(
Primary: model, Primary: model,
Fallbacks: fallbacks, Fallbacks: fallbacks,
} }
resolveFromModelList := func(raw string) (string, bool) { candidates := providers.ResolveCandidatesWithLookup(modelCfg, defaults.Provider, func(raw string) (string, bool) {
ensureProtocol := func(model string) string { return resolveFromModelList(cfg, raw)
model = strings.TrimSpace(model) })
if model == "" {
return ""
}
if strings.Contains(model, "/") {
return model
}
return "openai/" + model
}
raw = strings.TrimSpace(raw)
if raw == "" {
return "", false
}
if cfg != nil {
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(modelCfg, defaults.Provider, resolveFromModelList)
// Model routing setup: pre-resolve light model candidates at creation time // Model routing setup: pre-resolve light model candidates at creation time
// to avoid repeated model_list lookups on every incoming message. // to avoid repeated model_list lookups on every incoming message.
@ -202,7 +164,9 @@ func NewAgentInstance(
var lightCandidates []providers.FallbackCandidate var lightCandidates []providers.FallbackCandidate
if rc := defaults.Routing; rc != nil && rc.Enabled && rc.LightModel != "" { if rc := defaults.Routing; rc != nil && rc.Enabled && rc.LightModel != "" {
lightModelCfg := providers.ModelConfig{Primary: rc.LightModel} lightModelCfg := providers.ModelConfig{Primary: rc.LightModel}
resolved := providers.ResolveCandidatesWithLookup(lightModelCfg, defaults.Provider, resolveFromModelList) resolved := providers.ResolveCandidatesWithLookup(lightModelCfg, defaults.Provider, func(raw string) (string, bool) {
return resolveFromModelList(cfg, raw)
})
if len(resolved) > 0 { if len(resolved) > 0 {
router = routing.New(routing.RouterConfig{ router = routing.New(routing.RouterConfig{
LightModel: rc.LightModel, LightModel: rc.LightModel,

View file

@ -1514,47 +1514,13 @@ func (al *AgentLoop) resolveRequestedModelCandidates(
} }
cfg := al.GetConfig() 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( candidates := providers.ResolveCandidatesWithLookup(
providers.ModelConfig{Primary: requestedModel}, providers.ModelConfig{Primary: requestedModel},
al.cfg.Agents.Defaults.Provider, al.cfg.Agents.Defaults.Provider,
resolveFromModelList, func(raw string) (string, bool) {
return resolveFromModelList(cfg, raw)
},
) )
if len(candidates) == 0 { if len(candidates) == 0 {
return nil, "", fmt.Errorf("requested model %q not found in model_list", requestedModel) return nil, "", fmt.Errorf("requested model %q not found in model_list", requestedModel)

View file

@ -0,0 +1,46 @@
package agent
import (
"strings"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/providers"
)
func ensureProtocol(model string) string {
model = strings.TrimSpace(model)
if model == "" {
return ""
}
if strings.Contains(model, "/") {
return model
}
return "openai/" + model
}
func resolveFromModelList(cfg *config.Config, raw string) (string, bool) {
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
}

View file

@ -7,6 +7,7 @@ import (
"fmt" "fmt"
"net" "net"
"net/http" "net/http"
"net/url"
"sort" "sort"
"strconv" "strconv"
"strings" "strings"
@ -28,6 +29,8 @@ const (
responseWaitTimeout = 5 * time.Minute responseWaitTimeout = 5 * time.Minute
) )
var defaultAllowedOrigins = []string{"localhost"}
type responseTask struct { type responseTask struct {
ctx context.Context ctx context.Context
cancel context.CancelFunc cancel context.CancelFunc
@ -68,6 +71,7 @@ type translatedConversation struct {
type OpenAIAPIChannel struct { type OpenAIAPIChannel struct {
*channels.BaseChannel *channels.BaseChannel
config config.OpenAIAPIConfig config config.OpenAIAPIConfig
allowedOrigins []string
listenHost string listenHost string
models []config.ModelConfig models []config.ModelConfig
messageBus *bus.MessageBus messageBus *bus.MessageBus
@ -87,16 +91,20 @@ func NewOpenAIAPIChannel(cfg *config.Config, messageBus *bus.MessageBus) (*OpenA
return nil, fmt.Errorf("openai_api api_key is required") return nil, fmt.Errorf("openai_api api_key is required")
} }
channelConfig := cfg.Channels.OpenAIAPI
channelConfig.AllowOrigins = normalizeAllowedOrigins(channelConfig.AllowOrigins)
listenHost := strings.TrimSpace(cfg.Gateway.Host) listenHost := strings.TrimSpace(cfg.Gateway.Host)
if listenHost == "" { if listenHost == "" {
listenHost = "127.0.0.1" listenHost = "127.0.0.1"
} }
base := channels.NewBaseChannel("openai_api", cfg.Channels.OpenAIAPI, messageBus, nil) base := channels.NewBaseChannel("openai_api", channelConfig, messageBus, nil)
return &OpenAIAPIChannel{ return &OpenAIAPIChannel{
BaseChannel: base, BaseChannel: base,
config: cfg.Channels.OpenAIAPI, config: channelConfig,
allowedOrigins: append([]string(nil), channelConfig.AllowOrigins...),
listenHost: listenHost, listenHost: listenHost,
models: append([]config.ModelConfig(nil), cfg.ModelList...), models: append([]config.ModelConfig(nil), cfg.ModelList...),
messageBus: messageBus, messageBus: messageBus,
@ -197,19 +205,30 @@ func (c *OpenAIAPIChannel) Send(ctx context.Context, msg bus.OutboundMessage) er
} }
func (c *OpenAIAPIChannel) handleOptions(w http.ResponseWriter, r *http.Request) { func (c *OpenAIAPIChannel) handleOptions(w http.ResponseWriter, r *http.Request) {
setCORSHeaders(w) if !c.applyCORSHeaders(w, r) {
return
}
w.WriteHeader(http.StatusNoContent) w.WriteHeader(http.StatusNoContent)
} }
func (c *OpenAIAPIChannel) handleHealth(w http.ResponseWriter, r *http.Request) { func (c *OpenAIAPIChannel) handleHealth(w http.ResponseWriter, r *http.Request) {
if !c.applyCORSHeaders(w, r) {
return
}
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{"status": "ok"}) if err := writeJSONResponse(w, http.StatusOK, map[string]any{"status": "ok"}); err != nil {
return
}
} }
func (c *OpenAIAPIChannel) handleModels(w http.ResponseWriter, r *http.Request) { func (c *OpenAIAPIChannel) handleModels(w http.ResponseWriter, r *http.Request) {
setCORSHeaders(w) if !c.applyCORSHeaders(w, r) {
return
}
if !c.authenticate(r) { if !c.authenticate(r) {
writeOpenAIError(w, http.StatusUnauthorized, "Invalid API key", "invalid_request_error", "invalid_api_key") if err := writeOpenAIError(w, http.StatusUnauthorized, "Invalid API key", "invalid_request_error", "invalid_api_key"); err != nil {
return
}
return return
} }
@ -241,42 +260,64 @@ func (c *OpenAIAPIChannel) handleModels(w http.ResponseWriter, r *http.Request)
}) })
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{ if err := writeJSONResponse(w, http.StatusOK, map[string]any{
"object": "list", "object": "list",
"data": items, "data": items,
}) }); err != nil {
return
}
} }
func (c *OpenAIAPIChannel) handleChatCompletions(w http.ResponseWriter, r *http.Request) { func (c *OpenAIAPIChannel) handleChatCompletions(w http.ResponseWriter, r *http.Request) {
setCORSHeaders(w) if !c.applyCORSHeaders(w, r) {
return
}
if !c.authenticate(r) { if !c.authenticate(r) {
writeOpenAIError(w, http.StatusUnauthorized, "Invalid API key", "invalid_request_error", "invalid_api_key") if err := writeOpenAIError(w, http.StatusUnauthorized, "Invalid API key", "invalid_request_error", "invalid_api_key"); err != nil {
return
}
return return
} }
var req chatCompletionRequest var req chatCompletionRequest
decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, maxRequestBodySize)) decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, maxRequestBodySize))
if err := decoder.Decode(&req); err != nil { if err := decoder.Decode(&req); err != nil {
writeOpenAIError(w, http.StatusBadRequest, "Invalid JSON request body", "invalid_request_error", "invalid_json") logger.WarnCF("openai_api", "Invalid chat completion request body", map[string]any{
"error": err.Error(),
})
if err := writeOpenAIError(w, http.StatusBadRequest, "Invalid JSON request body", "invalid_request_error", "invalid_json"); err != nil {
return
}
return return
} }
if strings.TrimSpace(req.Model) == "" { if strings.TrimSpace(req.Model) == "" {
writeOpenAIError(w, http.StatusBadRequest, "model is required", "invalid_request_error", "missing_model") if err := writeOpenAIError(w, http.StatusBadRequest, "model is required", "invalid_request_error", "missing_model"); err != nil {
return
}
return return
} }
if !c.supportsModel(req.Model) { if !c.supportsModel(req.Model) {
writeOpenAIError(w, http.StatusBadRequest, fmt.Sprintf("model %q is not configured", req.Model), "invalid_request_error", "model_not_found") if err := writeOpenAIError(w, http.StatusBadRequest, fmt.Sprintf("model %q is not configured", req.Model), "invalid_request_error", "model_not_found"); err != nil {
return
}
return return
} }
if len(req.Messages) == 0 { if len(req.Messages) == 0 {
writeOpenAIError(w, http.StatusBadRequest, "messages must not be empty", "invalid_request_error", "missing_messages") if err := writeOpenAIError(w, http.StatusBadRequest, "messages must not be empty", "invalid_request_error", "missing_messages"); err != nil {
return
}
return return
} }
translated, err := translateConversation(req.Messages) translated, err := translateConversation(req.Messages)
if err != nil { if err != nil {
writeOpenAIError(w, http.StatusBadRequest, err.Error(), "invalid_request_error", "invalid_messages") logger.WarnCF("openai_api", "Invalid chat completion message sequence", map[string]any{
"error": err.Error(),
})
if err := writeOpenAIError(w, http.StatusBadRequest, err.Error(), "invalid_request_error", "invalid_messages"); err != nil {
return
}
return return
} }
@ -302,7 +343,12 @@ func (c *OpenAIAPIChannel) handleChatCompletions(w http.ResponseWriter, r *http.
if len(translated.InjectedHistory) > 0 { if len(translated.InjectedHistory) > 0 {
rawHistory, err := json.Marshal(translated.InjectedHistory) rawHistory, err := json.Marshal(translated.InjectedHistory)
if err != nil { if err != nil {
writeOpenAIError(w, http.StatusInternalServerError, "Failed to encode conversation history", "server_error", "history_encode_failed") logger.ErrorCF("openai_api", "Failed to encode injected history", map[string]any{
"error": err.Error(),
})
if err := writeOpenAIError(w, http.StatusInternalServerError, "Failed to encode conversation history", "server_error", "history_encode_failed"); err != nil {
return
}
return return
} }
metadata["injected_history"] = string(rawHistory) metadata["injected_history"] = string(rawHistory)
@ -327,13 +373,23 @@ func (c *OpenAIAPIChannel) handleChatCompletions(w http.ResponseWriter, r *http.
Peer: bus.Peer{Kind: "direct", ID: senderID}, Peer: bus.Peer{Kind: "direct", ID: senderID},
Metadata: metadata, Metadata: metadata,
}); err != nil { }); err != nil {
writeOpenAIError(w, http.StatusBadGateway, fmt.Sprintf("Failed to submit request: %v", err), "server_error", "publish_failed") logger.ErrorCF("openai_api", "Failed to publish inbound OpenAI API request", map[string]any{
"error": err.Error(),
})
if err := writeOpenAIError(w, http.StatusBadGateway, fmt.Sprintf("Failed to submit request: %v", err), "server_error", "publish_failed"); err != nil {
return
}
return return
} }
firstChunk, err := waitForFirstChunk(reqCtx, task) firstChunk, err := waitForFirstChunk(reqCtx, task)
if err != nil { if err != nil {
writeOpenAIError(w, http.StatusGatewayTimeout, "Timed out waiting for assistant response", "server_error", "response_timeout") logger.ErrorCF("openai_api", "Timed out waiting for first assistant chunk", map[string]any{
"error": err.Error(),
})
if err := writeOpenAIError(w, http.StatusGatewayTimeout, "Timed out waiting for assistant response", "server_error", "response_timeout"); err != nil {
return
}
return return
} }
@ -343,7 +399,9 @@ func (c *OpenAIAPIChannel) handleChatCompletions(w http.ResponseWriter, r *http.
if req.Stream { if req.Stream {
flusher, ok := w.(http.Flusher) flusher, ok := w.(http.Flusher)
if !ok { if !ok {
writeOpenAIError(w, http.StatusInternalServerError, "Streaming is not supported by this server", "server_error", "stream_not_supported") if err := writeOpenAIError(w, http.StatusInternalServerError, "Streaming is not supported by this server", "server_error", "stream_not_supported"); err != nil {
return
}
return return
} }
@ -352,28 +410,49 @@ func (c *OpenAIAPIChannel) handleChatCompletions(w http.ResponseWriter, r *http.
w.Header().Set("Connection", "keep-alive") w.Header().Set("Connection", "keep-alive")
if err := writeChatCompletionChunk(w, completionID, createdAt, req.Model, firstChunk, true, false); err != nil { if err := writeChatCompletionChunk(w, completionID, createdAt, req.Model, firstChunk, true, false); err != nil {
logger.ErrorCF("openai_api", "Failed to write first streaming chunk", map[string]any{
"error": err.Error(),
})
return return
} }
flusher.Flush() flusher.Flush()
if err := streamRemainingChunks(reqCtx, task, w, flusher, completionID, createdAt, req.Model); err != nil { if err := streamRemainingChunks(reqCtx, task, w, flusher, completionID, createdAt, req.Model); err != nil {
logger.ErrorCF("openai_api", "Failed to stream assistant chunks", map[string]any{
"error": err.Error(),
})
return return
} }
_ = writeChatCompletionChunk(w, completionID, createdAt, req.Model, "", false, true) if err := writeChatCompletionChunk(w, completionID, createdAt, req.Model, "", false, true); err != nil {
_, _ = fmt.Fprint(w, "data: [DONE]\n\n") logger.ErrorCF("openai_api", "Failed to write final streaming chunk", map[string]any{
"error": err.Error(),
})
return
}
if _, err := fmt.Fprint(w, "data: [DONE]\n\n"); err != nil {
logger.ErrorCF("openai_api", "Failed to write stream terminator", map[string]any{
"error": err.Error(),
})
return
}
flusher.Flush() flusher.Flush()
return return
} }
chunks, err := collectRemainingChunks(reqCtx, task, []string{firstChunk}) chunks, err := collectRemainingChunks(reqCtx, task, []string{firstChunk})
if err != nil { if err != nil {
writeOpenAIError(w, http.StatusGatewayTimeout, "Timed out waiting for assistant response", "server_error", "response_timeout") logger.ErrorCF("openai_api", "Timed out collecting assistant response chunks", map[string]any{
"error": err.Error(),
})
if err := writeOpenAIError(w, http.StatusGatewayTimeout, "Timed out waiting for assistant response", "server_error", "response_timeout"); err != nil {
return
}
return return
} }
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{ if err := writeJSONResponse(w, http.StatusOK, map[string]any{
"id": completionID, "id": completionID,
"object": "chat.completion", "object": "chat.completion",
"created": createdAt, "created": createdAt,
@ -388,7 +467,9 @@ func (c *OpenAIAPIChannel) handleChatCompletions(w http.ResponseWriter, r *http.
"finish_reason": "stop", "finish_reason": "stop",
}, },
}, },
}) }); err != nil {
return
}
} }
func translateConversation(messages []chatCompletionMessage) (translatedConversation, error) { func translateConversation(messages []chatCompletionMessage) (translatedConversation, error) {
@ -432,21 +513,17 @@ func translateConversation(messages []chatCompletionMessage) (translatedConversa
out.ExtraSystemPrompt = strings.Join(systemPrompts, "\n\n") out.ExtraSystemPrompt = strings.Join(systemPrompts, "\n\n")
last := nonSystem[len(nonSystem)-1] 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 { switch last.Role {
case "assistant": case "assistant":
out.CurrentMessage = "Continue the conversation with the next assistant response." return translatedConversation{}, fmt.Errorf("last message must be a user message, got assistant")
case "tool": case "tool":
out.CurrentMessage = "Continue the conversation after the tool result above." return translatedConversation{}, fmt.Errorf("last message must be a user message, got tool")
default:
out.CurrentMessage = "Continue the conversation based on the previous messages."
} }
if strings.TrimSpace(last.Content) == "" {
return translatedConversation{}, fmt.Errorf("last user message must not be empty")
}
out.CurrentMessage = last.Content
out.InjectedHistory = append([]providers.Message(nil), nonSystem[:len(nonSystem)-1]...)
return out, nil return out, nil
} }
@ -675,11 +752,9 @@ func writeChatCompletionChunk(
return err return err
} }
func writeOpenAIError(w http.ResponseWriter, status int, message, errorType, code string) { func writeOpenAIError(w http.ResponseWriter, status int, message, errorType, code string) error {
setCORSHeaders(w)
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status) return writeJSONResponse(w, status, map[string]any{
_ = json.NewEncoder(w).Encode(map[string]any{
"error": map[string]any{ "error": map[string]any{
"message": message, "message": message,
"type": errorType, "type": errorType,
@ -688,10 +763,94 @@ func writeOpenAIError(w http.ResponseWriter, status int, message, errorType, cod
}) })
} }
func setCORSHeaders(w http.ResponseWriter) { func writeJSONResponse(w http.ResponseWriter, status int, payload any) error {
w.Header().Set("Access-Control-Allow-Origin", "*") w.WriteHeader(status)
if err := json.NewEncoder(w).Encode(payload); err != nil {
logger.ErrorCF("openai_api", "Failed to write JSON response", map[string]any{
"error": err.Error(),
})
return err
}
return nil
}
func normalizeAllowedOrigins(origins []string) []string {
normalized := make([]string, 0, len(origins))
for _, origin := range origins {
origin = strings.TrimSpace(origin)
if origin == "" {
continue
}
normalized = append(normalized, origin)
}
if len(normalized) == 0 {
return append([]string(nil), defaultAllowedOrigins...)
}
return normalized
}
func (c *OpenAIAPIChannel) applyCORSHeaders(w http.ResponseWriter, r *http.Request) bool {
w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type") w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS") w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
origin := strings.TrimSpace(r.Header.Get("Origin"))
if origin == "" {
return true
}
if !originAllowed(c.allowedOrigins, origin) {
logger.WarnCF("openai_api", "Rejected request from disallowed origin", map[string]any{
"origin": origin,
})
if err := writeOpenAIError(w, http.StatusForbidden, "Origin is not allowed", "invalid_request_error", "origin_not_allowed"); err != nil {
return false
}
return false
}
w.Header().Set("Access-Control-Allow-Origin", origin)
addVaryHeader(w.Header(), "Origin")
return true
}
func originAllowed(allowedOrigins []string, origin string) bool {
parsedOrigin, err := url.Parse(origin)
if err != nil || parsedOrigin.Scheme == "" || parsedOrigin.Host == "" {
return false
}
requestHost := strings.ToLower(parsedOrigin.Hostname())
for _, allowed := range allowedOrigins {
allowed = strings.TrimSpace(allowed)
if allowed == "" {
continue
}
if strings.Contains(allowed, "://") {
parsedAllowed, err := url.Parse(allowed)
if err != nil || parsedAllowed.Scheme == "" || parsedAllowed.Host == "" {
continue
}
if strings.EqualFold(parsedAllowed.Scheme, parsedOrigin.Scheme) && strings.EqualFold(parsedAllowed.Host, parsedOrigin.Host) {
return true
}
continue
}
if strings.EqualFold(allowed, requestHost) {
return true
}
}
return false
}
func addVaryHeader(headers http.Header, value string) {
for _, existing := range headers.Values("Vary") {
for _, part := range strings.Split(existing, ",") {
if strings.EqualFold(strings.TrimSpace(part), value) {
return
}
}
}
headers.Add("Vary", value)
} }
func (c *OpenAIAPIChannel) authenticate(r *http.Request) bool { func (c *OpenAIAPIChannel) authenticate(r *http.Request) bool {

View file

@ -3,22 +3,33 @@ package openai_api
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"errors"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"reflect"
"strings" "strings"
"testing" "testing"
"time" "time"
"github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/providers"
) )
func newTestChannel(t *testing.T) (*OpenAIAPIChannel, *bus.MessageBus) { func newTestChannel(t *testing.T) (*OpenAIAPIChannel, *bus.MessageBus) {
t.Helper() t.Helper()
return newTestChannelWithConfig(t, nil)
}
func newTestChannelWithConfig(t *testing.T, mutate func(*config.Config)) (*OpenAIAPIChannel, *bus.MessageBus) {
t.Helper()
cfg := config.DefaultConfig() cfg := config.DefaultConfig()
cfg.Channels.OpenAIAPI.APIKey = "test-key" cfg.Channels.OpenAIAPI.APIKey = "test-key"
cfg.Channels.OpenAIAPI.Port = 0 cfg.Channels.OpenAIAPI.Port = 0
if mutate != nil {
mutate(cfg)
}
messageBus := bus.NewMessageBus() messageBus := bus.NewMessageBus()
channel, err := NewOpenAIAPIChannel(cfg, messageBus) channel, err := NewOpenAIAPIChannel(cfg, messageBus)
@ -30,6 +41,23 @@ func newTestChannel(t *testing.T) (*OpenAIAPIChannel, *bus.MessageBus) {
return channel, messageBus return channel, messageBus
} }
type failingResponseWriter struct {
headers http.Header
}
func (w *failingResponseWriter) Header() http.Header {
if w.headers == nil {
w.headers = make(http.Header)
}
return w.headers
}
func (w *failingResponseWriter) WriteHeader(statusCode int) {}
func (w *failingResponseWriter) Write(p []byte) (int, error) {
return 0, errors.New("write failed")
}
func TestHandleChatCompletions_NonStreaming(t *testing.T) { func TestHandleChatCompletions_NonStreaming(t *testing.T) {
channel, messageBus := newTestChannel(t) channel, messageBus := newTestChannel(t)
@ -164,3 +192,196 @@ func TestHandleModels_RequiresAuth(t *testing.T) {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusUnauthorized) t.Fatalf("status = %d, want %d", rec.Code, http.StatusUnauthorized)
} }
} }
func TestHandleModels_AllowsConfiguredOrigin(t *testing.T) {
channel, _ := newTestChannelWithConfig(t, func(cfg *config.Config) {
cfg.Channels.OpenAIAPI.AllowOrigins = []string{"https://console.example.com"}
})
req := httptest.NewRequest(http.MethodGet, "/v1/models", nil)
req.Header.Set("Authorization", "Bearer test-key")
req.Header.Set("Origin", "https://console.example.com")
rec := httptest.NewRecorder()
channel.handleModels(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "https://console.example.com" {
t.Fatalf("Access-Control-Allow-Origin = %q, want %q", got, "https://console.example.com")
}
}
func TestHandleModels_DefaultCORSAllowsLocalhost(t *testing.T) {
channel, _ := newTestChannelWithConfig(t, func(cfg *config.Config) {
cfg.Channels.OpenAIAPI.AllowOrigins = nil
})
req := httptest.NewRequest(http.MethodGet, "/v1/models", nil)
req.Header.Set("Authorization", "Bearer test-key")
req.Header.Set("Origin", "http://localhost:3000")
rec := httptest.NewRecorder()
channel.handleModels(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "http://localhost:3000" {
t.Fatalf("Access-Control-Allow-Origin = %q, want %q", got, "http://localhost:3000")
}
}
func TestHandleModels_RejectsDisallowedOrigin(t *testing.T) {
channel, _ := newTestChannelWithConfig(t, func(cfg *config.Config) {
cfg.Channels.OpenAIAPI.AllowOrigins = []string{"https://console.example.com"}
})
req := httptest.NewRequest(http.MethodGet, "/v1/models", nil)
req.Header.Set("Authorization", "Bearer test-key")
req.Header.Set("Origin", "https://evil.example.com")
rec := httptest.NewRecorder()
channel.handleModels(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusForbidden, rec.Body.String())
}
if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "" {
t.Fatalf("Access-Control-Allow-Origin = %q, want empty", got)
}
if !strings.Contains(rec.Body.String(), "origin_not_allowed") {
t.Fatalf("response body missing origin_not_allowed: %s", rec.Body.String())
}
}
func TestHandleChatCompletions_RejectsInvalidLastMessage(t *testing.T) {
channel, _ := newTestChannel(t)
testCases := []struct {
name string
body string
}{
{
name: "assistant last",
body: `{
"model": "gpt-5.4",
"messages": [
{"role": "user", "content": "hello"},
{"role": "assistant", "content": "hi"}
]
}`,
},
{
name: "tool last",
body: `{
"model": "gpt-5.4",
"messages": [
{"role": "user", "content": "hello"},
{"role": "tool", "content": "tool result", "tool_call_id": "call_1"}
]
}`,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(tc.body))
req.Header.Set("Authorization", "Bearer test-key")
rec := httptest.NewRecorder()
channel.handleChatCompletions(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusBadRequest, rec.Body.String())
}
if !strings.Contains(rec.Body.String(), "invalid_messages") {
t.Fatalf("response body missing invalid_messages: %s", rec.Body.String())
}
})
}
}
func TestTranslateConversation_RejectsInvalidLastMessage(t *testing.T) {
testCases := []struct {
name string
messages []chatCompletionMessage
wantErr string
}{
{
name: "assistant last",
messages: []chatCompletionMessage{
{Role: "user", Content: "hello"},
{Role: "assistant", Content: "hi"},
},
wantErr: "got assistant",
},
{
name: "tool last",
messages: []chatCompletionMessage{
{Role: "user", Content: "hello"},
{Role: "tool", Content: "tool result", ToolCallID: "call_1"},
},
wantErr: "got tool",
},
{
name: "empty user last",
messages: []chatCompletionMessage{
{Role: "user", Content: "hello"},
{Role: "user", Content: " "},
},
wantErr: "must not be empty",
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
_, err := translateConversation(tc.messages)
if err == nil {
t.Fatal("translateConversation() error = nil, want non-nil")
}
if !strings.Contains(err.Error(), tc.wantErr) {
t.Fatalf("translateConversation() error = %q, want substring %q", err.Error(), tc.wantErr)
}
})
}
}
func TestTranslateConversation_ExtractsHistoryAndCurrentUserMessage(t *testing.T) {
translated, err := translateConversation([]chatCompletionMessage{
{Role: "system", Content: "be concise"},
{Role: "user", Content: "hello"},
{Role: "assistant", Content: "hi"},
{Role: "user", Content: "what next?"},
})
if err != nil {
t.Fatalf("translateConversation() error = %v", err)
}
if translated.CurrentMessage != "what next?" {
t.Fatalf("CurrentMessage = %q, want %q", translated.CurrentMessage, "what next?")
}
if translated.ExtraSystemPrompt != "be concise" {
t.Fatalf("ExtraSystemPrompt = %q, want %q", translated.ExtraSystemPrompt, "be concise")
}
wantHistory := []providers.Message{
{Role: "user", Content: "hello"},
{Role: "assistant", Content: "hi"},
}
if len(translated.InjectedHistory) != len(wantHistory) {
t.Fatalf("len(InjectedHistory) = %d, want %d", len(translated.InjectedHistory), len(wantHistory))
}
for i := range wantHistory {
if !reflect.DeepEqual(translated.InjectedHistory[i], wantHistory[i]) {
t.Fatalf("InjectedHistory[%d] = %+v, want %+v", i, translated.InjectedHistory[i], wantHistory[i])
}
}
}
func TestWriteOpenAIError_ReturnsWriteError(t *testing.T) {
err := writeOpenAIError(&failingResponseWriter{}, http.StatusBadRequest, "bad request", "invalid_request_error", "bad_request")
if err == nil {
t.Fatal("writeOpenAIError() error = nil, want non-nil")
}
}

View file

@ -478,6 +478,7 @@ type OpenAIAPIConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_OPENAI_API_ENABLED"` Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_OPENAI_API_ENABLED"`
Port int `json:"port" env:"PICOCLAW_CHANNELS_OPENAI_API_PORT"` Port int `json:"port" env:"PICOCLAW_CHANNELS_OPENAI_API_PORT"`
APIKey string `json:"api_key" env:"PICOCLAW_CHANNELS_OPENAI_API_API_KEY"` APIKey string `json:"api_key" env:"PICOCLAW_CHANNELS_OPENAI_API_API_KEY"`
AllowOrigins []string `json:"allow_origins,omitempty"`
} }
type IRCConfig struct { type IRCConfig struct {

View file

@ -179,6 +179,7 @@ func DefaultConfig() *Config {
Enabled: false, Enabled: false,
Port: 18794, Port: 18794,
APIKey: "", APIKey: "",
AllowOrigins: []string{"localhost"},
}, },
}, },
Providers: ProvidersConfig{ Providers: ProvidersConfig{