Skip to content

Commit 0530ee7

Browse files
plane-botaaryan610Palanikannan1437NarayanBavisettiakash-plane
authored
Sync: Community Changes (#2045)
* chore: add live server prettier config (#6287) * [PE-97] refactor: pages actions (#6234) * dev: support for edition specific options in pages * refactor: page quick actions * chore: add customizable page actions * fix: type errors * dev: hook to get page operations * refactor: remove unnecessary props * chore: add permisssions to duplicate page endpoint * chore: memoize arranged options * chore: use enum for page access * chore: add type assertion * fix: auth for access change and delete * fix: removing readonly editor * chore: add sync for page access cahnge * fix: sync state * fix: indexeddb sync loader added * fix: remove node error fixed * style: page title and checkbox * chore: removing the syncing logic * revert: is editable check removed in display message * fix: editable field optional * fix: editable removed as optional prop * fix: extra options import fix * fix: remove readonly stuff * fix: added toggle access * chore: add access change sync * fix: full width toggle * refactor: types and enums added * refactore: update store action * chore: changed the duplicate viewset * fix: remove the page binary * fix: duplicate page action * fix: merge conflicts --------- Co-authored-by: Palanikannan M <akashmalinimurugu@gmail.com> Co-authored-by: NarayanBavisetti <narayan3119@gmail.com> * Integrates LiteLLM for Unified Access to Multiple LLM Models (#5925) * adds litellm gateway * Fixes repeating code * Fixes error exposing * Fixes error for None text * handles logging exception * Adds multiple providers support * handling edge cases * adds new envs to instance store * strategy pattern for llm config --------- Co-authored-by: akash5100 <akashzsh08@gmail.com> * chore: merge two separate info popovers (#6289) --------- Co-authored-by: Aaryan Khandelwal <65252264+aaryan610@users.noreply.github.com> Co-authored-by: Palanikannan M <akashmalinimurugu@gmail.com> Co-authored-by: NarayanBavisetti <narayan3119@gmail.com> Co-authored-by: Akash Verma <akash.verma@plane.so> Co-authored-by: akash5100 <akashzsh08@gmail.com> Co-authored-by: Nikhil <118773738+pablohashescobar@users.noreply.github.com>
1 parent efcbc33 commit 0530ee7

File tree

33 files changed

+975
-554
lines changed

33 files changed

+975
-554
lines changed

apiserver/plane/app/serializers/page.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,8 @@ def create(self, validated_data):
5656
labels = validated_data.pop("labels", None)
5757
project_id = self.context["project_id"]
5858
owned_by_id = self.context["owned_by_id"]
59+
description = self.context["description"]
60+
description_binary = self.context["description_binary"]
5961
description_html = self.context["description_html"]
6062

6163
# Get the workspace id from the project
@@ -64,6 +66,8 @@ def create(self, validated_data):
6466
# Create the page
6567
page = Page.objects.create(
6668
**validated_data,
69+
description=description,
70+
description_binary=description_binary,
6771
description_html=description_html,
6872
owned_by_id=owned_by_id,
6973
workspace_id=project.workspace_id,

apiserver/plane/app/urls/page.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
SubPagesEndpoint,
99
PagesDescriptionViewSet,
1010
PageVersionEndpoint,
11+
PageDuplicateEndpoint,
1112
)
1213

1314

@@ -84,4 +85,9 @@
8485
PageVersionEndpoint.as_view(),
8586
name="page-versions",
8687
),
88+
path(
89+
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/<uuid:page_id>/duplicate/",
90+
PageDuplicateEndpoint.as_view(),
91+
name="page-duplicate",
92+
),
8793
]

apiserver/plane/app/views/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,7 @@
155155
PageLogEndpoint,
156156
SubPagesEndpoint,
157157
PagesDescriptionViewSet,
158+
PageDuplicateEndpoint,
158159
)
159160
from .page.version import PageVersionEndpoint
160161

