open-swe/agent/middleware/model_fallback.py
Adam Moussa a4ed19ba61
Some checks failed
CI / Lint (push) Waiting to run
CI / Format check (push) Waiting to run
CI / Unit tests (push) Waiting to run
CI / Playwright E2E (push) Waiting to run
Build & publish app artifacts / Publish + deploy (dev) (push) Has been cancelled
Build & publish app artifacts / Publish + deploy (prod) (push) Has been cancelled
Infra CD / Infra CI (pre-deploy) (push) Has been cancelled
Infra CD / Deploy open-swe-dev (push) Has been cancelled
Infra CD / Deploy open-swe-prod (push) Has been cancelled
feat: migrate model providers to Bedrock (Claude) + Fireworks (everything else) (#62)
* feat: switch model providers to AWS Bedrock (Claude) and Fireworks (non-Claude)

Migrate off direct provider APIs: AWS Bedrock for Anthropic/Claude via the
cross-region inference profile us.anthropic.claude-opus-4-8, Fireworks AI for
all non-Claude models. Drop OpenAI (gpt-5.5) and Google (gemini-3.5-flash)
entirely. DEFAULT_MODEL_ID is now Bedrock Claude; all Fireworks models stay
freely selectable for the agent and reviewer graphs and via team/profile
defaults.

- pyproject: add langchain-aws (ChatBedrockConverse + boto3)
- options.py: Bedrock Claude entry + default; remove openai/google entries
- model.py: bedrock_converse provider_model_kwargs (effort -> thinking budget),
  region pin in make_model, bedrock<->fireworks fallback pairing, AWS_REGION/
  FIREWORKS_API_KEY local-dev validation
- server.py: provider-aware fallback kwargs build
- sanitize_thinking_blocks: also sanitize ChatBedrockConverse thinking blocks
- model_fallback: treat transient botocore ClientError codes as fallback-worthy
- eval_jobs: repoint hardcoded eval model id to Bedrock Claude
- tests: repoint dropped model ids; drop obsolete google test module

* fix(bedrock): use adaptive thinking + output_config.effort for Opus 4.8

The handoff spec wired Bedrock Converse thinking as
{type: enabled, budget_tokens: N}, but Opus 4.7+ rejects that with a
ValidationException: thinking.type "enabled" is not supported; it requires
thinking.type "adaptive" plus output_config.effort. Verified by live invoke
against us.anthropic.claude-opus-4-8 (account 328440206208, us-east-1):
the enabled+budget shape 400s, adaptive+effort returns normally.

Map profile effort to additional_model_request_fields:
  {thinking: {type: adaptive, display: summarized},
   output_config: {effort: <low|medium|high|xhigh|max>}}
reusing anthropic_thinking_for/anthropic_effort_for. Update the two
subagent-model tests asserting the old shape.

* fix(deploy): seed Bedrock/Fireworks models, not the dropped anthropic:/openai: ids

Model selection is store-driven, so seed_store.sh's team_settings/default seed is
what runs in prod. It still seeded the removed providers, which would fail at runtime
after the migration:
- agent/builder: anthropic:claude-opus-4-8 -> bedrock_converse:us.anthropic.claude-opus-4-8
- reviewer: openai:gpt-5.5 (dropped) -> bedrock_converse:us.anthropic.claude-opus-4-8
  (set SEED_REVIEWER_MODEL to a Fireworks model for a cross-family reviewer)
- fetch-config REQUIRED_PROVIDER_KEYS default ANTHROPIC_API_KEY,OPENAI_API_KEY ->
  FIREWORKS_API_KEY (Bedrock auths via host IAM role; dropping the old keys would
  otherwise fail-fast at boot)
- docs (DEPLOYMENT/ROTATION/put-config) updated to match.

Surfaced by the cross-family review + verified against deploy/.

* fix(bedrock): security-review NITs — region resolution, error sanitization, reasoning-block strip

From /sh-security-review (all confirmed-low):
- model.py: resolve region from AWS_REGION OR AWS_DEFAULT_REGION (matches
  validate_local_dev_llm_config) so the validated region is the one actually used.
- model_fallback.py: sanitize Bedrock AccessDenied/ResourceNotFound errors to the
  error code only, so the role ARN + account id in the raw botocore message never
  reach logs or the user channel (CWE-209).
- sanitize_thinking_blocks.py: also strip empty Bedrock reasoning_content blocks
  (Converse emits reasoning_content, not thinking) so the middleware is not a no-op
  on Bedrock; + unit tests. (Empty blocks replay fine today; defensive.)

* deploy(bedrock): grant instance-role Bedrock invoke + repoint LLM_MODEL_ID / eval model ids

Deployment-readiness for the Bedrock migration (PR #62):
- instance-role.ts: least-privilege bedrock:InvokeModel[WithResponseStream] on the
  us.anthropic.claude-opus-4-8 inference-profile ARN + the foundation-model ARN in
  each routed region (us-east-1/2, us-west-2). The model runs in the server process
  on the box, so the EC2 instance role is the principal. Simulator-verified (allowed
  for opus-4-8, implicitDeny for other models) and synth-verified. Passed the
  mandatory GPT-4.1 IAM cross-review (no blockers, least-privilege confirmed).
- config-store.ts: IaC SSM LLM_MODEL_ID anthropic:claude-opus-4-8 ->
  bedrock_converse:us.anthropic.claude-opus-4-8. This SSM value overrides
  seed_store.sh's default via pick precedence, so the seed-script fix alone was
  insufficient — both sources now point at the supported Bedrock id.
- infra/README.md + evals/reviewer/config.toml: repoint stale anthropic:/google_genai:
  ids to the Bedrock id (config.toml's model_id was an active, now-broken value).

AWS_REGION is already wired via user-data.sh (IMDS -> boot.env), so no change needed there.

* chore(secrets): drop OPENAI/GOOGLE/GROQ key shells (revoked, providers removed)

Those three providers were dropped in the Bedrock/Fireworks migration and their keys
revoked; the live Secrets Manager objects (open-swe-{dev,prod}/{OPENAI,GOOGLE,GROQ}_API_KEY)
were deleted (7-day recovery). Remove them from the IaC so a future cdk deploy does not
recreate the shells, and from fetch-config's mirror array so boot stops requesting them:
- config-store.ts SECRET_VARS + descriptions (28 -> 25 shells)
- fetch-config.sh SECRET_VARS array (kept in lockstep)
- put-config.sh: drop the put_secret lines; ANTHROPIC_API_KEY re-labelled optional
  (eval judge only — Bedrock builder/reviewer auth via the host IAM role).

REQUIRED_PROVIDER_KEYS is not set in SSM, so it uses the FIREWORKS_API_KEY default.
2026-06-29 15:57:19 -04:00

168 lines
6.6 KiB
Python

"""Middleware that falls back to a secondary model when the primary fails transiently.
Wraps the model call. When the primary model raises a transient provider error
(5xx, 429, connection/timeout), the same request is retried once against the
configured fallback model. The fallback is bound to tools by the agent factory
on the second call, so swapping ``request.model`` is sufficient.
Bidirectional: if the primary is Anthropic the fallback is typically OpenAI,
and vice versa. The middleware itself is provider-agnostic — it inspects the
exception type/status code to decide whether to fall over.
"""
from __future__ import annotations
import logging
from collections.abc import Awaitable, Callable
from typing import Any
import anthropic
import openai
from botocore.exceptions import ClientError
from langchain.agents.middleware import AgentMiddleware
from langchain.agents.middleware.types import ModelCallResult, ModelRequest, ModelResponse
from langchain_core.language_models import BaseChatModel
from langchain_core.messages import AIMessage
logger = logging.getLogger(__name__)
_RETRYABLE_STATUS_CODES = {408, 409, 425, 429, 500, 502, 503, 504, 529}
_TRANSIENT_EXCEPTIONS: tuple[type[BaseException], ...] = (
anthropic.APIConnectionError,
anthropic.APITimeoutError,
anthropic.RateLimitError,
anthropic.InternalServerError,
openai.APIConnectionError,
openai.APITimeoutError,
openai.RateLimitError,
openai.InternalServerError,
)
_RETRYABLE_BEDROCK_ERROR_CODES = {
"ThrottlingException",
"ServiceUnavailableException",
"ModelTimeoutException",
"InternalServerException",
}
def _should_fallback(exc: BaseException) -> bool:
if isinstance(exc, _TRANSIENT_EXCEPTIONS):
return True
# Catches OverloadedError (529) and other 5xx/429 surfaced as APIStatusError.
if isinstance(exc, (anthropic.APIStatusError, openai.APIStatusError)):
status = getattr(exc, "status_code", None)
if isinstance(status, int) and status in _RETRYABLE_STATUS_CODES:
return True
# Bedrock (Claude) raises botocore ClientError for transient throttling/5xx.
if isinstance(exc, ClientError):
code = exc.response.get("Error", {}).get("Code", "")
if code in _RETRYABLE_BEDROCK_ERROR_CODES:
return True
return False
def _error_body(exc: BaseException) -> dict[str, Any]:
body = getattr(exc, "body", None)
return body if isinstance(body, dict) else {}
def _nested_str(data: dict[str, Any], *keys: str) -> str | None:
current: Any = data
for key in keys:
if not isinstance(current, dict):
return None
current = current.get(key)
return current if isinstance(current, str) and current else None
def _provider_access_error_message(exc: BaseException) -> str | None:
if isinstance(exc, anthropic.BadRequestError):
body = _error_body(exc)
error_code = _nested_str(body, "error", "details", "error_code")
if error_code == "model_not_available":
provider_message = _nested_str(body, "error", "message") or str(exc)
return (
"The selected Anthropic model is not available to this workspace. "
f"Anthropic returned: {provider_message} "
"Choose a different model or update the workspace's Anthropic access and retry."
)
if isinstance(exc, (openai.BadRequestError, openai.NotFoundError)):
body = _error_body(exc)
error_code = _nested_str(body, "error", "code")
if error_code in {"model_not_found", "model_not_available"}:
provider_message = _nested_str(body, "error", "message") or str(exc)
return (
"The selected OpenAI model is not available to this workspace. "
f"OpenAI returned: {provider_message} "
"Choose a different model or update the workspace's OpenAI access and retry."
)
# Bedrock access/lookup failures embed the caller's role ARN and account id in the
# raw botocore message; surface only the error code so identifiers never reach logs
# or the user-facing channel.
if isinstance(exc, ClientError):
code = exc.response.get("Error", {}).get("Code", "")
if code in {"AccessDeniedException", "ResourceNotFoundException"}:
return (
"The selected Bedrock model is not available to this deployment "
f"(Bedrock error: {code}). Verify the model's inference-profile access and "
"IAM permissions, choose a different model, and retry."
)
return None
class ModelFallbackMiddleware(AgentMiddleware):
"""Retry the model call against a fallback provider on transient errors."""
def __init__(self, fallback_model: BaseChatModel) -> None:
super().__init__()
self._fallback_model = fallback_model
def wrap_model_call(
self,
request: ModelRequest,
handler: Callable[[ModelRequest], ModelResponse],
) -> ModelCallResult:
try:
return handler(request)
except Exception as exc:
access_error_message = _provider_access_error_message(exc)
if access_error_message is not None:
logger.warning("Model access error surfaced to user: %s", type(exc).__name__)
return AIMessage(content=access_error_message)
if not _should_fallback(exc):
raise
logger.warning(
"Primary model failed (%s); falling back to %s",
type(exc).__name__,
getattr(self._fallback_model, "model_name", None)
or getattr(self._fallback_model, "model", "fallback"),
)
return handler(request.override(model=self._fallback_model))
async def awrap_model_call(
self,
request: ModelRequest,
handler: Callable[[ModelRequest], Awaitable[ModelResponse]],
) -> Any:
try:
return await handler(request)
except Exception as exc:
access_error_message = _provider_access_error_message(exc)
if access_error_message is not None:
logger.warning("Model access error surfaced to user: %s", type(exc).__name__)
return AIMessage(content=access_error_message)
if not _should_fallback(exc):
raise
logger.warning(
"Primary model failed (%s); falling back to %s",
type(exc).__name__,
getattr(self._fallback_model, "model_name", None)
or getattr(self._fallback_model, "model", "fallback"),
)
return await handler(request.override(model=self._fallback_model))