2026-04-09 10:01:23 +08:00
|
|
|
package ai
|
|
|
|
|
|
|
|
|
|
import (
|
2026-08-28 22:23:13 +08:00
|
|
|
"context"
|
|
|
|
|
"fmt"
|
|
|
|
|
"strings"
|
|
|
|
|
"sync"
|
2026-04-09 10:01:23 +08:00
|
|
|
"time"
|
|
|
|
|
|
2026-08-28 22:23:13 +08:00
|
|
|
"github.com/google/uuid"
|
2026-04-09 10:01:23 +08:00
|
|
|
"github.com/mlogclub/simple/sqls"
|
|
|
|
|
openai "github.com/openai/openai-go/v3"
|
|
|
|
|
"github.com/openai/openai-go/v3/option"
|
|
|
|
|
|
2026-08-21 00:41:07 +08:00
|
|
|
"code.tczkiot.com/wlw/ai-agent/internal/models"
|
|
|
|
|
"code.tczkiot.com/wlw/ai-agent/internal/pkg/enums"
|
|
|
|
|
"code.tczkiot.com/wlw/ai-agent/internal/pkg/errorsx"
|
2026-08-28 22:23:13 +08:00
|
|
|
"code.tczkiot.com/wlw/ai-agent/internal/pkg/tracex"
|
2026-08-21 00:41:07 +08:00
|
|
|
"code.tczkiot.com/wlw/ai-agent/internal/repositories"
|
2026-04-09 10:01:23 +08:00
|
|
|
)
|
|
|
|
|
|
2026-04-17 17:57:01 +08:00
|
|
|
func newOpenAIClient(config models.AIConfig) openai.Client {
|
2026-04-09 10:01:23 +08:00
|
|
|
opts := []option.RequestOption{
|
|
|
|
|
option.WithAPIKey(config.APIKey),
|
|
|
|
|
option.WithBaseURL(config.BaseURL),
|
|
|
|
|
}
|
|
|
|
|
if config.TimeoutMS > 0 {
|
|
|
|
|
opts = append(opts, option.WithRequestTimeout(time.Duration(config.TimeoutMS)*time.Millisecond))
|
|
|
|
|
}
|
2026-08-28 22:23:13 +08:00
|
|
|
if config.HTTPClient != nil {
|
|
|
|
|
opts = append(opts, option.WithHTTPClient(config.HTTPClient))
|
|
|
|
|
}
|
2026-04-09 10:01:23 +08:00
|
|
|
if config.MaxRetryCount >= 0 {
|
|
|
|
|
opts = append(opts, option.WithMaxRetries(config.MaxRetryCount))
|
|
|
|
|
}
|
|
|
|
|
return openai.NewClient(opts...)
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-28 22:23:13 +08:00
|
|
|
type platformAIRequestScopeContextKey struct{}
|
|
|
|
|
type platformAIRequestPurposeContextKey struct{}
|
|
|
|
|
|
|
|
|
|
type platformAIRequestScope struct {
|
|
|
|
|
base string
|
|
|
|
|
mu sync.Mutex
|
|
|
|
|
next map[string]uint64
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// WithPlatformAIRequestScope binds a persisted business operation identity to
|
|
|
|
|
// platform AI calls. Recreating a scope with the same base during recovery
|
|
|
|
|
// reproduces the same purpose/ordinal request IDs, while one live scope gives
|
|
|
|
|
// every logical upstream call a distinct ordinal.
|
|
|
|
|
func WithPlatformAIRequestScope(ctx context.Context, base string) context.Context {
|
|
|
|
|
if ctx == nil {
|
|
|
|
|
ctx = context.Background()
|
|
|
|
|
}
|
|
|
|
|
base = strings.TrimSpace(base)
|
|
|
|
|
if base == "" {
|
|
|
|
|
return ctx
|
|
|
|
|
}
|
|
|
|
|
return context.WithValue(ctx, platformAIRequestScopeContextKey{}, &platformAIRequestScope{
|
|
|
|
|
base: base,
|
|
|
|
|
next: make(map[string]uint64),
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// WithPlatformAIRequestPurpose separates otherwise identical calls belonging
|
|
|
|
|
// to different stages such as retrieval and document indexing.
|
|
|
|
|
func WithPlatformAIRequestPurpose(ctx context.Context, purpose string) context.Context {
|
|
|
|
|
if ctx == nil {
|
|
|
|
|
ctx = context.Background()
|
|
|
|
|
}
|
|
|
|
|
purpose = strings.TrimSpace(purpose)
|
|
|
|
|
if purpose == "" {
|
|
|
|
|
return ctx
|
|
|
|
|
}
|
|
|
|
|
return context.WithValue(ctx, platformAIRequestPurposeContextKey{}, purpose)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func ensurePlatformAIRequestScope(ctx context.Context) context.Context {
|
|
|
|
|
if ctx == nil {
|
|
|
|
|
ctx = context.Background()
|
|
|
|
|
}
|
|
|
|
|
if scope, _ := ctx.Value(platformAIRequestScopeContextKey{}).(*platformAIRequestScope); scope != nil && strings.TrimSpace(scope.base) != "" {
|
|
|
|
|
return ctx
|
|
|
|
|
}
|
|
|
|
|
if requestID := tracex.RequestIDFromContext(ctx); requestID != "" {
|
|
|
|
|
return WithPlatformAIRequestScope(ctx, "request:"+requestID)
|
|
|
|
|
}
|
|
|
|
|
// No durable business identity is available (for example a one-off debug
|
|
|
|
|
// call), so create one scope for the public operation. Callers with recovery
|
|
|
|
|
// semantics must bind their persisted identity explicitly.
|
|
|
|
|
return WithPlatformAIRequestScope(ctx, "operation:"+uuid.NewString())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func nextPlatformAIRequestID(ctx context.Context, defaultPurpose string) string {
|
|
|
|
|
ctx = ensurePlatformAIRequestScope(ctx)
|
|
|
|
|
scope, _ := ctx.Value(platformAIRequestScopeContextKey{}).(*platformAIRequestScope)
|
|
|
|
|
purpose, _ := ctx.Value(platformAIRequestPurposeContextKey{}).(string)
|
|
|
|
|
purpose = strings.TrimSpace(purpose)
|
|
|
|
|
if purpose == "" {
|
|
|
|
|
purpose = strings.TrimSpace(defaultPurpose)
|
|
|
|
|
}
|
|
|
|
|
if purpose == "" {
|
|
|
|
|
purpose = "request"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
scope.mu.Lock()
|
|
|
|
|
scope.next[purpose]++
|
|
|
|
|
ordinal := scope.next[purpose]
|
|
|
|
|
scope.mu.Unlock()
|
|
|
|
|
return uuid.NewSHA1(uuid.NameSpaceOID, []byte(fmt.Sprintf("%s:purpose:%s:call:%d", scope.base, purpose, ordinal))).String()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// platformRequestOptions creates one deterministic idempotency key for one
|
|
|
|
|
// logical upstream call. SDK retries reuse these options. A later call in the
|
|
|
|
|
// same operation gets the next ordinal; recovery recreates the same sequence.
|
|
|
|
|
func platformRequestOptions(ctx context.Context, config models.AIConfig, purpose string) []option.RequestOption {
|
|
|
|
|
if !config.Platform {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
return []option.RequestOption{
|
|
|
|
|
option.WithHeader("X-AI-Request-ID", nextPlatformAIRequestID(ctx, purpose)),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-09 10:01:23 +08:00
|
|
|
func GetEnabledAIConfig(modelType enums.AIModelType) (*models.AIConfig, error) {
|
|
|
|
|
item := repositories.AIConfigRepository.GetEnabled(sqls.DB(), modelType)
|
|
|
|
|
if item == nil {
|
2026-06-02 21:10:17 +08:00
|
|
|
return nil, errorsx.BusinessErrorI18n(2005, "error.aiConfig.noneEnabled")
|
2026-04-09 10:01:23 +08:00
|
|
|
}
|
|
|
|
|
return item, nil
|
|
|
|
|
}
|