-
Notifications
You must be signed in to change notification settings - Fork 10
Add Config Management System with Version Control for LLM Providers #435
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
e553f94
b69fb83
3e01b59
c8c3665
f13ab72
33961b0
52a6c86
1b510fd
87a3150
409576a
6349af1
6288630
d8f2733
5903751
4316763
6b460e2
07f2440
4a159ae
0483759
c248f05
de674bb
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,101 @@ | ||
| """Config management tables | ||
|
|
||
| Revision ID: ecda6b144627 | ||
| Revises: 633e69806207 | ||
| Create Date: 2025-11-19 13:16:50.954576 | ||
|
|
||
| """ | ||
| from alembic import op | ||
| import sqlalchemy as sa | ||
| import sqlmodel.sql.sqltypes | ||
| from sqlalchemy.dialects import postgresql | ||
|
|
||
| # revision identifiers, used by Alembic. | ||
| revision = "ecda6b144627" | ||
| down_revision = "633e69806207" | ||
| branch_labels = None | ||
| depends_on = None | ||
|
|
||
|
|
||
| def upgrade(): | ||
| # ### commands auto generated by Alembic - please adjust! ### | ||
| op.create_table( | ||
| "config", | ||
| sa.Column("name", sqlmodel.sql.sqltypes.AutoString(length=128), nullable=False), | ||
| sa.Column( | ||
| "description", sqlmodel.sql.sqltypes.AutoString(length=512), nullable=True | ||
| ), | ||
| sa.Column("id", sa.Uuid(), nullable=False), | ||
| sa.Column("project_id", sa.Integer(), nullable=False), | ||
| sa.Column("inserted_at", sa.DateTime(), nullable=False), | ||
| sa.Column("updated_at", sa.DateTime(), nullable=False), | ||
| sa.Column("deleted_at", sa.DateTime(), nullable=True), | ||
| sa.ForeignKeyConstraint(["project_id"], ["project.id"], ondelete="CASCADE"), | ||
| sa.PrimaryKeyConstraint("id"), | ||
| ) | ||
| op.create_index( | ||
| "idx_config_project_id_updated_at_active", | ||
| "config", | ||
| ["project_id", "updated_at"], | ||
| unique=False, | ||
| postgresql_where=sa.text("deleted_at IS NULL"), | ||
| ) | ||
| op.create_index( | ||
| "uq_config_project_id_name_active", | ||
| "config", | ||
| ["project_id", "name"], | ||
| unique=True, | ||
| postgresql_where=sa.text("deleted_at IS NULL"), | ||
| ) | ||
| op.create_table( | ||
| "config_version", | ||
| sa.Column( | ||
| "config_blob", postgresql.JSONB(astext_type=sa.Text()), nullable=False | ||
| ), | ||
| sa.Column( | ||
| "commit_message", | ||
| sqlmodel.sql.sqltypes.AutoString(length=512), | ||
| nullable=True, | ||
| ), | ||
| sa.Column("id", sa.Uuid(), nullable=False), | ||
| sa.Column("config_id", sa.Uuid(), nullable=False), | ||
| sa.Column("version", sa.Integer(), nullable=False), | ||
| sa.Column("inserted_at", sa.DateTime(), nullable=False), | ||
| sa.Column("updated_at", sa.DateTime(), nullable=False), | ||
| sa.Column("deleted_at", sa.DateTime(), nullable=True), | ||
| sa.ForeignKeyConstraint(["config_id"], ["config.id"], ondelete="CASCADE"), | ||
| sa.PrimaryKeyConstraint("id"), | ||
| sa.UniqueConstraint( | ||
| "config_id", "version", name="uq_config_version_config_id_version" | ||
| ), | ||
| ) | ||
| op.create_index( | ||
| "idx_config_version_config_id_version_active", | ||
| "config_version", | ||
| ["config_id", "version"], | ||
| unique=False, | ||
| postgresql_where=sa.text("deleted_at IS NULL"), | ||
| ) | ||
| # ### end Alembic commands ### | ||
|
|
||
|
|
||
| def downgrade(): | ||
avirajsingh7 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| # ### commands auto generated by Alembic - please adjust! ### | ||
| op.drop_index( | ||
| "idx_config_version_config_id_version_active", | ||
| table_name="config_version", | ||
| postgresql_where=sa.text("deleted_at IS NULL"), | ||
| ) | ||
| op.drop_table("config_version") | ||
| op.drop_index( | ||
| "uq_config_project_id_name_active", | ||
| table_name="config", | ||
| postgresql_where=sa.text("deleted_at IS NULL"), | ||
| ) | ||
| op.drop_index( | ||
| "idx_config_project_id_updated_at_active", | ||
| table_name="config", | ||
| postgresql_where=sa.text("deleted_at IS NULL"), | ||
| ) | ||
| op.drop_table("config") | ||
| # ### end Alembic commands ### | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,10 @@ | ||
| from fastapi import APIRouter | ||
|
|
||
| from app.api.routes.config import config, version | ||
|
|
||
| router = APIRouter(prefix="/configs", tags=["Config Management"]) | ||
|
|
||
| router.include_router(config.router) | ||
| router.include_router(version.router) | ||
|
|
||
| __all__ = ["router"] |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,133 @@ | ||
| from uuid import UUID | ||
| from fastapi import APIRouter, Depends, Query, HTTPException | ||
|
|
||
| from app.api.deps import SessionDep, AuthContextDep | ||
| from app.crud.config import ConfigCrud | ||
| from app.models import ( | ||
| Config, | ||
| ConfigCreate, | ||
| ConfigUpdate, | ||
| ConfigPublic, | ||
| ConfigWithVersion, | ||
| ConfigVersion, | ||
| Message, | ||
| ) | ||
| from app.utils import APIResponse | ||
| from app.api.permissions import Permission, require_permission | ||
|
|
||
| router = APIRouter() | ||
|
|
||
|
|
||
| @router.post( | ||
| "/", | ||
| response_model=APIResponse[ConfigWithVersion], | ||
| status_code=201, | ||
| dependencies=[Depends(require_permission(Permission.REQUIRE_PROJECT))], | ||
| ) | ||
| def create_config( | ||
| config_create: ConfigCreate, | ||
| current_user: AuthContextDep, | ||
| session: SessionDep, | ||
| ): | ||
| """ | ||
| create new config along with initial version | ||
| """ | ||
| config_crud = ConfigCrud(session=session, project_id=current_user.project.id) | ||
| config, version = config_crud.create_or_raise(config_create) | ||
|
|
||
| response = ConfigWithVersion(**config.model_dump(), version=version) | ||
|
|
||
| return APIResponse.success_response( | ||
| data=response, | ||
| ) | ||
|
|
||
|
|
||
| @router.get( | ||
| "/", | ||
| response_model=APIResponse[list[ConfigPublic]], | ||
| status_code=200, | ||
| dependencies=[Depends(require_permission(Permission.REQUIRE_PROJECT))], | ||
| ) | ||
| def list_configs( | ||
| current_user: AuthContextDep, | ||
| session: SessionDep, | ||
| skip: int = Query(0, ge=0, description="Number of records to skip"), | ||
| limit: int = Query(100, ge=1, le=100, description="Maximum records to return"), | ||
| ): | ||
| """ | ||
| List all configurations for the current project. | ||
| Ordered by updated_at in descending order. | ||
| """ | ||
| config_crud = ConfigCrud(session=session, project_id=current_user.project.id) | ||
| configs = config_crud.read_all(skip=skip, limit=limit) | ||
| return APIResponse.success_response( | ||
| data=configs, | ||
| ) | ||
|
|
||
|
|
||
| @router.get( | ||
| "/{config_id}", | ||
| response_model=APIResponse[ConfigPublic], | ||
| status_code=200, | ||
| dependencies=[Depends(require_permission(Permission.REQUIRE_PROJECT))], | ||
| ) | ||
| def get_config( | ||
| config_id: UUID, | ||
| current_user: AuthContextDep, | ||
| session: SessionDep, | ||
| ): | ||
| """ | ||
| Get a specific configuration by its ID. | ||
| """ | ||
| config_crud = ConfigCrud(session=session, project_id=current_user.project.id) | ||
| config = config_crud.exists_or_raise(config_id=config_id) | ||
| return APIResponse.success_response( | ||
| data=config, | ||
| ) | ||
|
|
||
|
|
||
| @router.patch( | ||
| "/{config_id}", | ||
| response_model=APIResponse[ConfigPublic], | ||
| status_code=200, | ||
| dependencies=[Depends(require_permission(Permission.REQUIRE_PROJECT))], | ||
| ) | ||
| def update_config( | ||
| config_id: UUID, | ||
| config_update: ConfigUpdate, | ||
| current_user: AuthContextDep, | ||
| session: SessionDep, | ||
| ): | ||
| """ | ||
| Update a specific configuration. | ||
| """ | ||
| config_crud = ConfigCrud(session=session, project_id=current_user.project.id) | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Assuming this a DB call, may be could be done in a service instead of exposing to the router.
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Since this route only calls the CRUD directly and has no extra logic, adding a service layer would be unnecessary overhead. We can introduce one later if the logic grows.
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Cool! |
||
| config = config_crud.update_or_raise( | ||
| config_id=config_id, config_update=config_update | ||
| ) | ||
|
|
||
| return APIResponse.success_response( | ||
| data=config, | ||
| ) | ||
|
|
||
|
|
||
| @router.delete( | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. could explore soft deleting instead.
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @Prajna1999 Did not understand this one
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. As in not hard deleting from the database but toggling the flag that makes it invisible for GET-ing APIs but the entry remains in the db |
||
| "/{config_id}", | ||
| response_model=APIResponse[Message], | ||
| status_code=200, | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nitpick: status code would be 204 I guess.
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 204 is used when we return no content. |
||
| dependencies=[Depends(require_permission(Permission.REQUIRE_PROJECT))], | ||
| ) | ||
| def delete_config( | ||
| config_id: UUID, | ||
| current_user: AuthContextDep, | ||
| session: SessionDep, | ||
| ): | ||
| """ | ||
| Delete a specific configuration. | ||
| """ | ||
| config_crud = ConfigCrud(session=session, project_id=current_user.project.id) | ||
| config_crud.delete_or_raise(config_id=config_id) | ||
|
|
||
| return APIResponse.success_response( | ||
| data=Message(message="Config deleted successfully"), | ||
avirajsingh7 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| ) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,123 @@ | ||
| from uuid import UUID | ||
| from fastapi import APIRouter, Depends, Query, HTTPException, Path | ||
|
|
||
| from app.api.deps import SessionDep, AuthContextDep | ||
| from app.crud.config import ConfigCrud, ConfigVersionCrud | ||
| from app.models import ( | ||
| ConfigVersionCreate, | ||
| ConfigVersionPublic, | ||
| Message, | ||
| ConfigVersionItems, | ||
| ) | ||
| from app.utils import APIResponse | ||
| from app.api.permissions import Permission, require_permission | ||
|
|
||
| router = APIRouter() | ||
|
|
||
|
|
||
| @router.post( | ||
| "/{config_id}/versions", | ||
| response_model=APIResponse[ConfigVersionPublic], | ||
| status_code=201, | ||
| dependencies=[Depends(require_permission(Permission.REQUIRE_PROJECT))], | ||
| ) | ||
| def create_version( | ||
| config_id: UUID, | ||
| version_create: ConfigVersionCreate, | ||
| current_user: AuthContextDep, | ||
| session: SessionDep, | ||
| ): | ||
| """ | ||
| Create a new version for an existing configuration. | ||
| The version number is automatically incremented. | ||
| """ | ||
| version_crud = ConfigVersionCrud( | ||
| session=session, project_id=current_user.project.id, config_id=config_id | ||
| ) | ||
| version = version_crud.create_or_raise(version_create=version_create) | ||
|
|
||
| return APIResponse.success_response( | ||
| data=ConfigVersionPublic(**version.model_dump()), | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nitpick: expand the keys of the dict object for better readability. Its better not to spread dicts without santizing the keys first.
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I get the point, but in this case the model is already validated and sanitized by Pydantic, so spreading the dict is safe. Expanding every field manually doesn’t add value and would just increase noise. I'd prefer to keep it as is unless we hit a specific issue.
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Cool! |
||
| ) | ||
|
|
||
|
|
||
| @router.get( | ||
| "/{config_id}/versions", | ||
| response_model=APIResponse[list[ConfigVersionItems]], | ||
| status_code=200, | ||
| dependencies=[Depends(require_permission(Permission.REQUIRE_PROJECT))], | ||
| ) | ||
| def list_versions( | ||
| config_id: UUID, | ||
| current_user: AuthContextDep, | ||
| session: SessionDep, | ||
| skip: int = Query(0, ge=0, description="Number of records to skip"), | ||
| limit: int = Query(100, ge=1, le=100, description="Maximum records to return"), | ||
| ): | ||
| """ | ||
| List all versions for a specific configuration. | ||
| Ordered by version number in descending order. | ||
| """ | ||
| version_crud = ConfigVersionCrud( | ||
| session=session, project_id=current_user.project.id, config_id=config_id | ||
| ) | ||
| versions = version_crud.read_all( | ||
| skip=skip, | ||
| limit=limit, | ||
| ) | ||
| return APIResponse.success_response( | ||
| data=versions, | ||
| ) | ||
|
|
||
|
|
||
| @router.get( | ||
| "/{config_id}/versions/{version_number}", | ||
| response_model=APIResponse[ConfigVersionPublic], | ||
| status_code=200, | ||
| dependencies=[Depends(require_permission(Permission.REQUIRE_PROJECT))], | ||
| ) | ||
| def get_version( | ||
| config_id: UUID, | ||
| current_user: AuthContextDep, | ||
| session: SessionDep, | ||
| version_number: int = Path( | ||
| ..., ge=1, description="The version number of the config" | ||
| ), | ||
| ): | ||
| """ | ||
| Get a specific version of a config. | ||
| """ | ||
| version_crud = ConfigVersionCrud( | ||
| session=session, project_id=current_user.project.id, config_id=config_id | ||
| ) | ||
| version = version_crud.exists_or_raise(version_number=version_number) | ||
| return APIResponse.success_response( | ||
| data=version, | ||
| ) | ||
|
|
||
|
|
||
| @router.delete( | ||
| "/{config_id}/versions/{version_number}", | ||
| response_model=APIResponse[Message], | ||
| status_code=200, | ||
| dependencies=[Depends(require_permission(Permission.REQUIRE_PROJECT))], | ||
| ) | ||
| def delete_version( | ||
| config_id: UUID, | ||
| current_user: AuthContextDep, | ||
| session: SessionDep, | ||
| version_number: int = Path( | ||
| ..., ge=1, description="The version number of the config" | ||
| ), | ||
| ): | ||
| """ | ||
| Delete a specific version of a config. | ||
| """ | ||
| version_crud = ConfigVersionCrud( | ||
| session=session, project_id=current_user.project.id, config_id=config_id | ||
| ) | ||
| version_crud.delete_or_raise(version_number=version_number) | ||
|
|
||
| return APIResponse.success_response( | ||
| data=Message(message="Config Version deleted successfully"), | ||
| ) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,4 @@ | ||
| from app.crud.config.config import ConfigCrud | ||
| from app.crud.config.version import ConfigVersionCrud | ||
|
|
||
| __all__ = ["ConfigCrud", "ConfigVersionCrud"] |
Uh oh!
There was an error while loading. Please reload this page.