Adopt GPT-5.6 models for Actions (#805)
Co-authored-by: UltralyticsAssistant <[email protected]>
This commit is contained in:
co-authored by
UltralyticsAssistant
parent
595b58d2c3
commit
7432aeeac2
@@ -49,7 +49,7 @@ Choose between [OpenAI](https://developers.openai.com/) or [Anthropic](https://w
|
||||
|
||||
| Provider | Default Model | API Key |
|
||||
| --------- | ------------------- | ------------------- |
|
||||
| OpenAI | `gpt-5.5` | `openai_api_key` |
|
||||
| OpenAI | `gpt-5.6-luna` | `openai_api_key` |
|
||||
| Anthropic | `claude-sonnet-4-6` | `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.
|
||||
@@ -111,7 +111,7 @@ jobs:
|
||||
# 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: gpt-5.5 # Optional: set model explicitly
|
||||
# model: gpt-5.6-luna # Optional: set model explicitly
|
||||
# review_model: claude-opus-4-7 # Optional: override PR review model
|
||||
brave_api_key: ${{ secrets.BRAVE_API_KEY }} # Used for broken link resolution
|
||||
```
|
||||
|
||||
+2
-2
@@ -49,7 +49,7 @@
|
||||
|
||||
| 提供商 | 默认模型 | API Key |
|
||||
| --------- | ------------------- | ------------------- |
|
||||
| OpenAI | `gpt-5.5` | `openai_api_key` |
|
||||
| OpenAI | `gpt-5.6-luna` | `openai_api_key` |
|
||||
| Anthropic | `claude-sonnet-4-6` | `anthropic_api_key` |
|
||||
|
||||
模型会根据提供的 API key 自动检测。可通过 `model` 输入覆盖默认模型,也可使用 `review_model` 仅覆盖 PR review 模型。
|
||||
@@ -111,7 +111,7 @@ jobs:
|
||||
# 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: gpt-5.5 # Optional: set model explicitly
|
||||
# model: gpt-5.6-luna # Optional: set model explicitly
|
||||
# review_model: claude-opus-4-7 # Optional: override PR review model
|
||||
brave_api_key: ${{ secrets.BRAVE_API_KEY }} # Used for broken link resolution
|
||||
```
|
||||
|
||||
+2
-2
@@ -76,10 +76,10 @@ inputs:
|
||||
description: "Anthropic API Key"
|
||||
required: false
|
||||
model:
|
||||
description: "AI Model - defaults to GPT-5.5 or Sonnet 4.6 based on API key"
|
||||
description: "AI Model - defaults to GPT-5.6 Luna or Sonnet 4.6 based on API key"
|
||||
required: false
|
||||
review_model:
|
||||
description: "AI Model for PR reviews (optional, defaults to gpt-5.5)"
|
||||
description: "AI Model for PR reviews (optional, defaults to gpt-5.6-terra with medium reasoning)"
|
||||
required: false
|
||||
first_issue_response:
|
||||
description: "Example response to a new issue"
|
||||
|
||||
+1
-1
@@ -28,4 +28,4 @@
|
||||
# ├── test_summarize_pr.py
|
||||
# └── ...
|
||||
|
||||
__version__ = "0.2.24"
|
||||
__version__ = "0.2.25"
|
||||
|
||||
@@ -216,9 +216,13 @@ def main(*args, **kwargs):
|
||||
# Automatic PR review after first interaction
|
||||
if AUTO_PR_REVIEW:
|
||||
print("Starting automatic PR review...")
|
||||
review_number = review_pr.dismiss_previous_reviews(event)
|
||||
review_data = review_pr.generate_pr_review(event.repository, diff, title, summary, event)
|
||||
review_pr.post_review_summary(event, review_data, review_number)
|
||||
try:
|
||||
review_diff, head_sha = event.get_pr_diff_snapshot()
|
||||
except RuntimeError as e:
|
||||
print(f"Skipping stale PR review: {e}")
|
||||
return
|
||||
review_data = review_pr.generate_pr_review(event.repository, review_diff, title, summary, event, head_sha)
|
||||
review_pr.post_review_summary(event, review_data)
|
||||
print("PR review completed")
|
||||
return
|
||||
|
||||
|
||||
+67
-76
@@ -32,7 +32,7 @@ MAX_REVIEW_COMMENTS = 8
|
||||
MAX_TOOL_OUTPUT_CHARS = 20000
|
||||
MAX_TOOL_FILE_LINES = 240
|
||||
MAX_AGENT_TURNS = 16
|
||||
MAX_REVIEW_COST = 2.00 # USD ceiling per review across all agent turns
|
||||
REVIEW_COST_SOFT_LIMIT = 2.00 # stop requesting tools after cumulative spend reaches this amount
|
||||
SEVERITY_RANK = {"CRITICAL": 0, "HIGH": 1, "MEDIUM": 2, "LOW": 3, "SUGGESTION": 4, None: 5}
|
||||
|
||||
|
||||
@@ -83,21 +83,6 @@ def search_repo(query: str, path_glob=None) -> str:
|
||||
return _clip_tool_output("\n".join(matches)) if matches else "No matches found."
|
||||
|
||||
|
||||
def _pr_head_sha(event: Action | None) -> str | None:
|
||||
"""Return the live PR head SHA (payload fallback) so reviews read the same code the diff describes."""
|
||||
if event is None or not event.pr:
|
||||
return None
|
||||
if (number := event.pr.get("number")) and event.repository:
|
||||
try:
|
||||
response = event.get(f"{GITHUB_API_URL}/repos/{event.repository}/pulls/{number}")
|
||||
if response.status_code == 200 and (sha := (response.json().get("head") or {}).get("sha")):
|
||||
return sha
|
||||
except Exception as e:
|
||||
print(f"Live PR head lookup failed: {e}")
|
||||
sha = (event.pr.get("head") or {}).get("sha")
|
||||
return sha if isinstance(sha, str) else None
|
||||
|
||||
|
||||
def _fetch_head_file(event: Action, sha: str, path: str) -> str | None:
|
||||
"""Fetch one file's text from the PR head via the GitHub contents API; None only if it does not exist (404)."""
|
||||
url = f"{GITHUB_API_URL}/repos/{event.repository}/contents/{quote(path)}?ref={sha}"
|
||||
@@ -182,7 +167,7 @@ def build_review_agent_tools(
|
||||
if not head_tree:
|
||||
response = event.get(f"{GITHUB_API_URL}/repos/{event.repository}/git/trees/{head_sha}?recursive=1")
|
||||
if response.status_code != 200:
|
||||
return f"list_files failed: HTTP {response.status_code}." # don't cache failures
|
||||
raise RuntimeError(f"list_files failed: HTTP {response.status_code}")
|
||||
head_tree.append([t["path"] for t in response.json().get("tree", []) if t.get("type") == "blob"])
|
||||
files = sorted(p for p in head_tree[0] if (not path_glob or fnmatch(p, path_glob)) and not should_skip_file(p))
|
||||
return _clip_tool_output("\n".join(files[:300])) if files else "No matching files found."
|
||||
@@ -321,11 +306,7 @@ def get_repo_guidelines(
|
||||
# Prefer CLAUDE.md for Anthropic models, AGENTS.md for others; load only one, never both
|
||||
agent_prefs = ("CLAUDE.md", "AGENTS.md") if "claude" in model.lower() else ("AGENTS.md", "CLAUDE.md")
|
||||
for filename in ("CONTRIBUTING.md", *agent_prefs):
|
||||
try:
|
||||
content = (_read_head_file(event, head_sha, local_checkout, filename) or "")[:MAX_CONTEXT_FILE_CHARS]
|
||||
except Exception as e:
|
||||
print(f"Failed to read {filename}: {e}")
|
||||
continue
|
||||
content = (_read_head_file(event, head_sha, local_checkout, filename) or "")[:MAX_CONTEXT_FILE_CHARS]
|
||||
if content:
|
||||
guidelines.append(f"### {filename}\n~~~\n{content}\n~~~")
|
||||
print(f"Loaded {filename} ({len(content)} chars) for review context")
|
||||
@@ -376,19 +357,28 @@ def parse_diff_files(diff_text: str) -> tuple[dict, str]:
|
||||
else:
|
||||
augmented_lines.append(line)
|
||||
|
||||
files = {path: sides for path, sides in files.items() if sides["RIGHT"] or sides["LEFT"]}
|
||||
return files, "\n".join(augmented_lines)
|
||||
|
||||
|
||||
def generate_pr_review(
|
||||
repository: str, diff_text: str, pr_title: str, pr_description: str, event: Action = None
|
||||
repository: str,
|
||||
diff_text: str,
|
||||
pr_title: str,
|
||||
pr_description: str,
|
||||
event: Action = None,
|
||||
head_sha: str | None = None,
|
||||
) -> dict:
|
||||
"""Generate comprehensive PR review with line-specific comments and overall assessment."""
|
||||
head_sha = head_sha or (event.get_pr_head_sha() if event else None)
|
||||
if diff_text.startswith("ERROR:"):
|
||||
return {"comments": [], "summary": f"{ERROR_MARKER}: {diff_text}", "head_sha": head_sha}
|
||||
if not diff_text:
|
||||
return {"comments": [], "summary": "No changes detected in diff"}
|
||||
return {"comments": [], "summary": "No changes detected in diff", "head_sha": head_sha}
|
||||
|
||||
diff_files, augmented_diff = parse_diff_files(diff_text)
|
||||
if not diff_files:
|
||||
return {"comments": [], "summary": "No files with changes detected in diff"}
|
||||
return {"comments": [], "summary": "No reviewable text changes detected in diff", "head_sha": head_sha}
|
||||
|
||||
# Filter out generated/vendored files
|
||||
filtered_files = {p: s for p, s in diff_files.items() if not should_skip_file(p)}
|
||||
@@ -400,6 +390,7 @@ def generate_pr_review(
|
||||
"comments": [],
|
||||
"summary": f"All {len(skipped_files)} changed files are generated/vendored (skipped review)",
|
||||
"skipped_files": skipped_files,
|
||||
"head_sha": head_sha,
|
||||
}
|
||||
|
||||
file_list = list(diff_files.keys())
|
||||
@@ -408,7 +399,6 @@ def generate_pr_review(
|
||||
# Read model-appropriate guidelines from the PR head for project-specific review context
|
||||
review_model = get_review_model()
|
||||
is_agent_review_model = not _is_anthropic_model(review_model)
|
||||
head_sha = _pr_head_sha(event)
|
||||
local_checkout = _verified_local_checkout(head_sha)
|
||||
if head_sha:
|
||||
print(f"Reviewing PR head {head_sha[:7]} ({'local checkout' if local_checkout else 'via GitHub API'})")
|
||||
@@ -419,10 +409,7 @@ def generate_pr_review(
|
||||
if event and head_sha and not is_agent_review_model and len(file_list) <= 10: # Reasonable file count limit
|
||||
file_contents, total_chars = [], len(augmented_diff) + len(guidelines_section)
|
||||
for file_path in file_list: # already filtered by should_skip_file above
|
||||
try:
|
||||
text = _read_head_file(event, head_sha, local_checkout, file_path) or ""
|
||||
except Exception:
|
||||
continue
|
||||
text = _read_head_file(event, head_sha, local_checkout, file_path) or ""
|
||||
if not text or len(text) > 100_000: # skip missing and >100KB files entirely
|
||||
continue
|
||||
snippet = text[:MAX_CONTEXT_FILE_CHARS]
|
||||
@@ -575,10 +562,11 @@ def generate_pr_review(
|
||||
messages,
|
||||
text_format={"format": {"type": "json_schema", "name": "pr_review", "strict": True, "schema": schema}},
|
||||
model=review_model,
|
||||
reasoning_effort="medium",
|
||||
tools=tools,
|
||||
tool_handlers=tool_handlers,
|
||||
max_turns=MAX_AGENT_TURNS,
|
||||
max_cost=MAX_REVIEW_COST,
|
||||
max_cost=REVIEW_COST_SOFT_LIMIT,
|
||||
parallel_tools=True, # review tools are read-only GitHub/diff reads, safe to batch concurrently
|
||||
request_timeout=(30, 120),
|
||||
retries=1, # one transient failure on any of the sequential turns would otherwise abort the whole review
|
||||
@@ -687,36 +675,7 @@ def generate_pr_review(
|
||||
f"{ERROR_MARKER}: `{type(e).__name__}`\n\n"
|
||||
f"<details><summary>Debug Info</summary>\n\n```\n{error_details}\n```\n</details>"
|
||||
)
|
||||
return {"comments": [], "summary": summary}
|
||||
|
||||
|
||||
def dismiss_previous_reviews(event: Action) -> int:
|
||||
"""Dismiss previous bot reviews and delete inline comments, returns count for numbering."""
|
||||
if not (pr_number := event.pr.get("number")) or not (bot_username := event.get_username()):
|
||||
return 1
|
||||
|
||||
review_count = 0
|
||||
reviews_base = f"{GITHUB_API_URL}/repos/{event.repository}/pulls/{pr_number}/reviews"
|
||||
reviews_url = f"{reviews_base}?per_page=100"
|
||||
if (response := event.get(reviews_url)).status_code == 200:
|
||||
for review in response.json():
|
||||
if review.get("user", {}).get("login") == bot_username and REVIEW_MARKER in (review.get("body") or ""):
|
||||
review_count += 1
|
||||
if review.get("state") in ["APPROVED", "CHANGES_REQUESTED"] and (review_id := review.get("id")):
|
||||
event.put(f"{reviews_base}/{review_id}/dismissals", json={"message": "Superseded by new review"})
|
||||
|
||||
# Delete previous inline comments
|
||||
comments_url = f"{GITHUB_API_URL}/repos/{event.repository}/pulls/{pr_number}/comments?per_page=100"
|
||||
delete_base = f"{GITHUB_API_URL}/repos/{event.repository}/pulls/comments"
|
||||
if (response := event.get(comments_url)).status_code == 200:
|
||||
for comment in response.json():
|
||||
if comment.get("user", {}).get("login") == bot_username and (comment_id := comment.get("id")):
|
||||
event.delete(
|
||||
f"{delete_base}/{comment_id}",
|
||||
expected_status=[200, 204, 404],
|
||||
)
|
||||
|
||||
return review_count + 1
|
||||
return {"comments": [], "summary": summary, "head_sha": head_sha}
|
||||
|
||||
|
||||
def get_local_head_sha() -> str | None:
|
||||
@@ -744,27 +703,55 @@ def _verified_local_checkout(head_sha: str | None) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def post_review_summary(event: Action, review_data: dict, review_number: int) -> None:
|
||||
def clear_previous_review(event: Action) -> None:
|
||||
"""Dismiss the bot's active review decisions and delete its superseded inline comments."""
|
||||
pr_number, bot_username = event.pr.get("number"), event.get_username()
|
||||
reviews_base = f"{GITHUB_API_URL}/repos/{event.repository}/pulls/{pr_number}/reviews"
|
||||
reviews = event.get(reviews_base, params={"per_page": 100}, hard=True).json()
|
||||
owned_reviews = {
|
||||
review["id"]
|
||||
for review in reviews
|
||||
if review.get("user", {}).get("login") == bot_username and REVIEW_MARKER in (review.get("body") or "")
|
||||
}
|
||||
for review in reviews:
|
||||
if review.get("id") in owned_reviews and review.get("state") in ("APPROVED", "CHANGES_REQUESTED"):
|
||||
event.put(
|
||||
f"{reviews_base}/{review['id']}/dismissals",
|
||||
json={"message": "Superseded by new review"},
|
||||
hard=True,
|
||||
)
|
||||
|
||||
comments_base = f"{GITHUB_API_URL}/repos/{event.repository}/pulls/{pr_number}/comments"
|
||||
comments = event.get(comments_base, params={"per_page": 100}, hard=True).json()
|
||||
for comment in comments:
|
||||
if comment.get("pull_request_review_id") in owned_reviews:
|
||||
event.delete(f"{GITHUB_API_URL}/repos/{event.repository}/pulls/comments/{comment['id']}", hard=True)
|
||||
|
||||
|
||||
def post_review_summary(event: Action, review_data: dict) -> None:
|
||||
"""Post overall review summary and inline comments as a single PR review."""
|
||||
if not (pr_number := event.pr.get("number")):
|
||||
return
|
||||
|
||||
# Anchor to the exact head the review was generated from; fall back to the local checkout, then live head
|
||||
commit_sha = review_data.get("head_sha") or get_local_head_sha() or _pr_head_sha(event)
|
||||
if not commit_sha:
|
||||
return
|
||||
commit_sha = review_data["head_sha"]
|
||||
if event.get_pr_head_sha() != commit_sha:
|
||||
raise RuntimeError("PR head changed during review generation")
|
||||
|
||||
review_title = f"{REVIEW_MARKER} {review_number}" if review_number > 1 else REVIEW_MARKER
|
||||
comments = review_data.get("comments", [])
|
||||
summary = review_data.get("summary") or ""
|
||||
|
||||
# Don't approve if error occurred, inline comments exist, or medium-or-higher severity issues
|
||||
has_error = not summary or ERROR_MARKER in summary
|
||||
has_evidence = bool(review_data.get("diff_files"))
|
||||
has_inline_comments = review_data.get("comments_before_filtering", 0) > 0
|
||||
has_issues = any(c.get("severity") not in ["LOW", "SUGGESTION", None] for c in comments)
|
||||
event_type = "COMMENT" if (has_error or has_inline_comments or has_issues) else "APPROVE"
|
||||
event_type = (
|
||||
"COMMENT"
|
||||
if (has_error or not has_evidence or has_inline_comments or has_issues or review_data.get("diff_truncated"))
|
||||
else "APPROVE"
|
||||
)
|
||||
|
||||
body = f"{review_title}\n\n{ACTIONS_CREDIT}\n\n{summary[:3000]}\n\n"
|
||||
body = f"{REVIEW_MARKER}\n\n{ACTIONS_CREDIT}\n\n{summary[:3000]}\n\n"
|
||||
|
||||
if comments:
|
||||
body += f"💬 Posted {len(comments)} inline comment{'s' if len(comments) != 1 else ''}\n"
|
||||
@@ -806,7 +793,7 @@ def post_review_summary(event: Action, review_data: dict, review_number: int) ->
|
||||
if review_comments:
|
||||
payload["comments"] = review_comments
|
||||
|
||||
event.post(f"{GITHUB_API_URL}/repos/{event.repository}/pulls/{pr_number}/reviews", json=payload)
|
||||
event.post(f"{GITHUB_API_URL}/repos/{event.repository}/pulls/{pr_number}/reviews", json=payload, hard=True)
|
||||
|
||||
|
||||
def main(*args, **kwargs):
|
||||
@@ -828,12 +815,16 @@ def main(*args, **kwargs):
|
||||
return
|
||||
|
||||
print(f"Starting PR review for #{event.pr['number']}")
|
||||
review_number = dismiss_previous_reviews(event)
|
||||
|
||||
diff = event.get_pr_diff()
|
||||
review = generate_pr_review(event.repository, diff, event.pr.get("title") or "", event.pr.get("body") or "", event)
|
||||
|
||||
post_review_summary(event, review, review_number)
|
||||
try:
|
||||
diff, head_sha = event.get_pr_diff_snapshot()
|
||||
except RuntimeError as e:
|
||||
print(f"Skipping stale PR review: {e}")
|
||||
return
|
||||
clear_previous_review(event)
|
||||
review = generate_pr_review(
|
||||
event.repository, diff, event.pr.get("title") or "", event.pr.get("body") or "", event, head_sha
|
||||
)
|
||||
post_review_summary(event, review)
|
||||
print("PR review completed")
|
||||
|
||||
|
||||
|
||||
@@ -236,9 +236,9 @@ class Action:
|
||||
return True
|
||||
return False
|
||||
|
||||
def get_pr_diff(self) -> str:
|
||||
def get_pr_diff(self, refresh: bool = False) -> str:
|
||||
"""Retrieves the diff content for a specified pull request with caching."""
|
||||
if self._pr_diff_cache:
|
||||
if self._pr_diff_cache and not refresh:
|
||||
return self._pr_diff_cache
|
||||
|
||||
url = f"{GITHUB_API_URL}/repos/{self.repository}/pulls/{self.pr.get('number')}"
|
||||
@@ -251,6 +251,26 @@ class Action:
|
||||
self._pr_diff_cache = "ERROR: UNABLE TO RETRIEVE DIFF."
|
||||
return self._pr_diff_cache
|
||||
|
||||
def get_pr_head_sha(self) -> str | None:
|
||||
"""Return the live PR head SHA."""
|
||||
if not self.pr:
|
||||
return None
|
||||
response = self.get(f"{GITHUB_API_URL}/repos/{self.repository}/pulls/{self.pr.get('number')}")
|
||||
if response.status_code == 200 and (sha := (response.json().get("head") or {}).get("sha")):
|
||||
return sha
|
||||
return None
|
||||
|
||||
def get_pr_diff_snapshot(self) -> tuple[str, str]:
|
||||
"""Fetch a diff whose PR head stayed unchanged for the full request."""
|
||||
for _ in range(2):
|
||||
head_before = self.get_pr_head_sha()
|
||||
diff = self.get_pr_diff(refresh=True)
|
||||
head_after = self.get_pr_head_sha()
|
||||
if head_before and head_before == head_after:
|
||||
return diff, head_after
|
||||
print(f"PR head moved while fetching diff ({head_before} -> {head_after}); retrying")
|
||||
raise RuntimeError("PR head changed repeatedly while fetching the review diff")
|
||||
|
||||
def get_repo_data(self, endpoint: str) -> dict:
|
||||
"""Fetches repository data from a specified endpoint."""
|
||||
return self.get(f"{GITHUB_API_URL}/repos/{self.repository}/{endpoint}").json()
|
||||
|
||||
@@ -18,11 +18,12 @@ 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) # deliberate COST ceiling, not a context limit; agent tools read the rest
|
||||
WEB_SEARCH_CALL_COST = 0.01 # $10 per 1K calls
|
||||
|
||||
# Default models (single source of truth)
|
||||
OPENAI_MODEL_DEFAULT = "gpt-5.5"
|
||||
OPENAI_MODEL_DEFAULT = "gpt-5.6-luna"
|
||||
ANTHROPIC_MODEL_DEFAULT = "claude-sonnet-4-6"
|
||||
PR_REVIEW_MODEL_DEFAULT = "gpt-5.5"
|
||||
PR_REVIEW_MODEL_DEFAULT = "gpt-5.6-terra"
|
||||
|
||||
MODEL_COSTS = { # (input, output) per 1M tokens
|
||||
# OpenAI models
|
||||
@@ -34,6 +35,9 @@ MODEL_COSTS = { # (input, output) per 1M tokens
|
||||
"gpt-5.3-codex": (1.75, 14.00),
|
||||
"gpt-5.5": (5.00, 30.00),
|
||||
"gpt-5.4": (2.50, 15.00),
|
||||
"gpt-5.6-sol": (5.00, 30.00),
|
||||
"gpt-5.6-terra": (2.50, 15.00),
|
||||
"gpt-5.6-luna": (1.00, 6.00),
|
||||
"gpt-5-nano-2025-08-07": (0.05, 0.40),
|
||||
"gpt-5-mini-2025-08-07": (0.25, 2.00),
|
||||
# Anthropic Claude models
|
||||
@@ -222,7 +226,7 @@ def _add_openai_usage(total_usage: dict | None, response_json: dict) -> dict | N
|
||||
total_usage = total_usage or {
|
||||
"input_tokens": 0,
|
||||
"output_tokens": 0,
|
||||
"input_tokens_details": {"cached_tokens": 0},
|
||||
"input_tokens_details": {"cache_write_tokens": 0, "cached_tokens": 0},
|
||||
"output_tokens_details": {"reasoning_tokens": 0},
|
||||
}
|
||||
total_usage["input_tokens"] += usage.get("input_tokens", 0)
|
||||
@@ -230,29 +234,40 @@ def _add_openai_usage(total_usage: dict | None, response_json: dict) -> dict | N
|
||||
total_usage["input_tokens_details"]["cached_tokens"] += (usage.get("input_tokens_details") or {}).get(
|
||||
"cached_tokens", 0
|
||||
)
|
||||
total_usage["input_tokens_details"]["cache_write_tokens"] += (usage.get("input_tokens_details") or {}).get(
|
||||
"cache_write_tokens", 0
|
||||
)
|
||||
total_usage["output_tokens_details"]["reasoning_tokens"] += (usage.get("output_tokens_details") or {}).get(
|
||||
"reasoning_tokens", 0
|
||||
)
|
||||
return total_usage
|
||||
|
||||
|
||||
def _normalize_usage_tokens(usage: dict) -> tuple[int, int]:
|
||||
"""Return (input_tokens, cached_tokens) for OpenAI Responses or Anthropic Messages usage shapes.
|
||||
def _normalize_usage_tokens(usage: dict) -> tuple[int, int, int]:
|
||||
"""Return input, cache-read, and cache-write tokens for OpenAI Responses or Anthropic Messages usage shapes.
|
||||
|
||||
Anthropic reports cache reads/writes outside input_tokens, so both fold back into the input total and reads count as
|
||||
cached — the same normalization ultralytics/assistant applies, keeping cross-repo telemetry identical.
|
||||
"""
|
||||
cache_read = usage.get("cache_read_input_tokens", 0)
|
||||
input_tokens = usage.get("input_tokens", 0) + cache_read + usage.get("cache_creation_input_tokens", 0)
|
||||
cached_tokens = (usage.get("input_tokens_details") or {}).get("cached_tokens", 0) or cache_read
|
||||
return input_tokens, cached_tokens
|
||||
details = usage.get("input_tokens_details") or {}
|
||||
cached_tokens = details.get("cached_tokens", 0) or cache_read
|
||||
cache_write_tokens = details.get("cache_write_tokens", 0)
|
||||
return input_tokens, cached_tokens, cache_write_tokens
|
||||
|
||||
|
||||
def _openai_usage_cost(usage: dict, model: str) -> float:
|
||||
"""Compute billed USD cost for one usage block (cached input billed at 10% of the input rate)."""
|
||||
"""Compute billed USD cost including GPT-5.6 cache-write and long-context rates."""
|
||||
costs = MODEL_COSTS.get(model, (0.0, 0.0))
|
||||
input_tokens, cached_tokens = _normalize_usage_tokens(usage)
|
||||
return ((input_tokens - cached_tokens * 0.9) * costs[0] + usage.get("output_tokens", 0) * costs[1]) / 1e6
|
||||
input_tokens, cached_tokens, cache_write_tokens = _normalize_usage_tokens(usage)
|
||||
cache_write_premium = cache_write_tokens * 0.25 if model.startswith("gpt-5.6-") else 0
|
||||
billed_input = input_tokens - cached_tokens * 0.9 + cache_write_premium
|
||||
long_context = model.startswith("gpt-5.6-") and input_tokens > 272000
|
||||
return (
|
||||
billed_input * costs[0] * (2 if long_context else 1)
|
||||
+ usage.get("output_tokens", 0) * costs[1] * (1.5 if long_context else 1)
|
||||
) / 1e6
|
||||
|
||||
|
||||
def _format_tool_calls(calls: list[str]) -> str:
|
||||
@@ -264,13 +279,15 @@ def _format_tool_calls(calls: list[str]) -> str:
|
||||
return f"{len(calls)} tools" + (f" ({types})" if calls else "")
|
||||
|
||||
|
||||
def _print_openai_usage(response_json: dict, model: str, elapsed: float, metadata: str = "") -> None:
|
||||
def _print_openai_usage(
|
||||
response_json: dict, model: str, elapsed: float, metadata: str = "", billed_cost: float | None = None
|
||||
) -> None:
|
||||
"""Print token/cost telemetry: 'model: 136036→289 tokens (72% cached, 31 thinking), $0.69, 8.9s'."""
|
||||
if usage := response_json.get("usage"):
|
||||
input_tokens, cached_tokens = _normalize_usage_tokens(usage)
|
||||
input_tokens, cached_tokens, _ = _normalize_usage_tokens(usage)
|
||||
output_tokens = usage.get("output_tokens", 0) # includes thinking, noted in the parenthetical
|
||||
thinking_tokens = (usage.get("output_tokens_details") or {}).get("reasoning_tokens", 0)
|
||||
cost = _openai_usage_cost(usage, model)
|
||||
cost = _openai_usage_cost(usage, model) if billed_cost is None else billed_cost
|
||||
notes = []
|
||||
if cached_tokens:
|
||||
notes.append(f"{round(100 * cached_tokens / input_tokens)}% cached")
|
||||
@@ -306,7 +323,10 @@ def _post_openai_response(
|
||||
r.reason = f"{r.reason}\n{error_body}"
|
||||
|
||||
r.raise_for_status()
|
||||
return r.json(), elapsed
|
||||
response_json = r.json()
|
||||
return (
|
||||
_poll_openai_response(response_json, headers) if response_json.get("status") else response_json
|
||||
), elapsed
|
||||
except (requests.exceptions.ConnectionError, json.JSONDecodeError):
|
||||
# ConnectTimeout subclasses ConnectionError so it stays retryable; a ReadTimeout propagates instead,
|
||||
# because the request may have completed server-side and re-POSTing it would double-bill.
|
||||
@@ -340,14 +360,11 @@ def _handle_function_call(call: dict, tool_handlers: dict[str, Callable]) -> dic
|
||||
"""Execute one model-requested function call and return a Responses API output item."""
|
||||
name = call.get("name")
|
||||
call_id = call.get("call_id")
|
||||
try:
|
||||
if name not in tool_handlers:
|
||||
raise KeyError(f"Unknown tool: {name}")
|
||||
output = tool_handlers[name](**_parse_tool_arguments(call))
|
||||
if not isinstance(output, str):
|
||||
output = json.dumps(output)
|
||||
except Exception as e:
|
||||
output = f"{name or 'tool'} failed: {type(e).__name__}: {e}"
|
||||
if name not in tool_handlers:
|
||||
raise KeyError(f"Unknown tool: {name}")
|
||||
output = tool_handlers[name](**_parse_tool_arguments(call))
|
||||
if not isinstance(output, str):
|
||||
output = json.dumps(output)
|
||||
return {"type": "function_call_output", "call_id": call_id, "output": output}
|
||||
|
||||
|
||||
@@ -367,9 +384,9 @@ def get_agent_response(
|
||||
) -> str | dict:
|
||||
"""Run an iterative OpenAI Responses API agent with application-managed function tools.
|
||||
|
||||
max_cost is a USD ceiling across all turns (0 disables); once reached, remaining tool turns are skipped and the
|
||||
agent synthesizes a final answer. Models missing from MODEL_COSTS disable max_cost loudly; max_turns still bounds.
|
||||
parallel_tools runs a turn's batched tool calls concurrently: opt in ONLY when every handler is thread-safe.
|
||||
max_cost is a USD ceiling across all turns (0 disables); a tool request after reaching it aborts the incomplete
|
||||
agent run. Models missing from MODEL_COSTS disable max_cost loudly; max_turns still bounds. parallel_tools runs a
|
||||
turn's batched tool calls concurrently: opt in ONLY when every handler is thread-safe.
|
||||
"""
|
||||
model = model or _get_default_model()
|
||||
if max_cost and model not in MODEL_COSTS:
|
||||
@@ -396,6 +413,7 @@ def get_agent_response(
|
||||
|
||||
base_data = {
|
||||
"model": model,
|
||||
"service_tier": "default",
|
||||
"store": True,
|
||||
"temperature": temperature,
|
||||
"tools": tools,
|
||||
@@ -409,6 +427,7 @@ def get_agent_response(
|
||||
|
||||
tool_calls = []
|
||||
total_elapsed = 0.0
|
||||
total_cost = 0.0
|
||||
total_usage = None
|
||||
previous_response_id = None
|
||||
next_input = conversation
|
||||
@@ -423,9 +442,18 @@ def get_agent_response(
|
||||
previous_response_id = response_json.get("id")
|
||||
output_items = response_json.get("output", [])
|
||||
turn_calls = _response_tool_calls(output_items)
|
||||
turn_cost = (
|
||||
_openai_usage_cost(response_json.get("usage") or {}, model)
|
||||
+ turn_calls.count("web_search") * WEB_SEARCH_CALL_COST
|
||||
)
|
||||
total_cost += turn_cost
|
||||
tool_calls += turn_calls
|
||||
_print_openai_usage(
|
||||
response_json, model, elapsed, f"turn {turn + 1}/{max_turns}, {_format_tool_calls(turn_calls)}"
|
||||
response_json,
|
||||
model,
|
||||
elapsed,
|
||||
f"turn {turn + 1}/{max_turns}, {_format_tool_calls(turn_calls)}",
|
||||
turn_cost,
|
||||
)
|
||||
function_calls = [item for item in output_items if item.get("type") == "function_call"]
|
||||
|
||||
@@ -435,18 +463,14 @@ def get_agent_response(
|
||||
model,
|
||||
total_elapsed,
|
||||
f"agent total, {turn + 1} turns, {_format_tool_calls(tool_calls)}",
|
||||
total_cost,
|
||||
)
|
||||
return _finalize_response_content(response_json, text_format)
|
||||
|
||||
if not previous_response_id:
|
||||
raise RuntimeError("OpenAI response did not include an id for server-managed continuation")
|
||||
if max_cost and _openai_usage_cost(total_usage, model) >= max_cost:
|
||||
print(f"Agent cost budget ${max_cost:.2f} reached; skipping remaining tool turns")
|
||||
next_input = [ # pending calls still need outputs for the chained synthesis request to be valid
|
||||
{"type": "function_call_output", "call_id": call.get("call_id"), "output": "Tool budget exhausted."}
|
||||
for call in function_calls
|
||||
]
|
||||
break
|
||||
if max_cost and total_cost >= max_cost:
|
||||
raise RuntimeError(f"Agent cost budget ${max_cost:.2f} reached before requested tools could run")
|
||||
if parallel_tools and len(function_calls) > 1: # opt-in contract: handlers must be thread-safe
|
||||
with ThreadPoolExecutor(max_workers=min(8, len(function_calls))) as pool:
|
||||
next_input = list(pool.map(lambda call: _handle_function_call(call, tool_handlers), function_calls))
|
||||
@@ -467,9 +491,14 @@ def get_agent_response(
|
||||
response_json, elapsed = _post_openai_response(data, headers, max(retries, 2), request_timeout)
|
||||
total_elapsed += elapsed
|
||||
total_usage = _add_openai_usage(total_usage, response_json)
|
||||
total_cost += _openai_usage_cost(response_json.get("usage") or {}, model)
|
||||
_print_openai_usage(response_json, model, elapsed, f"turn final/{max_turns}, 0 tools")
|
||||
_print_openai_usage(
|
||||
{"usage": total_usage}, model, total_elapsed, f"agent total, {turn + 2} turns, {_format_tool_calls(tool_calls)}"
|
||||
{"usage": total_usage},
|
||||
model,
|
||||
total_elapsed,
|
||||
f"agent total, {turn + 2} turns, {_format_tool_calls(tool_calls)}",
|
||||
total_cost,
|
||||
)
|
||||
return _finalize_response_content(response_json, text_format)
|
||||
|
||||
@@ -566,7 +595,7 @@ def get_response(
|
||||
|
||||
# Parse response
|
||||
response_json = r.json()
|
||||
if background:
|
||||
if background or (not is_anthropic and response_json.get("status")):
|
||||
response_json = _poll_openai_response(response_json, headers)
|
||||
elapsed = time.time() - started
|
||||
if is_anthropic:
|
||||
|
||||
+8
-2
@@ -16,11 +16,17 @@ inputs:
|
||||
runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
- uses: astral-sh/setup-uv@v7
|
||||
with:
|
||||
ignore-empty-workdir: true
|
||||
enable-cache: false
|
||||
version: "0.9.4"
|
||||
|
||||
- name: Install requests
|
||||
run: |
|
||||
cd "$GITHUB_ACTION_PATH/.."
|
||||
python -m venv "$RUNNER_TEMP/ultralytics-cla"
|
||||
"$RUNNER_TEMP/ultralytics-cla/bin/python" -m pip install --disable-pip-version-check \
|
||||
uv venv "$RUNNER_TEMP/ultralytics-cla"
|
||||
uv pip install --python "$RUNNER_TEMP/ultralytics-cla/bin/python" \
|
||||
"requests==2.32.4; python_version < '3.10'" \
|
||||
"requests==2.33.0; python_version >= '3.10'"
|
||||
shell: bash
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Ultralytics 🚀 AGPL-3.0 License - https://ultralytics.com/license
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
from unittest.mock import MagicMock, call, patch
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -137,7 +137,8 @@ def test_generate_pr_review_uses_synchronous_response(mock_get_agent_response, m
|
||||
"search_repo",
|
||||
}
|
||||
assert kwargs["max_turns"] == review_pr.MAX_AGENT_TURNS
|
||||
assert kwargs["max_cost"] == review_pr.MAX_REVIEW_COST
|
||||
assert kwargs["max_cost"] == review_pr.REVIEW_COST_SOFT_LIMIT
|
||||
assert kwargs["reasoning_effort"] == "medium"
|
||||
assert kwargs["request_timeout"] == (30, 120)
|
||||
assert "FULL FILE CONTENTS" not in mock_get_agent_response.call_args.args[0][1]["content"]
|
||||
|
||||
@@ -177,17 +178,103 @@ def test_review_agent_search_scans_local_checkout_only(tmp_path, monkeypatch):
|
||||
|
||||
|
||||
def test_pr_head_sha_prefers_live_value():
|
||||
"""Test head SHA resolution prefers the live PR value and falls back to the event payload."""
|
||||
"""Test head SHA resolution requires the live PR value."""
|
||||
event = MagicMock()
|
||||
event.repository = "org/repo"
|
||||
event.pr = {"number": 5, "head": {"sha": "old"}}
|
||||
live = MagicMock(status_code=200)
|
||||
live.json.return_value = {"head": {"sha": "new"}}
|
||||
event.get.return_value = live
|
||||
assert review_pr._pr_head_sha(event) == "new"
|
||||
assert review_pr.Action.get_pr_head_sha(event) == "new"
|
||||
event.get.return_value = MagicMock(status_code=500)
|
||||
assert review_pr._pr_head_sha(event) == "old"
|
||||
assert review_pr._pr_head_sha(None) is None
|
||||
assert review_pr.Action.get_pr_head_sha(event) is None
|
||||
|
||||
|
||||
def test_review_snapshot_retries_until_diff_and_head_match():
|
||||
"""Test review snapshots refetch the diff when a push races the first request."""
|
||||
event = MagicMock()
|
||||
event.get_pr_head_sha.side_effect = ["old", "new", "new", "new"]
|
||||
event.get_pr_diff.side_effect = ["old diff", "new diff"]
|
||||
|
||||
assert review_pr.Action.get_pr_diff_snapshot(event) == ("new diff", "new")
|
||||
assert event.get_pr_diff.call_args_list == [call(refresh=True), call(refresh=True)]
|
||||
assert event.get_pr_head_sha.call_count == 4
|
||||
|
||||
|
||||
def test_post_review_summary_fails_when_github_rejects_review():
|
||||
"""Test review publication is a required operation rather than a silent best effort."""
|
||||
event = MagicMock()
|
||||
event.repository = "org/repo"
|
||||
event.pr = {"number": 7}
|
||||
event.get_pr_head_sha.return_value = "abc"
|
||||
|
||||
review_pr.post_review_summary(event, {"head_sha": "abc", "summary": "LGTM", "comments": []})
|
||||
|
||||
assert event.post.call_args.kwargs["hard"] is True
|
||||
|
||||
|
||||
def test_clear_previous_review_preserves_summaries_and_deletes_inline_comments():
|
||||
"""Test replacement reviews invalidate bot decisions and remove only bot inline comments."""
|
||||
event = MagicMock()
|
||||
event.repository = "org/repo"
|
||||
event.pr = {"number": 7}
|
||||
event.get_username.return_value = "review-bot"
|
||||
event.get.side_effect = [
|
||||
MagicMock(
|
||||
json=lambda: [
|
||||
{"id": 1, "state": "APPROVED", "body": review_pr.REVIEW_MARKER, "user": {"login": "review-bot"}},
|
||||
{"id": 2, "state": "COMMENTED", "body": review_pr.REVIEW_MARKER, "user": {"login": "review-bot"}},
|
||||
{"id": 3, "state": "APPROVED", "body": "Human review", "user": {"login": "human"}},
|
||||
{"id": 6, "state": "COMMENTED", "body": "Other automation", "user": {"login": "review-bot"}},
|
||||
]
|
||||
),
|
||||
MagicMock(
|
||||
json=lambda: [
|
||||
{"id": 4, "pull_request_review_id": 1, "user": {"login": "review-bot"}},
|
||||
{"id": 5, "pull_request_review_id": 6, "user": {"login": "review-bot"}},
|
||||
]
|
||||
),
|
||||
]
|
||||
|
||||
review_pr.clear_previous_review(event)
|
||||
|
||||
event.put.assert_called_once_with(
|
||||
"https://api.github.com/repos/org/repo/pulls/7/reviews/1/dismissals",
|
||||
json={"message": "Superseded by new review"},
|
||||
hard=True,
|
||||
)
|
||||
event.delete.assert_called_once_with(
|
||||
"https://api.github.com/repos/org/repo/pulls/comments/4",
|
||||
hard=True,
|
||||
)
|
||||
|
||||
|
||||
def test_incomplete_review_evidence_cannot_approve():
|
||||
"""Test unavailable or truncated diffs produce comments rather than approvals."""
|
||||
event = MagicMock()
|
||||
event.repository = "org/repo"
|
||||
event.pr = {"number": 7}
|
||||
event.get_pr_head_sha.return_value = "abc"
|
||||
|
||||
error = review_pr.generate_pr_review("org/repo", "ERROR: UNABLE TO RETRIEVE DIFF.", "PR", "", event, "abc")
|
||||
review_pr.post_review_summary(event, error)
|
||||
assert event.post.call_args.kwargs["json"]["event"] == "COMMENT"
|
||||
|
||||
review_pr.post_review_summary(event, {"head_sha": "abc", "summary": "LGTM", "comments": [], "diff_truncated": True})
|
||||
assert event.post.call_args.kwargs["json"]["event"] == "COMMENT"
|
||||
|
||||
binary = "diff --git a/image.png b/image.png\nBinary files a/image.png and b/image.png differ"
|
||||
binary_review = review_pr.generate_pr_review("org/repo", binary, "PR", "", event, "abc")
|
||||
review_pr.post_review_summary(event, binary_review)
|
||||
assert event.post.call_args.kwargs["json"]["event"] == "COMMENT"
|
||||
|
||||
with pytest.raises(KeyError):
|
||||
review_pr.post_review_summary(event, {"summary": "stale result", "comments": []})
|
||||
|
||||
review_pr.post_review_summary(
|
||||
event, {"head_sha": "abc", "summary": "All changed files were skipped", "comments": []}
|
||||
)
|
||||
assert event.post.call_args.kwargs["json"]["event"] == "COMMENT"
|
||||
|
||||
|
||||
def test_review_agent_tools_read_pr_head_via_api(tmp_path, monkeypatch):
|
||||
@@ -223,7 +310,8 @@ def test_review_agent_tools_read_pr_head_via_api(tmp_path, monkeypatch):
|
||||
handlers["read_file"](path="flaky.py", start_line=None, end_line=None)
|
||||
|
||||
tree_response.status_code = 500
|
||||
assert handlers["list_files"](path_glob=None) == "list_files failed: HTTP 500." # failures are not cached
|
||||
with pytest.raises(RuntimeError, match="list_files failed: HTTP 500"):
|
||||
handlers["list_files"](path_glob=None)
|
||||
tree_response.status_code = 200
|
||||
assert handlers["list_files"](path_glob=None).splitlines() == ["tests/test_python.py"]
|
||||
|
||||
@@ -232,10 +320,10 @@ def test_review_agent_tools_read_pr_head_via_api(tmp_path, monkeypatch):
|
||||
def test_get_repo_guidelines_fetches_pr_head(mock_fetch):
|
||||
"""Test guidelines are fetched from the PR head via the GitHub API."""
|
||||
mock_fetch.side_effect = lambda event, sha, path: "Be nice" if path == "AGENTS.md" else None
|
||||
section = review_pr.get_repo_guidelines("gpt-5.5", event=MagicMock(), head_sha="abc")
|
||||
section = review_pr.get_repo_guidelines("gpt-5.6-sol", event=MagicMock(), head_sha="abc")
|
||||
assert "AGENTS.md" in section and "Be nice" in section
|
||||
assert "CONTRIBUTING.md" not in section
|
||||
assert review_pr.get_repo_guidelines("gpt-5.5", event=None, head_sha="abc") == ""
|
||||
assert review_pr.get_repo_guidelines("gpt-5.6-sol", event=None, head_sha="abc") == ""
|
||||
|
||||
|
||||
def test_review_agent_tools_can_list_and_read_changed_file_diffs():
|
||||
|
||||
+108
-39
@@ -2,6 +2,7 @@
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
|
||||
from actions.utils.openai_utils import (
|
||||
@@ -9,6 +10,7 @@ from actions.utils.openai_utils import (
|
||||
OPENAI_MODEL_DEFAULT,
|
||||
PR_REVIEW_MODEL_DEFAULT,
|
||||
_is_anthropic_model,
|
||||
_openai_usage_cost,
|
||||
_response_tool_calls,
|
||||
get_agent_response,
|
||||
get_response,
|
||||
@@ -19,10 +21,32 @@ from actions.utils.openai_utils import (
|
||||
|
||||
def test_default_models():
|
||||
"""Test canonical default models are priced so max_cost budgets stay enforceable."""
|
||||
assert OPENAI_MODEL_DEFAULT == "gpt-5.5"
|
||||
assert PR_REVIEW_MODEL_DEFAULT == "gpt-5.5"
|
||||
assert OPENAI_MODEL_DEFAULT == "gpt-5.6-luna"
|
||||
assert PR_REVIEW_MODEL_DEFAULT == "gpt-5.6-terra"
|
||||
assert OPENAI_MODEL_DEFAULT in MODEL_COSTS # unpriced models disable max_cost budgets
|
||||
assert PR_REVIEW_MODEL_DEFAULT in MODEL_COSTS
|
||||
assert MODEL_COSTS["gpt-5.6-sol"] == (5.00, 30.00)
|
||||
assert MODEL_COSTS["gpt-5.6-terra"] == (2.50, 15.00)
|
||||
assert MODEL_COSTS["gpt-5.6-luna"] == (1.00, 6.00)
|
||||
|
||||
|
||||
def test_gpt_56_cost_includes_cache_write_and_long_context_rates():
|
||||
"""GPT-5.6 cache writes bill at 125%, with long requests at 2x input and 1.5x output."""
|
||||
usage = {
|
||||
"input_tokens": 1000,
|
||||
"input_tokens_details": {"cached_tokens": 200, "cache_write_tokens": 300},
|
||||
"output_tokens": 100,
|
||||
}
|
||||
expected = ((1000 - 200 * 0.9 + 300 * 0.25) * 5.00 + 100 * 30.00) / 1e6
|
||||
assert _openai_usage_cost(usage, "gpt-5.6-sol") == expected
|
||||
usage["input_tokens"] = 272001
|
||||
expected = ((272001 - 200 * 0.9 + 300 * 0.25) * 5.00 * 2 + 100 * 30.00 * 1.5) / 1e6
|
||||
assert _openai_usage_cost(usage, "gpt-5.6-sol") == expected
|
||||
turns = [{"input_tokens": 150000, "output_tokens": 0}] * 2
|
||||
assert sum(_openai_usage_cost(turn, "gpt-5.6-luna") for turn in turns) == 0.3
|
||||
assert _openai_usage_cost({"input_tokens": 300000, "output_tokens": 0}, "gpt-5.6-luna") == 0.6
|
||||
old_model_expected = ((1000 - 200 * 0.9) * 5.00 + 100 * 30.00) / 1e6
|
||||
assert _openai_usage_cost({**usage, "input_tokens": 1000}, "gpt-5.5") == old_model_expected
|
||||
|
||||
|
||||
def test_is_anthropic_model():
|
||||
@@ -30,7 +54,7 @@ def test_is_anthropic_model():
|
||||
assert _is_anthropic_model("claude-sonnet-4-6") is True
|
||||
assert _is_anthropic_model("claude-haiku-4-5-20251001") is True
|
||||
assert _is_anthropic_model("claude-opus-4-7") is True
|
||||
assert _is_anthropic_model("gpt-5.5") is False
|
||||
assert _is_anthropic_model("gpt-5.6-terra") is False
|
||||
assert _is_anthropic_model("gpt-5-mini-2025-08-07") is False
|
||||
|
||||
|
||||
@@ -65,7 +89,7 @@ def test_remove_outer_codeblocks():
|
||||
def test_get_review_model_override():
|
||||
"""Test review model override logic."""
|
||||
with patch("actions.utils.openai_utils.REVIEW_MODEL", "claude-opus-4-7"):
|
||||
with patch("actions.utils.openai_utils.MODEL", "gpt-5.5"):
|
||||
with patch("actions.utils.openai_utils.MODEL", "gpt-5.6-terra"):
|
||||
assert get_review_model() == "claude-opus-4-7"
|
||||
|
||||
|
||||
@@ -184,9 +208,14 @@ def test_get_agent_response_calls_function_tools(mock_post):
|
||||
"call_id": "call_123",
|
||||
"name": "lookup_value",
|
||||
"arguments": '{"value": "abc"}',
|
||||
}
|
||||
},
|
||||
{"type": "web_search_call"},
|
||||
],
|
||||
"usage": {"input_tokens": 10, "input_tokens_details": {"cached_tokens": 4}, "output_tokens": 5},
|
||||
"usage": {
|
||||
"input_tokens": 10,
|
||||
"input_tokens_details": {"cached_tokens": 4, "cache_write_tokens": 3},
|
||||
"output_tokens": 5,
|
||||
},
|
||||
}
|
||||
second_response = MagicMock()
|
||||
second_response.status_code = 200
|
||||
@@ -199,7 +228,11 @@ def test_get_agent_response_calls_function_tools(mock_post):
|
||||
"content": [{"type": "output_text", "text": '{"comments": [], "summary": "done"}'}],
|
||||
}
|
||||
],
|
||||
"usage": {"input_tokens": 20, "input_tokens_details": {"cached_tokens": 8}, "output_tokens": 7},
|
||||
"usage": {
|
||||
"input_tokens": 20,
|
||||
"input_tokens_details": {"cached_tokens": 8, "cache_write_tokens": 6},
|
||||
"output_tokens": 7,
|
||||
},
|
||||
}
|
||||
mock_post.side_effect = [first_response, second_response]
|
||||
|
||||
@@ -238,6 +271,8 @@ def test_get_agent_response_calls_function_tools(mock_post):
|
||||
assert mock_post.call_count == 2
|
||||
first_payload = mock_post.call_args_list[0].kwargs["json"]
|
||||
assert first_payload["store"] is True
|
||||
assert first_payload["service_tier"] == "default"
|
||||
assert first_payload["reasoning"] == {"effort": "low"}
|
||||
assert "include" not in first_payload
|
||||
assert "previous_response_id" not in first_payload
|
||||
assert first_payload["input"] == [{"role": "user", "content": "review"}]
|
||||
@@ -251,10 +286,10 @@ def test_get_agent_response_calls_function_tools(mock_post):
|
||||
}
|
||||
]
|
||||
printed = "\n".join(str(c.args[0]) for c in mock_print.call_args_list if c.args)
|
||||
assert "turn 1/6, 1 tools (lookup_value)" in printed
|
||||
assert "turn 1/6, 2 tools (lookup_value, web_search)" in printed
|
||||
assert "turn 2/6, 0 tools" in printed
|
||||
assert "30→12 tokens (40% cached), $0.00046" in printed
|
||||
assert "agent total, 2 turns, 1 tools (lookup_value)" in printed
|
||||
assert "30→12 tokens (40% cached), $0.01" in printed
|
||||
assert "agent total, 2 turns, 2 tools (lookup_value, web_search)" in printed
|
||||
assert "Agent tool turn" not in printed # tool names live in the per-turn usage line now
|
||||
|
||||
|
||||
@@ -375,7 +410,7 @@ def test_get_response_anthropic(mock_post):
|
||||
|
||||
@patch("requests.post")
|
||||
def test_get_agent_response_stops_at_cost_budget(mock_post):
|
||||
"""Test the cost budget skips remaining tool turns and forces final synthesis with stub tool outputs."""
|
||||
"""Test the cost budget aborts rather than synthesizing from incomplete tool evidence."""
|
||||
tool_response = MagicMock()
|
||||
tool_response.status_code = 200
|
||||
tool_response.elapsed.total_seconds.return_value = 1.0
|
||||
@@ -389,22 +424,9 @@ def test_get_agent_response_stops_at_cost_budget(mock_post):
|
||||
"arguments": '{"value": "abc"}',
|
||||
}
|
||||
],
|
||||
"usage": {"input_tokens": 1_000_000, "output_tokens": 0}, # $5.00 for gpt-5.5, over any small budget
|
||||
"usage": {"input_tokens": 1_000_000, "output_tokens": 0}, # $5.00 for gpt-5.6-sol, over any small budget
|
||||
}
|
||||
final_response = MagicMock()
|
||||
final_response.status_code = 200
|
||||
final_response.elapsed.total_seconds.return_value = 1.0
|
||||
final_response.json.return_value = {
|
||||
"id": "resp_final",
|
||||
"output": [
|
||||
{
|
||||
"type": "message",
|
||||
"content": [{"type": "output_text", "text": '{"comments": [], "summary": "budget"}'}],
|
||||
}
|
||||
],
|
||||
"usage": {"input_tokens": 20, "output_tokens": 7},
|
||||
}
|
||||
mock_post.side_effect = [tool_response, final_response]
|
||||
mock_post.return_value = tool_response
|
||||
|
||||
schema = {
|
||||
"type": "object",
|
||||
@@ -430,26 +452,73 @@ def test_get_agent_response_stops_at_cost_budget(mock_post):
|
||||
def forbidden_handler(value):
|
||||
raise AssertionError("tool handlers must not run once the cost budget is reached")
|
||||
|
||||
with patch("actions.utils.openai_utils.OPENAI_API_KEY", "test-key"):
|
||||
result = get_agent_response(
|
||||
with patch("actions.utils.openai_utils.OPENAI_API_KEY", "test-key"), pytest.raises(
|
||||
RuntimeError, match=r"cost budget.*reached"
|
||||
):
|
||||
get_agent_response(
|
||||
[{"role": "user", "content": "review"}],
|
||||
tools=tools,
|
||||
tool_handlers={"lookup_value": forbidden_handler},
|
||||
text_format={"format": {"type": "json_schema", "name": "review", "strict": True, "schema": schema}},
|
||||
model="gpt-5.5",
|
||||
model="gpt-5.6-sol",
|
||||
max_turns=8,
|
||||
max_cost=1.00,
|
||||
retries=0,
|
||||
)
|
||||
|
||||
assert result == {"comments": [], "summary": "budget"}
|
||||
assert mock_post.call_count == 2 # budget reached on turn 1, remaining turns skipped
|
||||
final_payload = mock_post.call_args_list[1].kwargs["json"]
|
||||
assert final_payload["tool_choice"] == "none"
|
||||
assert final_payload["previous_response_id"] == "resp_tool"
|
||||
assert final_payload["input"][0] == {
|
||||
"type": "function_call_output",
|
||||
"call_id": "call_123",
|
||||
"output": "Tool budget exhausted.",
|
||||
mock_post.assert_called_once()
|
||||
|
||||
|
||||
@patch("requests.post")
|
||||
def test_get_agent_response_rejects_incomplete_response(mock_post):
|
||||
"""Test terminal incomplete responses cannot become review evidence."""
|
||||
response = MagicMock(status_code=200)
|
||||
response.elapsed.total_seconds.return_value = 1.0
|
||||
response.json.return_value = {"id": "resp_incomplete", "status": "incomplete", "incomplete_details": "limit"}
|
||||
mock_post.return_value = response
|
||||
|
||||
with patch("actions.utils.openai_utils.OPENAI_API_KEY", "test-key"), pytest.raises(
|
||||
RuntimeError, match="ended with limit"
|
||||
):
|
||||
get_agent_response([{"role": "user", "content": "review"}], tools=[], tool_handlers={}, retries=0)
|
||||
|
||||
|
||||
@patch("requests.post")
|
||||
def test_get_response_rejects_incomplete_response(mock_post):
|
||||
"""Test synchronous Responses completions reject terminal incomplete output."""
|
||||
response = MagicMock(status_code=200)
|
||||
response.elapsed.total_seconds.return_value = 1.0
|
||||
response.json.return_value = {"id": "resp_incomplete", "status": "incomplete", "incomplete_details": "limit"}
|
||||
mock_post.return_value = response
|
||||
|
||||
with patch("actions.utils.openai_utils.OPENAI_API_KEY", "test-key"), pytest.raises(
|
||||
RuntimeError, match="ended with limit"
|
||||
):
|
||||
get_response([{"role": "user", "content": "summary"}], check_links=False, retries=0)
|
||||
|
||||
|
||||
@patch("requests.post")
|
||||
def test_get_agent_response_rejects_failed_tool(mock_post):
|
||||
"""Test failed evidence tools abort the agent run."""
|
||||
response = MagicMock(status_code=200)
|
||||
response.elapsed.total_seconds.return_value = 1.0
|
||||
response.json.return_value = {
|
||||
"id": "resp_tool",
|
||||
"status": "completed",
|
||||
"output": [{"type": "function_call", "call_id": "call_123", "name": "read_file", "arguments": "{}"}],
|
||||
"usage": {"input_tokens": 10, "output_tokens": 5},
|
||||
}
|
||||
assert "Synthesize the gathered tool results" in final_payload["input"][-1]["content"]
|
||||
mock_post.return_value = response
|
||||
|
||||
def failed_read():
|
||||
raise RuntimeError("read failed")
|
||||
|
||||
with patch("actions.utils.openai_utils.OPENAI_API_KEY", "test-key"), pytest.raises(
|
||||
RuntimeError, match="read failed"
|
||||
):
|
||||
get_agent_response(
|
||||
[{"role": "user", "content": "review"}],
|
||||
tools=[],
|
||||
tool_handlers={"read_file": failed_read},
|
||||
retries=0,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user