2026-03-05 17:27:17 +08:00
|
|
|
#
|
|
|
|
|
# Copyright 2026 The InfiniFlow Authors. All Rights Reserved.
|
|
|
|
|
#
|
|
|
|
|
# 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.
|
|
|
|
|
#
|
Fix: IMAGE2TEXT→CHAT fallback with model_type normalization in tenant_model_service (#14704)
## Summary
- When a model is registered as `chat` in `tenant_llm` but has the
`IMAGE2TEXT` tag in `llm_factories.json`, requesting it as `image2text`
(e.g. PDF parser) fails with `Tenant Model with name <model> and type
image2text not found`.
- After resolution via the new fallback, the returned
`config_dict["model_type"]` was still `"chat"`, causing
`tenant_llm_service.model_instance()` to instantiate `ChatModel` instead
of `CvModel` — breaking `describe_with_prompt` at ingestion time.
## What problem does this PR solve?
RAGFlow already has a `CHAT→IMAGE2TEXT` fallback: when a chat model is
not found, it retries with `image2text`. The symmetric fallback
(`IMAGE2TEXT→CHAT`) was missing.
This matters for multimodal models declared as `model_type: "chat"` with
an `IMAGE2TEXT` tag in `llm_factories.json` (e.g. models added after
tenant creation, or providers where a single model serves both
purposes). The frontend PDF parser selector correctly surfaces these
models via the `IMAGE2TEXT` tag, but the backend fails to resolve them
at runtime.
## Type of change
- [x] Bug Fix (non-breaking change which fixes an issue)
## Changes
**`api/db/joint_services/tenant_model_service.py`**
1. Add `IMAGE2TEXT→CHAT` fallback in
`get_model_config_by_type_and_name`: when an `image2text` model is not
found in `tenant_llm`, retry with `chat` — but only if the `llm` table
confirms `IMAGE2TEXT` capability via the `tags` field. This mirrors the
philosophy of the existing `CHAT→IMAGE2TEXT` fallback: substitution is
only allowed when the model has declared the required capability.
2. Normalize `config_dict["model_type"]` to `image2text` after the
fallback, so the caller (`model_instance`) correctly routes to `CvModel`
instead of `ChatModel`.
3. Extend the type validation guard to allow `(requested=image2text,
found=chat)` alongside the existing `(requested=chat, found=image2text)`
exception.
## Test plan
- [ ] Add a model with `model_type=chat` and `tags` containing
`IMAGE2TEXT` to a tenant
- [ ] Select it as PDF parser in a knowledge base
- [ ] Verify ingestion succeeds without `image2text not found` or
`describe_with_prompt` errors
- [ ] Verify the same model still works correctly in chat context
🤖 Generated with [Claude Code](https://claude.ai/claude-code)
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 04:40:58 +02:00
|
|
|
import logging
|
2026-03-05 17:27:17 +08:00
|
|
|
import os
|
|
|
|
|
import enum
|
2026-06-02 19:04:20 +08:00
|
|
|
import json
|
2026-03-05 17:27:17 +08:00
|
|
|
from common import settings
|
2026-07-01 13:29:28 +08:00
|
|
|
from common.constants import (
|
|
|
|
|
ActiveStatusEnum,
|
|
|
|
|
LLMType,
|
2026-07-08 09:47:29 +08:00
|
|
|
ModelTypeBinary,
|
2026-07-01 13:29:28 +08:00
|
|
|
MINERU_DEFAULT_CONFIG,
|
|
|
|
|
MINERU_ENV_KEYS,
|
|
|
|
|
OPENDATALOADER_DEFAULT_CONFIG,
|
|
|
|
|
OPENDATALOADER_ENV_KEYS,
|
|
|
|
|
PADDLEOCR_DEFAULT_CONFIG,
|
|
|
|
|
PADDLEOCR_ENV_KEYS,
|
|
|
|
|
SOMARK_DEFAULT_CONFIG,
|
|
|
|
|
SOMARK_ENV_KEYS,
|
|
|
|
|
)
|
2026-06-10 13:04:13 +08:00
|
|
|
from api.db.services.tenant_llm_service import TenantService
|
2026-05-29 17:39:41 +08:00
|
|
|
from api.db.services.tenant_model_provider_service import TenantModelProviderService
|
|
|
|
|
from api.db.services.tenant_model_instance_service import TenantModelInstanceService
|
|
|
|
|
from api.db.services.tenant_model_service import TenantModelService
|
2026-07-08 09:47:29 +08:00
|
|
|
from api.utils.model_utils import calculate_model_type, get_model_type_human
|
2026-03-05 17:27:17 +08:00
|
|
|
|
Fix: IMAGE2TEXT→CHAT fallback with model_type normalization in tenant_model_service (#14704)
## Summary
- When a model is registered as `chat` in `tenant_llm` but has the
`IMAGE2TEXT` tag in `llm_factories.json`, requesting it as `image2text`
(e.g. PDF parser) fails with `Tenant Model with name <model> and type
image2text not found`.
- After resolution via the new fallback, the returned
`config_dict["model_type"]` was still `"chat"`, causing
`tenant_llm_service.model_instance()` to instantiate `ChatModel` instead
of `CvModel` — breaking `describe_with_prompt` at ingestion time.
## What problem does this PR solve?
RAGFlow already has a `CHAT→IMAGE2TEXT` fallback: when a chat model is
not found, it retries with `image2text`. The symmetric fallback
(`IMAGE2TEXT→CHAT`) was missing.
This matters for multimodal models declared as `model_type: "chat"` with
an `IMAGE2TEXT` tag in `llm_factories.json` (e.g. models added after
tenant creation, or providers where a single model serves both
purposes). The frontend PDF parser selector correctly surfaces these
models via the `IMAGE2TEXT` tag, but the backend fails to resolve them
at runtime.
## Type of change
- [x] Bug Fix (non-breaking change which fixes an issue)
## Changes
**`api/db/joint_services/tenant_model_service.py`**
1. Add `IMAGE2TEXT→CHAT` fallback in
`get_model_config_by_type_and_name`: when an `image2text` model is not
found in `tenant_llm`, retry with `chat` — but only if the `llm` table
confirms `IMAGE2TEXT` capability via the `tags` field. This mirrors the
philosophy of the existing `CHAT→IMAGE2TEXT` fallback: substitution is
only allowed when the model has declared the required capability.
2. Normalize `config_dict["model_type"]` to `image2text` after the
fallback, so the caller (`model_instance`) correctly routes to `CvModel`
instead of `ChatModel`.
3. Extend the type validation guard to allow `(requested=image2text,
found=chat)` alongside the existing `(requested=chat, found=image2text)`
exception.
## Test plan
- [ ] Add a model with `model_type=chat` and `tags` containing
`IMAGE2TEXT` to a tenant
- [ ] Select it as PDF parser in a knowledge base
- [ ] Verify ingestion succeeds without `image2text not found` or
`describe_with_prompt` errors
- [ ] Verify the same model still works correctly in chat context
🤖 Generated with [Claude Code](https://claude.ai/claude-code)
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 04:40:58 +02:00
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
2026-03-05 17:27:17 +08:00
|
|
|
|
2026-06-10 15:35:21 +08:00
|
|
|
def _factory_model_types(llm: dict) -> list[str]:
|
|
|
|
|
model_type = llm.get("model_type")
|
|
|
|
|
if isinstance(model_type, list):
|
|
|
|
|
return model_type
|
|
|
|
|
return [model_type] if model_type else []
|
2026-07-01 07:00:54 +05:30
|
|
|
|
|
|
|
|
|
2026-07-01 13:30:13 +05:30
|
|
|
def _lookup_factory_llm_info(provider_name: str, pure_model_name: str, extra_fields: dict) -> dict | None:
|
|
|
|
|
region = extra_fields.get("region", "default")
|
|
|
|
|
if region == "intl" and provider_name.lower() == "siliconflow":
|
|
|
|
|
target_factory_name = "siliconflow_intl"
|
|
|
|
|
else:
|
|
|
|
|
target_factory_name = provider_name
|
|
|
|
|
fac_list = [f for f in settings.FACTORY_LLM_INFOS if f["name"] == target_factory_name]
|
|
|
|
|
if not fac_list:
|
|
|
|
|
return None
|
|
|
|
|
llm_list = [llm for llm in fac_list[0]["llm"] if llm["llm_name"] == pure_model_name]
|
|
|
|
|
return llm_list[0] if llm_list else None
|
|
|
|
|
|
|
|
|
|
|
2026-06-10 13:04:13 +08:00
|
|
|
def _decode_api_key_config(raw_api_key: str) -> tuple[str, bool | None, str | None]:
|
|
|
|
|
if not raw_api_key:
|
|
|
|
|
return raw_api_key, None, None
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
parsed = json.loads(raw_api_key)
|
|
|
|
|
except Exception:
|
|
|
|
|
return raw_api_key, None, None
|
|
|
|
|
|
|
|
|
|
if not isinstance(parsed, dict):
|
|
|
|
|
return raw_api_key, None, None
|
|
|
|
|
|
|
|
|
|
is_tools = bool(parsed["is_tools"]) if "is_tools" in parsed else None
|
|
|
|
|
if set(parsed.keys()) <= {"api_key", "is_tools"}:
|
|
|
|
|
return parsed.get("api_key", ""), is_tools, None
|
|
|
|
|
|
|
|
|
|
return parsed.get("api_key", raw_api_key), is_tools, raw_api_key
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def get_first_provider_model_name(tenant_id: str, provider_name: str, model_type: str | enum.Enum) -> str | None:
|
2026-07-08 09:47:29 +08:00
|
|
|
model_type_bin = calculate_model_type(model_type)
|
2026-06-10 13:04:13 +08:00
|
|
|
provider_obj = TenantModelProviderService.get_by_tenant_id_and_provider_name(tenant_id, provider_name)
|
|
|
|
|
if not provider_obj:
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
for instance_obj in TenantModelInstanceService.get_all_by_provider_id(provider_obj.id):
|
|
|
|
|
if instance_obj.status != ActiveStatusEnum.ACTIVE.value:
|
|
|
|
|
continue
|
|
|
|
|
for model_obj in TenantModelService.get_models_by_instance_id(instance_obj.id):
|
2026-07-08 09:47:29 +08:00
|
|
|
if model_obj.model_type & model_type_bin and model_obj.status == ActiveStatusEnum.ACTIVE.value:
|
2026-06-10 13:04:13 +08:00
|
|
|
return f"{model_obj.model_name}@{instance_obj.instance_name}@{provider_name}"
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _collect_env_config(env_keys: list[str], default_config: dict) -> dict | None:
|
|
|
|
|
config = dict(default_config)
|
|
|
|
|
found = False
|
|
|
|
|
for key in env_keys:
|
|
|
|
|
value = os.environ.get(key)
|
|
|
|
|
if value:
|
|
|
|
|
found = True
|
|
|
|
|
config[key] = value
|
|
|
|
|
return config if found else None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _ensure_ocr_provider_from_env(tenant_id: str, provider_name: str, model_name: str, config: dict | None) -> str | None:
|
|
|
|
|
if not config:
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
provider_obj = TenantModelProviderService.get_by_tenant_id_and_provider_name(tenant_id, provider_name)
|
|
|
|
|
if not provider_obj:
|
|
|
|
|
TenantModelProviderService.insert(tenant_id=tenant_id, provider_name=provider_name)
|
|
|
|
|
provider_obj = TenantModelProviderService.get_by_tenant_id_and_provider_name(tenant_id, provider_name)
|
|
|
|
|
|
|
|
|
|
api_key = json.dumps(config)
|
|
|
|
|
instance_obj = TenantModelInstanceService.get_by_provider_id_and_api_key(provider_obj.id, api_key)
|
|
|
|
|
if not instance_obj:
|
|
|
|
|
instance_obj = TenantModelInstanceService.create_instance(
|
|
|
|
|
provider_id=provider_obj.id,
|
|
|
|
|
instance_name=model_name,
|
|
|
|
|
api_key=api_key,
|
|
|
|
|
extra="{}",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
model_obj = TenantModelService.get_by_provider_id_and_instance_id_and_model_type_and_model_name(
|
|
|
|
|
provider_obj.id,
|
|
|
|
|
instance_obj.id,
|
|
|
|
|
LLMType.OCR.value,
|
|
|
|
|
model_name,
|
|
|
|
|
)
|
|
|
|
|
if not model_obj:
|
|
|
|
|
TenantModelService.insert(
|
|
|
|
|
model_name=model_name,
|
|
|
|
|
provider_id=provider_obj.id,
|
|
|
|
|
instance_id=instance_obj.id,
|
2026-07-08 09:47:29 +08:00
|
|
|
model_type=ModelTypeBinary.OCR.value,
|
2026-06-10 13:04:13 +08:00
|
|
|
extra=json.dumps({"max_tokens": 0}),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
return f"{model_name}@{instance_obj.instance_name}@{provider_name}"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def ensure_mineru_from_env(tenant_id: str) -> str | None:
|
|
|
|
|
return _ensure_ocr_provider_from_env(
|
|
|
|
|
tenant_id,
|
|
|
|
|
"MinerU",
|
|
|
|
|
"mineru-from-env",
|
|
|
|
|
_collect_env_config(MINERU_ENV_KEYS, MINERU_DEFAULT_CONFIG),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def ensure_paddleocr_from_env(tenant_id: str) -> str | None:
|
|
|
|
|
return _ensure_ocr_provider_from_env(
|
|
|
|
|
tenant_id,
|
|
|
|
|
"PaddleOCR",
|
|
|
|
|
"paddleocr-from-env",
|
|
|
|
|
_collect_env_config(PADDLEOCR_ENV_KEYS, PADDLEOCR_DEFAULT_CONFIG),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2026-07-01 07:00:54 +05:30
|
|
|
def get_tenant_default_model_by_type(tenant_id: str, model_type: str | enum.Enum):
|
2026-03-05 17:27:17 +08:00
|
|
|
exist, tenant = TenantService.get_by_id(tenant_id)
|
|
|
|
|
if not exist:
|
|
|
|
|
raise LookupError("Tenant not found")
|
|
|
|
|
model_type_val = model_type if isinstance(model_type, str) else model_type.value
|
2026-07-08 09:47:29 +08:00
|
|
|
model_id: str | None = None
|
2026-03-05 17:27:17 +08:00
|
|
|
model_name: str = ""
|
|
|
|
|
match model_type_val:
|
|
|
|
|
case LLMType.EMBEDDING.value:
|
2026-07-08 09:47:29 +08:00
|
|
|
model_id = tenant.tenant_embd_id
|
2026-03-05 17:27:17 +08:00
|
|
|
model_name = tenant.embd_id
|
2026-07-13 16:42:55 +08:00
|
|
|
case LLMType.ASR.value:
|
2026-07-08 09:47:29 +08:00
|
|
|
model_id = tenant.tenant_asr_id
|
2026-07-01 07:00:54 +05:30
|
|
|
model_name = tenant.asr_id
|
2026-07-13 16:42:55 +08:00
|
|
|
case LLMType.VISION.value:
|
2026-07-08 09:47:29 +08:00
|
|
|
model_id = tenant.tenant_img2txt_id
|
2026-03-05 17:27:17 +08:00
|
|
|
model_name = tenant.img2txt_id
|
|
|
|
|
case LLMType.CHAT.value:
|
2026-07-08 09:47:29 +08:00
|
|
|
model_id = tenant.tenant_llm_id
|
2026-03-05 17:27:17 +08:00
|
|
|
model_name = tenant.llm_id
|
|
|
|
|
case LLMType.RERANK.value:
|
2026-07-08 09:47:29 +08:00
|
|
|
model_id = tenant.tenant_rerank_id
|
2026-03-05 17:27:17 +08:00
|
|
|
model_name = tenant.rerank_id
|
|
|
|
|
case LLMType.TTS.value:
|
2026-07-08 09:47:29 +08:00
|
|
|
model_id = tenant.tenant_tts_id
|
2026-03-05 17:27:17 +08:00
|
|
|
model_name = tenant.tts_id
|
|
|
|
|
case LLMType.OCR.value:
|
|
|
|
|
raise Exception("OCR model name is required")
|
|
|
|
|
case _:
|
|
|
|
|
raise Exception(f"Unknown model type {model_type}")
|
|
|
|
|
if not model_name:
|
|
|
|
|
raise Exception(f"No default {model_type} model is set.")
|
2026-07-08 09:47:29 +08:00
|
|
|
# Prefer resolving by tenant_model.id when available
|
|
|
|
|
if model_id:
|
|
|
|
|
try:
|
2026-07-09 14:02:08 +08:00
|
|
|
return get_model_config_by_id(tenant_id, model_type, model_id)
|
2026-07-08 09:47:29 +08:00
|
|
|
except LookupError:
|
|
|
|
|
logger.warning("tenant_model id=%s not found, falling back to model_name lookup for %s", model_id, model_name)
|
2026-07-09 14:02:08 +08:00
|
|
|
return resolve_model_config(tenant_id, model_type, model_name)
|
2026-05-29 17:39:41 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def split_model_name(model_name: str):
|
|
|
|
|
# Parse model_name: {model_name} or {model_name}@{factory_name} or {model_name}@{instance_name}@{factory_name}
|
2026-07-07 07:10:27 +05:30
|
|
|
#
|
|
|
|
|
# The composite key is right-anchored on the provider: the *last* '@'-separated
|
|
|
|
|
# field is the factory/provider, the second-to-last is the instance (when
|
|
|
|
|
# present), and everything to the left is the bare model name. Some model
|
|
|
|
|
# names legitimately contain '@' characters themselves (e.g. LM Studio
|
|
|
|
|
# embedding model IDs such as `text-embedding-nomic-embed-text-v1.5@q8_0`),
|
|
|
|
|
# which produces composite keys like
|
|
|
|
|
# `text-embedding-nomic-embed-text-v1.5@q8_0@lmstudio@LM-Studio`.
|
|
|
|
|
# Use rsplit with maxsplit=2 from the right so any '@' characters embedded
|
|
|
|
|
# in the leftmost model-name component are preserved as part of that name.
|
|
|
|
|
parts = model_name.rsplit("@", 2)
|
|
|
|
|
n = len(parts)
|
|
|
|
|
if n == 3:
|
|
|
|
|
pure_model_name, instance_name, provider_name = parts
|
|
|
|
|
elif n == 2:
|
|
|
|
|
pure_model_name, provider_name = parts
|
2026-05-29 17:39:41 +08:00
|
|
|
instance_name = "default"
|
|
|
|
|
else:
|
|
|
|
|
pure_model_name = parts[0]
|
2026-07-07 07:10:27 +05:30
|
|
|
provider_name = ""
|
|
|
|
|
instance_name = ""
|
2026-05-29 17:39:41 +08:00
|
|
|
return pure_model_name, instance_name, provider_name
|
|
|
|
|
|
|
|
|
|
|
2026-06-26 22:55:49 +08:00
|
|
|
def _resolve_instance_for_model(provider_obj, instance_name: str, model_name: str):
|
|
|
|
|
instance_obj = TenantModelInstanceService.get_by_provider_id_and_instance_name(provider_obj.id, instance_name)
|
|
|
|
|
if instance_obj:
|
|
|
|
|
return instance_obj
|
|
|
|
|
if instance_name != "default":
|
|
|
|
|
raise LookupError(f"Instance {instance_name} not found for model {model_name}.")
|
|
|
|
|
|
2026-07-01 07:00:54 +05:30
|
|
|
active_instances = [inst for inst in TenantModelInstanceService.get_all_by_provider_id(provider_obj.id) if inst.status == ActiveStatusEnum.ACTIVE.value]
|
2026-06-26 22:55:49 +08:00
|
|
|
if len(active_instances) == 1:
|
|
|
|
|
logger.warning(
|
|
|
|
|
"Model instance fallback applied for legacy default instance name",
|
|
|
|
|
extra={
|
|
|
|
|
"provider_name": provider_obj.provider_name,
|
|
|
|
|
"requested_instance_name": instance_name,
|
|
|
|
|
"resolved_instance_name": active_instances[0].instance_name,
|
|
|
|
|
"model_name": model_name,
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
return active_instances[0]
|
|
|
|
|
|
|
|
|
|
raise LookupError(f"Instance {instance_name} not found for model {model_name}.")
|
|
|
|
|
|
2026-07-09 14:52:41 +08:00
|
|
|
|
2026-07-09 14:02:08 +08:00
|
|
|
def resolve_model_config(tenant_id, model_type: str | enum.Enum, model_ref: str):
|
|
|
|
|
try:
|
|
|
|
|
return get_model_config_by_id(tenant_id, model_type, model_ref)
|
|
|
|
|
except LookupError:
|
|
|
|
|
return get_model_config_from_provider_instance(tenant_id, model_type, model_ref)
|
2026-06-26 22:55:49 +08:00
|
|
|
|
2026-07-09 14:52:41 +08:00
|
|
|
|
2026-07-01 07:00:54 +05:30
|
|
|
def get_model_config_from_provider_instance(tenant_id, model_type: str | enum.Enum, model_name: str):
|
2026-05-29 17:39:41 +08:00
|
|
|
pure_model_name, instance_name, provider_name = split_model_name(model_name)
|
|
|
|
|
model_type_val = model_type if isinstance(model_type, str) else model_type.value
|
|
|
|
|
# Builtin embedding model
|
|
|
|
|
compose_profiles = os.getenv("COMPOSE_PROFILES", "")
|
|
|
|
|
is_tei_builtin_embedding = (
|
2026-07-01 07:00:54 +05:30
|
|
|
model_type_val == LLMType.EMBEDDING.value and "tei-" in compose_profiles and pure_model_name == os.getenv("TEI_MODEL", "") and (provider_name == "Builtin" or not provider_name)
|
2026-05-29 17:39:41 +08:00
|
|
|
)
|
|
|
|
|
if is_tei_builtin_embedding:
|
|
|
|
|
# configured local embedding model
|
|
|
|
|
embedding_cfg = settings.EMBEDDING_CFG
|
|
|
|
|
return {
|
|
|
|
|
"llm_factory": "Builtin",
|
|
|
|
|
"api_key": embedding_cfg["api_key"],
|
|
|
|
|
"llm_name": pure_model_name,
|
|
|
|
|
"api_base": embedding_cfg["base_url"],
|
|
|
|
|
"model_type": LLMType.EMBEDDING.value,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
provider_obj = TenantModelProviderService.get_by_tenant_id_and_provider_name(tenant_id, provider_name)
|
|
|
|
|
if not provider_obj:
|
|
|
|
|
raise LookupError(f"Provider {provider_name} not found for model {model_name}.")
|
2026-06-26 22:55:49 +08:00
|
|
|
instance_obj = _resolve_instance_for_model(provider_obj, instance_name, model_name)
|
2026-05-29 17:39:41 +08:00
|
|
|
model_obj = TenantModelService.get_by_provider_id_and_instance_id_and_model_type_and_model_name(provider_obj.id, instance_obj.id, model_type_val, pure_model_name)
|
|
|
|
|
|
2026-06-10 13:04:13 +08:00
|
|
|
api_key, is_tool, api_key_payload = _decode_api_key_config(instance_obj.api_key)
|
2026-05-29 17:39:41 +08:00
|
|
|
extra_fields = json.loads(instance_obj.extra) if instance_obj.extra else {}
|
|
|
|
|
|
|
|
|
|
if model_obj:
|
|
|
|
|
if model_obj.status == ActiveStatusEnum.INACTIVE.value:
|
|
|
|
|
raise LookupError(f"Model {model_name} is disabled.")
|
2026-06-15 19:10:33 +08:00
|
|
|
if model_obj.status == ActiveStatusEnum.UNSUPPORTED.value:
|
|
|
|
|
raise LookupError(f"Model {model_name} cannot be used as {model_type_val} model.")
|
2026-05-29 17:39:41 +08:00
|
|
|
|
fix: propagate max_tokens from model config to downstream consumers (#15945)
## Summary
`get_model_config_from_provider_instance()` was not including
`max_tokens` in its returned dict, causing all downstream consumers
(dialog truncation, message fitting, knowledge base trimming, embedding,
graphrag, RAPTOR) to fall back to the hardcoded default of **8192
tokens** regardless of the actual model context window size (e.g.,
GPT-4o 128K, Claude 200K).
Closes #15944
## Root Cause
The function builds `model_config` with only: `llm_factory`, `api_key`,
`llm_name`, `api_base`, `model_type`, `is_tools`. `max_tokens` is never
included.
Yet the data exists in four independent sources:
1. `TenantModel.extra` JSON field — written by
`provider_api_service.py:659`
2. `conf/llm_factories.json` — every model entry has `max_tokens`
3. `rag/llm/model_meta.py` — 9 provider classes fetch real context
windows from APIs
4. `TenantLLM.max_tokens` database column
None of them are read by this function.
## Fix
Two lines added, one per return path:
- **Path B** (model_obj exists → provider-instance model): reads
`max_tokens` from `model_obj.extra` JSON
- **Path C** (fallback → factory config): reads `max_tokens` from
`llm_info` (sourced from `llm_factories.json`)
Both fall back to 8192 when the value is absent, preserving backward
compatibility.
## Impact
This single 5-line change fixes the context window budget for all **78+
call sites** across **20 files** that construct `LLMBundle` or read
`max_tokens` from the config dict, including:
| Consumer | File | Effect |
|---|---|---|
| Dialog chat truncation | `dialog_service.py:562` |
`message_fit_in(msg, max_tokens * 0.95)` now uses real context window |
| Knowledge base trimming | `dialog_service.py:752` |
`kb_prompt(kbinfos, max_tokens)` now fits more retrieved content |
| Agent message fitting | `agent/component/llm.py:322` | Agent prompts
no longer truncated at 7946 tokens |
| Embedding truncation | `task_executor.py:704` | Embedding input uses
actual model limit |
| GraphRAG extraction | `graphrag/*/extractor.py` | Entity extraction
gets full context budget |
| LLM4Tenant.max_length | `tenant_llm_service.py:513` | Chat model
wrapper exposes real context window |
2026-06-11 17:24:58 +08:00
|
|
|
model_extra = json.loads(model_obj.extra) if model_obj.extra else {}
|
2026-07-01 13:30:13 +05:30
|
|
|
llm_info = _lookup_factory_llm_info(provider_obj.provider_name, pure_model_name, extra_fields)
|
|
|
|
|
if "max_tokens" in model_extra:
|
|
|
|
|
max_tokens = model_extra["max_tokens"]
|
|
|
|
|
else:
|
|
|
|
|
max_tokens = (llm_info or {}).get("max_tokens", 8192)
|
2026-05-29 17:39:41 +08:00
|
|
|
model_config = {
|
|
|
|
|
"llm_factory": provider_obj.provider_name,
|
|
|
|
|
"api_key": api_key,
|
|
|
|
|
"llm_name": model_obj.model_name,
|
|
|
|
|
"api_base": extra_fields.get("base_url", ""),
|
2026-07-08 09:47:29 +08:00
|
|
|
"model_type": model_type_val,
|
2026-06-11 17:29:28 +08:00
|
|
|
"is_tools": model_extra.get("is_tools", is_tool),
|
2026-07-01 13:30:13 +05:30
|
|
|
"max_tokens": max_tokens,
|
2026-05-29 17:39:41 +08:00
|
|
|
}
|
2026-07-01 13:29:28 +08:00
|
|
|
if provider_name.lower() == "somark":
|
|
|
|
|
# SoMark/OCR factories read parser config (somark_*, parse_method, ...)
|
|
|
|
|
# from model_config["extra"]; see tenant_llm_service.LLMBundle OCR path.
|
2026-07-02 20:28:27 +08:00
|
|
|
model_config["extra"] = model_extra
|
2026-07-01 13:29:28 +08:00
|
|
|
|
2026-05-29 17:39:41 +08:00
|
|
|
if api_key_payload is not None:
|
|
|
|
|
model_config["api_key_payload"] = api_key_payload
|
|
|
|
|
|
|
|
|
|
return model_config
|
|
|
|
|
else:
|
2026-07-08 09:47:29 +08:00
|
|
|
raise LookupError(f"Model {model_name} not found for model {model_type_val}")
|
|
|
|
|
|
|
|
|
|
|
2026-07-09 14:02:08 +08:00
|
|
|
def get_model_config_by_id(tenant_id: str, model_type: str | enum.Enum, model_id: str):
|
2026-07-08 09:47:29 +08:00
|
|
|
"""Get model config from tenant_model by its id (CharField PK)."""
|
2026-07-09 14:02:08 +08:00
|
|
|
model_type_val = model_type if isinstance(model_type, str) else model_type.value
|
|
|
|
|
model_type_bin = calculate_model_type(model_type_val)
|
2026-07-08 09:47:29 +08:00
|
|
|
exist, model_obj = TenantModelService.get_by_id(model_id)
|
|
|
|
|
if not exist:
|
|
|
|
|
raise LookupError(f"TenantModel id={model_id} not found.")
|
2026-07-09 14:02:08 +08:00
|
|
|
if model_obj.status == ActiveStatusEnum.INACTIVE.value:
|
2026-07-08 09:47:29 +08:00
|
|
|
raise LookupError(f"TenantModel id={model_id} is disabled.")
|
2026-07-09 14:02:08 +08:00
|
|
|
if model_obj.status == ActiveStatusEnum.UNSUPPORTED.value:
|
|
|
|
|
raise LookupError(f"TenantModel id={model_id} cannot be used as {model_type_val} model.")
|
|
|
|
|
if not (model_obj.model_type & model_type_bin):
|
|
|
|
|
raise LookupError(f"TenantModel id={model_id} cannot be used as {model_type_val} model.")
|
2026-07-08 09:47:29 +08:00
|
|
|
|
2026-07-09 14:02:08 +08:00
|
|
|
ok, provider_obj = TenantModelProviderService.get_by_id(model_obj.provider_id)
|
|
|
|
|
if not ok:
|
2026-07-08 09:47:29 +08:00
|
|
|
raise LookupError(f"Provider id={model_obj.provider_id} not found for model id={model_id}.")
|
|
|
|
|
|
|
|
|
|
# Validate that tenant_id owns the provider or is a joined tenant of the provider's owner.
|
|
|
|
|
if tenant_id != provider_obj.tenant_id:
|
|
|
|
|
joined_tenants = TenantService.get_joined_tenants_by_user_id(tenant_id)
|
|
|
|
|
joined_tenant_ids = [t["tenant_id"] for t in joined_tenants]
|
|
|
|
|
if provider_obj.tenant_id not in joined_tenant_ids:
|
|
|
|
|
raise LookupError(f"Tenant {tenant_id} has no access to provider owned by tenant {provider_obj.tenant_id}.")
|
|
|
|
|
|
2026-07-09 14:02:08 +08:00
|
|
|
ok, instance_obj = TenantModelInstanceService.get_by_id(model_obj.instance_id)
|
|
|
|
|
if not ok:
|
2026-07-08 09:47:29 +08:00
|
|
|
raise LookupError(f"Instance id={model_obj.instance_id} not found for model id={model_id}.")
|
|
|
|
|
|
|
|
|
|
api_key, is_tool, api_key_payload = _decode_api_key_config(instance_obj.api_key)
|
|
|
|
|
extra_fields = json.loads(instance_obj.extra) if instance_obj.extra else {}
|
|
|
|
|
model_extra = json.loads(model_obj.extra) if model_obj.extra else {}
|
|
|
|
|
|
|
|
|
|
model_config = {
|
|
|
|
|
"llm_factory": provider_obj.provider_name,
|
|
|
|
|
"api_key": api_key,
|
|
|
|
|
"llm_name": model_obj.model_name,
|
|
|
|
|
"api_base": extra_fields.get("base_url", ""),
|
2026-07-09 14:02:08 +08:00
|
|
|
"model_type": model_type_val,
|
2026-07-08 09:47:29 +08:00
|
|
|
"is_tools": model_extra.get("is_tools", is_tool),
|
|
|
|
|
"max_tokens": model_extra.get("max_tokens") or 8192,
|
|
|
|
|
}
|
|
|
|
|
if provider_obj.provider_name.lower() == "somark":
|
|
|
|
|
model_config["extra"] = model_extra
|
|
|
|
|
|
|
|
|
|
if api_key_payload is not None:
|
|
|
|
|
model_config["api_key_payload"] = api_key_payload
|
|
|
|
|
|
|
|
|
|
return model_config
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def resolve_model_id(tenant_id: str, model_type: str | enum.Enum, model_name: str) -> str | None:
|
|
|
|
|
"""Given a tenant_id, model_type and model_name (e.g. 'model@instance@provider'),
|
|
|
|
|
look up the corresponding tenant_model.id. Returns None if not found."""
|
|
|
|
|
pure_model_name, instance_name, provider_name = split_model_name(model_name)
|
|
|
|
|
model_type_val = model_type if isinstance(model_type, str) else model_type.value
|
|
|
|
|
|
|
|
|
|
# Builtin TEI embedding — no tenant_model row exists
|
|
|
|
|
compose_profiles = os.getenv("COMPOSE_PROFILES", "")
|
|
|
|
|
is_tei_builtin_embedding = (
|
|
|
|
|
model_type_val == LLMType.EMBEDDING.value and "tei-" in compose_profiles and pure_model_name == os.getenv("TEI_MODEL", "") and (provider_name == "Builtin" or not provider_name)
|
|
|
|
|
)
|
|
|
|
|
if is_tei_builtin_embedding:
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
if not provider_name:
|
|
|
|
|
raise LookupError(f"Provider name is required to resolve model id for {model_name}.")
|
|
|
|
|
|
|
|
|
|
provider_obj = TenantModelProviderService.get_by_tenant_id_and_provider_name(tenant_id, provider_name)
|
|
|
|
|
if not provider_obj:
|
|
|
|
|
raise LookupError(f"Provider {provider_name} not found for model {model_name}.")
|
|
|
|
|
|
|
|
|
|
instance_obj = _resolve_instance_for_model(provider_obj, instance_name, model_name)
|
2026-07-09 14:52:41 +08:00
|
|
|
model_obj = TenantModelService.get_by_provider_id_and_instance_id_and_model_type_and_model_name(provider_obj.id, instance_obj.id, model_type_val, pure_model_name)
|
2026-07-08 09:47:29 +08:00
|
|
|
if not model_obj:
|
|
|
|
|
raise LookupError(f"Model {model_name} not found for type {model_type_val}.")
|
|
|
|
|
return model_obj.id
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# Mapping from model-name field → (LLMType, tenant_model id field)
|
|
|
|
|
_MODEL_NAME_TO_ID_FIELD_MAP: dict[str, tuple[str, str]] = {
|
2026-07-09 14:52:41 +08:00
|
|
|
"llm_id": (LLMType.CHAT, "tenant_llm_id"),
|
|
|
|
|
"embd_id": (LLMType.EMBEDDING, "tenant_embd_id"),
|
|
|
|
|
"rerank_id": (LLMType.RERANK, "tenant_rerank_id"),
|
2026-07-13 16:42:55 +08:00
|
|
|
"asr_id": (LLMType.ASR, "tenant_asr_id"),
|
|
|
|
|
"img2txt_id": (LLMType.VISION, "tenant_img2txt_id"),
|
2026-07-09 14:52:41 +08:00
|
|
|
"tts_id": (LLMType.TTS, "tenant_tts_id"),
|
2026-07-08 09:47:29 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def ensure_tenant_model_ids_for_params(tenant_id: str, params: dict) -> dict:
|
|
|
|
|
"""For each model-name field present in *params*, resolve the corresponding
|
|
|
|
|
tenant_model id if the id field is not already present.
|
|
|
|
|
|
|
|
|
|
Modifies *params* in-place (adds ``tenant_*_id`` keys) and returns it.
|
|
|
|
|
Silently skips resolution when the model is not found in tenant_model
|
|
|
|
|
(e.g. builtin TEI embedding).
|
|
|
|
|
|
|
|
|
|
Typical usage at API entry points:
|
|
|
|
|
|
|
|
|
|
req = await get_request_json()
|
|
|
|
|
ensure_tenant_model_ids_for_params(current_user.id, req)
|
|
|
|
|
# req now has tenant_llm_id / tenant_embd_id etc. filled in
|
|
|
|
|
"""
|
|
|
|
|
for name_field, (model_type, id_field) in _MODEL_NAME_TO_ID_FIELD_MAP.items():
|
|
|
|
|
if name_field in params and id_field not in params:
|
|
|
|
|
try:
|
|
|
|
|
params[id_field] = resolve_model_id(tenant_id, model_type, params[name_field])
|
|
|
|
|
except LookupError:
|
|
|
|
|
logger.debug("Could not resolve %s → %s for tenant %s, skipping", name_field, id_field, tenant_id)
|
|
|
|
|
return params
|
2026-05-29 17:39:41 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def get_api_key(tenant_id: str, model_name: str):
|
|
|
|
|
_, instance_name, provider_name = split_model_name(model_name)
|
|
|
|
|
|
|
|
|
|
if not provider_name:
|
|
|
|
|
raise LookupError("Provider name is required.")
|
|
|
|
|
provider_obj = TenantModelProviderService.get_by_tenant_id_and_provider_name(tenant_id, provider_name)
|
|
|
|
|
if not provider_obj:
|
|
|
|
|
raise LookupError(f"Provider {provider_name} not found.")
|
2026-06-26 22:55:49 +08:00
|
|
|
instance_obj = _resolve_instance_for_model(provider_obj, instance_name, model_name)
|
2026-05-29 17:39:41 +08:00
|
|
|
return instance_obj.api_key
|
|
|
|
|
|
2026-07-09 14:52:41 +08:00
|
|
|
|
2026-07-09 14:02:08 +08:00
|
|
|
def get_model_type_by_id(model_id: str):
|
|
|
|
|
exist, model_obj = TenantModelService.get_by_id(model_id)
|
|
|
|
|
if not exist:
|
|
|
|
|
raise LookupError(f"TenantModel id={model_id} not found.")
|
|
|
|
|
return get_model_type_human(model_obj.model_type)
|
|
|
|
|
|
2026-07-09 14:52:41 +08:00
|
|
|
|
2026-07-09 14:02:08 +08:00
|
|
|
def resolve_model_type(tenant_id: str, model_ref: str):
|
|
|
|
|
try:
|
|
|
|
|
return get_model_type_by_id(model_ref)
|
|
|
|
|
except LookupError:
|
|
|
|
|
return get_model_type_by_name(tenant_id, model_ref)
|
2026-05-29 17:39:41 +08:00
|
|
|
|
2026-07-09 14:52:41 +08:00
|
|
|
|
2026-05-29 17:39:41 +08:00
|
|
|
def get_model_type_by_name(tenant_id: str, model_name: str):
|
|
|
|
|
pure_model_name, instance_name, provider_name = split_model_name(model_name)
|
|
|
|
|
provider_obj = TenantModelProviderService.get_by_tenant_id_and_provider_name(tenant_id, provider_name)
|
|
|
|
|
if not provider_obj:
|
|
|
|
|
raise LookupError(f"Provider {provider_name} not found for model {model_name}.")
|
2026-07-08 09:47:29 +08:00
|
|
|
instance_obj = TenantModelInstanceService.get_by_provider_id_and_instance_name(provider_obj.id, instance_name)
|
|
|
|
|
if not instance_obj:
|
|
|
|
|
raise LookupError(f"Instance {instance_name} not found for model {model_name}.")
|
|
|
|
|
model_obj = TenantModelService.get_by_provider_id_and_instance_id_and_model_name(provider_obj.id, instance_obj.id, pure_model_name)
|
|
|
|
|
if not model_obj:
|
|
|
|
|
raise LookupError(f"Model {model_name} not found.")
|
|
|
|
|
return get_model_type_human(model_obj.model_type)
|
2026-05-29 17:39:41 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def delete_models_by_instance_ids(instance_ids: list[str]):
|
|
|
|
|
return TenantModelService.delete_by_instance_ids(instance_ids)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def delete_instances_by_provider_ids(provider_ids: list[str]):
|
|
|
|
|
return TenantModelInstanceService.delete_by_provider_ids(provider_ids)
|
2026-06-10 14:59:57 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def ensure_opendataloader_from_env(tenant_id: str) -> str | None:
|
|
|
|
|
return _ensure_ocr_provider_from_env(
|
|
|
|
|
tenant_id,
|
|
|
|
|
"OpenDataLoader",
|
|
|
|
|
"opendataloader-from-env",
|
|
|
|
|
_collect_env_config(OPENDATALOADER_ENV_KEYS, OPENDATALOADER_DEFAULT_CONFIG),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2026-07-01 13:29:28 +08:00
|
|
|
def ensure_somark_from_env(tenant_id: str) -> str | None:
|
|
|
|
|
return _ensure_ocr_provider_from_env(
|
|
|
|
|
tenant_id,
|
|
|
|
|
"SoMark",
|
|
|
|
|
"somark-from-env",
|
|
|
|
|
_collect_env_config(SOMARK_ENV_KEYS, SOMARK_DEFAULT_CONFIG),
|
|
|
|
|
)
|