Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,10 @@ AWS_S3_BUCKET_NAME="uploads"
FILE_SIZE_LIMIT=5242880

# GPT settings
LLM_PROVIDER="openai"
LLM_MODEL="gpt-4o-mini"
LLM_MODELS=""
LLM_API_BASE=""
OPENAI_API_BASE="https://api.openai.com/v1" # deprecated
OPENAI_API_KEY="sk-" # deprecated
GPT_ENGINE="gpt-3.5-turbo" # deprecated
Comment thread
davidmz marked this conversation as resolved.
Expand Down
6 changes: 6 additions & 0 deletions apps/api/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -73,3 +73,9 @@ MINIO_ENDPOINT_SSL=0

# API key rate limit
API_KEY_RATE_LIMIT="60/minute"

# LLM settings
LLM_PROVIDER="openai"
LLM_MODEL="gpt-4o-mini"
LLM_MODELS=""
LLM_API_BASE=""
Comment thread
davidmz marked this conversation as resolved.
36 changes: 21 additions & 15 deletions apps/api/plane/app/views/external/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,10 +73,10 @@ class GeminiProvider(LLMProvider):
}


def get_llm_config() -> Tuple[str | None, str | None, str | None]:
def get_llm_config() -> Tuple[str | None, str | None, str | None, str | None]:
"""
Helper to get LLM configuration values, returns:
- api_key, model, provider
- api_key, model, provider, api_base
"""
api_key, provider_key, model = get_configuration_value(
[
Expand All @@ -94,41 +94,47 @@ def get_llm_config() -> Tuple[str | None, str | None, str | None]:
},
]
)
models = os.environ.get("LLM_MODELS")
api_base = os.environ.get("LLM_API_BASE")

Comment thread
davidmz marked this conversation as resolved.
provider = SUPPORTED_PROVIDERS.get(provider_key.lower())
if not provider:
log_exception(ValueError(f"Unsupported provider: {provider_key}"))
return None, None, None
return None, None, None, None

if not api_key:
log_exception(ValueError(f"Missing API key for provider: {provider.name}"))
return None, None, None
return None, None, None, None

# If no model specified, use provider's default
if not model:
model = provider.default_model

# Validate model is supported by provider
if model not in provider.models:
supported_models = [item.strip() for item in models.split(",") if item.strip()] if models else provider.models

# Validate model is supported by provider or the configured override
if model not in supported_models:
log_exception(
ValueError(
f"Model {model} not supported by {provider.name}. Supported models: {', '.join(provider.models)}"
f"Model {model} not supported by {provider.name}. Supported models: {', '.join(supported_models)}"
)
)
return None, None, None
return None, None, None, None

return api_key, model, provider_key
return api_key, model, provider_key, api_base


def get_llm_response(task, prompt, api_key: str, model: str, provider: str) -> Tuple[str | None, str | None]:
def get_llm_response(
task, prompt, api_key: str, model: str, provider: str, api_base: str | None = None
) -> Tuple[str | None, str | None]:
"""Helper to get LLM completion response"""
final_text = task + "\n" + prompt
try:
# For Gemini, prepend provider name to model
if provider.lower() == "gemini":
model = f"gemini/{model}"

client = OpenAI(api_key=api_key)
client = OpenAI(api_key=api_key, base_url=api_base)
chat_completion = client.chat.completions.create(
model=model, messages=[{"role": "user", "content": final_text}]
)
Expand All @@ -148,7 +154,7 @@ def get_llm_response(task, prompt, api_key: str, model: str, provider: str) -> T
class GPTIntegrationEndpoint(BaseAPIView):
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
def post(self, request, slug, project_id):
api_key, model, provider = get_llm_config()
api_key, model, provider, api_base = get_llm_config()

