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:
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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:])
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user