Adopt GPT-5.6 models for Actions (#805)

Co-authored-by: UltralyticsAssistant <[email protected]>
This commit is contained in:
Glenn Jocher
2026-07-24 12:45:45 +02:00
co-authored by UltralyticsAssistant
parent 595b58d2c3
commit 7432aeeac2
11 changed files with 380 additions and 173 deletions
+2 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -28,4 +28,4 @@
# ├── test_summarize_pr.py
# └── ...
__version__ = "0.2.24"
__version__ = "0.2.25"
+7 -3
View File
@@ -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
View File
@@ -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")
+22 -2
View File
@@ -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()
+64 -35
View File
@@ -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
View File
@@ -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
+97 -9
View File
@@ -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
View File
@@ -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,
)