Changeset 0.10.1 (#57)
This commit is contained in:
@@ -267,11 +267,19 @@ func (h *AdminHandler) PublicSettings(c *gin.Context) {
|
||||
// Banner config, branding, etc. — safe subset for non-admin users
|
||||
banner, _ := h.stores.GlobalConfig.Get(c.Request.Context(), "banner")
|
||||
branding, _ := h.stores.GlobalConfig.Get(c.Request.Context(), "branding")
|
||||
systemPrompt, _ := h.stores.GlobalConfig.Get(c.Request.Context(), "system_prompt")
|
||||
policies, _ := h.stores.Policies.GetAll(c.Request.Context())
|
||||
|
||||
// Only tell the user whether admin prompt exists — don't expose content
|
||||
hasAdminPrompt := false
|
||||
if content, ok := systemPrompt["content"].(string); ok && content != "" {
|
||||
hasAdminPrompt = true
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"banner": banner,
|
||||
"branding": branding,
|
||||
"banner": banner,
|
||||
"branding": branding,
|
||||
"has_admin_prompt": hasAdminPrompt,
|
||||
"policies": gin.H{
|
||||
"allow_registration": policies["allow_registration"],
|
||||
"allow_user_byok": policies["allow_user_byok"],
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
@@ -480,14 +481,25 @@ func (h *CompletionHandler) resolveConfig(userID string, channelID string, req c
|
||||
// active path through the message tree. Only the current branch is sent —
|
||||
// sibling branches are never included in context.
|
||||
func (h *CompletionHandler) loadConversation(channelID, userID, presetSystemPrompt string) ([]providers.Message, error) {
|
||||
// Load system prompt from channel
|
||||
messages := make([]providers.Message, 0)
|
||||
|
||||
// ── Admin system prompt (always injected first, no opt out) ──
|
||||
adminPrompt, err := h.stores.GlobalConfig.Get(context.Background(), "system_prompt")
|
||||
if err == nil {
|
||||
if content, ok := adminPrompt["content"].(string); ok && content != "" {
|
||||
messages = append(messages, providers.Message{
|
||||
Role: "system",
|
||||
Content: content,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ── User/preset system prompt (appended after admin prompt) ──
|
||||
var systemPrompt *string
|
||||
_ = database.DB.QueryRow(
|
||||
`SELECT system_prompt FROM channels WHERE id = $1`, channelID,
|
||||
).Scan(&systemPrompt)
|
||||
|
||||
messages := make([]providers.Message, 0)
|
||||
|
||||
// Preset system prompt takes priority; channel system prompt is fallback
|
||||
if presetSystemPrompt != "" {
|
||||
messages = append(messages, providers.Message{
|
||||
|
||||
@@ -1861,21 +1861,35 @@ func TestIntegration_Usage_PersonalIncludesBYOK(t *testing.T) {
|
||||
_, adminToken := h.createAdminUser("admin", "admin@test.com")
|
||||
database.TestDB.Exec("INSERT INTO platform_policies (key, value) VALUES ('allow_registration', 'true') ON CONFLICT (key) DO UPDATE SET value = 'true'")
|
||||
database.TestDB.Exec("INSERT INTO platform_policies (key, value) VALUES ('default_user_active', 'true') ON CONFLICT (key) DO UPDATE SET value = 'true'")
|
||||
database.TestDB.Exec("INSERT INTO platform_policies (key, value) VALUES ('allow_user_byok', 'true') ON CONFLICT (key) DO UPDATE SET value = 'true'")
|
||||
userID, userToken := h.registerUser("carol", "carol@test.com", "password123")
|
||||
|
||||
// Create global provider
|
||||
// Create global provider (admin-managed)
|
||||
w := h.request("POST", "/api/v1/admin/configs", adminToken, map[string]interface{}{
|
||||
"name": "Global2", "provider": "openai",
|
||||
"endpoint": "https://api.openai.com/v1", "api_key": "sk-g2",
|
||||
})
|
||||
var cfg map[string]interface{}
|
||||
decode(w, &cfg)
|
||||
cfgID := cfg["id"].(string)
|
||||
var globalCfg map[string]interface{}
|
||||
decode(w, &globalCfg)
|
||||
globalCfgID := globalCfg["id"].(string)
|
||||
|
||||
seedUsage(t, userID, cfgID, "gpt-4o", "global", 1000, 200, 0.01, 0.01)
|
||||
seedUsage(t, userID, cfgID, "gpt-4o", "personal", 500, 100, 0.005, 0.005)
|
||||
// Create personal BYOK provider (user-managed)
|
||||
w = h.request("POST", "/api/v1/api-configs", userToken, map[string]interface{}{
|
||||
"name": "MyKey", "provider": "openai",
|
||||
"endpoint": "http://localhost:1/v1", "api_key": "sk-user-carol",
|
||||
})
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("create personal config: want 201, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var personalCfg map[string]interface{}
|
||||
decode(w, &personalCfg)
|
||||
personalCfgID := personalCfg["id"].(string)
|
||||
|
||||
// Personal view should include all scopes
|
||||
// Seed usage against both providers
|
||||
seedUsage(t, userID, globalCfgID, "gpt-4o", "global", 1000, 200, 0.01, 0.01)
|
||||
seedUsage(t, userID, personalCfgID, "gpt-4o", "personal", 500, 100, 0.005, 0.005)
|
||||
|
||||
// Personal view should only show BYOK usage (not global)
|
||||
w = h.request("GET", "/api/v1/usage", userToken, nil)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("personal usage: %d: %s", w.Code, w.Body.String())
|
||||
@@ -1884,8 +1898,26 @@ func TestIntegration_Usage_PersonalIncludesBYOK(t *testing.T) {
|
||||
decode(w, &resp)
|
||||
totals := resp["totals"].(map[string]interface{})
|
||||
|
||||
if int(totals["requests"].(float64)) != 2 {
|
||||
t.Errorf("personal should see 2 requests (includes BYOK), got %v", totals["requests"])
|
||||
if int(totals["requests"].(float64)) != 1 {
|
||||
t.Errorf("personal should see 1 request (BYOK only, not global), got %v", totals["requests"])
|
||||
}
|
||||
if int(totals["input_tokens"].(float64)) != 500 {
|
||||
t.Errorf("personal should see 500 input tokens (BYOK row), got %v", totals["input_tokens"])
|
||||
}
|
||||
|
||||
// Admin view should show global usage but exclude BYOK
|
||||
w = h.request("GET", "/api/v1/admin/usage", adminToken, nil)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("admin usage: %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
decode(w, &resp)
|
||||
totals = resp["totals"].(map[string]interface{})
|
||||
|
||||
if int(totals["requests"].(float64)) != 1 {
|
||||
t.Errorf("admin should see 1 request (global only, not BYOK), got %v", totals["requests"])
|
||||
}
|
||||
if int(totals["input_tokens"].(float64)) != 1000 {
|
||||
t.Errorf("admin should see 1000 input tokens (global row), got %v", totals["input_tokens"])
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -92,7 +92,7 @@ func (h *UsageHandler) PersonalUsage(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
results, err := h.stores.Usage.QueryByUser(c.Request.Context(), userID, opts)
|
||||
results, err := h.stores.Usage.QueryByUserPersonal(c.Request.Context(), userID, opts)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to query usage"})
|
||||
return
|
||||
|
||||
@@ -285,6 +285,7 @@ type GlobalConfigStore interface {
|
||||
type UsageStore interface {
|
||||
Log(ctx context.Context, entry *models.UsageEntry) error
|
||||
QueryByUser(ctx context.Context, userID string, opts UsageQueryOptions) ([]models.UsageAggregate, error)
|
||||
QueryByUserPersonal(ctx context.Context, userID string, opts UsageQueryOptions) ([]models.UsageAggregate, error)
|
||||
QueryByTeam(ctx context.Context, teamID string, opts UsageQueryOptions) ([]models.UsageAggregate, error)
|
||||
QueryByTeamProviders(ctx context.Context, teamID string, opts UsageQueryOptions) ([]models.UsageAggregate, error)
|
||||
QueryByModel(ctx context.Context, opts UsageQueryOptions) ([]models.UsageAggregate, error)
|
||||
|
||||
@@ -40,6 +40,16 @@ func (s *UsageStore) QueryByUser(ctx context.Context, userID string, opts store.
|
||||
return s.query(ctx, where, args, opts)
|
||||
}
|
||||
|
||||
// QueryByUserPersonal returns aggregated usage for a user's own (BYOK) providers only.
|
||||
func (s *UsageStore) QueryByUserPersonal(ctx context.Context, userID string, opts store.UsageQueryOptions) ([]models.UsageAggregate, error) {
|
||||
where := []string{
|
||||
"user_id = $1",
|
||||
"provider_config_id IN (SELECT id FROM provider_configs WHERE scope = 'personal' AND owner_id = $1)",
|
||||
}
|
||||
args := []interface{}{userID}
|
||||
return s.query(ctx, where, args, opts)
|
||||
}
|
||||
|
||||
// QueryByTeam returns aggregated usage for all members of a team (admin view).
|
||||
func (s *UsageStore) QueryByTeam(ctx context.Context, teamID string, opts store.UsageQueryOptions) ([]models.UsageAggregate, error) {
|
||||
where := []string{"user_id IN (SELECT user_id FROM team_members WHERE team_id = $1)"}
|
||||
@@ -104,11 +114,15 @@ func (s *UsageStore) GetTeamProviderTotals(ctx context.Context, teamID string, o
|
||||
return &t, err
|
||||
}
|
||||
|
||||
// GetPersonalTotals returns aggregate totals for a specific user (all scopes).
|
||||
// GetPersonalTotals returns aggregate totals for a user's own provider usage.
|
||||
// Only includes usage against personal (BYOK) providers — global provider
|
||||
// costs are the org's responsibility, not the user's.
|
||||
func (s *UsageStore) GetPersonalTotals(ctx context.Context, userID string, opts store.UsageQueryOptions) (*models.UsageTotals, error) {
|
||||
baseWhere := []string{"user_id = $1"}
|
||||
baseWhere := []string{
|
||||
"user_id = $1",
|
||||
"provider_config_id IN (SELECT id FROM provider_configs WHERE scope = 'personal' AND owner_id = $1)",
|
||||
}
|
||||
baseArgs := []interface{}{userID}
|
||||
// Don't exclude BYOK for personal view
|
||||
opts.ExcludeBYOK = false
|
||||
where, args := s.buildFilters(baseWhere, baseArgs, opts)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user