From 317836adeddc16b661770f213b7cfd66a1433a9e Mon Sep 17 00:00:00 2001 From: ievgenii vdovenko Date: Mon, 8 Sep 2025 15:35:01 +0200 Subject: [PATCH] poc: implements claude ping\basic integration --- requirements.txt | 1 + rhodecode/apps/admin/views/ai.py | 1 + rhodecode/apps/ai_agents/ai_service.py | 11 ++- rhodecode/apps/ai_agents/ai_settings.py | 7 +- rhodecode/apps/ai_agents/models/base.py | 68 ++++++++++++++++++- rhodecode/apps/ai_agents/models/claude.py | 81 +++++++++++++++++++++++ rhodecode/apps/ai_agents/models/gpt.py | 63 ------------------ 7 files changed, 166 insertions(+), 66 deletions(-) create mode 100644 rhodecode/apps/ai_agents/models/claude.py diff --git a/requirements.txt b/requirements.txt index f81f4321..a390d2ae 100644 --- a/requirements.txt +++ b/requirements.txt @@ -306,6 +306,7 @@ zope.cachedescriptors==5.1.0 qrcode==7.4.2 configupdater~=3.2 openai~=1.100.2 +anthropic~=0.66.0 ## uncomment to add the debug libraries #-r requirements_debug.txt diff --git a/rhodecode/apps/admin/views/ai.py b/rhodecode/apps/admin/views/ai.py index fe7b3f2f..c3570e50 100644 --- a/rhodecode/apps/admin/views/ai.py +++ b/rhodecode/apps/admin/views/ai.py @@ -43,6 +43,7 @@ class AdminAiView(BaseAppView): c.model_map = { AIModelName.GPT.name: [v.name for v in GPTVersion], + AIModelName.Claude.name: [v.name for v in ClaudeVersion], } return self._get_template_context(c) diff --git a/rhodecode/apps/ai_agents/ai_service.py b/rhodecode/apps/ai_agents/ai_service.py index 8428c6f4..d6e584ea 100644 --- a/rhodecode/apps/ai_agents/ai_service.py +++ b/rhodecode/apps/ai_agents/ai_service.py @@ -1,5 +1,6 @@ -from rhodecode.apps.ai_agents.ai_settings import AISettings, GPTVersion, AIModelName +from rhodecode.apps.ai_agents.ai_settings import AISettings, GPTVersion, AIModelName, ClaudeVersion from rhodecode.apps.ai_agents.models.base import wrap_ai_exceptions +from rhodecode.apps.ai_agents.models.claude import ClaudeService from rhodecode.apps.ai_agents.models.gpt import GPTService @@ -15,6 +16,14 @@ def get_ai_service(api_key: str, model_name: str = AIModelName.GPT.name, version api_key=api_key, ) ) + if AIModelName[model_name] is AIModelName.Claude: + return ClaudeService( + AISettings( + model_name=AIModelName.Claude, + model_version=ClaudeVersion[version], + api_key=api_key, + ) + ) except KeyError as ke: raise ValueError("Unknown model or version: %s" % ke) diff --git a/rhodecode/apps/ai_agents/ai_settings.py b/rhodecode/apps/ai_agents/ai_settings.py index 876ce74b..4eeb0c9f 100644 --- a/rhodecode/apps/ai_agents/ai_settings.py +++ b/rhodecode/apps/ai_agents/ai_settings.py @@ -8,7 +8,12 @@ class AIModelName(enum.StrEnum): class ClaudeVersion(enum.StrEnum): - Opus = "opus" + Opus_41 = "opus-4-1" + Opus_4 = "opus-4" + Sonnet_4 = "sonnet-4" + Sonnet_37 = "3-7-sonnet" + Haiku_35 = "3-5-haiku" + Haiku_3 = "3-haiku" class GPTVersion(enum.StrEnum): diff --git a/rhodecode/apps/ai_agents/models/base.py b/rhodecode/apps/ai_agents/models/base.py index a04ff2be..6733748c 100644 --- a/rhodecode/apps/ai_agents/models/base.py +++ b/rhodecode/apps/ai_agents/models/base.py @@ -118,6 +118,72 @@ class AIServiceBase: def _get_model_name(self, model_name=None): return model_name if model_name else self._get_model_name() + def _get_function(self, name): + """ + this is a GPT/Claude-specific function - AKA instruction for GPT + doc: https://platform.openai.com/docs/guides/function-calling + + Claude uses the same tools method + """ + # TODO: unify tools + match name: + case "ping_pong": + return { + "type": "function", + "name": "ping_pong", + "description": "Always return pong", + "strict": True, + "parameters": { + "type": "object", + "properties": {"response": {"type": "string", "enum": ["pong"]}}, + "required": ["response"], + "additionalProperties": False, + }, + } + case "return_code_review": + return { + "type": "function", + "name": "return_code_review", + "description": ( + "Return structured code review per file. Each file uses per-file 1-based " + "line numbers. Only include lines that warrant a concrete suggestion." + ), + "strict": True, + "parameters": { + "type": "object", + "properties": { + "response": { + "type": "array", + "items": { + "type": "object", + "properties": { + "file_name": {"type": "string"}, + "review": { + "type": "array", + "items": { + "type": "object", + "properties": { + "line_number": {"type": "integer", "minimum": 1}, + "line_code": {"type": "string"}, + "suggestion": {"type": "string"}, + }, + "required": ["line_number", "line_code", "suggestion"], + "additionalProperties": False, + }, + }, + }, + "required": ["file_name", "review"], + "additionalProperties": False, + }, + } + }, + "required": ["response"], + "additionalProperties": False, + }, + } + case _: + raise ValueError("Unknown function name: %s" % name) + @abstractmethod def _get_ping_request(self) -> Request: raise NotImplementedError @@ -130,7 +196,7 @@ class AIServiceBase: custom_instructions: Optional[str] = None, *args, **kwargs, - ) -> list[Request]: + ) -> Request: raise NotImplementedError @abstractmethod diff --git a/rhodecode/apps/ai_agents/models/claude.py b/rhodecode/apps/ai_agents/models/claude.py new file mode 100644 index 00000000..1dea2fd8 --- /dev/null +++ b/rhodecode/apps/ai_agents/models/claude.py @@ -0,0 +1,81 @@ +import logging +from dataclasses import dataclass +from typing import Optional, Iterable + +from anthropic import Anthropic +from anthropic.types import MessageParam, ToolParam + +from rhodecode.apps.ai_agents.ai_settings import AISettings +from rhodecode.apps.ai_agents.models.base import AIServiceBase, Request, Response, AIServiceError +from rhodecode.lib.codeblocks import DiffSet + + +@dataclass +class ClaudePingRequest(Request): + function_name: str + role: str + + +class ClaudeService(AIServiceBase): + def __init__(self, model_settings: AISettings): + super().__init__(model_settings) + self._client = Anthropic(api_key=self.model_settings.api_key) + self.log = logging.getLogger(ClaudeService.__name__) + + def _get_ping_request(self) -> Request: + return ClaudePingRequest(content="ping", function_name="ping_pong", role="user") + + def _get_response(self, request: ClaudePingRequest): + if isinstance(request, ClaudePingRequest): + _input: MessageParam = { + "role": request.role, + "content": request.content, + } + else: + raise ValueError("Unknown request type: %s" % type(request)) + + model_full_name = self._get_current_model_version() + + return self._client.messages.create( + model=model_full_name, + messages=[_input], + max_tokens=1024, + tools=[ + self._adapt_function(self._get_function(request.function_name)), + ], + ) + + def _adapt_function(self, function_dict: dict) -> ToolParam: + return ToolParam( + name=function_dict["name"], + description=function_dict["description"], + input_schema=function_dict["parameters"], + ) + + def _transform(self, resp, model=None) -> Response: + for r in resp.content: + # TODO: push function name + if r.type == "tool_use" and r.name == "ping_pong": + return Response(model=resp.model, message=r.input) + raise ValueError("Response has incorrect return type, can't parses it.") + + def _get_current_model_version(self): + models = self._client.models.list() + semi_key = "%s-%s" % ( + self.model_settings.model_name.lower().strip(), + self.model_settings.model_version.lower().strip(), + ) + for model in models: + if semi_key in model.id: + return model.id + raise AIServiceError("Unknown model version: %s" % semi_key) + + def _get_review_requests( + self, + pr_diffset: DiffSet, + basic_instructions: Optional[Iterable[str]] = None, + custom_instructions: Optional[str] = None, + *args, + **kwargs, + ) -> Request: + raise NotImplementedError() diff --git a/rhodecode/apps/ai_agents/models/gpt.py b/rhodecode/apps/ai_agents/models/gpt.py index de9eae7b..03a1fbbb 100644 --- a/rhodecode/apps/ai_agents/models/gpt.py +++ b/rhodecode/apps/ai_agents/models/gpt.py @@ -132,69 +132,6 @@ class GPTService(AIServiceBase): raise ValueError("No function call found in response") - def _get_function(self, name): - """ - this is a GPT-specific function - AKA instruction for GPT - doc: https://platform.openai.com/docs/guides/function-calling - """ - match name: - case "ping_pong": - return { - "type": "function", - "name": "ping_pong", - "description": "Always return pong", - "strict": True, - "parameters": { - "type": "object", - "properties": {"response": {"type": "string", "enum": ["pong"]}}, - "required": ["response"], - "additionalProperties": False, - }, - } - case "return_code_review": - return { - "type": "function", - "name": "return_code_review", - "description": ( - "Return structured code review per file. Each file uses per-file 1-based " - "line numbers. Only include lines that warrant a concrete suggestion." - ), - "strict": True, - "parameters": { - "type": "object", - "properties": { - "response": { - "type": "array", - "items": { - "type": "object", - "properties": { - "file_name": {"type": "string"}, - "review": { - "type": "array", - "items": { - "type": "object", - "properties": { - "line_number": {"type": "integer", "minimum": 1}, - "line_code": {"type": "string"}, - "suggestion": {"type": "string"}, - }, - "required": ["line_number", "line_code", "suggestion"], - "additionalProperties": False, - }, - }, - }, - "required": ["file_name", "review"], - "additionalProperties": False, - }, - } - }, - "required": ["response"], - "additionalProperties": False, - }, - } - case _: - raise ValueError("Unknown function name: %s" % name) - def _get_model_name(self, model_name=None): name = self.model_settings.model_name.value version = self.model_settings.model_version.value