feat(models): persist context metadata

This commit is contained in:
huanghuoguoguo
2026-06-08 00:39:30 +08:00
parent 573e1fe36e
commit b82db2b7f8
23 changed files with 498 additions and 22 deletions
@@ -31,6 +31,7 @@ class LLMModel(Base):
name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False) name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
provider_uuid = sqlalchemy.Column(sqlalchemy.String(255), nullable=False) provider_uuid = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
abilities = sqlalchemy.Column(sqlalchemy.JSON, nullable=False, default=[]) abilities = sqlalchemy.Column(sqlalchemy.JSON, nullable=False, default=[])
context_length = sqlalchemy.Column(sqlalchemy.Integer, nullable=True)
extra_args = sqlalchemy.Column(sqlalchemy.JSON, nullable=False, default={}) extra_args = sqlalchemy.Column(sqlalchemy.JSON, nullable=False, default={})
prefered_ranking = sqlalchemy.Column(sqlalchemy.Integer, nullable=False, default=0) prefered_ranking = sqlalchemy.Column(sqlalchemy.Integer, nullable=False, default=0)
created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, server_default=sqlalchemy.func.now()) created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, server_default=sqlalchemy.func.now())
@@ -0,0 +1,30 @@
"""add llm model context length
Revision ID: 0004_add_llm_model_context_length
Revises: 0003_add_rerank_models
Create Date: 2026-06-07
"""
import sqlalchemy as sa
from alembic import op
revision = '0004_add_llm_model_context_length'
down_revision = '0003_add_rerank_models'
branch_labels = None
depends_on = None
def upgrade() -> None:
conn = op.get_bind()
inspector = sa.inspect(conn)
columns = {column['name'] for column in inspector.get_columns('llm_models')}
if 'context_length' not in columns:
op.add_column('llm_models', sa.Column('context_length', sa.Integer(), nullable=True))
def downgrade() -> None:
conn = op.get_bind()
inspector = sa.inspect(conn)
columns = {column['name'] for column in inspector.get_columns('llm_models')}
if 'context_length' in columns:
op.drop_column('llm_models', 'context_length')
@@ -0,0 +1,42 @@
import sqlalchemy
from .. import migration
@migration.migration_class(26)
class DBMigrateLLMModelContextLength(migration.DBMigration):
"""Add context_length column to LLM models"""
async def upgrade(self):
columns = await self._get_columns('llm_models')
if 'context_length' not in columns:
await self.ap.persistence_mgr.execute_async(
sqlalchemy.text('ALTER TABLE llm_models ADD COLUMN context_length INTEGER')
)
async def downgrade(self):
columns = await self._get_columns('llm_models')
if 'context_length' not in columns:
return
if self.ap.persistence_mgr.db.name == 'postgresql':
await self.ap.persistence_mgr.execute_async(
sqlalchemy.text('ALTER TABLE llm_models DROP COLUMN IF EXISTS context_length')
)
else:
await self.ap.persistence_mgr.execute_async(
sqlalchemy.text('ALTER TABLE llm_models DROP COLUMN context_length')
)
async def _get_columns(self, table_name: str) -> set[str]:
if self.ap.persistence_mgr.db.name == 'postgresql':
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.text("""
SELECT column_name FROM information_schema.columns
WHERE table_name = :table_name
"""),
{'table_name': table_name},
)
return {row[0] for row in result.fetchall()}
result = await self.ap.persistence_mgr.execute_async(sqlalchemy.text(f'PRAGMA table_info({table_name})'))
return {row[1] for row in result.fetchall()}
@@ -266,6 +266,7 @@ class ModelManager:
name=model_info.get('name', ''), name=model_info.get('name', ''),
provider_uuid='', provider_uuid='',
abilities=model_info.get('abilities', []), abilities=model_info.get('abilities', []),
context_length=model_info.get('context_length'),
extra_args=model_info.get('extra_args', {}), extra_args=model_info.get('extra_args', {}),
), ),
provider=runtime_provider, provider=runtime_provider,
@@ -460,6 +461,7 @@ class ModelManager:
name=model_info.get('name', ''), name=model_info.get('name', ''),
provider_uuid=model_info.get('provider_uuid', ''), provider_uuid=model_info.get('provider_uuid', ''),
abilities=model_info.get('abilities', []), abilities=model_info.get('abilities', []),
context_length=model_info.get('context_length'),
extra_args=model_info.get('extra_args', {}), extra_args=model_info.get('extra_args', {}),
) )
@@ -59,8 +59,15 @@ class LiteLLMRequester(requester.ProviderAPIRequester):
litellm_model_name = self._build_litellm_model_name(model_name) litellm_model_name = self._build_litellm_model_name(model_name)
if litellm_model_name != model_name: if litellm_model_name != model_name:
candidates.append((litellm_model_name, None)) candidates.append((litellm_model_name, None))
for metadata_provider in self._metadata_provider_candidates(model_name):
candidates.append((f'{metadata_provider}/{model_name}', None))
tried_candidates: set[tuple[str, str | None]] = set()
for candidate_model, candidate_provider in candidates: for candidate_model, candidate_provider in candidates:
candidate_key = (candidate_model, candidate_provider)
if candidate_key in tried_candidates:
continue
tried_candidates.add(candidate_key)
try: try:
if bool(helper(model=candidate_model, custom_llm_provider=candidate_provider)): if bool(helper(model=candidate_model, custom_llm_provider=candidate_provider)):
return True return True
@@ -68,24 +75,80 @@ class LiteLLMRequester(requester.ProviderAPIRequester):
continue continue
return False return False
def _context_length_from_scan_payload(self, model_payload: dict[str, typing.Any] | None) -> int | None:
if not model_payload:
return None
for field_name in ('context_length', 'context_window', 'max_context_length'):
value = model_payload.get(field_name)
if isinstance(value, bool):
continue
if isinstance(value, int) and value > 0:
return value
if isinstance(value, str) and value.isdigit():
parsed_value = int(value)
if parsed_value > 0:
return parsed_value
return None
def _metadata_provider_candidates(self, model_name: str) -> list[str]:
normalized_model_name = (model_name or '').lower()
candidates = []
if normalized_model_name.startswith(('moonshot-', 'kimi-')):
candidates.append('moonshot')
if normalized_model_name.startswith('deepseek-'):
candidates.append('deepseek')
base_url = self.requester_cfg.get('base_url', '').lower()
if 'moonshot' in base_url:
candidates.append('moonshot')
if 'deepseek' in base_url:
candidates.append('deepseek')
deduped_candidates = []
for candidate in candidates:
if candidate not in deduped_candidates:
deduped_candidates.append(candidate)
return deduped_candidates
def _known_context_length_fallback(self, model_name: str) -> int | None:
normalized_model_name = (model_name or '').lower()
if normalized_model_name.startswith('deepseek-v4-'):
return 1_000_000
if normalized_model_name.startswith(('kimi-k2.5', 'kimi-k2.6')):
return 256 * 1024
if normalized_model_name.startswith('moonshot-v1-8k'):
return 8 * 1024
if normalized_model_name.startswith('moonshot-v1-32k'):
return 32 * 1024
if normalized_model_name.startswith('moonshot-v1-128k') or normalized_model_name == 'moonshot-v1-auto':
return 128 * 1024
return None
def _safe_context_length(self, model_name: str) -> int | None: def _safe_context_length(self, model_name: str) -> int | None:
helper = getattr(litellm, 'get_max_tokens', None) helper = getattr(litellm, 'get_max_tokens', None)
if not callable(helper): if not callable(helper):
return None return self._known_context_length_fallback(model_name)
candidates = [model_name] candidates = [model_name]
litellm_model_name = self._build_litellm_model_name(model_name) litellm_model_name = self._build_litellm_model_name(model_name)
if litellm_model_name != model_name: if litellm_model_name != model_name:
candidates.append(litellm_model_name) candidates.append(litellm_model_name)
for provider in self._metadata_provider_candidates(model_name):
candidates.append(f'{provider}/{model_name}')
tried_candidates = []
for candidate in candidates: for candidate in candidates:
if candidate in tried_candidates:
continue
tried_candidates.append(candidate)
try: try:
max_tokens = helper(candidate) max_tokens = helper(candidate)
except Exception: except Exception:
continue continue
if isinstance(max_tokens, int) and max_tokens > 0: if isinstance(max_tokens, int) and max_tokens > 0:
return max_tokens return max_tokens
return None return self._known_context_length_fallback(model_name)
def _supports_function_calling(self, model_name: str) -> bool: def _supports_function_calling(self, model_name: str) -> bool:
return self._safe_litellm_bool_helper('supports_function_calling', model_name) return self._safe_litellm_bool_helper('supports_function_calling', model_name)
@@ -101,7 +164,11 @@ class LiteLLMRequester(requester.ProviderAPIRequester):
return 'embedding' return 'embedding'
return 'llm' return 'llm'
def _enrich_scanned_model(self, model_id: str) -> dict[str, typing.Any]: def _enrich_scanned_model(
self,
model_id: str,
model_payload: dict[str, typing.Any] | None = None,
) -> dict[str, typing.Any]:
model_type = self._infer_model_type(model_id) model_type = self._infer_model_type(model_id)
scanned_model: dict[str, typing.Any] = { scanned_model: dict[str, typing.Any] = {
'id': model_id, 'id': model_id,
@@ -113,11 +180,17 @@ class LiteLLMRequester(requester.ProviderAPIRequester):
abilities = [] abilities = []
if self._supports_function_calling(model_id): if self._supports_function_calling(model_id):
abilities.append('func_call') abilities.append('func_call')
if self._supports_vision(model_id): supports_provider_reported_vision = bool(
model_payload
and (model_payload.get('supports_image_in') is True or model_payload.get('supports_vision') is True)
)
if supports_provider_reported_vision or self._supports_vision(model_id):
abilities.append('vision') abilities.append('vision')
scanned_model['abilities'] = abilities scanned_model['abilities'] = abilities
context_length = self._safe_context_length(model_id) context_length = self._context_length_from_scan_payload(model_payload)
if context_length is None:
context_length = self._safe_context_length(model_id)
if context_length is not None: if context_length is not None:
scanned_model['context_length'] = context_length scanned_model['context_length'] = context_length
@@ -557,7 +630,7 @@ class LiteLLMRequester(requester.ProviderAPIRequester):
if not model_id: if not model_id:
continue continue
models.append(self._enrich_scanned_model(model_id)) models.append(self._enrich_scanned_model(model_id, item))
models.sort(key=lambda x: (x['type'] != 'llm', x['name'].lower())) models.sort(key=lambda x: (x['type'] != 'llm', x['name'].lower()))
+1 -1
View File
@@ -2,7 +2,7 @@ import langbot
semantic_version = f'v{langbot.__version__}' semantic_version = f'v{langbot.__version__}'
required_database_version = 25 required_database_version = 26
"""Tag the version of the database schema, used to check if the database needs to be migrated""" """Tag the version of the database schema, used to check if the database needs to be migrated"""
debug_mode = False debug_mode = False
@@ -35,6 +35,7 @@ def _create_mock_llm_model(
name: str = 'Test LLM', name: str = 'Test LLM',
provider_uuid: str = 'provider-uuid', provider_uuid: str = 'provider-uuid',
abilities: list = None, abilities: list = None,
context_length: int | None = None,
extra_args: dict = None, extra_args: dict = None,
) -> Mock: ) -> Mock:
"""Helper to create mock LLMModel entity.""" """Helper to create mock LLMModel entity."""
@@ -43,6 +44,7 @@ def _create_mock_llm_model(
model.name = name model.name = name
model.provider_uuid = provider_uuid model.provider_uuid = provider_uuid
model.abilities = abilities or [] model.abilities = abilities or []
model.context_length = context_length
model.extra_args = extra_args or {} model.extra_args = extra_args or {}
return model return model
@@ -142,10 +144,12 @@ class TestRuntimeModelData:
'name': 'Model', 'name': 'Model',
'provider_uuid': 'provider', 'provider_uuid': 'provider',
'abilities': ['vision'], 'abilities': ['vision'],
'context_length': 128000,
'extra_args': {'temp': 0.7}, 'extra_args': {'temp': 0.7},
} }
result = _runtime_model_data('uuid', update_payload) result = _runtime_model_data('uuid', update_payload)
assert result['abilities'] == ['vision'] assert result['abilities'] == ['vision']
assert result['context_length'] == 128000
assert result['extra_args'] == {'temp': 0.7} assert result['extra_args'] == {'temp': 0.7}
@@ -188,7 +192,7 @@ class TestLLMModelsServiceGetLLMModels:
ap = SimpleNamespace() ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace() ap.persistence_mgr = SimpleNamespace()
model = _create_mock_llm_model() model = _create_mock_llm_model(context_length=128000)
provider = _create_mock_provider() provider = _create_mock_provider()
mock_model_result = _create_mock_result([model]) mock_model_result = _create_mock_result([model])
@@ -206,6 +210,7 @@ class TestLLMModelsServiceGetLLMModels:
'uuid': entity.uuid, 'uuid': entity.uuid,
'name': entity.name, 'name': entity.name,
'provider_uuid': entity.provider_uuid if hasattr(entity, 'provider_uuid') else None, 'provider_uuid': entity.provider_uuid if hasattr(entity, 'provider_uuid') else None,
'context_length': getattr(entity, 'context_length', None),
'api_keys': entity.api_keys if hasattr(entity, 'api_keys') else None, 'api_keys': entity.api_keys if hasattr(entity, 'api_keys') else None,
} }
) )
@@ -218,6 +223,7 @@ class TestLLMModelsServiceGetLLMModels:
# Verify # Verify
assert len(result) == 1 assert len(result) == 1
assert result[0]['name'] == 'Test LLM' assert result[0]['name'] == 'Test LLM'
assert result[0]['context_length'] == 128000
async def test_get_llm_models_hide_secret_keys(self): async def test_get_llm_models_hide_secret_keys(self):
"""Hides secret API keys when include_secret=False.""" """Hides secret API keys when include_secret=False."""
@@ -265,7 +271,7 @@ class TestLLMModelsServiceGetLLMModel:
ap = SimpleNamespace() ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace() ap.persistence_mgr = SimpleNamespace()
model = _create_mock_llm_model(model_uuid='found-uuid') model = _create_mock_llm_model(model_uuid='found-uuid', context_length=128000)
provider = _create_mock_provider() provider = _create_mock_provider()
mock_model_result = _create_mock_result([], first_item=model) mock_model_result = _create_mock_result([], first_item=model)
@@ -279,11 +285,12 @@ class TestLLMModelsServiceGetLLMModel:
ap.persistence_mgr.execute_async = AsyncMock(side_effect=mock_execute) ap.persistence_mgr.execute_async = AsyncMock(side_effect=mock_execute)
ap.persistence_mgr.serialize_model = Mock( ap.persistence_mgr.serialize_model = Mock(
return_value={ side_effect=lambda model_cls, entity: {
'uuid': 'found-uuid', 'uuid': entity.uuid,
'name': 'Test LLM', 'name': entity.name,
'provider_uuid': 'provider-uuid', 'provider_uuid': getattr(entity, 'provider_uuid', None),
'provider': {'uuid': 'provider-uuid', 'api_keys': ['key']}, 'context_length': getattr(entity, 'context_length', None),
'api_keys': getattr(entity, 'api_keys', None),
} }
) )
@@ -295,6 +302,7 @@ class TestLLMModelsServiceGetLLMModel:
# Verify # Verify
assert result is not None assert result is not None
assert result['uuid'] == 'found-uuid' assert result['uuid'] == 'found-uuid'
assert result['context_length'] == 128000
async def test_get_llm_model_not_found(self): async def test_get_llm_model_not_found(self):
"""Returns None when model not found.""" """Returns None when model not found."""
@@ -402,6 +410,39 @@ class TestLLMModelsServiceCreateLLMModel:
# Verify # Verify
assert model_uuid == 'preserved-uuid' assert model_uuid == 'preserved-uuid'
async def test_create_llm_model_persists_context_length_as_column(self):
"""Creates LLM model with context_length outside extra_args."""
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
ap.model_mgr = SimpleNamespace()
ap.model_mgr.provider_dict = {'provider-uuid': Mock()}
ap.model_mgr.llm_models = []
ap.model_mgr.load_llm_model_with_provider = AsyncMock(return_value=Mock())
ap.pipeline_service = SimpleNamespace(update_pipeline=AsyncMock())
mock_result = _create_mock_result([])
ap.persistence_mgr.execute_async = AsyncMock(return_value=mock_result)
service = LLMModelsService(ap)
await service.create_llm_model(
{
'uuid': 'model-with-context',
'name': 'Context Model',
'provider_uuid': 'provider-uuid',
'abilities': ['func_call'],
'context_length': 128000,
'extra_args': {'temperature': 0.2},
},
preserve_uuid=True,
auto_set_to_default_pipeline=False,
)
runtime_entity = ap.model_mgr.load_llm_model_with_provider.await_args.args[0]
assert runtime_entity.context_length == 128000
assert runtime_entity.extra_args == {'temperature': 0.2}
assert 'context_length' not in runtime_entity.extra_args
async def test_create_llm_model_provider_not_found_raises_error(self): async def test_create_llm_model_provider_not_found_raises_error(self):
"""Raises Exception when provider not found in runtime.""" """Raises Exception when provider not found in runtime."""
# Setup # Setup
@@ -512,6 +553,35 @@ class TestLLMModelsServiceUpdateLLMModel:
'provider_uuid': 'nonexistent-provider', 'provider_uuid': 'nonexistent-provider',
}) })
async def test_update_llm_model_reloads_context_length_as_column(self):
"""Updates runtime model with context_length outside extra_args."""
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace(execute_async=AsyncMock())
ap.model_mgr = SimpleNamespace()
ap.model_mgr.provider_dict = {'provider-uuid': Mock()}
ap.model_mgr.llm_models = []
ap.model_mgr.remove_llm_model = AsyncMock()
ap.model_mgr.load_llm_model_with_provider = AsyncMock(return_value=Mock())
service = LLMModelsService(ap)
await service.update_llm_model(
'existing-uuid',
{
'name': 'Updated Name',
'provider_uuid': 'provider-uuid',
'abilities': ['vision'],
'context_length': 64000,
'extra_args': {'temperature': 0.4},
},
)
runtime_entity = ap.model_mgr.load_llm_model_with_provider.await_args.args[0]
assert runtime_entity.uuid == 'existing-uuid'
assert runtime_entity.context_length == 64000
assert runtime_entity.extra_args == {'temperature': 0.4}
assert 'context_length' not in runtime_entity.extra_args
class TestLLMModelsServiceDeleteLLMModel: class TestLLMModelsServiceDeleteLLMModel:
"""Tests for LLMModelsService.delete_llm_model method.""" """Tests for LLMModelsService.delete_llm_model method."""
@@ -961,4 +1031,4 @@ class TestRerankModelsServiceGetRerankModelsByProvider:
result = await service.get_rerank_models_by_provider('provider-uuid') result = await service.get_rerank_models_by_provider('provider-uuid')
# Verify # Verify
assert len(result) == 2 assert len(result) == 2
@@ -896,6 +896,121 @@ class TestScanModels:
assert by_id['text-embedding-3-small']['type'] == 'embedding' assert by_id['text-embedding-3-small']['type'] == 'embedding'
assert by_id['bge-reranker-v2']['type'] == 'rerank' assert by_id['bge-reranker-v2']['type'] == 'rerank'
@pytest.mark.asyncio
async def test_scan_models_prefers_context_length_from_provider_payload(self):
"""Provider-supplied context_length is preserved before LiteLLM metadata fallback."""
requester = litellmchat.LiteLLMRequester(
ap=Mock(),
config={
'base_url': 'https://api.moonshot.cn/v1',
'timeout': 60,
},
)
requester._supports_function_calling = Mock(return_value=False)
requester._supports_vision = Mock(return_value=False)
requester._safe_context_length = Mock(return_value=None)
mock_response = Mock()
mock_response.json = Mock(
return_value={
'data': [
{'id': 'moonshot-v1-128k', 'context_length': 131072},
]
}
)
mock_response.raise_for_status = Mock()
with patch('httpx.AsyncClient') as mock_client:
mock_client.return_value.__aenter__ = AsyncMock(return_value=Mock())
mock_client.return_value.__aenter__.return_value.get = AsyncMock(return_value=mock_response)
result = await requester.scan_models(api_key='test-key')
assert result['models'][0]['context_length'] == 131072
requester._safe_context_length.assert_not_called()
def test_safe_context_length_tries_moonshot_metadata_alias(self):
"""OpenAI-compatible Moonshot endpoints still use Moonshot metadata for context windows."""
requester = litellmchat.LiteLLMRequester(
ap=Mock(),
config={
'base_url': 'https://api.moonshot.cn/v1',
'custom_llm_provider': 'openai',
},
)
with patch.object(litellmchat.litellm, 'get_max_tokens') as mock_get_max_tokens:
mock_get_max_tokens.side_effect = lambda model: 131072 if model == 'moonshot/moonshot-v1-128k' else None
assert requester._safe_context_length('moonshot-v1-128k') == 131072
def test_litellm_bool_helper_tries_moonshot_metadata_alias(self):
"""OpenAI-compatible Moonshot endpoints still use Moonshot metadata for abilities."""
requester = litellmchat.LiteLLMRequester(
ap=Mock(),
config={
'base_url': 'https://api.moonshot.cn/v1',
'custom_llm_provider': 'openai',
},
)
with patch.object(litellmchat.litellm, 'supports_function_calling') as mock_supports_function_calling:
mock_supports_function_calling.side_effect = (
lambda model, custom_llm_provider=None: model == 'moonshot/kimi-k2.6'
and custom_llm_provider is None
)
assert requester._supports_function_calling('kimi-k2.6') is True
@pytest.mark.asyncio
async def test_scan_models_uses_provider_payload_for_vision_ability(self):
"""Provider-supplied vision support is used when scanning models."""
requester = litellmchat.LiteLLMRequester(
ap=Mock(),
config={
'base_url': 'https://api.moonshot.cn/v1',
'timeout': 60,
},
)
requester._supports_function_calling = Mock(return_value=False)
requester._supports_vision = Mock(return_value=False)
requester._safe_context_length = Mock(return_value=None)
mock_response = Mock()
mock_response.json = Mock(
return_value={
'data': [
{
'id': 'moonshot-v1-128k-vision-preview',
'supports_image_in': True,
},
]
}
)
mock_response.raise_for_status = Mock()
with patch('httpx.AsyncClient') as mock_client:
mock_client.return_value.__aenter__ = AsyncMock(return_value=Mock())
mock_client.return_value.__aenter__.return_value.get = AsyncMock(return_value=mock_response)
result = await requester.scan_models(api_key='test-key')
assert result['models'][0]['abilities'] == ['vision']
def test_safe_context_length_falls_back_for_deepseek_v4_models(self):
"""DeepSeek V4 API ids have a known 1M context even before LiteLLM maps them."""
requester = litellmchat.LiteLLMRequester(
ap=Mock(),
config={
'base_url': 'https://api.deepseek.com',
'custom_llm_provider': 'deepseek',
},
)
with patch.object(litellmchat.litellm, 'get_max_tokens', side_effect=Exception('not mapped')):
assert requester._safe_context_length('deepseek-v4-pro') == 1_000_000
assert requester._safe_context_length('deepseek-v4-flash') == 1_000_000
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_scan_models_no_base_url(self): async def test_scan_models_no_base_url(self):
"""Test scan_models without base_url raises error""" """Test scan_models without base_url raises error"""
@@ -494,6 +494,7 @@ async def test_model_manager_init_temporary_runtime_llm_model(fake_requester_reg
'api_keys': ['temp-key'], 'api_keys': ['temp-key'],
}, },
'abilities': ['func_call'], 'abilities': ['func_call'],
'context_length': 128000,
'extra_args': {'temperature': 0.5}, 'extra_args': {'temperature': 0.5},
} }
@@ -501,6 +502,9 @@ async def test_model_manager_init_temporary_runtime_llm_model(fake_requester_reg
assert runtime_model.model_entity.uuid == 'temp-model-uuid' assert runtime_model.model_entity.uuid == 'temp-model-uuid'
assert runtime_model.model_entity.name == 'TempModel' assert runtime_model.model_entity.name == 'TempModel'
assert runtime_model.model_entity.context_length == 128000
assert runtime_model.model_entity.extra_args == {'temperature': 0.5}
assert 'context_length' not in runtime_model.model_entity.extra_args
assert runtime_model.provider.provider_entity.uuid == 'temp-provider-uuid' assert runtime_model.provider.provider_entity.uuid == 'temp-provider-uuid'
assert runtime_model.provider.token_mgr.tokens == ['temp-key'] assert runtime_model.provider.token_mgr.tokens == ['temp-key']
@@ -785,4 +789,4 @@ def test_provider_not_found_error_str():
error = provider_errors.ProviderNotFoundError('test-provider') error = provider_errors.ProviderNotFoundError('test-provider')
assert str(error) == 'Provider test-provider not found' assert str(error) == 'Provider test-provider not found'
assert error.provider_name == 'test-provider' assert error.provider_name == 'test-provider'
@@ -64,6 +64,17 @@ function convertExtraArgsToObject(
return obj; return obj;
} }
function parseContextLength(
value: number | null | undefined,
invalidMessage: string,
): number | null {
if (value === undefined || value === null) return null;
if (!Number.isInteger(value) || value <= 0) {
throw new Error(invalidMessage);
}
return value;
}
export default function ModelsDialog({ export default function ModelsDialog({
open, open,
onOpenChange, onOpenChange,
@@ -254,6 +265,7 @@ export default function ModelsDialog({
name: string, name: string,
abilities: string[], abilities: string[],
extraArgs: ExtraArg[], extraArgs: ExtraArg[],
contextLength?: number | null,
) { ) {
if (!name.trim()) { if (!name.trim()) {
toast.error(t('models.modelNameRequired')); toast.error(t('models.modelNameRequired'));
@@ -268,6 +280,10 @@ export default function ModelsDialog({
name, name,
provider_uuid: providerUuid, provider_uuid: providerUuid,
abilities, abilities,
context_length: parseContextLength(
contextLength,
t('models.contextLengthInvalid'),
),
extra_args: extraArgsObj, extra_args: extraArgsObj,
} as never); } as never);
} else if (modelType === 'embedding') { } else if (modelType === 'embedding') {
@@ -325,6 +341,7 @@ export default function ModelsDialog({
name: item.model.name, name: item.model.name,
provider_uuid: providerUuid, provider_uuid: providerUuid,
abilities: item.abilities, abilities: item.abilities,
context_length: item.model.context_length ?? null,
extra_args: {}, extra_args: {},
} as never); } as never);
} else if (effectiveType === 'embedding') { } else if (effectiveType === 'embedding') {
@@ -361,6 +378,7 @@ export default function ModelsDialog({
name: string, name: string,
abilities: string[], abilities: string[],
extraArgs: ExtraArg[], extraArgs: ExtraArg[],
contextLength?: number | null,
) { ) {
if (!name.trim()) { if (!name.trim()) {
toast.error(t('models.modelNameRequired')); toast.error(t('models.modelNameRequired'));
@@ -375,6 +393,10 @@ export default function ModelsDialog({
name, name,
provider_uuid: providerUuid, provider_uuid: providerUuid,
abilities, abilities,
context_length: parseContextLength(
contextLength,
t('models.contextLengthInvalid'),
),
extra_args: extraArgsObj, extra_args: extraArgsObj,
} as never); } as never);
} else if (modelType === 'embedding') { } else if (modelType === 'embedding') {
@@ -509,8 +531,15 @@ export default function ModelsDialog({
onSpaceLogin={handleSpaceLogin} onSpaceLogin={handleSpaceLogin}
onOpenAddModel={() => setAddModelPopoverOpen(provider.uuid)} onOpenAddModel={() => setAddModelPopoverOpen(provider.uuid)}
onCloseAddModel={() => setAddModelPopoverOpen(null)} onCloseAddModel={() => setAddModelPopoverOpen(null)}
onAddModel={(modelType, name, abilities, extraArgs) => onAddModel={(modelType, name, abilities, extraArgs, contextLength) =>
handleAddModel(provider.uuid, modelType, name, abilities, extraArgs) handleAddModel(
provider.uuid,
modelType,
name,
abilities,
extraArgs,
contextLength,
)
} }
onScanModels={(modelType) => handleScanModels(provider.uuid, modelType)} onScanModels={(modelType) => handleScanModels(provider.uuid, modelType)}
onAddScannedModels={(modelType, models) => onAddScannedModels={(modelType, models) =>
@@ -518,7 +547,14 @@ export default function ModelsDialog({
} }
onOpenEditModel={(modelId) => setEditModelPopoverOpen(modelId)} onOpenEditModel={(modelId) => setEditModelPopoverOpen(modelId)}
onCloseEditModel={() => setEditModelPopoverOpen(null)} onCloseEditModel={() => setEditModelPopoverOpen(null)}
onUpdateModel={(modelId, modelType, name, abilities, extraArgs) => onUpdateModel={(
modelId,
modelType,
name,
abilities,
extraArgs,
contextLength,
) =>
handleUpdateModel( handleUpdateModel(
provider.uuid, provider.uuid,
modelId, modelId,
@@ -526,6 +562,7 @@ export default function ModelsDialog({
name, name,
abilities, abilities,
extraArgs, extraArgs,
contextLength,
) )
} }
onOpenDeleteConfirm={(modelId) => setDeleteConfirmOpen(modelId)} onOpenDeleteConfirm={(modelId) => setDeleteConfirmOpen(modelId)}
@@ -41,6 +41,7 @@ interface AddModelPopoverProps {
name: string, name: string,
abilities: string[], abilities: string[],
extraArgs: ExtraArg[], extraArgs: ExtraArg[],
contextLength?: number | null,
) => Promise<void>; ) => Promise<void>;
onScanModels: (modelType?: ModelType) => Promise<ScanModelsResult>; onScanModels: (modelType?: ModelType) => Promise<ScanModelsResult>;
onAddScannedModels: ( onAddScannedModels: (
@@ -81,6 +82,7 @@ export default function AddModelPopover({
const [mode, setMode] = useState<'manual' | 'scan'>('manual'); const [mode, setMode] = useState<'manual' | 'scan'>('manual');
const [name, setName] = useState(''); const [name, setName] = useState('');
const [abilities, setAbilities] = useState<string[]>([]); const [abilities, setAbilities] = useState<string[]>([]);
const [contextLength, setContextLength] = useState('');
const [extraArgs, setExtraArgs] = useState<ExtraArg[]>([]); const [extraArgs, setExtraArgs] = useState<ExtraArg[]>([]);
const [scanLoading, setScanLoading] = useState(false); const [scanLoading, setScanLoading] = useState(false);
const [scannedModels, setScannedModels] = useState<ScannedProviderModel[]>( const [scannedModels, setScannedModels] = useState<ScannedProviderModel[]>(
@@ -98,6 +100,7 @@ export default function AddModelPopover({
setMode(initialMode); setMode(initialMode);
setName(''); setName('');
setAbilities([]); setAbilities([]);
setContextLength('');
setExtraArgs([]); setExtraArgs([]);
setScanLoading(false); setScanLoading(false);
setScannedModels([]); setScannedModels([]);
@@ -119,7 +122,11 @@ export default function AddModelPopover({
}, [tab, mode]); }, [tab, mode]);
const handleAdd = async () => { const handleAdd = async () => {
await onAddModel(tab, name, abilities, extraArgs); const parsedContextLength =
tab === 'llm' && contextLength.trim()
? Number(contextLength.trim())
: null;
await onAddModel(tab, name, abilities, extraArgs, parsedContextLength);
}; };
const handleTest = async () => { const handleTest = async () => {
@@ -318,6 +325,24 @@ export default function AddModelPopover({
</div> </div>
)} )}
{tab === 'llm' && (
<div className="space-y-2">
<Label htmlFor="add-context-length">
{t('models.contextLength')}
</Label>
<Input
id="add-context-length"
type="number"
min={1}
step={1}
inputMode="numeric"
placeholder={t('models.contextLengthPlaceholder')}
value={contextLength}
onChange={(e) => setContextLength(e.target.value)}
/>
</div>
)}
<ExtraArgsEditor <ExtraArgsEditor
args={extraArgs} args={extraArgs}
onChange={setExtraArgs} onChange={setExtraArgs}
@@ -31,6 +31,7 @@ interface ModelItemProps {
name: string, name: string,
abilities: string[], abilities: string[],
extraArgs: ExtraArg[], extraArgs: ExtraArg[],
contextLength?: number | null,
) => Promise<void>; ) => Promise<void>;
onTestModel: ( onTestModel: (
name: string, name: string,
@@ -92,6 +93,11 @@ export default function ModelItem({
const [editAbilities, setEditAbilities] = useState<string[]>( const [editAbilities, setEditAbilities] = useState<string[]>(
modelType === 'llm' ? (model as LLMModel).abilities || [] : [], modelType === 'llm' ? (model as LLMModel).abilities || [] : [],
); );
const [editContextLength, setEditContextLength] = useState(
modelType === 'llm' && (model as LLMModel).context_length
? String((model as LLMModel).context_length)
: '',
);
const [editExtraArgs, setEditExtraArgs] = useState<ExtraArg[]>( const [editExtraArgs, setEditExtraArgs] = useState<ExtraArg[]>(
convertExtraArgsToArray(model.extra_args), convertExtraArgsToArray(model.extra_args),
); );
@@ -106,13 +112,27 @@ export default function ModelItem({
setEditAbilities( setEditAbilities(
modelType === 'llm' ? (model as LLMModel).abilities || [] : [], modelType === 'llm' ? (model as LLMModel).abilities || [] : [],
); );
setEditContextLength(
modelType === 'llm' && (model as LLMModel).context_length
? String((model as LLMModel).context_length)
: '',
);
setEditExtraArgs(convertExtraArgsToArray(model.extra_args)); setEditExtraArgs(convertExtraArgsToArray(model.extra_args));
onResetTestResult(); onResetTestResult();
} }
}, [isEditOpen]); }, [isEditOpen]);
const handleSave = async () => { const handleSave = async () => {
await onUpdateModel(editName, editAbilities, editExtraArgs); const parsedContextLength =
modelType === 'llm' && editContextLength.trim()
? Number(editContextLength.trim())
: null;
await onUpdateModel(
editName,
editAbilities,
editExtraArgs,
parsedContextLength,
);
}; };
const handleTest = async () => { const handleTest = async () => {
@@ -287,6 +307,25 @@ export default function ModelItem({
</div> </div>
)} )}
{modelType === 'llm' && (
<div className="space-y-2">
<Label htmlFor={`edit-context-length-${model.uuid}`}>
{t('models.contextLength')}
</Label>
<Input
id={`edit-context-length-${model.uuid}`}
type="number"
min={1}
step={1}
inputMode="numeric"
placeholder={t('models.contextLengthPlaceholder')}
value={editContextLength}
disabled={isLangBotModels}
onChange={(e) => setEditContextLength(e.target.value)}
/>
</div>
)}
<ExtraArgsEditor <ExtraArgsEditor
args={editExtraArgs} args={editExtraArgs}
onChange={setEditExtraArgs} onChange={setEditExtraArgs}
@@ -60,6 +60,7 @@ interface ProviderCardProps {
name: string, name: string,
abilities: string[], abilities: string[],
extraArgs: ExtraArg[], extraArgs: ExtraArg[],
contextLength?: number | null,
) => Promise<void>; ) => Promise<void>;
onScanModels: (modelType?: ModelType) => Promise<ScanModelsResult>; onScanModels: (modelType?: ModelType) => Promise<ScanModelsResult>;
onAddScannedModels: ( onAddScannedModels: (
@@ -74,6 +75,7 @@ interface ProviderCardProps {
name: string, name: string,
abilities: string[], abilities: string[],
extraArgs: ExtraArg[], extraArgs: ExtraArg[],
contextLength?: number | null,
) => Promise<void>; ) => Promise<void>;
onOpenDeleteConfirm: (modelId: string) => void; onOpenDeleteConfirm: (modelId: string) => void;
onCloseDeleteConfirm: () => void; onCloseDeleteConfirm: () => void;
@@ -405,13 +407,19 @@ export default function ProviderCard({
onOpenDeleteConfirm={onOpenDeleteConfirm} onOpenDeleteConfirm={onOpenDeleteConfirm}
onCloseDeleteConfirm={onCloseDeleteConfirm} onCloseDeleteConfirm={onCloseDeleteConfirm}
onDeleteModel={() => onDeleteModel(model.uuid, 'llm')} onDeleteModel={() => onDeleteModel(model.uuid, 'llm')}
onUpdateModel={(name, abilities, extraArgs) => onUpdateModel={(
name,
abilities,
extraArgs,
contextLength,
) =>
onUpdateModel( onUpdateModel(
model.uuid, model.uuid,
'llm', 'llm',
name, name,
abilities, abilities,
extraArgs, extraArgs,
contextLength,
) )
} }
onTestModel={(name, abilities, extraArgs) => onTestModel={(name, abilities, extraArgs) =>
@@ -53,6 +53,7 @@ export interface ModelItemProps {
name: string, name: string,
abilities: string[], abilities: string[],
extraArgs: ExtraArg[], extraArgs: ExtraArg[],
contextLength?: number | null,
) => Promise<void>; ) => Promise<void>;
onTest: ( onTest: (
name: string, name: string,
@@ -89,6 +90,7 @@ export interface ProviderCardProps {
name: string, name: string,
abilities: string[], abilities: string[],
extraArgs: ExtraArg[], extraArgs: ExtraArg[],
contextLength?: number | null,
) => Promise<void>; ) => Promise<void>;
onScanModels: (modelType?: ModelType) => Promise<ScanModelsResult>; onScanModels: (modelType?: ModelType) => Promise<ScanModelsResult>;
onAddScannedModels: ( onAddScannedModels: (
@@ -103,6 +105,7 @@ export interface ProviderCardProps {
name: string, name: string,
abilities: string[], abilities: string[],
extraArgs: ExtraArg[], extraArgs: ExtraArg[],
contextLength?: number | null,
) => Promise<void>; ) => Promise<void>;
onOpenDeleteConfirm: (modelId: string) => void; onOpenDeleteConfirm: (modelId: string) => void;
onCloseDeleteConfirm: () => void; onCloseDeleteConfirm: () => void;
+1
View File
@@ -96,6 +96,7 @@ export interface LLMModel {
provider_uuid: string; provider_uuid: string;
provider?: ModelProvider; provider?: ModelProvider;
abilities?: string[]; abilities?: string[];
context_length?: number | null;
extra_args?: object; extra_args?: object;
} }
+3
View File
@@ -201,6 +201,9 @@ const enUS = {
selectModelAbilities: 'Select model abilities', selectModelAbilities: 'Select model abilities',
visionAbility: 'Vision Ability', visionAbility: 'Vision Ability',
functionCallAbility: 'Function Call', functionCallAbility: 'Function Call',
contextLength: 'Context Window',
contextLengthPlaceholder: 'Unknown',
contextLengthInvalid: 'Context window must be a positive integer',
extraParameters: 'Extra Parameters', extraParameters: 'Extra Parameters',
addParameter: 'Add Parameter', addParameter: 'Add Parameter',
keyName: 'Key Name', keyName: 'Key Name',
+3
View File
@@ -206,6 +206,9 @@ const esES = {
selectModelAbilities: 'Seleccionar capacidades del modelo', selectModelAbilities: 'Seleccionar capacidades del modelo',
visionAbility: 'Capacidad de visión', visionAbility: 'Capacidad de visión',
functionCallAbility: 'Llamada a funciones', functionCallAbility: 'Llamada a funciones',
contextLength: 'Ventana de contexto',
contextLengthPlaceholder: 'Desconocido',
contextLengthInvalid: 'La ventana de contexto debe ser un entero positivo',
extraParameters: 'Parámetros adicionales', extraParameters: 'Parámetros adicionales',
addParameter: 'Añadir parámetro', addParameter: 'Añadir parámetro',
keyName: 'Nombre de la clave', keyName: 'Nombre de la clave',
+4
View File
@@ -204,6 +204,10 @@ const jaJP = {
selectModelAbilities: 'モデル機能を選択', selectModelAbilities: 'モデル機能を選択',
visionAbility: '視覚機能', visionAbility: '視覚機能',
functionCallAbility: '関数呼び出し', functionCallAbility: '関数呼び出し',
contextLength: 'コンテキストウィンドウ',
contextLengthPlaceholder: '不明',
contextLengthInvalid:
'コンテキストウィンドウは正の整数である必要があります',
extraParameters: '追加パラメータ', extraParameters: '追加パラメータ',
addParameter: 'パラメータを追加', addParameter: 'パラメータを追加',
keyName: 'キー名', keyName: 'キー名',
+4
View File
@@ -203,6 +203,10 @@ const ruRU = {
selectModelAbilities: 'Выберите возможности модели', selectModelAbilities: 'Выберите возможности модели',
visionAbility: 'Распознавание изображений', visionAbility: 'Распознавание изображений',
functionCallAbility: 'Вызов функций', functionCallAbility: 'Вызов функций',
contextLength: 'Контекстное окно',
contextLengthPlaceholder: 'Неизвестно',
contextLengthInvalid:
'Контекстное окно должно быть положительным целым числом',
extraParameters: 'Дополнительные параметры', extraParameters: 'Дополнительные параметры',
addParameter: 'Добавить параметр', addParameter: 'Добавить параметр',
keyName: 'Имя ключа', keyName: 'Имя ключа',
+3
View File
@@ -199,6 +199,9 @@ const thTH = {
selectModelAbilities: 'เลือกความสามารถของโมเดล', selectModelAbilities: 'เลือกความสามารถของโมเดล',
visionAbility: 'ความสามารถด้านภาพ', visionAbility: 'ความสามารถด้านภาพ',
functionCallAbility: 'การเรียกฟังก์ชัน', functionCallAbility: 'การเรียกฟังก์ชัน',
contextLength: 'หน้าต่างบริบท',
contextLengthPlaceholder: 'ไม่ทราบ',
contextLengthInvalid: 'หน้าต่างบริบทต้องเป็นจำนวนเต็มบวก',
extraParameters: 'พารามิเตอร์เพิ่มเติม', extraParameters: 'พารามิเตอร์เพิ่มเติม',
addParameter: 'เพิ่มพารามิเตอร์', addParameter: 'เพิ่มพารามิเตอร์',
keyName: 'ชื่อคีย์', keyName: 'ชื่อคีย์',
+3
View File
@@ -203,6 +203,9 @@ const viVN = {
selectModelAbilities: 'Chọn khả năng mô hình', selectModelAbilities: 'Chọn khả năng mô hình',
visionAbility: 'Khả năng thị giác', visionAbility: 'Khả năng thị giác',
functionCallAbility: 'Gọi hàm', functionCallAbility: 'Gọi hàm',
contextLength: 'Cửa sổ ngữ cảnh',
contextLengthPlaceholder: 'Không rõ',
contextLengthInvalid: 'Cửa sổ ngữ cảnh phải là số nguyên dương',
extraParameters: 'Tham số bổ sung', extraParameters: 'Tham số bổ sung',
addParameter: 'Thêm tham số', addParameter: 'Thêm tham số',
keyName: 'Tên khóa', keyName: 'Tên khóa',
+3
View File
@@ -193,6 +193,9 @@ const zhHans = {
selectModelAbilities: '选择模型能力', selectModelAbilities: '选择模型能力',
visionAbility: '视觉能力', visionAbility: '视觉能力',
functionCallAbility: '函数调用', functionCallAbility: '函数调用',
contextLength: '上下文窗口',
contextLengthPlaceholder: '未知',
contextLengthInvalid: '上下文窗口必须是正整数',
extraParameters: '额外参数', extraParameters: '额外参数',
addParameter: '添加参数', addParameter: '添加参数',
keyName: '键名', keyName: '键名',
+3
View File
@@ -193,6 +193,9 @@ const zhHant = {
selectModelAbilities: '選擇模型能力', selectModelAbilities: '選擇模型能力',
visionAbility: '視覺能力', visionAbility: '視覺能力',
functionCallAbility: '函數呼叫', functionCallAbility: '函數呼叫',
contextLength: '上下文視窗',
contextLengthPlaceholder: '未知',
contextLengthInvalid: '上下文視窗必須是正整數',
extraParameters: '額外參數', extraParameters: '額外參數',
addParameter: '新增參數', addParameter: '新增參數',
keyName: '鍵名', keyName: '鍵名',