if not api_key or not model or not provider:
return Response(
Expand All @@ -160,7 +166,7 @@ def post(self, request, slug, project_id):
if not task:
return Response({"error": "Task is required"}, status=status.HTTP_400_BAD_REQUEST)

text, error = get_llm_response(task, request.data.get("prompt", False), api_key, model, provider)
text, error = get_llm_response(task, request.data.get("prompt", False), api_key, model, provider, api_base)
if not text and error:
return Response(
{"error": "An internal error has occurred."},
Expand All @@ -184,7 +190,7 @@ def post(self, request, slug, project_id):
class WorkspaceGPTIntegrationEndpoint(BaseAPIView):
@allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE")
def post(self, request, slug):
api_key, model, provider = get_llm_config()
api_key, model, provider, api_base = get_llm_config()

if not api_key or not model or not provider:
return Response(
Expand All @@ -196,7 +202,7 @@ def post(self, request, slug):
if not task:
return Response({"error": "Task is required"}, status=status.HTTP_400_BAD_REQUEST)

text, error = get_llm_response(task, request.data.get("prompt", False), api_key, model, provider)
text, error = get_llm_response(task, request.data.get("prompt", False), api_key, model, provider, api_base)
if not text and error:
return Response(
{"error": "An internal error has occurred."},
Expand Down
78 changes: 78 additions & 0 deletions apps/api/plane/tests/unit/views/test_external_llm.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.

from unittest.mock import MagicMock, patch

import pytest

from plane.app.views.external.base import get_llm_config, get_llm_response


@pytest.mark.unit
class TestLLMConfiguration:
@patch("plane.app.views.external.base.get_configuration_value")
def test_uses_provider_models_without_override(self, mock_get_configuration_value, monkeypatch):
monkeypatch.delenv("LLM_MODELS", raising=False)
monkeypatch.delenv("LLM_API_BASE", raising=False)
Comment thread
davidmz marked this conversation as resolved.
mock_get_configuration_value.return_value = (
"api-key",
"openai",
"gpt-4o-mini",
)

assert get_llm_config() == ("api-key", "gpt-4o-mini", "openai", None)

@patch("plane.app.views.external.base.get_configuration_value")
def test_uses_configured_model_override(self, mock_get_configuration_value, monkeypatch):
monkeypatch.setenv("LLM_MODELS", "gpt-5-mini, qwen3-235b")
monkeypatch.setenv("LLM_API_BASE", "https://example.com/v1")
mock_get_configuration_value.return_value = (
"api-key",
"openai",
"qwen3-235b",
)

assert get_llm_config() == (
"api-key",
"qwen3-235b",
"openai",
"https://example.com/v1",
)

@patch("plane.app.views.external.base.log_exception")
@patch("plane.app.views.external.base.get_configuration_value")
def test_rejects_model_outside_configured_override(
self, mock_get_configuration_value, mock_log_exception, monkeypatch
):
monkeypatch.setenv("LLM_MODELS", "gpt-5-mini,qwen3-235b")
monkeypatch.delenv("LLM_API_BASE", raising=False)
mock_get_configuration_value.return_value = (
"api-key",
"openai",
"gpt-4o-mini",
)

assert get_llm_config() == (None, None, None, None)
mock_log_exception.assert_called_once()

@patch("plane.app.views.external.base.OpenAI")
def test_uses_default_client_without_api_base(self, mock_openai):
completion = MagicMock()
completion.choices[0].message.content = "response"
mock_openai.return_value.chat.completions.create.return_value = completion

assert get_llm_response("task", "prompt", "api-key", "gpt-4o-mini", "openai") == ("response", None)
mock_openai.assert_called_once_with(api_key="api-key", base_url=None)

@patch("plane.app.views.external.base.OpenAI")
def test_passes_configured_api_base_to_client(self, mock_openai):
completion = MagicMock()
completion.choices[0].message.content = "response"
mock_openai.return_value.chat.completions.create.return_value = completion

assert get_llm_response("task", "prompt", "api-key", "qwen3-235b", "openai", "https://example.com/v1") == (
"response",
None,
)
mock_openai.assert_called_once_with(api_key="api-key", base_url="https://example.com/v1")
20 changes: 20 additions & 0 deletions deployments/aio/community/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,26 @@ docker run --name myaio --rm -it \

- `API_KEY_RATE_LIMIT`: API key rate limit (default: `60/minute`)

#### LLM Configuration

God Mode exposes only the API key and selected model. `LLM_MODELS` and `LLM_API_BASE` are not available there and must be configured through environment variables. The provider can also be initialized through the environment:

- `LLM_PROVIDER`: Built-in provider configuration (default: `openai`). Use `openai` for an OpenAI-compatible API. Plane imports this value when it first creates the instance configuration.
- `LLM_MODEL`: Model used for AI requests (default: `gpt-4o-mini`). Plane imports this value when it first creates the instance configuration; later changes can be made in God Mode.
- `LLM_MODELS`: Comma-separated model allowlist that replaces the provider's built-in list. Leave it empty to preserve the built-in allowlist.
Comment thread
davidmz marked this conversation as resolved.
Outdated
- `LLM_API_BASE`: API base URL override. Leave it empty to use the OpenAI client's default endpoint.

Example:

```env
LLM_PROVIDER=openai
LLM_MODEL=gpt-5-mini
LLM_MODELS=gpt-5-mini,gpt-5.4-nano,qwen3-235b
LLM_API_BASE=https://example.com/v1
```

`LLM_MODELS` and `LLM_API_BASE` are read directly from the environment. Restart the container after changing them.

## Port Mapping

The following ports are exposed:
Expand Down
6 changes: 6 additions & 0 deletions deployments/aio/community/variables.env
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,12 @@ MINIO_ENDPOINT_SSL=0
# API key rate limit
API_KEY_RATE_LIMIT=60/minute

# LLM settings
LLM_PROVIDER=openai
LLM_MODEL=gpt-4o-mini
LLM_MODELS=
LLM_API_BASE=

Comment thread
davidmz marked this conversation as resolved.
# Per-IP throttle for anonymous authentication endpoints (magic-link
# generate / sign-in / sign-up, email sign-in). DRF format: "<n>/<period>"
# where period is second/minute/hour/day.
Expand Down
24 changes: 22 additions & 2 deletions deployments/cli/community/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,24 @@ Below are the most import keys you must refer to. _<span style="color: #fcba03">

> `CORS_ALLOWED_ORIGINS` - This is default set to `http://localhost`. Change this to the FQDN you plan to use along with LISTEN_HTTP_PORT (eg. `https://plane.example.com:8080` or `http://[IP-ADDRESS]:8080`)

#### LLM settings

God Mode exposes only the API key and selected model. `LLM_MODELS` and `LLM_API_BASE` are not available there and must be configured through `plane.env`. The provider can also be initialized through `plane.env`:

```env
LLM_PROVIDER=openai
LLM_MODEL=gpt-5-mini
Comment thread
davidmz marked this conversation as resolved.
Outdated
LLM_MODELS=gpt-5-mini,gpt-5.4-nano,qwen3-235b
LLM_API_BASE=https://example.com/v1
```

- `LLM_PROVIDER` selects a built-in provider configuration. Use `openai` for an OpenAI-compatible API. Plane imports this value when it first creates the instance configuration.
- `LLM_MODEL` selects the model used for AI requests. Plane imports this value when it first creates the instance configuration; later changes can be made in God Mode.
- `LLM_MODELS` optionally replaces the provider's built-in model allowlist with a comma-separated list. Leave it empty to preserve the built-in allowlist.
- `LLM_API_BASE` optionally overrides the API base URL. Leave it empty to use the OpenAI client's default endpoint.

`LLM_MODELS` and `LLM_API_BASE` are read directly from the environment. Restart the services after changing them.

There are many other settings you can play with, but we suggest you configure `EMAIL SETTINGS` as it will enable you to invite your teammates onto the platform.

---
Expand Down Expand Up @@ -420,7 +438,9 @@ api-1 | EMAIL_PORT loaded with value from environment variable.
api-1 | EMAIL_FROM loaded with value from environment variable.
api-1 | EMAIL_USE_TLS loaded with value from environment variable.
api-1 | EMAIL_USE_SSL loaded with value from environment variable.
api-1 | OPENAI_API_KEY loaded with value from environment variable.
api-1 | LLM_API_KEY loaded with value from environment variable.
api-1 | LLM_PROVIDER loaded with value from environment variable.
api-1 | LLM_MODEL loaded with value from environment variable.
api-1 | GPT_ENGINE loaded with value from environment variable.
api-1 | UNSPLASH_ACCESS_KEY loaded with value from environment variable.
api-1 | Checking bucket...
Expand Down Expand Up @@ -627,4 +647,4 @@ In case the suffixes are wrong or the mentioned volumes are not found, you will
In case of successful migration, it will be a silent exit without error.

Now its time to restart v0.14.0 setup.
</details>
</details>
4 changes: 4 additions & 0 deletions deployments/cli/community/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,10 @@ x-app-env: &app-env
LIVE_SERVER_SECRET_KEY: ${LIVE_SERVER_SECRET_KEY}
WEBHOOK_ALLOWED_IPS: ${WEBHOOK_ALLOWED_IPS:-}
WEBHOOK_ALLOWED_HOSTS: ${WEBHOOK_ALLOWED_HOSTS:-}
LLM_PROVIDER: ${LLM_PROVIDER:-openai}
LLM_MODEL: ${LLM_MODEL:-gpt-4o-mini}
LLM_MODELS: ${LLM_MODELS:-}
LLM_API_BASE: ${LLM_API_BASE:-}
Comment thread
davidmz marked this conversation as resolved.

services:
web:
Expand Down
6 changes: 6 additions & 0 deletions deployments/cli/community/variables.env
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,12 @@ MINIO_ENDPOINT_SSL=0
# API key rate limit
API_KEY_RATE_LIMIT=60/minute

# LLM settings
LLM_PROVIDER=openai
LLM_MODEL=gpt-4o-mini
LLM_MODELS=
LLM_API_BASE=

Comment thread
davidmz marked this conversation as resolved.
# Per-IP throttle for anonymous authentication endpoints (magic-link
# generate / sign-in / sign-up, email sign-in). DRF format: "<n>/<period>"
# where period is second/minute/hour/day.
Expand Down