Add Anthropic support (#678)

Co-authored-by: Glenn Jocher <[email protected]>
Co-authored-by: UltralyticsAssistant <[email protected]>
This commit is contained in:
fatih akyon
2025-12-31 01:28:51 +01:00
committed by GitHub
co-authored by Glenn Jocher UltralyticsAssistant
parent 561452b15e
commit e02546da2b
11 changed files with 227 additions and 67 deletions
+2 -1
View File
@@ -33,5 +33,6 @@ jobs:
links: false # Check broken links with Lychee
summary: true # Generate AI-powered PR summaries
review: true # PR reviews
openai_api_key: ${{ secrets.OPENAI_API_KEY }} # Powers PR summaries, labels and comments
openai_api_key: ${{ secrets.OPENAI_API_KEY }}
# anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
brave_api_key: ${{ secrets.BRAVE_API_KEY }} # Used for broken link resolution
+20 -5
View File
@@ -2,7 +2,7 @@
# 🚀 Ultralytics Actions
Welcome to [Ultralytics Actions](https://github.com/ultralytics/actions) - a collection of GitHub Actions and Python tools for automating code quality, PR management, and CI/CD workflows across Ultralytics projects.
Welcome to [Ultralytics Actions](https://github.com/ultralytics/actions) - a collection of GitHub Actions for automating code quality, PR management, and CI/CD workflows across Ultralytics projects.
[![GitHub Actions Marketplace](https://img.shields.io/badge/Marketplace-Ultralytics_Actions-blue?style=flat&logo=github)](https://github.com/marketplace/actions/ultralytics-actions)
@@ -36,9 +36,20 @@ AI-powered formatting, labeling, and PR summaries for Python, Swift, and Markdow
- **Swift Code:** Formatted with [`swift-format`](https://github.com/swiftlang/swift-format) _(requires `macos-latest` runner)_
- **Spell Check:** Common misspellings caught using [codespell](https://github.com/codespell-project/codespell)
- **Broken Links Check:** Broken links identified using [Lychee](https://github.com/lycheeverse/lychee)
- **PR Summary:** Concise Pull Request summaries generated using [OpenAI](https://openai.com/) GPT-5
- **PR Summary:** Concise Pull Request summaries generated using AI
- **PR Review:** AI-powered code reviews identify critical bugs, security issues, and quality concerns with suggested fixes
- **Auto-labeling:** Applies relevant labels to issues and PRs via [OpenAI](https://openai.com/) GPT-5
- **Auto-labeling:** Applies relevant labels to issues and PRs via AI
### 🤖 Supported AI Providers
Choose between [OpenAI](https://openai.com/) or [Anthropic](https://anthropic.com/) for AI-powered features:
| Provider | Default Model | API Key |
| --------- | ---------------------------- | ------------------- |
| OpenAI | `gpt-5.2-2025-12-11` | `openai_api_key` |
| Anthropic | `claude-sonnet-4-5-20250929` | `anthropic_api_key` |
The model is auto-detected based on which API key you provide. Override with the `model` input, or use `review_model` to override PR review only.
### 🛠️ How It Works
@@ -46,7 +57,7 @@ Triggers on GitHub events to streamline workflows:
- **Push Events:** Automatically formats code when changes are pushed to `main`
- **Pull Requests:** Ensures formatting standards, generates summaries, provides AI reviews, and applies labels
- **Issues:** Automatically applies relevant labels using GPT-5
- **Issues:** Automatically applies relevant labels using AI
### 🔧 Setup
@@ -90,7 +101,11 @@ jobs:
spelling: true # Check spelling with codespell
links: true # Check broken links with Lychee
summary: true # Generate AI-powered PR summaries
openai_api_key: ${{ secrets.OPENAI_API_KEY }} # Powers PR summaries, labels and reviews
# AI API keys - provide OpenAI OR Anthropic (model auto-detected from key)
openai_api_key: ${{ secrets.OPENAI_API_KEY }}
# anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
# model: claude-haiku-4-5-20251001 # Optional: override default model
# review_model: claude-opus-4-5-20251101 # Optional: override PR review model
brave_api_key: ${{ secrets.BRAVE_API_KEY }} # Used for broken link resolution
```
+15 -6
View File
@@ -68,10 +68,15 @@ inputs:
openai_api_key:
description: "OpenAI API Key"
required: false
openai_model:
description: "OpenAI Model"
anthropic_api_key:
description: "Anthropic API Key"
required: false
model:
description: "AI Model - defaults to GPT-5.2 or Sonnet 4.5 based on API key"
required: false
review_model:
description: "AI Model for PR reviews (optional, defaults to model)"
required: false
default: "gpt-5.2-2025-12-11"
first_issue_response:
description: "Example response to a new issue"
required: false
@@ -281,7 +286,8 @@ runs:
GITHUB_TOKEN: ${{ inputs.token }}
BRAVE_API_KEY: ${{ inputs.brave_api_key }}
OPENAI_API_KEY: ${{ inputs.openai_api_key }}
OPENAI_MODEL: ${{ inputs.openai_model }}
ANTHROPIC_API_KEY: ${{ inputs.anthropic_api_key }}
MODEL: ${{ inputs.model }}
run: ultralytics-actions-summarize-pr
shell: bash
continue-on-error: true
@@ -295,7 +301,8 @@ runs:
FIRST_PR_RESPONSE: ${{ inputs.first_pr_response }}
BRAVE_API_KEY: ${{ inputs.brave_api_key }}
OPENAI_API_KEY: ${{ inputs.openai_api_key }}
OPENAI_MODEL: ${{ inputs.openai_model }}
ANTHROPIC_API_KEY: ${{ inputs.anthropic_api_key }}
MODEL: ${{ inputs.model }}
REVIEW: ${{ inputs.review }}
run: ultralytics-actions-first-interaction
shell: bash
@@ -310,7 +317,9 @@ runs:
env:
GITHUB_TOKEN: ${{ inputs.token }}
OPENAI_API_KEY: ${{ inputs.openai_api_key }}
OPENAI_MODEL: ${{ inputs.openai_model }}
ANTHROPIC_API_KEY: ${{ inputs.anthropic_api_key }}
MODEL: ${{ inputs.model }}
REVIEW_MODEL: ${{ inputs.review_model }}
run: ultralytics-actions-review-pr
shell: bash
continue-on-error: true
+1 -1
View File
@@ -27,4 +27,4 @@
# ├── test_summarize_pr.py
# └── ...
__version__ = "0.2.10"
__version__ = "0.2.11"
+1 -1
View File
@@ -182,7 +182,7 @@ YOUR {issue_type.upper()} RESPONSE:
def main(*args, **kwargs):
"""Executes auto-labeling and custom response generation for new GitHub issues, PRs, and discussions."""
event = Action(*args, **kwargs)
if event.should_skip_openai():
if event.should_skip_llm():
return
number, node_id, title, body, username, issue_type, action = get_event_content(event)
+3 -1
View File
@@ -14,6 +14,7 @@ from .utils import (
Action,
format_skipped_files_dropdown,
get_response,
get_review_model,
remove_html_comments,
sanitize_ai_text,
should_skip_file,
@@ -223,8 +224,8 @@ def generate_pr_review(
response = get_response(
messages,
reasoning_effort="low",
model="gpt-5.2-2025-12-11",
text_format={"format": {"type": "json_schema", "name": "pr_review", "strict": True, "schema": schema}},
model=get_review_model(),
tools=[
{
"type": "web_search",
@@ -237,6 +238,7 @@ def generate_pr_review(
},
}
],
retries=0,
)
# Sanitize leaked tool-citation tokens from model output
+1 -1
View File
@@ -108,7 +108,7 @@ def main(*args, **kwargs):
if action == "opened":
print("Skipping PR open - handled by first_interaction.py with unified API call")
return
if event.should_skip_openai():
if event.should_skip_llm():
return
print(f"Retrieving diff for PR {event.pr['number']}")
+2
View File
@@ -24,6 +24,7 @@ from .openai_utils import (
get_pr_summary_guidelines,
get_pr_summary_prompt,
get_response,
get_review_model,
sanitize_ai_text,
)
from .version_utils import check_pubdev_version, check_pypi_version
@@ -52,6 +53,7 @@ __all__ = (
"get_pr_summary_guidelines",
"get_pr_summary_prompt",
"get_response",
"get_review_model",
"remove_html_comments",
"sanitize_ai_text",
"should_skip_file",
+5 -5
View File
@@ -219,12 +219,12 @@ class Action:
head_repo = self.pr.get("head", {}).get("repo", {}).get("full_name")
return bool(head_repo) and head_repo != self.repository
def should_skip_openai(self) -> bool:
"""Check if OpenAI operations should be skipped."""
from actions.utils.openai_utils import OPENAI_API_KEY
def should_skip_llm(self) -> bool:
"""Check if LLM operations should be skipped (no API key found)."""
from actions.utils.openai_utils import ANTHROPIC_API_KEY, OPENAI_API_KEY
if not OPENAI_API_KEY:
print("⚠️ Skipping LLM operations (OPENAI_API_KEY not found)")
if not OPENAI_API_KEY and not ANTHROPIC_API_KEY:
print("⚠️ Skipping LLM operations (no OPENAI_API_KEY or ANTHROPIC_API_KEY found)")
return True
return False
+127 -45
View File
@@ -12,15 +12,27 @@ import requests
from actions.utils.common_utils import check_links_in_string, filter_diff_text, format_skipped_files_note
OPENAI_API_KEY = os.getenv("OPENAI_API_KEY")
OPENAI_MODEL = os.getenv("OPENAI_MODEL", "gpt-5.2-2025-12-11")
ANTHROPIC_API_KEY = os.getenv("ANTHROPIC_API_KEY")
MODEL = os.getenv("MODEL") # Auto-detected from API keys if not set
REVIEW_MODEL = os.getenv("REVIEW_MODEL") # Optional override for PR reviews
MAX_PROMPT_CHARS = round(128000 * 3.3 * 0.5) # Max characters for prompt (50% of 128k context)
MODEL_COSTS = {
# Default models (single source of truth)
OPENAI_MODEL_DEFAULT = "gpt-5.2-2025-12-11"
ANTHROPIC_MODEL_DEFAULT = "claude-sonnet-4-5-20250929"
MODEL_COSTS = { # (input, output) per 1M tokens
# OpenAI models
"gpt-5-codex": (1.25, 10.00),
"gpt-5.1-codex": (1.25, 10.00),
"gpt-5.1-2025-11-13": (1.25, 10.00),
"gpt-5.2-2025-12-11": (1.75, 14.00),
"gpt-5-nano-2025-08-07": (0.05, 0.40),
"gpt-5-mini-2025-08-07": (0.25, 2.00),
# Anthropic Claude 4.5 models
"claude-sonnet-4-5-20250929": (3.00, 15.00),
"claude-haiku-4-5-20251001": (1.00, 5.00),
"claude-opus-4-5-20251101": (5.00, 25.00),
}
SYSTEM_PROMPT_ADDITION = """Guidance:
- Ultralytics Branding: Use YOLO11, YOLO26, etc., not YOLOv11, YOLOv26 (only older versions like YOLOv10 have a v). Always capitalize "HUB" in "Ultralytics HUB"; use "Ultralytics HUB", not "The Ultralytics HUB".
@@ -122,6 +134,25 @@ def get_pr_first_comment_template(repository: str, username: str) -> str:
For more guidance, please refer to our [Contributing Guide](https://docs.ultralytics.com/help/contributing/). Don't hesitate to leave a comment if you have any questions. Thank you for contributing to Ultralytics! 🚀"""
def _is_anthropic_model(model: str) -> bool:
"""Check if the model is an Anthropic model."""
return model.startswith("claude")
def _get_default_model() -> str:
"""Get default model based on available API keys."""
if MODEL:
return MODEL
if ANTHROPIC_API_KEY:
return ANTHROPIC_MODEL_DEFAULT
return OPENAI_MODEL_DEFAULT
def get_review_model() -> str:
"""Get model for PR reviews, using REVIEW_MODEL if set, otherwise default model."""
return REVIEW_MODEL or _get_default_model()
def get_response(
messages: list[dict[str, str]],
check_links: bool = True,
@@ -129,24 +160,62 @@ def get_response(
temperature: float = 1.0,
reasoning_effort: str | None = None,
text_format: dict | None = None,
model: str = OPENAI_MODEL,
model: str | None = None,
tools: list[dict] | None = None,
retries: int = 2,
) -> str | dict:
"""Generates a completion using OpenAI's Responses API with retry logic."""
assert OPENAI_API_KEY, "OpenAI API key is required."
url = "https://api.openai.com/v1/responses"
headers = {"Authorization": f"Bearer {OPENAI_API_KEY}", "Content-Type": "application/json"}
if messages and messages[0].get("role") == "system":
messages[0]["content"] += "\n\n" + SYSTEM_PROMPT_ADDITION
"""Generates a completion using OpenAI or Anthropic API with retry logic."""
model = model or _get_default_model()
is_anthropic = _is_anthropic_model(model)
for attempt in range(3):
data = {"model": model, "input": messages, "store": False, "temperature": temperature}
if "gpt-5" in model:
data["reasoning"] = {"effort": reasoning_effort or "low"}
if text_format:
data["text"] = text_format
if tools:
data["tools"] = tools
# Validate API key
if is_anthropic:
assert ANTHROPIC_API_KEY, "Anthropic API key is required for Claude models."
url = "https://api.anthropic.com/v1/messages"
headers = {
"x-api-key": ANTHROPIC_API_KEY,
"anthropic-version": "2023-06-01",
"Content-Type": "application/json",
}
else:
assert OPENAI_API_KEY, "OpenAI API key is required."
url = "https://api.openai.com/v1/responses"
headers = {"Authorization": f"Bearer {OPENAI_API_KEY}", "Content-Type": "application/json"}
# Extract system message and append guidance
system_content = ""
user_messages = messages.copy()
if user_messages and user_messages[0].get("role") == "system":
system_content = user_messages.pop(0)["content"] + "\n\n" + SYSTEM_PROMPT_ADDITION
if not is_anthropic:
# For OpenAI, keep system message in messages list with addition
messages = [{"role": "system", "content": system_content}, *user_messages]
for attempt in range(retries + 1):
if is_anthropic:
data = {
"model": model,
"max_tokens": 8192,
"temperature": temperature,
"messages": user_messages,
}
if system_content:
data["system"] = system_content
# Skip web_search for Anthropic when using JSON schema (causes empty responses)
# Handle structured JSON output for Anthropic
if text_format and text_format.get("format", {}).get("type") == "json_schema":
schema = text_format["format"].get("schema", {})
# Add JSON instruction to system prompt
json_instruction = f"\n\nRespond ONLY with valid JSON matching this schema:\n{json.dumps(schema)}"
data["system"] = (data.get("system") or "") + json_instruction
else:
data = {"model": model, "input": messages, "store": False, "temperature": temperature}
if "gpt-5" in model:
data["reasoning"] = {"effort": reasoning_effort or "low"}
if text_format:
data["text"] = text_format
if tools:
data["tools"] = tools
try:
r = requests.post(url, json=data, headers=headers, timeout=(30, 900))
@@ -155,45 +224,58 @@ def get_response(
print(f"{'✓' if success else '✗'} POST {url} → {r.status_code} ({elapsed:.1f}s)")
# Retry server errors
if attempt < 2 and r.status_code >= 500:
print(f"Retrying {r.status_code} in {2**attempt}s (attempt {attempt + 1}/3)...")
if attempt < retries and r.status_code >= 500:
print(f"Retrying {r.status_code} in {2**attempt}s (attempt {attempt + 1}/{retries + 1})...")
time.sleep(2**attempt)
continue
if r.status_code >= 400:
error_body = r.text
print(f"API Error {r.status_code}: {error_body}")
r.reason = f"{r.reason}\n{error_body}" # Add error body to exception message
r.reason = f"{r.reason}\n{error_body}"
r.raise_for_status()
# Parse response
response_json = r.json()
content = ""
for item in response_json.get("output", []):
if item.get("type") == "message":
for c in item.get("content", []):
if c.get("type") == "output_text":
content += c.get("text") or ""
content = content.strip()
if is_anthropic:
content = ""
for block in response_json.get("content", []):
if block.get("type") == "text":
content += block.get("text") or ""
content = content.strip()
# Extract and print token usage
if usage := response_json.get("usage"):
input_tokens = usage.get("input_tokens", 0)
output_tokens = usage.get("output_tokens", 0)
thinking_tokens = (usage.get("output_tokens_details") or {}).get("reasoning_tokens", 0)
# Extract usage
if usage := response_json.get("usage"):
input_tokens = usage.get("input_tokens", 0)
output_tokens = usage.get("output_tokens", 0)
costs = MODEL_COSTS.get(model, (0.0, 0.0))
cost = (input_tokens * costs[0] + output_tokens * costs[1]) / 1e6
print(
f"{model} ({input_tokens}→{output_tokens} = {input_tokens + output_tokens} tokens, ${cost:.5f}, {elapsed:.1f}s)"
)
else:
content = ""
for item in response_json.get("output", []):
if item.get("type") == "message":
for c in item.get("content", []):
if c.get("type") == "output_text":
content += c.get("text") or ""
content = content.strip()
# Calculate cost
costs = MODEL_COSTS.get(model, (0.0, 0.0))
cost = (input_tokens * costs[0] + output_tokens * costs[1]) / 1e6
# Format summary
token_str = f"{input_tokens}→{output_tokens - thinking_tokens}"
if thinking_tokens:
token_str += f" (+{thinking_tokens} thinking)"
print(f"{model} ({token_str} = {input_tokens + output_tokens} tokens, ${cost:.5f}, {elapsed:.1f}s)")
if usage := response_json.get("usage"):
input_tokens = usage.get("input_tokens", 0)
output_tokens = usage.get("output_tokens", 0)
thinking_tokens = (usage.get("output_tokens_details") or {}).get("reasoning_tokens", 0)
costs = MODEL_COSTS.get(model, (0.0, 0.0))
cost = (input_tokens * costs[0] + output_tokens * costs[1]) / 1e6
token_str = f"{input_tokens}→{output_tokens - thinking_tokens}"
if thinking_tokens:
token_str += f" (+{thinking_tokens} thinking)"
print(f"{model} ({token_str} = {input_tokens + output_tokens} tokens, ${cost:.5f}, {elapsed:.1f}s)")
if text_format and text_format.get("format", {}).get("type") in ["json_object", "json_schema"]:
content = remove_outer_codeblocks(content)
return json.loads(content)
content = remove_outer_codeblocks(content)
@@ -201,19 +283,19 @@ def get_response(
content = content.replace(x, "")
# Retry on bad links
if attempt < 2 and check_links and not check_links_in_string(content):
if attempt < retries and check_links and not check_links_in_string(content):
print("Bad URLs detected, retrying")
continue
return content
except (requests.exceptions.ConnectionError, requests.exceptions.Timeout, json.JSONDecodeError) as e:
if attempt < 2:
print(f"Retrying {e.__class__.__name__} in {2**attempt}s (attempt {attempt + 1}/3)...")
if attempt < retries:
print(f"Retrying {e.__class__.__name__} in {2**attempt}s (attempt {attempt + 1}/{retries + 1})...")
time.sleep(2**attempt)
continue
raise
except requests.exceptions.HTTPError: # 4xx errors
except requests.exceptions.HTTPError:
raise
return content
+50 -1
View File
@@ -2,7 +2,16 @@
from unittest.mock import MagicMock, patch
from actions.utils.openai_utils import get_response, remove_outer_codeblocks
from actions.utils.openai_utils import _is_anthropic_model, get_response, get_review_model, remove_outer_codeblocks
def test_is_anthropic_model():
"""Test model provider detection."""
assert _is_anthropic_model("claude-sonnet-4-5-20250929") is True
assert _is_anthropic_model("claude-haiku-4-5-20251001") is True
assert _is_anthropic_model("claude-opus-4-5-20251101") is True
assert _is_anthropic_model("gpt-5.2-2025-12-11") is False
assert _is_anthropic_model("gpt-5-mini-2025-08-07") is False
def test_remove_outer_codeblocks():
@@ -22,6 +31,20 @@ def test_remove_outer_codeblocks():
assert remove_outer_codeblocks(input_str) == input_str
def test_get_review_model_override():
"""Test review model override logic."""
with patch("actions.utils.openai_utils.REVIEW_MODEL", "claude-opus-4-5-20251101"):
with patch("actions.utils.openai_utils.MODEL", "gpt-5.2-2025-12-11"):
assert get_review_model() == "claude-opus-4-5-20251101"
def test_get_review_model_fallback():
"""Test review model fallback to default model."""
with patch("actions.utils.openai_utils.REVIEW_MODEL", None):
with patch("actions.utils.openai_utils.MODEL", "gpt-5.2-2025-12-11"):
assert get_review_model() == "gpt-5.2-2025-12-11"
@patch("requests.post")
def test_get_response(mock_post):
"""Test OpenAI Responses API completion function with mocked response."""
@@ -79,3 +102,29 @@ def test_get_response_with_link_check(mock_check_links, mock_post):
assert result == "Response with https://example.com link"
mock_check_links.assert_called_once()
@patch("requests.post")
def test_get_response_anthropic(mock_post):
"""Test Anthropic Messages API completion function with mocked response."""
# Setup mock response with Anthropic Messages API structure
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.elapsed.total_seconds.return_value = 1.5
mock_response.json.return_value = {
"content": [{"type": "text", "text": "Test response from Claude"}],
"usage": {"input_tokens": 50, "output_tokens": 20},
}
mock_post.return_value = mock_response
messages = [{"role": "system", "content": "You are a helpful assistant"}, {"role": "user", "content": "Hello"}]
with patch.dict("os.environ", {"ANTHROPIC_API_KEY": "test-key"}, clear=False):
with patch("actions.utils.openai_utils.ANTHROPIC_API_KEY", "test-key"):
result = get_response(messages, check_links=False, model="claude-sonnet-4-5-20250929")
assert result == "Test response from Claude"
mock_post.assert_called_once()
# Verify Anthropic endpoint was called
call_args = mock_post.call_args
assert call_args[0][0] == "https://api.anthropic.com/v1/messages"