Add Anthropic support (#678)
Co-authored-by: Glenn Jocher <[email protected]> Co-authored-by: UltralyticsAssistant <[email protected]>
This commit is contained in:
co-authored by
Glenn Jocher
UltralyticsAssistant
parent
561452b15e
commit
e02546da2b
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
[](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
@@ -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
@@ -27,4 +27,4 @@
|
||||
# ├── test_summarize_pr.py
|
||||
# └── ...
|
||||
|
||||
__version__ = "0.2.10"
|
||||
__version__ = "0.2.11"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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']}")
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user