feat(docs): enhance LLM model configs API documentation with schema updates

- Add `model_type` field to database schema with supported values (text, vision, stt, tts, multimodal, embedding, reranking, other)
- Add `name` field as table-level column for indexing and filtering
- Add reserved JSONB fields for future extensibility
- Update API request/response schemas to include `model_type` and `name` fields
- Add new `LLMModelConfigWithSource` schema for detailed configuration responses
- Update usage examples to reflect new required fields
- Improve database constraints and indexing documentation
This commit is contained in:
YueGuobin committed 2026-03-03 17:54:30 +08:00
1 parent b968920a75
commit d42fff49a5
6 files changed
+272 -58

No files matched your search

@@ -97,6 +97,8 @@ async def get_user_own_llm_model_configs(
return [
schemas.LLMModelConfigResponse(
config_id=config.config_id,
name=config.name,
model_type=config.model_type,
config=config.config,
user_id=config.user_id,
group_id=config.group_id,
@@ -139,15 +141,20 @@ async def create_user_llm_model_config(
raise ControllerNotFoundError(f"User '{user_id}' not found")
try:
config_data = config_create.model_dump(exclude={"is_default"})
# Extract config fields (excluding table-level fields)
config_fields = config_create.model_dump(exclude={"name", "model_type", "is_default"})
new_config = await llm_repo.create_user_config(
user_id,
config_data,
config_create.name,
config_create.model_type,
config_fields,
is_default=config_create.is_default
)
return schemas.LLMModelConfigResponse(
config_id=new_config.config_id,
name=new_config.name,
model_type=new_config.model_type,
config=new_config.config,
user_id=new_config.user_id,
group_id=new_config.group_id,
@@ -206,6 +213,8 @@ async def update_user_llm_model_config(
return schemas.LLMModelConfigResponse(
config_id=updated_config.config_id,
name=updated_config.name,
model_type=updated_config.model_type,
config=updated_config.config,
user_id=updated_config.user_id,
group_id=updated_config.group_id,
@@ -293,6 +302,8 @@ async def set_user_default_llm_model_config(
config = await llm_repo.get_user_config(config_id)
return schemas.LLMModelConfigResponse(
config_id=config.config_id,
name=config.name,
model_type=config.model_type,
config=config.config,
user_id=config.user_id,
group_id=config.group_id,
@@ -335,6 +346,8 @@ async def get_group_llm_model_configs(
return [
schemas.LLMModelConfigResponse(
config_id=config.config_id,
name=config.name,
model_type=config.model_type,
config=config.config,
user_id=config.user_id,
group_id=config.group_id,
@@ -377,15 +390,20 @@ async def create_group_llm_model_config(
raise ControllerNotFoundError(f"User group '{group_id}' not found")
try:
config_data = config_create.model_dump(exclude={"is_default"})
# Extract config fields (excluding table-level fields)
config_fields = config_create.model_dump(exclude={"name", "model_type", "is_default"})
new_config = await llm_repo.create_group_config(
group_id,
config_data,
config_create.name,
config_create.model_type,
config_fields,
is_default=config_create.is_default
)
return schemas.LLMModelConfigResponse(
config_id=new_config.config_id,
name=new_config.name,
model_type=new_config.model_type,
config=new_config.config,
user_id=new_config.user_id,
group_id=new_config.group_id,
@@ -444,6 +462,8 @@ async def update_group_llm_model_config(
return schemas.LLMModelConfigResponse(
config_id=updated_config.config_id,
name=updated_config.name,
model_type=updated_config.model_type,
config=updated_config.config,
user_id=updated_config.user_id,
group_id=updated_config.group_id,
@@ -531,6 +551,8 @@ async def set_group_default_llm_model_config(
config = await llm_repo.get_group_config(config_id)
return schemas.LLMModelConfigResponse(
config_id=config.config_id,
name=config.name,
model_type=config.model_type,
config=config.config,
user_id=config.user_id,
group_id=config.group_id,
+16 -4
View File
@@ -15,7 +15,7 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from sqlalchemy import Column, Boolean, ForeignKey, CheckConstraint, UniqueConstraint, Index, Integer
from sqlalchemy import Column, Boolean, ForeignKey, CheckConstraint, UniqueConstraint, Index, Integer, String
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import relationship
@@ -35,12 +35,19 @@ class LLMModelConfig(BaseTable):
__tablename__ = "llm_model_configs"
config_id = Column(GUID, primary_key=True, default=generate_uuid)
config = Column(JSONB, nullable=False) # All config fields including name, provider, etc.
name = Column(String(100), nullable=False) # Configuration name (table-level for indexing)
model_type = Column(String(50), nullable=False) # Model type: text, vision, stt, tts, multimodal, etc.
config = Column(JSONB, nullable=False) # Config fields: provider, base_url, model, temperature, api_key, etc.
user_id = Column(GUID, ForeignKey("users.user_id", ondelete="CASCADE"), nullable=True)
group_id = Column(GUID, ForeignKey("user_groups.user_group_id", ondelete="CASCADE"), nullable=True)
is_default = Column(Boolean, default=False, nullable=False)
version = Column(Integer, default=0, nullable=False) # Optimistic locking version
# Reserved fields for future use (currently unused in code)
reserved_jsonb_1 = Column(JSONB, nullable=True)
reserved_jsonb_2 = Column(JSONB, nullable=True)
reserved_jsonb_3 = Column(JSONB, nullable=True)
# Relationships
user = relationship("User", backref="llm_model_configs")
group = relationship("UserGroup", backref="llm_model_configs")
@@ -53,6 +60,11 @@ class LLMModelConfig(BaseTable):
"(user_id IS NULL AND group_id IS NOT NULL)",
name="single_owner_check"
),
# Validate model_type values
CheckConstraint(
"model_type IN ('text', 'vision', 'stt', 'tts', 'multimodal', 'embedding', 'reranking', 'other')",
name="valid_model_type_check"
),
# Each user can have at most one default config
UniqueConstraint("user_id", "is_default", name="unique_user_default",
deferrable=True, initially="deferred",
@@ -64,10 +76,10 @@ class LLMModelConfig(BaseTable):
# Indexes for efficient queries
Index("idx_llm_model_configs_user_id", "user_id"),
Index("idx_llm_model_configs_group_id", "group_id"),
Index("idx_llm_model_configs_model_type", "model_type"),
Index("idx_llm_model_configs_config", "config", postgresql_using="gin"),
)
def __repr__(self):
config_name = self.config.get("name", "unnamed") if self.config else "unnamed"
owner = f"user_{self.user_id}" if self.user_id else f"group_{self.group_id}"
return f"<LLMModelConfig {config_name} for {owner} (default={self.is_default}, version={self.version})>"
return f"<LLMModelConfig {self.name} ({self.model_type}) for {owner} (default={self.is_default}, version={self.version})>"
+68 -26
View File
@@ -68,6 +68,8 @@ class LLMModelConfigsRepository(BaseRepository):
async def create_user_config(
self,
user_id: UUID,
name: str,
model_type: str,
config_data: Dict[str, Any],
is_default: bool = False
) -> models.LLMModelConfig:
@@ -83,6 +85,8 @@ class LLMModelConfigsRepository(BaseRepository):
raise
db_config = models.LLMModelConfig(
name=name,
model_type=model_type,
config=config_to_store,
user_id=user_id,
is_default=is_default
@@ -130,21 +134,36 @@ class LLMModelConfigsRepository(BaseRepository):
# Encrypt API key if present in updates
from gns3server.utils.encryption import encrypt
updates_copy = updates.copy()
if "api_key" in updates_copy and updates_copy["api_key"]:
try:
updates_copy["api_key"] = encrypt(updates_copy["api_key"])
except Exception as e:
log.error(f"Failed to encrypt API key: {e}")
raise
# Update config JSONB fields
# Table-level fields
if "name" in updates and updates["name"] is not None:
db_config.name = updates["name"]
if "model_type" in updates and updates["model_type"] is not None:
db_config.model_type = updates["model_type"]
if "is_default" in updates and updates["is_default"] is not None:
db_config.is_default = updates["is_default"]
# Config JSONB fields
config_fields = ["provider", "base_url", "model", "temperature", "api_key", "max_tokens"]
current_config = db_config.config.copy()
for key, value in updates_copy.items():
if key == "is_default":
db_config.is_default = value
elif value is not None:
current_config[key] = value
for field in config_fields:
if field in updates and updates[field] is not None:
if field == "api_key":
# Encrypt API key
try:
current_config[field] = encrypt(updates[field])
except Exception as e:
log.error(f"Failed to encrypt API key: {e}")
raise
else:
current_config[field] = updates[field]
# Handle extra config fields
for key, value in updates.items():
if key not in ["name", "model_type", "is_default", "expected_version"] + config_fields:
if value is not None:
current_config[key] = value
db_config.config = current_config
# Increment version for optimistic locking
@@ -225,6 +244,8 @@ class LLMModelConfigsRepository(BaseRepository):
async def create_group_config(
self,
group_id: UUID,
name: str,
model_type: str,
config_data: Dict[str, Any],
is_default: bool = False
) -> models.LLMModelConfig:
@@ -240,6 +261,8 @@ class LLMModelConfigsRepository(BaseRepository):
raise
db_config = models.LLMModelConfig(
name=name,
model_type=model_type,
config=config_to_store,
group_id=group_id,
is_default=is_default
@@ -287,21 +310,36 @@ class LLMModelConfigsRepository(BaseRepository):
# Encrypt API key if present in updates
from gns3server.utils.encryption import encrypt
updates_copy = updates.copy()
if "api_key" in updates_copy and updates_copy["api_key"]:
try:
updates_copy["api_key"] = encrypt(updates_copy["api_key"])
except Exception as e:
log.error(f"Failed to encrypt API key: {e}")
raise
# Update config JSONB fields
# Table-level fields
if "name" in updates and updates["name"] is not None:
db_config.name = updates["name"]
if "model_type" in updates and updates["model_type"] is not None:
db_config.model_type = updates["model_type"]
if "is_default" in updates and updates["is_default"] is not None:
db_config.is_default = updates["is_default"]
# Config JSONB fields
config_fields = ["provider", "base_url", "model", "temperature", "api_key", "max_tokens"]
current_config = db_config.config.copy()
for key, value in updates_copy.items():
if key == "is_default":
db_config.is_default = value
elif value is not None:
current_config[key] = value
for field in config_fields:
if field in updates and updates[field] is not None:
if field == "api_key":
# Encrypt API key
try:
current_config[field] = encrypt(updates[field])
except Exception as e:
log.error(f"Failed to encrypt API key: {e}")
raise
else:
current_config[field] = updates[field]
# Handle extra config fields
for key, value in updates.items():
if key not in ["name", "model_type", "is_default", "expected_version"] + config_fields:
if value is not None:
current_config[key] = value
db_config.config = current_config
# Increment version for optimistic locking
@@ -392,6 +430,8 @@ class LLMModelConfigsRepository(BaseRepository):
configs_with_source.append({
"config_id": config.config_id,
"name": config.name,
"model_type": config.model_type,
"source": "user",
"group_name": None,
"is_default": config.is_default,
@@ -416,6 +456,8 @@ class LLMModelConfigsRepository(BaseRepository):
configs_with_source.append({
"config_id": config.config_id,
"name": config.name,
"model_type": config.model_type,
"source": "group",
"group_name": group_names_map[group_id],
"is_default": config.is_default,
@@ -33,6 +33,8 @@ def upgrade() -> None:
op.create_table(
'llm_model_configs',
sa.Column('config_id', postgresql.UUID(as_uuid=True), primary_key=True),
sa.Column('name', sa.String(100), nullable=False),
sa.Column('model_type', sa.String(50), nullable=False),
sa.Column('config', postgresql.JSONB(), nullable=False),
sa.Column('user_id', postgresql.UUID(as_uuid=True), sa.ForeignKey('users.user_id', ondelete='CASCADE'), nullable=True),
sa.Column('group_id', postgresql.UUID(as_uuid=True), sa.ForeignKey('user_groups.user_group_id', ondelete='CASCADE'), nullable=True),
@@ -40,11 +42,18 @@ def upgrade() -> None:
sa.Column('version', sa.Integer(), nullable=False, server_default='0'),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.Column('updated_at', sa.DateTime(), nullable=True),
sa.Column('reserved_jsonb_1', postgresql.JSONB(), nullable=True, comment='Reserved field for future use'),
sa.Column('reserved_jsonb_2', postgresql.JSONB(), nullable=True, comment='Reserved field for future use'),
sa.Column('reserved_jsonb_3', postgresql.JSONB(), nullable=True, comment='Reserved field for future use'),
sa.CheckConstraint(
"(user_id IS NOT NULL AND group_id IS NULL) OR "
"(user_id IS NULL AND group_id IS NOT NULL)",
name='single_owner_check'
),
sa.CheckConstraint(
"model_type IN ('text', 'vision', 'stt', 'tts', 'multimodal', 'embedding', 'reranking', 'other')",
name='valid_model_type_check'
),
sa.UniqueConstraint(
'user_id', 'is_default',
name='unique_user_default',
@@ -62,12 +71,14 @@ def upgrade() -> None:
# Create indexes for efficient queries
op.create_index('idx_llm_model_configs_user_id', 'llm_model_configs', ['user_id'])
op.create_index('idx_llm_model_configs_group_id', 'llm_model_configs', ['group_id'])
op.create_index('idx_llm_model_configs_model_type', 'llm_model_configs', ['model_type'])
op.create_index('idx_llm_model_configs_config', 'llm_model_configs', ['config'], postgresql_using='gin')
def downgrade() -> None:
# Drop indexes
op.drop_index('idx_llm_model_configs_config', table_name='llm_model_configs')
op.drop_index('idx_llm_model_configs_model_type', table_name='llm_model_configs')
op.drop_index('idx_llm_model_configs_group_id', table_name='llm_model_configs')
op.drop_index('idx_llm_model_configs_user_id', table_name='llm_model_configs')
@@ -14,21 +14,24 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from typing import Optional, Dict, Any
from typing import Optional, List, Literal
from pydantic import BaseModel, Field, ConfigDict
from uuid import UUID
from .base import DateTimeModelMixin
# Valid model types
ModelType = Literal['text', 'vision', 'stt', 'tts', 'multimodal', 'embedding', 'reranking', 'other']
# Core model config schema (stored in config JSONB field)
class LLMModelConfigData(BaseModel):
"""
LLM model configuration data.
All fields are stored in the config JSONB column.
Stored in the config JSONB column (provider, base_url, model, etc.).
"""
name: str = Field(..., min_length=1, max_length=100, description="Configuration name")
provider: str = Field(..., description="LLM provider (e.g., 'openai', 'anthropic', 'ollama')")
base_url: str = Field(..., description="API base URL")
model: str = Field(..., description="Model name")
@@ -41,26 +44,42 @@ class LLMModelConfigData(BaseModel):
# Request schemas
class LLMModelConfigCreate(LLMModelConfigData):
class LLMModelConfigCreate(BaseModel):
"""Request to create a new LLM model configuration."""
name: str = Field(..., min_length=1, max_length=100, description="Configuration name")
model_type: ModelType = Field(..., description="Model type")
is_default: Optional[bool] = Field(False, description="Set as default configuration")
# Config fields
provider: str = Field(..., description="LLM provider")
base_url: str = Field(..., description="API base URL")
model: str = Field(..., description="Model name")
temperature: float = Field(default=0.7, ge=0.0, le=2.0)
api_key: Optional[str] = None
max_tokens: Optional[int] = Field(None, gt=0)
# Allow extra config fields
model_config = ConfigDict(extra="allow")
class LLMModelConfigUpdate(BaseModel):
"""Request to update an existing LLM model configuration."""
# Table-level fields
name: Optional[str] = Field(None, min_length=1, max_length=100)
model_type: Optional[ModelType] = None
is_default: Optional[bool] = None
expected_version: Optional[int] = Field(None, description="Expected version for optimistic locking")
# Config fields
provider: Optional[str] = None
base_url: Optional[str] = None
model: Optional[str] = None
temperature: Optional[float] = Field(None, ge=0.0, le=2.0)
api_key: Optional[str] = None
max_tokens: Optional[int] = Field(None, gt=0)
is_default: Optional[bool] = None
expected_version: Optional[int] = Field(None, description="Expected version for optimistic locking")
# Allow extra fields for extensibility
# Allow extra config fields
model_config = ConfigDict(extra="allow")
@@ -69,6 +88,8 @@ class LLMModelConfigResponse(DateTimeModelMixin):
"""LLM model configuration response."""
config_id: UUID
name: str
model_type: ModelType
config: LLMModelConfigData
user_id: Optional[UUID] = None
group_id: Optional[UUID] = None
@@ -78,22 +99,25 @@ class LLMModelConfigResponse(DateTimeModelMixin):
model_config = ConfigDict(from_attributes=True)
class LLMModelConfigListResponse(BaseModel):
"""Response containing list of LLM model configurations."""
configs: list[LLMModelConfigData]
default_config_id: Optional[UUID] = None
total: int
# Inheritance response (user configs + inherited group configs)
class LLMModelConfigWithSource(LLMModelConfigData):
"""Model configuration with source information."""
class LLMModelConfigWithSource(BaseModel):
"""Model configuration with source information (for inheritance)."""
config_id: UUID
name: str
model_type: ModelType
source: str = Field(..., description="Source: 'user' or 'group'")
group_name: Optional[str] = Field(None, description="Group name if source is 'group'")
is_default: bool
# Config fields
provider: str
base_url: str
model: str
temperature: float
api_key: Optional[str] = None
max_tokens: Optional[int] = None
# Allow extra config fields
model_config = ConfigDict(extra="allow")
class LLMModelConfigInheritedResponse(BaseModel):