Changeset 0.10.1 (#57)

This commit is contained in:
2026-02-24 16:51:08 +00:00
parent ea03f956ca
commit 13772ebd6c
11 changed files with 472 additions and 159 deletions

View File

@@ -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"],

View File

@@ -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{

View File

@@ -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"])
}
}

View File

@@ -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

View File

@@ -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)

View File

@@ -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)