Changeset 0.10.2 (#58)
This commit is contained in:
@@ -480,6 +480,9 @@ func (h *CompletionHandler) resolveConfig(userID string, channelID string, req c
|
||||
// loadConversation builds the message history for the LLM by walking the
|
||||
// active path through the message tree. Only the current branch is sent —
|
||||
// sibling branches are never included in context.
|
||||
//
|
||||
// Summary-aware: if the path contains a summary node (metadata.type = "summary"),
|
||||
// messages before it are replaced by the summary content as a system message.
|
||||
func (h *CompletionHandler) loadConversation(channelID, userID, presetSystemPrompt string) ([]providers.Message, error) {
|
||||
messages := make([]providers.Message, 0)
|
||||
|
||||
@@ -519,10 +522,32 @@ func (h *CompletionHandler) loadConversation(channelID, userID, presetSystemProm
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, m := range path {
|
||||
// Find the most recent summary node in the path
|
||||
summaryIdx := -1
|
||||
for i, m := range path {
|
||||
if isSummaryMessage(&m) {
|
||||
summaryIdx = i
|
||||
}
|
||||
}
|
||||
|
||||
// If we found a summary, inject it as a system message and skip prior messages
|
||||
startIdx := 0
|
||||
if summaryIdx >= 0 {
|
||||
messages = append(messages, providers.Message{
|
||||
Role: "system",
|
||||
Content: "Previous conversation summary:\n" + path[summaryIdx].Content,
|
||||
})
|
||||
startIdx = summaryIdx + 1
|
||||
}
|
||||
|
||||
for _, m := range path[startIdx:] {
|
||||
if m.Role == "system" {
|
||||
continue // system prompts handled above
|
||||
}
|
||||
// Skip summary nodes that aren't the boundary (shouldn't happen, but defensive)
|
||||
if isSummaryMessage(&m) {
|
||||
continue
|
||||
}
|
||||
messages = append(messages, providers.Message{
|
||||
Role: m.Role,
|
||||
Content: m.Content,
|
||||
@@ -532,6 +557,18 @@ func (h *CompletionHandler) loadConversation(channelID, userID, presetSystemProm
|
||||
return messages, nil
|
||||
}
|
||||
|
||||
// isSummaryMessage checks if a PathMessage has summary metadata.
|
||||
func isSummaryMessage(m *PathMessage) bool {
|
||||
if m.Metadata == nil {
|
||||
return false
|
||||
}
|
||||
var meta map[string]interface{}
|
||||
if err := json.Unmarshal(*m.Metadata, &meta); err != nil {
|
||||
return false
|
||||
}
|
||||
return meta["type"] == "summary"
|
||||
}
|
||||
|
||||
// ── Message Persistence ─────────────────────
|
||||
|
||||
// persistMessage inserts a message into the tree, updates the cursor, and
|
||||
|
||||
@@ -1611,6 +1611,7 @@ func TestIntegration_Roles_ListReturnsSeeded(t *testing.T) {
|
||||
decode(w, &resp)
|
||||
|
||||
// Migration 004 seeds utility, embedding, generation
|
||||
// (generation removed from ValidRoles in v0.10.2 but still in JSONB seed data)
|
||||
for _, role := range []string{"utility", "embedding", "generation"} {
|
||||
if _, ok := resp[role]; !ok {
|
||||
t.Errorf("expected role %q in response, got: %v", role, resp)
|
||||
@@ -2174,3 +2175,48 @@ func TestIntegration_Pricing_RejectBYOKUpsert(t *testing.T) {
|
||||
t.Fatalf("pricing BYOK upsert: want 403, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// ── GetRole Endpoint (exercises GetConfig signature) ──
|
||||
// The existing Roles_UpdateAndGet test validates via ListRoles.
|
||||
// This test hits GET /admin/roles/:role which routes through
|
||||
// resolver.GetConfig — the exact call site that broke in v0.10.2
|
||||
// when the signature changed from 3-arg to 4-arg.
|
||||
|
||||
func TestIntegration_Roles_GetSingleRole(t *testing.T) {
|
||||
h := setupHarness(t)
|
||||
_, adminToken := h.createAdminUser("admin_getrole", "admin_getrole@test.com")
|
||||
|
||||
// Create provider + configure utility role
|
||||
w := h.request("POST", "/api/v1/admin/configs", adminToken, map[string]interface{}{
|
||||
"name": "getrole-provider", "provider": "openai",
|
||||
"endpoint": "http://localhost:1/v1", "api_key": "sk-getrole",
|
||||
})
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("create config: %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var cfg map[string]interface{}
|
||||
decode(w, &cfg)
|
||||
cfgID := cfg["id"].(string)
|
||||
|
||||
w = h.request("PUT", "/api/v1/admin/roles/utility", adminToken, map[string]interface{}{
|
||||
"primary": map[string]string{
|
||||
"provider_config_id": cfgID,
|
||||
"model_id": "gpt-4o-mini",
|
||||
},
|
||||
})
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("update role: %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// GET /admin/roles/utility — the endpoint that broke
|
||||
w = h.request("GET", "/api/v1/admin/roles/utility", adminToken, nil)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("get single role: want 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// Seeded role with null config via GetRole → 200 (migration seeds all roles)
|
||||
w = h.request("GET", "/api/v1/admin/roles/embedding", adminToken, nil)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("get seeded-but-empty role: want 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@ func (h *RolesHandler) GetRole(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
cfg, err := h.resolver.GetConfig(c.Request.Context(), role, nil)
|
||||
cfg, err := h.resolver.GetConfig(c.Request.Context(), role, "", nil)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||
return
|
||||
@@ -104,7 +104,7 @@ func (h *RolesHandler) TestRole(c *gin.Context) {
|
||||
|
||||
if role == roles.RoleEmbedding {
|
||||
// Test embedding
|
||||
result, err := h.resolver.Embed(c.Request.Context(), role, nil, []string{"test embedding"})
|
||||
result, err := h.resolver.Embed(c.Request.Context(), role, "", nil, []string{"test embedding"})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||
return
|
||||
@@ -120,7 +120,7 @@ func (h *RolesHandler) TestRole(c *gin.Context) {
|
||||
}
|
||||
|
||||
// Test completion with a minimal prompt
|
||||
result, err := h.resolver.Complete(c.Request.Context(), role, nil, []providers.Message{
|
||||
result, err := h.resolver.Complete(c.Request.Context(), role, "", nil, []providers.Message{
|
||||
{Role: "user", Content: "Say 'ok' and nothing else."},
|
||||
})
|
||||
if err != nil {
|
||||
|
||||
270
server/handlers/summarize.go
Normal file
270
server/handlers/summarize.go
Normal file
@@ -0,0 +1,270 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/database"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/models"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/providers"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/roles"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/store"
|
||||
)
|
||||
|
||||
// SummarizeHandler handles conversation summarization using the utility model role.
|
||||
type SummarizeHandler struct {
|
||||
stores store.Stores
|
||||
resolver *roles.Resolver
|
||||
}
|
||||
|
||||
// NewSummarizeHandler creates a new handler.
|
||||
func NewSummarizeHandler(s store.Stores, resolver *roles.Resolver) *SummarizeHandler {
|
||||
return &SummarizeHandler{stores: s, resolver: resolver}
|
||||
}
|
||||
|
||||
// ── Summarize & Continue ──────────────────
|
||||
// POST /channels/:id/summarize
|
||||
//
|
||||
// Calls the utility role to summarize the conversation history, inserts
|
||||
// the summary as a special message node in the tree, and returns it.
|
||||
// Subsequent completions will use the summary as context boundary.
|
||||
|
||||
func (h *SummarizeHandler) Summarize(c *gin.Context) {
|
||||
channelID := c.Param("id")
|
||||
userID := getUserID(c)
|
||||
|
||||
// ── Verify channel ownership ──
|
||||
var ownerID string
|
||||
err := database.DB.QueryRow(
|
||||
`SELECT user_id FROM channels WHERE id = $1 AND deleted_at IS NULL`, channelID,
|
||||
).Scan(&ownerID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "channel not found"})
|
||||
return
|
||||
}
|
||||
if ownerID != userID {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "not your channel"})
|
||||
return
|
||||
}
|
||||
|
||||
// ── Check utility role is configured ──
|
||||
if !h.resolver.IsConfigured(c.Request.Context(), roles.RoleUtility) {
|
||||
// If user doesn't have a personal override either, it's not available
|
||||
if !h.resolver.IsPersonalOverride(c.Request.Context(), userID, roles.RoleUtility) {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{
|
||||
"error": "Utility model role is not configured. Ask your admin to set one, or configure your own in Settings → Model Roles.",
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// ── Rate limiting (org-funded calls only) ──
|
||||
isPersonal := h.resolver.IsPersonalOverride(c.Request.Context(), userID, roles.RoleUtility)
|
||||
if !isPersonal {
|
||||
if err := h.checkRateLimit(c.Request.Context(), userID); err != nil {
|
||||
c.JSON(http.StatusTooManyRequests, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// ── Load active path ──
|
||||
path, err := getActivePath(channelID, userID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load conversation"})
|
||||
return
|
||||
}
|
||||
if len(path) < 4 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "conversation too short to summarize"})
|
||||
return
|
||||
}
|
||||
|
||||
// Find existing summary boundary (if any) and only summarize after it
|
||||
startIdx := 0
|
||||
for i, m := range path {
|
||||
if isSummaryMessage(&m) {
|
||||
startIdx = i + 1
|
||||
}
|
||||
}
|
||||
|
||||
// Build messages to summarize (skip system messages, skip previous summaries)
|
||||
var toSummarize []string
|
||||
messagesInScope := 0
|
||||
var lastMessageID string
|
||||
for _, m := range path[startIdx:] {
|
||||
if m.Role == "system" || isSummaryMessage(&m) {
|
||||
continue
|
||||
}
|
||||
toSummarize = append(toSummarize, fmt.Sprintf("%s: %s", m.Role, m.Content))
|
||||
messagesInScope++
|
||||
lastMessageID = m.ID
|
||||
}
|
||||
|
||||
if messagesInScope < 4 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "not enough new messages since last summary"})
|
||||
return
|
||||
}
|
||||
|
||||
// ── Build the summarization prompt ──
|
||||
conversationText := strings.Join(toSummarize, "\n\n")
|
||||
summaryPrompt := []providers.Message{
|
||||
{
|
||||
Role: "system",
|
||||
Content: `You are a conversation summarizer. Create a concise but comprehensive summary of the following conversation that preserves:
|
||||
- Key decisions and conclusions reached
|
||||
- Important facts, names, numbers, and technical details mentioned
|
||||
- Action items or commitments made
|
||||
- The overall context and topic flow
|
||||
|
||||
Write the summary in a way that would allow the conversation to continue naturally. Use clear, factual language. Do not include meta-commentary about the summarization process.`,
|
||||
},
|
||||
{
|
||||
Role: "user",
|
||||
Content: "Summarize this conversation:\n\n" + conversationText,
|
||||
},
|
||||
}
|
||||
|
||||
// ── Resolve team context for the user ──
|
||||
teamID := h.getUserTeamID(c.Request.Context(), userID)
|
||||
|
||||
// ── Call utility role ──
|
||||
log.Printf("📝 Summarizing channel %s for user %s (%d messages)", channelID, userID, messagesInScope)
|
||||
result, err := h.resolver.Complete(c.Request.Context(), roles.RoleUtility, userID, teamID, summaryPrompt)
|
||||
if err != nil {
|
||||
log.Printf("⚠ Summarize failed for channel %s: %v", channelID, err)
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": "summarization failed: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// ── Log usage ──
|
||||
h.logSummaryUsage(c.Request.Context(), channelID, userID, result)
|
||||
|
||||
// ── Insert summary message as a tree node ──
|
||||
// The summary message is inserted as an "assistant" message with special metadata.
|
||||
// Its parent is the last message that was summarized, making it part of the tree.
|
||||
metadata := models.JSONMap{
|
||||
"type": "summary",
|
||||
"summarized_until_id": lastMessageID,
|
||||
"summarized_count": messagesInScope,
|
||||
"utility_model": result.Model,
|
||||
"used_fallback": result.UsedFallback,
|
||||
}
|
||||
|
||||
metaJSON, _ := json.Marshal(metadata)
|
||||
siblingIdx := nextSiblingIndex(channelID, &lastMessageID)
|
||||
|
||||
var summaryMsgID string
|
||||
err = database.DB.QueryRow(`
|
||||
INSERT INTO messages (channel_id, parent_id, role, content, model, metadata, sibling_index, participant_type)
|
||||
VALUES ($1, $2, 'assistant', $3, $4, $5, $6, 'system')
|
||||
RETURNING id
|
||||
`, channelID, lastMessageID, result.Content, result.Model, string(metaJSON), siblingIdx).Scan(&summaryMsgID)
|
||||
if err != nil {
|
||||
log.Printf("⚠ Failed to persist summary for channel %s: %v", channelID, err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save summary"})
|
||||
return
|
||||
}
|
||||
|
||||
// Update cursor to point to the summary node
|
||||
if err := updateCursor(channelID, userID, summaryMsgID); err != nil {
|
||||
log.Printf("⚠ Failed to update cursor after summarize: %v", err)
|
||||
}
|
||||
|
||||
log.Printf("✅ Summary created for channel %s: %s (%d messages → %d chars)",
|
||||
channelID, summaryMsgID, messagesInScope, len(result.Content))
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"summary_id": summaryMsgID,
|
||||
"summarized_count": messagesInScope,
|
||||
"model": result.Model,
|
||||
"used_fallback": result.UsedFallback,
|
||||
"content": result.Content,
|
||||
})
|
||||
}
|
||||
|
||||
// ── Rate Limiting ─────────────────────────
|
||||
|
||||
func (h *SummarizeHandler) checkRateLimit(ctx context.Context, userID string) error {
|
||||
// Load rate limit from global settings (default: 20/hour, 0 = unlimited)
|
||||
limit := 20
|
||||
settings, err := h.stores.GlobalConfig.Get(ctx, "utility_rate_limit")
|
||||
if err == nil {
|
||||
if v, ok := settings["value"]; ok {
|
||||
switch n := v.(type) {
|
||||
case float64:
|
||||
limit = int(n)
|
||||
case int:
|
||||
limit = n
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if limit <= 0 {
|
||||
return nil // unlimited
|
||||
}
|
||||
|
||||
since := time.Now().Add(-1 * time.Hour)
|
||||
count, err := h.stores.Usage.CountRecentByRole(ctx, userID, roles.RoleUtility, since)
|
||||
if err != nil {
|
||||
log.Printf("⚠ Rate limit check failed: %v", err)
|
||||
return nil // fail open — don't block on DB errors
|
||||
}
|
||||
|
||||
if count >= limit {
|
||||
return fmt.Errorf("rate limit exceeded: %d utility calls in the last hour (limit: %d)", count, limit)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────
|
||||
|
||||
func (h *SummarizeHandler) getUserTeamID(ctx context.Context, userID string) *string {
|
||||
// Get the user's first team (for role override resolution)
|
||||
var teamID string
|
||||
err := database.DB.QueryRow(`
|
||||
SELECT team_id FROM team_members WHERE user_id = $1 LIMIT 1
|
||||
`, userID).Scan(&teamID)
|
||||
if err != nil || teamID == "" {
|
||||
return nil
|
||||
}
|
||||
return &teamID
|
||||
}
|
||||
|
||||
func (h *SummarizeHandler) logSummaryUsage(ctx context.Context, channelID, userID string, result *roles.CompletionResult) {
|
||||
role := roles.RoleUtility
|
||||
entry := &models.UsageEntry{
|
||||
ChannelID: &channelID,
|
||||
UserID: userID,
|
||||
ProviderConfigID: &result.ConfigID,
|
||||
ProviderScope: result.ProviderScope,
|
||||
ModelID: result.Model,
|
||||
Role: &role,
|
||||
PromptTokens: result.InputTokens,
|
||||
CompletionTokens: result.OutputTokens,
|
||||
CacheCreationTokens: result.CacheCreationTokens,
|
||||
CacheReadTokens: result.CacheReadTokens,
|
||||
}
|
||||
|
||||
// Calculate cost from pricing
|
||||
pricing, err := h.stores.Pricing.GetForModel(ctx, result.ConfigID, result.Model)
|
||||
if err == nil && pricing != nil {
|
||||
if pricing.InputPerM != nil {
|
||||
costIn := float64(result.InputTokens) / 1_000_000 * *pricing.InputPerM
|
||||
entry.CostInput = &costIn
|
||||
}
|
||||
if pricing.OutputPerM != nil {
|
||||
costOut := float64(result.OutputTokens) / 1_000_000 * *pricing.OutputPerM
|
||||
entry.CostOutput = &costOut
|
||||
}
|
||||
}
|
||||
|
||||
if err := h.stores.Usage.Log(ctx, entry); err != nil {
|
||||
log.Printf("⚠ Failed to log summary usage: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,7 @@ type PathMessage struct {
|
||||
Model *string `json:"model"`
|
||||
TokensUsed *int `json:"tokens_used,omitempty"`
|
||||
ToolCalls *json.RawMessage `json:"tool_calls,omitempty"`
|
||||
Metadata *json.RawMessage `json:"metadata,omitempty"`
|
||||
SiblingCount int `json:"sibling_count"`
|
||||
SiblingIndex int `json:"sibling_index"`
|
||||
ParticipantType string `json:"participant_type,omitempty"`
|
||||
@@ -98,7 +99,7 @@ func getPathToLeaf(channelID, leafID string) ([]PathMessage, error) {
|
||||
rows, err := database.DB.Query(`
|
||||
WITH RECURSIVE path AS (
|
||||
-- Anchor: start at the leaf
|
||||
SELECT id, parent_id, role, content, model, tokens_used, tool_calls,
|
||||
SELECT id, parent_id, role, content, model, tokens_used, tool_calls, metadata,
|
||||
participant_type, participant_id, sibling_index, created_at,
|
||||
0 AS depth
|
||||
FROM messages
|
||||
@@ -107,14 +108,14 @@ func getPathToLeaf(channelID, leafID string) ([]PathMessage, error) {
|
||||
UNION ALL
|
||||
|
||||
-- Walk up via parent_id
|
||||
SELECT m.id, m.parent_id, m.role, m.content, m.model, m.tokens_used, m.tool_calls,
|
||||
SELECT m.id, m.parent_id, m.role, m.content, m.model, m.tokens_used, m.tool_calls, m.metadata,
|
||||
m.participant_type, m.participant_id, m.sibling_index, m.created_at,
|
||||
p.depth + 1
|
||||
FROM messages m
|
||||
JOIN path p ON m.id = p.parent_id
|
||||
WHERE m.deleted_at IS NULL
|
||||
)
|
||||
SELECT id, parent_id, role, content, model, tokens_used, tool_calls,
|
||||
SELECT id, parent_id, role, content, model, tokens_used, tool_calls, metadata,
|
||||
participant_type, participant_id, sibling_index, created_at
|
||||
FROM path
|
||||
ORDER BY depth DESC
|
||||
@@ -128,10 +129,10 @@ func getPathToLeaf(channelID, leafID string) ([]PathMessage, error) {
|
||||
for rows.Next() {
|
||||
var m PathMessage
|
||||
var participantType, participantID sql.NullString
|
||||
var toolCallsJSON []byte
|
||||
var toolCallsJSON, metadataJSON []byte
|
||||
if err := rows.Scan(
|
||||
&m.ID, &m.ParentID, &m.Role, &m.Content, &m.Model, &m.TokensUsed,
|
||||
&toolCallsJSON,
|
||||
&toolCallsJSON, &metadataJSON,
|
||||
&participantType, &participantID, &m.SiblingIndex, &m.CreatedAt,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("getPathToLeaf scan: %w", err)
|
||||
@@ -140,6 +141,10 @@ func getPathToLeaf(channelID, leafID string) ([]PathMessage, error) {
|
||||
raw := json.RawMessage(toolCallsJSON)
|
||||
m.ToolCalls = &raw
|
||||
}
|
||||
if len(metadataJSON) > 0 && string(metadataJSON) != "null" && string(metadataJSON) != "{}" {
|
||||
raw := json.RawMessage(metadataJSON)
|
||||
m.Metadata = &raw
|
||||
}
|
||||
if participantType.Valid {
|
||||
m.ParticipantType = participantType.String
|
||||
}
|
||||
|
||||
@@ -154,6 +154,10 @@ func main() {
|
||||
comp := handlers.NewCompletionHandler(keyResolver, stores)
|
||||
protected.POST("/chat/completions", comp.Complete)
|
||||
|
||||
// Summarize & Continue
|
||||
summarize := handlers.NewSummarizeHandler(stores, roleResolver)
|
||||
protected.POST("/channels/:id/summarize", summarize.Summarize)
|
||||
|
||||
// Provider Configs (user-facing — replaces /api-configs)
|
||||
provCfg := handlers.NewProviderConfigHandler(stores, keyResolver)
|
||||
protected.GET("/api-configs", provCfg.ListConfigs) // backward compat
|
||||
|
||||
@@ -14,13 +14,14 @@ import (
|
||||
// ── Known Roles ────────────────────────────
|
||||
|
||||
const (
|
||||
RoleUtility = "utility" // Internal tasks: summarization, title generation
|
||||
RoleEmbedding = "embedding" // Vector embedding for knowledge bases
|
||||
RoleGeneration = "generation" // Primary chat generation (future routing)
|
||||
RoleUtility = "utility" // Internal tasks: summarization, title generation
|
||||
RoleEmbedding = "embedding" // Vector embedding for knowledge bases
|
||||
)
|
||||
|
||||
// ValidRoles lists all recognized role names.
|
||||
var ValidRoles = []string{RoleUtility, RoleEmbedding, RoleGeneration}
|
||||
// Note: "generation" (image/media) was removed in v0.10.2 — image gen
|
||||
// will be extension-managed with its own provider config, not a role slot.
|
||||
var ValidRoles = []string{RoleUtility, RoleEmbedding}
|
||||
|
||||
// IsValidRole returns true if the given role name is recognized.
|
||||
func IsValidRole(role string) bool {
|
||||
@@ -96,9 +97,9 @@ func NewResolver(s store.Stores, vault *crypto.KeyResolver) *Resolver {
|
||||
}
|
||||
|
||||
// Complete sends a chat completion using the named role.
|
||||
// Resolution: team override → global config → try primary → fallback on error.
|
||||
func (r *Resolver) Complete(ctx context.Context, role string, teamID *string, messages []providers.Message) (*CompletionResult, error) {
|
||||
cfg, err := r.GetConfig(ctx, role, teamID)
|
||||
// Resolution: personal override → team override → global config → try primary → fallback on error.
|
||||
func (r *Resolver) Complete(ctx context.Context, role string, userID string, teamID *string, messages []providers.Message) (*CompletionResult, error) {
|
||||
cfg, err := r.GetConfig(ctx, role, userID, teamID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -129,8 +130,8 @@ func (r *Resolver) Complete(ctx context.Context, role string, teamID *string, me
|
||||
}
|
||||
|
||||
// Embed generates embeddings using the named role.
|
||||
func (r *Resolver) Embed(ctx context.Context, role string, teamID *string, input []string) (*EmbeddingResult, error) {
|
||||
cfg, err := r.GetConfig(ctx, role, teamID)
|
||||
func (r *Resolver) Embed(ctx context.Context, role string, userID string, teamID *string, input []string) (*EmbeddingResult, error) {
|
||||
cfg, err := r.GetConfig(ctx, role, userID, teamID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -161,13 +162,21 @@ func (r *Resolver) Embed(ctx context.Context, role string, teamID *string, input
|
||||
}
|
||||
|
||||
// GetConfig returns the resolved role config for the given role name.
|
||||
// Team overrides take precedence over global config.
|
||||
func (r *Resolver) GetConfig(ctx context.Context, role string, teamID *string) (*RoleConfig, error) {
|
||||
// Resolution order: personal override → team override → global config.
|
||||
func (r *Resolver) GetConfig(ctx context.Context, role string, userID string, teamID *string) (*RoleConfig, error) {
|
||||
if !IsValidRole(role) {
|
||||
return nil, fmt.Errorf("unknown role: %q", role)
|
||||
}
|
||||
|
||||
// Check team override first
|
||||
// Check personal override first (BYOK users)
|
||||
if userID != "" {
|
||||
personalCfg, err := r.getPersonalRoleConfig(ctx, userID, role)
|
||||
if err == nil && personalCfg != nil && (personalCfg.Primary != nil || personalCfg.Fallback != nil) {
|
||||
return personalCfg, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Check team override
|
||||
if teamID != nil && *teamID != "" {
|
||||
teamCfg, err := r.getTeamRoleConfig(ctx, *teamID, role)
|
||||
if err == nil && teamCfg != nil && (teamCfg.Primary != nil || teamCfg.Fallback != nil) {
|
||||
@@ -181,7 +190,13 @@ func (r *Resolver) GetConfig(ctx context.Context, role string, teamID *string) (
|
||||
|
||||
// IsConfigured returns true if the named role has at least a primary binding.
|
||||
func (r *Resolver) IsConfigured(ctx context.Context, role string) bool {
|
||||
cfg, err := r.GetConfig(ctx, role, nil)
|
||||
cfg, err := r.GetConfig(ctx, role, "", nil)
|
||||
return err == nil && cfg != nil && cfg.Primary != nil
|
||||
}
|
||||
|
||||
// IsPersonalOverride returns true if the user has a personal binding for the role.
|
||||
func (r *Resolver) IsPersonalOverride(ctx context.Context, userID, role string) bool {
|
||||
cfg, err := r.getPersonalRoleConfig(ctx, userID, role)
|
||||
return err == nil && cfg != nil && cfg.Primary != nil
|
||||
}
|
||||
|
||||
@@ -285,6 +300,35 @@ func (r *Resolver) resolveBinding(ctx context.Context, binding *RoleBinding) (pr
|
||||
|
||||
// ── Internal: Config Loading ───────────────
|
||||
|
||||
func (r *Resolver) getPersonalRoleConfig(ctx context.Context, userID, role string) (*RoleConfig, error) {
|
||||
user, err := r.stores.Users.GetByID(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
settings := user.Settings
|
||||
if settings == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
rolesRaw, ok := settings["model_roles"]
|
||||
if !ok {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
rolesMap, ok := rolesRaw.(map[string]interface{})
|
||||
if !ok {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
roleData, ok := rolesMap[role]
|
||||
if !ok {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return parseRoleConfig(roleData)
|
||||
}
|
||||
|
||||
func (r *Resolver) getGlobalRoleConfig(ctx context.Context, role string) (*RoleConfig, error) {
|
||||
allRoles, err := r.stores.GlobalConfig.Get(ctx, "model_roles")
|
||||
if err != nil {
|
||||
|
||||
@@ -284,6 +284,7 @@ type GlobalConfigStore interface {
|
||||
|
||||
type UsageStore interface {
|
||||
Log(ctx context.Context, entry *models.UsageEntry) error
|
||||
CountRecentByRole(ctx context.Context, userID, role string, since time.Time) (int, 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)
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/models"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/store"
|
||||
@@ -143,6 +144,17 @@ func (s *UsageStore) GetPersonalTotals(ctx context.Context, userID string, opts
|
||||
return &t, err
|
||||
}
|
||||
|
||||
// CountRecentByRole counts how many usage entries a user has for a given
|
||||
// role within the specified duration. Used for rate limiting utility calls.
|
||||
func (s *UsageStore) CountRecentByRole(ctx context.Context, userID, role string, since time.Time) (int, error) {
|
||||
var count int
|
||||
err := DB.QueryRowContext(ctx, `
|
||||
SELECT COUNT(*) FROM usage_log
|
||||
WHERE user_id = $1 AND role = $2 AND created_at >= $3
|
||||
`, userID, role, since).Scan(&count)
|
||||
return count, err
|
||||
}
|
||||
|
||||
// ── Internal ───────────────────────────────
|
||||
|
||||
func (s *UsageStore) buildFilters(baseWhere []string, baseArgs []interface{}, opts store.UsageQueryOptions) ([]string, []interface{}) {
|
||||
|
||||
Reference in New Issue
Block a user