Setting the file. One moment.
Preflight Bedrock · LLM To Bedrock · aws/agent-toolkit-for-aws · Skills Docs
ContentsBack to the top of the page 81
Creating Amazon Aurora Db Cluster With Instances
104
Routing Traffic With Route53 And CloudFront
Resilience Program Design
Creating API Gateway Stage
Number 30.15
Position 15 of 35
Type Python
Size 15 KB
Lines 295 scripts/ preflight_bedrock.py
Python · 295 lines · 15 KB
14
The 1-token probe costs a fraction of a cent (noted in output).
15 """
16 import argparse, json, sys
17
18
19 def classify_invoke_error (code: str , message: str ) -> dict :
20 """Pure: map a botocore error code to a structured preflight verdict."""
21 if code in ( "AccessDeniedException" ,):
22 # Bedrock raises AccessDeniedException for two distinct problems:
23 # (a) model access not enabled in the Bedrock console (message mentions
24 # model access / "use the model"), fixed in the console, not IAM;
25 # (b) the IAM principal lacks bedrock:InvokeModel, fixed in IAM.
26 # Sending the user to the wrong fix wastes their time — disambiguate
27 # on the message text.
28 lowered = message.lower()
29 if ( "model access" in lowered or "access to the model" in lowered
30 or "use the model" in lowered or "model is not" in lowered):
31 return { "ok" : False , "reason" : "model_access" ,
32 "detail" : f "Bedrock model access not enabled for this model — { message } . "
33 f "Enable it in the Bedrock console (Model access page); "
34 f "this is separate from IAM." }
35 return { "ok" : False , "reason" : "authz" ,
36 "detail" : f "IAM denies bedrock:InvokeModel — { message } . "
37 f "Grant bedrock:InvokeModel to this principal." }
38 if code in ( "ValidationException" , "ResourceNotFoundException" ):
39 return { "ok" : False , "reason" : "model_unavailable" ,
40 "detail" : f "Model not available in this region — { message } . "
41 f "Try a cross-region inference profile (e.g. us.<model-id>)." }
42 if code in ( "ThrottlingException" , "ServiceQuotaExceededException" ):
43 # We got far enough to be throttled => we are authorized.
44 return { "ok" : True , "reason" : "throttled_ok" ,
45 "detail" : "Authorized (probe throttled, which still proves access)." }
46 if code in ( "UnrecognizedClientException" , "InvalidSignatureException" ,
47 "ExpiredTokenException" , "ExpiredToken" ):
48 return { "ok" : False , "reason" : "credentials" ,
49 "detail" : f "AWS credentials invalid or expired — { message } . "
50 f "Run 'aws configure', refresh your SSO session, or set AWS_PROFILE." }
51 return { "ok" : False , "reason" : "unknown" , "detail" : f " { code } : { message } " }
52
53
54 def is_embedding_model (model_id: str ) -> bool :
55 """Pure: embedding models don't speak the Converse API."""
56 return "embed" in model_id.lower()
57
58
59 def is_mantle_model (model_id: str ) -> bool :
60 """Pure: ids that must be probed via the mantle Responses API rather than
61 Converse/InvokeModel. Matches the BARE proprietary GPT ids (`openai.gpt-5*`),
62 which are mantle-served. Deliberately does NOT match:
63 - gpt-oss ids (Converse-capable on bedrock-runtime), and
64 - GPT-5.6 CRIS profile ids (`us.`/`in.`/`global.` prefixed) — those are
65 bedrock-runtime targets where Converse IS supported, so falling through to
66 the standard Converse probe is the correct behavior, not an accident."""
67 mid = model_id.lower()
68 return mid.startswith( "openai.gpt-5" ) and "oss" not in mid
69
70
71 def classify_mantle_error (status: int | None , message: str ) -> dict :
72 """Pure: map an OpenAI-SDK HTTP status from the mantle endpoint to a verdict.
73
74 Mirrors classify_invoke_error's contract but for the mantle surface, where the
75 remedies differ: IAM needs bedrock-mantle:* actions (not bedrock:InvokeModel),
76 and mantle itself has no cross-region form — though for GPT-5.6 a
77 bedrock-runtime CRIS id can cover the region instead."""
78 if status in ( 401 , 403 ):
79 lowered = message.lower()
80 if ( "model access" in lowered or "access to the model" in lowered
81 or "use the model" in lowered or "not authorized to use" in lowered):
82 return { "ok" : False , "reason" : "model_access" ,
83 "detail" : f "Bedrock model access not enabled for this model — { message } . "
84 f "Enable it in the Bedrock console (Model access page); "
85 f "this is separate from IAM." }
86 return { "ok" : False , "reason" : "authz" ,
87 "detail" : f "IAM denies mantle inference — { message } . Grant bedrock-mantle "
88 f "actions (e.g. the AmazonBedrockMantleInferenceAccess managed "
89 f "policy); bedrock:InvokeModel does NOT authorize these models." }
90 if status == 404 :
91 return { "ok" : False , "reason" : "model_unavailable" ,
92 "detail" : f "Model not available at this mantle endpoint/region — { message } . "
93 f "Mantle is in-region only; for GPT-5.6 try a bedrock-runtime CRIS "
94 f "id (us./in./global. prefixed) instead, for GPT-5.5/5.4 switch to "
95 f "a supported region or a different model." }
96 if status == 429 :
97 # Reaching a token-per-minute ceiling still proves we are authorized.
98 return { "ok" : True , "reason" : "throttled_ok" ,
99 "detail" : "Authorized (probe throttled on a TPM quota, which still proves access)." }
100 return { "ok" : False , "reason" : "unknown" , "detail" : f " { status } : { message } " }
101
102
103 def probe_mantle_model (model_id: str , region: str ) -> dict :
104 """Real minimal probe against the mantle Responses API.
105
106 Fails CLOSED when the OpenAI SDK is missing. This deliberately does NOT follow
107 the `embedding_unprobed` precedent: that one passes because an unrecognized
108 embedding family is a rare edge case, whereas a missing SDK here would be the
109 normal path for every run if the dependency were absent — returning ok=True
110 would turn a fail-fast preflight into an unconditional green light and let the
111 migration proceed with endpoint, model, and IAM access never checked. Both
112 packages are declared in pyproject.toml, so reaching this branch means the
113 pinned environment is broken and the operator needs to know."""
114 try :
115 from aws_bedrock_token_generator import provide_token
116 from openai import BedrockOpenAI
117 except ImportError as e:
118 return { "ok" : False , "reason" : "mantle_deps_missing" ,
119 "detail" : f "Cannot probe mantle-only model { model_id } : { e } . This needs "
120 f "'openai>=2.45.0' and 'aws-bedrock-token-generator', both declared "
121 f "in scripts/pyproject.toml — re-sync the pinned environment "
122 f "(`uv sync --project <scripts dir>`). Access was NOT verified; "
123 f "preflight fails closed rather than assuming it works." }
124 try :
125 client = BedrockOpenAI(
126 aws_region = region,
127 bedrock_token_provider =lambda : provide_token( region = region),
128 max_retries = 0 ,
129 )
130 client.responses.create( model = model_id, input = "ping" , max_output_tokens = 16 , store = False )
131 return { "ok" : True , "reason" : "ok" , "detail" : "Mantle Responses API authorized." }
132 except Exception as e: # noqa: BLE001 - SDK raises many types; status is what matters
133 status = getattr (e, "status_code" , None )
134 if status is None :
135 resp = getattr (e, "response" , None )
136 status = getattr (resp, "status_code" , None )
137 if status is None :
138 return { "ok" : False , "reason" : "credentials" ,
139 "detail" : f " { type (e). __name__ } : { e } . Check AWS credentials, the region "
140 f "name, and network access to the bedrock-mantle endpoint." }
141 return classify_mantle_error( int (status), str (e))
142
143
144 def _embed_request_body (model_id: str ) -> dict | None :
145 """Pure: minimal valid request body per embedding-model family; None if unknown."""
146 parts = model_id.split( "." )
147 vendor = parts[ 1 ] if parts[ 0 ] in ( "us" , "eu" , "apac" , "global" ) and len (parts) > 1 else parts[ 0 ]
148 if vendor == "amazon" :
149 return { "inputText" : "ping" }
150 if vendor == "cohere" :
151 return { "texts" : [ "ping" ], "input_type" : "search_document" }
152 return None
153
154
155 def probe_model (client, model_id: str ) -> dict :
156 """Real minimal probe: Converse for chat models, InvokeModel for embeddings."""
157 from botocore.exceptions import BotoCoreError, ClientError
158 try :
159 if is_embedding_model(model_id):
160 body = _embed_request_body(model_id)
161 if body is None :
162 # Unknown embedding family — probing with a wrong body would
163 # produce a ValidationException indistinguishable from a real
164 # availability problem. Pass with an explicit caveat instead.
165 return { "ok" : True , "reason" : "embedding_unprobed" ,
166 "detail" : "Embedding model from an unrecognized family — access not "
167 "verified by preflight; confirm in the Bedrock console." }
168 client.invoke_model( modelId = model_id, body = json.dumps(body),
169 contentType = "application/json" , accept = "application/json" )
170 return { "ok" : True , "reason" : "ok" , "detail" : "InvokeModel (embedding) authorized." }
171 client.converse(
172 modelId = model_id,
173 messages = [{ "role" : "user" , "content" : [{ "text" : "ping" }]}],
174 inferenceConfig = { "maxTokens" : 1 },
175 )
176 return { "ok" : True , "reason" : "ok" , "detail" : "InvokeModel authorized." }
177 except ClientError as e:
178 code = e.response.get( "Error" , {}).get( "Code" , "Unknown" )
179 msg = e.response.get( "Error" , {}).get( "Message" , str (e))
180 return classify_invoke_error(code, msg)
181 except BotoCoreError as e:
182 # NoCredentialsError, EndpointConnectionError, SSO token errors, etc.
183 # These are config problems on the caller's machine, not Bedrock verdicts.
184 return { "ok" : False , "reason" : "credentials" ,
185 "detail" : f " { type (e). __name__ } : { e } . "
186 f "Run 'aws configure', refresh your SSO session, or check the region name." }
187
188
189 def aggregate_failure (results: list ) -> dict :
190 """Pure: lift the first failing model's reason/detail to the top level so the
191 orchestrator can branch on a single top-level `reason` (its documented contract)."""
192 failing = [r for r in results if not r[ "ok" ]]
193 if not failing:
194 return {}
195 return { "reason" : failing[ 0 ][ "reason" ], "detail" : failing[ 0 ][ "detail" ],
196 "failing_models" : [r[ "model_id" ] for r in failing]}
197
198
199 def quota_rpm (quotas: list , model_id: str ) -> int | None :
200 """Best-effort on-demand requests-per-minute quota for this model from a
201 pre-fetched quota list; None if no match. Quota names follow the form
202 'On-demand model inference requests per minute for <Model Display Name>',
203 so we match per-minute inference quotas whose name shares a token with the
204 model id (e.g. 'claude', 'nova', 'titan')."""
205 tokens = [t for t in model_id.lower().replace( ":" , "." ).split( "." ) if t]
206 name_tokens = set ()
207 for t in tokens:
208 name_tokens.update(p for p in t.split( "-" ) if p and not p.isdigit())
209 lowest = None
210 for q in quotas:
211 name = q.get( "QuotaName" , "" ).lower()
212 if "per minute" not in name or "request" not in name:
213 continue
214 if not any (tok in name for tok in name_tokens):
215 continue
216 v = int (q.get( "Value" , 0 ))
217 lowest = v if lowest is None else min (lowest, v)
218 return lowest
219
220
221 def fetch_bedrock_quotas (region: str ) -> list :
222 """Fetch all Bedrock service quotas once; empty list on any failure."""
223 import boto3
224 from botocore.exceptions import BotoCoreError, ClientError
225 try :
226 sq = boto3.client( "service-quotas" , region_name = region)
227 quotas = []
228 for page in sq.get_paginator( "list_service_quotas" ).paginate( ServiceCode = "bedrock" ):
229 quotas.extend(page.get( "Quotas" , []))
230 return quotas
231 except (BotoCoreError, ClientError):
232 return []
233
234
235 def main (argv = None ) -> int :
236 ap = argparse.ArgumentParser()
237 ap.add_argument( "--region" , required = True )
238 ap.add_argument( "--models" , required = True , help = "comma-separated model ids" )
239 ap.add_argument( "--dataset-size" , type = int , default = 0 )
240 args = ap.parse_args(argv)
241
242 model_ids = [m.strip() for m in args.models.split( "," ) if m.strip()]
243 if not model_ids:
244 print (json.dumps({ "ok" : False , "region" : args.region, "models" : [],
245 "reason" : "no_models" ,
246 "detail" : "--models resolved to an empty list; nothing to probe." },
247 indent = 2 ))
248 return 1
249
250 import boto3
251 from botocore.exceptions import BotoCoreError
252 try :
253 client = boto3.client( "bedrock-runtime" , region_name = args.region)
254 except BotoCoreError as e:
255 print (json.dumps({ "ok" : False , "region" : args.region, "models" : [],
256 "reason" : "credentials" ,
257 "detail" : f " { type (e). __name__ } : { e } " }, indent = 2 ))
258 return 1
259
260 quotas = fetch_bedrock_quotas(args.region)
261 results = []
262 all_ok = True
263 for model_id in model_ids:
264 if is_mantle_model(model_id):
265 verdict = probe_mantle_model(model_id, args.region)
266 verdict[ "model_id" ] = model_id
267 # Mantle enforces per-model input/output TPM quotas and has no RPM
268 # quota, so an RPM-derived pacing warning would be meaningless here.
269 verdict[ "rpm_quota" ] = None
270 verdict[ "quota_note" ] = (
271 "Mantle quotas are per-model input/output tokens per minute; there is no RPM "
272 "quota. Pace on token throughput, and note that prompt-cached input tokens are "
273 "exempt from the input-TPM quota." )
274 else :
275 verdict = probe_model(client, model_id)
276 rpm = quota_rpm(quotas, model_id)
277 verdict[ "model_id" ] = model_id
278 verdict[ "rpm_quota" ] = rpm
279 if rpm is not None and args.dataset_size > rpm:
280 verdict[ "quota_warning" ] = (
281 f "Dataset ( { args.dataset_size } ) exceeds ~ { rpm } RPM quota — "
282 f "Eval will pace with backoff and may be slow." )
283 all_ok = all_ok and verdict[ "ok" ]
284 results.append(verdict)
285
286 out = { "ok" : all_ok, "region" : args.region,
287 "probe_cost_note" : "1-token InvokeModel probe per model (~$0.00001 each)" ,
288 "models" : results}
289 out.update(aggregate_failure(results))
290 print (json.dumps(out, indent = 2 ))
291 return 0 if all_ok else 1
292
293
294 if __name__ == "__main__" :
295 sys.exit(main())