apiserver/plane/app/views/external/base.py

Lines changed: 152 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -1,71 +1,169 @@
1-
# Python imports
2-
import requests
1+
# Python import
32
import os
3+
from typing import List, Dict, Tuple
4+
5+
# Third party import
6+
import litellm
7+
import requests
48

5-
# Third party imports
6-
from openai import OpenAI
7-
from rest_framework.response import Response
89
from rest_framework import status
10+
from rest_framework.response import Response
911

10-
# Django imports
12+
# Module import
13+
from plane.app.permissions import ROLE, allow_permission
14+
from plane.app.serializers import (ProjectLiteSerializer,
15+
WorkspaceLiteSerializer)
16+
from plane.db.models import Project, Workspace
17+
from plane.license.utils.instance_value import get_configuration_value
18+
from plane.utils.exception_logger import log_exception
1119

12-
# Module imports
1320
from ..base import BaseAPIView
14-
from plane.app.permissions import allow_permission, ROLE
15-
from plane.db.models import Workspace, Project
16-
from plane.app.serializers import ProjectLiteSerializer, WorkspaceLiteSerializer
17-
from plane.license.utils.instance_value import get_configuration_value
1821

1922

23+
class LLMProvider:
24+
"""Base class for LLM provider configurations"""
25+
name: str = ""
26+
models: List[str] = []
27+
default_model: str = ""
28+
29+
@classmethod
30+
def get_config(cls) -> Dict[str, str | List[str]]:
31+
return {
32+
"name": cls.name,
33+
"models": cls.models,
34+
"default_model": cls.default_model,
35+
}
36+
37+
class OpenAIProvider(LLMProvider):
38+
name = "OpenAI"
39+
models = ["gpt-3.5-turbo", "gpt-4o-mini", "gpt-4o", "o1-mini", "o1-preview"]
40+
default_model = "gpt-4o-mini"
41+
42+
class AnthropicProvider(LLMProvider):
43+
name = "Anthropic"
44+
models = [
45+
"claude-3-5-sonnet-20240620",
46+
"claude-3-haiku-20240307",
47+
"claude-3-opus-20240229",
48+
"claude-3-sonnet-20240229",
49+
"claude-2.1",
50+
"claude-2",
51+
"claude-instant-1.2",
52+
"claude-instant-1"
53+
]
54+
default_model = "claude-3-sonnet-20240229"
55+
56+
class GeminiProvider(LLMProvider):
57+
name = "Gemini"
58+
models = ["gemini-pro", "gemini-1.5-pro-latest", "gemini-pro-vision"]
59+
default_model = "gemini-pro"
60+
61+
SUPPORTED_PROVIDERS = {
62+
"openai": OpenAIProvider,
63+
"anthropic": AnthropicProvider,
64+
"gemini": GeminiProvider,
65+
}
66+
67+
def get_llm_config() -> Tuple[str | None, str | None, str | None]:
68+
"""
69+
Helper to get LLM configuration values, returns:
70+
- api_key, model, provider
71+
"""
72+
api_key, provider_key, model = get_configuration_value([
73+
{
74+
"key": "LLM_API_KEY",
75+
"default": os.environ.get("LLM_API_KEY", None),
76+
},
77+
{
78+
"key": "LLM_PROVIDER",
79+
"default": os.environ.get("LLM_PROVIDER", "openai"),
80+
},
81+
{
82+
"key": "LLM_MODEL",
83+
"default": os.environ.get("LLM_MODEL", None),
84+
},
85+
])
86+
87+
provider = SUPPORTED_PROVIDERS.get(provider_key.lower())
88+
if not provider:
89+
log_exception(ValueError(f"Unsupported provider: {provider_key}"))
90+
return None, None, None
91+
92+
if not api_key:
93+
log_exception(ValueError(f"Missing API key for provider: {provider.name}"))
94+
return None, None, None
95+
96+
# If no model specified, use provider's default
97+
if not model:
98+
model = provider.default_model
99+
100+
# Validate model is supported by provider
101+
if model not in provider.models:
102+
log_exception(ValueError(
103+
f"Model {model} not supported by {provider.name}. "
104+
f"Supported models: {', '.join(provider.models)}"
105+
))
106+
return None, None, None
107+
108+
return api_key, model, provider_key
109+
110+
111+
def get_llm_response(task, prompt, api_key: str, model: str, provider: str) -> Tuple[str | None, str | None]:
112+
"""Helper to get LLM completion response"""
113+
final_text = task + "\n" + prompt
114+
try:
115+
# For Gemini, prepend provider name to model
116+
if provider.lower() == "gemini":
117+
model = f"gemini/{model}"
118+
119+
response = litellm.completion(
120+
model=model,
121+
messages=[{"role": "user", "content": final_text}],
122+
api_key=api_key,
123+
)
124+
text = response.choices[0].message.content.strip()
125+
return text, None
126+
except Exception as e:
127+
log_exception(e)
128+
error_type = e.__class__.__name__
129+
if error_type == "AuthenticationError":
130+
return None, f"Invalid API key for {provider}"
131+
elif error_type == "RateLimitError":
132+
return None, f"Rate limit exceeded for {provider}"
133+
else:
134+
return None, f"Error occurred while generating response from {provider}"
135+
20136
class GPTIntegrationEndpoint(BaseAPIView):
21137
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
22138
def post(self, request, slug, project_id):
23-
OPENAI_API_KEY, GPT_ENGINE = get_configuration_value(
24-
[
25-
{
26-
"key": "OPENAI_API_KEY",
27-
"default": os.environ.get("OPENAI_API_KEY", None),
28-
},
29-
{
30-
"key": "GPT_ENGINE",
31-
"default": os.environ.get("GPT_ENGINE", "gpt-4o-mini"),
32-
},
33-
]
34-
)
139+
api_key, model, provider = get_llm_config()
35140

