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 2026-03-03 17:54:30 +08:00
parent b968920a75
commit d42fff49a5
6 changed files with 272 additions and 58 deletions

View File

@ -36,14 +36,31 @@ User's own config > User's group config
| Column | Type | Description | | Column | Type | Description |
|--------|------|-------------| |--------|------|-------------|
| `config_id` | UUID | Primary key | | `config_id` | UUID | Primary key |
| `config` | JSONB | Configuration data (name, provider, model, etc.) | | `name` | VARCHAR(100) | Configuration name (table-level for indexing) |
| `model_type` | VARCHAR(50) | Model type (table-level for filtering) |
| `config` | JSONB | Configuration data (provider, base_url, model, temperature, api_key, etc.) |
| `user_id` | UUID (nullable) | Foreign key to users table | | `user_id` | UUID (nullable) | Foreign key to users table |
| `group_id` | UUID (nullable) | Foreign key to user_groups table | | `group_id` | UUID (nullable) | Foreign key to user_groups table |
| `is_default` | BOOLEAN | Default configuration flag | | `is_default` | BOOLEAN | Default configuration flag |
| `version` | INTEGER | Optimistic locking version (starts at 0, increments on each update) | | `version` | INTEGER | Optimistic locking version (starts at 0, increments on each update) |
| `reserved_jsonb_1` | JSONB (nullable) | Reserved field for future use |
| `reserved_jsonb_2` | JSONB (nullable) | Reserved field for future use |
| `reserved_jsonb_3` | JSONB (nullable) | Reserved field for future use |
| `created_at` | TIMESTAMP | Creation timestamp | | `created_at` | TIMESTAMP | Creation timestamp |
| `updated_at` | TIMESTAMP | Last update timestamp | | `updated_at` | TIMESTAMP | Last update timestamp |
### Model Types
The `model_type` field accepts the following values:
- `text` - Text generation models
- `vision` - Vision/image understanding models
- `stt` - Speech-to-Text models
- `tts` - Text-to-Speech models
- `multimodal` - Multimodal models supporting multiple input types
- `embedding` - Text embedding models
- `reranking` - Reranking models
- `other` - Other model types
### Constraints ### Constraints
- Each config belongs to **either** a user **or** a group (not both) - Each config belongs to **either** a user **or** a group (not both)
@ -87,6 +104,7 @@ User's own config > User's group config
| Field | Type | Description | | Field | Type | Description |
|-------|------|-------------| |-------|------|-------------|
| `name` | string | Configuration name (1-100 chars) | | `name` | string | Configuration name (1-100 chars) |
| `model_type` | string | Model type (text, vision, stt, tts, multimodal, embedding, reranking, other) |
| `provider` | string | LLM provider (e.g., "openai", "anthropic", "ollama") | | `provider` | string | LLM provider (e.g., "openai", "anthropic", "ollama") |
| `base_url` | string | API base URL | | `base_url` | string | API base URL |
| `model` | string | Model name | | `model` | string | Model name |
@ -107,6 +125,7 @@ User's own config > User's group config
| Field | Type | Description | | Field | Type | Description |
|-------|------|-------------| |-------|------|-------------|
| `name` | string (optional) | Configuration name | | `name` | string (optional) | Configuration name |
| `model_type` | string (optional) | Model type |
| `provider` | string (optional) | LLM provider | | `provider` | string (optional) | LLM provider |
| `base_url` | string (optional) | API base URL | | `base_url` | string (optional) | API base URL |
| `model` | string (optional) | Model name | | `model` | string (optional) | Model name |
@ -123,7 +142,9 @@ User's own config > User's group config
| Field | Type | Description | | Field | Type | Description |
|-------|------|-------------| |-------|------|-------------|
| `config_id` | UUID | Configuration ID | | `config_id` | UUID | Configuration ID |
| `config` | LLMModelConfigData | Configuration data | | `name` | string | Configuration name |
| `model_type` | string | Model type |
| `config` | LLMModelConfigData | Configuration data (provider, base_url, model, temperature, etc.) |
| `user_id` | UUID (nullable) | Owner user ID | | `user_id` | UUID (nullable) | Owner user ID |
| `group_id` | UUID (nullable) | Owner group ID | | `group_id` | UUID (nullable) | Owner group ID |
| `is_default` | boolean | Default flag | | `is_default` | boolean | Default flag |
@ -139,6 +160,23 @@ User's own config > User's group config
| `default_config` | LLMModelConfigWithSource (nullable) | Default configuration | | `default_config` | LLMModelConfigWithSource (nullable) | Default configuration |
| `total` | integer | Total count | | `total` | integer | Total count |
### LLMModelConfigWithSource
| Field | Type | Description |
|-------|------|-------------|
| `config_id` | UUID | Configuration ID |
| `name` | string | Configuration name |
| `model_type` | string | Model type |
| `source` | string | Source: "user" or "group" |
| `group_name` | string (nullable) | Group name if source is "group" |
| `is_default` | boolean | Default flag |
| `provider` | string | LLM provider |
| `base_url` | string | API base URL |
| `model` | string | Model name |
| `temperature` | float | Temperature |
| `api_key` | string (nullable) | API key |
| `max_tokens` | integer (nullable) | Max tokens |
--- ---
## Usage Examples ## Usage Examples
@ -151,6 +189,7 @@ curl -X POST http://localhost:3080/v3/access/users/{user_id}/llm-model-configs \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d '{ -d '{
"name": "GPT-4", "name": "GPT-4",
"model_type": "text",
"provider": "openai", "provider": "openai",
"base_url": "https://api.openai.com/v1", "base_url": "https://api.openai.com/v1",
"model": "gpt-4", "model": "gpt-4",
@ -164,8 +203,9 @@ curl -X POST http://localhost:3080/v3/access/users/{user_id}/llm-model-configs \
```json ```json
{ {
"config_id": "uuid-1", "config_id": "uuid-1",
"name": "GPT-4",
"model_type": "text",
"config": { "config": {
"name": "GPT-4",
"provider": "openai", "provider": "openai",
"base_url": "https://api.openai.com/v1", "base_url": "https://api.openai.com/v1",
"model": "gpt-4", "model": "gpt-4",
@ -189,6 +229,7 @@ curl -X POST http://localhost:3080/v3/access/groups/{group_id}/llm-model-configs
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d '{ -d '{
"name": "Claude-3", "name": "Claude-3",
"model_type": "text",
"provider": "anthropic", "provider": "anthropic",
"base_url": "https://api.anthropic.com", "base_url": "https://api.anthropic.com",
"model": "claude-3-opus-20240229", "model": "claude-3-opus-20240229",
@ -211,10 +252,11 @@ curl -X GET http://localhost:3080/v3/access/users/{user_id}/llm-model-configs \
"configs": [ "configs": [
{ {
"config_id": "uuid-1", "config_id": "uuid-1",
"name": "GPT-4",
"model_type": "text",
"source": "user", "source": "user",
"group_name": null, "group_name": null,
"is_default": true, "is_default": true,
"name": "GPT-4",
"provider": "openai", "provider": "openai",
"model": "gpt-4", "model": "gpt-4",
"base_url": "https://api.openai.com/v1", "base_url": "https://api.openai.com/v1",
@ -224,10 +266,12 @@ curl -X GET http://localhost:3080/v3/access/users/{user_id}/llm-model-configs \
], ],
"default_config": { "default_config": {
"config_id": "uuid-1", "config_id": "uuid-1",
"name": "GPT-4",
"model_type": "text",
"source": "user", "source": "user",
"group_name": null, "group_name": null,
"is_default": true, "is_default": true,
"name": "GPT-4", "provider": "openai",
... ...
}, },
"total": 1 "total": 1
@ -240,10 +284,11 @@ curl -X GET http://localhost:3080/v3/access/users/{user_id}/llm-model-configs \
"configs": [ "configs": [
{ {
"config_id": "uuid-2", "config_id": "uuid-2",
"name": "Claude-3",
"model_type": "text",
"source": "group", "source": "group",
"group_name": "Developers", "group_name": "Developers",
"is_default": true, "is_default": true,
"name": "Claude-3",
"provider": "anthropic", "provider": "anthropic",
"model": "claude-3-opus-20240229", "model": "claude-3-opus-20240229",
"base_url": "https://api.anthropic.com", "base_url": "https://api.anthropic.com",
@ -253,6 +298,8 @@ curl -X GET http://localhost:3080/v3/access/users/{user_id}/llm-model-configs \
], ],
"default_config": { "default_config": {
"config_id": "uuid-2", "config_id": "uuid-2",
"name": "Claude-3",
"model_type": "text",
"source": "group", "source": "group",
"group_name": "Developers", "group_name": "Developers",
"is_default": true, "is_default": true,
@ -441,3 +488,59 @@ The old user settings API (`/v3/access/users/{user_id}/profiles`) stored configu
- **Optimistic locking**: New `version` field and `expected_version` parameter - **Optimistic locking**: New `version` field and `expected_version` parameter
- **Dedicated table**: Better query performance and data integrity - **Dedicated table**: Better query performance and data integrity
- **Transparent encryption**: API keys auto-encrypted/decrypted by the API - **Transparent encryption**: API keys auto-encrypted/decrypted by the API
- **Model type support**: New `model_type` field for categorizing models (text, vision, stt, tts, multimodal, embedding, reranking, other)
- **Table-level indexing**: `name` and `model_type` stored as table columns for efficient filtering and querying
---
## Model Type Filtering
The `model_type` table column enables efficient filtering and querying of configurations by model type:
### Common Use Cases
1. **Filter by model type**: Retrieve only text generation models for chat features
2. **Multi-model applications**: Select appropriate model based on task type (text vs vision vs embedding)
3. **Model type analytics**: Query and analyze usage patterns by model type
4. **Type-specific defaults**: Set different default models for different model types
### Example: Filter text models (client-side)
```python
# After fetching configs, filter by model_type
configs = get_user_configs(user_id)
text_models = [c for c in configs if c["model_type"] == "text"]
vision_models = [c for c in configs if c["model_type"] == "vision"]
```
### Database Index
The `model_type` column is indexed for efficient queries:
```sql
CREATE INDEX idx_llm_model_configs_model_type ON llm_model_configs(model_type);
```
This enables fast lookups when filtering by model type, even with large datasets.
---
## Reserved Fields
The `llm_model_configs` table includes three reserved JSONB fields for future extensibility:
| Field | Type | Description |
|-------|------|-------------|
| `reserved_jsonb_1` | JSONB (nullable) | Reserved for future use |
| `reserved_jsonb_2` | JSONB (nullable) | Reserved for future use |
| `reserved_jsonb_3` | JSONB (nullable) | Reserved for future use |
**Purpose:** These fields are reserved for future feature development without requiring schema changes. They are currently unused in the API code but are available in the database layer for future enhancements.
**Use Cases:** Future features might use these fields for:
- Advanced configuration options
- Metadata storage
- Feature flags
- Extension data
- Caching computed values
**Note:** These fields are not exposed in the current API schemas and are reserved for internal use.

View File

@ -97,6 +97,8 @@ async def get_user_own_llm_model_configs(
return [ return [
schemas.LLMModelConfigResponse( schemas.LLMModelConfigResponse(
config_id=config.config_id, config_id=config.config_id,
name=config.name,
model_type=config.model_type,
config=config.config, config=config.config,
user_id=config.user_id, user_id=config.user_id,
group_id=config.group_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") raise ControllerNotFoundError(f"User '{user_id}' not found")
try: 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( new_config = await llm_repo.create_user_config(
user_id, user_id,
config_data, config_create.name,
config_create.model_type,
config_fields,
is_default=config_create.is_default is_default=config_create.is_default
) )
return schemas.LLMModelConfigResponse( return schemas.LLMModelConfigResponse(
config_id=new_config.config_id, config_id=new_config.config_id,
name=new_config.name,
model_type=new_config.model_type,
config=new_config.config, config=new_config.config,
user_id=new_config.user_id, user_id=new_config.user_id,
group_id=new_config.group_id, group_id=new_config.group_id,
@ -206,6 +213,8 @@ async def update_user_llm_model_config(
return schemas.LLMModelConfigResponse( return schemas.LLMModelConfigResponse(
config_id=updated_config.config_id, config_id=updated_config.config_id,
name=updated_config.name,
model_type=updated_config.model_type,
config=updated_config.config, config=updated_config.config,
user_id=updated_config.user_id, user_id=updated_config.user_id,
group_id=updated_config.group_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) config = await llm_repo.get_user_config(config_id)
return schemas.LLMModelConfigResponse( return schemas.LLMModelConfigResponse(
config_id=config.config_id, config_id=config.config_id,
name=config.name,
model_type=config.model_type,
config=config.config, config=config.config,
user_id=config.user_id, user_id=config.user_id,
group_id=config.group_id, group_id=config.group_id,
@ -335,6 +346,8 @@ async def get_group_llm_model_configs(
return [ return [
schemas.LLMModelConfigResponse( schemas.LLMModelConfigResponse(
config_id=config.config_id, config_id=config.config_id,
name=config.name,
model_type=config.model_type,
config=config.config, config=config.config,
user_id=config.user_id, user_id=config.user_id,
group_id=config.group_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") raise ControllerNotFoundError(f"User group '{group_id}' not found")
try: 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( new_config = await llm_repo.create_group_config(
group_id, group_id,
config_data, config_create.name,
config_create.model_type,
config_fields,
is_default=config_create.is_default is_default=config_create.is_default
) )
return schemas.LLMModelConfigResponse( return schemas.LLMModelConfigResponse(
config_id=new_config.config_id, config_id=new_config.config_id,
name=new_config.name,
model_type=new_config.model_type,
config=new_config.config, config=new_config.config,
user_id=new_config.user_id, user_id=new_config.user_id,
group_id=new_config.group_id, group_id=new_config.group_id,
@ -444,6 +462,8 @@ async def update_group_llm_model_config(
return schemas.LLMModelConfigResponse( return schemas.LLMModelConfigResponse(
config_id=updated_config.config_id, config_id=updated_config.config_id,
name=updated_config.name,
model_type=updated_config.model_type,
config=updated_config.config, config=updated_config.config,
user_id=updated_config.user_id, user_id=updated_config.user_id,
group_id=updated_config.group_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) config = await llm_repo.get_group_config(config_id)
return schemas.LLMModelConfigResponse( return schemas.LLMModelConfigResponse(
config_id=config.config_id, config_id=config.config_id,
name=config.name,
model_type=config.model_type,
config=config.config, config=config.config,
user_id=config.user_id, user_id=config.user_id,
group_id=config.group_id, group_id=config.group_id,

View File

@ -15,7 +15,7 @@
# You should have received a copy of the GNU General Public License # You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>. # 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.dialects.postgresql import JSONB
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship
@ -35,12 +35,19 @@ class LLMModelConfig(BaseTable):
__tablename__ = "llm_model_configs" __tablename__ = "llm_model_configs"
config_id = Column(GUID, primary_key=True, default=generate_uuid) 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) 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) group_id = Column(GUID, ForeignKey("user_groups.user_group_id", ondelete="CASCADE"), nullable=True)
is_default = Column(Boolean, default=False, nullable=False) is_default = Column(Boolean, default=False, nullable=False)
version = Column(Integer, default=0, nullable=False) # Optimistic locking version 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 # Relationships
user = relationship("User", backref="llm_model_configs") user = relationship("User", backref="llm_model_configs")
group = relationship("UserGroup", 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)", "(user_id IS NULL AND group_id IS NOT NULL)",
name="single_owner_check" 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 # Each user can have at most one default config
UniqueConstraint("user_id", "is_default", name="unique_user_default", UniqueConstraint("user_id", "is_default", name="unique_user_default",
deferrable=True, initially="deferred", deferrable=True, initially="deferred",
@ -64,10 +76,10 @@ class LLMModelConfig(BaseTable):
# Indexes for efficient queries # Indexes for efficient queries
Index("idx_llm_model_configs_user_id", "user_id"), Index("idx_llm_model_configs_user_id", "user_id"),
Index("idx_llm_model_configs_group_id", "group_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"), Index("idx_llm_model_configs_config", "config", postgresql_using="gin"),
) )
def __repr__(self): 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}" 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})>"

View File

@ -68,6 +68,8 @@ class LLMModelConfigsRepository(BaseRepository):
async def create_user_config( async def create_user_config(
self, self,
user_id: UUID, user_id: UUID,
name: str,
model_type: str,
config_data: Dict[str, Any], config_data: Dict[str, Any],
is_default: bool = False is_default: bool = False
) -> models.LLMModelConfig: ) -> models.LLMModelConfig:
@ -83,6 +85,8 @@ class LLMModelConfigsRepository(BaseRepository):
raise raise
db_config = models.LLMModelConfig( db_config = models.LLMModelConfig(
name=name,
model_type=model_type,
config=config_to_store, config=config_to_store,
user_id=user_id, user_id=user_id,
is_default=is_default is_default=is_default
@ -130,21 +134,36 @@ class LLMModelConfigsRepository(BaseRepository):
# Encrypt API key if present in updates # Encrypt API key if present in updates
from gns3server.utils.encryption import encrypt 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() current_config = db_config.config.copy()
for key, value in updates_copy.items():
if key == "is_default": for field in config_fields:
db_config.is_default = value if field in updates and updates[field] is not None:
elif value is not None: if field == "api_key":
current_config[key] = value # 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 db_config.config = current_config
# Increment version for optimistic locking # Increment version for optimistic locking
@ -225,6 +244,8 @@ class LLMModelConfigsRepository(BaseRepository):
async def create_group_config( async def create_group_config(
self, self,
group_id: UUID, group_id: UUID,
name: str,
model_type: str,
config_data: Dict[str, Any], config_data: Dict[str, Any],
is_default: bool = False is_default: bool = False
) -> models.LLMModelConfig: ) -> models.LLMModelConfig:
@ -240,6 +261,8 @@ class LLMModelConfigsRepository(BaseRepository):
raise raise
db_config = models.LLMModelConfig( db_config = models.LLMModelConfig(
name=name,
model_type=model_type,
config=config_to_store, config=config_to_store,
group_id=group_id, group_id=group_id,
is_default=is_default is_default=is_default
@ -287,21 +310,36 @@ class LLMModelConfigsRepository(BaseRepository):
# Encrypt API key if present in updates # Encrypt API key if present in updates
from gns3server.utils.encryption import encrypt 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() current_config = db_config.config.copy()
for key, value in updates_copy.items():
if key == "is_default": for field in config_fields:
db_config.is_default = value if field in updates and updates[field] is not None:
elif value is not None: if field == "api_key":
current_config[key] = value # 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 db_config.config = current_config
# Increment version for optimistic locking # Increment version for optimistic locking
@ -392,6 +430,8 @@ class LLMModelConfigsRepository(BaseRepository):
configs_with_source.append({ configs_with_source.append({
"config_id": config.config_id, "config_id": config.config_id,
"name": config.name,
"model_type": config.model_type,
"source": "user", "source": "user",
"group_name": None, "group_name": None,
"is_default": config.is_default, "is_default": config.is_default,
@ -416,6 +456,8 @@ class LLMModelConfigsRepository(BaseRepository):
configs_with_source.append({ configs_with_source.append({
"config_id": config.config_id, "config_id": config.config_id,
"name": config.name,
"model_type": config.model_type,
"source": "group", "source": "group",
"group_name": group_names_map[group_id], "group_name": group_names_map[group_id],
"is_default": config.is_default, "is_default": config.is_default,

View File

@ -33,6 +33,8 @@ def upgrade() -> None:
op.create_table( op.create_table(
'llm_model_configs', 'llm_model_configs',
sa.Column('config_id', postgresql.UUID(as_uuid=True), primary_key=True), 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('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('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), 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('version', sa.Integer(), nullable=False, server_default='0'),
sa.Column('created_at', sa.DateTime(), nullable=True), sa.Column('created_at', sa.DateTime(), nullable=True),
sa.Column('updated_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( sa.CheckConstraint(
"(user_id IS NOT NULL AND group_id IS NULL) OR " "(user_id IS NOT NULL AND group_id IS NULL) OR "
"(user_id IS NULL AND group_id IS NOT NULL)", "(user_id IS NULL AND group_id IS NOT NULL)",
name='single_owner_check' name='single_owner_check'
), ),
sa.CheckConstraint(
"model_type IN ('text', 'vision', 'stt', 'tts', 'multimodal', 'embedding', 'reranking', 'other')",
name='valid_model_type_check'
),
sa.UniqueConstraint( sa.UniqueConstraint(
'user_id', 'is_default', 'user_id', 'is_default',
name='unique_user_default', name='unique_user_default',
@ -62,12 +71,14 @@ def upgrade() -> None:
# Create indexes for efficient queries # 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_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_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') op.create_index('idx_llm_model_configs_config', 'llm_model_configs', ['config'], postgresql_using='gin')
def downgrade() -> None: def downgrade() -> None:
# Drop indexes # Drop indexes
op.drop_index('idx_llm_model_configs_config', table_name='llm_model_configs') 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_group_id', table_name='llm_model_configs')
op.drop_index('idx_llm_model_configs_user_id', table_name='llm_model_configs') op.drop_index('idx_llm_model_configs_user_id', table_name='llm_model_configs')

View File

@ -14,21 +14,24 @@
# You should have received a copy of the GNU General Public License # You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>. # 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 pydantic import BaseModel, Field, ConfigDict
from uuid import UUID from uuid import UUID
from .base import DateTimeModelMixin 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) # Core model config schema (stored in config JSONB field)
class LLMModelConfigData(BaseModel): class LLMModelConfigData(BaseModel):
""" """
LLM model configuration data. 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')") provider: str = Field(..., description="LLM provider (e.g., 'openai', 'anthropic', 'ollama')")
base_url: str = Field(..., description="API base URL") base_url: str = Field(..., description="API base URL")
model: str = Field(..., description="Model name") model: str = Field(..., description="Model name")
@ -41,26 +44,42 @@ class LLMModelConfigData(BaseModel):
# Request schemas # Request schemas
class LLMModelConfigCreate(LLMModelConfigData): class LLMModelConfigCreate(BaseModel):
"""Request to create a new LLM model configuration.""" """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") 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): class LLMModelConfigUpdate(BaseModel):
"""Request to update an existing LLM model configuration.""" """Request to update an existing LLM model configuration."""
# Table-level fields
name: Optional[str] = Field(None, min_length=1, max_length=100) 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 provider: Optional[str] = None
base_url: Optional[str] = None base_url: Optional[str] = None
model: Optional[str] = None model: Optional[str] = None
temperature: Optional[float] = Field(None, ge=0.0, le=2.0) temperature: Optional[float] = Field(None, ge=0.0, le=2.0)
api_key: Optional[str] = None api_key: Optional[str] = None
max_tokens: Optional[int] = Field(None, gt=0) 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") model_config = ConfigDict(extra="allow")
@ -69,6 +88,8 @@ class LLMModelConfigResponse(DateTimeModelMixin):
"""LLM model configuration response.""" """LLM model configuration response."""
config_id: UUID config_id: UUID
name: str
model_type: ModelType
config: LLMModelConfigData config: LLMModelConfigData
user_id: Optional[UUID] = None user_id: Optional[UUID] = None
group_id: Optional[UUID] = None group_id: Optional[UUID] = None
@ -78,22 +99,25 @@ class LLMModelConfigResponse(DateTimeModelMixin):
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
class LLMModelConfigListResponse(BaseModel): class LLMModelConfigWithSource(BaseModel):
"""Response containing list of LLM model configurations.""" """Model configuration with source information (for inheritance)."""
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."""
config_id: UUID config_id: UUID
name: str
model_type: ModelType
source: str = Field(..., description="Source: 'user' or 'group'") source: str = Field(..., description="Source: 'user' or 'group'")
group_name: Optional[str] = Field(None, description="Group name if source is 'group'") group_name: Optional[str] = Field(None, description="Group name if source is 'group'")
is_default: bool 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): class LLMModelConfigInheritedResponse(BaseModel):