refa: remove chat model target resolver (#20055)

This commit is contained in:
buua436 authored and GitHub committed 2026-09-23 10:07:55 +08:00
1 parent 83635b5ad6
commit 04090ada0f
5 files changed
+66 -73

No files matched your search

+16 -6
View File
@@ -57,12 +57,22 @@ func NewHarnessRetriever(modelProviderService *service.ModelProviderService, met
// "gpt-4o"), i.e. Python chat_mdl.llm_name. It is the gen_json reply-cache
// key's model component; empty disables that cache.
resolvedModelName := ""
// The resolution goes through the same entry point the pipeline's own
// capability probe uses, so the model decided there is the model wired
// here: req.ModelID may be empty (a dialog without an llm_id), and both
// layers must then fall back to the tenant default rather than resolving
// nothing here and judging capability there.
if target, mErr := modelProviderService.ResolveChatModelTarget(ctx, req.TenantID, req.ModelID); mErr == nil {
// Resolve the same Chat model that the pipeline uses. An empty model ID
// selects the tenant default; an explicit reference must be enrolled as
// Chat, so an image2text-only model is rejected here.
var target *service.ModelTarget
var mErr error
if modelProviderService == nil {
mErr = errors.New("model provider service is not initialized")
} else {
solver := service.NewModelSolver()
if strings.TrimSpace(req.ModelID) == "" {
target, mErr = solver.ResolveDefaultModelConfig(ctx, req.TenantID, entity.ModelTypeChat)
} else {
target, mErr = solver.ResolveModelConfig(ctx, req.TenantID, entity.ModelTypeChat, req.ModelID)
}
}
if mErr == nil {
if inv := component.NewResolvedInvoker(target.Driver, target.ModelName, target.APIConfig); inv != nil {
// MaxLength mirrors Python LLMBundle.max_length (the model's
// context window in tokens); message-fitting nodes (calculate,
+30 -13
View File
@@ -1638,7 +1638,12 @@ func (s *ChatPipelineService) AsyncChatSolo(
}
// 4. Build the chat model wrapper.
target, err := s.ModelProviderSvc.ResolveChatModelTarget(ctx, chat.TenantID, chat.LLMID)
var target *ModelTarget
if strings.TrimSpace(chat.LLMID) == "" {
target, err = s.ModelProviderSvc.modelSolver().ResolveDefaultModelConfig(ctx, chat.TenantID, entity.ModelTypeChat)
} else {
target, err = s.ModelProviderSvc.modelSolver().ResolveModelConfig(ctx, chat.TenantID, entity.ModelTypeChat, chat.LLMID)
}
if err != nil {
out <- AsyncChatResult{
Answer: fmt.Sprintf("**ERROR**: %s", err.Error()),
@@ -2130,12 +2135,13 @@ func tokenizeText(text string) string {
}
// getLLMModelConfig resolves the LLM model configuration for the chat.
// Mirrors Python's three-branch resolver at dialog_service.py:552-561,
// extended so the tenant-default branch also probes vision capability:
// Mirrors Python's three-branch resolver at dialog_service.py:552-561. Chat
// model resolution always requires the enrolled Chat type; vision capability
// is determined separately for attachment dispatch:
//
// if chat.llm_id:
// if "image2text" in get_model_type_by_name(...): → IMAGE2TEXT
// else: → CHAT
// if "chat" and "image2text" in get_model_type_by_name(...): → IMAGE2TEXT
// else: → CHAT
// else: → tenant default
// (IMAGE2TEXT when the default model is vision-capable, else CHAT)
//
@@ -2166,15 +2172,14 @@ func (s *ChatPipelineService) getLLMModelConfig(ctx context.Context, chat *entit
return cfg, modelName, factoryName, baseURL, nil
}
// Branches 1/2: explicit LLM. Resolve the enrolled type first — IMAGE2TEXT
// when the LLM is registered as vision-capable, CHAT otherwise — and let the
// same resolution report the model's tool capability.
// Branches 1/2: explicit LLM. Resolve it as a Chat model first so an
// image2text-only enrollment is rejected. The enrolled type is resolved
// separately below only to decide whether image attachments are allowed.
//
// This mirrors Python, which resolves chat_mdl once in get_models() and then
// reads chat_mdl.is_tools off it (dialog_service.py rag_agent): one lookup, and
// the model that runs is by construction the model that was judged.
modelType := s.ModelProviderSvc.modelSolver().ResolveChatModelType(ctx, chat.TenantID, chat.LLMID)
target, err := s.ModelProviderSvc.modelSolver().ResolveModelConfig(ctx, chat.TenantID, modelType, chat.LLMID)
target, err := s.ModelProviderSvc.modelSolver().ResolveModelConfig(ctx, chat.TenantID, entity.ModelTypeChat, chat.LLMID)
if err != nil {
return nil, "", "", "", err
}
@@ -2184,7 +2189,7 @@ func (s *ChatPipelineService) getLLMModelConfig(ctx context.Context, chat *entit
if err != nil {
return nil, "", "", "", err
}
cfg["model_type"] = chatModelTypeName(modelType)
cfg["model_type"] = s.resolveChatModelType(ctx, chat.TenantID, chat.LLMID)
cfg["is_tools"] = target.SupportsTools
return cfg, modelName, factoryName, baseURL, nil
}
@@ -2331,7 +2336,13 @@ func (s *ChatPipelineService) getModels(ctx context.Context, chat *entity.Chat)
}
// Chat model.
target, err := s.ModelProviderSvc.ResolveChatModelTarget(ctx, chat.TenantID, chat.LLMID)
var target *ModelTarget
var err error
if strings.TrimSpace(chat.LLMID) == "" {
target, err = s.ModelProviderSvc.modelSolver().ResolveDefaultModelConfig(ctx, chat.TenantID, entity.ModelTypeChat)
} else {
target, err = s.ModelProviderSvc.modelSolver().ResolveModelConfig(ctx, chat.TenantID, entity.ModelTypeChat, chat.LLMID)
}
var chatModel *modelModule.ChatModel
if err == nil {
chatModel = modelModule.NewChatModel(target.Driver, &target.ModelName, target.APIConfig)
@@ -2905,7 +2916,13 @@ func (s *ChatPipelineService) buildChatDriver(ctx context.Context, chat *entity.
if chatModel != nil {
return chatModel
}
target, err := s.ModelProviderSvc.ResolveChatModelTarget(ctx, chat.TenantID, chat.LLMID)
var target *ModelTarget
var err error
if strings.TrimSpace(chat.LLMID) == "" {
target, err = s.ModelProviderSvc.modelSolver().ResolveDefaultModelConfig(ctx, chat.TenantID, entity.ModelTypeChat)
} else {
target, err = s.ModelProviderSvc.modelSolver().ResolveModelConfig(ctx, chat.TenantID, entity.ModelTypeChat, chat.LLMID)
}
if err != nil {
return nil
}
+8 -19
View File
@@ -2442,10 +2442,6 @@ func TestGetLLMModelConfigCarriesToolSupport(t *testing.T) {
}{
{"tool-capable model", "model-tools-on", true, "chat"},
{"model without tool support", "model-tools-off", false, "chat"},
// Enrolled only as image-to-text. The type has to be resolved before the
// row is loaded, otherwise the load is rejected as a chat model and the
// capability reads as absent while the pipeline runs on this same model.
{"image2text-only model", "model-image2text-tools", true, "image2text"},
} {
t.Run(tc.name, func(t *testing.T) {
cfg, _, _, _, err := svc.getLLMModelConfig(t.Context(), &entity.Chat{TenantID: "tenant-1", LLMID: tc.llm})
@@ -2462,24 +2458,17 @@ func TestGetLLMModelConfigCarriesToolSupport(t *testing.T) {
}
}
// TestResolveChatModelTargetAcceptsImage2TextOnlyModel pins the shared entry point
// the agentic wiring and the generation path both use: it must load a model
// enrolled only as image-to-text — and report its capability — instead of
// rejecting it as a chat model, which is what made such a request silently fall
// back to the regular RAG path.
func TestResolveChatModelTargetAcceptsImage2TextOnlyModel(t *testing.T) {
// TestResolveModelConfigRejectsImage2TextOnlyModel ensures that a model
// enrolled only as image-to-text cannot be used as a chat model.
func TestResolveModelConfigRejectsImage2TextOnlyModel(t *testing.T) {
setupChatPipelineToolSupportTestDB(t)
svc := NewChatPipelineService()
target, err := svc.ModelProviderSvc.ResolveChatModelTarget(t.Context(), "tenant-1", "model-image2text-tools")
if err != nil {
t.Fatalf("ResolveChatModelTarget(image2text-only model) failed: %v", err)
}
if !target.ModelType.Has(entity.ModelTypeImage2Text) {
t.Errorf("resolved type = %v, want image2text", target.ModelType)
}
if !target.SupportsTools {
t.Error("resolved target reported no tool support, want true")
_, err := svc.ModelProviderSvc.modelSolver().ResolveModelConfig(
t.Context(), "tenant-1", entity.ModelTypeChat, "model-image2text-tools",
)
if err == nil {
t.Fatal("ResolveModelConfig accepted an image2text-only model as chat")
}
}
-19
View File
@@ -3496,25 +3496,6 @@ type AddModelRequest struct {
Extra map[string]interface{} `json:"extra"`
}
// ResolveChatModelTarget resolves the chat model a request will run on: the
// caller's reference when it has one, the tenant default otherwise — a dialog
// without an llm_id still runs on the default (dialog_service get_models).
// Shared by the capability probe and the agentic wiring so the two cannot resolve
// different models for one request.
func (m *ModelProviderService) ResolveChatModelTarget(ctx context.Context, tenantID, modelRef string) (*ModelTarget, error) {
if m == nil {
return nil, fmt.Errorf("%w: model provider service is not initialized", errModelConfigUnavailable)
}
solver := m.modelSolver()
if strings.TrimSpace(modelRef) == "" {
return solver.ResolveDefaultModelConfig(ctx, tenantID, entity.ModelTypeChat)
}
// Resolve the enrolled type first: a reference enrolled only as image-to-text
// is a valid chat-pipeline input (its driver answers chat requests), and
// resolving it as chat would fail the type check outright.
return solver.ResolveModelConfig(ctx, tenantID, solver.ResolveChatModelType(ctx, tenantID, modelRef), modelRef)
}
// modelTargetRef renders a resolved model as the lookups' reference: its
// tenant_model id, or the composite "model@instance@provider" form.
func modelTargetRef(target *ModelTarget) string {
+12 -16
View File
@@ -116,17 +116,11 @@ func (s *ModelSolver) ResolveModelConfig(ctx context.Context, tenantID string, m
}, nil
}
// ResolveChatModelType returns the type a chat reference must be resolved as:
// ModelTypeImage2Text when the reference is enrolled vision-capable, chat
// otherwise.
//
// A dialog's llm_id may name a model that is enrolled ONLY as image-to-text, and
// that model is still a valid chat-pipeline input. Resolving such a reference as
// chat fails outright ("cannot be used as chat model"), so every caller that
// needs a capability of the request's chat model must agree on which row to load
// — otherwise the model the request runs on and the model it is judged by are two
// different rows (or, for an image2text-only enrollment, the second lookup fails
// and the capability reads as absent).
// ResolveChatModelType returns the output type used by the chat pipeline for
// attachment dispatch. A model enrolled as both chat and image2text is rendered
// as image2text so image content can be passed to it; a chat-only model remains
// chat. An image2text-only enrollment remains chat here so ResolveModelConfig
// can reject it as an invalid chat model before this display type is used.
//
// Probe failures are conservative and yield chat: that is the type a plain chat
// model is enrolled as, and it keeps image attachments out of a model whose
@@ -139,12 +133,14 @@ func (s *ModelSolver) ResolveChatModelType(ctx context.Context, tenantID, modelR
if err != nil {
return entity.ModelTypeChat
}
hasChat := false
hasImage2Text := false
for _, mt := range modelTypes {
// ModelType is a bitmask: a model enrolled as chat+image2text reports a
// combined value, so test membership, not equality.
if mt.Has(entity.ModelTypeImage2Text) {
return entity.ModelTypeImage2Text
}
hasChat = hasChat || mt.Has(entity.ModelTypeChat)
hasImage2Text = hasImage2Text || mt.Has(entity.ModelTypeImage2Text)
}
if hasChat && hasImage2Text {
return entity.ModelTypeImage2Text
}
return entity.ModelTypeChat
}