36-
# Get the configuration value
37-
# Check the keys
38-
if not OPENAI_API_KEY or not GPT_ENGINE:
141+
if not api_key or not model or not provider:
39142
return Response(
40-
{"error": "OpenAI API key and engine is required"},
143+
{"error": "LLM provider API key and model are required"},
41144
status=status.HTTP_400_BAD_REQUEST,
42145
)
43146

44-
prompt = request.data.get("prompt", False)
45147
task = request.data.get("task", False)
46-
47148
if not task:
48149
return Response(
49150
{"error": "Task is required"}, status=status.HTTP_400_BAD_REQUEST
50151
)
51152

52-
final_text = task + "\n" + prompt
53-
54-
client = OpenAI(api_key=OPENAI_API_KEY)
55-
56-
response = client.chat.completions.create(
57-
model=GPT_ENGINE, messages=[{"role": "user", "content": final_text}]
58-
)
153+
text, error = get_llm_response(task, request.data.get("prompt", False), api_key, model, provider)
154+
if not text and error:
155+
return Response(
156+
{"error": "An internal error has occurred."},
157+
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
158+
)
59159

60160
workspace = Workspace.objects.get(slug=slug)
61161
project = Project.objects.get(pk=project_id)
62162

63-
text = response.choices[0].message.content.strip()
64-
text_html = text.replace("\n", "<br/>")
65163
return Response(
66164
{
67165
"response": text,
68-
"response_html": text_html,
166+
"response_html": text.replace("\n", "<br/>"),
69167
"project_detail": ProjectLiteSerializer(project).data,
70168
"workspace_detail": WorkspaceLiteSerializer(workspace).data,
71169
},
@@ -76,47 +174,34 @@ def post(self, request, slug, project_id):
76174
class WorkspaceGPTIntegrationEndpoint(BaseAPIView):
77175
@allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE")
78176
def post(self, request, slug):
79-
OPENAI_API_KEY, GPT_ENGINE = get_configuration_value(
80-
[
81-
{
82-
"key": "OPENAI_API_KEY",
83-
"default": os.environ.get("OPENAI_API_KEY", None),
84-
},
85-
{
86-
"key": "GPT_ENGINE",
87-
"default": os.environ.get("GPT_ENGINE", "gpt-4o-mini"),
88-
},
89-
]
90-
)
177+
api_key, model, provider = get_llm_config()
178+
179+
if not api_key or not model or not provider:
91180

92-
# Get the configuration value
93-
# Check the keys
94-
if not OPENAI_API_KEY or not GPT_ENGINE:
95181
return Response(
96-
{"error": "OpenAI API key and engine is required"},
182+
{"error": "LLM provider API key and model are required"},
97183
status=status.HTTP_400_BAD_REQUEST,
98184
)
99185

100-
prompt = request.data.get("prompt", False)
101186
task = request.data.get("task", False)
102-
103187
if not task:
104188
return Response(
105189
{"error": "Task is required"}, status=status.HTTP_400_BAD_REQUEST
106190
)
107191

108-
final_text = task + "\n" + prompt
109-
110-
client = OpenAI(api_key=OPENAI_API_KEY)
111-
112-
response = client.chat.completions.create(
113-
model=GPT_ENGINE, messages=[{"role": "user", "content": final_text}]
114-
)
192+
text, error = get_llm_response(task, request.data.get("prompt", False), api_key, model, provider)
193+
if not text and error:
194+
return Response(
195+
{"error": "An internal error has occurred."},
196+
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
197+
)
115198

116-
text = response.choices[0].message.content.strip()
117-
text_html = text.replace("\n", "<br/>")
118199
return Response(
119-
{"response": text, "response_html": text_html}, status=status.HTTP_200_OK
200+
{
201+
"response": text,
202+
"response_html": text.replace("\n", "<br/>"),
203+
},
204+
status=status.HTTP_200_OK,
120205
)
121206

122207

apiserver/plane/app/views/page/base.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,8 @@ def create(self, request, slug, project_id):
129129
context={
130130
"project_id": project_id,
131131
"owned_by_id": request.user.id,
132+
"description": request.data.get("description", {}),
133+
"description_binary": request.data.get("description_binary", None),
132134
"description_html": request.data.get("description_html", "<p></p>"),
133135
},
134136
)
@@ -565,3 +567,37 @@ def partial_update(self, request, slug, project_id, pk):
565567
return Response({"message": "Updated successfully"})
566568
else:
567569
return Response({"error": "No binary data provided"})
570+
571+
572+
class PageDuplicateEndpoint(BaseAPIView):
573+
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
574+
def post(self, request, slug, project_id, page_id):
575+
page = Page.objects.filter(
576+
pk=page_id, workspace__slug=slug, projects__id=project_id
577+
).first()
578+
579+
# get all the project ids where page is present
580+
project_ids = ProjectPage.objects.filter(page_id=page_id).values_list(
581+
"project_id", flat=True
582+
)
583+
584+
page.pk = None
585+
page.name = f"{page.name} (Copy)"
586+
page.description_binary = None
587+
page.save()
588+
589+
for project_id in project_ids:
590+
ProjectPage.objects.create(
591+
workspace_id=page.workspace_id,
592+
project_id=project_id,
593+
page_id=page.id,
594+
created_by_id=page.created_by_id,
595+
updated_by_id=page.updated_by_id,
596+
)
597+
598+
page_transaction.delay(
599+
{"description_html": page.description_html}, None, page.id
600+
)
601+
page = Page.objects.get(pk=page.id)
602+
serializer = PageDetailSerializer(page)
603+
return Response(serializer.data, status=status.HTTP_201_CREATED)

0 commit comments

Comments
 (0)