Initial commit of Descrybe v2 without local scratch artifacts.

Drop one-shot tmp/axe scripts and agent i18n scratch so the Gitea tree is deployable.
This commit is contained in:
2026-08-09 22:47:43 +02:00
commit 8580c996c3
1285 changed files with 325780 additions and 0 deletions
+148
View File
@@ -0,0 +1,148 @@
package aiprovider
import "strings"
// Mode values stored on ai_providers.mode (UI / API).
const (
ModeInternal = "internal"
ModePopular = "popular"
ModeCustom = "custom"
)
// ModeInternalLabel is the analytics / job recording value when using platform OpenAI (admin settings or env fallback).
const ModeInternalLabel = "internal"
// ModeCustomLabel is the analytics value for custom OpenAI-compatible endpoints.
const ModeCustomLabel = "custom"
// PopularProvider is a curated OpenAI-compatible catalog entry.
type PopularProvider struct {
Name string `json:"name"`
Label string `json:"label"`
BaseURL string `json:"base_url"`
DefaultModel string `json:"default_model"`
Models []string `json:"models"`
}
// PopularCatalog lists OpenAI-compatible providers tenants can pick by API key only.
var PopularCatalog = []PopularProvider{
{
Name: "openai",
Label: "OpenAI",
BaseURL: "https://api.openai.com/v1",
DefaultModel: "gpt-4o-mini",
Models: []string{"gpt-4o-mini", "gpt-4o", "gpt-4.1-mini", "gpt-4.1"},
},
{
Name: "google",
Label: "Google (Gemini OpenAI compat)",
BaseURL: "https://generativelanguage.googleapis.com/v1beta/openai",
DefaultModel: "gemini-2.0-flash",
Models: []string{"gemini-2.0-flash", "gemini-2.5-flash", "gemini-2.0-flash-lite"},
},
{
Name: "groq",
Label: "Groq",
BaseURL: "https://api.groq.com/openai/v1",
DefaultModel: "llama-3.3-70b-versatile",
Models: []string{"llama-3.3-70b-versatile", "llama-3.1-8b-instant", "mixtral-8x7b-32768"},
},
{
Name: "mistral",
Label: "Mistral",
BaseURL: "https://api.mistral.ai/v1",
DefaultModel: "mistral-small-latest",
Models: []string{"mistral-small-latest", "mistral-medium-latest", "mistral-large-latest"},
},
{
Name: "deepseek",
Label: "DeepSeek",
BaseURL: "https://api.deepseek.com/v1",
DefaultModel: "deepseek-chat",
Models: []string{"deepseek-chat", "deepseek-reasoner"},
},
{
Name: "openrouter",
Label: "OpenRouter",
BaseURL: "https://openrouter.ai/api/v1",
DefaultModel: "openai/gpt-4o-mini",
Models: []string{"openai/gpt-4o-mini", "anthropic/claude-sonnet-4", "google/gemini-2.0-flash-001"},
},
}
func FindPopular(name string) (PopularProvider, bool) {
name = strings.ToLower(strings.TrimSpace(name))
for _, p := range PopularCatalog {
if p.Name == name {
return p, true
}
}
return PopularProvider{}, false
}
// AnalyticsMode returns the recorded provider mode for jobs/products.
// Contract for analytics: "internal" | "popular:<name>" | "custom"
func AnalyticsMode(mode, popularName string) string {
switch strings.ToLower(strings.TrimSpace(mode)) {
case ModePopular:
name := strings.ToLower(strings.TrimSpace(popularName))
if name == "" {
name = "unknown"
}
return "popular:" + name
case ModeCustom:
return ModeCustomLabel
default:
return ModeInternalLabel
}
}
// AnalyticsClass maps a stored ai_provider_mode value to a rollup class.
// Returns: internal | popular | custom | unknown
func AnalyticsClass(modeLabel string) string {
m := strings.ToLower(strings.TrimSpace(modeLabel))
switch {
case m == "" || m == "unknown":
return "unknown"
case m == ModeInternalLabel || m == ModeInternal:
return ModeInternal
case m == ModeCustomLabel || m == ModeCustom:
return ModeCustom
case strings.HasPrefix(m, "popular:"):
return ModePopular
default:
return "unknown"
}
}
// NormalizeAnalyticsMode coerces free-form labels into the analytics contract.
func NormalizeAnalyticsMode(modeLabel string) string {
m := strings.ToLower(strings.TrimSpace(modeLabel))
switch {
case m == "" || m == "unknown":
return "unknown"
case m == ModeInternalLabel || m == ModeInternal:
return ModeInternalLabel
case m == ModeCustomLabel || m == ModeCustom:
return ModeCustomLabel
case strings.HasPrefix(m, "popular:"):
name := strings.TrimSpace(strings.TrimPrefix(m, "popular:"))
if name == "" {
name = "unknown"
}
return "popular:" + name
default:
return "unknown"
}
}
func normalizeMode(mode string) string {
switch strings.ToLower(strings.TrimSpace(mode)) {
case ModePopular:
return ModePopular
case ModeCustom:
return ModeCustom
default:
return ModeInternal
}
}
@@ -0,0 +1,34 @@
package aiprovider
import "testing"
func TestAnalyticsClass(t *testing.T) {
t.Parallel()
cases := []struct {
in, want string
}{
{"", "unknown"},
{"unknown", "unknown"},
{"internal", "internal"},
{"custom", "custom"},
{"popular:openai", "popular"},
{"popular:groq", "popular"},
{"POPULAR:openai", "popular"},
{"weird", "unknown"},
}
for _, c := range cases {
if got := AnalyticsClass(c.in); got != c.want {
t.Fatalf("AnalyticsClass(%q)=%q want %q", c.in, got, c.want)
}
}
}
func TestNormalizeAnalyticsMode(t *testing.T) {
t.Parallel()
if got := NormalizeAnalyticsMode("popular:"); got != "popular:unknown" {
t.Fatalf("got %q", got)
}
if got := NormalizeAnalyticsMode("CUSTOM"); got != "custom" {
t.Fatalf("got %q", got)
}
}
+120
View File
@@ -0,0 +1,120 @@
package aiprovider
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"errors"
"io"
"strings"
"github.com/descrybe/descrybe-v2/apps/api/internal/config"
)
const encPrefix = "enc:v1:"
// DeriveKey builds a 32-byte AES key. Prefer APP_ENCRYPTION_KEY /
// CREDENTIALS_ENCRYPTION_KEY; falls back to DATABASE_URL material (local/dev).
// In production, explicitKey is required; empty returns nil (fail closed).
func DeriveKey(explicitKey, fallbackMaterial string) []byte {
explicitKey = strings.TrimSpace(explicitKey)
if explicitKey != "" {
if b, err := decodeKeyMaterial(explicitKey); err == nil {
return b
}
sum := sha256.Sum256([]byte(explicitKey))
return sum[:]
}
if config.IsProductionEnv() {
return nil
}
sum := sha256.Sum256([]byte("descrybe-ai-v1|" + fallbackMaterial))
return sum[:]
}
func decodeKeyMaterial(s string) ([]byte, error) {
if b, err := base64.StdEncoding.DecodeString(s); err == nil && len(b) == 32 {
return b, nil
}
if b, err := base64.RawStdEncoding.DecodeString(s); err == nil && len(b) == 32 {
return b, nil
}
if b, err := hex.DecodeString(s); err == nil && len(b) == 32 {
return b, nil
}
return nil, errors.New("invalid key material")
}
func EncryptSecret(key []byte, plaintext string) (string, error) {
if plaintext == "" {
return "", nil
}
if len(key) != 32 {
return "", errors.New("encryption key must be 32 bytes")
}
block, err := aes.NewCipher(key)
if err != nil {
return "", err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return "", err
}
nonce := make([]byte, gcm.NonceSize())
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
return "", err
}
sealed := gcm.Seal(nonce, nonce, []byte(plaintext), nil)
return encPrefix + base64.RawStdEncoding.EncodeToString(sealed), nil
}
func DecryptSecret(key []byte, stored string) (string, error) {
if stored == "" {
return "", nil
}
if !strings.HasPrefix(stored, encPrefix) {
if config.IsProductionEnv() {
return "", errors.New("plaintext secrets are not allowed when APP_ENV=production")
}
return stored, nil
}
if len(key) != 32 {
return "", errors.New("encryption key must be 32 bytes")
}
raw, err := base64.RawStdEncoding.DecodeString(strings.TrimPrefix(stored, encPrefix))
if err != nil {
return "", err
}
block, err := aes.NewCipher(key)
if err != nil {
return "", err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return "", err
}
if len(raw) < gcm.NonceSize() {
return "", errors.New("ciphertext too short")
}
nonce, ciphertext := raw[:gcm.NonceSize()], raw[gcm.NonceSize():]
plain, err := gcm.Open(nil, nonce, ciphertext, nil)
if err != nil {
return "", err
}
return string(plain), nil
}
func last4(secret string) string {
secret = strings.TrimSpace(secret)
if secret == "" {
return ""
}
runes := []rune(secret)
if len(runes) <= 4 {
return string(runes)
}
return string(runes[len(runes)-4:])
}
+45
View File
@@ -0,0 +1,45 @@
package aiprovider
import (
"errors"
"github.com/descrybe/descrybe-v2/apps/api/internal/security"
)
// clientError is a validation message safe to return to API clients.
type clientError struct {
msg string
}
func (e *clientError) Error() string { return e.msg }
// ClientMsg marks a message as safe to expose in HTTP 4xx responses.
func ClientMsg(msg string) error {
return &clientError{msg: msg}
}
// ClientError reports whether err is a known client-facing AI provider error.
func ClientError(err error) (msg string, ok bool) {
if err == nil {
return "", false
}
var ce *clientError
if errors.As(err, &ce) {
return ce.msg, true
}
switch {
case errors.Is(err, ErrNotConfigured),
errors.Is(err, ErrInvalidMode),
errors.Is(err, ErrInvalidPopular),
errors.Is(err, ErrMissingAPIKey),
errors.Is(err, ErrMissingModel),
errors.Is(err, ErrMissingURL):
return err.Error(), true
case errors.Is(err, security.ErrInvalidURL),
errors.Is(err, security.ErrBlockedURL),
errors.Is(err, security.ErrBlockedHost):
return "invalid base_url", true
default:
return "", false
}
}
@@ -0,0 +1,110 @@
package aiprovider
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/descrybe/descrybe-v2/apps/api/internal/platformsettings"
)
func TestTestPlatformRole_unknown(t *testing.T) {
t.Parallel()
svc := NewService(nil, EnvConfig{})
res, err := svc.TestPlatformRole(context.Background(), "nope")
if err == nil {
t.Fatal("expected error")
}
if res["status"] != "failed" {
t.Fatalf("status=%v", res["status"])
}
}
func TestTestPlatformRole_skippedWhenUnset(t *testing.T) {
t.Parallel()
svc := NewService(nil, EnvConfig{})
svc.Platform = platformsettings.NewService(nil, platformsettings.EnvConfig{})
res, err := svc.TestPlatformRole(context.Background(), RoleSupport)
if err != nil {
t.Fatal(err)
}
if res["status"] != "skipped" {
t.Fatalf("status=%v message=%v", res["status"], res["message"])
}
}
func TestTestPlatformRole_chatProbeOK(t *testing.T) {
t.Parallel()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/chat/completions" {
http.NotFound(w, r)
return
}
auth := r.Header.Get("Authorization")
if !strings.HasPrefix(auth, "Bearer sk-test-") {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
_ = json.NewEncoder(w).Encode(map[string]any{
"choices": []map[string]any{
{"message": map[string]any{"content": "ok"}},
},
"usage": map[string]any{"total_tokens": 1},
})
}))
t.Cleanup(srv.Close)
plat := platformsettings.NewService(nil, platformsettings.EnvConfig{
OpenAIAPIKey: "sk-test-platform",
OpenAIBaseURL: srv.URL + "/v1",
OpenAIModel: "test-model",
})
svc := NewService(nil, EnvConfig{})
svc.Platform = plat
res, err := svc.TestPlatformRole(context.Background(), RoleProcessing)
if err != nil {
t.Fatalf("err=%v res=%v", err, res)
}
if res["status"] != "ok" {
t.Fatalf("status=%v message=%v", res["status"], res["message"])
}
if msg, _ := res["message"].(string); strings.Contains(strings.ToLower(msg), "sk-") {
t.Fatalf("message must not leak key fragments: %q", msg)
}
}
func TestTestPlatformRole_embedProbeOK(t *testing.T) {
t.Parallel()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/embeddings" {
http.NotFound(w, r)
return
}
_ = json.NewEncoder(w).Encode(map[string]any{
"data": []map[string]any{
{"embedding": []float32{0.1, 0.2}, "index": 0},
},
})
}))
t.Cleanup(srv.Close)
plat := platformsettings.NewService(nil, platformsettings.EnvConfig{
OpenAIEmbeddingAPIKey: "sk-test-embed",
OpenAIEmbeddingBaseURL: srv.URL + "/v1",
OpenAIEmbeddingModel: "text-embedding-3-small",
})
svc := NewService(nil, EnvConfig{})
svc.Platform = plat
res, err := svc.TestPlatformRole(context.Background(), RoleVectorization)
if err != nil {
t.Fatalf("err=%v res=%v", err, res)
}
if res["status"] != "ok" {
t.Fatalf("status=%v message=%v", res["status"], res["message"])
}
}
@@ -0,0 +1,63 @@
package aiprovider
import (
"context"
"testing"
"github.com/descrybe/descrybe-v2/apps/api/internal/platformsettings"
)
func TestResolvePlatformOpenAI_envOnly(t *testing.T) {
svc := &Service{
Env: EnvConfig{
OpenAIAPIKey: "sk-env-fallback",
OpenAIBaseURL: "https://api.openai.com/v1",
OpenAIModel: "gpt-4o-mini",
},
}
oi, err := svc.resolvePlatformOpenAI(context.Background())
if err != nil {
t.Fatal(err)
}
if oi.APIKey != "sk-env-fallback" || oi.Source != platformsettings.SourceEnv {
t.Fatalf("got key=%q source=%q", oi.APIKey, oi.Source)
}
ok, err := svc.platformConfigured(context.Background())
if err != nil || !ok {
t.Fatalf("configured=%v err=%v", ok, err)
}
}
func TestResolvePlatformOpenAI_unset(t *testing.T) {
svc := &Service{Env: EnvConfig{}}
oi, err := svc.resolvePlatformOpenAI(context.Background())
if err != nil {
t.Fatal(err)
}
if oi.APIKey != "" || oi.Source != platformsettings.SourceNone {
t.Fatalf("got key=%q source=%q", oi.APIKey, oi.Source)
}
ok, err := svc.platformConfigured(context.Background())
if err != nil || ok {
t.Fatalf("configured=%v err=%v", ok, err)
}
}
func TestResolvePlatformOpenAI_viaPlatformService(t *testing.T) {
plat := platformsettings.NewService(nil, platformsettings.EnvConfig{
OpenAIAPIKey: "sk-from-plat-env",
OpenAIBaseURL: "http://127.0.0.1:8767/v1",
OpenAIModel: "local-model",
})
svc := &Service{Platform: plat, Env: EnvConfig{OpenAIAPIKey: "sk-should-not-win"}}
oi, err := svc.resolvePlatformOpenAI(context.Background())
if err != nil {
t.Fatal(err)
}
if oi.APIKey != "sk-from-plat-env" {
t.Fatalf("key=%q", oi.APIKey)
}
if oi.Source != platformsettings.SourceEnv {
t.Fatalf("source=%q", oi.Source)
}
}
+180
View File
@@ -0,0 +1,180 @@
package aiprovider
import (
"context"
"strings"
"github.com/descrybe/descrybe-v2/apps/api/internal/processing"
"github.com/google/uuid"
)
// Role identifiers — keep in sync with platformsettings.AIRole* / processing.AIRole*.
//
// RoleSupport is a FUTURE config slot (platformsettings.ai_roles["support"]).
// Resolving a Completer when the slot is configured is allowed for future
// draft-assist UIs, but support.TryAutoReplyLLM must remain the only gate for
// ticket auto-replies — and that stub currently refuses. Guided /docs Ask is
// rule-based and must never use RoleSupport or RoleDocsAPI.
const (
RoleProcessing = processing.AIRoleProcessing
RoleVectorization = processing.AIRoleVectorization
RoleDocsAPI = processing.AIRoleDocsAPI
RoleSupport = processing.AIRoleSupport
)
// RoleEndpoint is a resolved OpenAI-compatible chat endpoint for one role.
// Secrets are plaintext only in-process — never log or return to clients.
type RoleEndpoint struct {
APIKey string
BaseURL string
Model string
UsingBYOK bool
ModeLabel string
}
// RoleEndpointSource looks up admin-configured role bindings (platform / company).
// ok=false means the role is unset — callers must fall back.
type RoleEndpointSource interface {
LookupRole(ctx context.Context, companyID uuid.UUID, role string) (ep RoleEndpoint, ok bool, err error)
}
// ResolveCompleterForRole prefers an injected RoleEndpointSource binding when set;
// otherwise uses company BYOK then platformsettings.ResolveAIConfig for the role
// (processing falls back to legacy openai JSON + OPENAI_* env when unset).
func (s *Service) ResolveCompleterForRole(ctx context.Context, companyID uuid.UUID, role string) (processing.Completer, string, bool, error) {
role = strings.TrimSpace(role)
if role == "" {
role = RoleProcessing
}
if s != nil && s.Roles != nil {
ep, ok, err := s.Roles.LookupRole(ctx, companyID, role)
if err != nil {
return nil, ModeInternalLabel, false, err
}
if ok && strings.TrimSpace(ep.APIKey) != "" && strings.TrimSpace(ep.Model) != "" {
return s.completerFromEndpoint(ep)
}
}
switch role {
case RoleProcessing:
// Full Resolve needs Pool for company BYOK; without Pool use platform/env only.
if s != nil && s.Pool != nil {
return s.ResolveCompleter(ctx, companyID)
}
return s.resolvePlatformRoleCompleter(ctx, RoleProcessing)
case RoleDocsAPI, RoleSupport:
return s.resolvePlatformRoleCompleter(ctx, role)
default:
// Vectorization uses embeddings clients — not chat Completer.
return nil, ModeInternalLabel, false, nil
}
}
// ResolveEmbedderForRole returns an OpenAI-compatible Embedder for the
// vectorization role (platformsettings.AIRoleVectorization) with env fallback.
// Non-vectorization roles return (nil, nil). Unset config returns (nil, nil).
func (s *Service) ResolveEmbedderForRole(ctx context.Context, companyID uuid.UUID, role string) (processing.Embedder, error) {
role = strings.TrimSpace(role)
if role == "" {
role = RoleVectorization
}
if role != RoleVectorization {
return nil, nil
}
if s != nil && s.Roles != nil {
ep, ok, err := s.Roles.LookupRole(ctx, companyID, role)
if err != nil {
return nil, err
}
if ok && strings.TrimSpace(ep.APIKey) != "" {
model := strings.TrimSpace(ep.Model)
if model == "" {
model = "text-embedding-3-small"
}
rpm, retries := 0, 3
if s != nil {
rpm = s.Env.ProcessingRPM
retries = s.Env.ProcessingMaxRetries
}
client := processing.NewOpenAIClient(ep.APIKey, ep.BaseURL, model, rpm, retries)
if s.HTTPClient != nil {
client.HTTPClient = s.HTTPClient
}
return client, nil
}
}
if s != nil && s.Platform != nil {
return s.Platform.ResolveEmbedder(ctx)
}
return nil, nil
}
func (s *Service) resolvePlatformRoleCompleter(ctx context.Context, role string) (processing.Completer, string, bool, error) {
if s == nil {
return nil, ModeInternalLabel, false, nil
}
if s.Platform != nil {
cfg, err := s.Platform.ResolveAIConfig(ctx, role)
if err != nil {
return nil, ModeInternalLabel, false, err
}
if strings.TrimSpace(cfg.APIKey) != "" {
if role != RoleProcessing && !cfg.Enabled {
return nil, ModeInternalLabel, false, nil
}
model := strings.TrimSpace(cfg.Model)
if model == "" && role == RoleProcessing {
model = strings.TrimSpace(s.Env.OpenAIModel)
}
if model != "" {
return s.completerFromEndpoint(RoleEndpoint{
APIKey: cfg.APIKey,
BaseURL: cfg.BaseURL,
Model: model,
UsingBYOK: false,
ModeLabel: ModeInternalLabel,
})
}
}
return nil, ModeInternalLabel, false, nil
}
if role == RoleProcessing {
key := strings.TrimSpace(s.Env.OpenAIAPIKey)
model := strings.TrimSpace(s.Env.OpenAIModel)
if key != "" && model != "" {
return s.completerFromEndpoint(RoleEndpoint{
APIKey: key,
BaseURL: strings.TrimSpace(s.Env.OpenAIBaseURL),
Model: model,
UsingBYOK: false,
ModeLabel: ModeInternalLabel,
})
}
}
return nil, ModeInternalLabel, false, nil
}
func (s *Service) completerFromEndpoint(ep RoleEndpoint) (processing.Completer, string, bool, error) {
rpm := 0
retries := 0
if s != nil {
rpm = s.Env.ProcessingRPM
retries = s.Env.ProcessingMaxRetries
}
client := processing.NewOpenAIClient(ep.APIKey, ep.BaseURL, ep.Model, rpm, retries)
label := strings.TrimSpace(ep.ModeLabel)
if label == "" {
if ep.UsingBYOK {
label = ModeCustom
} else {
label = ModeInternalLabel
}
}
client.ModeLabel = label
if s != nil && s.HTTPClient != nil {
client.HTTPClient = s.HTTPClient
}
return client, label, ep.UsingBYOK, nil
}
+149
View File
@@ -0,0 +1,149 @@
package aiprovider
import (
"context"
"testing"
"github.com/descrybe/descrybe-v2/apps/api/internal/platformsettings"
"github.com/descrybe/descrybe-v2/apps/api/internal/processing"
"github.com/google/uuid"
)
type stubRoleSource struct {
ep RoleEndpoint
ok bool
err error
}
func (s stubRoleSource) LookupRole(_ context.Context, _ uuid.UUID, _ string) (RoleEndpoint, bool, error) {
return s.ep, s.ok, s.err
}
func TestResolveCompleterForRole_unsetFallsBackToEnv(t *testing.T) {
svc := &Service{
Env: EnvConfig{
OpenAIAPIKey: "sk-env-fallback",
OpenAIBaseURL: "https://api.openai.com/v1",
OpenAIModel: "gpt-4o-mini",
},
}
c, label, byok, err := svc.ResolveCompleterForRole(context.Background(), uuid.Nil, RoleProcessing)
if err != nil {
t.Fatal(err)
}
oc, ok := c.(*processing.OpenAIClient)
if !ok || oc == nil || !oc.Enabled() {
t.Fatalf("expected enabled OpenAIClient, got %T", c)
}
if label != ModeInternalLabel {
t.Fatalf("label=%q", label)
}
if byok {
t.Fatal("env fallback must not be BYOK")
}
}
func TestResolveCompleterForRole_usesRoleBindingWhenSet(t *testing.T) {
svc := &Service{
Env: EnvConfig{
OpenAIAPIKey: "sk-should-not-win",
OpenAIModel: "env-model",
},
Roles: stubRoleSource{
ok: true,
ep: RoleEndpoint{
APIKey: "sk-role-processing",
BaseURL: "https://role.example/v1",
Model: "role-model",
UsingBYOK: false,
ModeLabel: ModeInternalLabel,
},
},
}
c, label, byok, err := svc.ResolveCompleterForRole(context.Background(), uuid.New(), RoleProcessing)
if err != nil {
t.Fatal(err)
}
oc, ok := c.(*processing.OpenAIClient)
if !ok || oc == nil {
t.Fatalf("type=%T", c)
}
if oc.APIKey != "sk-role-processing" || oc.Model != "role-model" {
t.Fatalf("key=%q model=%q", oc.APIKey, oc.Model)
}
if label != ModeInternalLabel || byok {
t.Fatalf("label=%q byok=%v", label, byok)
}
}
func TestResolveCompleterForRole_platformProcessingRole(t *testing.T) {
plat := platformsettings.NewService(nil, platformsettings.EnvConfig{
OpenAIAPIKey: "sk-plat-processing",
OpenAIBaseURL: "http://127.0.0.1:8767/v1",
OpenAIModel: "plat-model",
})
svc := &Service{
Platform: plat,
Env: EnvConfig{OpenAIAPIKey: "sk-should-not-win", OpenAIModel: "env-model"},
}
c, label, byok, err := svc.ResolveCompleterForRole(context.Background(), uuid.Nil, RoleProcessing)
if err != nil {
t.Fatal(err)
}
oc, ok := c.(*processing.OpenAIClient)
if !ok || oc == nil {
t.Fatalf("type=%T", c)
}
if oc.APIKey != "sk-plat-processing" || oc.Model != "plat-model" {
t.Fatalf("key=%q model=%q", oc.APIKey, oc.Model)
}
if label != ModeInternalLabel || byok {
t.Fatalf("label=%q byok=%v", label, byok)
}
}
func TestResolveCompleterForRole_vectorizationUnsetNoChatFallback(t *testing.T) {
svc := &Service{
Env: EnvConfig{OpenAIAPIKey: "sk-env", OpenAIModel: "m"},
}
c, _, _, err := svc.ResolveCompleterForRole(context.Background(), uuid.Nil, RoleVectorization)
if err != nil {
t.Fatal(err)
}
if c != nil {
t.Fatal("vectorization must not fall back to chat completer")
}
}
func TestResolveEmbedderForRole_usesPlatformVectorization(t *testing.T) {
plat := platformsettings.NewService(nil, platformsettings.EnvConfig{
OpenAIAPIKey: "sk-embed-env",
OpenAIBaseURL: "http://127.0.0.1:8767/v1",
OpenAIEmbeddingModel: "text-embedding-3-small",
})
svc := &Service{Platform: plat}
emb, err := svc.ResolveEmbedderForRole(context.Background(), uuid.Nil, RoleVectorization)
if err != nil {
t.Fatal(err)
}
oc, ok := emb.(*processing.OpenAIClient)
if !ok || oc == nil || !oc.Enabled() {
t.Fatalf("expected OpenAIClient embedder, got %T", emb)
}
if oc.APIKey != "sk-embed-env" || oc.Model != "text-embedding-3-small" {
t.Fatalf("key=%q model=%q", oc.APIKey, oc.Model)
}
}
func TestResolveCompleterForRole_supportUnsetNoEnvFallback(t *testing.T) {
svc := &Service{
Env: EnvConfig{OpenAIAPIKey: "sk-env", OpenAIModel: "m"},
}
c, _, _, err := svc.ResolveCompleterForRole(context.Background(), uuid.Nil, RoleSupport)
if err != nil {
t.Fatal(err)
}
if c != nil {
t.Fatal("unset support must not fall back to processing/env completer")
}
}
+443
View File
@@ -0,0 +1,443 @@
package aiprovider
import (
"context"
"errors"
"fmt"
"net/http"
"strings"
"time"
"github.com/descrybe/descrybe-v2/apps/api/internal/platformsettings"
"github.com/descrybe/descrybe-v2/apps/api/internal/processing"
"github.com/descrybe/descrybe-v2/apps/api/internal/security"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
var (
ErrNotConfigured = errors.New("ai provider not configured")
ErrInvalidMode = errors.New("mode must be internal, popular, or custom")
ErrInvalidPopular = errors.New("unknown popular provider")
ErrMissingAPIKey = errors.New("api key required")
ErrMissingModel = errors.New("model required")
ErrMissingURL = errors.New("base url required for custom provider")
)
// aiProbeTimeout bounds admin/company connection tests so a hung provider cannot
// hold the HTTP request for multi-retry OpenAI client durations.
const aiProbeTimeout = 45 * time.Second
type Service struct {
Pool *pgxpool.Pool
Key []byte
Env EnvConfig
// Platform is optional; when set, platform OpenAI is loaded from admin
// settings (DB) with EnvConfig as bootstrap fallback.
Platform *platformsettings.Service
// Roles is optional admin role-binding lookup (processing / embeddings / …).
// When nil or a role is unset, ResolveCompleterForRole falls back to Resolve.
Roles RoleEndpointSource
HTTPClient *http.Client
}
func NewService(pool *pgxpool.Pool, env EnvConfig) *Service {
keyMaterial := firstNonEmpty(env.AppEncryptionKey, env.CredentialsEncryptionKey, env.TokenSigningSecret)
return &Service{
Pool: pool,
Key: DeriveKey(keyMaterial, env.DatabaseURL),
Env: env,
// HTTPClient is optional (tests). Production uses NewOpenAIClient's
// SafeHTTPClient so dial-time SSRF applies; leave nil here so platform
// OPENAI_BASE_URL loopback (local models) is not overwritten.
}
}
type stored struct {
mode, popularName, baseURL, model, keyEnc, last4 string
enabled bool
lastTest *time.Time
lastStatus *string
}
func (s *Service) loadStored(ctx context.Context, companyID uuid.UUID) (stored, error) {
var st stored
err := s.Pool.QueryRow(ctx, `
SELECT mode, popular_name, base_url, model, api_key_enc, api_key_last4, is_enabled,
last_test_at, last_test_status
FROM ai_providers WHERE company_id = $1`, companyID).Scan(
&st.mode, &st.popularName, &st.baseURL, &st.model, &st.keyEnc, &st.last4, &st.enabled,
&st.lastTest, &st.lastStatus,
)
return st, err
}
func (s *Service) GetConfig(ctx context.Context, companyID uuid.UUID) (PublicConfig, error) {
platformOK, err := s.platformConfigured(ctx)
if err != nil {
return PublicConfig{}, err
}
st, err := s.loadStored(ctx, companyID)
if errors.Is(err, pgx.ErrNoRows) {
return PublicConfig{
Mode: ModeInternal,
Configured: false,
IsEnabled: false,
ActiveModeLabel: ModeInternalLabel,
PlatformFallback: platformOK,
PopularProviders: PopularCatalog,
}, nil
}
if err != nil {
return PublicConfig{}, err
}
hasKey := st.keyEnc != ""
masked := ""
if hasKey && st.last4 != "" {
masked = "••••" + st.last4
}
active := ModeInternalLabel
if st.enabled && hasKey && (st.mode == ModePopular || st.mode == ModeCustom) {
active = AnalyticsMode(st.mode, st.popularName)
}
return PublicConfig{
Mode: normalizeMode(st.mode),
PopularName: st.popularName,
BaseURL: st.baseURL,
Model: st.model,
IsEnabled: st.enabled,
Configured: true,
HasAPIKey: hasKey,
APIKeyLast4: st.last4,
APIKeyMasked: masked,
LastTestAt: st.lastTest,
LastTestStatus: st.lastStatus,
ActiveModeLabel: active,
PlatformFallback: platformOK,
PopularProviders: PopularCatalog,
}, nil
}
func (s *Service) UpdateConfig(ctx context.Context, companyID uuid.UUID, in UpdateInput) (PublicConfig, error) {
mode := normalizeMode(in.Mode)
if mode != ModeInternal && mode != ModePopular && mode != ModeCustom {
return PublicConfig{}, ErrInvalidMode
}
var existing stored
existing, err := s.loadStored(ctx, companyID)
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
return PublicConfig{}, err
}
hasExisting := err == nil
keyEnc := ""
last4v := ""
if hasExisting {
keyEnc = existing.keyEnc
last4v = existing.last4
}
if in.ClearAPIKey {
keyEnc = ""
last4v = ""
} else if strings.TrimSpace(in.APIKey) != "" {
plain := strings.TrimSpace(in.APIKey)
enc, err := EncryptSecret(s.Key, plain)
if err != nil {
return PublicConfig{}, err
}
keyEnc = enc
last4v = last4(plain)
}
popularName := ""
baseURL := ""
model := strings.TrimSpace(in.Model)
switch mode {
case ModeInternal:
// Platform fallback; company key optional/cleared when switching away from BYOK.
if !in.IsEnabled {
keyEnc = ""
last4v = ""
}
case ModePopular:
pop, ok := FindPopular(in.PopularName)
if !ok {
return PublicConfig{}, ErrInvalidPopular
}
popularName = pop.Name
baseURL = pop.BaseURL
if model == "" {
model = pop.DefaultModel
}
if !modelAllowed(pop, model) {
return PublicConfig{}, ClientMsg(fmt.Sprintf("model %q is not in the %s catalog (or leave blank for default)", model, pop.Name))
}
if in.IsEnabled && keyEnc == "" {
return PublicConfig{}, ErrMissingAPIKey
}
case ModeCustom:
normalized, err := validateProviderBaseURL(in.BaseURL)
if err != nil {
return PublicConfig{}, err
}
baseURL = normalized
if model == "" {
return PublicConfig{}, ErrMissingModel
}
if in.IsEnabled && keyEnc == "" {
return PublicConfig{}, ErrMissingAPIKey
}
}
_, err = s.Pool.Exec(ctx, `
INSERT INTO ai_providers (
company_id, mode, popular_name, base_url, model, api_key_enc, api_key_last4, is_enabled, updated_at
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8, now())
ON CONFLICT (company_id) DO UPDATE SET
mode = EXCLUDED.mode,
popular_name = EXCLUDED.popular_name,
base_url = EXCLUDED.base_url,
model = EXCLUDED.model,
api_key_enc = EXCLUDED.api_key_enc,
api_key_last4 = EXCLUDED.api_key_last4,
is_enabled = EXCLUDED.is_enabled,
updated_at = now()`,
companyID, mode, popularName, baseURL, model, keyEnc, last4v, in.IsEnabled && mode != ModeInternal)
if err != nil {
return PublicConfig{}, err
}
return s.GetConfig(ctx, companyID)
}
func modelAllowed(pop PopularProvider, model string) bool {
model = strings.TrimSpace(model)
if model == "" || model == pop.DefaultModel {
return true
}
for _, m := range pop.Models {
if m == model {
return true
}
}
// Allow unknown model strings for popular providers (API may add models faster than catalog).
return true
}
func validateProviderBaseURL(raw string) (string, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return "", ErrMissingURL
}
normalized, err := security.ValidatePublicHTTPSURL(raw)
if err != nil {
return "", err
}
if normalized == "" {
return "", ErrMissingURL
}
return strings.TrimRight(normalized, "/"), nil
}
// Resolve picks company BYOK completer when enabled+keyed, else platform AI
// from admin settings (DB), with optional env fallback via Platform / Env.
func (s *Service) Resolve(ctx context.Context, companyID uuid.UUID) (Resolved, error) {
rpm := s.Env.ProcessingRPM
retries := s.Env.ProcessingMaxRetries
st, err := s.loadStored(ctx, companyID)
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
return Resolved{}, err
}
if err == nil && st.enabled && (st.mode == ModePopular || st.mode == ModeCustom) {
key, derr := DecryptSecret(s.Key, st.keyEnc)
if derr != nil {
return Resolved{}, derr
}
if strings.TrimSpace(key) != "" && strings.TrimSpace(st.baseURL) != "" && strings.TrimSpace(st.model) != "" {
client := processing.NewOpenAIClient(key, st.baseURL, st.model, rpm, retries)
client.ModeLabel = AnalyticsMode(st.mode, st.popularName)
if s.HTTPClient != nil {
client.HTTPClient = s.HTTPClient
}
return Resolved{
Completer: client,
ModeLabel: client.ModeLabel,
UsingBYOK: true,
}, nil
}
}
platform, err := s.resolvePlatformOpenAI(ctx)
if err != nil {
return Resolved{}, err
}
if strings.TrimSpace(platform.APIKey) == "" {
return Resolved{ModeLabel: ModeInternalLabel, UsingBYOK: false}, nil
}
client := processing.NewOpenAIClient(
platform.APIKey,
platform.BaseURL,
platform.Model,
rpm,
retries,
)
client.ModeLabel = ModeInternalLabel
if s.HTTPClient != nil {
client.HTTPClient = s.HTTPClient
}
return Resolved{
Completer: client,
ModeLabel: ModeInternalLabel,
UsingBYOK: false,
}, nil
}
func (s *Service) platformConfigured(ctx context.Context) (bool, error) {
oi, err := s.resolvePlatformOpenAI(ctx)
if err != nil {
return false, err
}
return strings.TrimSpace(oi.APIKey) != "", nil
}
func (s *Service) resolvePlatformOpenAI(ctx context.Context) (platformsettings.ResolvedOpenAI, error) {
if s.Platform != nil {
return s.Platform.ResolveOpenAI(ctx)
}
out := platformsettings.ResolvedOpenAI{
APIKey: strings.TrimSpace(s.Env.OpenAIAPIKey),
BaseURL: strings.TrimSpace(s.Env.OpenAIBaseURL),
Model: strings.TrimSpace(s.Env.OpenAIModel),
Source: platformsettings.SourceNone,
}
if out.APIKey != "" {
out.Source = platformsettings.SourceEnv
}
return out, nil
}
// ResolveCompleter implements processing.CompanyCompleterResolver (legacy callers).
// Prefer ResolveCompleterForRole for new call sites.
func (s *Service) ResolveCompleter(ctx context.Context, companyID uuid.UUID) (processing.Completer, string, bool, error) {
r, err := s.Resolve(ctx, companyID)
if err != nil {
return nil, ModeInternalLabel, false, err
}
return r.Completer, r.ModeLabel, r.UsingBYOK, nil
}
// TestPlatformRole probes admin platform AI role credentials (not company BYOK).
// Chat roles send a minimal completion; vectorization sends a one-token embed.
// Never returns upstream error bodies (may contain key fragments).
func (s *Service) TestPlatformRole(ctx context.Context, role string) (map[string]any, error) {
ctx, cancel := context.WithTimeout(ctx, aiProbeTimeout)
defer cancel()
role = strings.TrimSpace(role)
out := map[string]any{"role": role}
if role == "" || !platformsettings.ValidAIRole(role) {
out["status"] = "failed"
out["message"] = "unknown ai role"
return out, fmt.Errorf("unknown ai role %q", role)
}
if role == RoleVectorization {
emb, err := s.ResolveEmbedderForRole(ctx, uuid.Nil, role)
if err != nil {
out["status"] = "failed"
out["message"] = "provider resolve failed"
return out, err
}
if emb == nil {
out["status"] = "skipped"
out["message"] = "Vectorization AI is not configured in admin platform settings"
return out, nil
}
if _, err := emb.Embed(ctx, []string{"ping"}); err != nil {
out["status"] = "failed"
out["message"] = "connection failed — check vectorization provider, key, and model"
return out, err
}
out["status"] = "ok"
out["message"] = "Embeddings probe succeeded"
return out, nil
}
completer, _, _, err := s.resolvePlatformRoleCompleter(ctx, role)
if err != nil {
out["status"] = "failed"
out["message"] = "provider resolve failed"
return out, err
}
if completer == nil {
out["status"] = "skipped"
out["message"] = "AI role is not configured (or disabled) in admin platform settings"
return out, nil
}
if _, err := completer.Complete(ctx, "Reply with exactly: ok", "ping"); err != nil {
out["status"] = "failed"
out["message"] = "connection failed — check provider, key, base URL, and model"
return out, err
}
out["status"] = "ok"
out["message"] = "Connection probe succeeded"
return out, nil
}
// TestConnection sends a minimal chat completion and records last_test_*.
func (s *Service) TestConnection(ctx context.Context, companyID uuid.UUID) (map[string]any, error) {
ctx, cancel := context.WithTimeout(ctx, aiProbeTimeout)
defer cancel()
resolved, err := s.Resolve(ctx, companyID)
status := "ok"
message := "connection successful"
if err != nil {
status = "failed"
message = "provider resolve failed"
_, _ = s.Pool.Exec(ctx, `
UPDATE ai_providers SET last_test_at = now(), last_test_status = $2, updated_at = now()
WHERE company_id = $1`, companyID, status)
return map[string]any{"status": status, "message": message, "mode": ModeInternalLabel}, err
}
if resolved.Completer == nil {
status = "failed"
message = "no api key configured (company BYOK or admin platform settings)"
_, _ = s.Pool.Exec(ctx, `
UPDATE ai_providers SET last_test_at = now(), last_test_status = $2, updated_at = now()
WHERE company_id = $1`, companyID, status)
return map[string]any{"status": status, "message": message, "mode": resolved.ModeLabel}, ErrNotConfigured
}
_, err = resolved.Completer.Complete(ctx, "Reply with exactly: ok", "ping")
if err != nil {
status = "failed"
// TruncateError classifies transport/auth failures without leaking secrets.
message = processing.TruncateError(err)
if message == "" || message == "provider error (details redacted)" {
message = "connection failed — check provider, key, base URL, and model"
}
_, _ = s.Pool.Exec(ctx, `
UPDATE ai_providers SET last_test_at = now(), last_test_status = $2, updated_at = now()
WHERE company_id = $1`, companyID, status)
return map[string]any{"status": status, "message": message, "mode": resolved.ModeLabel}, err
}
_, _ = s.Pool.Exec(ctx, `
UPDATE ai_providers SET last_test_at = now(), last_test_status = $2, updated_at = now()
WHERE company_id = $1`, companyID, status)
return map[string]any{
"status": status,
"message": message,
"mode": resolved.ModeLabel,
"byok": resolved.UsingBYOK,
}, nil
}
func firstNonEmpty(vals ...string) string {
for _, v := range vals {
if strings.TrimSpace(v) != "" {
return v
}
}
return ""
}
@@ -0,0 +1,93 @@
package aiprovider
import "testing"
func TestEncryptDecryptRoundTrip(t *testing.T) {
t.Setenv("APP_ENV", "development")
key := DeriveKey("test-ai-key-material", "fallback")
enc, err := EncryptSecret(key, "sk-test-secret-value")
if err != nil {
t.Fatal(err)
}
if enc == "" || enc == "sk-test-secret-value" {
t.Fatalf("expected ciphertext, got %q", enc)
}
plain, err := DecryptSecret(key, enc)
if err != nil {
t.Fatal(err)
}
if plain != "sk-test-secret-value" {
t.Fatalf("got %q", plain)
}
}
func TestDecryptSecret_plaintextPassthrough(t *testing.T) {
// Legacy/migrated rows may store unprefixed plaintext in local/dev only.
t.Setenv("APP_ENV", "development")
key := DeriveKey("test-ai-key-material", "fallback")
got, err := DecryptSecret(key, "sk-legacy-plain")
if err != nil {
t.Fatal(err)
}
if got != "sk-legacy-plain" {
t.Fatalf("got %q", got)
}
}
func TestDecryptSecret_plaintextRejectedInProduction(t *testing.T) {
t.Setenv("APP_ENV", "production")
key := DeriveKey("test-ai-key-material", "fallback")
if _, err := DecryptSecret(key, "sk-legacy-plain"); err == nil {
t.Fatal("expected plaintext decrypt rejected in production")
}
}
func TestAnalyticsMode(t *testing.T) {
cases := []struct {
mode, name, want string
}{
{ModeInternal, "", "internal"},
{ModePopular, "openai", "popular:openai"},
{ModePopular, "Google", "popular:google"},
{ModeCustom, "", "custom"},
{"", "", "internal"},
}
for _, c := range cases {
got := AnalyticsMode(c.mode, c.name)
if got != c.want {
t.Fatalf("AnalyticsMode(%q,%q)=%q want %q", c.mode, c.name, got, c.want)
}
}
}
func TestLast4(t *testing.T) {
if got := last4("sk-abcdefgh"); got != "efgh" {
t.Fatalf("got %q", got)
}
if got := last4("ab"); got != "ab" {
t.Fatalf("got %q", got)
}
}
func TestFindPopular(t *testing.T) {
p, ok := FindPopular("openai")
if !ok || p.BaseURL == "" {
t.Fatal("expected openai")
}
if _, ok := FindPopular("nope"); ok {
t.Fatal("expected miss")
}
}
func TestValidateProviderBaseURL(t *testing.T) {
ok, err := validateProviderBaseURL("https://api.openai.com/v1")
if err != nil || ok == "" {
t.Fatalf("want ok, got %q err=%v", ok, err)
}
if _, err := validateProviderBaseURL("http://169.254.169.254/"); err == nil {
t.Fatal("expected metadata URL blocked")
}
if _, err := validateProviderBaseURL("http://192.168.1.1/v1"); err == nil {
t.Fatal("expected private IP blocked")
}
}
+55
View File
@@ -0,0 +1,55 @@
package aiprovider
import (
"time"
"github.com/descrybe/descrybe-v2/apps/api/internal/processing"
)
// PublicConfig is the tenant-safe view (no raw secrets).
type PublicConfig struct {
Mode string `json:"mode"`
PopularName string `json:"popular_name,omitempty"`
BaseURL string `json:"base_url,omitempty"`
Model string `json:"model,omitempty"`
IsEnabled bool `json:"is_enabled"`
Configured bool `json:"configured"`
HasAPIKey bool `json:"has_api_key"`
APIKeyLast4 string `json:"api_key_last4,omitempty"`
APIKeyMasked string `json:"api_key_masked,omitempty"`
LastTestAt *time.Time `json:"last_test_at,omitempty"`
LastTestStatus *string `json:"last_test_status,omitempty"`
ActiveModeLabel string `json:"active_mode_label"`
PlatformFallback bool `json:"platform_fallback_available"`
PopularProviders []PopularProvider `json:"popular_providers,omitempty"`
}
// UpdateInput is the PUT body. Empty api_key keeps the existing encrypted key.
type UpdateInput struct {
Mode string `json:"mode"`
PopularName string `json:"popular_name"`
BaseURL string `json:"base_url"`
Model string `json:"model"`
APIKey string `json:"api_key"`
IsEnabled bool `json:"is_enabled"`
ClearAPIKey bool `json:"clear_api_key"`
}
// Resolved is the runtime completer + analytics mode for one company job.
type Resolved struct {
Completer processing.Completer
ModeLabel string // internal | popular:<name> | custom
UsingBYOK bool // true when company key is used (skip managed token credits)
}
type EnvConfig struct {
AppEncryptionKey string
CredentialsEncryptionKey string
TokenSigningSecret string
DatabaseURL string
OpenAIAPIKey string // optional env bootstrap; prefer admin platform settings
OpenAIBaseURL string
OpenAIModel string
ProcessingRPM int
ProcessingMaxRetries int
}