Files
cloudpods/pkg/aiproxy/models/ai_models.go
Zexi Li cdc8ab78b5 feat(aiproxy): move visual provider to model columns and support streaming responses (#25144)
Promote visual_provider_id/visual_model_key to dedicated ai_model columns,
force non-stream upstream orchestration for visual paths, and synthesize SSE
chunks for streaming Responses requests that include images.
2026-07-14 18:39:30 +08:00

320 lines
10 KiB
Go

// Copyright 2019 Yunion
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package models
import (
"context"
"strings"
"yunion.io/x/jsonutils"
"yunion.io/x/log"
"yunion.io/x/pkg/errors"
"yunion.io/x/sqlchemy"
api "yunion.io/x/onecloud/pkg/apis/aiproxy"
"yunion.io/x/onecloud/pkg/cloudcommon/db"
"yunion.io/x/onecloud/pkg/httperrors"
"yunion.io/x/onecloud/pkg/mcclient"
"yunion.io/x/onecloud/pkg/util/stringutils2"
)
// SAiModel stores a model catalog row associated with an SAiProvider.
type SAiModel struct {
db.SEnabledStatusStandaloneResourceBase
AiProviderId string `width:"128" charset:"ascii" nullable:"false" list:"user" create:"required"`
// ModelKey is the model id sent to the upstream API (e.g. gpt-4o-mini, qwen-turbo).
ModelKey string `width:"256" charset:"utf8" nullable:"false" list:"user" create:"required" update:"user"`
// VisualProviderId is the ai_provider used for tool-delegated image analysis.
VisualProviderId string `width:"128" charset:"ascii" nullable:"true" list:"user" create:"optional" update:"user"`
// VisualModelKey is the upstream model id for visual analysis.
VisualModelKey string `width:"256" charset:"utf8" nullable:"true" list:"user" create:"optional" update:"user"`
// Config stores per-model extension settings (e.g. visual delegation).
Config *api.SAiModelConfig `length:"long" charset:"utf8" list:"user" create:"optional" update:"user"`
}
// VisualActive reports whether visual extension is enabled and columns are set.
func (m *SAiModel) VisualActive() bool {
if m == nil || !m.Config.VisualEnabled() {
return false
}
return strings.TrimSpace(m.VisualProviderId) != "" && strings.TrimSpace(m.VisualModelKey) != ""
}
type SAiModelManager struct {
db.SEnabledStatusStandaloneResourceBaseManager
}
var AiModelManager *SAiModelManager
func init() {
AiModelManager = &SAiModelManager{
SEnabledStatusStandaloneResourceBaseManager: db.NewEnabledStatusStandaloneResourceBaseManager(
SAiModel{},
"ai_models_tbl",
"ai_model",
"ai_models",
),
}
AiModelManager.SetVirtualObject(AiModelManager)
}
func (manager *SAiModelManager) ListItemFilter(
ctx context.Context,
q *sqlchemy.SQuery,
userCred mcclient.TokenCredential,
query api.AiModelListInput,
) (*sqlchemy.SQuery, error) {
q, err := manager.SEnabledStatusStandaloneResourceBaseManager.ListItemFilter(ctx, q, userCred, query.EnabledStatusStandaloneResourceListInput)
if err != nil {
return nil, errors.Wrap(err, "SEnabledStatusStandaloneResourceBaseManager.ListItemFilter")
}
if id := strings.TrimSpace(query.AiProviderId); id != "" {
q = q.Equals("ai_provider_id", id)
}
if key := strings.TrimSpace(query.ModelKey); key != "" {
q = q.Equals("model_key", key)
}
if routingRef := strings.TrimSpace(query.AiRoutingId); routingRef != "" {
routingId, err := resolveAiRoutingIdForListFilter(ctx, userCred, routingRef)
if err != nil {
return nil, err
}
modelIds, err := aiModelIdsBoundToRouting(routingId)
if err != nil {
return nil, err
}
if len(modelIds) == 0 {
q = q.In("id", []string{"__no_such_ai_model__"})
} else {
q = q.In("id", modelIds)
}
}
return q, nil
}
func resolveAiRoutingIdForListFilter(ctx context.Context, userCred mcclient.TokenCredential, idOrName string) (string, error) {
obj, err := AiRoutingManager.FetchByIdOrName(ctx, userCred, idOrName)
if err != nil {
return "", errors.Wrapf(err, "fetch ai_routing %s", idOrName)
}
return obj.GetId(), nil
}
func aiModelIdsBoundToRouting(routingId string) ([]string, error) {
routingId = strings.TrimSpace(routingId)
if routingId == "" {
return nil, nil
}
bindings := make([]SAiRoutingModel, 0, 8)
q := AiRoutingModelManager.Query().Equals("ai_routing_id", routingId).Equals("enabled", true)
if err := q.All(&bindings); err != nil {
return nil, errors.Wrap(err, "list ai_routing_models")
}
ids := make([]string, 0, len(bindings))
for i := range bindings {
ids = append(ids, bindings[i].AiModelId)
}
return uniqueNonEmptyStrings(ids), nil
}
func (manager *SAiModelManager) FetchCustomizeColumns(
ctx context.Context,
userCred mcclient.TokenCredential,
query jsonutils.JSONObject,
objs []interface{},
fields stringutils2.SSortedStrings,
isList bool,
) []api.AiModelDetails {
rows := make([]api.AiModelDetails, len(objs))
baseRows := manager.SEnabledStatusStandaloneResourceBaseManager.FetchCustomizeColumns(ctx, userCred, query, objs, fields, isList)
providerIds := make([]string, len(objs))
visualProviderIds := make([]string, 0, len(objs))
for i := range objs {
rows[i].EnabledStatusStandaloneResourceDetails = baseRows[i]
m := objs[i].(*SAiModel)
providerIds[i] = m.AiProviderId
if vid := strings.TrimSpace(m.VisualProviderId); vid != "" {
visualProviderIds = append(visualProviderIds, vid)
}
}
providerNames, err := db.FetchIdNameMap2(AiProviderManager, providerIds)
if err != nil {
log.Errorf("FetchIdNameMap2 ai_provider: %v", err)
return rows
}
visualProviderNames, err := db.FetchIdNameMap2(AiProviderManager, visualProviderIds)
if err != nil {
log.Errorf("FetchIdNameMap2 visual ai_provider: %v", err)
visualProviderNames = nil
}
visualProviderKeys, err := db.FetchIdFieldMap2(AiProviderManager, "provider_key", visualProviderIds)
if err != nil {
log.Errorf("FetchIdFieldMap2 visual ai_provider provider_key: %v", err)
visualProviderKeys = nil
}
for i := range rows {
rows[i].AiProviderName, _ = providerNames[providerIds[i]]
m := objs[i].(*SAiModel)
rows[i].VisualProviderId = m.VisualProviderId
rows[i].VisualModelKey = m.VisualModelKey
rows[i].VisualActive = m.VisualActive()
rows[i].Config = m.Config
if visualProviderNames != nil {
rows[i].VisualProviderName, _ = visualProviderNames[m.VisualProviderId]
}
if visualProviderKeys != nil {
rows[i].VisualProviderKey, _ = visualProviderKeys[m.VisualProviderId]
}
}
return rows
}
func (manager *SAiModelManager) ValidateCreateData(
ctx context.Context,
userCred mcclient.TokenCredential,
ownerId mcclient.IIdentityProvider,
query jsonutils.JSONObject,
input api.AiModelCreateInput,
) (api.AiModelCreateInput, error) {
var err error
input.EnabledStatusStandaloneResourceCreateInput, err = manager.SEnabledStatusStandaloneResourceBaseManager.ValidateCreateData(ctx, userCred, ownerId, query, input.EnabledStatusStandaloneResourceCreateInput)
if err != nil {
return input, errors.Wrap(err, "SEnabledStatusStandaloneResourceBaseManager.ValidateCreateData")
}
prov, err := fetchEnabledAiProvider(ctx, userCred, input.AiProviderId)
if err != nil {
return input, err
}
input.AiProviderId = prov.Id
mk, err := validateAiModelKey(input.ModelKey)
if err != nil {
return input, err
}
input.ModelKey = mk
if err := ensureAiModelKeyUniquePerProvider(ctx, prov.Id, mk, ""); err != nil {
return input, err
}
if strings.TrimSpace(input.Name) == "" {
input.Name = defaultAiModelName(prov.Name, mk)
}
vpid, vmk, err := normalizeAiModelVisualFields(ctx, userCred, input.VisualProviderId, input.VisualModelKey)
if err != nil {
return input, err
}
input.VisualProviderId = vpid
input.VisualModelKey = vmk
if err := validateAiModelVisualSettings(input.Config, vpid, vmk); err != nil {
return input, err
}
return input, nil
}
func (m *SAiModel) ValidateUpdateData(
ctx context.Context,
userCred mcclient.TokenCredential,
query jsonutils.JSONObject,
input *api.AiModelUpdateInput,
) (*api.AiModelUpdateInput, error) {
var err error
input.EnabledStatusStandaloneResourceBaseUpdateInput, err = m.SEnabledStatusStandaloneResourceBase.ValidateUpdateData(ctx, userCred, query, input.EnabledStatusStandaloneResourceBaseUpdateInput)
if err != nil {
return input, errors.Wrap(err, "SEnabledStatusStandaloneResourceBase.ValidateUpdateData")
}
providerId := m.AiProviderId
if pid := strings.TrimSpace(input.AiProviderId); pid != "" {
prov, err := fetchEnabledAiProvider(ctx, userCred, pid)
if err != nil {
return input, err
}
providerId = prov.Id
input.AiProviderId = prov.Id
}
modelKey := m.ModelKey
if mk := strings.TrimSpace(input.ModelKey); mk != "" {
modelKey, err = validateAiModelKey(mk)
if err != nil {
return input, err
}
input.ModelKey = modelKey
}
if modelKey != m.ModelKey || providerId != m.AiProviderId {
if err := ensureAiModelKeyUniquePerProvider(ctx, providerId, modelKey, m.Id); err != nil {
return input, err
}
}
cfg := m.Config
if input.Config != nil {
cfg = input.Config
}
vpid := m.VisualProviderId
vmk := m.VisualModelKey
if strings.TrimSpace(input.VisualProviderId) != "" || strings.TrimSpace(input.VisualModelKey) != "" {
nvpid, nvmk, err := normalizeAiModelVisualFields(ctx, userCred, input.VisualProviderId, input.VisualModelKey)
if err != nil {
return input, err
}
if strings.TrimSpace(input.VisualProviderId) != "" {
vpid = nvpid
input.VisualProviderId = nvpid
}
if strings.TrimSpace(input.VisualModelKey) != "" {
vmk = nvmk
input.VisualModelKey = nvmk
}
}
if err := validateAiModelVisualSettings(cfg, vpid, vmk); err != nil {
return input, err
}
return input, nil
}
func normalizeAiModelVisualFields(
ctx context.Context,
userCred mcclient.TokenCredential,
visualProviderId, visualModelKey string,
) (string, string, error) {
vpid := strings.TrimSpace(visualProviderId)
vmk := strings.TrimSpace(visualModelKey)
if vpid != "" {
prov, err := fetchEnabledAiProvider(ctx, userCred, vpid)
if err != nil {
return "", "", errors.Wrap(err, "visual_provider_id")
}
vpid = prov.Id
}
return vpid, vmk, nil
}
func validateAiModelVisualSettings(cfg *api.SAiModelConfig, visualProviderId, visualModelKey string) error {
vpid := strings.TrimSpace(visualProviderId)
vmk := strings.TrimSpace(visualModelKey)
if cfg.VisualEnabled() && (vpid == "" || vmk == "") {
return errors.Wrap(httperrors.ErrInputParameter, "visual_provider_id and visual_model_key are required when visual is enabled")
}
return nil
}