diff --git a/.gitignore b/.gitignore index ebd0767ba..d5bbd089a 100644 --- a/.gitignore +++ b/.gitignore @@ -216,8 +216,11 @@ playground/.gitignore playground/requirements.txt run.sh .claude +.cursor bruno docs/.astro docs/node_modules docs/.cache docs/package-lock.json +oauth2-proxy.cfg +compose.*.yml \ No newline at end of file diff --git a/api/domain/user/_userrepository.py b/api/domain/user/_userrepository.py index c805969b3..ee91d7aa8 100644 --- a/api/domain/user/_userrepository.py +++ b/api/domain/user/_userrepository.py @@ -14,8 +14,8 @@ async def has_admin_user(self) -> bool: async def create_user( self, email: str, - password: str, role_id: int, + password: str | None = None, name: str | None = None, sub: str | None = None, iss: str | None = None, diff --git a/api/endpoints/admin/tokens.py b/api/endpoints/admin/tokens.py index 9120c2560..e77ede739 100644 --- a/api/endpoints/admin/tokens.py +++ b/api/endpoints/admin/tokens.py @@ -31,6 +31,7 @@ async def create_token( token_id, token = await global_context.identity_access_manager.create_token( postgres_session=postgres_session, user_id=body.user, + email=body.email, name=body.name, expires=body.expires, ) diff --git a/api/helpers/_identityaccessmanager.py b/api/helpers/_identityaccessmanager.py index 0602e5f7d..eea2e3558 100644 --- a/api/helpers/_identityaccessmanager.py +++ b/api/helpers/_identityaccessmanager.py @@ -546,14 +546,24 @@ async def get_organizations( return organizations - async def create_token(self, postgres_session: AsyncSession, user_id: int, name: str, expires: int | None = None) -> tuple[int, str]: + async def create_token( + self, postgres_session: AsyncSession, name: str, expires: int | None = None, user_id: int | None = None, email: str | None = None + ) -> tuple[int, str]: + assert user_id is not None or email is not None, "user_id or email is required" + assert user_id is None or email is None, "user_id and email cannot be provided together" + if self.key_max_expiration_days: if expires is None: expires = int(dt.datetime.now(tz=dt.UTC).timestamp()) + self.key_max_expiration_days * 86400 elif expires > int(dt.datetime.now(tz=dt.UTC).timestamp()) + self.key_max_expiration_days * 86400: raise InvalidTokenExpirationException(detail=f"Token expiration timestamp cannot be greater than {self.key_max_expiration_days} days from now.") # fmt: off - result = await postgres_session.execute(statement=select(UserTable).where(UserTable.id == user_id)) + statement = select(UserTable) + if user_id is not None: + statement = statement.where(UserTable.id == user_id) + if email is not None: + statement = statement.where(UserTable.email == email) + result = await postgres_session.execute(statement=statement) try: user = result.scalar_one() except NoResultFound: @@ -600,7 +610,7 @@ async def refresh_token(self, postgres_session: AsyncSession, user_id: int, name expires = int((datetime.now() + timedelta(seconds=self.playground_session_duration)).timestamp()) # Create a new token - token_id, token = await self.create_token(postgres_session, user_id, name, expires=expires) + token_id, token = await self.create_token(postgres_session=postgres_session, user_id=user_id, name=name, expires=expires) return token_id, token diff --git a/api/infrastructure/fastapi/schemas/users.py b/api/infrastructure/fastapi/schemas/users.py index 6fac7d645..aa5db9e97 100644 --- a/api/infrastructure/fastapi/schemas/users.py +++ b/api/infrastructure/fastapi/schemas/users.py @@ -9,8 +9,8 @@ class CreateUserBody(BaseModel): email: Annotated[str, StringConstraints(strip_whitespace=True, min_length=1), Field(..., description="The user email.")] name: Annotated[str, StringConstraints(strip_whitespace=True, min_length=1)] | None = Field(default=None, description="The user name.") - password: Annotated[str, StringConstraints(strip_whitespace=True, min_length=1)] = Field(..., description="The user password.") - role: int = Field(..., description="The role ID.") + password: Annotated[str, StringConstraints(strip_whitespace=True, min_length=1)] | None = Field(default=None, description="The user password.") + role: int = Field(..., description="The role ID.") # @TODO: replace by role_id organization: int | None = Field(default=None, description="The organization ID.") budget: float | None = Field(default=None, description="The budget.") expires: int | None = Field(default=None, description="The expiration timestamp.") @@ -18,6 +18,7 @@ class CreateUserBody(BaseModel): @field_validator("expires", mode="before") def must_be_future(cls, expires): + # @TODO: replace by Pydantic FutureDatetime if isinstance(expires, int): if expires <= int(dt.datetime.now(tz=dt.UTC).timestamp()): raise ValueError("Wrong timestamp, must be in the future.") diff --git a/api/infrastructure/postgres/_postgresusersrepository.py b/api/infrastructure/postgres/_postgresusersrepository.py index 7c0f68211..c64daa61d 100644 --- a/api/infrastructure/postgres/_postgresusersrepository.py +++ b/api/infrastructure/postgres/_postgresusersrepository.py @@ -35,8 +35,8 @@ async def has_admin_user(self) -> bool: async def create_user( self, email: str, - password: str, role_id: int, + password: str | None = None, name: str | None = None, sub: str | None = None, iss: str | None = None, diff --git a/api/schemas/admin/tokens.py b/api/schemas/admin/tokens.py index a355f5f29..6c6445318 100644 --- a/api/schemas/admin/tokens.py +++ b/api/schemas/admin/tokens.py @@ -1,7 +1,7 @@ import datetime as dt from typing import Literal -from pydantic import Field, constr, field_validator +from pydantic import Field, constr, field_validator, model_validator from api.schemas import BaseModel @@ -12,18 +12,27 @@ class TokensResponse(BaseModel): class CreateToken(BaseModel): - name: constr(strip_whitespace=True, min_length=1) - user: int = Field(description="User ID to create the token for another user (by default, the current user). Required CREATE_USER permission.") # fmt: off - expires: int | None = Field(None, description="Timestamp in seconds") + name: constr(strip_whitespace=True, min_length=1) = Field(..., description="The name of the token.") + user: int | None = Field(None, description="User ID of the user to create the token for. Optional if email is provided.") + email: str | None = Field(None, description="Email of the user to create the token for. Optional if user is provided.") + expires: int | None = Field(None, description="Timestamp in seconds for the token expiration.") @field_validator("expires", mode="before") def must_be_future(cls, expires): + # @TODO: replace by Pydantic FutureDatetime if isinstance(expires, int): if expires <= int(dt.datetime.now(tz=dt.UTC).timestamp()): raise ValueError("Wrong timestamp, must be in the future.") return expires + @model_validator(mode="after") + def validate_user_or_email(self): + if self.user is None and self.email is None: + raise ValueError("Either user or email must be provided.") + + return self + class Token(BaseModel): object: Literal["token"] = "token" diff --git a/api/schemas/core/configuration.py b/api/schemas/core/configuration.py index 764d41b62..6d342c77a 100644 --- a/api/schemas/core/configuration.py +++ b/api/schemas/core/configuration.py @@ -21,7 +21,7 @@ # utils ---------------------------------------------------------------------------------------------------------------------------------------------- -def custom_validation_error(url: str | None = None): +def custom_validation_error(suffix: str = ""): """ Decorator to override Pydantic ValidationError to change error message. @@ -63,7 +63,7 @@ def resolve_model_for_error(model: type[BaseModel], loc: tuple[Any, ...]): break current_model = next_model - documentation_url = f"{base_url}#{current_model.__name__.lower()}" + documentation_url = f"{base_url}#{current_model.__name__.lower()}{suffix}" return documentation_url @@ -145,8 +145,6 @@ class Model(ConfigBaseModel): serve the same type of model (text-generation or text-embeddings-inference, etc.). We recommend that all providers of a model serve exactly the same model, otherwise users may receive responses of varying quality. For embedding models, the API verifies that all providers output vectors of the same dimension. You can define the load balancing strategy between the model's providers. By default, it is random. - - For more information to configure model providers, see the [ModelProvider section](#modelprovider). """ name: constr(strip_whitespace=True, min_length=1, max_length=64) = Field(..., description="Unique name exposed to clients when selecting the model.", examples=["gpt-4o"]) # fmt: off @@ -269,13 +267,13 @@ class EmptyDependency(ConfigBaseModel): @custom_validation_error() class Dependencies(ConfigBaseModel): - albert: AlbertDependency | None = Field(default=None, description="**[DEPRECATED]** See the [AlbertDependency section](#albertdependency) for more information.") # fmt: off - celery: CeleryDependency | None = Field(default=None, description="**[DEPRECATED]** See the [CeleryDependency section](#celerydependency) for more information.") # fmt: off - elasticsearch: ElasticsearchDependency | None = Field(default=None, description="See the [ElasticsearchDependency section](#elasticsearchdependency) for more information.") # fmt: off - marker: MarkerDependency | None = Field(default=None, description="**[DEPRECATED]** See the [MarkerDependency section](#markerdependency) for more information.") # fmt: off - postgres: PostgresDependency = Field(..., description="See the [PostgresDependency section](#postgresdependency) for more information.") # fmt: off - redis: RedisDependency = Field(..., description="See the [RedisDependency section](#redisdependency) for more information.") # fmt: off - sentry: SentryDependency | None = Field(default=None, description="See the [SentryDependency section](#sentrydependency) for more information.") # fmt: off + albert: AlbertDependency | None = Field(default=None, json_schema_extra={"deprecated": True}) # fmt: off + celery: CeleryDependency | None = Field(default=None, json_schema_extra={"deprecated": True}) # fmt: off + elasticsearch: ElasticsearchDependency | None = Field(default=None, description="Elasticsearch is an optional dependency of OpenGateLLM. Elasticsearch is used as a vector store. If this dependency is provided, all documents endpoint are enabled.") # fmt: off + marker: MarkerDependency | None = Field(default=None, json_schema_extra={"deprecated": True}) # fmt: off + postgres: PostgresDependency = Field(..., description="Postgres is a required dependency of OpenGateLLM to store API data.") # fmt: off + redis: RedisDependency = Field(..., description="Redis is a required dependency of OpenGateLLM to store rate limiting counters and performance metrics.") # fmt: off + sentry: SentryDependency | None = Field(default=None, description="Sentry is an optional dependency of OpenGateLLM. Sentry helps you identify, diagnose, and fix errors in real-time.") # fmt: off @model_validator(mode="after") def complete_celery(self): @@ -345,7 +343,7 @@ class Tokenizer(StrEnum): TIKTOKEN_O200K_BASE = "tiktoken_o200k_base" -@custom_validation_error(url="https://docs.opengatellm.org/configuration/configuration_file#settings") +@custom_validation_error() class Settings(ConfigBaseModel): """ General settings configuration fields. @@ -381,7 +379,7 @@ class Settings(ConfigBaseModel): swagger_redoc_url: str = Field(default="/redoc", pattern=r"^/", description="Redoc URL of swagger UI, see https://fastapi.tiangolo.com/tutorial/metadata for more information.") # fmt: off # auth - auth_master_key: Annotated[str, StringConstraints(strip_whitespace=True, min_length=1)] = Field(default="changeme", description="[DEPRECATED] Master key for the API. It should be a random string with at least 32 characters. This key has all permissions and cannot be modified or deleted. This key is used to create the first role and the first user.") # fmt: off + auth_master_key: Annotated[str, StringConstraints(strip_whitespace=True, min_length=1)] = Field(default="changeme", description="[DEPRECATED] Master key for the API. It should be a random string with at least 32 characters. This key has all permissions and cannot be modified or deleted. This key is used to create the first role and the first user.", json_schema_extra={"deprecated": True}) # fmt: off auth_secret_key: Annotated[str, StringConstraints(strip_whitespace=True, min_length=1)] | None = Field(default=None, description="Secret key for the API. It should be a random string with at least 32 characters. This key is used to encrypt user tokens, watch out if you modify the secret key, you'll need to update all user API keys. If not provided, the master key will be used.") # fmt: off auth_default_username: Annotated[str, StringConstraints(strip_whitespace=True, min_length=1)] = Field(default="admin", description="Username of the admin user created at startup.") # fmt: off auth_default_password: Annotated[str, StringConstraints(strip_whitespace=True, min_length=1)] = Field(default="changeme", description="Password of the admin user created at startup.") # fmt: off diff --git a/api/use_cases/admin/users/_createuserusecase.py b/api/use_cases/admin/users/_createuserusecase.py index 25e474156..9ad273916 100644 --- a/api/use_cases/admin/users/_createuserusecase.py +++ b/api/use_cases/admin/users/_createuserusecase.py @@ -11,8 +11,8 @@ class CreateUserCommand: user_id: int email: str - password: str role_id: int + password: str | None = None name: str | None = None organization_id: int | None = None budget: float | None = None diff --git a/compose.example.yml b/compose.example.yml index 8119bb18f..66f3652a6 100644 --- a/compose.example.yml +++ b/compose.example.yml @@ -8,7 +8,7 @@ services: ports: - "${API_PORT:-8000}:8000" volumes: - - "${CONFIG_FILE:-./config.yml}:/config.yml:ro" # outside the container, do not change this line + - "${CONFIG_FILE:-./config.yml}:/config.yml:ro" depends_on: redis: condition: service_healthy @@ -27,6 +27,12 @@ services: - "${PLAYGROUND_PORT:-8501}:8501" volumes: - "./${CONFIG_FILE:-config.yml}:/config.yml:ro" + healthcheck: + test: [ "CMD-SHELL", "curl -sf http://localhost:8501/ping || exit 1" ] + interval: 5s + timeout: 5s + retries: 10 + start_period: 30s depends_on: redis: condition: service_healthy diff --git a/config.example.yml b/config.example.yml index dbae1b78a..88e799b02 100644 --- a/config.example.yml +++ b/config.example.yml @@ -41,7 +41,6 @@ dependencies: index_name: opengatellm index_language: english number_of_shards: 1 - index_name: "opengatellm" number_of_replicas: 0 hosts: "http://${ELASTICSEARCH_HOST:-localhost}:${ELASTICSEARCH_PORT:-9200}" basic_auth: @@ -85,6 +84,9 @@ settings: # search_multi_agents_reranker_model: my-model playground_opengatellm_url: ${OPENGATELLM_URL} + # playground_sso_enabled: False + # playground_sso_opengatellm_admin_api_key: ${SSO_OPENGATELLM_ADMIN_API_KEY} + # playground_sso_opengatellm_default_role_id: 1 # playground_default_model: my-model # playground_theme_has_background: True # playground_theme_accent_color: purple diff --git a/docs/src/content/docs/configuration/configuration_file.md b/docs/src/content/docs/configuration/configuration_file.md deleted file mode 100644 index cd4ae32ec..000000000 --- a/docs/src/content/docs/configuration/configuration_file.md +++ /dev/null @@ -1,375 +0,0 @@ ---- -title: Configuration file -sidebar: - label: "[lucide:file-text] Configuration file" - order: 0 ---- - -OpenGateLLM requires configuring a configuration file. This defines models, dependencies, and settings parameters. Playground and API need a configuration file (could be the same file), see [API configuration](#api-configuration) and [Playground configuration](#playground-configuration). - -By default, the configuration file must be `./config.yml` file. - -You can change the configuration file by setting the `CONFIG_FILE` environment variable. - -## Secrets - -You can pass environment variables in configuration file with pattern `${ENV_VARIABLE_NAME}`. All environment variables will be loaded in the configuration file. - -**Example** - -```yaml -models: - [...] - - name: my-language-model - type: text-generation - providers: - - type: openai - url: https://api.openai.com - key: ${OPENAI_API_KEY} - model_name: gpt-4o-mini -``` - -## Example - -The following is an example of configuration file: - -```yaml -# ----------------------------------- models ------------------------------------ -models: - - name: albert-testbed - type: text-generation - # aliases: ["model-alias"] - # owned_by: Me - # load_balancing_strategy: shuffle - # cost_prompt_tokens: 0.10 - # cost_completion_tokens: 0.10 - providers: - - type: vllm - url: http://albert-testbed.etalab.gouv.fr:8000 - # key: sk-xxx - model_name: "gemma3:1b" - # timeout: 60 - # model_hosting_zone: FRA - # model_total_params: 8 - # model_active_params: 8 - -# -------------------------------- dependencies --------------------------------- -dependencies: - postgres: # required - url: postgresql+asyncpg://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-changeme}@${POSTGRES_HOST:-localhost}:${POSTGRES_PORT:-5432}/postgres - echo: False - pool_size: 5 - connect_args: - server_settings: - statement_timeout: "120s" - command_timeout: 60 - - redis: # required - url: redis://:${REDIS_PASSWORD:-changeme}@${REDIS_HOST:-localhost}:${REDIS_PORT:-6379} - max_connections: 200 - socket_connect_timeout: 5 - retry_on_timeout: True - health_check_interval: 30 - decode_responses: False - socket_keepalive: True - - elasticsearch: # optional - index_name: opengatellm - index_language: english - number_of_shards: 1 - index_name: "opengatellm" - number_of_replicas: 0 - hosts: "http://${ELASTICSEARCH_HOST:-localhost}:${ELASTICSEARCH_PORT:-9200}" - basic_auth: - - "elastic" - - ${ELASTICSEARCH_PASSWORD} - - # sentry: - # dsn: ${SENTRY_DSN} - -# ---------------------------------- settings ----------------------------------- -settings: - # disabled_routers: ["admin", "audio"] - # hidden_routers: ["auth"] - # usage_tokenizer: tiktoken_gpt2 - # app_title: My OpenGateLLM API - - # log_level: INFO - # log_format: [%(asctime)s][%(process)d:%(name)s][%(levelname)s] %(client_ip)s - %(message)s - - swagger_version: 0.4.2 - # swagger_contact_url: https://github.com/etalab-ia/OpenGateLLM - # swagger_contact_email: john.doe@example.com - # swagger_docs_url: /docs - # swagger_redoc_url: /redoc - - auth_master_key: changeme # DEPRECATED, use auth_secret_key instead - auth_secret_key: changeme - - auth_default_username: admin - auth_default_password: changeme - - # rate_limiting_strategy: fixed_window - - # monitoring_sentry_enabled: True - # monitoring_postgres_enabled: True - # monitoring_prometheus_enabled: True - - # vector_store_model: my-model - - # search_multi_agents_synthesis_model: my-model - # search_multi_agents_reranker_model: my-model - - playground_opengatellm_url: ${OPENGATELLM_URL} - # playground_default_model: my-model - # playground_theme_has_background: True - # playground_theme_accent_color: purple - # playground_theme_appearance: dark - # playground_theme_gray_color: gray - # playground_theme_panel_background: solid - # playground_theme_radius: medium - # playground_theme_scaling: 100% - -``` - -## API configuration -Configuration file is composed of 3 sections, models: -- `models`: to declare models API exposed to the API. -- `dependencies`: to declare both required plugins for the API (e.g. PostgreSQL, Redis) and optional ones (e.g. Elasticsearch). -- `settings`: to configure the API. - -:::warnings -We don't recommend to use the configuration file to declare models, prefer to use the API to declare models, by endpoints or on the Playground UI (see [Models configuration](/getting-started/models/)). -::: -

- -| Attribute | Type | Description | Default | Values | Examples | -| --- | --- | --- | --- | --- | --- | -| dependencies | object | Dependencies used by the API. For details of configuration, see the [Dependencies section](#dependencies). | **required** | | | -| models | array | Models used by the API. For details of configuration, see the [Model section](#model). | **required** | | | -| settings | object | For details of configuration, see the [Settings section](#settings). | **required** | | | - -

- -### Settings -General settings configuration fields. -

- -| Attribute | Type | Description | Default | Values | Examples | -| --- | --- | --- | --- | --- | --- | -| app_title | string | Display title of your API in swagger UI, see https://fastapi.tiangolo.com/tutorial/metadata for more information. | `OpenGateLLM` | | `My API` | -| auth_key_max_expiration_days | integer | Maximum number of days for a new API key to be valid. | `None` | | | -| auth_master_key | string | Master key for the API. It should be a random string with at least 32 characters. This key has all permissions and cannot be modified or deleted. This key is used to create the first role and the first user. This key is also used to encrypt user tokens, watch out if you modify the master key, you'll need to update all user API keys. | `changeme` | | | -| auth_playground_session_duration | integer | Duration of the playground postgres_session in seconds. | `3600` | | | -| disabled_routers | array | Disabled routers to limits services of the API. | `[]` | • `admin`

• `audio`

• `auth`

• `chat`

• `chunks`

• `collections`

• `documents`

• `embeddings`

• ... | `['embeddings']` | -| document_parsing_max_concurrent | integer | Maximum number of concurrent document parsing tasks per worker. | `10` | | | -| front_url | string | Front-end URL for the application. | `http://localhost:8501` | | | -| hidden_routers | array | Routers are enabled but hidden in the swagger and the documentation of the API. | `[]` | • `admin`

• `audio`

• `auth`

• `chat`

• `chunks`

• `collections`

• `documents`

• `embeddings`

• ... | `['admin']` | -| log_format | string | Logging format of the API. | `[%(asctime)s][%(process)d:%(name)s][%(levelname)s] %(client_ip)s - %(message)s` | | | -| log_level | string | Logging level of the API. | `INFO` | • `DEBUG`

• `INFO`

• `WARNING`

• `ERROR`

• `CRITICAL` | | -| monitoring_postgres_enabled | boolean | If true, the log usage will be written in the PostgreSQL database. | `True` | | | -| monitoring_prometheus_enabled | boolean | If true, Prometheus metrics will be exposed in the `/metrics` endpoint. | `True` | | | -| rate_limiting_strategy | string | Rate limiting strategy for the API. | `fixed_window` | • `moving_window`

• `fixed_window`

• `sliding_window` | | -| routing_max_priority | integer | Maximum allowed priority in routing tasks. | `4` | | | -| routing_max_retries | integer | Maximum number of retries for routing tasks. | `3` | | | -| routing_retry_countdown | integer | Number of seconds before retrying a failed routing task. | `3` | | | -| session_secret_key | string | Secret key for postgres_session middleware. If not provided, the master key will be used. | `None` | | `knBnU1foGtBEwnOGTOmszldbSwSYLTcE6bdibC8bPGM` | -| swagger_contact | object | Contact informations of the API in swagger UI, see https://fastapi.tiangolo.com/tutorial/metadata for more information. | `None` | | | -| swagger_description | string | Display description of your API in swagger UI, see https://fastapi.tiangolo.com/tutorial/metadata for more information. | `[See documentation](https://github.com/etalab-ia/opengatellm/blob/main/README.md)` | | `[See documentation](https://github.com/etalab-ia/opengatellm/blob/main/README.md)` | -| swagger_docs_url | string | Docs URL of swagger UI, see https://fastapi.tiangolo.com/tutorial/metadata for more information. | `/docs` | | | -| swagger_license_info | object | Licence informations of the API in swagger UI, see https://fastapi.tiangolo.com/tutorial/metadata for more information. | `{'name': 'MIT Licence', 'identifier': 'MIT', 'url': 'https://raw.githubusercontent.com/etalab-ia/opengatellm/refs/heads/main/LICENSE'}` | | | -| swagger_openapi_tags | array | OpenAPI tags of the API in swagger UI, see https://fastapi.tiangolo.com/tutorial/metadata for more information. | `[]` | | | -| swagger_openapi_url | string | OpenAPI URL of swagger UI, see https://fastapi.tiangolo.com/tutorial/metadata for more information. | `/openapi.json` | | | -| swagger_redoc_url | string | Redoc URL of swagger UI, see https://fastapi.tiangolo.com/tutorial/metadata for more information. | `/redoc` | | | -| swagger_summary | string | Display summary of your API in swagger UI, see https://fastapi.tiangolo.com/tutorial/metadata for more information. | `OpenGateLLM connect to your models. You can configuration this swagger UI in the configuration file, like hide routes or change the title.` | | `My API description.` | -| swagger_terms_of_service | string | A URL to the Terms of Service for the API in swagger UI. If provided, this has to be a URL. | `None` | | `https://example.com/terms-of-service` | -| swagger_version | string | Display version of your API in swagger UI, see https://fastapi.tiangolo.com/tutorial/metadata for more information. | `latest` | | `2.5.0` | -| usage_tokenizer | string | Tokenizer used to compute usage of the API. | `tiktoken_gpt2` | • `tiktoken_gpt2`

• `tiktoken_r50k_base`

• `tiktoken_p50k_base`

• `tiktoken_p50k_edit`

• `tiktoken_cl100k_base`

• `tiktoken_o200k_base` | | -| vector_store_model | string | Model used to vectorize the text in the vector store database. Is required if a vector store dependency is provided (Elasticsearch). This model must be defined in the `models` section and have type `text-embeddings-inference`. | `None` | | | - -

- -### Model -In the models section, you define a list of models. Each model is a set of API providers for that model. Users will access the models specified in -this section using their *name*. Load balancing is performed between the different providers of the requested model. All providers in a model must -serve the same type of model (text-generation or text-embeddings-inference, etc.). We recommend that all providers of a model serve exactly the same -model, otherwise users may receive responses of varying quality. For embedding models, the API verifies that all providers output vectors of the -same dimension. You can define the load balancing strategy between the model's providers. By default, it is random. - -For more information to configure model providers, see the [ModelProvider section](#modelprovider). -

- -| Attribute | Type | Description | Default | Values | Examples | -| --- | --- | --- | --- | --- | --- | -| aliases | array | Aliases of the model. It will be used to identify the model by users. | `[]` | | `['model-alias', 'model-alias-2']` | -| cost_completion_tokens | number | Model costs completion tokens for user budget computation. The cost is by 1M tokens. Set to `0.0` to disable budget computation for this model. | `0.0` | | `0.1` | -| cost_prompt_tokens | number | Model costs prompt tokens for user budget computation. The cost is by 1M tokens. | `0.0` | | `0.1` | -| load_balancing_strategy | string | Routing strategy for load balancing between providers of the model. | `shuffle` | • `shuffle`

• `least_busy` | `least_busy` | -| name | string | Unique name exposed to clients when selecting the model. | **required** | | `gpt-4o` | -| providers | array | API providers of the model. If there are multiple providers, the model will be load balanced between them according to the routing strategy. The different models have to the same type. For details of configuration, see the [ModelProvider section](#modelprovider). | **required** | | | -| type | string | Type of the model. It will be used to identify the model type. | **required** | • `automatic-speech-recognition`

• `image-text-to-text`

• `image-to-text`

• `text-embeddings-inference`

• `text-generation`

• `text-classification` | `text-generation` | - -

- -#### ModelProvider -| Attribute | Type | Description | Default | Values | Examples | -| --- | --- | --- | --- | --- | --- | -| key | string | Model provider API key. | `None` | | `sk-1234567890` | -| model_active_params | integer | Active params of the model in billions of parameters for carbon footprint computation. For more information, see https://ecologits.ai | `0` | | `8` | -| model_hosting_zone | string | Model hosting zone using ISO 3166-1 alpha-3 code format (e.g., `WOR` for World, `FRA` for France, `USA` for United States). This determines the electricity mix used for carbon intensity calculations. For more information, see https://ecologits.ai | `WOR` | • `ABW`

• `AFG`

• `AGO`

• `AIA`

• `ALA`

• `ALB`

• `AND`

• `ARE`

• ... | `WOR` | -| model_name | string | Model name from the model provider. | **required** | | `gpt-4o` | -| model_total_params | integer | Total params of the model in billions of parameters for carbon footprint computation. For more information, see https://ecologits.ai | `0` | | `8` | -| qos_limit | number | The value to use for the quality of service. Depends of the metric, the value can be a percentile, a threshold, etc. | `None` | | `0.5` | -| qos_metric | string | The metric to use for the quality of service. If not provided, no QoS policy is applied. | `None` | • `ttft`

• `latency`

• `inflight`

• `performance` | `inflight` | -| timeout | integer | Timeout for the model provider requests, after user receive an 500 error (model is too busy). | `300` | | `10` | -| type | string | Model provider type. | **required** | • `albert`

• `openai`

• `mistral`

• `tei`

• `vllm` | `openai` | -| url | string | Model provider API url. The url must only contain the domain name (without `/v1` suffix for example). Depends of the model provider type, the url can be optional (Albert, OpenAI). | `None` | | `https://api.openai.com` | - -

- -### Dependencies -| Attribute | Type | Description | Default | Values | Examples | -| --- | --- | --- | --- | --- | --- | -| albert | object | **[DEPRECATED]** See the [AlbertDependency section](#albertdependency) for more information. For details of configuration, see the [AlbertDependency section](#albertdependency). | `None` | | | -| celery | object | **[DEPRECATED]** See the [CeleryDependency section](#celerydependency) for more information. For details of configuration, see the [CeleryDependency section](#celerydependency). | `None` | | | -| elasticsearch | object | See the [ElasticsearchDependency section](#elasticsearchdependency) for more information. For details of configuration, see the [ElasticsearchDependency section](#elasticsearchdependency). | `None` | | | -| marker | object | **[DEPRECATED]** See the [MarkerDependency section](#markerdependency) for more information. For details of configuration, see the [MarkerDependency section](#markerdependency). | `None` | | | -| postgres | object | See the [PostgresDependency section](#postgresdependency) for more information. For details of configuration, see the [PostgresDependency section](#postgresdependency). | **required** | | | -| redis | object | See the [RedisDependency section](#redisdependency) for more information. For details of configuration, see the [RedisDependency section](#redisdependency). | **required** | | | -| sentry | object | See the [SentryDependency section](#sentrydependency) for more information. For details of configuration, see the [SentryDependency section](#sentrydependency). | `None` | | | - -

- -#### SentryDependency -Sentry is an optional dependency of OpenGateLLM. Sentry helps you identify, diagnose, and fix errors in real-time. -In this section, you can pass all sentry python SDK arguments, see https://docs.sentry.io/platforms/python/configuration/options/ for more information. -

- - -

- -#### RedisDependency -Redis is a required dependency of OpenGateLLM. Redis is used to store rate limiting counters and performance metrics. -Pass all `from_url()` method arguments of `redis.asyncio.connection.ConnectionPool` class, see https://redis.readthedocs.io/en/stable/connections.html#redis.asyncio.connection.ConnectionPool.from_url for more information. -

- -| Attribute | Type | Description | Default | Values | Examples | -| --- | --- | --- | --- | --- | --- | -| url | string | Redis connection url. | **required** | | `redis://:changeme@localhost:6379` | - -

- -#### PostgresDependency -Postgres is a required dependency of OpenGateLLM. In this section, you can pass all postgres python SDK arguments, see https://github.com/etalab-ia/opengatellm/blob/main/docs/dependencies/postgres.md for more information. -Only the `url` argument is required. The connection URL must use the asynchronous scheme, `postgresql+asyncpg://`. If you provide a standard `postgresql://` URL, it will be automatically converted to use asyncpg. -

- -| Attribute | Type | Description | Default | Values | Examples | -| --- | --- | --- | --- | --- | --- | -| url | string | PostgreSQL connection url. | **required** | | `postgresql+asyncpg://postgres:changeme@localhost:5432/postgres` | - -

- -#### MarkerDependency -**[DEPRECATED]** -

- -| Attribute | Type | Description | Default | Values | Examples | -| --- | --- | --- | --- | --- | --- | -| headers | object | Marker API request headers. | `{}` | | ``{'Authorization': 'Bearer my-api-key'}`` | -| timeout | integer | Timeout for the Marker API requests. | `300` | | `10` | -| url | string | Marker API url. | **required** | | | - -

- -#### ElasticsearchDependency -Elasticsearch is an optional dependency of OpenGateLLM. Elasticsearch is used as a vector store. If this dependency is provided, all documents endpoint are enabled. -Pass all arguments of `elasticsearch.Elasticsearch` class, see https://elasticsearch-py.readthedocs.io/en/latest/api/elasticsearch.html for more information. -Other arguments declared below are used to configure the Elasticsearch index. -

- -| Attribute | Type | Description | Default | Values | Examples | -| --- | --- | --- | --- | --- | --- | -| index_language | string | Language of the Elasticsearch index. | `english` | • `english`

• `french`

• `german`

• `italian`

• `portuguese`

• `spanish`

• `swedish` | `english` | -| index_name | string | Name of the Elasticsearch index. | `opengatellm` | | `my_index` | -| number_of_replicas | integer | Number of replicas for the Elasticsearch index. | `1` | | `1` | -| number_of_shards | integer | Number of shards for the Elasticsearch index. | `24` | | `1` | - -

- -#### CeleryDependency -**[DEPRECATED]** -

- -| Attribute | Type | Description | Default | Values | Examples | -| --- | --- | --- | --- | --- | --- | -| broker_url | string | Celery broker url like Redis (redis://) or RabbitMQ (amqp://). If not provided, use redis dependency as broker. | `None` | | | -| enable_utc | boolean | Enable UTC. | `True` | | `True` | -| result_backend | string | Celery result backend url. If not provided, use redis dependency as result backend. | `None` | | | -| timezone | string | Timezone. | `UTC` | | `UTC` | - -

- -#### AlbertDependency -**[DEPRECATED]** -

- -| Attribute | Type | Description | Default | Values | Examples | -| --- | --- | --- | --- | --- | --- | -| headers | object | Albert API request headers. | `{}` | | ``{'Authorization': 'Bearer my-api-key'}`` | -| timeout | integer | Timeout for the Albert API requests. | `300` | | `10` | -| url | string | Albert API url. | `https://albert.api.etalab.gouv.fr` | | | - -

- -## Playground configuration -The following parameters allow you to configure the Playground application. The configuration file can be shared with the API, as the sections are -identical and compatible. Some parameters are common to both the API and the Playground (for example, `app_title`). - -For Plagroud deployment, some environment variables are required to be set, like Reflex backend URL. See -[Environment variables](/configuration/environment_variable/#playground) for more information. -

- -| Attribute | Type | Description | Default | Values | Examples | -| --- | --- | --- | --- | --- | --- | -| dependencies | object | Dependencies used by the playground. For details of configuration, see the [Dependencies section](#dependencies). | **required** | | | -| settings | object | General settings configuration fields. Some fields are common to the API and the playground. For details of configuration, see the [Settings section](#settings). | **required** | | | - -

- -### Settings -| Attribute | Type | Description | Default | Values | Examples | -| --- | --- | --- | --- | --- | --- | -| app_title | string | The title of the application. | `OpenGateLLM` | | | -| auth_key_max_expiration_days | integer | Maximum number of days for a token to be valid. | `None` | | | -| documentation_url | string | Documentation URL. If not provided, deactivated documentation link in the navigation bar. | `https://docs.opengatellm.org` | | | -| playground_default_model | string | The first model selected in chat page. | `None` | | | -| playground_opengatellm_timeout | integer | The timeout in seconds for the OpenGateLLM API. | `60` | | | -| playground_opengatellm_url | string | The URL of the OpenGateLLM API. | `http://localhost:8000` | | | -| playground_theme_accent_color | string | The primary color used for default buttons, typography, backgrounds, etc. See available colors at https://www.radix-ui.com/colors. | `purple` | | | -| playground_theme_appearance | string | The appearance of the theme. | `light` | | | -| playground_theme_gray_color | string | The secondary color used for default buttons, typography, backgrounds, etc. See available colors at https://www.radix-ui.com/colors. | `gray` | | | -| playground_theme_has_background | boolean | Whether the theme has a background. | `True` | | | -| playground_theme_panel_background | string | Whether panel backgrounds are translucent: 'solid' \| 'translucent'. | `solid` | | | -| playground_theme_radius | string | The radius of the theme. Can be 'small', 'medium', or 'large'. | `medium` | | | -| playground_theme_scaling | string | The scaling of the theme. | `100%` | | | -| reference_url | string | Reference URL. If not provided, deactivated reference link in the navigation bar. | `http://localhost:8000/redoc` | | | -| routing_max_priority | integer | Maximum allowed priority in routing tasks. | `10` | | | -| swagger_url | string | Swagger URL. If not provided, deactivated swagger link in the navigation bar. | `http://localhost:8000/docs` | | | - -

- -### Dependencies -| Attribute | Type | Description | Default | Values | Examples | -| --- | --- | --- | --- | --- | --- | -| redis | object | Set the Redis connection url to use as stage manager. See https://reflex.dev/docs/api-reference/config/ for more information. For details of configuration, see the [RedisDependency section](#redisdependency). | `None` | | | - -

- -#### RedisDependency -| Attribute | Type | Description | Default | Values | Examples | -| --- | --- | --- | --- | --- | --- | -| url | string | Redis connection url. | **required** | | `redis://:changeme@localhost:6379` | - -

- diff --git a/docs/src/content/docs/configuration/configuration_file.mdx b/docs/src/content/docs/configuration/configuration_file.mdx new file mode 100644 index 000000000..baca95fb2 --- /dev/null +++ b/docs/src/content/docs/configuration/configuration_file.mdx @@ -0,0 +1,454 @@ +--- +title: Configuration file +sidebar: + label: "[lucide:file-text] Configuration file" + order: 0 +--- +import { Tabs, TabItem } from '@astrojs/starlight/components'; + +OpenGateLLM requires configuring a configuration file. This defines models, dependencies, and settings parameters. Playground and API need a configuration file (could be the same file), see [API configuration](#api-configuration) and [Playground configuration](#playground-configuration). + +By default, the configuration file must be `./config.yml` file. + +You can change the configuration file by setting the `CONFIG_FILE` environment variable. + +## Secrets + +You can pass environment variables in configuration file with pattern `${ENV_VARIABLE_NAME}`. All environment variables will be loaded in the configuration file. + +**Example** + +```yaml +models: + [...] + - name: my-language-model + type: text-generation + providers: + - type: openai + url: https://api.openai.com + key: ${OPENAI_API_KEY} + model_name: gpt-4o-mini +``` + +## Example + +The following is an example of configuration file: + +```yaml +# ----------------------------------- models ------------------------------------ +models: + - name: albert-testbed + type: text-generation + # aliases: ["model-alias"] + # owned_by: Me + # load_balancing_strategy: shuffle + # cost_prompt_tokens: 0.10 + # cost_completion_tokens: 0.10 + providers: + - type: vllm + url: http://albert-testbed.etalab.gouv.fr:8000 + # key: sk-xxx + model_name: "gemma3:1b" + # timeout: 60 + # model_hosting_zone: FRA + # model_total_params: 8 + # model_active_params: 8 + +# -------------------------------- dependencies --------------------------------- +dependencies: + postgres: # required + url: postgresql+asyncpg://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-changeme}@${POSTGRES_HOST:-localhost}:${POSTGRES_PORT:-5432}/postgres + echo: False + pool_size: 5 + connect_args: + server_settings: + statement_timeout: "120s" + command_timeout: 60 + + redis: # required + url: redis://:${REDIS_PASSWORD:-changeme}@${REDIS_HOST:-localhost}:${REDIS_PORT:-6379} + max_connections: 200 + socket_connect_timeout: 5 + retry_on_timeout: True + health_check_interval: 30 + decode_responses: False + socket_keepalive: True + + elasticsearch: # optional + index_name: opengatellm + index_language: english + number_of_shards: 1 + number_of_replicas: 0 + hosts: "http://${ELASTICSEARCH_HOST:-localhost}:${ELASTICSEARCH_PORT:-9200}" + basic_auth: + - "elastic" + - ${ELASTICSEARCH_PASSWORD} + + # sentry: + # dsn: ${SENTRY_DSN} + +# ---------------------------------- settings ----------------------------------- +settings: + # disabled_routers: ["admin", "audio"] + # hidden_routers: ["auth"] + # usage_tokenizer: tiktoken_gpt2 + # app_title: My OpenGateLLM API + + # log_level: INFO + # log_format: [%(asctime)s][%(process)d:%(name)s][%(levelname)s] %(client_ip)s - %(message)s + + swagger_version: 0.4.2 + # swagger_contact_url: https://github.com/etalab-ia/OpenGateLLM + # swagger_contact_email: john.doe@example.com + # swagger_docs_url: /docs + # swagger_redoc_url: /redoc + + auth_master_key: changeme # DEPRECATED, use auth_secret_key instead + auth_secret_key: changeme + + auth_default_username: admin + auth_default_password: changeme + + # rate_limiting_strategy: fixed_window + + # monitoring_sentry_enabled: True + # monitoring_postgres_enabled: True + # monitoring_prometheus_enabled: True + + # vector_store_model: my-model + + # search_multi_agents_synthesis_model: my-model + # search_multi_agents_reranker_model: my-model + + playground_opengatellm_url: ${OPENGATELLM_URL} + # playground_sso_enabled: False + # playground_sso_opengatellm_admin_api_key: ${SSO_OPENGATELLM_ADMIN_API_KEY} + # playground_sso_opengatellm_default_role_id: 1 + # playground_default_model: my-model + # playground_theme_has_background: True + # playground_theme_accent_color: purple + # playground_theme_appearance: dark + # playground_theme_gray_color: gray + # playground_theme_panel_background: solid + # playground_theme_radius: medium + # playground_theme_scaling: 100% + +``` + +## API configuration + +Configuration file is composed of 3 sections, models: +- `models`: to declare models API exposed to the API. +- `dependencies`: to declare both required plugins for the API (e.g. PostgreSQL, Redis) and optional ones (e.g. Elasticsearch). +- `settings`: to configure the API. + +:::warnings +We don't recommend to use the configuration file to declare models, prefer to use the API to declare models, by endpoints or on the Playground UI (see [Models configuration](/getting-started/models/)). +::: + +

+ +| Attribute | Type | Description | Default | Values | Examples | +|:-------------|:-------|:-----------------------------------------------------------------------------------------------------------|:-------------|:---------|:-----------| +| models | array | Models used by the API. For details of configuration, see the [Model section](#model). | **required** | | | +| dependencies | | Dependencies used by the API. For details of configuration, see the [Dependencies section](#dependencies). | **required** | | | +| settings | | For details of configuration, see the [Settings section](#settings). | **required** | | | + +

+ + + + +### Model + +In the models section, you define a list of models. Each model is a set of API providers for that model. Users will access the models specified in +this section using their *name*. Load balancing is performed between the different providers of the requested model. All providers in a model must +serve the same type of model (text-generation or text-embeddings-inference, etc.). We recommend that all providers of a model serve exactly the same +model, otherwise users may receive responses of varying quality. For embedding models, the API verifies that all providers output vectors of the +same dimension. You can define the load balancing strategy between the model's providers. By default, it is random. + +

+ +| Attribute | Type | Description | Default | Values | Examples | +|:------------------------|:-------|:------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|:-------------|:--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|:---------------------------------| +| name | string | Unique name exposed to clients when selecting the model. | **required** | | gpt-4o | +| type | string | Type of the model. It will be used to identify the model type. | **required** | • image-to-text

• automatic-speech-recognition

• text-embeddings-inference

• text-classification

• text-generation

• image-text-to-text | text-generation | +| aliases | array | Aliases of the model. It will be used to identify the model by users. | [] | | ['model-alias', 'model-alias-2'] | +| load_balancing_strategy | string | Routing strategy for load balancing between providers of the model. | shuffle | • least_busy

• shuffle | least_busy | +| cost_prompt_tokens | number | Model costs prompt tokens for user budget computation. The cost is by 1M tokens. | 0.0 | | 0.1 | +| cost_completion_tokens | number | Model costs completion tokens for user budget computation. The cost is by 1M tokens. Set to `0.0` to disable budget computation for this model. | 0.0 | | 0.1 | +| providers | array | API providers of the model. If there are multiple providers, the model will be load balanced between them according to the routing strategy. The different models have to the same type. For details of configuration, see the [ModelProvider section](#modelprovider). | **required** | | | + +

+ +#### ModelProvider + + + +

+ +| Attribute | Type | Description | Default | Values | Examples | +|:--------------------|:-------------|:-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|:-------------|:----------------------------------------------------------------------------------------------------------------------|:-----------------------| +| type | string | Model provider type. | **required** | • openai

• tei

• mistral

• albert

• vllm | openai | +| url | null, string | Model provider API url. The url must only contain the domain name (without `/v1` suffix for example). Depends of the model provider type, the url can be optional (Albert, OpenAI). | None | | https://api.openai.com | +| key | null, string | Model provider API key. | None | | sk-1234567890 | +| timeout | integer | Timeout for the model provider requests, after user receive an 500 error (model is too busy). | 300 | | 10 | +| model_name | string | Model name from the model provider. | **required** | | gpt-4o | +| model_hosting_zone | string | Model hosting zone using ISO 3166-1 alpha-3 code format (e.g., `WOR` for World, `FRA` for France, `USA` for United States). This determines the electricity mix used for carbon intensity calculations. For more information, see https://ecologits.ai | WOR | • JEY

• UMI

• ZWE

• NAM

• PER

• PRI

• GRD

• AUT

• ... | WOR | +| model_total_params | integer | Total params of the model in billions of parameters for carbon footprint computation. For more information, see https://ecologits.ai | 0 | | 8 | +| model_active_params | integer | Active params of the model in billions of parameters for carbon footprint computation. For more information, see https://ecologits.ai | 0 | | 8 | +| qos_metric | null, string | The metric to use for the quality of service. If not provided, no QoS policy is applied. | None | • inflight

• ttft

• performance

• latency | inflight | +| qos_limit | null, number | The value to use for the quality of service. Depends of the metric, the value can be a percentile, a threshold, etc. | None | | 0.5 | + +

+ +
+ + +### Dependencies + + + +

+ +| Attribute | Type | Description | Default | Values | Examples | +|:--------------|:-------|:------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|:-------------|:---------|:-----------| +| elasticsearch | null | Elasticsearch is an optional dependency of OpenGateLLM. Elasticsearch is used as a vector store. If this dependency is provided, all documents endpoint are enabled. For details of configuration, see the [ElasticsearchDependency section](#elasticsearchdependency). | None | | | +| postgres | | Postgres is a required dependency of OpenGateLLM to store API data. For details of configuration, see the [PostgresDependency section](#postgresdependency). | **required** | | | +| redis | | Redis is a required dependency of OpenGateLLM to store rate limiting counters and performance metrics. For details of configuration, see the [RedisDependency section](#redisdependency). | **required** | | | +| sentry | null | Sentry is an optional dependency of OpenGateLLM. Sentry helps you identify, diagnose, and fix errors in real-time. For details of configuration, see the [SentryDependency section](#sentrydependency). | None | | | + +

+ + + + +#### ElasticsearchDependency + +Elasticsearch is an optional dependency of OpenGateLLM. Elasticsearch is used as a vector store. If this dependency is provided, all documents endpoint are enabled. +Pass all arguments of `elasticsearch.Elasticsearch` class, see https://elasticsearch-py.readthedocs.io/en/latest/api/elasticsearch.html for more information. +Other arguments declared below are used to configure the Elasticsearch index. + +

+ +| Attribute | Type | Description | Default | Values | Examples | +|:-------------------|:--------|:-----------------------------------------------------------------------------------------------------------------------------------------------------------------------|:------------|:-----------------------------------------------------------------------------------------------------------------------|:-----------| +| index_name | string | Name of the Elasticsearch index. | opengatellm | | my_index | +| index_language | string | The language of the Elasticsearch index, composed by the value, the stopwords and the stemmer. | english | • spanish

• french

• german

• swedish

• english

• portuguese

• italian | english | +| | | For more information about stemmer, see https://www.elastic.co/docs/reference/text-analysis/analysis-stemmer-tokenfilter#analysis-stemmer-tokenfilter-configure-parms. | | | | +| number_of_shards | integer | Number of shards for the Elasticsearch index. | 24 | | 1 | +| number_of_replicas | integer | Number of replicas for the Elasticsearch index. | 1 | | 1 | + +

+ +
+ + +#### PostgresDependency + +Postgres is a required dependency of OpenGateLLM. In this section, you can pass all postgres python SDK arguments, see https://github.com/etalab-ia/opengatellm/blob/main/docs/dependencies/postgres.md for more information. +Only the `url` argument is required. The connection URL must use the asynchronous scheme, `postgresql+asyncpg://`. If you provide a standard `postgresql://` URL, it will be automatically converted to use asyncpg. + +

+ +| Attribute | Type | Description | Default | Values | Examples | +|:------------|:-------|:---------------------------|:-------------|:---------|:---------------------------------------------------------------| +| url | string | PostgreSQL connection url. | **required** | | postgresql+asyncpg://postgres:changeme@localhost:5432/postgres | + +

+ +
+ + +#### RedisDependency + +Redis is a required dependency of OpenGateLLM. Redis is used to store rate limiting counters and performance metrics. +Pass all `from_url()` method arguments of `redis.asyncio.connection.ConnectionPool` class, see https://redis.readthedocs.io/en/stable/connections.html#redis.asyncio.connection.ConnectionPool.from_url for more information. + +

+ +| Attribute | Type | Description | Default | Values | Examples | +|:------------|:-------|:----------------------|:-------------|:---------|:---------------------------------| +| url | string | Redis connection url. | **required** | | redis://:changeme@localhost:6379 | + +

+ +
+ + +#### SentryDependency + +Sentry is an optional dependency of OpenGateLLM. Sentry helps you identify, diagnose, and fix errors in real-time. +In this section, you can pass all sentry python SDK arguments, see https://docs.sentry.io/platforms/python/configuration/options/ for more information. + +

+ +**No settings.** + +

+ +
+
+ +
+ + +### Settings + +General settings configuration fields. + +

+ +| Attribute | Type | Description | Default | Values | Examples | +|:---------------------------------|:--------------|:----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|:-------------------------------------------------------------------------------------------------------------------------------------------|:--------------------------------------------------------------------------------------------------------------------------------------------------------------------|:----------------------------------------------------------------------------------| +| disabled_routers | array | Disabled routers to limits services of the API. | [] | • auth

• collections

• chunks

• monitoring

• parse

• embeddings

• me

• audio

• ... | ['embeddings'] | +| hidden_routers | array | Routers are enabled but hidden in the swagger and the documentation of the API. | [] | • auth

• collections

• chunks

• monitoring

• parse

• embeddings

• me

• audio

• ... | ['admin'] | +| app_title | string | Display title of your API in swagger UI, see https://fastapi.tiangolo.com/tutorial/metadata for more information. | OpenGateLLM | | My API | +| routing_max_retries | integer | Maximum number of retries for routing tasks. | 3 | | | +| routing_retry_countdown | integer | Number of seconds before retrying a failed routing task. | 3 | | | +| routing_max_priority | integer | Maximum allowed priority in routing tasks. | 4 | | | +| usage_tokenizer | string | Tokenizer used to compute usage of the API. | tiktoken_gpt2 | • tiktoken_r50k_base

• tiktoken_gpt2

• tiktoken_cl100k_base

• tiktoken_p50k_base

• tiktoken_p50k_edit

• tiktoken_o200k_base | | +| log_level | string | Logging level of the API. | INFO | • ERROR

• DEBUG

• WARNING

• CRITICAL

• INFO | | +| log_format | string | Logging format of the API. | [%(asctime)s][%(process)d:%(name)s][%(levelname)s] %(client_ip)s - %(message)s | | | +| swagger_summary | string | Display summary of your API in swagger UI, see https://fastapi.tiangolo.com/tutorial/metadata for more information. | OpenGateLLM connect to your models. You can configuration this swagger UI in the configuration file, like hide routes or change the title. | | My API description. | +| swagger_version | string | Display version of your API in swagger UI, see https://fastapi.tiangolo.com/tutorial/metadata for more information. | latest | | 2.5.0 | +| swagger_description | string | Display description of your API in swagger UI, see https://fastapi.tiangolo.com/tutorial/metadata for more information. | [See documentation](https://github.com/etalab-ia/opengatellm/blob/main/README.md) | | [See documentation](https://github.com/etalab-ia/opengatellm/blob/main/README.md) | +| swagger_contact | null, object | Contact informations of the API in swagger UI, see https://fastapi.tiangolo.com/tutorial/metadata for more information. | None | | | +| swagger_license_info | object | Licence informations of the API in swagger UI, see https://fastapi.tiangolo.com/tutorial/metadata for more information. | \{'name': 'MIT Licence', 'identifier': 'MIT', 'url': 'https://raw.githubusercontent.com/etalab-ia/opengatellm/refs/heads/main/LICENSE'\} | | | +| swagger_terms_of_service | null, string | A URL to the Terms of Service for the API in swagger UI. If provided, this has to be a URL. | None | | https://example.com/terms-of-service | +| swagger_openapi_tags | array | OpenAPI tags of the API in swagger UI, see https://fastapi.tiangolo.com/tutorial/metadata for more information. | [] | | | +| swagger_openapi_url | string | OpenAPI URL of swagger UI, see https://fastapi.tiangolo.com/tutorial/metadata for more information. | /openapi.json | | | +| swagger_docs_url | string | Docs URL of swagger UI, see https://fastapi.tiangolo.com/tutorial/metadata for more information. | /docs | | | +| swagger_redoc_url | string | Redoc URL of swagger UI, see https://fastapi.tiangolo.com/tutorial/metadata for more information. | /redoc | | | +| auth_secret_key | null, string | Secret key for the API. It should be a random string with at least 32 characters. This key is used to encrypt user tokens, watch out if you modify the secret key, you'll need to update all user API keys. If not provided, the master key will be used. | None | | | +| auth_default_username | string | Username of the admin user created at startup. | admin | | | +| auth_default_password | string | Password of the admin user created at startup. | changeme | | | +| auth_key_max_expiration_days | null, integer | Maximum number of days for a new API key to be valid. | None | | | +| auth_playground_session_duration | integer | Duration of the playground postgres_session in seconds. | 3600 | | | +| rate_limiting_strategy | string | Rate limiting strategy for the API. | fixed_window | • fixed_window

• moving_window

• sliding_window | | +| monitoring_postgres_enabled | boolean | If true, the log usage will be written in the PostgreSQL database. | True | | | +| monitoring_prometheus_enabled | boolean | If true, Prometheus metrics will be exposed in the `/metrics` endpoint. | True | | | +| vector_store_model | null, string | Model used to vectorize the text in the vector store database. Is required if a vector store dependency is provided (Elasticsearch). This model must be defined in the `models` section and have type `text-embeddings-inference`. | None | | | +| document_parsing_max_concurrent | integer | Maximum number of concurrent document parsing tasks per worker. | 10 | | | +| front_url | string | Front-end URL for the application. | http://localhost:8501 | | | + +

+ +
+
+ +## Playground configuration + +The following parameters allow you to configure the Playground application. The configuration file can be shared with the API, as the sections are +identical and compatible. Some parameters are common to both the API and the Playground (for example, `app_title`). + +For Plagroud deployment, some environment variables are required to be set, like Reflex backend URL. See +[Environment variables](/configuration/environment_variable/#playground) for more information. + +

+ +| Attribute | Type | Description | Default | Values | Examples | +|:-------------|:-------|:-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|:-------------|:---------|:-----------| +| dependencies | | Dependencies used by the playground. For details of configuration, see the [Dependencies section](#dependencies). | **required** | | | +| settings | | General settings configuration fields. Some fields are common to the API and the playground. For details of configuration, see the [SettingsWithoutSSO section](#settingswithoutsso). For details of configuration, see the [SettingsWithSSO section](#settingswithsso). | **required** | | | + +

+ + + + +### Dependencies + + + +

+ +| Attribute | Type | Description | Default | Values | Examples | +|:------------|:-------|:-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|:----------|:---------|:-----------| +| redis | null | Set the Redis connection url to use as stage manager. See https://reflex.dev/docs/api-reference/config/ for more information. For details of configuration, see the [RedisDependency section](#redisdependency). | None | | | + +

+ +#### RedisDependency + + + +

+ +| Attribute | Type | Description | Default | Values | Examples | +|:------------|:-------|:----------------------|:-------------|:---------|:---------------------------------| +| url | string | Redis connection url. | **required** | | redis://:changeme@localhost:6379 | + +

+ +
+ + +### SettingsWithoutSSO + + + +

+ +| Attribute | Type | Description | Default | Values | Examples | +|:-------------------------------------------|:--------------|:-------------------------------------------------------------------------------------------------------------------------------------|:-----------------------------|:---------|:-----------| +| auth_key_max_expiration_days | null, integer | Maximum number of days for a token to be valid. | None | | | +| routing_max_priority | integer | Maximum allowed priority in routing tasks. | 10 | | | +| app_title | string | The title of the application. | OpenGateLLM | | | +| playground_opengatellm_url | string | The URL of the OpenGateLLM API. | http://localhost:8000 | | | +| playground_opengatellm_timeout | integer | The timeout in seconds for the OpenGateLLM API. | 60 | | | +| playground_default_model | null, string | The first model selected in chat page. | None | | | +| playground_theme_has_background | boolean | Whether the theme has a background. | True | | | +| playground_theme_accent_color | string | The primary color used for default buttons, typography, backgrounds, etc. See available colors at https://www.radix-ui.com/colors. | purple | | | +| playground_theme_appearance | string | The appearance of the theme. | light | | | +| playground_theme_gray_color | string | The secondary color used for default buttons, typography, backgrounds, etc. See available colors at https://www.radix-ui.com/colors. | gray | | | +| playground_theme_panel_background | string | Whether panel backgrounds are translucent: 'solid' \| 'translucent'. | solid | | | +| playground_theme_radius | string | The radius of the theme. Can be 'small', 'medium', or 'large'. | medium | | | +| playground_theme_scaling | string | The scaling of the theme. | 100% | | | +| swagger_url | null, string | Swagger URL. If not provided, deactivated swagger link in the navigation bar. | http://localhost:8000/docs | | | +| reference_url | null, string | Reference URL. If not provided, deactivated reference link in the navigation bar. | http://localhost:8000/redoc | | | +| documentation_url | null, string | Documentation URL. If not provided, deactivated documentation link in the navigation bar. | https://docs.opengatellm.org | | | +| playground_sso_enabled | boolean | Whether SSO is enabled. | False | | | +| playground_sso_opengatellm_admin_api_key | | To activate SSO, set OpenGateLLM API key with ADMIN permissions to create users and tokens. | None | | | +| playground_sso_opengatellm_default_role_id | | To activate SSO, set the default role ID of OpenGateLLM API for new users. | None | | | +| playground_sso_provider_logout_url | | The logout url for SSO. | None | | | + +

+ +
+ + +### SettingsWithSSO + + + +

+ +| Attribute | Type | Description | Default | Values | Examples | +|:-------------------------------------------|:--------------|:-------------------------------------------------------------------------------------------------------------------------------------|:-----------------------------|:---------|:-----------| +| auth_key_max_expiration_days | null, integer | Maximum number of days for a token to be valid. | None | | | +| routing_max_priority | integer | Maximum allowed priority in routing tasks. | 10 | | | +| app_title | string | The title of the application. | OpenGateLLM | | | +| playground_opengatellm_url | string | The URL of the OpenGateLLM API. | http://localhost:8000 | | | +| playground_opengatellm_timeout | integer | The timeout in seconds for the OpenGateLLM API. | 60 | | | +| playground_default_model | null, string | The first model selected in chat page. | None | | | +| playground_theme_has_background | boolean | Whether the theme has a background. | True | | | +| playground_theme_accent_color | string | The primary color used for default buttons, typography, backgrounds, etc. See available colors at https://www.radix-ui.com/colors. | purple | | | +| playground_theme_appearance | string | The appearance of the theme. | light | | | +| playground_theme_gray_color | string | The secondary color used for default buttons, typography, backgrounds, etc. See available colors at https://www.radix-ui.com/colors. | gray | | | +| playground_theme_panel_background | string | Whether panel backgrounds are translucent: 'solid' \| 'translucent'. | solid | | | +| playground_theme_radius | string | The radius of the theme. Can be 'small', 'medium', or 'large'. | medium | | | +| playground_theme_scaling | string | The scaling of the theme. | 100% | | | +| swagger_url | null, string | Swagger URL. If not provided, deactivated swagger link in the navigation bar. | http://localhost:8000/docs | | | +| reference_url | null, string | Reference URL. If not provided, deactivated reference link in the navigation bar. | http://localhost:8000/redoc | | | +| documentation_url | null, string | Documentation URL. If not provided, deactivated documentation link in the navigation bar. | https://docs.opengatellm.org | | | +| playground_sso_enabled | boolean | Whether SSO is enabled. | True | | | +| playground_sso_opengatellm_admin_api_key | string | To activate SSO, set OpenGateLLM API key with ADMIN permissions to create users and tokens. | **required** | | | +| playground_sso_opengatellm_default_role_id | integer | To activate SSO, set the default role ID of OpenGateLLM API for new users. | **required** | | | +| playground_sso_provider_logout_url | string | The logout url for SSO. | **required** | | | + +

+ +
+
+ diff --git a/playground/app/app.py b/playground/app/app.py index d128123cf..ba3be48d6 100644 --- a/playground/app/app.py +++ b/playground/app/app.py @@ -134,8 +134,11 @@ def providers() -> rx.Component: head_components=[rx.el.link(rel="icon", type="image/svg+xml", href="/favicon.svg")], ) + # Add pages -app.add_page(component=index, route="/") +sso_login = [AuthState.sso_login] if configuration.settings.playground_sso_enabled else [] + +app.add_page(component=index, route="/", on_load=sso_login) app.add_page(component=account, route="/account") app.add_page(component=keys, route="/keys", on_load=[KeysState.load_entities]) app.add_page(component=usage, route="/usage", on_load=[UsageState.load_entities]) diff --git a/playground/app/core/configuration.py b/playground/app/core/configuration.py index ded20a3ab..851e57099 100644 --- a/playground/app/core/configuration.py +++ b/playground/app/core/configuration.py @@ -2,9 +2,9 @@ import logging import os import re -from typing import Any +from typing import Annotated, Any, Literal, get_args, get_origin -from pydantic import BaseModel, ConfigDict, Field, constr, field_validator, model_validator +from pydantic import BaseModel, ConfigDict, Field, StringConstraints, constr, field_validator, model_validator from pydantic import ValidationError as PydanticValidationError from pydantic_settings import BaseSettings import yaml @@ -12,7 +12,7 @@ from app.core.variables import DEFAULT_APP_NAME -def custom_validation_error(url: str | None = None): +def custom_validation_error(suffix: str = ""): """ Decorator to override Pydantic ValidationError to change error message. @@ -21,25 +21,50 @@ def custom_validation_error(url: str | None = None): """ class ValidationError(Exception): - def __init__(self, exc: PydanticValidationError, cls: BaseModel, url: str): + def __init__( + self, exc: PydanticValidationError, cls: BaseModel, base_url: str = "https://docs.opengatellm.org/configuration/configuration_file" + ): super().__init__() - error_count = exc.error_count() error_content = exc.errors() - message = f"{error_count} validation error for {cls.__name__}\n" + def resolve_model_for_error(model: type[BaseModel], loc: tuple[Any, ...]): + current_model = model + documentation_url = base_url + + for idx, part in enumerate(loc): + if not isinstance(part, str): + continue + if part not in current_model.__pydantic_fields__: + break + + field_info = current_model.__pydantic_fields__[part] + + annotation = field_info.annotation + next_model = None + origin = get_origin(annotation) + args = get_args(annotation) + candidates = args if origin is not None else (annotation,) + + for candidate in candidates: + if isinstance(candidate, type) and issubclass(candidate, BaseModel): + next_model = candidate + break + + if next_model is None: + break + + current_model = next_model + documentation_url = f"{base_url}#{current_model.__name__.lower()}{suffix}" + + return documentation_url + + message = str(exc) for error in error_content: - url = url or error["url"] - if error["type"] == "assertion_error": - message += f"{error['msg']}\n" - else: - if len(error["loc"]) > 0: - message += f"{error['loc'][0]}\n" - message += f" {error["msg"]} [type={error["type"]}, input_value={error.get("input", "")}, input_type={type(error.get("input")).__name__}]\n" # fmt: off - if len(error["loc"]) > 0: - description = cls.__pydantic_fields__[error["loc"][0]].description - if description: - message += f"\n {description}\n" - message += f" For further information visit {url}\n\n" + loc = tuple(error.get("loc", ())) + documentation_url = resolve_model_for_error(cls, loc) + original_line = f" For further information visit {error['url']}" + replacement_line = f" For further information visit {documentation_url}" + message = message.replace(original_line, replacement_line, 1) self.message = message @@ -54,7 +79,7 @@ def new_init(self, **data): try: original_init(self, **data) except PydanticValidationError as e: - raise ValidationError(exc=e, cls=cls, url=url) from None # hide previous traceback + raise ValidationError(exc=e, cls=cls) from None # hide previous traceback cls.__init__ = new_init return cls @@ -66,18 +91,18 @@ class ConfigBaseModel(BaseModel): model_config = ConfigDict(extra="allow") -@custom_validation_error(url="https://docs.opengatellm.org/configuration/configuration_file#redisdependency-1") +@custom_validation_error(suffix="-1") class RedisDependency(ConfigBaseModel): url: constr(strip_whitespace=True, min_length=1) = Field(..., pattern=r"^redis://", description="Redis connection url.", examples=["redis://:changeme@localhost:6379"]) # fmt: off -@custom_validation_error(url="https://docs.opengatellm.org/configuration/configuration_file#dependencies-1") +@custom_validation_error(suffix="-1") class Dependencies(ConfigBaseModel): redis: RedisDependency | None = Field(default=None, description="Set the Redis connection url to use as stage manager. See https://reflex.dev/docs/api-reference/config/ for more information.") # fmt: off -@custom_validation_error(url="https://docs.opengatellm.org/configuration/configuration_file#settings-1") -class Settings(ConfigBaseModel): +@custom_validation_error(suffix="-1") +class Settings(BaseModel): auth_key_max_expiration_days: int | None = Field(default=None, ge=1, description="Maximum number of days for a token to be valid.") # fmt: off routing_max_priority: int = Field(default=10, ge=0, description="Maximum allowed priority in routing tasks.") # fmt: off app_title: str = Field(default=DEFAULT_APP_NAME, description="The title of the application.") @@ -85,6 +110,7 @@ class Settings(ConfigBaseModel): playground_opengatellm_url: str = Field(default="http://localhost:8000", description="The URL of the OpenGateLLM API.") playground_opengatellm_timeout: int = Field(default=60, description="The timeout in seconds for the OpenGateLLM API.") playground_default_model: str | None = Field(default=None, description="The first model selected in chat page.") + playground_theme_has_background: bool = Field(default=True, description="Whether the theme has a background.") playground_theme_accent_color: str = Field(default="purple", description="The primary color used for default buttons, typography, backgrounds, etc. See available colors at https://www.radix-ui.com/colors.") # fmt: off playground_theme_appearance: str = Field(default="light", description="The appearance of the theme.") @@ -98,6 +124,20 @@ class Settings(ConfigBaseModel): documentation_url: str | None = Field(default="https://docs.opengatellm.org", pattern=r"^http[s]?://", description="Documentation URL. If not provided, deactivated documentation link in the navigation bar.") # fmt: off +class SettingsWithoutSSO(Settings): + playground_sso_enabled: Literal[False] = Field(default=False, description="Whether SSO is enabled.") + playground_sso_opengatellm_admin_api_key: Any = Field(default=None, description="To activate SSO, set OpenGateLLM API key with ADMIN permissions to create users and tokens.") # fmt: off + playground_sso_opengatellm_default_role_id: Any = Field(default=None, description="To activate SSO, set the default role ID of OpenGateLLM API for new users.") # fmt: off + playground_sso_provider_logout_url: Any = Field(default=None, description="The logout url for SSO.") + + +class SettingsWithSSO(Settings): + playground_sso_enabled: Literal[True] = Field(default=True, description="Whether SSO is enabled.") + playground_sso_opengatellm_admin_api_key: Annotated[str, StringConstraints(strip_whitespace=True, min_length=1)] = Field(description="To activate SSO, set OpenGateLLM API key with ADMIN permissions to create users and tokens.") # fmt: off + playground_sso_opengatellm_default_role_id: Annotated[int, Field(ge=0, description="To activate SSO, set the default role ID of OpenGateLLM API for new users.")] = Field(description="To activate SSO, set the default role ID of OpenGateLLM API for new users.") # fmt: off + playground_sso_provider_logout_url: Annotated[str, StringConstraints(strip_whitespace=True, min_length=1)] = Field(description="The logout url for SSO.") # fmt: off + + class ConfigFile(ConfigBaseModel): """ The following parameters allow you to configure the Playground application. The configuration file can be shared with the API, as the sections are @@ -108,7 +148,14 @@ class ConfigFile(ConfigBaseModel): """ dependencies: Dependencies = Field(default_factory=Dependencies, description="Dependencies used by the playground.") # fmt: off - settings: Settings = Field(default_factory=Settings, description="General settings configuration fields. Some fields are common to the API and the playground.") # fmt: off + settings: Annotated[SettingsWithoutSSO | SettingsWithSSO, Field(discriminator="playground_sso_enabled", default_factory=SettingsWithoutSSO, description="General settings configuration fields. Some fields are common to the API and the playground.")] # fmt: off + + @model_validator(mode="before") + @classmethod + def normalize(cls, data: Any) -> Any: + if isinstance(data, dict) and isinstance(data.get("settings"), dict): + data["settings"].setdefault("playground_sso_enabled", False) + return data class Configuration(BaseSettings): @@ -122,6 +169,7 @@ def config_file_exists(cls, config_file): return config_file @model_validator(mode="after") + @classmethod def setup_config(cls, values) -> Any: with open(file=values.config_file) as file: lines = file.readlines() @@ -130,6 +178,14 @@ def setup_config(cls, values) -> Any: file_content = cls.replace_environment_variables(file_content="".join(uncommented_lines)) config = ConfigFile(**yaml.safe_load(stream=file_content)) + try: + default_role_id = config.settings.playground_sso_opengatellm_default_role_id + if default_role_id is not None: + default_role_id = int(default_role_id) + config.settings.playground_sso_opengatellm_default_role_id = default_role_id + except ValueError: + raise ValueError("For SSO to be enabled, default role ID must be an integer.") + values.dependencies = config.dependencies values.settings = config.settings diff --git a/playground/app/features/account/state.py b/playground/app/features/account/state.py index f218eb97c..0abad9179 100644 --- a/playground/app/features/account/state.py +++ b/playground/app/features/account/state.py @@ -3,7 +3,6 @@ import httpx import reflex as rx -from app.core.configuration import configuration from app.features.auth.state import AuthState from app.shared.components.toasts import httpx_error_toast @@ -67,7 +66,7 @@ async def change_password(self): url=f"{self.opengatellm_url}/v1/me/info", headers={"Authorization": f"Bearer {self.api_key}", "Content-Type": "application/json"}, json={"current_password": self.current_password, "password": self.new_password}, - timeout=configuration.settings.playground_opengatellm_timeout, + timeout=self.opengatellm_timeout, ) response.raise_for_status() @@ -104,7 +103,7 @@ async def update_name(self): url=f"{self.opengatellm_url}/v1/me/info", headers={"Authorization": f"Bearer {self.api_key}", "Content-Type": "application/json"}, json={"name": self.edit_name.strip()}, - timeout=configuration.settings.playground_opengatellm_timeout, + timeout=self.opengatellm_timeout, ) response.raise_for_status() diff --git a/playground/app/features/auth/components/forms.py b/playground/app/features/auth/components/forms.py index 6074291c2..cf9a5d577 100644 --- a/playground/app/features/auth/components/forms.py +++ b/playground/app/features/auth/components/forms.py @@ -57,10 +57,11 @@ def login_form() -> rx.Component: ), rx.button( "Sign In", - on_click=AuthState.login_direct, + on_click=AuthState.basic_login, width="100%", loading=AuthState.is_loading, disabled=AuthState.is_loading, + cursor="pointer", ), spacing="4", width="100%", diff --git a/playground/app/features/auth/state.py b/playground/app/features/auth/state.py index a29336df5..9dbe7c175 100644 --- a/playground/app/features/auth/state.py +++ b/playground/app/features/auth/state.py @@ -1,3 +1,5 @@ +from urllib.parse import quote + import httpx import reflex as rx @@ -30,6 +32,11 @@ class AuthState(rx.State): is_loading: bool = False opengatellm_url: str = configuration.settings.playground_opengatellm_url + opengatellm_timeout: int = configuration.settings.playground_opengatellm_timeout + sso_enabled: bool = configuration.settings.playground_sso_enabled + sso_opengatellm_admin_api_key: str | None = configuration.settings.playground_sso_opengatellm_admin_api_key + sso_opengatellm_default_role_id: int | None = configuration.settings.playground_sso_opengatellm_default_role_id + sso_provider_logout_url: str = configuration.settings.playground_sso_provider_logout_url # Form fields email_input: str = "" @@ -45,9 +52,42 @@ def set_password_input(self, value: str): """Set password input value.""" self.password_input = value + async def _login(self, client: httpx.AsyncClient, email: str, password: str): + response = await client.post( + url=f"{self.opengatellm_url}/v1/auth/login", + json={"email": email, "password": password}, + timeout=self.opengatellm_timeout, + ) + return response + + async def _create_api_key(self, client: httpx.AsyncClient, email: str): + response = await client.post( + url=f"{self.opengatellm_url}/v1/admin/tokens", + json={"email": email, "name": "playground"}, + headers={"Authorization": f"Bearer {self.sso_opengatellm_admin_api_key}"}, + timeout=self.opengatellm_timeout, + ) + return response + + async def _create_user(self, client: httpx.AsyncClient, email: str): + response = await client.post( + url=f"{self.opengatellm_url}/v1/admin/users", + json={"email": email, "name": email, "role": self.sso_opengatellm_default_role_id}, + headers={"Authorization": f"Bearer {self.sso_opengatellm_admin_api_key}"}, + timeout=self.opengatellm_timeout, + ) + return response + + async def _get_user_info(self, client: httpx.AsyncClient, api_key: str): + response = await client.get( + url=f"{self.opengatellm_url}/v1/me/info", + headers={"Authorization": f"Bearer {api_key}"}, + timeout=self.opengatellm_timeout, + ) + return response + @rx.event - async def login_direct(self): - """Handle login using direct state values.""" + async def basic_login(self): email = self.email_input.strip() password = self.password_input.strip() @@ -61,35 +101,15 @@ async def login_direct(self): response = None try: async with httpx.AsyncClient() as client: - # Login to get API key - response = await client.post( - f"{self.opengatellm_url}/v1/auth/login", - json={"email": email, "password": password}, - timeout=configuration.settings.playground_opengatellm_timeout, - ) - if response.status_code != 200: - error_detail = response.json().get("detail", "Login failed") - yield rx.toast.error(error_detail, position="bottom-right") - self.is_loading = False - yield - return - - login_data = response.json() - api_key = login_data.get("key") - api_key_id = login_data.get("id") + # Create API key + response = await self._login(client=client, email=email, password=password) + response.raise_for_status() + api_key = response.json().get("key") + api_key_id = response.json().get("id") # Get user info - response = await client.get( - f"{self.opengatellm_url}/v1/me/info", - headers={"Authorization": f"Bearer {api_key}"}, - timeout=configuration.settings.playground_opengatellm_timeout, - ) - - if response.status_code != 200: - yield rx.toast.error("Failed to fetch user info", position="bottom-right") - self.is_loading = False - yield - return + response = await self._get_user_info(client=client, api_key=api_key) + response.raise_for_status() user_data = response.json() @@ -122,6 +142,61 @@ async def login_direct(self): self.is_loading = False yield + @rx.event + async def sso_login(self): + if self.is_authenticated: + return + + email = self.router.headers.raw_headers.get("x-forwarded-email") + if not email: + return + + self.is_loading = True + yield + + response = None + try: + async with httpx.AsyncClient() as client: + # Create API key + response = await self._create_api_key(client=client, email=email) + + if response.status_code == 404: + response = await self._create_user(client=client, email=email) + response.raise_for_status() + + response = await self._create_api_key(client=client, email=email) + response.raise_for_status() + + response.raise_for_status() + api_key = response.json().get("token") + api_key_id = response.json().get("id") + + # Get user info + response = await self._get_user_info(client=client, api_key=api_key) + response.raise_for_status() + user_data = response.json() + + # Update state + self.is_authenticated = True + self.user_id = user_data.get("id") + self.user_email = user_data.get("email") + self.user_name = user_data.get("name") + self.api_key = api_key + self.api_key_id = api_key_id + self.user_organization = user_data.get("organization") + self.user_budget = user_data.get("budget") + self.user_priority = user_data.get("priority", 0) + self.user_created = user_data.get("created") + self.user_updated = user_data.get("updated") + self.user_permissions = user_data.get("permissions", []) + self.user_limits = user_data.get("limits", []) + + except Exception as e: + yield httpx_error_toast(exception=e, response=response) + finally: + self.is_loading = False + yield + @rx.var def is_admin(self) -> bool: """Check if user has admin permission.""" @@ -133,7 +208,7 @@ def is_master(self) -> bool: return self.user_id == 0 @rx.event - def logout(self): + def basic_logout(self): """Handle logout.""" self.is_authenticated = False self.user_id = None @@ -148,3 +223,29 @@ def logout(self): self.user_updated = None self.user_permissions = [] self.user_limits = [] + + @rx.event + def sso_logout(self): + """Handle SSO logout by redirecting the browser to oauth2-proxy sign_out. + + oauth2-proxy clears the session cookie then redirects to the provider logout URL. + """ + self.is_authenticated = False + self.user_id = None + self.user_email = None + self.user_name = None + self.api_key = None + self.api_key_id = None + self.user_organization = None + self.user_budget = None + self.user_priority = None + self.user_created = None + self.user_updated = None + self.user_permissions = [] + self.user_limits = [] + + # return rx.redirect(rd) + # client_id = "557aea18a617ec6a06260ec42015f26251d671f3914a7312e6b168dc4e4f738e" + # url = f"https://fca.integ01.dev-agentconnect.fr/api/v2/session/end?client_id={client_id}&post_logout_redirect_uri=http%3A%2F%2Flocalhost:4180/oauth2/sign_in" + # return rx.redirect(urljoin(base=self.sso_oauth2_proxy_url, url=f"/oauth2/sign_out?rd={rd}")) + return rx.redirect(quote(self.sso_provider_logout_url, safe="")) diff --git a/playground/app/features/chat/state.py b/playground/app/features/chat/state.py index 121b2d804..53d4e82e2 100644 --- a/playground/app/features/chat/state.py +++ b/playground/app/features/chat/state.py @@ -61,7 +61,7 @@ async def load_models(self): response = await client.get( f"{self.opengatellm_url}/v1/models", headers={"Authorization": f"Bearer {self.api_key}"}, - timeout=configuration.settings.playground_opengatellm_timeout, + timeout=self.opengatellm_timeout, ) response.raise_for_status() data = response.json() @@ -204,7 +204,7 @@ async def api_process_question(self, question: str): "Content-Type": "application/json", }, json=payload, - timeout=configuration.settings.playground_opengatellm_timeout, + timeout=self.opengatellm_timeout, ) as response: if response.status_code != 200: error_text = await response.aread() @@ -242,7 +242,7 @@ async def api_process_question(self, question: str): "Content-Type": "application/json", }, json=payload, - timeout=configuration.settings.playground_opengatellm_timeout, + timeout=self.opengatellm_timeout, ) if response.status_code != 200: diff --git a/playground/app/features/keys/state.py b/playground/app/features/keys/state.py index 7c7128512..cc8df19ca 100644 --- a/playground/app/features/keys/state.py +++ b/playground/app/features/keys/state.py @@ -56,7 +56,7 @@ async def load_entities(self): url=f"{self.opengatellm_url}/v1/me/keys", params=params, headers={"Authorization": f"Bearer {self.api_key}"}, - timeout=configuration.settings.playground_opengatellm_timeout, + timeout=self.opengatellm_timeout, ) response.raise_for_status() @@ -106,7 +106,7 @@ async def delete_entity(self): response = await client.delete( url=f"{self.opengatellm_url}/v1/me/keys/{self.entity_to_delete.id}", headers={"Authorization": f"Bearer {self.api_key}"}, - timeout=configuration.settings.playground_opengatellm_timeout, + timeout=self.opengatellm_timeout, ) response.raise_for_status() @@ -184,7 +184,7 @@ async def create_entity(self): url=f"{self.opengatellm_url}/v1/me/keys", json=payload, headers={"Authorization": f"Bearer {self.api_key}"}, - timeout=configuration.settings.playground_opengatellm_timeout, + timeout=self.opengatellm_timeout, ) response.raise_for_status() data = response.json() diff --git a/playground/app/features/navigation/components/sidebars.py b/playground/app/features/navigation/components/sidebars.py index 899b8d9bc..adb50442f 100644 --- a/playground/app/features/navigation/components/sidebars.py +++ b/playground/app/features/navigation/components/sidebars.py @@ -103,7 +103,11 @@ def navigation_sidebar() -> rx.Component: rx.button( rx.icon("log-out", size=16), "Logout", - on_click=AuthState.logout, + on_click=rx.cond( + AuthState.sso_enabled, + AuthState.sso_logout, + AuthState.basic_logout, + ), variant="soft", color_scheme="red", width="100%", @@ -123,7 +127,7 @@ def navigation_sidebar() -> rx.Component: width="250px", height="94%", background_color=rx.color("mauve", 2), - border_right=f"1px solid {rx.color("mauve", 3)}", + border_right=f"1px solid {rx.color('mauve', 3)}", position="fixed", left="0", top="65px", diff --git a/playground/app/features/organizations/state.py b/playground/app/features/organizations/state.py index 897d0d7b0..16e02bc90 100644 --- a/playground/app/features/organizations/state.py +++ b/playground/app/features/organizations/state.py @@ -3,7 +3,6 @@ import httpx import reflex as rx -from app.core.configuration import configuration from app.features.organizations.models import Organization from app.shared.components.toasts import httpx_error_toast from app.shared.states.entity_state import EntityState @@ -56,7 +55,7 @@ async def load_entities(self): url=f"{self.opengatellm_url}/v1/admin/organizations", params=params, headers={"Authorization": f"Bearer {self.api_key}"}, - timeout=configuration.settings.playground_opengatellm_timeout, + timeout=self.opengatellm_timeout, ) response.raise_for_status() @@ -105,7 +104,7 @@ async def delete_entity(self): response = await client.delete( url=f"{self.opengatellm_url}/v1/admin/organizations/{self.entity_to_delete.id}", headers={"Authorization": f"Bearer {self.api_key}"}, - timeout=configuration.settings.playground_opengatellm_timeout, + timeout=self.opengatellm_timeout, ) response.raise_for_status() @@ -152,7 +151,7 @@ async def create_entity(self): url=f"{self.opengatellm_url}/v1/admin/organizations", json=payload, headers={"Authorization": f"Bearer {self.api_key}"}, - timeout=configuration.settings.playground_opengatellm_timeout, + timeout=self.opengatellm_timeout, ) response.raise_for_status() @@ -210,7 +209,7 @@ async def edit_entity(self): url=f"{self.opengatellm_url}/v1/admin/organizations/{self.entity.id}", json=payload, headers={"Authorization": f"Bearer {self.api_key}"}, - timeout=configuration.settings.playground_opengatellm_timeout, + timeout=self.opengatellm_timeout, ) response.raise_for_status() diff --git a/playground/app/features/providers/state.py b/playground/app/features/providers/state.py index 89d63a6de..3097cb9ca 100644 --- a/playground/app/features/providers/state.py +++ b/playground/app/features/providers/state.py @@ -4,7 +4,6 @@ import pycountry import reflex as rx -from app.core.configuration import configuration from app.features.providers.models import Provider from app.shared.components.toasts import httpx_error_toast from app.shared.states.entity_state import EntityState @@ -118,7 +117,7 @@ async def load_entities(self): url=f"{self.opengatellm_url}/v1/admin/routers", params={"offset": offset, "limit": 100}, headers={"Authorization": f"Bearer {self.api_key}"}, - timeout=configuration.settings.playground_opengatellm_timeout, + timeout=self.opengatellm_timeout, ) response.raise_for_status() @@ -134,7 +133,7 @@ async def load_entities(self): f"{self.opengatellm_url}/v1/admin/providers", params=params, headers={"Authorization": f"Bearer {self.api_key}"}, - timeout=configuration.settings.playground_opengatellm_timeout, + timeout=self.opengatellm_timeout, ) response.raise_for_status() @@ -146,7 +145,7 @@ async def load_entities(self): response = await client.get( url=f"{self.opengatellm_url}/v1/admin/users/{provider['user_id']}", headers={"Authorization": f"Bearer {self.api_key}"}, - timeout=configuration.settings.playground_opengatellm_timeout, + timeout=self.opengatellm_timeout, ) if response.status_code == 404: self.provider_owners[provider["user_id"]] = "Master" @@ -160,7 +159,7 @@ async def load_entities(self): response = await client.get( url=f"{self.opengatellm_url}/v1/admin/routers/{provider['router_id']}", headers={"Authorization": f"Bearer {self.api_key}"}, - timeout=configuration.settings.playground_opengatellm_timeout, + timeout=self.opengatellm_timeout, ) if response.status_code == 200: @@ -213,7 +212,7 @@ async def delete_entity(self): response = await client.delete( url=f"{self.opengatellm_url}/v1/admin/providers/{self.entity_to_delete.id}", headers={"Authorization": f"Bearer {self.api_key}"}, - timeout=configuration.settings.playground_opengatellm_timeout, + timeout=self.opengatellm_timeout, ) response.raise_for_status() @@ -285,7 +284,7 @@ async def create_entity(self): url=f"{self.opengatellm_url}/v1/admin/providers", headers={"Authorization": f"Bearer {self.api_key}"}, json=payload, - timeout=configuration.settings.playground_opengatellm_timeout, + timeout=self.opengatellm_timeout, ) response.raise_for_status() @@ -351,7 +350,7 @@ async def edit_entity(self): url=f"{self.opengatellm_url}/v1/admin/providers/{self.entity.id}", json=payload, headers={"Authorization": f"Bearer {self.api_key}"}, - timeout=configuration.settings.playground_opengatellm_timeout, + timeout=self.opengatellm_timeout, ) response.raise_for_status() diff --git a/playground/app/features/roles/state.py b/playground/app/features/roles/state.py index 5ea2ed4cf..54709794a 100644 --- a/playground/app/features/roles/state.py +++ b/playground/app/features/roles/state.py @@ -4,7 +4,6 @@ import httpx import reflex as rx -from app.core.configuration import configuration from app.features.roles.models import Role from app.shared.components.toasts import httpx_error_toast from app.shared.states.entity_state import EntityState @@ -89,7 +88,7 @@ async def load_entities(self): f"{self.opengatellm_url}/v1/admin/routers", params={"offset": offset, "limit": limit}, headers={"Authorization": f"Bearer {self.api_key}"}, - timeout=configuration.settings.playground_opengatellm_timeout, + timeout=self.opengatellm_timeout, ) response.raise_for_status() @@ -110,7 +109,7 @@ async def load_entities(self): "order_direction": self.order_direction_value, }, headers={"Authorization": f"Bearer {self.api_key}"}, - timeout=configuration.settings.playground_opengatellm_timeout, + timeout=self.opengatellm_timeout, ) response.raise_for_status() @@ -160,7 +159,7 @@ async def delete_entity(self): response = await client.delete( url=f"{self.opengatellm_url}/v1/admin/roles/{self.entity_to_delete.id}", headers={"Authorization": f"Bearer {self.api_key}"}, - timeout=configuration.settings.playground_opengatellm_timeout, + timeout=self.opengatellm_timeout, ) response.raise_for_status() @@ -189,14 +188,12 @@ async def delete_limit(self, role: Role, router: str): if limit["router"] == router: continue - limits.extend( - [ - {"router_id": self.routers_dict[limit["router"]], "type": "rpm", "value": limit["rpm"]}, - {"router_id": self.routers_dict[limit["router"]], "type": "rpd", "value": limit["rpd"]}, - {"router_id": self.routers_dict[limit["router"]], "type": "tpm", "value": limit["tpm"]}, - {"router_id": self.routers_dict[limit["router"]], "type": "tpd", "value": limit["tpd"]}, - ] - ) + limits.extend([ + {"router_id": self.routers_dict[limit["router"]], "type": "rpm", "value": limit["rpm"]}, + {"router_id": self.routers_dict[limit["router"]], "type": "rpd", "value": limit["rpd"]}, + {"router_id": self.routers_dict[limit["router"]], "type": "tpm", "value": limit["tpm"]}, + {"router_id": self.routers_dict[limit["router"]], "type": "tpd", "value": limit["tpd"]}, + ]) yield @@ -208,7 +205,7 @@ async def delete_limit(self, role: Role, router: str): url=f"{self.opengatellm_url}/v1/admin/roles/{role.id}", json=payload, headers={"Authorization": f"Bearer {self.api_key}"}, - timeout=configuration.settings.playground_opengatellm_timeout, + timeout=self.opengatellm_timeout, ) response.raise_for_status() @@ -286,7 +283,7 @@ async def create_entity(self): url=f"{self.opengatellm_url}/v1/admin/roles", json=payload, headers={"Authorization": f"Bearer {self.api_key}"}, - timeout=configuration.settings.playground_opengatellm_timeout, + timeout=self.opengatellm_timeout, ) response.raise_for_status() @@ -326,7 +323,7 @@ async def create_limit(self, role: Role): f"{self.opengatellm_url}/v1/admin/roles/{role.id}", json=payload, headers={"Authorization": f"Bearer {self.api_key}", "Content-Type": "application/json"}, - timeout=configuration.settings.playground_opengatellm_timeout, + timeout=self.opengatellm_timeout, ) response.raise_for_status() @@ -416,7 +413,7 @@ async def edit_entity(self): url=f"{self.opengatellm_url}/v1/admin/roles/{self.entity.id}", json=payload, headers={"Authorization": f"Bearer {self.api_key}"}, - timeout=configuration.settings.playground_opengatellm_timeout, + timeout=self.opengatellm_timeout, ) response.raise_for_status() diff --git a/playground/app/features/routers/state.py b/playground/app/features/routers/state.py index ee923c415..6ba648cba 100644 --- a/playground/app/features/routers/state.py +++ b/playground/app/features/routers/state.py @@ -3,7 +3,6 @@ import httpx import reflex as rx -from app.core.configuration import configuration from app.features.routers.models import Router from app.shared.components.toasts import httpx_error_toast from app.shared.states.entity_state import EntityState @@ -15,16 +14,14 @@ class RoutersState(EntityState): @rx.var def router_types_list(self) -> list[str]: """Get list of router types.""" - return sorted( - [ - "image-to-text", - "image-text-to-text", - "automatic-speech-recognition", - "text-embeddings-inference", - "text-generation", - "text-classification", - ] - ) + return sorted([ + "image-to-text", + "image-text-to-text", + "automatic-speech-recognition", + "text-embeddings-inference", + "text-generation", + "text-classification", + ]) @rx.var def router_load_balancing_strategies_list(self) -> list[str]: @@ -88,7 +85,7 @@ async def load_entities(self): f"{self.opengatellm_url}/v1/admin/routers", params=params, headers={"Authorization": f"Bearer {self.api_key}"}, - timeout=configuration.settings.playground_opengatellm_timeout, + timeout=self.opengatellm_timeout, ) response.raise_for_status() @@ -99,9 +96,9 @@ async def load_entities(self): if router["user_id"] not in self.router_owners: async with httpx.AsyncClient() as client: response = await client.get( - url=f"{self.opengatellm_url}/v1/admin/users/{router["user_id"]}", + url=f"{self.opengatellm_url}/v1/admin/users/{router['user_id']}", headers={"Authorization": f"Bearer {self.api_key}"}, - timeout=configuration.settings.playground_opengatellm_timeout, + timeout=self.opengatellm_timeout, ) if response.status_code == 404: self.router_owners[router["user_id"]] = "Master" @@ -152,7 +149,7 @@ async def delete_entity(self): response = await client.delete( url=f"{self.opengatellm_url}/v1/admin/routers/{self.entity_to_delete.id}", headers={"Authorization": f"Bearer {self.api_key}"}, - timeout=configuration.settings.playground_opengatellm_timeout, + timeout=self.opengatellm_timeout, ) response.raise_for_status() @@ -217,7 +214,7 @@ async def create_entity(self): url=f"{self.opengatellm_url}/v1/admin/routers", json=payload, headers={"Authorization": f"Bearer {self.api_key}"}, - timeout=configuration.settings.playground_opengatellm_timeout, + timeout=self.opengatellm_timeout, ) response.raise_for_status() @@ -285,7 +282,7 @@ async def edit_entity(self): url=f"{self.opengatellm_url}/v1/admin/routers/{self.entity.id}", json=payload, headers={"Authorization": f"Bearer {self.api_key}"}, - timeout=configuration.settings.playground_opengatellm_timeout, + timeout=self.opengatellm_timeout, ) response.raise_for_status() diff --git a/playground/app/features/usage/state.py b/playground/app/features/usage/state.py index 40e579e82..766ffefcb 100644 --- a/playground/app/features/usage/state.py +++ b/playground/app/features/usage/state.py @@ -6,7 +6,6 @@ import httpx import reflex as rx -from app.core.configuration import configuration from app.features.usage.models import Usage from app.shared.components.toasts import httpx_error_toast from app.shared.states.entity_state import EntityState @@ -64,7 +63,7 @@ async def load_entities(self): url=f"{self.opengatellm_url}/v1/me/usage", params=params, headers={"Authorization": f"Bearer {self.api_key}"}, - timeout=configuration.settings.playground_opengatellm_timeout, + timeout=self.opengatellm_timeout, ) response.raise_for_status() data = response.json() @@ -82,17 +81,15 @@ async def load_entities(self): def usage_rows(self) -> list[dict[str, Any]]: rows: list[dict[str, Any]] = [] for row in self.entities: - rows.append( - { - "date": row.created, - "endpoint": row.endpoint, - "key": row.key, - "model": row.model, - "tokens": "" if row.total_tokens == 0 else f"{row.prompt_tokens} → {row.completion_tokens}", - "cost": "" if row.cost == 0.0 or row.cost is None else f"{row.cost:.4f}", - "kgCO2eq": "" if row.kgco2eq is None else f"{round(row.kgco2eq, 5)}", - } - ) + rows.append({ + "date": row.created, + "endpoint": row.endpoint, + "key": row.key, + "model": row.model, + "tokens": "" if row.total_tokens == 0 else f"{row.prompt_tokens} → {row.completion_tokens}", + "cost": "" if row.cost == 0.0 or row.cost is None else f"{row.cost:.4f}", + "kgCO2eq": "" if row.kgco2eq is None else f"{round(row.kgco2eq, 5)}", + }) return rows ############################################################ diff --git a/playground/app/features/users/state.py b/playground/app/features/users/state.py index 409c8c28a..f6db0561b 100644 --- a/playground/app/features/users/state.py +++ b/playground/app/features/users/state.py @@ -3,7 +3,6 @@ import httpx import reflex as rx -from app.core.configuration import configuration from app.features.users.models import User from app.shared.components.toasts import httpx_error_toast from app.shared.states.entity_state import EntityState @@ -99,7 +98,7 @@ async def load_entities(self): response = await client.get( url=f"{self.opengatellm_url}/v1/admin/roles", headers={"Authorization": f"Bearer {self.api_key}"}, - timeout=configuration.settings.playground_opengatellm_timeout, + timeout=self.opengatellm_timeout, ) response.raise_for_status() @@ -121,7 +120,7 @@ async def load_entities(self): url=f"{self.opengatellm_url}/v1/admin/organizations", params={"offset": offset, "limit": 100}, headers={"Authorization": f"Bearer {self.api_key}"}, - timeout=configuration.settings.playground_opengatellm_timeout, + timeout=self.opengatellm_timeout, ) response.raise_for_status() @@ -138,7 +137,7 @@ async def load_entities(self): url=f"{self.opengatellm_url}/v1/admin/users", params=params, headers={"Authorization": f"Bearer {self.api_key}"}, - timeout=configuration.settings.playground_opengatellm_timeout, + timeout=self.opengatellm_timeout, ) response.raise_for_status() @@ -187,7 +186,7 @@ async def delete_entity(self): response = await client.delete( url=f"{self.opengatellm_url}/v1/admin/users/{self.entity_to_delete.id}", headers={"Authorization": f"Bearer {self.api_key}"}, - timeout=configuration.settings.playground_opengatellm_timeout, + timeout=self.opengatellm_timeout, ) response.raise_for_status() @@ -272,7 +271,7 @@ async def create_entity(self): url=f"{self.opengatellm_url}/v1/admin/users", json=payload, headers={"Authorization": f"Bearer {self.api_key}"}, - timeout=configuration.settings.playground_opengatellm_timeout, + timeout=self.opengatellm_timeout, ) response.raise_for_status() @@ -347,7 +346,7 @@ async def edit_entity(self): url=f"{self.opengatellm_url}/v1/admin/users/{self.entity.id}", json=payload, headers={"Authorization": f"Bearer {self.api_key}"}, - timeout=configuration.settings.playground_opengatellm_timeout, + timeout=self.opengatellm_timeout, ) response.raise_for_status() diff --git a/playground/nginx.conf b/playground/nginx.conf index 2bb19b2e8..e77018fca 100644 --- a/playground/nginx.conf +++ b/playground/nginx.conf @@ -20,6 +20,9 @@ server { proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; + # Forward ProConnect identity headers injected by oauth2-proxy + proxy_set_header X-Auth-Request-Email $http_x_auth_request_email; + proxy_set_header X-Auth-Request-User $http_x_auth_request_user; proxy_read_timeout 86400; } diff --git a/scripts/docs/configuration_header.md b/scripts/docs/configuration_header.md index ac9f43e1b..b5cdbdf24 100644 --- a/scripts/docs/configuration_header.md +++ b/scripts/docs/configuration_header.md @@ -4,6 +4,7 @@ sidebar: label: "[lucide:file-text] Configuration file" order: 0 --- +import { Tabs, TabItem } from '@astrojs/starlight/components'; OpenGateLLM requires configuring a configuration file. This defines models, dependencies, and settings parameters. Playground and API need a configuration file (could be the same file), see [API configuration](#api-configuration) and [Playground configuration](#playground-configuration). diff --git a/scripts/docs/generate_configuration_documentation.py b/scripts/docs/generate_configuration_documentation.py index 0c7ed0ca3..a77ebfc40 100644 --- a/scripts/docs/generate_configuration_documentation.py +++ b/scripts/docs/generate_configuration_documentation.py @@ -2,6 +2,9 @@ import os import sys +from pydantic import BaseModel, Field +from tabulate import tabulate + PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) sys.path.insert(0, PROJECT_ROOT) sys.path.insert(0, os.path.join(PROJECT_ROOT, "playground")) @@ -11,124 +14,245 @@ from app.core.configuration import ConfigFile as PlaygroundConfigFile # noqa: E402 # type: ignore parser = argparse.ArgumentParser() -parser.add_argument("--output", type=str, default=os.path.join("./docs/src/content/docs/configuration/configuration_file.md")) - - -def convert_field_to_string_if_dict(field): - if isinstance(field, dict): - return "`" + str(field) + "`" - return field - - -def get_documentation_data(title: str, data: list, properties: dict, defs: dict, header: str = "", level: int = 1): - # attribute, type, description, required, default, values, examples - table = list() - for property in sorted(properties): - description = properties[property].get("description", "") - description = description.replace("|", "\\|") - default = properties[property].get("default", properties[property].get("default", "**required**")) - default = f"`{default}`" if default != "**required**" else "**required**" - - examples = convert_field_to_string_if_dict(properties[property].get("examples", [""])[0]) - examples = f"`{examples}`" if examples != "" else "" - - if "anyOf" in properties[property]: - properties[property].update(properties[property]["anyOf"][0]) - - if "$ref" in properties[property]: - ref_key = properties[property]["$ref"].split("/")[-1] - ref = defs[ref_key] - type = ref.get("type", "") - values = ref.get("enum", []) - - # neested object section, get the data from the nested section - if "properties" in ref: - data = get_documentation_data( - title=ref_key, - data=data, - properties=ref["properties"], - defs=defs, - header=ref.get("description"), - level=level + 1, - ) - description += f" For details of configuration, see the [{ref_key} section](#{ref_key.lower().replace(' ', '-')})." +parser.add_argument("--output", type=str, default=os.path.join("./docs/src/content/docs/configuration/configuration_file.mdx")) - else: - type = properties[property].get("type", "") - values = properties[property].get("enum", []) - if type == "array" and "$ref" in properties[property]["items"]: - ref_key = properties[property]["items"]["$ref"].split("/")[-1] - ref = defs[ref_key] +class Row(BaseModel): + attribute: str + types: list[str] + description: str + default: str + values: list + examples: list - # neested array section, get the data from the nested section - if "properties" in ref: - data = get_documentation_data( - title=ref_key, - data=data, - properties=ref["properties"], - defs=defs, - header=ref.get("description"), - level=level + 1, - ) - description += f" For details of configuration, see the [{ref_key} section](#{ref_key.lower().replace(' ', '-')})." - else: - values = ref.get("enum", []) - values = [f"`{value}`" for value in values] - table.append([property, type, description, default, values, examples]) +class Table(BaseModel): + title: str + description: str + rows: list[Row] + tables: list["Table"] = Field(default_factory=list) # recursive field - data.append({"title": title, "table": table, "level": level, "header": header}) - return data +def get_description(property: dict, ref_keys: list[str]): + description = property.get("description", "") + for ref_key in ref_keys: + description += f" For details of configuration, see the [{ref_key} section](#{ref_key.lower().replace(' ', '-')})." + return description -def get_example_configuration(config_example: str): - data = f""" -## Example -The following is an example of configuration file: +def get_default(property: dict): + default = property.get("default", "required") + default = str(default) + return default -```yaml -{config_example} -``` -""" +def get_attribute(property: dict): + return property.get("title", "") + + +def get_types(property: dict): + type = property.get("type") + types = [] if type is None else [type] + if "anyOf" in property: + for any_of in property["anyOf"]: + if "type" in any_of: + types.append(any_of.get("type")) + + return list(set(types)) + + +def get_values(property: dict): + values = property.get("enum", []) + if "anyOf" in property: + for any_of in property["anyOf"]: + if "enum" in any_of: + values.extend(any_of.get("enum", [])) + + elif property.get("type") == "array": + values.extend(property.get("items", {}).get("enum", [])) + + elif "oneOf" in property: + for one_of in property["oneOf"]: + values.extend(one_of.get("enum", [])) + + return list(set(values)) - return data +def get_examples(property: dict): + return property.get("examples", []) -def convert_to_markdown(data: list): - markdown = "" - for item in reversed(data): - markdown += f"{'#' * (item['level'] + 1)} {item['title']}\n" - if item["header"]: - markdown += f"{item['header']}\n

\n\n" - if len(item["table"]) > 0: - markdown += "| Attribute | Type | Description | Default | Values | Examples |\n" - markdown += "| --- | --- | --- | --- | --- | --- |\n" - for row in item["table"]: - if len(row[4]) > 10: - row[4] = "• " + "

• ".join(row[4][:8]) + "

• ..." - elif len(row[4]) > 0: - row[4] = "• " + "

• ".join(row[4]) +def replace_enum_ref_by_enum_schema_and_extract_ref_keys(property: dict, enum_schemas: dict) -> tuple[dict, list[str]]: + def _extract_key(ref: str) -> str: + return ref.split("/")[-1] + + ref_keys = [] + if "$ref" in property: + if property["$ref"] in enum_schemas: + property.update(enum_schemas[property["$ref"]]) + property.pop("$ref") + else: + ref_key = _extract_key(ref=property["$ref"]) + ref_keys.append(ref_key) + + elif property.get("type") == "array" and "$ref" in property["items"]: + if property["items"]["$ref"] in enum_schemas: + property["items"] = enum_schemas[property["items"]["$ref"]] + else: + ref_key = _extract_key(ref=property["items"]["$ref"]) + ref_keys.append(ref_key) + + elif "anyOf" in property: + for i, any_of in enumerate(property["anyOf"]): + if "$ref" in any_of: + if any_of["$ref"] in enum_schemas: + property["anyOf"][i] = enum_schemas[any_of["$ref"]] + else: + ref_key = _extract_key(ref=any_of["$ref"]) + ref_keys.append(ref_key) + + elif "oneOf" in property: + for i, one_of in enumerate(property["oneOf"]): + if "$ref" in one_of: + if one_of["$ref"] in enum_schemas: + property["oneOf"][i] = enum_schemas[one_of["$ref"]] else: - row[4] = "" + ref_key = _extract_key(ref=one_of["$ref"]) + ref_keys.append(ref_key) + + return property, ref_keys + + +def build_row(attribute: str, property: dict, ref_keys: list[str]): + description = get_description(property=property, ref_keys=ref_keys) + default = get_default(property=property) + types = get_types(property=property) + values = get_values(property=property) + examples = get_examples(property=property) + row = Row(attribute=attribute, types=types, description=description, default=default, values=values, examples=examples) + + return row + + +def parse_schema(table: Table, properties: dict, defs: dict, enum_schemas: dict): + for attribute, property in properties.items(): + if property.get("deprecated", False): + continue + + property, ref_keys = replace_enum_ref_by_enum_schema_and_extract_ref_keys(property=property, enum_schemas=enum_schemas) + row = build_row(attribute=attribute, property=property, ref_keys=ref_keys) + table.rows.append(row) + + for ref_key in ref_keys: + if "properties" in defs[ref_key]: + sub_table = Table(title=defs[ref_key].get("title", ""), description=get_description(defs[ref_key], ref_keys=[]), rows=[], tables=[]) + sub_table = parse_schema(table=sub_table, properties=defs[ref_key]["properties"], defs=defs, enum_schemas=enum_schemas) + table.tables.append(sub_table) + + return table + + +def handle_acorn(text: str) -> str: + text = text.replace("{", "\\{") + text = text.replace("}", "\\}") + return text + - markdown += "| " + " | ".join(str(cell) for cell in row) + " |\n" +def format_examples(examples: list) -> str: + if len(examples) > 0: + example = str(examples[0]) + example = handle_acorn(text=example) + return example + else: + return "" - elif item["header"] == "": - markdown += "No settings." - markdown += "\n

\n\n" +def format_description(description: str): + description = handle_acorn(text=description) + return description.replace("|", "\\|") + + +def format_default(default: str) -> str: + default = handle_acorn(text=default) + default = default if default != "required" else "**required**" + return default + + +def format_values(values: list) -> str: + values = [handle_acorn(text=value) for value in values] + if len(values) > 10: + return "• " + "

• ".join(values[:8]) + "

• ..." + elif len(values) > 0: + return "• " + "

• ".join(values) + else: + return "" + + +def format_types(types: list) -> str: + types = [handle_acorn(text=type) for type in types] + return ", ".join(types) + + +def format_row(row: Row): + row = row.model_dump() + row["description"] = format_description(row["description"]) + row["default"] = format_default(row["default"]) + row["values"] = format_values(row["values"]) + row["examples"] = format_examples(row["examples"]) + row["types"] = format_types(row["types"]) + row = [value for key, value in row.items()] + + return row + + +def convert_to_markdown(table: Table, markdown: str = "", level: int = 1): + breakline_small = "\n\n" + breakline_large = "\n\n

\n\n" + level += 1 + markdown += f"{'#' * level} {table.title}{breakline_small}" + markdown += f"{table.description}{breakline_large}" + + if len(table.rows) == 0: + markdown += f"**No settings.**{breakline_large}" + return markdown + + md_table = tabulate( + tabular_data=[format_row(row) for row in table.rows], + headers=["Attribute", "Type", "Description", "Default", "Values", "Examples"], + tablefmt="pipe", + ) + markdown += f"{md_table}{breakline_large}" + + if table.tables: + markdown += "\n" if len(table.tables) > 1 else "" + for sub_table in table.tables: + markdown += f'\n\n' if len(table.tables) > 1 else "" + markdown = convert_to_markdown(table=sub_table, markdown=markdown, level=level) + markdown += "\n" if len(table.tables) > 1 else "" + markdown += f"{breakline_small}" if len(table.tables) > 1 else "" + + return markdown + + +def get_example_configuration(config_example: str): + markdown = f""" +## Example + +The following is an example of configuration file: + +```yaml +{config_example} +``` + +""" return markdown if __name__ == "__main__": args = parser.parse_args() - assert args.output.endswith(".md"), f"Output file must end with .md ({args.output})" + assert args.output.endswith(".mdx"), f"Output file must end with .md ({args.output})" assert os.path.exists(os.path.dirname(args.output)), f"Output directory does not exist ({os.path.dirname(args.output)})" with open(file=os.path.join("./scripts/docs/configuration_header.md")) as f: @@ -139,27 +263,22 @@ def convert_to_markdown(data: list): with open(file=os.path.join("config.example.yml")) as f: config_example = f.read() f.close() + markdown += get_example_configuration(config_example=config_example) schema = ApiConfigFile.model_json_schema() - api_data = get_documentation_data( - title="API configuration", - data=[], - properties=schema["properties"], - header=schema.get("description", ""), - defs=schema["$defs"], - ) - markdown += convert_to_markdown(data=api_data) + table = Table(title="API configuration", description=schema.get("description", ""), rows=[], tables=[]) + enum_schemas = {f"#/$defs/{attribute}": schema["$defs"][attribute] for attribute in schema["$defs"] if "enum" in schema["$defs"][attribute]} + + table = parse_schema(table=table, properties=schema["properties"], defs=schema["$defs"], enum_schemas=enum_schemas) + markdown += convert_to_markdown(table=table) schema = PlaygroundConfigFile.model_json_schema() - playground_data = get_documentation_data( - title="Playground configuration", - data=[], - properties=schema["properties"], - header=schema.get("description", ""), - defs=schema["$defs"], - ) - markdown += convert_to_markdown(data=playground_data) + table = Table(title="Playground configuration", description=schema.get("description", ""), rows=[], tables=[]) + enum_schemas = {f"#/$defs/{attribute}": schema["$defs"][attribute] for attribute in schema["$defs"] if "enum" in schema["$defs"][attribute]} + + table = parse_schema(table=table, properties=schema["properties"], defs=schema["$defs"], enum_schemas=enum_schemas) + markdown += convert_to_markdown(table=table) with open(file=args.output, mode="w") as f: f.write(markdown)