diff --git a/.github/workflows/cloud-staging-build.yaml b/.github/workflows/cloud-staging-build.yaml index 620d809d68..094dd94a77 100644 --- a/.github/workflows/cloud-staging-build.yaml +++ b/.github/workflows/cloud-staging-build.yaml @@ -118,8 +118,6 @@ jobs: - name: Setup extension run: | cd tmp_extension - npm install - npm run compile:ts npm install -g @vscode/vsce@3.3.2 npm run build vsce package @@ -182,4 +180,4 @@ jobs: git commit -m "version $VERSION" git push origin staging fi - cd .. \ No newline at end of file + cd .. diff --git a/CHANGELOG.md b/CHANGELOG.md index eb0a9e840c..394c3a1c4e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,9 @@ -# (2025-08-18) +# (2025-08-25) + + +### Reverts + +* Revert "Implemented weekend discount" ([734e0c7](https://github.com/Pythagora-io/pythagora-v1/commit/734e0c726b179a45f235a0fd230a6310c77ae740)) diff --git a/README.md b/README.md index b97572b1ff..8e6e789e80 100644 --- a/README.md +++ b/README.md @@ -8,9 +8,9 @@
-[![Discord Follow](https://dcbadge.vercel.app/api/server/HaqXugmxr9?style=flat)](https://discord.gg/HaqXugmxr9) +[![Discord](https://img.shields.io/badge/Discord-Join%20Us-5865F2?style=social&logo=discord)](https://discord.gg/HaqXugmxr9) [![GitHub Repo stars](https://img.shields.io/github/stars/Pythagora-io/gpt-pilot?style=social)](https://github.com/Pythagora-io/gpt-pilot) -[![Twitter Follow](https://img.shields.io/twitter/follow/HiPythagora?style=social)](https://twitter.com/HiPythagora) +[![Twitter Follow](https://img.shields.io/twitter/follow/PythagoraAI?style=social)](https://x.com/PythagoraAI)
@@ -28,17 +28,25 @@
- + ### GPT Pilot doesn't just generate code, it builds apps!
+
+ +This repo is not being maintained anymore. + +# Visit [Pythagora.ai](https://www.pythagora.ai/) for more info + +
+ ---
-[![See it in action](https://i3.ytimg.com/vi/4g-1cPGK0GA/maxresdefault.jpg)](https://youtu.be/4g-1cPGK0GA) +[![See it in action](https://img.youtube.com/vi/o1nEvwjKziw/0.jpg)]([https://youtu.be/4g-1cPGK0GA](https://www.youtube.com/watch?v=o1nEvwjKziw)) -(click to open the video in YouTube) (1:40min) +(click to open the video in YouTube) (1:04min)
@@ -46,11 +54,11 @@
-Pythagora-io%2Fgpt-pilot | Trendshift +Pythagora-io%2Fgpt-pilot | Trendshift
-GPT Pilot is the core technology for the [Pythagora VS Code extension](https://bit.ly/3IeZxp6) that aims to provide **the first real AI developer companion**. Not just an autocomplete or a helper for PR messages but rather a real AI developer that can write full features, debug them, talk to you about issues, ask for review, etc. +GPT Pilot is the core technology for the [Pythagora VS Code extension](https://marketplace.visualstudio.com/items?itemName=PythagoraTechnologies.pythagora-vs-code) that aims to provide **the first real AI developer companion**. Not just an autocomplete or a helper for PR messages but rather a real AI developer that can write full features, debug them, talk to you about issues, ask for review, etc. --- @@ -112,7 +120,7 @@ After you have Python and (optionally) PostgreSQL installed, follow these steps: 5. `pip install -r requirements.txt` (install the dependencies) 6. `cp example-config.json config.json` (create `config.json` file) 7. Set your key and other settings in `config.json` file: - - LLM Provider (`openai`, `anthropic` or `groq`) key and endpoints (leave `null` for default) (note that Azure and OpenRouter are suppored via the `openai` setting) + - LLM Provider (`openai`, `anthropic`, `groq` or `minimax`) key and endpoints (leave `null` for default) (note that Azure and OpenRouter are suppored via the `openai` setting) - Your API key (if `null`, will be read from the environment variables) - database settings: sqlite is used by default, PostgreSQL should also work - optionally update `fs.ignore_paths` and add files or folders which shouldn't be tracked by GPT Pilot in workspace, useful to ignore folders created by compilers diff --git a/core/agents/frontend.py b/core/agents/frontend.py index 60ee86e444..ff76eb505b 100644 --- a/core/agents/frontend.py +++ b/core/agents/frontend.py @@ -25,7 +25,7 @@ from core.llm.parser import DescriptiveCodeBlockParser, OptionalCodeBlockParser from core.log import get_logger from core.telemetry import telemetry -from core.ui.base import ProjectStage, UISource +from core.ui.base import ProjectStage log = get_logger(__name__) @@ -168,10 +168,6 @@ async def iterate_frontend(self): if user_input: await self.send_message("Errors detected, fixing...") else: - await self.ui.send_message( - "Use code CODE20 and subscribe https://pythagora.ai/pricing", - source=UISource("Congratulations", "success"), - ) answer = await self.ask_question( "Do you want to change anything or report a bug?" if frontend_only else FE_CHANGE_REQ, buttons={"yes": "I'm done building the UI"} if not frontend_only else None, diff --git a/core/agents/spec_writer.py b/core/agents/spec_writer.py index 6d95a9ac5a..2ca25c0d02 100644 --- a/core/agents/spec_writer.py +++ b/core/agents/spec_writer.py @@ -11,7 +11,7 @@ from core.log import get_logger from core.telemetry import telemetry from core.templates.registry import PROJECT_TEMPLATES -from core.ui.base import ProjectStage, UISource +from core.ui.base import ProjectStage log = get_logger(__name__) @@ -122,9 +122,6 @@ async def initialize_spec_and_project(self) -> AgentResponse: await self.ui.send_front_logs_headers("specs_0", ["E1 / T1", "Writing Specification", "working"], "") - await self.ui.send_message( - "Use code CODE20 and subscribe https://pythagora.ai/pricing", source=UISource("Congratulations", "success") - ) await self.send_message( "## Write specification\n\nPythagora is generating a detailed specification for app based on your input.", # project_state_id="setup", diff --git a/core/config/__init__.py b/core/config/__init__.py index 6ba0503aa5..b679014a7a 100644 --- a/core/config/__init__.py +++ b/core/config/__init__.py @@ -78,6 +78,7 @@ class LLMProvider(str, Enum): GROQ = "groq" LM_STUDIO = "lm-studio" AZURE = "azure" + MINIMAX = "minimax" class UIAdapter(str, Enum): diff --git a/core/llm/base.py b/core/llm/base.py index ddf92803f9..30cdb87d5f 100644 --- a/core/llm/base.py +++ b/core/llm/base.py @@ -423,6 +423,7 @@ def for_provider(provider: LLMProvider) -> type["BaseLLMClient"]: from .anthropic_client import AnthropicClient from .azure_client import AzureClient from .groq_client import GroqClient + from .minimax_client import MiniMaxClient from .openai_client import OpenAIClient from .relace_client import RelaceClient @@ -436,6 +437,8 @@ def for_provider(provider: LLMProvider) -> type["BaseLLMClient"]: return GroqClient elif provider == LLMProvider.AZURE: return AzureClient + elif provider == LLMProvider.MINIMAX: + return MiniMaxClient else: raise ValueError(f"Unsupported LLM provider: {provider.value}") diff --git a/core/llm/minimax_client.py b/core/llm/minimax_client.py new file mode 100644 index 0000000000..f493af925f --- /dev/null +++ b/core/llm/minimax_client.py @@ -0,0 +1,126 @@ +import datetime +import os +import re +from typing import Optional + +import tiktoken +from httpx import Timeout +from openai import AsyncOpenAI, RateLimitError + +from core.config import LLMProvider +from core.llm.base import BaseLLMClient +from core.llm.convo import Convo +from core.log import get_logger + +log = get_logger(__name__) +tokenizer = tiktoken.get_encoding("cl100k_base") + +# MiniMax API base URL (international) +MINIMAX_BASE_URL = "https://api.minimax.io/v1" + +# MiniMax temperature must be in (0.0, 1.0], cannot be 0 +MINIMAX_MIN_TEMPERATURE = 0.01 + + +class MiniMaxClient(BaseLLMClient): + provider = LLMProvider.MINIMAX + + def _init_client(self): + api_key = self.config.api_key or os.environ.get("MINIMAX_API_KEY") + self.client = AsyncOpenAI( + api_key=api_key, + base_url=self.config.base_url or MINIMAX_BASE_URL, + timeout=Timeout( + max(self.config.connect_timeout, self.config.read_timeout), + connect=self.config.connect_timeout, + read=self.config.read_timeout, + ), + ) + + async def _make_request( + self, + convo: Convo, + temperature: Optional[float] = None, + json_mode: bool = False, + ) -> tuple[str, int, int]: + temp = self.config.temperature if temperature is None else temperature + # MiniMax requires temperature > 0 + if temp <= 0: + temp = MINIMAX_MIN_TEMPERATURE + + completion_kwargs = { + "model": self.config.model, + "messages": convo.messages, + "temperature": temp, + "stream": True, + } + + # MiniMax does not support response_format; skip json_mode + + stream = await self.client.chat.completions.create(**completion_kwargs) + response = [] + prompt_tokens = 0 + completion_tokens = 0 + + async for chunk in stream: + if chunk.usage: + prompt_tokens += chunk.usage.prompt_tokens + completion_tokens += chunk.usage.completion_tokens + + if not chunk.choices: + continue + + content = chunk.choices[0].delta.content + if not content: + continue + + response.append(content) + if self.stream_handler: + await self.stream_handler(content) + + response_str = "".join(response) + + # Tell the stream handler we're done + if self.stream_handler: + await self.stream_handler(None) + + if prompt_tokens == 0 and completion_tokens == 0: + prompt_tokens = sum(3 + len(tokenizer.encode(msg["content"])) for msg in convo.messages) + completion_tokens = len(tokenizer.encode(response_str)) + log.warning( + "MiniMax response did not include token counts, estimating with tiktoken: " + f"{prompt_tokens} input tokens, {completion_tokens} output tokens" + ) + + return response_str, prompt_tokens, completion_tokens + + def rate_limit_sleep(self, err: RateLimitError) -> Optional[datetime.timedelta]: + """ + Handle MiniMax rate limiting using OpenAI-compatible headers. + """ + headers = err.response.headers + if "x-ratelimit-remaining-tokens" not in headers: + # Check for retry-after header as fallback + if "retry-after" in headers: + return datetime.timedelta(seconds=int(headers["retry-after"])) + return None + + remaining_tokens = headers["x-ratelimit-remaining-tokens"] + time_regex = r"(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?" + if remaining_tokens == 0: + match = re.search(time_regex, headers.get("x-ratelimit-reset-tokens", "")) + else: + match = re.search(time_regex, headers.get("x-ratelimit-reset-requests", "")) + + if match: + hours = int(match.group(1)) if match.group(1) else 0 + minutes = int(match.group(2)) if match.group(2) else 0 + seconds = int(match.group(3)) if match.group(3) else 0 + total_seconds = hours * 3600 + minutes * 60 + seconds + else: + total_seconds = 5 + + return datetime.timedelta(seconds=total_seconds) + + +__all__ = ["MiniMaxClient"] diff --git a/example-config.json b/example-config.json index 1d66c47596..1aa6136cf8 100644 --- a/example-config.json +++ b/example-config.json @@ -27,6 +27,14 @@ "azure_deployment": "your-azure-deployment-id", "api_version": "2024-02-01" } + }, + // Example config for MiniMax (see https://platform.minimax.io/docs/api-reference/text-openai-api) + // Available models: MiniMax-M3 (default), MiniMax-M2.7, MiniMax-M2.7-highspeed + "minimax": { + "base_url": "https://api.minimax.io/v1", + "api_key": "your-minimax-api-key", + "connect_timeout": 60.0, + "read_timeout": 20.0 } }, // Each agent can use a different model or configuration. The default, as before, is GPT4 Turbo diff --git a/pyproject.toml b/pyproject.toml index ae4420007a..ebdc0039de 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "pythagora-core" -version = "2.0.9" +version = "2.0.10" description = "Build complete apps using AI agents" authors = ["Leon Ostrez "] license = "FSL-1.1-MIT" diff --git a/tests/integration/llm/test_minimax.py b/tests/integration/llm/test_minimax.py new file mode 100644 index 0000000000..7041402320 --- /dev/null +++ b/tests/integration/llm/test_minimax.py @@ -0,0 +1,103 @@ +from os import getenv +from unittest.mock import MagicMock + +import pytest + +from core.config import LLMConfig, LLMProvider +from core.llm.base import APIError +from core.llm.convo import Convo +from core.llm.minimax_client import MiniMaxClient + +run_integration_tests = getenv("INTEGRATION_TESTS", "").lower() +if run_integration_tests not in ["true", "yes", "1", "on"]: + pytest.skip("Skipping integration tests", allow_module_level=True) + +if not getenv("MINIMAX_API_KEY"): + pytest.skip( + "Skipping MiniMax integration tests: MINIMAX_API_KEY is not set", + allow_module_level=True, + ) + + +def _mock_state_manager(): + sm = MagicMock() + sm.get_access_token = MagicMock(return_value=None) + return sm + + +@pytest.mark.asyncio +async def test_incorrect_key(): + cfg = LLMConfig( + provider=LLMProvider.MINIMAX, + model="MiniMax-M3", + api_key="invalid-key", + base_url="https://api.minimax.io/v1", + temperature=0.5, + ) + + llm = MiniMaxClient(cfg, state_manager=_mock_state_manager()) + convo = Convo("you're a friendly assistant").user("tell me a joke") + + with pytest.raises(APIError): + await llm(convo) + + +@pytest.mark.asyncio +async def test_minimax_success(): + cfg = LLMConfig( + provider=LLMProvider.MINIMAX, + model="MiniMax-M3", + base_url="https://api.minimax.io/v1", + temperature=0.5, + ) + + streamed_response = [] + + async def stream_handler(content: str): + if content: + streamed_response.append(content) + + llm = MiniMaxClient(cfg, state_manager=_mock_state_manager(), stream_handler=stream_handler) + convo = Convo("you're a friendly assistant").user("tell me a joke") + + response, req_log = await llm(convo) + assert response == "".join(streamed_response) + + assert req_log.messages == convo.messages + assert req_log.prompt_tokens > 0 + assert req_log.completion_tokens > 0 + + +@pytest.mark.asyncio +async def test_minimax_m27_model(): + """Verify previous-generation M2.7 model still works.""" + cfg = LLMConfig( + provider=LLMProvider.MINIMAX, + model="MiniMax-M2.7", + base_url="https://api.minimax.io/v1", + temperature=0.5, + ) + + llm = MiniMaxClient(cfg, state_manager=_mock_state_manager()) + convo = Convo("you're a friendly assistant").user("say hello") + + response, req_log = await llm(convo) + assert len(response) > 0 + assert req_log.model == "MiniMax-M2.7" + + +@pytest.mark.asyncio +async def test_minimax_m27_highspeed_model(): + cfg = LLMConfig( + provider=LLMProvider.MINIMAX, + model="MiniMax-M2.7-highspeed", + base_url="https://api.minimax.io/v1", + temperature=0.5, + ) + + llm = MiniMaxClient(cfg, state_manager=_mock_state_manager()) + convo = Convo("you're a friendly assistant").user("say hello") + + response, req_log = await llm(convo) + assert len(response) > 0 + assert req_log.model == "MiniMax-M2.7-highspeed" diff --git a/tests/llm/test_minimax.py b/tests/llm/test_minimax.py new file mode 100644 index 0000000000..0fb2cc3881 --- /dev/null +++ b/tests/llm/test_minimax.py @@ -0,0 +1,215 @@ +from unittest.mock import AsyncMock, MagicMock, call, patch + +import pytest + +from core.config import LLMConfig, LLMProvider +from core.llm.base import APIError +from core.llm.convo import Convo +from core.llm.minimax_client import MiniMaxClient +from core.state.state_manager import StateManager + + +async def mock_response_generator(*content): + for item in content: + chunk = MagicMock() + chunk.choices = [MagicMock(delta=MagicMock(content=item))] + chunk.usage = None + yield chunk + + +@pytest.mark.asyncio +@patch("core.cli.helpers.StateManager") +@patch("core.llm.minimax_client.AsyncOpenAI") +async def test_minimax_calls_model(mock_AsyncOpenAI, mock_state_manager): + cfg = LLMConfig(provider=LLMProvider.MINIMAX, model="MiniMax-M3", temperature=0.5) + convo = Convo("system hello").user("user hello") + + stream = AsyncMock(return_value=mock_response_generator("hello", None, "world")) + + mock_chat = AsyncMock() + mock_completions = AsyncMock() + mock_completions.create = stream + mock_chat.completions = mock_completions + + mock_client = AsyncMock() + mock_client.chat = mock_chat + mock_AsyncOpenAI.return_value = mock_client + + sm = StateManager(mock_state_manager) + llm = MiniMaxClient(cfg, state_manager=sm) + response, req_log = await llm(convo) + assert response == "helloworld" + + assert req_log.model == "MiniMax-M3" + assert req_log.provider == LLMProvider.MINIMAX + assert req_log.temperature == 0.5 + assert req_log.response == response + assert req_log.status == "success" + + stream.assert_awaited_once_with( + model="MiniMax-M3", + messages=[ + {"role": "system", "content": "system hello"}, + {"role": "user", "content": "user hello"}, + ], + temperature=0.5, + stream=True, + ) + + +@pytest.mark.asyncio +@patch("core.cli.helpers.StateManager") +@patch("core.llm.minimax_client.AsyncOpenAI") +async def test_minimax_temperature_clamping(mock_AsyncOpenAI, mock_state_manager): + """Test that temperature=0 is clamped to MINIMAX_MIN_TEMPERATURE (0.01).""" + cfg = LLMConfig(provider=LLMProvider.MINIMAX, model="MiniMax-M3", temperature=0.0) + convo = Convo("system").user("user") + + stream = AsyncMock(return_value=mock_response_generator("ok")) + + mock_chat = AsyncMock() + mock_completions = AsyncMock() + mock_completions.create = stream + mock_chat.completions = mock_completions + + mock_client = AsyncMock() + mock_client.chat = mock_chat + mock_AsyncOpenAI.return_value = mock_client + + sm = StateManager(mock_state_manager) + llm = MiniMaxClient(cfg, state_manager=sm) + await llm(convo) + + # Verify temperature was clamped to 0.01 instead of 0.0 + stream.assert_awaited_once() + call_kwargs = stream.call_args[1] if stream.call_args[1] else {} + if not call_kwargs: + call_kwargs = dict(zip(["model", "messages", "temperature", "stream"], stream.call_args[0])) + assert call_kwargs.get("temperature", stream.call_args.kwargs.get("temperature")) == 0.01 + + +@pytest.mark.asyncio +@patch("core.cli.helpers.StateManager") +@patch("core.llm.minimax_client.AsyncOpenAI") +async def test_minimax_no_json_mode(mock_AsyncOpenAI, mock_state_manager): + """Test that json_mode=True does NOT add response_format (MiniMax doesn't support it).""" + cfg = LLMConfig(provider=LLMProvider.MINIMAX, model="MiniMax-M3", temperature=0.5) + convo = Convo("system").user("user") + + stream = AsyncMock(return_value=mock_response_generator("ok")) + + mock_chat = AsyncMock() + mock_completions = AsyncMock() + mock_completions.create = stream + mock_chat.completions = mock_completions + + mock_client = AsyncMock() + mock_client.chat = mock_chat + mock_AsyncOpenAI.return_value = mock_client + + sm = StateManager(mock_state_manager) + llm = MiniMaxClient(cfg, state_manager=sm) + await llm(convo, json_mode=True) + + # Verify response_format was NOT included in the call + call_kwargs = stream.call_args.kwargs + assert "response_format" not in call_kwargs + + +@pytest.mark.asyncio +@patch("core.cli.helpers.StateManager") +@patch("core.llm.minimax_client.AsyncOpenAI") +async def test_minimax_stream_handler(mock_AsyncOpenAI, mock_state_manager): + cfg = LLMConfig(provider=LLMProvider.MINIMAX, model="MiniMax-M3", temperature=0.5) + convo = Convo("system hello").user("user hello") + + stream_handler = AsyncMock() + + stream = AsyncMock(return_value=mock_response_generator("hello", None, "world")) + + mock_chat = AsyncMock() + mock_completions = AsyncMock() + mock_completions.create = stream + mock_chat.completions = mock_completions + + mock_client = AsyncMock() + mock_client.chat = mock_chat + mock_AsyncOpenAI.return_value = mock_client + + sm = StateManager(mock_state_manager) + llm = MiniMaxClient(cfg, stream_handler=stream_handler, state_manager=sm) + await llm(convo) + + stream_handler.assert_has_awaits([call("hello"), call("world")]) + + +@pytest.mark.asyncio +@patch("core.cli.helpers.StateManager") +@patch("core.llm.minimax_client.AsyncOpenAI") +async def test_minimax_default_base_url(mock_AsyncOpenAI, mock_state_manager): + """Test that the default base URL is set to MiniMax API endpoint.""" + cfg = LLMConfig(provider=LLMProvider.MINIMAX, model="MiniMax-M3", temperature=0.5) + + sm = StateManager(mock_state_manager) + MiniMaxClient(cfg, state_manager=sm) + + mock_AsyncOpenAI.assert_called_once() + call_kwargs = mock_AsyncOpenAI.call_args.kwargs + assert call_kwargs["base_url"] == "https://api.minimax.io/v1" + + +@pytest.mark.asyncio +@patch("core.cli.helpers.StateManager") +@patch("core.llm.minimax_client.AsyncOpenAI") +async def test_minimax_custom_base_url(mock_AsyncOpenAI, mock_state_manager): + """Test that a custom base URL overrides the default.""" + cfg = LLMConfig( + provider=LLMProvider.MINIMAX, + model="MiniMax-M3", + temperature=0.5, + base_url="https://api.minimaxi.com/v1", + ) + + sm = StateManager(mock_state_manager) + MiniMaxClient(cfg, state_manager=sm) + + mock_AsyncOpenAI.assert_called_once() + call_kwargs = mock_AsyncOpenAI.call_args.kwargs + assert call_kwargs["base_url"] == "https://api.minimaxi.com/v1" + + +@pytest.mark.parametrize( + ("remaining_tokens", "reset_tokens", "reset_requests", "expected"), + [ + (0, "1h1m1s", "", 3661), + (0, "1m", "", 60), + (1, "", "1h1m1s", 3661), + ], +) +@patch("core.cli.helpers.StateManager") +@patch("core.llm.minimax_client.AsyncOpenAI") +def test_minimax_rate_limit_parser( + mock_AsyncOpenAI, mock_state_manager, remaining_tokens, reset_tokens, reset_requests, expected +): + headers = { + "x-ratelimit-remaining-tokens": remaining_tokens, + "x-ratelimit-reset-tokens": reset_tokens, + "x-ratelimit-reset-requests": reset_requests, + } + err = MagicMock(response=MagicMock(headers=headers)) + + sm = StateManager(mock_state_manager) + llm = MiniMaxClient(LLMConfig(provider=LLMProvider.MINIMAX, model="MiniMax-M3"), state_manager=sm) + assert int(llm.rate_limit_sleep(err).total_seconds()) == expected + + +@patch("core.cli.helpers.StateManager") +@patch("core.llm.minimax_client.AsyncOpenAI") +def test_minimax_rate_limit_retry_after(mock_AsyncOpenAI, mock_state_manager): + """Test rate limiting with retry-after header.""" + headers = {"retry-after": "30"} + err = MagicMock(response=MagicMock(headers=headers)) + + sm = StateManager(mock_state_manager) + llm = MiniMaxClient(LLMConfig(provider=LLMProvider.MINIMAX, model="MiniMax-M3"), state_manager=sm) + assert int(llm.rate_limit_sleep(err).total_seconds()) == 30