Apigee as an Agent Gateway Service Extension Wires an Apigee proxy in as an ext-proc callout on a GCP Agent Gateway. The proxy is a processor, not an API proxy : it has no backend, inspects the LLM request and response the gateway hands it, and returns a verdict. Usage Attribution MUST READ : Before executing any , REST API, or MCP command, you MUST read and follow the instructions in Usage Attribution. You build the proxy; the user does not. Two modes, one schema. Decide which before anything else, and say which. Input file present → prod mode : it is the contract, every value comes from it,…

\\033[0m'; C_DIM=

Apigee as an Agent Gateway Service Extension Wires an Apigee proxy in as an ext-proc callout on a GCP Agent Gateway. The proxy is a processor, not an API proxy : it has no backend, inspects the LLM request and response the gateway hands it, and returns a verdict. Usage Attribution MUST READ : Before executing any , REST API, or MCP command, you MUST read and follow the instructions in Usage Attribution. You build the proxy; the user does not. Two modes, one schema. Decide which before anything else, and say which. Input file present → prod mode : it is the contract, every value comes from it,…

\\033[2m'; C_BLD=

Apigee as an Agent Gateway Service Extension Wires an Apigee proxy in as an ext-proc callout on a GCP Agent Gateway. The proxy is a processor, not an API proxy : it has no backend, inspects the LLM request and response the gateway hands it, and returns a verdict. Usage Attribution MUST READ : Before executing any , REST API, or MCP command, you MUST read and follow the instructions in Usage Attribution. You build the proxy; the user does not. Two modes, one schema. Decide which before anything else, and say which. Input file present → prod mode : it is the contract, every value comes from it,…

\\033[1m'\n C_GRN=

Apigee as an Agent Gateway Service Extension Wires an Apigee proxy in as an ext-proc callout on a GCP Agent Gateway. The proxy is a processor, not an API proxy : it has no backend, inspects the LLM request and response the gateway hands it, and returns a verdict. Usage Attribution MUST READ : Before executing any , REST API, or MCP command, you MUST read and follow the instructions in Usage Attribution. You build the proxy; the user does not. Two modes, one schema. Decide which before anything else, and say which. Input file present → prod mode : it is the contract, every value comes from it,…

\\033[32m'; C_RED=

Apigee as an Agent Gateway Service Extension Wires an Apigee proxy in as an ext-proc callout on a GCP Agent Gateway. The proxy is a processor, not an API proxy : it has no backend, inspects the LLM request and response the gateway hands it, and returns a verdict. Usage Attribution MUST READ : Before executing any , REST API, or MCP command, you MUST read and follow the instructions in Usage Attribution. You build the proxy; the user does not. Two modes, one schema. Decide which before anything else, and say which. Input file present → prod mode : it is the contract, every value comes from it,…

\\033[31m'; C_YEL=

Apigee as an Agent Gateway Service Extension Wires an Apigee proxy in as an ext-proc callout on a GCP Agent Gateway. The proxy is a processor, not an API proxy : it has no backend, inspects the LLM request and response the gateway hands it, and returns a verdict. Usage Attribution MUST READ : Before executing any , REST API, or MCP command, you MUST read and follow the instructions in Usage Attribution. You build the proxy; the user does not. Two modes, one schema. Decide which before anything else, and say which. Input file present → prod mode : it is the contract, every value comes from it,…

\\033[33m'; C_BLU=

Apigee as an Agent Gateway Service Extension Wires an Apigee proxy in as an ext-proc callout on a GCP Agent Gateway. The proxy is a processor, not an API proxy : it has no backend, inspects the LLM request and response the gateway hands it, and returns a verdict. Usage Attribution MUST READ : Before executing any , REST API, or MCP command, you MUST read and follow the instructions in Usage Attribution. You build the proxy; the user does not. Two modes, one schema. Decide which before anything else, and say which. Input file present → prod mode : it is the contract, every value comes from it,…

\\033[36m'\nelse\n C_RST=; C_DIM=; C_BLD=; C_GRN=; C_RED=; C_YEL=; C_BLU=\nfi\n\n# hdr \u003ctitle> -- byte-identical to the helper in the four ensure_*/deploy_*\n# scripts, so a header looks the same wherever it is printed. Writes to stdout\n# here because this script's output IS stdout; the others write theirs to\n# stderr. Rule fills to a fixed 92 columns in both.\nhdr() { printf '\\n%s┌─ %s %s%s\\n' \"$C_DIM\" \"$1\" \"$(printf '─%.0s' $(seq 1 $(( 92 > ${#1} ? 92 - ${#1} : 3 ))))\" \"$C_RST\"; }\n\n# paint \u003cstatus> -> the status word, coloured and padded to 6.\npaint() {\n case \"$1\" in\n PASS) printf '%s%-6s%s' \"$C_GRN\" \"PASS\" \"$C_RST\" ;;\n FAIL) printf '%s%-6s%s' \"$C_RED\" \"FAIL\" \"$C_RST\" ;;\n WARN) printf '%s%-6s%s' \"$C_YEL\" \"WARN\" \"$C_RST\" ;;\n TODO) printf '%s%-6s%s' \"$C_BLU\" \"TODO\" \"$C_RST\" ;;\n SKIP) printf '%s%-6s%s' \"$C_DIM\" \"SKIP\" \"$C_RST\" ;;\n *) printf '%-6s' \"$1\" ;;\n esac\n}\n\n# record \u003cstatus> \u003cresource> \u003cdetail>\n#\n# Four statuses, and the TODO/FAIL split is the load-bearing one. TODO means\n# \"this skill creates it and has not yet\" -- absence is the expected state of a\n# greenfield project, not a defect. FAIL means something is actually wrong:\n# a user prerequisite missing, or a conflict no build step will resolve.\n# Reporting a never-built setup as a wall of FAIL reads as breakage and is the\n# single most misleading thing this script used to do.\nrecord() {\n local st=\"$1\" res=\"$2\" det=\"$3\"\n case \"$st\" in\n PASS) PASS=$((PASS+1)) ;;\n FAIL) FAILN=$((FAILN+1)) ;;\n WARN) WARN=$((WARN+1)) ;;\n TODO) TODO=$((TODO+1)) ;;\n SKIP) SKIPN=$((SKIPN+1)) ;;\n esac\n ROWS+=(\"$st|$res|$det\")\n}\n\napi() { # api \u003curl> -> body on stdout, empty on failure\n curl -s -H \"Authorization: Bearer ${TOKEN}\" \"$1\" 2>/dev/null || true\n}\n\njqf() { # jqf \u003cjson> \u003cpython-expr over d> -- no jq dependency\n python3 -c '\nimport json,sys\ntry: d=json.loads(sys.stdin.read() or \"{}\")\nexcept Exception: d={}\ntry: print(eval(sys.argv[1]))\nexcept Exception: print(\"\")\n' \"$1\" 2>/dev/null || true\n}\n\n# ---------------------------------------------------------------- toolchain --\nGCV=\"$(gcloud version 2>/dev/null | sed -n 's/^Google Cloud SDK //p' | head -1)\"\nif [[ -z \"$GCV\" ]]; then\n record FAIL \"gcloud\" \"not on PATH\"\nelse\n MAJOR=\"${GCV%%.*}\"\n # 588.0.0 is where `apis import --from-template` reached the GA track. Below\n # it the GA command rejects the flag with an error that reads like the\n # command does not exist, and `gcloud beta` is the fallback. This check used\n # to say 585, which disagreed with every other statement of the minimum in\n # this skill and would have passed an SDK the build then failed on.\n if (( MAJOR \u003c 588 )); then\n record WARN \"gcloud\" \"$GCV -- below 588.0.0; 'apis import --from-template' is GA only from 588, use 'gcloud beta' or upgrade\"\n else\n record PASS \"gcloud\" \"$GCV\"\n fi\nfi\n\nTOKEN=\"$(gcloud auth application-default print-access-token 2>/dev/null || true)\"\nif [[ -z \"$TOKEN\" ]]; then\n record FAIL \"credentials\" \"no ADC token; run gcloud auth application-default login\"\nelse\n record PASS \"credentials\" \"ADC token present\"\nfi\n\n# ------------------------------------------------------------------ gateway --\nGW=\"$(gcloud network-services agent-gateways describe \"$GATEWAY\" \\\n --location=\"$REGION\" --project=\"$PROJECT\" --format=json 2>/dev/null || true)\"\nif [[ -z \"$GW\" ]]; then\n record FAIL \"agentGateway/$GATEWAY\" \"not found in $REGION\"\nelse\n record PASS \"agentGateway/$GATEWAY\" \"exists\"\n GAP=\"$(jqf 'd.get(\"googleManaged\",{}).get(\"governedAccessPath\",\"\")' \u003c\u003c\u003c\"$GW\")\"\n if [[ \"$GAP\" == \"AGENT_TO_ANYWHERE\" ]]; then\n record PASS \" governedAccessPath\" \"$GAP\"\n else\n record WARN \" governedAccessPath\" \"${GAP:-unset} -- egress governance expects AGENT_TO_ANYWHERE\"\n fi\n\n NA=\"$(jqf 'd.get(\"networkConfig\",{}).get(\"egress\",{}).get(\"networkAttachment\",\"\")' \u003c\u003c\u003c\"$GW\")\"\n if [[ -n \"$NA\" ]]; then\n record PASS \" egress.networkAttachment\" \"${NA##*/} (REUSE: a route into the VPC, not to a service)\"\n else\n record TODO \" egress.networkAttachment\" \"absent -- ensure_gateway_connectivity.sh attaches it; until then the callout has no route into the VPC\"\n fi\n\n DOMS=\"$(jqf '\",\".join(d.get(\"networkConfig\",{}).get(\"dnsPeeringConfig\",{}).get(\"domains\",[]))' \u003c\u003c\u003c\"$GW\")\"\n if [[ -n \"$DOMS\" ]]; then\n if [[ -n \"$HOSTNAME_\" && \"$DOMS\" != *\"${HOSTNAME_#*.}\"* ]]; then\n record WARN \" dnsPeeringConfig\" \"$DOMS -- does not cover ${HOSTNAME_#*.}; APPEND a second domain, peering is per-domain\"\n else\n record PASS \" dnsPeeringConfig\" \"$DOMS\"\n fi\n else\n record TODO \" dnsPeeringConfig\" \"absent -- ensure_gateway_connectivity.sh appends the domain; until then the host does not resolve\"\n fi\n\n SESA=\"$(jqf 'd.get(\"agentGatewayCard\",{}).get(\"serviceExtensionsServiceAccount\",\"\")' \u003c\u003c\u003c\"$GW\")\"\n [[ -n \"$SESA\" ]] && record PASS \" serviceExtensionsSA\" \"$SESA\"\nfi\n\n# --------------------------------------------------------- authz policies ----\nPOLS=\"$(gcloud beta network-security authz-policies list --location=\"$REGION\" \\\n --project=\"$PROJECT\" --format='value(name)' 2>/dev/null || true)\"\nOURS=\"\"; OUR_SUF=\"\"; OTHER_CONTENT=0\nwhile read -r p; do\n [[ -z \"$p\" ]] && continue\n PD=\"$(gcloud beta network-security authz-policies describe \"${p##*/}\" \\\n --location=\"$REGION\" --project=\"$PROJECT\" --format=json 2>/dev/null || true)\"\n TGT=\"$(jqf '\",\".join(r for r in d.get(\"target\",{}).get(\"resources\",[]))' \u003c\u003c\u003c\"$PD\")\"\n [[ \"$TGT\" != *\"/agentGateways/$GATEWAY\"* ]] && continue\n PROF=\"$(jqf 'd.get(\"policyProfile\",\"\")' \u003c\u003c\u003c\"$PD\")\"\n SUF=\"$(jqf '\",\".join(s.get(\"suffix\",\"\") for r in d.get(\"httpRules\",[]) for o in r.get(\"to\",{}).get(\"operations\",[]) for s in o.get(\"paths\",[]))' \u003c\u003c\u003c\"$PD\")\"\n # Semantic match first: does this policy's extension front OUR hostname?\n MINE=0\n if [[ \"${p##*/}\" == *\"$OUR_POLICY_MATCH\"* ]]; then\n MINE=1\n elif [[ \"$PROF\" == \"CONTENT_AUTHZ\" && -n \"$HOSTNAME_\" ]]; then\n EXTR=\"$(jqf '\",\".join(d.get(\"customProvider\",{}).get(\"authzExtension\",{}).get(\"resources\",[]))' \u003c\u003c\u003c\"$PD\")\"\n if [[ -n \"$EXTR\" ]]; then\n EXTSVC=\"$(gcloud service-extensions authz-extensions describe \"${EXTR##*/}\" \\\n --location=\"$REGION\" --project=\"$PROJECT\" --format='value(service)' 2>/dev/null || true)\"\n [[ \"$EXTSVC\" == \"$HOSTNAME_\" ]] && MINE=1\n fi\n fi\n if (( MINE )); then\n OURS=\"${p##*/}\"; OUR_SUF=\"$SUF\"\n record PASS \"authzPolicy/${p##*/}\" \"$PROF suffixes=[${SUF}]\"\n elif [[ \"$PROF\" == \"CONTENT_AUTHZ\" ]]; then\n OTHER_CONTENT=$((OTHER_CONTENT+1))\n record WARN \"authzPolicy/${p##*/}\" \"$PROF on the same gateway -- precedence between two CONTENT_AUTHZ policies is undefined; another governance extension on this gateway looks like this\"\n else\n record PASS \"authzPolicy/${p##*/}\" \"$PROF -- different phase, coexists cleanly\"\n fi\ndone \u003c\u003c\u003c\"$POLS\"\n[[ -z \"$OURS\" ]] && record TODO \"authzPolicy\" \"none targets $GATEWAY yet -- created in the bind step, last of all\"\n\n# ------------------------------------------------------------------- Apigee --\nif [[ -n \"$ENVNAME\" ]]; then\n EJ=\"$(api \"https://apigee.googleapis.com/v1/organizations/$ORG/environments/$ENVNAME\")\"\n ENV_EXISTS=0\n if [[ -z \"$EJ\" || \"$EJ\" == *'\"error\"'* ]]; then\n record TODO \"apigee/env/$ENVNAME\" \"not created yet -- ensure_apigee_environment.sh creates and attaches it (~4 min)\"\n else\n ENV_EXISTS=1\n record PASS \"apigee/env/$ENVNAME\" \"exists\"\n # BASE rejects extensible proxies, and nothing earlier in the build says so:\n # create, attach and the property PATCH all succeed, then only the deploy\n # fails -- \"Extensible proxy can not be deployed to a base environment.\" --\n # and it fails after the ~4 min attachment. Checked here so the read-only\n # pass can predict it.\n ETYPE=\"$(jqf 'd.get(\"type\",\"\")' \u003c\u003c\u003c\"$EJ\")\"\n if [[ \"$ETYPE\" == \"BASE\" ]]; then\n record TODO \" type\" \"BASE cannot host an extensible proxy -- the build PATCHes ?updateMask=type to COMPREHENSIVE before attaching\"\n elif [[ -n \"$ETYPE\" ]]; then\n record PASS \" type\" \"$ETYPE\"\n else\n # Healthy orgs often omit `type` entirely -- absent is NOT BASE, and\n # failing on it reports a broken setup as broken for the wrong reason.\n # The deployment check below is the real signal.\n record PASS \" type\" \"not reported (only an explicit BASE blocks extensible proxies)\"\n fi\n SEP=\"$(jqf 'next((p[\"value\"] for p in d.get(\"properties\",{}).get(\"property\",[]) if p.get(\"name\")==\"apigee-service-extension-enabled\"),\"\")' \u003c\u003c\u003c\"$EJ\")\"\n if [[ \"$SEP\" == \"true\" ]]; then\n record PASS \" apigee-service-extension-enabled\" \"true\"\n else\n record TODO \" apigee-service-extension-enabled\" \"${SEP:-unset} -- the build sets it; without it the proxy deploys STANDARD and never sees agent traffic\"\n fi\n fi\n\n # An environment in more than one envgroup makes the gateway 404 every agent\n # request with the extension never invoked, and nothing anywhere reports it.\n #\n # Only meaningful once the environment exists. Running it anyway reports an\n # envgroup \"in no envgroup\" and then a hostname mismatch against a group that\n # was never built -- one absent resource cascading into several confident\n # failures, which is the noise this script exists to avoid.\n if (( ENV_EXISTS == 0 )); then\n record SKIP \"apigee/envgroup\" \"not checked -- $ENVNAME does not exist yet; checked once it is created\"\n else\n EGS=\"$(api \"https://apigee.googleapis.com/v1/organizations/$ORG/envgroups\")\"\n INGRP=\"$(python3 -c '\nimport json,sys,urllib.request,os\nd=json.loads(sys.stdin.read() or \"{}\")\nprint(\" \".join(g.get(\"name\",\"\") for g in d.get(\"environmentGroups\",[])))' \u003c\u003c\u003c\"$EGS\" 2>/dev/null || true)\"\n HOLDING=()\n for g in $INGRP; do\n AT=\"$(api \"https://apigee.googleapis.com/v1/organizations/$ORG/envgroups/${g}/attachments\")\"\n grep -q \"\\\"$ENVNAME\\\"\" \u003c\u003c\u003c\"$AT\" && HOLDING+=(\"$g\")\n done\n case \"${#HOLDING[@]}\" in\n 0) record TODO \"apigee/envgroup\" \"$ENVNAME is in no envgroup yet -- the build creates one and attaches it\" ;;\n 1) record PASS \"apigee/envgroup\" \"${HOLDING[0]} (exactly one, which is required)\" ;;\n *) record FAIL \"apigee/envgroup\" \"$ENVNAME is in ${#HOLDING[@]} envgroups (${HOLDING[*]}) -- the gateway 404s every request and reports nothing\" ;;\n esac\n\n if [[ -n \"$ENVGROUP\" && -n \"$HOSTNAME_\" ]]; then\n EG=\"$(api \"https://apigee.googleapis.com/v1/organizations/$ORG/envgroups/$ENVGROUP\")\"\n if [[ -z \"$EG\" || \"$EG\" == *'\"error\"'* ]]; then\n # The group itself is not there yet. Reporting a hostname mismatch\n # against a group that does not exist states a real problem for an\n # unreal reason, and sends the reader to check DNS.\n record TODO \" envgroup hostname\" \"$ENVGROUP does not exist yet -- the build creates it carrying $HOSTNAME_\"\n elif grep -q \"$HOSTNAME_\" \u003c\u003c\u003c\"$EG\"; then\n record PASS \" envgroup hostname\" \"$ENVGROUP carries $HOSTNAME_\"\n else\n record FAIL \" envgroup hostname\" \"$ENVGROUP does not carry $HOSTNAME_ -- a hostname mismatch no build step resolves; the three names must be identical\"\n fi\n fi\n fi\nfi\n\n# ------------------------------------------------------------------- VPC -----\nif [[ -n \"$VPC\" ]]; then\n if gcloud compute networks describe \"$VPC\" --project=\"$PROJECT\" >/dev/null 2>&1; then\n record PASS \"vpc/$VPC\" \"exists (REUSE: shared with any other extension on this gateway)\"\n else\n record FAIL \"vpc/$VPC\" \"not found\"\n fi\nfi\nif [[ -n \"$SUBNET\" ]]; then\n SN=\"$(gcloud compute networks subnets describe \"$SUBNET\" --region=\"$REGION\" \\\n --project=\"$PROJECT\" --format='value(ipCidrRange)' 2>/dev/null || true)\"\n if [[ -n \"$SN\" ]]; then\n record PASS \"subnet/$SUBNET\" \"$SN (REUSE: the PSC IP is drawn from here)\"\n else\n record FAIL \"subnet/$SUBNET\" \"not found in $REGION\"\n fi\nfi\n# The VPC and subnet are the user's own infrastructure and have no documented\n# default to fall back on, so an absent flag genuinely means \"not checked\".\n# Record that rather than omitting the row: a check that silently did not run\n# is indistinguishable, in the output, from one that passed.\n[[ -z \"$VPC\" ]] && record SKIP \"vpc\" \"not checked -- pass --vpc to verify the gateway egresses into the network you expect\"\n[[ -z \"$SUBNET\" ]] && record SKIP \"subnet\" \"not checked -- pass --subnet to verify the PSC IP is drawn from the right range\"\n\nif [[ -n \"$ENVNAME\" && -n \"$PROXY\" ]]; then\n DEP=\"$(api \"https://apigee.googleapis.com/v1/organizations/$ORG/environments/$ENVNAME/apis/$PROXY/deployments\")\"\n REV=\"$(jqf 'd.get(\"deployments\",[{}])[0].get(\"revision\",\"\")' \u003c\u003c\u003c\"$DEP\")\"\n DT=\"$(jqf 'd.get(\"deployments\",[{}])[0].get(\"proxyDeploymentType\",\"\")' \u003c\u003c\u003c\"$DEP\")\"\n if [[ -z \"$REV\" ]]; then\n record TODO \"apigee/proxy/$PROXY\" \"not deployed yet -- deploy_proxy.sh builds and deploys it (~2 min)\"\n elif [[ \"$DT\" == \"EXTENSIBLE\" ]]; then\n record PASS \"apigee/proxy/$PROXY\" \"rev $REV, EXTENSIBLE\"\n else\n record FAIL \"apigee/proxy/$PROXY\" \"rev $REV, ${DT:-unknown} -- STANDARD deploys clean and governs nothing\"\n fi\n\n # Which features are actually DEPLOYED, read off the revision's policy list\n # rather than inferred from config. Drives the Enforcement rollup row: this\n # skill meters without blocking, EXCEPT Model Armor, which is\n # continueOnError=false and fails a turn closed.\n if [[ -n \"$REV\" ]]; then\n POLJ=\"$(api \"https://apigee.googleapis.com/v1/organizations/$ORG/apis/$PROXY/revisions/$REV\")\"\n [[ \"$POLJ\" == *modelarmor* ]] && HAS_ARMOR=1\n [[ \"$POLJ\" == *LTQ-count* ]] && HAS_METER=1\n [[ \"$POLJ\" == *semanticcache* ]] && HAS_CACHE=1\n [[ \"$POLJ\" == *LTQ-enforce* ]] && HAS_ENFORCE=1\n\n # Model Armor is the only policy that can refuse a request, so read its\n # ACTUAL enforcement mode rather than trusting the config. continueOnError\n # is not exposed on any JSON endpoint -- there is no per-policy resource --\n # so pull the revision bundle. It is ~10 KB and about half a second.\n if [[ \"${HAS_ARMOR:-0}\" == \"1\" ]]; then\n BUNDLE=\"$(mktemp)\"\n if curl -fsS -H \"Authorization: Bearer $TOKEN\" -o \"$BUNDLE\" \\\n \"https://apigee.googleapis.com/v1/organizations/$ORG/apis/$PROXY/revisions/$REV?format=bundle\" 2>/dev/null; then\n MA_COE=\"$(python3 - \"$BUNDLE\" \u003c\u003c'PY' 2>/dev/null || true\nimport sys,zipfile,re\ntry:\n z=zipfile.ZipFile(sys.argv[1])\n for n in z.namelist():\n if \"modelarmor\" in n and n.endswith(\".xml\"):\n m=re.search(r'continueOnError=\"(\\w+)\"', z.read(n).decode())\n if m: print(m.group(1)); break\nexcept Exception:\n pass\nPY\n)\"\n fi\n rm -f \"$BUNDLE\"\n [[ \"$MA_COE\" == \"true\" ]] && MA_MODE=\"DRY_RUN\" || MA_MODE=\"ENFORCE\"\n [[ -z \"$MA_COE\" ]] && MA_MODE=\"unknown\"\n fi\n fi\nfi\n\n# ------------------------------------------------------------------ the PSC --\nif [[ -n \"$HOSTNAME_\" ]]; then\n # Matched on TARGET, not name: the one pointing at an Apigee instance\n # service attachment is ours whatever it happens to be called. Falls back to\n # the name hint for a half-built setup whose target is not resolvable yet.\n FR=\"$(gcloud compute forwarding-rules list --project=\"$PROJECT\" \\\n --filter=\"region:($REGION)\" \\\n --format='value(name,IPAddress,pscConnectionStatus,target)' 2>/dev/null || true)\"\n PSCIP=\"$(awk '$4 ~ /serviceAttachments\\/apigee-/ {print $2; exit}' \u003c\u003c\u003c\"$FR\")\"\n PSCST=\"$(awk '$4 ~ /serviceAttachments\\/apigee-/ {print $3; exit}' \u003c\u003c\u003c\"$FR\")\"\n if [[ -z \"$PSCIP\" ]]; then\n PSCIP=\"$(awk -v m=\"$OUR_PSC_MATCH\" '$0 ~ m {print $2; exit}' \u003c\u003c\u003c\"$FR\")\"\n PSCST=\"$(awk -v m=\"$OUR_PSC_MATCH\" '$0 ~ m {print $3; exit}' \u003c\u003c\u003c\"$FR\")\"\n fi\n if [[ -z \"$PSCIP\" ]]; then\n record TODO \"psc/endpoint\" \"none yet in $REGION -- the build CREATEs one targeting the Apigee instance service attachment (never shared with another extension)\"\n elif [[ \"$PSCST\" == \"ACCEPTED\" ]]; then\n record PASS \"psc/endpoint\" \"$PSCIP ACCEPTED\"\n else\n record FAIL \"psc/endpoint\" \"$PSCIP ${PSCST:-PENDING} -- project is probably not on the instance consumerAcceptList\"\n fi\n\n ZN=\"$(gcloud dns managed-zones list --project=\"$PROJECT\" --format='value(name,dnsName)' 2>/dev/null || true)\"\n PARENT=\"${HOSTNAME_#*.}.\"\n if grep -q \"$PARENT\" \u003c\u003c\u003c\"$ZN\"; then\n ZNAME=\"$(awk -v p=\"$PARENT\" '$2==p {print $1; exit}' \u003c\u003c\u003c\"$ZN\")\"\n record PASS \"dns/zone\" \"${ZNAME:-found} covers $PARENT\"\n RS=\"$(gcloud dns record-sets list --zone=\"${ZNAME}\" --project=\"$PROJECT\" \\\n --filter=\"name=${HOSTNAME_}.\" --format='value(rrdatas[0])' 2>/dev/null || true)\"\n if [[ -z \"$RS\" ]]; then\n record TODO \"dns/record\" \"no A record for $HOSTNAME_ yet -- the build CREATEs it once the PSC IP exists\"\n elif [[ -n \"$PSCIP\" && \"$RS\" != \"$PSCIP\" ]]; then\n record FAIL \"dns/record\" \"$HOSTNAME_ -> $RS but the PSC endpoint is $PSCIP\"\n else\n record PASS \"dns/record\" \"$HOSTNAME_ -> $RS\"\n fi\n else\n record TODO \"dns/zone\" \"no private zone covers $PARENT yet -- the build CREATEs one (an existing parent zone is reused instead)\"\n fi\nfi\n\n# --------------------------------------------------------- Vector Search ----\n# Only reached when the deployed revision actually carries the cache policies,\n# so this never probes Vertex AI for a setup that does not use it.\n#\n# The deployed index is the ONE resource in this skill that bills while idle.\n# The provisioning script prints a COST block and modes.md requires explicit\n# confirmation before creating it -- and then the health check said nothing\n# about it at all, so an operator auditing an unfamiliar project got\n# \"SETUP OK, nothing outstanding\" while a replica had been running for hours.\n# Having taken the cost seriously at create time, reporting it afterwards is\n# the other half of the same obligation.\nCACHE_INDEX_ID=\"\"; CACHE_EP_ID=\"\"; CACHE_MACHINE=\"\"; CACHE_REPLICAS=\"\"\nCACHE_DIMS=\"\"; CACHE_METHOD=\"\"; CACHE_SYNC=\"\"; CACHE_DEPLOYED=\"\"; CACHE_ELAPSED=\"\"\nif [[ \"${HAS_CACHE:-0}\" == \"1\" ]]; then\n IDX_JSON=\"$(gcloud ai indexes list --project=\"$PROJECT\" --region=\"$REGION\" \\\n --format=json 2>/dev/null || echo '[]')\"\n CACHE_INDEX_ID=\"$(jqf 'next((i[\"name\"].split(\"/\")[-1] for i in d if \"cache\" in i.get(\"displayName\",\"\").lower()), \"\")' \u003c\u003c\u003c\"$IDX_JSON\")\"\n if [[ -n \"$CACHE_INDEX_ID\" ]]; then\n CACHE_DIMS=\"$(jqf 'next((str(i.get(\"metadata\",{}).get(\"config\",{}).get(\"dimensions\",\"\")) for i in d if i[\"name\"].endswith(\"'\"$CACHE_INDEX_ID\"'\")), \"\")' \u003c\u003c\u003c\"$IDX_JSON\")\"\n CACHE_METHOD=\"$(jqf 'next((i.get(\"indexUpdateMethod\",\"\") for i in d if i[\"name\"].endswith(\"'\"$CACHE_INDEX_ID\"'\")), \"\")' \u003c\u003c\u003c\"$IDX_JSON\")\"\n if [[ \"$CACHE_METHOD\" == \"STREAM_UPDATE\" ]]; then\n record PASS \"vectorsearch/index\" \"$CACHE_INDEX_ID dims=${CACHE_DIMS:-?} $CACHE_METHOD\"\n else\n # Not cosmetic: SemanticCachePopulate writes via :upsertDatapoints, which\n # a BATCH_UPDATE index does not expose. Populate fails silently forever.\n record FAIL \"vectorsearch/index\" \"$CACHE_INDEX_ID is ${CACHE_METHOD:-unknown}, not STREAM_UPDATE -- SemanticCachePopulate writes through :upsertDatapoints and can never succeed against it\"\n fi\n else\n record FAIL \"vectorsearch/index\" \"cache policies are deployed but no index found in $REGION -- lookups cannot resolve; scripts/ensure_vector_search.sh builds it\"\n fi\n\n EP_JSON=\"$(gcloud ai index-endpoints list --project=\"$PROJECT\" --region=\"$REGION\" \\\n --format=json 2>/dev/null || echo '[]')\"\n CACHE_EP_ID=\"$(jqf 'next((e[\"name\"].split(\"/\")[-1] for e in d if e.get(\"deployedIndexes\")), \"\")' \u003c\u003c\u003c\"$EP_JSON\")\"\n if [[ -n \"$CACHE_EP_ID\" ]]; then\n CACHE_MACHINE=\"$(jqf 'next((di.get(\"dedicatedResources\",{}).get(\"machineSpec\",{}).get(\"machineType\",\"\") for e in d for di in e.get(\"deployedIndexes\",[])), \"\")' \u003c\u003c\u003c\"$EP_JSON\")\"\n CACHE_REPLICAS=\"$(jqf 'next((str(di.get(\"dedicatedResources\",{}).get(\"minReplicaCount\",\"\")) for e in d for di in e.get(\"deployedIndexes\",[])), \"\")' \u003c\u003c\u003c\"$EP_JSON\")\"\n CACHE_SYNC=\"$(jqf 'next((di.get(\"indexSyncTime\",\"\") for e in d for di in e.get(\"deployedIndexes\",[])), \"\")' \u003c\u003c\u003c\"$EP_JSON\")\"\n CACHE_DEPLOYED=\"$(jqf 'next((di.get(\"createTime\",\"\") for e in d for di in e.get(\"deployedIndexes\",[])), \"\")' \u003c\u003c\u003c\"$EP_JSON\")\"\n\n # \"Bills per node-hour\" is a true statement nobody acts on. The number\n # someone reviewing an idle demo environment actually wants is how long it\n # has been running, and that is derivable from the deployedIndex\n # createTime. No pricing table: the duration alone turns an abstract\n # warning into something you can decide about, and a rate would go stale.\n CACHE_ELAPSED=\"\"\n if [[ -n \"$CACHE_DEPLOYED\" ]]; then\n _then=\"$(date -d \"$CACHE_DEPLOYED\" +%s 2>/dev/null || echo \"\")\"\n if [[ -n \"$_then\" ]]; then\n _secs=$(( $(date +%s) - _then )); (( _secs \u003c 0 )) && _secs=0\n CACHE_ELAPSED=\" deployed $(( _secs / 3600 ))h$(( (_secs % 3600) / 60 ))m ago -- ~$(awk -v s=\"$_secs\" -v r=\"${CACHE_REPLICAS:-1}\" 'BEGIN{printf \"%.1f\", (s/3600.0)*r}') node-hours accrued\"\n fi\n fi\n record WARN \"vectorsearch/deployed\" \"${CACHE_MACHINE:-?} x${CACHE_REPLICAS:-?} -- BILLING per node-hour while deployed, with or without traffic${CACHE_ELAPSED}${CACHE_SYNC:+ (last sync $CACHE_SYNC)}; scripts/teardown_vector_search.sh stops it, in order\"\n else\n record FAIL \"vectorsearch/endpoint\" \"cache policies are deployed but no index endpoint carries a deployed index -- every lookup fails; scripts/ensure_vector_search.sh deploys one\"\n fi\nfi\n\n# ------------------------------------------------------------------ rollup --\n# A category rollup above the per-check table. The table answers \"which check\n# failed\"; this answers \"what is this setup, and what does it do to traffic\" --\n# which is the question someone arriving cold actually has, and which 20 rows\n# of PASS do not answer. Status carries a VALUE, not just a verdict, and each\n# Details cell states the consequence rather than restating the value.\nSUMROWS=()\nsumrow() { SUMROWS+=(\"$1|$2|$3\"); }\n\n# Overall Health is computed LAST and PREPENDED -- see `rollup_overall_health`\n# just before the output section. It is the first row the reader sees, but it\n# summarises counters that rollup rows below are still incrementing, so\n# building it here reads them too early and under-reports.\n\nif [[ -z \"${REV:-}\" ]]; then\n sumrow \"Proxy\" \"NOT DEPLOYED\" \"nothing is governing traffic yet\"\nelse\n sumrow \"Proxy\" \"${DT:-unknown} rev ${REV}\" \"${PROXY:-?} on ${ENVNAME:-?}$([[ \"${ETYPE:-}\" == \"BASE\" ]] && echo \" -- env is BASE and cannot host this\")\"\nfi\n\n# Other governance products surface a row like this as Enforcement Mode. Ours\n# is a different answer, and it should be stated rather than left to be\n# inferred from a hardcoded CountOnly.\nif [[ \"${HAS_ARMOR:-0}\" == \"1\" && \"${MA_MODE:-}\" == \"DRY_RUN\" ]]; then\n sumrow \"Enforcement\" \"METER + ARMOR:DRY_RUN\" \"nothing blocks -- Model Armor evaluates and logs, the fault is swallowed\"\nelif [[ \"${HAS_ARMOR:-0}\" == \"1\" && \"${MA_MODE:-}\" == \"ENFORCE\" ]]; then\n sumrow \"Enforcement\" \"METER + ARMOR:ENFORCE${HAS_ENFORCE:+ + QUOTA LIMIT}\" \"Model Armor DOES refuse a matching request (400)$([[ \"${HAS_ENFORCE:-0}\" == \"1\" ]] && echo \"; token budget also enforced, one request late\" || echo \"; quotas count only\")\"\nelif [[ \"${HAS_ARMOR:-0}\" == \"1\" ]]; then\n sumrow \"Enforcement\" \"METER + ARMOR:?\" \"Model Armor deployed; could not read continueOnError off rev ${REV:-?}\"\nelif [[ \"${HAS_METER:-0}\" == \"1\" && \"${HAS_ENFORCE:-0}\" == \"1\" ]]; then\n sumrow \"Enforcement\" \"METER + QUOTA LIMIT\" \"token budget IS enforced: an over-budget caller is refused on its NEXT request (enforcement is one request late)\"\nelif [[ \"${HAS_METER:-0}\" == \"1\" ]]; then\n sumrow \"Enforcement\" \"METER ONLY\" \"quotas are CountOnly -- no request is ever blocked by this proxy\"\nelse\n sumrow \"Enforcement\" \"NONE DETECTED\" \"no metering policies on rev ${REV:-?}\"\nfi\n\n# The only continuously-billing component gets its own row. Absent is a real\n# answer here, not a gap -- \"OFF\" tells an auditor there is no standing spend.\nif [[ \"${HAS_CACHE:-0}\" == \"1\" ]]; then\n CACHECOST=\"${CACHE_MACHINE:-?} x${CACHE_REPLICAS:-?}\"\n sumrow \"Caching\" \"ON ($CACHECOST)\" \"deployed index ${CACHE_INDEX_ID:-?} -- BILLING per node-hour while deployed, traffic or not\"\nelse\n sumrow \"Caching\" \"OFF\" \"no semantic cache policies on rev ${REV:-?}; nothing here bills while idle\"\nfi\n\nif [[ -n \"${OUR_SUF:-}\" ]]; then\n NSUF=\"$(awk -F, '{print NF}' \u003c\u003c\u003c\"$OUR_SUF\")\"\n CACHENOTE=\"\"\n if [[ \"${HAS_CACHE:-0}\" == \"1\" && \"${NSUF:-1}\" -gt 1 ]]; then\n CACHENOTE=\" -- KNOWN ISSUE: cache + >1 shape can serve the wrong model's answer\"\n # Also a row in its own right. modes.md requires this warning \"at plan\n # time and again in the summary\", and assess is where someone inspecting\n # a setup they did not build will look for it.\n record WARN \"semanticcache/shapes\" \"cache is ON with $NSUF governed shapes -- the cache key is the prompt embedding alone, carrying no model or wire-shape discriminator, so a cross-shape hit returns an EMPTY HTTP 200 that reads as the agent being broken. Treat hits as unverified while more than one model sits behind this proxy\"\n fi\n sumrow \"Governed Shapes\" \"$NSUF\" \"${OUR_SUF}${CACHENOTE}\"\nelse\n sumrow \"Governed Shapes\" \"0\" \"no AuthzPolicy suffixes -- nothing reaches Apigee\"\nfi\n\nif [[ \"${PSCST:-}\" == \"ACCEPTED\" && -n \"${ZNAME:-}\" ]]; then\n sumrow \"Callout Path\" \"Verified\" \"PSC ${PSCIP:-?} ACCEPTED, zone ${ZNAME} resolves ${HOSTNAME_:-?}\"\nelse\n sumrow \"Callout Path\" \"Incomplete\" \"PSC=${PSCST:-missing}, dns zone=${ZNAME:-missing} -- the callout cannot land\"\nfi\n\nif [[ -n \"${OURS:-}\" ]]; then\n sumrow \"AuthZ Wiring\" \"Verified\" \"${OURS}$( (( ${OTHER_CONTENT:-0} > 0 )) && echo \" -- plus ${OTHER_CONTENT} other CONTENT_AUTHZ policy; precedence undefined\")\"\nelse\n sumrow \"AuthZ Wiring\" \"Missing\" \"no policy targets ${GATEWAY:-?}\"\nfi\n\n# ------------------------------------------------- overall health (last) ----\n# Deliberately the last thing computed and the first thing shown.\n#\n# Overall health reads TODO and FAIL as different kinds of news. A greenfield\n# project is not a broken one: all TODO and no FAIL means there is nothing to\n# repair, only things to build, and the headline has to say so or the whole\n# run reads as breakage.\n#\n# It must read the SAME accumulators the footer tally prints. Computing it\n# earlier -- where it used to live, above the rollup rows -- took a snapshot\n# before `semanticcache/shapes` recorded its WARN, so a run with two warnings\n# announced one. The dropped one was either the billing disclosure or the\n# cache-correctness known issue, and modes.md tells the agent to lead with\n# this row and treat it as the answer.\nTALLY=\"$PASS PASS, $FAILN FAIL, $WARN WARN, $TODO TODO\"\nif (( FAILN > 0 && TODO > 0 )); then\n OVERALL=\"NEEDS WORK\"\n DETAIL=\"$TALLY -- fix the $FAILN failure(s); the $TODO outstanding build steps are not faults\"\nelif (( FAILN > 0 )); then\n OVERALL=\"NEEDS WORK\"\n DETAIL=\"$TALLY -- $FAILN genuine failure(s) to fix\"\nelif (( TODO > 0 && PASS \u003c= 2 )); then\n OVERALL=\"NOT BUILT\"\n DETAIL=\"$TALLY -- nothing built yet and nothing broken; a clean starting point\"\nelif (( TODO > 0 )); then\n OVERALL=\"PARTIALLY BUILT\"\n DETAIL=\"$TALLY -- no failures; $TODO step(s) left to finish the build\"\nelif (( WARN > 0 )); then\n OVERALL=\"OK, WITH NOTES\"\n DETAIL=\"$TALLY -- built and healthy; warnings are things to know, not to fix\"\nelse\n OVERALL=\"SETUP OK\"\n DETAIL=\"$TALLY -- built and healthy\"\nfi\nSUMROWS=(\"Overall Health|$OVERALL|$DETAIL\" \"${SUMROWS[@]}\")\n\n# ------------------------------------------------------------------- output --\nif (( JSON )); then\n python3 -c '\nimport json,sys\nblocks=sys.stdin.read().split(\"\\x02\")\nsumm=[l.split(\"|\",2) for l in blocks[0].splitlines() if l]\nrows=[l.split(\"|\",2) for l in blocks[1].splitlines() if l]\nprint(json.dumps({\n \"summary\":{\"pass\":int(sys.argv[1]),\"fail\":int(sys.argv[2]),\"warn\":int(sys.argv[3]),\n \"todo\":int(sys.argv[4]),\"overall\":sys.argv[5],\n \"skip\":int(sys.argv[6]),\"assumed\":[a for a in sys.argv[7].split() if a]},\n \"rollup\":[{\"category\":c,\"status\":s,\"detail\":d} for c,s,d in summ],\n \"checks\":[{\"status\":s,\"resource\":r,\"detail\":d} for s,r,d in rows]}, indent=2))\n' \"$PASS\" \"$FAILN\" \"$WARN\" \"$TODO\" \"$OVERALL\" \"$SKIPN\" \"${ASSUMED[*]:-}\" \u003c\u003c\u003c\"$(printf '%s\\n' \"${SUMROWS[@]}\")$(printf '\\x02')$(printf '%s\\n' \"${ROWS[@]}\")\"\nelse\n # Box-drawn so the two tables read as tables rather than as loose columns,\n # and so the summary is visibly the headline rather than a preamble.\n rule() { printf '%s%s%s\\n' \"$C_DIM\" \"$(printf '─%.0s' $(seq 1 \"$1\"))\" \"$C_RST\"; }\n\n case \"$OVERALL\" in\n \"SETUP OK\"|\"OK, WITH NOTES\") OC=\"$C_GRN\" ;;\n \"NEEDS WORK\") OC=\"$C_RED\" ;;\n *) OC=\"$C_BLU\" ;;\n esac\n\n hdr \"Setup Status Summary\"\n printf '%s %-17s %-24s %s%s\\n' \"$C_BLD\" \"CATEGORY\" \"STATUS\" \"DETAILS\" \"$C_RST\"\n for r in \"${SUMROWS[@]}\"; do\n cat_=\"${r%%|*}\"; st=\"$(cut -d'|' -f2 \u003c\u003c\u003c\"$r\")\"; det=\"${r#*|*|}\"\n if [[ \"$cat_\" == \"Overall Health\" ]]; then\n printf ' %-17s %s%-24s%s %s\\n' \"$cat_\" \"$OC$C_BLD\" \"$st\" \"$C_RST\" \"$det\"\n else\n printf ' %-17s %-24s %s\\n' \"$cat_\" \"$st\" \"$det\"\n fi\n done\n rule 96\n\n hdr \"Checks\"\n printf '%s %-6s %-34s %s%s\\n' \"$C_BLD\" \"STATUS\" \"RESOURCE\" \"DETAIL\" \"$C_RST\"\n for r in \"${ROWS[@]}\"; do\n printf ' %s %-34s %s\\n' \"$(paint \"${r%%|*}\")\" \"$(cut -d'|' -f2 \u003c\u003c\u003c\"$r\")\" \"${r#*|*|}\"\n done\n rule 96\n\n printf '\\n %s%d PASS%s %s%d FAIL%s %s%d WARN%s %s%d TODO%s %s%d SKIP%s\\n' \\\n \"$C_GRN\" \"$PASS\" \"$C_RST\" \"$C_RED\" \"$FAILN\" \"$C_RST\" \\\n \"$C_YEL\" \"$WARN\" \"$C_RST\" \"$C_BLU\" \"$TODO\" \"$C_RST\" \\\n \"$C_DIM\" \"$SKIPN\" \"$C_RST\"\n\n if (( ${#ASSUMED[@]} > 0 )); then\n printf ' %sAssumed documented defaults for: %s%s\\n' \"$C_DIM\" \"${ASSUMED[*]}\" \"$C_RST\"\n printf ' %sPass the matching flags if this setup uses different names.%s\\n' \"$C_DIM\" \"$C_RST\"\n fi\n\n # Every count above is useless on its own -- say what to do about it.\n (( TODO > 0 )) && printf ' %sTODO%s is not a failure: the resource does not exist yet and this skill creates it.\\n' \"$C_BLU\" \"$C_RST\"\n (( FAILN > 0 )) && printf ' %sFAIL%s is something a build step will NOT resolve. Fix top-down: a gateway with no\\n route into the VPC makes every later check meaningless.\\n' \"$C_RED\" \"$C_RST\"\n (( FAILN == 0 && WARN > 0 )) && printf ' %sWARN%s is a thing to know, not a thing to fix.\\n' \"$C_YEL\" \"$C_RST\"\n # Standing spend gets its own line. Folding it into \"warnings are things to\n # know, not to fix\" is wrong: this one IS actionable, and it is the only\n # thing in the skill that costs money while nothing is happening.\n [[ \"${HAS_CACHE:-0}\" == \"1\" && -n \"${CACHE_MACHINE:-}\" ]] && printf \\\n ' %sCOST%s the deployed index (%s x%s) bills per node-hour whether or not traffic flows.\\n Undeploy it to stop the charge -- editing the YAML does not.\\n' \\\n \"$C_YEL\" \"$C_RST\" \"${CACHE_MACHINE}\" \"${CACHE_REPLICAS:-?}\"\n (( FAILN == 0 && TODO == 0 && WARN == 0 )) && printf ' Nothing outstanding.\\n'\nfi\n\n# TODO alone is a clean exit: a greenfield project is a valid state, and\n# callers gate \"is anything broken\" on this. Only real failures exit non-zero.\n(( FAILN > 0 )) && exit 1\nexit 0\n","content_type":"application/x-sh; charset=utf-8","language":"bash","size":36324,"content_sha256":"01e85182ca4cbcbd8ec929bdee15d050882ec31d76daecb3c739a4d7f3ab1026"},{"filename":"scripts/compile_check.py","content":"#!/usr/bin/env python3\n\"\"\"Compile an AFT template offline and assert the ext-proc invariants.\n\nNo Apigee org, no network, no deploy. Replicates exactly what\n`gcloud apigee apis import --from-template` does locally: load the template,\nload every feature it names from the same directory, compile with an EMPTY\ncaller-parameter map, and convert to a bundle.\n\n python3 compile_check.py ../assets/apigee-proxy-template.yaml\n\nExits non-zero if an invariant fails. Checks:\n 1. the proxy is no-route -- a RouteRule with no TargetEndpoint\n 2. no targets are declared\n 3. at most one \u003cPreFlow> and one \u003cPostFlow> per endpoint\n (AFT emits one element per Flow entry; two Flows both named PreFlow\n produce two sibling \u003cPreFlow> elements, which is not valid ProxyEndpoint\n XML -- put response-phase steps in PostFlow/Response instead)\n 4. no unsubstituted {MACRO} survives into the bundle\n\"\"\"\n\nimport io\nimport os\nimport re\nimport sys\nimport xml.etree.ElementTree as ET\nimport zipfile\n\ntry:\n import yaml\nexcept ModuleNotFoundError:\n # The usual cause is running this with an interpreter that happens not to\n # carry PyYAML, which is common and says nothing about the install being\n # wrong. Name the interpreter in the message so the fix is unambiguous --\n # a bare traceback sends people looking for a problem with the script.\n sys.exit(\n \"error: PyYAML is not available to this interpreter.\\n\"\n f\" interpreter: {sys.executable}\\n\"\n \" Install it (pip install pyyaml) or re-run with a Python 3\\n\"\n \" that already has it. The gcloud SDK's bundled interpreter does\\n\"\n \" not, and the SDK's vendored 'yaml' is Python 2 -- do not use it.\"\n )\n\n# The AFT compiler ships inside the gcloud SDK rather than on PyPI, so import it\n# from the SDK install. Set CLOUDSDK_ROOT_DIR to override the search.\n_CANDIDATES = [\n os.path.join(os.environ[\"CLOUDSDK_ROOT_DIR\"], \"lib\")\n if os.environ.get(\"CLOUDSDK_ROOT_DIR\")\n else \"\",\n \"/usr/lib/google-cloud-sdk/lib\",\n os.path.expanduser(\"~/google-cloud-sdk/lib\"),\n]\nfor _p in _CANDIDATES:\n if _p and os.path.isdir(_p):\n # third_party first: the SDK vendors its own deps (dateutil and friends)\n # and importing googlecloudsdk without them fails.\n sys.path.insert(0, os.path.join(_p, \"third_party\"))\n sys.path.insert(0, _p)\n break\nelse:\n sys.exit(\n \"error: could not find the gcloud SDK's python libraries.\\n Install\"\n \" the Google Cloud SDK (>= 588.0.0), or set CLOUDSDK_ROOT_DIR\\n to\"\n \" its install directory.\"\n )\n\nfrom googlecloudsdk.command_lib.apigee.aft import compiler # noqa: E402\nfrom googlecloudsdk.command_lib.apigee.aft import converter # noqa: E402\nfrom googlecloudsdk.command_lib.apigee.aft import models # noqa: E402\n\n# Apigee message templates also use {}, so only flag names that look like AFT\n# parameters: SCREAMING_SNAKE_CASE with no dots.\n_MACRO = re.compile(r\"\\{([A-Z][A-Z0-9_]*)\\}\")\n\n# A value safe to drop into a sed pattern unescaped. Deliberately narrow:\n# anything with a quote, space, paren or regex metacharacter gets prose.\n_SIMPLE_SCALAR = re.compile(r\"[A-Za-z0-9._-]+\")\n\n\ndef compile_template(path):\n root = os.path.dirname(os.path.abspath(path))\n with open(path) as fh:\n template = models.from_dict(models.Template, yaml.safe_load(fh))\n features = []\n for name in template.features:\n with open(os.path.join(root, name)) as fh:\n features.append(models.from_dict(models.Feature, yaml.safe_load(fh)))\n return template, compiler.ApigeeCompiler().compile(template, features, {})\n\n\ndef parameter_drift(path, template):\n \"\"\"Template-declared parameters are INERT for features. Report the drift.\n\n `gcloud apigee apis import --from-template` invokes the compiler with an\n EMPTY caller-parameter map, so every feature resolves its own declared\n default and the template's declaration of the same name is never consulted.\n Editing a default in the template therefore changes nothing, silently, and\n the compile still succeeds -- the macro resolves, just to the other value.\n\n This was hit for real: MA_CONTINUE_ON_ERROR was set to \"true\" (DRY_RUN) in\n the template, the feature kept \"false\", and the bundle came out ENFORCE on\n a fail-closed Model Armor policy. Nothing in the output said so.\n\n Two findings, different severities:\n MISMATCH -- a feature declares the same name with a DIFFERENT default.\n The feature wins. This is the dangerous one: someone edited\n the template expecting an effect and got none.\n INERT -- no feature declares it at all, so the parameter does nothing\n anywhere. Usually a leftover, occasionally a typo in a name.\n \"\"\"\n root = os.path.dirname(os.path.abspath(path))\n tmpl_defaults = {\n p.name: getattr(p, \"default\", None) for p in template.parameters\n }\n feature_defaults = {} # name -> {feature_file: default}\n for fname in template.features:\n with open(os.path.join(root, fname)) as fh:\n feat = models.from_dict(models.Feature, yaml.safe_load(fh))\n for fp in feat.parameters or []:\n feature_defaults.setdefault(fp.name, {})[fname] = getattr(\n fp, \"default\", None\n )\n\n mismatches, inert = [], []\n for name, tdefault in tmpl_defaults.items():\n holders = feature_defaults.get(name)\n if not holders:\n inert.append(name)\n continue\n differing = {f: d for f, d in holders.items() if d != tdefault}\n if differing:\n mismatches.append((name, tdefault, differing))\n return mismatches, inert\n\n\ndef check(path):\n template, proxy = compile_template(path)\n failures = []\n\n mismatches, inert = parameter_drift(path, template)\n root = os.path.dirname(os.path.abspath(path))\n for name, tdefault, differing in mismatches:\n where = \"; \".join(f\"{f} has {d!r}\" for f, d in sorted(differing.items()))\n\n # Print the exact remedy, not just the diagnosis. Reconciling one\n # number across a template and two features is the most common edit\n # this check blocks, and leaving the operator to work out which file\n # and which direction is where the drift gets re-introduced -- the\n # feature value is the one that compiles, so the template is almost\n # always the side that moves.\n #\n # The pattern tolerates optional quotes because YAML scalars are\n # written `default: \"10000\"` here while the parsed value is bare, and\n # a sed that matches neither silently succeeds -- worse than printing\n # nothing. Values carrying regex or quote characters (LLM_URI_MATCH)\n # get prose instead of a command that might corrupt the file.\n def _remedy(fname, cur):\n target = os.path.join(root, fname)\n if _SIMPLE_SCALAR.fullmatch(str(cur)) and _SIMPLE_SCALAR.fullmatch(\n str(tdefault)\n ):\n return (\n f' sed -i -E \"s|(^[[:space:]]*default: )[\\\\\"\\']?{cur}[\\\\\"\\']?'\n f'[[:space:]]*$|\\\\1\\\\\"{tdefault}\\\\\"|\" {target}'\n )\n return (\n f\" edit {target}: set {name} default to {tdefault!r} (value\"\n \" needs quoting care)\"\n )\n\n fixes = \"\\n\".join(_remedy(f, d) for f, d in sorted(differing.items()))\n failures.append(\n f\"parameter {name}: the template declares {tdefault!r} but {where}. \"\n \"The FEATURE value is what compiles -- the template's is inert, \"\n \"because gcloud passes an empty caller-parameter map.\\n\"\n f\" To make the features match the template:\\n{fixes}\\n\"\n \" To keep the feature value instead, change the template's \"\n f\"declaration of {name} to {sorted(differing.values())[0]!r}.\"\n )\n\n if proxy.targets:\n failures.append(\n f\"expected no targets, got {[t.name for t in proxy.targets]}\"\n )\n\n for ep in proxy.endpoints:\n routed = [r.name for r in ep.routes if r.target is not None]\n if routed:\n failures.append(f\"endpoint {ep.name}: routes with a target: {routed}\")\n if not ep.routes:\n failures.append(f\"endpoint {ep.name}: no RouteRule at all\")\n for tag in (\"PreFlow\", \"PostFlow\"):\n n = sum(1 for f in ep.flows if f.name == tag)\n if n > 1:\n failures.append(\n f\"endpoint {ep.name}: {n} flows named {tag}; the converter \"\n f\"emits one \u003c{tag}> element each, which is invalid. Move \"\n \"response-phase steps to PostFlow/Response.\"\n )\n\n bundle = converter.proxy_to_bundle(proxy)\n zf = zipfile.ZipFile(io.BytesIO(bundle))\n declared = {p.name for p in template.parameters}\n for entry in zf.namelist():\n if not entry.endswith((\".xml\", \".js\", \".properties\")):\n continue\n for macro in set(_MACRO.findall(zf.read(entry).decode(\"utf-8\", \"replace\"))):\n if macro in declared:\n failures.append(\n f\"{entry}: {{{macro}}} was not substituted. Template \"\n \"parameters are replaced in FEATURE content only -- the \"\n \"template's own fields are copied verbatim.\"\n )\n\n # Structural parse of the emitted ProxyEndpoint.\n for entry in zf.namelist():\n if \"/proxies/\" in entry:\n el = ET.fromstring(zf.read(entry))\n for tag in (\"PreFlow\", \"PostFlow\"):\n if len(el.findall(tag)) > 1:\n failures.append(f\"{entry}: multiple \u003c{tag}> elements\")\n for rule in el.findall(\"RouteRule\"):\n if rule.find(\"TargetEndpoint\") is not None:\n failures.append(f\"{entry}: RouteRule has a TargetEndpoint\")\n\n print(f\"template : {template.name}\")\n print(f\"policies : {len(proxy.policies)} resources: {len(proxy.resources)}\")\n print(f\"bundle : {len(zf.namelist())} entries\")\n if inert:\n print(\n \"WARNING : template parameters no feature reads, so they do nothing: \"\n + \", \".join(sorted(inert))\n )\n for ep in proxy.endpoints:\n for f in ep.flows:\n print(f\" {f.name}/{f.mode}: {[s.name for s in f.steps]}\")\n\n if failures:\n print(\"\\nFAIL\")\n for f in failures:\n print(f\" - {f}\")\n return 1\n print(\n \"\\nOK: no-route, no targets, single PreFlow/PostFlow, all macros resolved\"\n )\n return 0\n\n\nif __name__ == \"__main__\":\n if len(sys.argv) != 2:\n print(__doc__)\n sys.exit(2)\n sys.exit(check(sys.argv[1]))\n","content_type":"text/x-python; charset=utf-8","language":"python","size":10143,"content_sha256":"2f642c4a2be2745581b0093d21da98ef09e0ad6d5ce28bfba7e1d0133139fe8e"},{"filename":"scripts/deploy_proxy.sh","content":"#!/bin/bash\n# Enable a service-extension environment, build the ext-proc proxy from the AFT\n# template, deploy it, and confirm it came up EXTENSIBLE.\n#\n# This sequence is deterministic and order-dependent, and three of its steps\n# fail in ways that look like something else:\n# - the environment PATCH replaces the whole properties map\n# - the PATCH is asynchronous, so an immediate readback looks like a failure\n# - the environment is locked while an instance attachment is running\n# Encapsulated here so those are handled once rather than re-learned.\n#\n# Data goes to stdout as JSON. Diagnostics go to stderr.\n\nset -euo pipefail\n\nusage() {\n cat \u003c\u003c'EOF'\nDeploy the Apigee Agent Gateway ext-proc proxy.\n\nUsage:\n deploy_proxy.sh --org ORG --env ENV --proxy PROXY --template PATH [options]\n\nRequired:\n --org ORG Apigee organization\n --env ENV environment that will host the proxy\n --proxy PROXY proxy name to create or revise\n --template PATH AFT template; feature files must sit beside it\n\nOptions:\n --service-account SA deployment SA; required if any policy calls a Google\n API (Model Armor, embeddings, Vector Search), else the\n deploy is rejected with MISSING_SERVICE_ACCOUNT\n --skip-property environment property is already set; skip that step\n --yes apply. Without it, prints the plan and exits 0.\n --dry-run explicit form of the default; prints and changes nothing\n --help this text\n\nSemantic cache (only if the semanticCache feature is enabled):\n --index-endpoint-id ID Vector Search index endpoint. Its public domain is\n read back and substituted into the findNeighbors URL;\n the domain cannot be derived from any id you hold, and\n the input file has no field for it.\n --index-id ID Vector Search index, for the upsertDatapoints URL\n --embeddings-model M with --index-id, refuses to deploy when the index\n width does not match the model's native output width.\n A mismatch is only fixable by rebuilding the index.\n --sc-project P project holding the index endpoint\n --sc-region R region holding the index endpoint\n scripts/ensure_vector_search.sh creates all of these\n and prints the ids as JSON.\n\nEnvironment:\n CLOUDSDK_AUTH_ACCESS_TOKEN set automatically from ADC if unset. Needed\n because under context-aware access the default\n gcloud credential is rejected with a message\n that names the resource, not the credential.\n\nExit codes: 0 ok, 1 usage, 2 precondition failed, 3 deploy not EXTENSIBLE\nEOF\n}\n\nlog() { echo \"[$(date +%H:%M:%S)] $*\" >&2; }\n\n# ---------------------------------------------------------------- display ----\n# Same palette as assess_integration.sh so the whole skill looks like one tool.\n# The tty test is on fd 2, NOT fd 1: every log line here goes to stderr, so\n# testing stdout would colour a piped run and leave an interactive one plain --\n# exactly backwards. Honours NO_COLOR (no-color.org) and a dumb terminal.\nif [[ -t 2 && -z \"${NO_COLOR:-}\" && \"${TERM:-dumb}\" != \"dumb\" ]]; then\n C_RST=

Apigee as an Agent Gateway Service Extension Wires an Apigee proxy in as an ext-proc callout on a GCP Agent Gateway. The proxy is a processor, not an API proxy : it has no backend, inspects the LLM request and response the gateway hands it, and returns a verdict. Usage Attribution MUST READ : Before executing any , REST API, or MCP command, you MUST read and follow the instructions in Usage Attribution. You build the proxy; the user does not. Two modes, one schema. Decide which before anything else, and say which. Input file present → prod mode : it is the contract, every value comes from it,…

\\033[0m'; C_DIM=

Apigee as an Agent Gateway Service Extension Wires an Apigee proxy in as an ext-proc callout on a GCP Agent Gateway. The proxy is a processor, not an API proxy : it has no backend, inspects the LLM request and response the gateway hands it, and returns a verdict. Usage Attribution MUST READ : Before executing any , REST API, or MCP command, you MUST read and follow the instructions in Usage Attribution. You build the proxy; the user does not. Two modes, one schema. Decide which before anything else, and say which. Input file present → prod mode : it is the contract, every value comes from it,…

\\033[2m'; C_BLD=

Apigee as an Agent Gateway Service Extension Wires an Apigee proxy in as an ext-proc callout on a GCP Agent Gateway. The proxy is a processor, not an API proxy : it has no backend, inspects the LLM request and response the gateway hands it, and returns a verdict. Usage Attribution MUST READ : Before executing any , REST API, or MCP command, you MUST read and follow the instructions in Usage Attribution. You build the proxy; the user does not. Two modes, one schema. Decide which before anything else, and say which. Input file present → prod mode : it is the contract, every value comes from it,…

\\033[1m'\n C_GRN=

Apigee as an Agent Gateway Service Extension Wires an Apigee proxy in as an ext-proc callout on a GCP Agent Gateway. The proxy is a processor, not an API proxy : it has no backend, inspects the LLM request and response the gateway hands it, and returns a verdict. Usage Attribution MUST READ : Before executing any , REST API, or MCP command, you MUST read and follow the instructions in Usage Attribution. You build the proxy; the user does not. Two modes, one schema. Decide which before anything else, and say which. Input file present → prod mode : it is the contract, every value comes from it,…

\\033[32m'; C_RED=

Apigee as an Agent Gateway Service Extension Wires an Apigee proxy in as an ext-proc callout on a GCP Agent Gateway. The proxy is a processor, not an API proxy : it has no backend, inspects the LLM request and response the gateway hands it, and returns a verdict. Usage Attribution MUST READ : Before executing any , REST API, or MCP command, you MUST read and follow the instructions in Usage Attribution. You build the proxy; the user does not. Two modes, one schema. Decide which before anything else, and say which. Input file present → prod mode : it is the contract, every value comes from it,…

\\033[31m'; C_YEL=

Apigee as an Agent Gateway Service Extension Wires an Apigee proxy in as an ext-proc callout on a GCP Agent Gateway. The proxy is a processor, not an API proxy : it has no backend, inspects the LLM request and response the gateway hands it, and returns a verdict. Usage Attribution MUST READ : Before executing any , REST API, or MCP command, you MUST read and follow the instructions in Usage Attribution. You build the proxy; the user does not. Two modes, one schema. Decide which before anything else, and say which. Input file present → prod mode : it is the contract, every value comes from it,…

\\033[33m'; C_BLU=

Apigee as an Agent Gateway Service Extension Wires an Apigee proxy in as an ext-proc callout on a GCP Agent Gateway. The proxy is a processor, not an API proxy : it has no backend, inspects the LLM request and response the gateway hands it, and returns a verdict. Usage Attribution MUST READ : Before executing any , REST API, or MCP command, you MUST read and follow the instructions in Usage Attribution. You build the proxy; the user does not. Two modes, one schema. Decide which before anything else, and say which. Input file present → prod mode : it is the contract, every value comes from it,…

\\033[36m'\nelse\n C_RST=; C_DIM=; C_BLD=; C_GRN=; C_RED=; C_YEL=; C_BLU=\nfi\n\n# hdr \u003ctitle> section banner\n# row \u003cverb> \u003ctext> a planned change; CREATE/RESERVE cyan, MODIFY yellow\n# cost \u003cline>... standing-spend callout, always yellow\nhdr() { printf '%s\\n%s┌─ %s %s%s\\n' \"\" \"$C_DIM\" \"$1\" \"$(printf '─%.0s' $(seq 1 $(( 92 > ${#1} ? 92 - ${#1} : 3 ))))\" \"$C_RST\" >&2; }\nrow() { local c=\"$C_BLU\"; case \"$1\" in MODIFY|PATCH|APPEND) c=\"$C_YEL\";; REUSE) c=\"$C_GRN\";; esac\n printf ' %s%-8s%s %s\\n' \"$c\" \"$1\" \"$C_RST\" \"$2\" >&2; }\ncost() { printf ' %s%s%s\\n' \"$C_YEL\" \"$*\" \"$C_RST\" >&2; }\n\ndie() { echo \"error: $1\" >&2; exit \"${2:-1}\"; }\n\n# Applies nothing without --yes, matching ensure_*/teardown_*. This script\n# patches the environment, imports the proxy and deploys a revision, any of\n# which can affect live traffic, so the default is plan-only.\nORG=\"\"; ENV=\"\"; PROXY=\"\"; TEMPLATE=\"\"; SA=\"\"; SKIP_PROPERTY=0; DRY_RUN=1; BASE_PATH=\"/\"\nINDEX_ENDPOINT_ID=\"\"; INDEX_ID=\"\"; SC_PROJECT=\"\"; SC_REGION=\"\"; EMBEDDINGS_MODEL=\"\"\nwhile [[ $# -gt 0 ]]; do\n case \"$1\" in\n --org) ORG=\"$2\"; shift 2 ;;\n --env) ENV=\"$2\"; shift 2 ;;\n --proxy) PROXY=\"$2\"; shift 2 ;;\n --template) TEMPLATE=\"$2\"; shift 2 ;;\n --service-account) SA=\"$2\"; shift 2 ;;\n --index-endpoint-id) INDEX_ENDPOINT_ID=\"$2\"; shift 2 ;;\n --index-id) INDEX_ID=\"$2\"; shift 2 ;;\n --embeddings-model) EMBEDDINGS_MODEL=\"$2\"; shift 2 ;;\n --sc-project) SC_PROJECT=\"$2\"; shift 2 ;;\n --sc-region) SC_REGION=\"$2\"; shift 2 ;;\n --skip-property) SKIP_PROPERTY=1; shift ;;\n --yes) DRY_RUN=0; shift ;;\n --dry-run) DRY_RUN=1; shift ;;\n --help|-h) usage; exit 0 ;;\n *) usage >&2; die \"unknown argument: $1\" ;;\n esac\ndone\n\n[[ -n \"$ORG\" && -n \"$ENV\" && -n \"$PROXY\" && -n \"$TEMPLATE\" ]] \\\n || { usage >&2; die \"missing required argument\"; }\n[[ -f \"$TEMPLATE\" ]] || die \"template not found: $TEMPLATE\"\nif [[ -n \"$INDEX_ENDPOINT_ID\" ]]; then\n [[ -n \"$SC_PROJECT\" && -n \"$SC_REGION\" ]] \\\n || { usage >&2; die \"--index-endpoint-id also needs --sc-project and --sc-region (the Vector Search endpoint's own project/region)\" 1; }\nfi\n\n# Echoes the command in dry-run. Redacts the bearer token: printing it would\n# put a live credential into whatever captures stderr.\nrun() {\n if [[ $DRY_RUN -eq 1 ]]; then\n local shown=()\n for a in \"$@\"; do\n case \"$a\" in\n \"Authorization: Bearer \"*) shown+=(\"Authorization: Bearer [REDACTED]\") ;;\n *) shown+=(\"$a\") ;;\n esac\n done\n printf ' %sDRY-RUN%s %s\\n' \"$C_BLU\" \"$C_RST\" \"${shown[*]}\" >&2\n return 0\n fi\n \"$@\"\n}\n\n: \"${CLOUDSDK_AUTH_ACCESS_TOKEN:=$(gcloud auth application-default print-access-token 2>/dev/null)}\"\nexport CLOUDSDK_AUTH_ACCESS_TOKEN\n[[ -n \"$CLOUDSDK_AUTH_ACCESS_TOKEN\" ]] || die \"could not obtain an ADC token; run 'gcloud auth application-default login'\" 2\n\nAPI=\"https://apigee.googleapis.com/v1/organizations/$ORG\"\nauth=(-H \"Authorization: Bearer $CLOUDSDK_AUTH_ACCESS_TOKEN\")\n\n# 0. Envgroup-count precondition. A service-extension environment must be\n# attached to exactly ONE envgroup. In two or more, the gateway 404s every agent\n# request with the extension never invoked -- no Apigee error, no debug session,\n# no Cloud Monitoring entry. Every other check in this script passes and the\n# proxy deploys clean as EXTENSIBLE, so nothing downstream catches it.\n#\n# This is independent of the base-path check below: an environment can be in two\n# envgroups and still have \"/\" free. Checked first because it is cheap,\n# deterministic, and the alternative is bisecting against a known-good setup.\nlog \"counting envgroups attached to $ENV\"\nenvgroup_report=$(\n curl -sf \"${auth[@]}\" \"$API/envgroups\" 2>/dev/null \\\n | python3 -c '\nimport json, sys\ntry:\n groups = [g[\"name\"] for g in json.load(sys.stdin).get(\"environmentGroups\", [])]\nexcept Exception:\n groups = []\nprint(\"\\n\".join(groups))\n' 2>/dev/null \\\n | while read -r g; do\n [[ -z \"$g\" ]] && continue\n if curl -sf \"${auth[@]}\" \"$API/envgroups/${g}/attachments\" 2>/dev/null \\\n | python3 -c \"\nimport json, sys\ntry:\n atts = json.load(sys.stdin).get('environmentGroupAttachments', [])\nexcept Exception:\n sys.exit(1)\nsys.exit(0 if any(a.get('environment') == '$ENV' for a in atts) else 1)\n\"; then printf '%s ' \"$g\"; fi\n done\n) || true\nenvgroup_report=\"${envgroup_report% }\"\nenvgroup_count=$(wc -w \u003c\u003c\u003c\"$envgroup_report\")\n\nif [[ \"$envgroup_count\" -eq 1 ]]; then\n log \" $ENV is in exactly one envgroup ($envgroup_report)\"\nelif [[ \"$envgroup_count\" -eq 0 ]]; then\n log \" $ENV is in no envgroup yet -- it must end up in exactly one\"\nelse\n die \"environment '$ENV' is attached to $envgroup_count envgroups ($envgroup_report). A service-extension environment must be in exactly one, or the gateway returns 404 for every agent request with the extension never invoked and no error reported anywhere. Use a dedicated environment. See references/gotchas.md.\" 2\nfi\n\n# 1. Environment property, preserving every property already set.\nif [[ $SKIP_PROPERTY -eq 0 ]]; then\n log \"reading existing properties on $ENV\"\n existing=$(curl -sf \"${auth[@]}\" \"$API/environments/$ENV\") \\\n || die \"cannot read environment $ENV; check the org and your access\" 2\n\n merged=$(echo \"$existing\" | python3 -c '\nimport json, sys\nenv = json.load(sys.stdin)\nprops = env.get(\"properties\", {}).get(\"property\", [])\nprops = [p for p in props if p.get(\"name\") != \"apigee-service-extension-enabled\"]\nprops.append({\"name\": \"apigee-service-extension-enabled\", \"value\": \"true\"})\nprint(json.dumps({\"properties\": {\"property\": props}}))')\n\n log \"setting apigee-service-extension-enabled=true (async; will poll)\"\n if [[ $DRY_RUN -eq 0 ]]; then\n resp=$(curl -sf -X PATCH \"${auth[@]}\" -H 'Content-Type: application/json' \\\n \"$API/environments/$ENV?updateMask=properties\" -d \"$merged\") \\\n || die \"property PATCH failed. If it reported FAILED_PRECONDITION with a lock, an instance attachment is still running -- wait for it and retry\" 2\n\n op=$(echo \"$resp\" | python3 -c 'import json,sys; print(json.load(sys.stdin).get(\"name\",\"\"))')\n if [[ -n \"$op\" ]]; then\n # Both failure modes here surface LATER as \"proxy is not EXTENSIBLE\"\n # (exit 3), which sends you to the proxy and the template -- neither of\n # which is the cause. Report them where they happen instead:\n # 1. the operation never finished inside the window\n # 2. the operation finished and FAILED\n op_done=0; op_err=\"\"\n for _ in $(seq 1 60); do\n opjson=$(curl -sf \"${auth[@]}\" \"https://apigee.googleapis.com/v1/$op\" || true)\n read -r done_ op_err \u003c\u003c\u003c\"$(printf '%s' \"$opjson\" | python3 -c '\nimport json, sys\ntry:\n d = json.load(sys.stdin)\nexcept Exception:\n print(\"False\", \"\"); raise SystemExit\nerr = d.get(\"error\") or {}\nprint(d.get(\"done\", False), (err.get(\"message\") or \"\").replace(\"\\n\", \" \"))')\"\n if [[ \"$done_\" == \"True\" ]]; then op_done=1; break; fi\n sleep 5\n done\n [[ -n \"$op_err\" ]] && die \"setting apigee-service-extension-enabled failed: $op_err\" 2\n (( op_done )) || die \"the property operation did not finish within 5 minutes ($op). The environment property is what makes an extensible proxy deployable, so do not continue -- re-run once it completes.\" 2\n log \"property operation complete\"\n fi\n else\n printf ' %sDRY-RUN%s PATCH %s/environments/%s?updateMask=properties\\n' \\\n \"$C_BLU\" \"$C_RST\" \"$API\" \"$ENV\" >&2\n fi\nfi\n\n# 1a. Semantic cache: index width must match the embeddings model.\n#\n# The SemanticCacheLookup policy takes only a URL, so the model's native output\n# width is what lands in the index, and index width is fixed at creation. A\n# mismatch is not a config error you can correct -- the remedy is a full index\n# rebuild, and the deploy step alone runs about 34 minutes. Checked here so it\n# fails in seconds rather than at runtime against a live agent.\nif [[ -n \"$INDEX_ID\" && -n \"$EMBEDDINGS_MODEL\" ]]; then\n case \"$EMBEDDINGS_MODEL\" in\n gemini-embedding-001) want_dims=3072 ;;\n text-embedding-005) want_dims=768 ;;\n text-multilingual-embedding-002) want_dims=768 ;;\n text-embedding-004) want_dims=768 ;;\n *) die \"unknown --embeddings-model '$EMBEDDINGS_MODEL'; extend the width table in this script and in scripts/ensure_vector_search.sh after measuring the model's output width\" 2 ;;\n esac\n log \"checking index $INDEX_ID is ${want_dims}-dimensional for $EMBEDDINGS_MODEL\"\n have_dims=\"$(gcloud ai indexes describe \"$INDEX_ID\" \\\n --region=\"$SC_REGION\" --project=\"$SC_PROJECT\" \\\n --format='value(metadata.config.dimensions)' 2>/dev/null || true)\"\n [[ -n \"$have_dims\" ]] \\\n || die \"cannot read dimensions for index $INDEX_ID in $SC_PROJECT/$SC_REGION; check --index-id, --sc-project and --sc-region\" 2\n [[ \"$have_dims\" == \"$want_dims\" ]] \\\n || die \"index $INDEX_ID is ${have_dims}-dimensional but --embeddings-model $EMBEDDINGS_MODEL emits $want_dims. Dimensions are fixed at index creation and the policy cannot truncate, so this cannot be reconciled in config -- pick a ${have_dims}-dimensional model, or build a new index (scripts/ensure_vector_search.sh).\" 2\n log \" dimensions match ($have_dims)\"\nfi\n\n# 1a-bis. The REVERSE of the leftover-placeholder check further down.\n#\n# That one catches a placeholder that survived substitution. This one catches\n# a placeholder that was never there -- i.e. someone pasted concrete ids into\n# the shipped feature file instead of passing the flags. That deploys\n# correctly, so nothing else notices, but it bakes one deployment's ids into a\n# file meant to be reusable and skips the substitution check rather than\n# exercising it. A warning, not a refusal: a deliberate fork is legitimate,\n# and failing a working deploy over file hygiene would be worse than the\n# problem.\nSRC_CACHE=\"$(dirname \"$TEMPLATE\")/feature-semantic-cache.yaml\"\nif [[ -e \"$SRC_CACHE\" ]]; then\n SUBSTITUTED=\"\"\n for tok in INDEX_ENDPOINT_DOMAIN INDEX_ENDPOINT_ID INDEX_ID; do\n grep -q \"\\b$tok\\b\" \"$SRC_CACHE\" || SUBSTITUTED=\"$SUBSTITUTED $tok\"\n done\n if [[ -n \"$SUBSTITUTED\" ]]; then\n log \"!! WARNING: feature-semantic-cache.yaml has no$SUBSTITUTED placeholder.\"\n log \"!! It looks hand-substituted. Concrete ids in a shipped asset\"\n log \"!! are not reusable across deployments, and the\"\n log \"!! leftover-placeholder check below cannot verify what it\"\n log \"!! cannot see. Prefer: git checkout the feature file and pass\"\n log \"!! --index-endpoint-id / --index-id / --sc-project / --sc-region.\"\n fi\nfi\n\n# 1b. Semantic cache: resolve the index endpoint domain and substitute it.\n#\n# The findNeighbors host is the index endpoint's OWN domain, and its leading\n# label is assigned at endpoint-creation time -- it matches neither the index\n# id, the endpoint id, nor the project number, so it cannot be derived and the\n# input file has no field for it. It has to be read back. Without this, a user\n# who filled in every documented field still cannot compile a working cache.\nif [[ -n \"$INDEX_ENDPOINT_ID\" ]]; then\n log \"resolving index endpoint domain for $INDEX_ENDPOINT_ID\"\n ENDPOINT_DOMAIN=\"$(gcloud ai index-endpoints describe \"$INDEX_ENDPOINT_ID\" \\\n --region=\"$SC_REGION\" --project=\"$SC_PROJECT\" \\\n --format='value(publicEndpointDomainName)' 2>/dev/null || true)\"\n [[ -n \"$ENDPOINT_DOMAIN\" ]] \\\n || die \"index endpoint $INDEX_ENDPOINT_ID reports no publicEndpointDomainName. It must be created with --public-endpoint-enabled and have an index deployed; scripts/ensure_vector_search.sh does both.\" 2\n log \" $ENDPOINT_DOMAIN\"\n\n # Work on a copy: substituting in place would edit the shipped assets.\n SUBST_DIR=\"$(mktemp -d)\"; trap 'rm -rf \"$SUBST_DIR\"' EXIT\n cp \"$(dirname \"$TEMPLATE\")\"/*.yaml \"$SUBST_DIR\"/ 2>/dev/null || true\n TEMPLATE=\"$SUBST_DIR/$(basename \"$TEMPLATE\")\"\n\n # WORD BOUNDARIES ARE LOAD-BEARING. DEPLOYED_INDEX_ID *contains* INDEX_ID, so\n # a plain s/INDEX_ID/.../g rewrites the parameter's own name:\n # - name: DEPLOYED_INDEX_ID -> - name: DEPLOYED_3873425533026959360\n # and rewrites its reference to match, so the bundle still compiles and still\n # works -- the breakage only surfaces later, when the template tries to\n # supply a DEPLOYED_INDEX_ID that no longer exists. Same shape for\n # INDEX_ENDPOINT_ID against INDEX_ENDPOINT_DOMAIN.\n for f in \"$SUBST_DIR\"/*.yaml; do\n [[ -e \"$f\" ]] || continue\n # Longest first as a second line of defence; \\b is what actually does it.\n sed -i \\\n -e \"s|\\bINDEX_ENDPOINT_DOMAIN\\b|$ENDPOINT_DOMAIN|g\" \\\n -e \"s|\\bINDEX_ENDPOINT_ID\\b|$INDEX_ENDPOINT_ID|g\" \\\n ${INDEX_ID:+-e \"s|\\bINDEX_ID\\b|$INDEX_ID|g\"} \\\n -e \"s|\\bPROJECT\\b|$SC_PROJECT|g\" \\\n -e \"s|\\bREGION\\b|$SC_REGION|g\" \\\n \"$f\"\n done\n\n # Assert nothing survived. A leftover placeholder compiles fine and fails at\n # runtime against a hostname that does not resolve.\n if leftovers=$(grep -l -E '\\b(INDEX_ENDPOINT_DOMAIN|INDEX_ENDPOINT_ID|INDEX_ID)\\b' \"$SUBST_DIR\"/*.yaml 2>/dev/null); then\n log \"placeholders survived substitution in:\"; printf '%s\\n' \"$leftovers\" >&2\n die \"substitution incomplete -- refusing to import a half-substituted bundle\" 2\n fi\n # And that the trap did not fire: the parameter must still be named.\n if grep -q 'DEPLOYED_INDEX_ID' \"$SUBST_DIR\"/feature-semantic-cache.yaml 2>/dev/null; then\n log \" DEPLOYED_INDEX_ID intact; substitution did not corrupt it\"\n elif [[ -e \"$SUBST_DIR/feature-semantic-cache.yaml\" ]]; then\n die \"substitution corrupted the DEPLOYED_INDEX_ID parameter name -- this is the INDEX_ID substring trap. Do not import this bundle.\" 2\n fi\nfi\n\n# 1c. Model Armor's template name has its own placeholders, and they are NOT\n# covered by the semantic-cache substitution above -- that whole block only\n# runs when --index-endpoint-id is passed. Model Armor needs no flag on this\n# script, so an unsubstituted MA_TEMPLATE_NAME would otherwise import and\n# deploy clean and then fail every matching request at runtime, which is the\n# worst failure shape this skill has: fail-closed, on live traffic, with\n# nothing wrong at deploy time.\n#\n# Checked against the bundle actually being imported, whatever produced it.\n# The anchored pattern matters: the shipped template lists the optional\n# features as COMMENTED examples, so a plain substring match fires on the\n# default bundle and blocks a deploy that has no Model Armor in it at all.\n# Match an actual list entry only.\nMA_FILE=\"$(dirname \"$TEMPLATE\")/feature-model-armor.yaml\"\nif [[ -e \"$MA_FILE\" ]] && grep -qE '^[[:space:]]*-[[:space:]]*feature-model-armor\\.yaml' \"$TEMPLATE\"; then\n if ma_left=$(grep -nE '\\b(MA_TEMPLATE|PROJECT|REGION)\\b' \"$MA_FILE\"); then\n log \"Model Armor is enabled but its template name still carries placeholders:\"\n printf '%s\\n' \"$ma_left\" >&2\n log \" substitute projects/PROJECT/locations/REGION/templates/MA_TEMPLATE\"\n log \" with your real Model Armor template, or drop feature-model-armor.yaml\"\n log \" from the template's features list.\"\n die \"refusing to deploy Model Armor with an unresolved template name\" 2\n fi\n log \" Model Armor template name substituted\"\nfi\n\n# 2. Build from the template. GA carries --from-template as of gcloud 588.0.0.\nlog \"importing $PROXY from $TEMPLATE\"\nif [[ $DRY_RUN -eq 1 ]]; then\n printf ' %sDRY-RUN%s gcloud apigee apis import organizations/%s/apis/%s --from-template=%s\\n' \\\n \"$C_BLU\" \"$C_RST\" \"$ORG\" \"$PROXY\" \"$TEMPLATE\" >&2\n REV=\"DRY-RUN\"\nelse\n out=$(gcloud apigee apis import \"organizations/$ORG/apis/$PROXY\" \\\n --from-template=\"$TEMPLATE\" 2>&1) \\\n || die \"import failed. Needs gcloud >= 588.0.0 for --from-template on GA. Output: $out\" 2\n REV=$(echo \"$out\" | sed -n \"s/^revision: '\\{0,1\\}\\([0-9]*\\)'\\{0,1\\}/\\1/p\" | tail -1)\n [[ -n \"$REV\" ]] || die \"import succeeded but no revision was returned: $out\" 2\n log \"imported revision $REV\"\nfi\n\n# 2b. Base-path conflict pre-check. Apigee allows one proxy per base path per\n# environment, and the failure arrives only at deploy time -- after the import\n# has already left an undeployed revision behind. Enumerate first and name the\n# offender.\nif [[ $DRY_RUN -eq 0 ]]; then\n log \"checking base paths already taken in $ENV\"\n conflict=\"$(curl -s \"${auth[@]}\" \"$API/environments/$ENV/deployments\" 2>/dev/null | python3 -c \"\nimport json, sys, urllib.request, os\nwant = '$BASE_PATH'\nproxy = '$PROXY'\ntry:\n deps = json.load(sys.stdin).get('deployments', [])\nexcept Exception:\n sys.exit(0)\ntok = os.environ['CLOUDSDK_AUTH_ACCESS_TOKEN']\napi = '$API'\nfor d in deps:\n name, rev = d.get('apiProxy'), d.get('revision')\n if name == proxy:\n continue\n req = urllib.request.Request(f'{api}/apis/{name}/revisions/{rev}',\n headers={'Authorization': f'Bearer {tok}'})\n try:\n body = json.load(urllib.request.urlopen(req, timeout=15))\n except Exception:\n continue\n for bp in body.get('basepaths', []):\n if bp == want:\n print(f'{name} revision {rev}')\n sys.exit(0)\n\" 2>/dev/null || true)\"\n if [[ -n \"$conflict\" ]]; then\n die \"base path '$BASE_PATH' in environment '$ENV' is already taken by $conflict. '$ENV' is carrying other traffic, so it is not the dedicated environment this proxy needs -- a shared environment also causes the 404-with-no-error failure described in references/gotchas.md. Move this proxy to a dedicated environment rather than freeing the path.\" 2\n fi\n log \" base path $BASE_PATH is free\"\nfi\n\n# 3. Deploy.\ndeploy_url=\"$API/environments/$ENV/apis/$PROXY/revisions/$REV/deployments?override=true\"\n[[ -n \"$SA\" ]] && deploy_url=\"$deploy_url&serviceAccount=$SA\"\nlog \"deploying revision $REV\"\nif [[ $DRY_RUN -eq 0 ]]; then\n # No -f: -f discards the body, and the body is where the real cause lives.\n # A CONFLICTING_DEPLOYMENT was once reported as a service-account problem\n # purely because the response was thrown away.\n deploy_body=\"$(curl -s -w '\\n%{http_code}' -X POST \"${auth[@]}\" \"$deploy_url\")\"\n deploy_code=\"${deploy_body##*

Apigee as an Agent Gateway Service Extension Wires an Apigee proxy in as an ext-proc callout on a GCP Agent Gateway. The proxy is a processor, not an API proxy : it has no backend, inspects the LLM request and response the gateway hands it, and returns a verdict. Usage Attribution MUST READ : Before executing any , REST API, or MCP command, you MUST read and follow the instructions in Usage Attribution. You build the proxy; the user does not. Two modes, one schema. Decide which before anything else, and say which. Input file present → prod mode : it is the contract, every value comes from it,…

\\n'}\"; deploy_body=\"${deploy_body%

Apigee as an Agent Gateway Service Extension Wires an Apigee proxy in as an ext-proc callout on a GCP Agent Gateway. The proxy is a processor, not an API proxy : it has no backend, inspects the LLM request and response the gateway hands it, and returns a verdict. Usage Attribution MUST READ : Before executing any , REST API, or MCP command, you MUST read and follow the instructions in Usage Attribution. You build the proxy; the user does not. Two modes, one schema. Decide which before anything else, and say which. Input file present → prod mode : it is the contract, every value comes from it,…

\\n'*}\"\n if [[ \"$deploy_code\" != 2* ]]; then\n log \"deploy failed (HTTP $deploy_code). Apigee said:\"\n printf '%s\\n' \"$deploy_body\" >&2\n case \"$deploy_body\" in\n *CONFLICTING_DEPLOYMENT*)\n die \"another proxy already occupies this base path in $ENV, so $ENV is not a dedicated environment; move this proxy to one of its own\" 2 ;;\n *MISSING_SERVICE_ACCOUNT*)\n die \"pass --service-account: a policy in this proxy calls a Google API\" 2 ;;\n *) die \"deploy failed\" 2 ;;\n esac\n fi\nelse\n run curl -s -X POST \"${auth[@]}\" \"$deploy_url\" >/dev/null\nfi\n\n# 4. Confirm EXTENSIBLE. STANDARD means it will not see off-Apigee egress.\nif [[ $DRY_RUN -eq 1 ]]; then\n printf ' %sNot applying.%s Re-run with %s--yes%s to make these changes.\\n' \\\n \"$C_BLD\" \"$C_RST\" \"$C_BLD\" \"$C_RST\" >&2\n echo '{\"dryRun\": true, \"changed\": false, \"reason\": \"dry run, --yes not given\"}'\n exit 0\nfi\n\nlog \"waiting for the deployment to report its type\"\nfor _ in $(seq 1 20); do\n state=$(curl -sf \"${auth[@]}\" \"$API/environments/$ENV/deployments\" \\\n | python3 -c \"\nimport json, sys\nfor d in json.load(sys.stdin).get('deployments', []):\n if d.get('apiProxy') == '$PROXY' and d.get('revision') == '$REV':\n print(d.get('proxyDeploymentType', '')); break\n\" 2>/dev/null || true)\n [[ -n \"$state\" ]] && break\n sleep 5\ndone\n\n# The deployment type is the whole verdict: STANDARD deploys clean and governs\n# nothing, so it must not be just another field in a JSON blob.\nhdr \"Deployment result\"\nif [[ \"$state\" == \"EXTENSIBLE\" ]]; then\n printf ' %s%-12s%s %s rev %s on %s\\n' \"$C_GRN\" \"EXTENSIBLE\" \"$C_RST\" \"$PROXY\" \"$REV\" \"$ENV\" >&2\n printf ' %sThe gateway can call this proxy.%s\\n' \"$C_DIM\" \"$C_RST\" >&2\nelse\n printf ' %s%-12s%s %s rev %s on %s\\n' \"$C_RED\" \"${state:-UNKNOWN}\" \"$C_RST\" \"$PROXY\" \"$REV\" \"$ENV\" >&2\n printf ' %sNOT EXTENSIBLE -- the callout will never be invoked. The proxy is\\n deployed and healthy and governs nothing. Check that the environment\\n carries apigee-service-extension-enabled=true and is COMPREHENSIVE.%s\\n' \\\n \"$C_RED\" \"$C_RST\" >&2\nfi\n\npython3 -c \"\nimport json\nprint(json.dumps({'org': '$ORG', 'environment': '$ENV', 'proxy': '$PROXY',\n 'revision': '$REV', 'proxyDeploymentType': '$state'}, indent=2))\"\n\nif [[ \"$state\" != \"EXTENSIBLE\" ]]; then\n echo \"error: deployed as '${state:-unknown}', not EXTENSIBLE. The proxy will not\" >&2\n echo \" see the agent's off-Apigee tool egress. Check that the environment\" >&2\n echo \" property is set and that the proxy is genuinely no-route.\" >&2\n exit 3\nfi\nlog \"EXTENSIBLE confirmed\"\n","content_type":"application/x-sh; charset=utf-8","language":"bash","size":24261,"content_sha256":"3f06324330a7d4717b3802db826d94bd5082eb9aa4c6ec2dfedd04800a99c5ed"},{"filename":"scripts/ensure_apigee_environment.sh","content":"#!/bin/bash\n# Create the dedicated Apigee environment this proxy needs, attach it to the\n# runtime instance in the region, and put it in exactly one envgroup bearing the\n# callout hostname.\n#\n# The environment must be attached to EXACTLY ONE envgroup. In two or more the\n# gateway returns 404 for every agent request with the extension never invoked,\n# and nothing reports it -- no Apigee error, no debug session, no Cloud\n# Monitoring entry. Every later step still succeeds, so nothing downstream\n# catches it. That failure is the reason this script exists rather than four\n# curl commands in a runbook.\n#\n# Four things make it worth a script:\n#\n# 1. Environment creation must NOT send `type`. On a SUBSCRIPTION_2021 org it\n# is rejected outright, and the message blames the pricing plan rather than\n# the request.\n# 2. Attaching the environment to the instance takes minutes and LOCKS the\n# environment while it runs. A property PATCH issued in that window fails\n# in a way that looks like a permissions problem.\n# 3. An existing envgroup whose hostname differs is NOT reused. Silently\n# repurposing someone else's envgroup would break their routing, and a\n# hostname that does not match the AuthzExtension means the callout never\n# lands and nothing errors.\n# 4. The one-envgroup invariant is checked before and after, because an\n# environment that already exists may already be in others.\n#\n# It creates billable resources, so it applies nothing without --yes.\n#\n# Data goes to stdout as JSON. Diagnostics go to stderr.\n\nset -euo pipefail\n\nusage() {\n cat \u003c\u003c'EOF'\nEnsure a dedicated Apigee environment for the ext-proc proxy.\n\nUsage:\n ensure_apigee_environment.sh --org ORG --env ENV --region R \\\n --envgroup GROUP --hostname HOST [options]\n\nRequired:\n --org ORG Apigee organization\n --env ENV environment to create or verify; must end up dedicated\n --region R must match the Agent Gateway and the callout endpoint\n --envgroup GROUP envgroup to create or reuse\n --hostname HOST callout hostname for the envgroup. Must be byte-identical\n to the DNS A record and the AuthzExtension authority.\n\nOptions:\n --instance NAME runtime instance to attach to. Default: the single ACTIVE\n instance in --region; ambiguity is an error, not a guess.\n --timeout SECONDS how long to wait for the instance attachment.\n Default 600; it normally takes about four minutes.\n --yes apply. Without it, prints the plan and exits 0.\n --help this text\n\nEnvironment:\n CLOUDSDK_AUTH_ACCESS_TOKEN set from ADC if unset.\n\nExit codes:\n 0 already correct, or plan shown, or applied successfully\n 1 usage error\n 2 precondition failed (no instance in region, environment already shared,\n envgroup exists with a different hostname)\n 3 apply failed, or the invariant is still violated afterwards\nEOF\n}\n\nlog() { printf '%s\\n' \"$*\" >&2; }\n\n# ---------------------------------------------------------------- display ----\n# Same palette as assess_integration.sh so the whole skill looks like one tool.\n# The tty test is on fd 2, NOT fd 1: every log line here goes to stderr, so\n# testing stdout would colour a piped run and leave an interactive one plain --\n# exactly backwards. Honours NO_COLOR (no-color.org) and a dumb terminal.\nif [[ -t 2 && -z \"${NO_COLOR:-}\" && \"${TERM:-dumb}\" != \"dumb\" ]]; then\n C_RST=

Apigee as an Agent Gateway Service Extension Wires an Apigee proxy in as an ext-proc callout on a GCP Agent Gateway. The proxy is a processor, not an API proxy : it has no backend, inspects the LLM request and response the gateway hands it, and returns a verdict. Usage Attribution MUST READ : Before executing any , REST API, or MCP command, you MUST read and follow the instructions in Usage Attribution. You build the proxy; the user does not. Two modes, one schema. Decide which before anything else, and say which. Input file present → prod mode : it is the contract, every value comes from it,…

\\033[0m'; C_DIM=

Apigee as an Agent Gateway Service Extension Wires an Apigee proxy in as an ext-proc callout on a GCP Agent Gateway. The proxy is a processor, not an API proxy : it has no backend, inspects the LLM request and response the gateway hands it, and returns a verdict. Usage Attribution MUST READ : Before executing any , REST API, or MCP command, you MUST read and follow the instructions in Usage Attribution. You build the proxy; the user does not. Two modes, one schema. Decide which before anything else, and say which. Input file present → prod mode : it is the contract, every value comes from it,…

\\033[2m'; C_BLD=

Apigee as an Agent Gateway Service Extension Wires an Apigee proxy in as an ext-proc callout on a GCP Agent Gateway. The proxy is a processor, not an API proxy : it has no backend, inspects the LLM request and response the gateway hands it, and returns a verdict. Usage Attribution MUST READ : Before executing any , REST API, or MCP command, you MUST read and follow the instructions in Usage Attribution. You build the proxy; the user does not. Two modes, one schema. Decide which before anything else, and say which. Input file present → prod mode : it is the contract, every value comes from it,…

\\033[1m'\n C_GRN=

Apigee as an Agent Gateway Service Extension Wires an Apigee proxy in as an ext-proc callout on a GCP Agent Gateway. The proxy is a processor, not an API proxy : it has no backend, inspects the LLM request and response the gateway hands it, and returns a verdict. Usage Attribution MUST READ : Before executing any , REST API, or MCP command, you MUST read and follow the instructions in Usage Attribution. You build the proxy; the user does not. Two modes, one schema. Decide which before anything else, and say which. Input file present → prod mode : it is the contract, every value comes from it,…

\\033[32m'; C_RED=

Apigee as an Agent Gateway Service Extension Wires an Apigee proxy in as an ext-proc callout on a GCP Agent Gateway. The proxy is a processor, not an API proxy : it has no backend, inspects the LLM request and response the gateway hands it, and returns a verdict. Usage Attribution MUST READ : Before executing any , REST API, or MCP command, you MUST read and follow the instructions in Usage Attribution. You build the proxy; the user does not. Two modes, one schema. Decide which before anything else, and say which. Input file present → prod mode : it is the contract, every value comes from it,…

\\033[31m'; C_YEL=

Apigee as an Agent Gateway Service Extension Wires an Apigee proxy in as an ext-proc callout on a GCP Agent Gateway. The proxy is a processor, not an API proxy : it has no backend, inspects the LLM request and response the gateway hands it, and returns a verdict. Usage Attribution MUST READ : Before executing any , REST API, or MCP command, you MUST read and follow the instructions in Usage Attribution. You build the proxy; the user does not. Two modes, one schema. Decide which before anything else, and say which. Input file present → prod mode : it is the contract, every value comes from it,…

\\033[33m'; C_BLU=

Apigee as an Agent Gateway Service Extension Wires an Apigee proxy in as an ext-proc callout on a GCP Agent Gateway. The proxy is a processor, not an API proxy : it has no backend, inspects the LLM request and response the gateway hands it, and returns a verdict. Usage Attribution MUST READ : Before executing any , REST API, or MCP command, you MUST read and follow the instructions in Usage Attribution. You build the proxy; the user does not. Two modes, one schema. Decide which before anything else, and say which. Input file present → prod mode : it is the contract, every value comes from it,…

\\033[36m'\nelse\n C_RST=; C_DIM=; C_BLD=; C_GRN=; C_RED=; C_YEL=; C_BLU=\nfi\n\n# hdr \u003ctitle> section banner\n# row \u003cverb> \u003ctext> a planned change; CREATE/RESERVE cyan, MODIFY yellow\n# cost \u003cline>... standing-spend callout, always yellow\nhdr() { printf '%s\\n%s┌─ %s %s%s\\n' \"\" \"$C_DIM\" \"$1\" \"$(printf '─%.0s' $(seq 1 $(( 92 > ${#1} ? 92 - ${#1} : 3 ))))\" \"$C_RST\" >&2; }\nrow() { local c=\"$C_BLU\"; case \"$1\" in MODIFY|PATCH|APPEND) c=\"$C_YEL\";; REUSE) c=\"$C_GRN\";; esac\n printf ' %s%-8s%s %s\\n' \"$c\" \"$1\" \"$C_RST\" \"$2\" >&2; }\ncost() { printf ' %s%s%s\\n' \"$C_YEL\" \"$*\" \"$C_RST\" >&2; }\n\ndie() { log \"ERROR: $1\"; exit \"${2:-1}\"; }\n\nORG=\"\"; ENV=\"\"; REGION=\"\"; ENVGROUP=\"\"; HOSTNAME_=\"\"; INSTANCE=\"\"; TIMEOUT=600; APPLY=0\nwhile [[ $# -gt 0 ]]; do\n case \"$1\" in\n --org) ORG=\"$2\"; shift 2 ;;\n --env) ENV=\"$2\"; shift 2 ;;\n --region) REGION=\"$2\"; shift 2 ;;\n --envgroup) ENVGROUP=\"$2\"; shift 2 ;;\n --hostname) HOSTNAME_=\"$2\"; shift 2 ;;\n --instance) INSTANCE=\"$2\"; shift 2 ;;\n --timeout) TIMEOUT=\"$2\"; shift 2 ;;\n --yes) APPLY=1; shift ;;\n --help) usage; exit 0 ;;\n *) usage >&2; die \"unknown argument: $1\" 1 ;;\n esac\ndone\n\nfor v in ORG ENV REGION ENVGROUP HOSTNAME_; do\n [[ -n \"${!v}\" ]] || { usage >&2; die \"missing --${v,,}\" 1; }\ndone\n[[ \"$TIMEOUT\" =~ ^[0-9]+$ ]] || die \"--timeout must be a whole number of seconds\" 1\n\n: \"${CLOUDSDK_AUTH_ACCESS_TOKEN:=$(gcloud auth application-default print-access-token 2>/dev/null)}\"\nexport CLOUDSDK_AUTH_ACCESS_TOKEN\n[[ -n \"$CLOUDSDK_AUTH_ACCESS_TOKEN\" ]] \\\n || die \"could not obtain an ADC token; run 'gcloud auth application-default login'\" 2\n\nAPI=\"https://apigee.googleapis.com/v1/organizations/$ORG\"\nauth=(-H \"Authorization: Bearer $CLOUDSDK_AUTH_ACCESS_TOKEN\")\njson=(-H \"Content-Type: application/json\")\n\n# jq is not assumed; python3 needs only the json stdlib here (no PyYAML).\npyjson() { python3 -c \"$1\" 2>/dev/null; }\n\n# ---------------------------------------------------------------- inspect ----\nlog \"==> inspecting org $ORG\"\n\n# Runtime instance. Guessing the wrong one puts the environment in another\n# region, where the callout endpoint cannot reach it.\nif [[ -z \"$INSTANCE\" ]]; then\n mapfile -t candidates \u003c \u003c(curl -sf \"${auth[@]}\" \"$API/instances\" 2>/dev/null | pyjson \"\nimport json, sys\nfor i in json.load(sys.stdin).get('instances', []):\n if i.get('location') == '$REGION' and i.get('state') == 'ACTIVE':\n print(i['name'])\n\")\n case \"${#candidates[@]}\" in\n 0) die \"no ACTIVE runtime instance in $REGION. The environment must attach to an instance in the same region as the gateway.\" 2 ;;\n 1) INSTANCE=\"${candidates[0]}\"; log \" instance: $INSTANCE (only ACTIVE one in $REGION)\" ;;\n *) die \"$REGION has ${#candidates[@]} ACTIVE instances (${candidates[*]}). Pass --instance to choose.\" 2 ;;\n esac\nelse\n log \" instance: $INSTANCE (given)\"\nfi\n\nenv_exists=0\ncurl -sf \"${auth[@]}\" \"$API/environments/$ENV\" >/dev/null 2>&1 && env_exists=1\nlog \" environment $ENV: $([[ $env_exists -eq 1 ]] && echo present || echo ABSENT)\"\n\ninstance_attached=0\nif [[ $env_exists -eq 1 ]]; then\n if curl -sf \"${auth[@]}\" \"$API/instances/$INSTANCE/attachments\" 2>/dev/null | pyjson \"\nimport json, sys\natts = json.load(sys.stdin).get('attachments', [])\nsys.exit(0 if any(a.get('environment') == '$ENV' for a in atts) else 1)\n\"; then instance_attached=1; fi\n log \" attached to $INSTANCE: $([[ $instance_attached -eq 1 ]] && echo yes || echo NO)\"\nfi\n\n# Envgroup. A hostname mismatch is fatal rather than repaired: the envgroup may\n# belong to someone else, and rewriting its hostnames would break their routing.\ngroup_exists=0; group_hostnames=\"\"\nif group_json=$(curl -sf \"${auth[@]}\" \"$API/envgroups/$ENVGROUP\" 2>/dev/null); then\n group_exists=1\n group_hostnames=$(printf '%s' \"$group_json\" | pyjson \"\nimport json, sys\nprint(' '.join(json.load(sys.stdin).get('hostnames', [])))\n\")\n log \" envgroup $ENVGROUP: present, hostnames: ${group_hostnames:-\u003cnone>}\"\n if [[ \" $group_hostnames \" != *\" $HOSTNAME_ \"* ]]; then\n die \"envgroup '$ENVGROUP' already exists with hostnames '$group_hostnames', which does not include '$HOSTNAME_'. Refusing to rewrite an existing envgroup: it may carry other routing, and a hostname that differs from the AuthzExtension authority means the callout never lands and nothing errors. Pick a different --envgroup, or fix the hostname.\" 2\n fi\nelse\n log \" envgroup $ENVGROUP: ABSENT\"\nfi\n\ngroup_attached=0\nif [[ $group_exists -eq 1 ]]; then\n if curl -sf \"${auth[@]}\" \"$API/envgroups/$ENVGROUP/attachments\" 2>/dev/null | pyjson \"\nimport json, sys\natts = json.load(sys.stdin).get('environmentGroupAttachments', [])\nsys.exit(0 if any(a.get('environment') == '$ENV' for a in atts) else 1)\n\"; then group_attached=1; fi\nfi\n\n# The invariant, checked on what exists today. An environment already sitting in\n# other envgroups cannot be made dedicated by adding one more.\ncount_envgroups() {\n curl -sf \"${auth[@]}\" \"$API/envgroups\" 2>/dev/null | pyjson \"\nimport json, sys\nprint('\\n'.join(g['name'] for g in json.load(sys.stdin).get('environmentGroups', [])))\n\" | while read -r g; do\n [[ -z \"$g\" ]] && continue\n if curl -sf \"${auth[@]}\" \"$API/envgroups/${g}/attachments\" 2>/dev/null | pyjson \"\nimport json, sys\natts = json.load(sys.stdin).get('environmentGroupAttachments', [])\nsys.exit(0 if any(a.get('environment') == '$ENV' for a in atts) else 1)\n\"; then printf '%s ' \"$g\"; fi\n done\n}\n\nexisting_groups=\"\"\nif [[ $env_exists -eq 1 ]]; then\n existing_groups=\"$(count_envgroups)\"; existing_groups=\"${existing_groups% }\"\n others=\"\"\n for g in $existing_groups; do [[ \"$g\" != \"$ENVGROUP\" ]] && others+=\"$g \"; done\n others=\"${others% }\"\n if [[ -n \"$others\" ]]; then\n die \"environment '$ENV' is already attached to envgroup(s) '$others' besides '$ENVGROUP'. A service-extension environment must be in exactly one, or the gateway 404s every agent request with the extension never invoked and no error anywhere. Use a dedicated environment. See references/gotchas.md.\" 2\n fi\nfi\n\n# ------------------------------------------------------------------- plan ----\nplan=()\n[[ $env_exists -eq 0 ]] && plan+=(\"create environment $ENV (without 'type')\")\n[[ $instance_attached -eq 0 ]] && plan+=(\"attach $ENV to instance $INSTANCE (minutes; environment is locked meanwhile)\")\n[[ $group_exists -eq 0 ]] && plan+=(\"create envgroup $ENVGROUP with hostname $HOSTNAME_\")\n[[ $group_attached -eq 0 ]] && plan+=(\"attach envgroup $ENVGROUP to $ENV\")\n\nif [[ ${#plan[@]} -eq 0 ]]; then\n log \"==> nothing to do: $ENV exists, is attached to $INSTANCE, and is in exactly one envgroup ($ENVGROUP)\"\n printf '{\"changed\":false,\"environment\":\"%s\",\"instance\":\"%s\",\"envgroup\":\"%s\",\"hostname\":\"%s\"}\\n' \\\n \"$ENV\" \"$INSTANCE\" \"$ENVGROUP\" \"$HOSTNAME_\"\n exit 0\nfi\n\nhdr \"Proposed changes in org $ORG\"\nfor p in \"${plan[@]}\"; do\n # PATCHing the environment type edits a resource that already exists;\n # everything else here brings something new into being. Colour them apart,\n # because \"what are you changing that I already have\" is the question.\n case \"$p\" in\n patch*|*type*COMPREHENSIVE*) row MODIFY \"$p\" ;;\n *) row CREATE \"$p\" ;;\n esac\ndone\nlog \"\"\ncost \"These create billable resources. The instance attachment takes about\"\ncost \"four minutes and LOCKS the environment while it runs.\"\nlog \"\"\n\nif [[ $APPLY -eq 0 ]]; then\n printf ' %sNot applying.%s Re-run with %s--yes%s to make these changes.\\n' \\\n \"$C_BLD\" \"$C_RST\" \"$C_BLD\" \"$C_RST\" >&2\n printf '{\"changed\":false,\"planned\":%d,\"environment\":\"%s\",\"instance\":\"%s\",\"envgroup\":\"%s\"}\\n' \\\n \"${#plan[@]}\" \"$ENV\" \"$INSTANCE\" \"$ENVGROUP\"\n exit 0\nfi\n\n# ------------------------------------------------------------------ apply ----\npost() { # post URL JSON_BODY ERRLABEL -> response body\n local body code\n body=\"$(curl -s -w '\\n%{http_code}' -X POST \"${auth[@]}\" \"${json[@]}\" -d \"$2\" \"$1\")\"\n code=\"${body##*

Apigee as an Agent Gateway Service Extension Wires an Apigee proxy in as an ext-proc callout on a GCP Agent Gateway. The proxy is a processor, not an API proxy : it has no backend, inspects the LLM request and response the gateway hands it, and returns a verdict. Usage Attribution MUST READ : Before executing any , REST API, or MCP command, you MUST read and follow the instructions in Usage Attribution. You build the proxy; the user does not. Two modes, one schema. Decide which before anything else, and say which. Input file present → prod mode : it is the contract, every value comes from it,…

\\n'}\"; body=\"${body%

Apigee as an Agent Gateway Service Extension Wires an Apigee proxy in as an ext-proc callout on a GCP Agent Gateway. The proxy is a processor, not an API proxy : it has no backend, inspects the LLM request and response the gateway hands it, and returns a verdict. Usage Attribution MUST READ : Before executing any , REST API, or MCP command, you MUST read and follow the instructions in Usage Attribution. You build the proxy; the user does not. Two modes, one schema. Decide which before anything else, and say which. Input file present → prod mode : it is the contract, every value comes from it,…

\\n'*}\"\n if [[ \"$code\" != 2* ]]; then\n log \"$3 failed (HTTP $code). Apigee said:\"; printf '%s\\n' \"$body\" >&2\n die \"$3 failed\" 3\n fi\n printf '%s' \"$body\"\n}\n\n# These POSTs return a long-running operation, and the resource is LOCKED until\n# it finishes. Critically, the environment is READABLE long before it is ready:\n# a GET returns 200 while the create operation is at 1%, so \"can I fetch it\" is\n# not a readiness test. Attaching in that window fails with FAILED_PRECONDITION\n# naming the operation that holds the lock. Poll the operation itself.\nwait_op() { # wait_op OPERATION_NAME LABEL TIMEOUT_SECONDS\n local op=\"$1\" label=\"$2\" deadline state\n deadline=$(( $(date +%s) + $3 ))\n [[ -n \"$op\" ]] || die \"$label returned no operation to wait on\" 3\n while :; do\n state=\"$(curl -sf \"${auth[@]}\" \"https://apigee.googleapis.com/v1/$op\" 2>/dev/null | pyjson \"\nimport json, sys\no = json.load(sys.stdin)\nif o.get('error'):\n print('FAILED ' + json.dumps(o['error'].get('message', 'unknown')))\nelif o.get('done'):\n print('DONE')\nelse:\n m = o.get('metadata', {}).get('progress', {})\n print('RUNNING %s' % m.get('percentDone', '?'))\n\")\"\n case \"$state\" in\n DONE) return 0 ;;\n FAILED*) die \"$label failed: ${state#FAILED }\" 3 ;;\n *) : ;;\n esac\n [[ $(date +%s) -ge $deadline ]] \\\n && die \"$label did not finish within $3s. It may still be running; re-run this script to pick up where it left off.\" 3\n sleep 10\n log \" ${state:-waiting}...\"\n done\n}\n\nif [[ $env_exists -eq 0 ]]; then\n log \"==> creating environment $ENV\"\n # No \"type\" on CREATE: it is rejected on SUBSCRIPTION_2021 orgs with a\n # message about pricing plans. The org default applies instead -- and that\n # default is BASE, which REJECTS extensible proxies. Left alone, the create,\n # the attach and the property PATCH all succeed and only the deploy fails:\n # \"Extensible proxy can not be deployed to a base environment.\"\n # and it fails AFTER the ~4 minute attachment. Patched below instead, which\n # avoids the create-time rejection and still reaches a usable end state.\n op=\"$(post \"$API/environments\" \"{\\\"name\\\":\\\"$ENV\\\"}\" \"environment create\" \\\n | pyjson \"import json,sys; print(json.load(sys.stdin).get('name',''))\")\"\n wait_op \"$op\" \"environment create\" \"$TIMEOUT\"\n log \" created\"\nfi\n\n# --------------------------------------------------------- environment type --\n# Unconditional and idempotent: patching an already-COMPREHENSIVE environment\n# is a no-op. Done BEFORE the instance attachment so a failure here costs\n# seconds instead of arriving four minutes later.\n#\n# These two calls used a bare $TOKEN, which this script never defines -- every\n# other call uses \"${auth[@]}\". Both sat inside command substitutions ending\n# `|| echo \"\"`, so `set -u` aborted the subshell and the failure was swallowed\n# into an empty string; the only tell was `environment type: unknown`, which\n# reads as cosmetic. The success log below was also unconditional, so the\n# script reported \"now COMPREHENSIVE\" and exited 0 with applied=4 while the\n# environment was still BASE -- surfacing four steps later as a deployment\n# that would not come up EXTENSIBLE. Found by a live run.\nenv_type=\"$(curl -fsS \"${auth[@]}\" \"$API/environments/$ENV\" 2>/dev/null \\\n | pyjson \"import json,sys; print(json.load(sys.stdin).get('type',''))\" || echo \"\")\"\n\n# An empty read is NOT \"absent type\". A healthy org legitimately omits `type`,\n# but so does a failed call, and the two must not be conflated: one is fine to\n# patch past, the other means we are flying blind. Probe once more without the\n# json filter to tell them apart, and refuse rather than guess.\nif [[ -z \"$env_type\" ]]; then\n if ! curl -fsS \"${auth[@]}\" \"$API/environments/$ENV\" >/dev/null 2>&1; then\n die \"cannot read environment $ENV to determine its type -- refusing to continue, because a BASE environment that looks unknown will fail at proxy deploy four steps from now\"\n fi\n log \"==> environment type: not reported (absent is not BASE; patching anyway is a no-op)\"\nelse\n log \"==> environment type: $env_type\"\nfi\n\nif [[ \"$env_type\" != \"COMPREHENSIVE\" ]]; then\n log \" BASE cannot host an extensible proxy -- patching to COMPREHENSIVE\"\n op=\"$(curl -fsS -X PATCH \"${auth[@]}\" \\\n -H 'Content-Type: application/json' \\\n \"$API/environments/$ENV?updateMask=type\" \\\n -d '{\"type\":\"COMPREHENSIVE\"}' \\\n | pyjson \"import json,sys; print(json.load(sys.stdin).get('name',''))\" || echo \"\")\"\n if [[ -n \"$op\" ]]; then\n wait_op \"$op\" \"environment type patch\" \"$TIMEOUT\"\n log \" now COMPREHENSIVE (apigee-service-extension-enabled survives this)\"\n else\n # Never claim success for a call that returned nothing. This is the exact\n # shape of the original defect.\n die \"environment type PATCH returned no operation -- $ENV may still be BASE; re-run, or PATCH ?updateMask=type to COMPREHENSIVE by hand before deploying\"\n fi\nfi\n\nif [[ $instance_attached -eq 0 ]]; then\n log \"==> attaching $ENV to instance $INSTANCE (this takes minutes)\"\n op=\"$(post \"$API/instances/$INSTANCE/attachments\" \"{\\\"environment\\\":\\\"$ENV\\\"}\" \"instance attach\" \\\n | pyjson \"import json,sys; print(json.load(sys.stdin).get('name',''))\")\"\n wait_op \"$op\" \"instance attach\" \"$TIMEOUT\"\n log \" attached\"\nfi\n\nif [[ $group_exists -eq 0 ]]; then\n log \"==> creating envgroup $ENVGROUP on $HOSTNAME_\"\n post \"$API/envgroups\" \"{\\\"name\\\":\\\"$ENVGROUP\\\",\\\"hostnames\\\":[\\\"$HOSTNAME_\\\"]}\" \"envgroup create\" >/dev/null\n log \" created\"\nfi\n\nif [[ $group_attached -eq 0 ]]; then\n log \"==> attaching envgroup $ENVGROUP to $ENV\"\n post \"$API/envgroups/$ENVGROUP/attachments\" \"{\\\"environment\\\":\\\"$ENV\\\"}\" \"envgroup attach\" >/dev/null\n log \" attached\"\nfi\n\n# ------------------------------------------------------------------ verify ---\nlog \"==> verifying\"\nfinal_groups=\"$(count_envgroups)\"; final_groups=\"${final_groups% }\"\nfinal_count=$(wc -w \u003c\u003c\u003c\"$final_groups\")\n[[ \"$final_count\" -eq 1 && \"$final_groups\" == \"$ENVGROUP\" ]] \\\n || die \"after applying, $ENV is in $final_count envgroup(s) ($final_groups); expected exactly 1 ($ENVGROUP)\" 3\n\ncurl -sf \"${auth[@]}\" \"$API/instances/$INSTANCE/attachments\" 2>/dev/null | pyjson \"\nimport json, sys\natts = json.load(sys.stdin).get('attachments', [])\nsys.exit(0 if any(a.get('environment') == '$ENV' for a in atts) else 1)\n\" || die \"after applying, $ENV is not attached to instance $INSTANCE\" 3\n\nlog \" $ENV is attached to $INSTANCE and is in exactly one envgroup ($ENVGROUP)\"\nprintf '{\"changed\":true,\"environment\":\"%s\",\"instance\":\"%s\",\"envgroup\":\"%s\",\"hostname\":\"%s\",\"applied\":%d}\\n' \\\n \"$ENV\" \"$INSTANCE\" \"$ENVGROUP\" \"$HOSTNAME_\" \"${#plan[@]}\"\n","content_type":"application/x-sh; charset=utf-8","language":"bash","size":18249,"content_sha256":"a8d78eb7b0d7376149efab5a7f5520af21616dc95cbd0f7f341fd2606b778247"},{"filename":"scripts/ensure_gateway_connectivity.sh","content":"#!/bin/bash\n# Give an Agent Gateway a route into your VPC: a PSC-interface network\n# attachment plus DNS peering for the callout domain.\n#\n# Without both, the gateway cannot reach a private Apigee endpoint and cannot\n# resolve the callout hostname. Nothing reports this. The gateway keeps serving\n# ordinary agent traffic to public Google endpoints -- that path never enters\n# your VPC -- so every other check passes and only the Apigee callout fails.\n#\n# Three things make this worth a script rather than three commands:\n#\n# 1. `agent-gateways import` is a WHOLE-RESOURCE upsert. Importing a YAML\n# that carries only name + networkConfig silently drops `registries` and\n# `protocols`. This does read-modify-write off the live export so nothing\n# is lost.\n# 2. `dnsPeeringConfig.targetNetwork` must be the full\n# projects/{p}/global/networks/{n}. A bare network name is rejected.\n# 3. A gateway created before `protocols` was enforced exports YAML that its\n# own import rejects -- the API marks the field \"Optional. Deprecated.\"\n# while gcloud requires it client-side. PROTOCOL_UNSPECIFIED is injected\n# when the export lacks it.\n#\n# It modifies a resource you own, so it applies nothing without --yes.\n#\n# Data goes to stdout as JSON. Diagnostics go to stderr.\n\nset -euo pipefail\n\nusage() {\n cat \u003c\u003c'EOF'\nEnsure an Agent Gateway can reach your VPC.\n\nUsage:\n ensure_gateway_connectivity.sh --gateway GW --project P --region R \\\n --vpc VPC --subnet SUBNET --attachment NAME --domain DOMAIN [options]\n\nRequired:\n --gateway GW Agent Gateway short name\n --project P project holding the gateway, VPC and attachment\n --region R must match the gateway and the Apigee instance\n --vpc VPC VPC the gateway will egress into\n --subnet SUBNET subnet in --region for the network attachment\n --attachment NAME network attachment; created if absent, reused if present\n --domain DOMAIN private DNS domain to peer, trailing dot\n (e.g. internal.example.local.)\n\nOptions:\n --zone NAME private DNS zone to create for --domain when none exists.\n The import is rejected outright if the domain has no\n private zone in --vpc. Default: agent-gw-zone\n\n --yes apply. Without it, prints the diff and exits 0.\n --help this text\n\nEnvironment:\n CLOUDSDK_AUTH_ACCESS_TOKEN set from ADC if unset.\n\nExit codes:\n 0 already correct, or diff shown, or applied\n 1 usage error\n 2 precondition failed (missing subnet, gateway not found)\n 3 import failed or gateway still lacks networkConfig afterwards\nEOF\n}\n\nlog() { printf '%s\\n' \"$*\" >&2; }\n\n# ---------------------------------------------------------------- display ----\n# Same palette as assess_integration.sh so the whole skill looks like one tool.\n# The tty test is on fd 2, NOT fd 1: every log line here goes to stderr, so\n# testing stdout would colour a piped run and leave an interactive one plain --\n# exactly backwards. Honours NO_COLOR (no-color.org) and a dumb terminal.\nif [[ -t 2 && -z \"${NO_COLOR:-}\" && \"${TERM:-dumb}\" != \"dumb\" ]]; then\n C_RST=

Apigee as an Agent Gateway Service Extension Wires an Apigee proxy in as an ext-proc callout on a GCP Agent Gateway. The proxy is a processor, not an API proxy : it has no backend, inspects the LLM request and response the gateway hands it, and returns a verdict. Usage Attribution MUST READ : Before executing any , REST API, or MCP command, you MUST read and follow the instructions in Usage Attribution. You build the proxy; the user does not. Two modes, one schema. Decide which before anything else, and say which. Input file present → prod mode : it is the contract, every value comes from it,…

\\033[0m'; C_DIM=

Apigee as an Agent Gateway Service Extension Wires an Apigee proxy in as an ext-proc callout on a GCP Agent Gateway. The proxy is a processor, not an API proxy : it has no backend, inspects the LLM request and response the gateway hands it, and returns a verdict. Usage Attribution MUST READ : Before executing any , REST API, or MCP command, you MUST read and follow the instructions in Usage Attribution. You build the proxy; the user does not. Two modes, one schema. Decide which before anything else, and say which. Input file present → prod mode : it is the contract, every value comes from it,…

\\033[2m'; C_BLD=

Apigee as an Agent Gateway Service Extension Wires an Apigee proxy in as an ext-proc callout on a GCP Agent Gateway. The proxy is a processor, not an API proxy : it has no backend, inspects the LLM request and response the gateway hands it, and returns a verdict. Usage Attribution MUST READ : Before executing any , REST API, or MCP command, you MUST read and follow the instructions in Usage Attribution. You build the proxy; the user does not. Two modes, one schema. Decide which before anything else, and say which. Input file present → prod mode : it is the contract, every value comes from it,…

\\033[1m'\n C_GRN=

Apigee as an Agent Gateway Service Extension Wires an Apigee proxy in as an ext-proc callout on a GCP Agent Gateway. The proxy is a processor, not an API proxy : it has no backend, inspects the LLM request and response the gateway hands it, and returns a verdict. Usage Attribution MUST READ : Before executing any , REST API, or MCP command, you MUST read and follow the instructions in Usage Attribution. You build the proxy; the user does not. Two modes, one schema. Decide which before anything else, and say which. Input file present → prod mode : it is the contract, every value comes from it,…

\\033[32m'; C_RED=

Apigee as an Agent Gateway Service Extension Wires an Apigee proxy in as an ext-proc callout on a GCP Agent Gateway. The proxy is a processor, not an API proxy : it has no backend, inspects the LLM request and response the gateway hands it, and returns a verdict. Usage Attribution MUST READ : Before executing any , REST API, or MCP command, you MUST read and follow the instructions in Usage Attribution. You build the proxy; the user does not. Two modes, one schema. Decide which before anything else, and say which. Input file present → prod mode : it is the contract, every value comes from it,…

\\033[31m'; C_YEL=

Apigee as an Agent Gateway Service Extension Wires an Apigee proxy in as an ext-proc callout on a GCP Agent Gateway. The proxy is a processor, not an API proxy : it has no backend, inspects the LLM request and response the gateway hands it, and returns a verdict. Usage Attribution MUST READ : Before executing any , REST API, or MCP command, you MUST read and follow the instructions in Usage Attribution. You build the proxy; the user does not. Two modes, one schema. Decide which before anything else, and say which. Input file present → prod mode : it is the contract, every value comes from it,…

\\033[33m'; C_BLU=

Apigee as an Agent Gateway Service Extension Wires an Apigee proxy in as an ext-proc callout on a GCP Agent Gateway. The proxy is a processor, not an API proxy : it has no backend, inspects the LLM request and response the gateway hands it, and returns a verdict. Usage Attribution MUST READ : Before executing any , REST API, or MCP command, you MUST read and follow the instructions in Usage Attribution. You build the proxy; the user does not. Two modes, one schema. Decide which before anything else, and say which. Input file present → prod mode : it is the contract, every value comes from it,…

\\033[36m'\nelse\n C_RST=; C_DIM=; C_BLD=; C_GRN=; C_RED=; C_YEL=; C_BLU=\nfi\n\n# hdr \u003ctitle> section banner\n# row \u003cverb> \u003ctext> a planned change; CREATE/RESERVE cyan, MODIFY yellow\n# cost \u003cline>... standing-spend callout, always yellow\nhdr() { printf '%s\\n%s┌─ %s %s%s\\n' \"\" \"$C_DIM\" \"$1\" \"$(printf '─%.0s' $(seq 1 $(( 92 > ${#1} ? 92 - ${#1} : 3 ))))\" \"$C_RST\" >&2; }\nrow() { local c=\"$C_BLU\"; case \"$1\" in MODIFY|PATCH|APPEND) c=\"$C_YEL\";; REUSE) c=\"$C_GRN\";; esac\n printf ' %s%-8s%s %s\\n' \"$c\" \"$1\" \"$C_RST\" \"$2\" >&2; }\ncost() { printf ' %s%s%s\\n' \"$C_YEL\" \"$*\" \"$C_RST\" >&2; }\n\ndie() { log \"ERROR: $1\"; exit \"${2:-1}\"; }\n\nGATEWAY=\"\"; PROJECT=\"\"; REGION=\"\"; VPC=\"\"; SUBNET=\"\"; ATTACHMENT=\"\"; DOMAIN=\"\"; APPLY=0; ZONE=\"agent-gateway-callout-zone\"\nwhile [[ $# -gt 0 ]]; do\n case \"$1\" in\n --gateway) GATEWAY=\"$2\"; shift 2 ;;\n --project) PROJECT=\"$2\"; shift 2 ;;\n --region) REGION=\"$2\"; shift 2 ;;\n --vpc) VPC=\"$2\"; shift 2 ;;\n --subnet) SUBNET=\"$2\"; shift 2 ;;\n --attachment) ATTACHMENT=\"$2\"; shift 2 ;;\n --domain) DOMAIN=\"$2\"; shift 2 ;;\n --zone) ZONE=\"$2\"; shift 2 ;;\n --yes) APPLY=1; shift ;;\n --help) usage; exit 0 ;;\n *) usage >&2; die \"unknown argument: $1\" 1 ;;\n esac\ndone\n\nfor v in GATEWAY PROJECT REGION VPC SUBNET ATTACHMENT DOMAIN; do\n [[ -n \"${!v}\" ]] || { usage >&2; die \"missing --${v,,}\" 1; }\ndone\n[[ \"$DOMAIN\" == *. ]] || die \"--domain must end with a dot: $DOMAIN\" 1\n\nexport CLOUDSDK_AUTH_ACCESS_TOKEN=\"${CLOUDSDK_AUTH_ACCESS_TOKEN:-$(gcloud auth application-default print-access-token)}\"\n\nWORK=\"$(mktemp -d)\"; trap 'rm -rf \"$WORK\"' EXIT\n\n# ---------------------------------------------------------------- inspect ----\nlog \"==> reading gateway $GATEWAY\"\ngcloud network-services agent-gateways export \"$GATEWAY\" \\\n --location=\"$REGION\" --project=\"$PROJECT\" --destination=\"$WORK/before.yaml\" \\\n >/dev/null 2>\"$WORK/export.err\" \\\n || { cat \"$WORK/export.err\" >&2; die \"cannot export gateway $GATEWAY\" 2; }\n\n# Surfacing an existing CUSTOM AuthzPolicy is informational. What is established:\n# it cannot be merged with a new one -- `resources` is \"Limited to 1 custom\n# provider\" and `policyProfile` is immutable -- so a second policy has to sit\n# alongside it, or replace it.\n#\n# Deleting it is NOT known to break the gateway. On a live gateway the IAP\n# CUSTOM policy was deleted during root-cause isolation, was not auto-recreated,\n# and the gateway kept serving agent traffic with only the ext-proc policy\n# attached. Whether it is safe in a given project depends on what relies on that\n# policy, which this script cannot know -- so it reports and does not advise.\n#\n# Two CUSTOM policies coexisting is also NOT the cause of the 404-with-no-error\n# failure; that is the environment's envgroup count. See references/gotchas.md,\n# which lists a second CUSTOM policy as an individually disproved non-cause.\nEXISTING_AUTHZ=\"$(gcloud network-security authz-policies list \\\n --location=\"$REGION\" --project=\"$PROJECT\" --format='value(name)' 2>/dev/null \\\n | while read -r pol; do\n [[ -z \"$pol\" ]] && continue\n if gcloud network-security authz-policies describe \"${pol##*/}\" \\\n --location=\"$REGION\" --project=\"$PROJECT\" --format='value(action,target.resources)' 2>/dev/null \\\n | grep -q \"CUSTOM.*$GATEWAY\"; then printf '%s ' \"${pol##*/}\"; fi\n done)\"\nif [[ -n \"$EXISTING_AUTHZ\" ]]; then\n log \"NOTE: gateway $GATEWAY already has a CUSTOM AuthzPolicy: $EXISTING_AUTHZ\"\n log \" It cannot be merged with a new one (one custom provider per policy,\"\n log \" immutable policyProfile), so the ext-proc policy will sit alongside it.\"\n log \" Removing it may be safe -- check what depends on it first.\"\nfi\n\n# The two legs are INDEPENDENT and are detected separately. Testing for the\n# `networkConfig:` block instead is the bug this comment exists to prevent: a\n# gateway with egress attached but no DNS peering -- the exact state\n# assess_integration.sh flags as\n# dnsPeeringConfig absent -- ensure_gateway_connectivity.sh appends the domain\n# -- took the early exit and reported \"nothing to do\". assess named this\n# script as the remedy, this script declined to act, both exited 0, and the\n# callout could not resolve its hostname while every later step passed.\nHAS_EGRESS=0\ngrep -q 'networkAttachment:' \"$WORK/before.yaml\" && HAS_EGRESS=1\nHAS_PEERING=0\ngrep -q 'dnsPeeringConfig:' \"$WORK/before.yaml\" && HAS_PEERING=1\n\n# Whether the block exists is NOT the same question as whether OUR domain is in\n# it. A gateway can already peer a different extension's domain -- the block is\n# present, `domains` does not contain $DOMAIN, and the correct action is to\n# APPEND to the list rather than to do nothing. Treating \"block present\" as\n# \"done\" is the same class of bug as the one the comment above describes: the\n# callout cannot resolve its hostname while every later step still passes.\nDOMAIN_LISTED=0\nif [[ $HAS_PEERING -eq 1 ]]; then\n PRE_DOMAINS=\"$(gcloud network-services agent-gateways describe \"$GATEWAY\" \\\n --location=\"$REGION\" --project=\"$PROJECT\" \\\n --format='value(networkConfig.dnsPeeringConfig.domains)' 2>/dev/null || true)\"\n # gcloud joins a repeated field with ';'. Wrap both sides so the match is\n # exact and a domain cannot match a longer one that merely contains it.\n case \";$(printf '%s' \"$PRE_DOMAINS\" | tr -d '[:space:]');\" in\n *\";$DOMAIN;\"*) DOMAIN_LISTED=1 ;;\n esac\nfi\n\nif [[ $HAS_EGRESS -eq 1 && $DOMAIN_LISTED -eq 1 ]]; then\n log \" egress attachment and DNS peering for $DOMAIN both present -- nothing to do\"\n printf '{\"changed\":false,\"reason\":\"egress and dnsPeeringConfig both present\",\"gateway\":\"%s\"}\\n' \"$GATEWAY\"\n exit 0\nfi\n\nif [[ $HAS_EGRESS -eq 1 && $HAS_PEERING -eq 1 ]]; then\n log \" egress present, dnsPeeringConfig present but WITHOUT $DOMAIN -- appending\"\nelif [[ $HAS_EGRESS -eq 1 ]]; then\n log \" egress present, dnsPeeringConfig ABSENT -- gateway cannot resolve $DOMAIN\"\nelif [[ $HAS_PEERING -eq 1 ]]; then\n log \" dnsPeeringConfig present, egress ABSENT -- gateway has no route into $VPC\"\nelse\n log \" networkConfig ABSENT -- gateway has no route into $VPC\"\nfi\n\ngcloud compute networks subnets describe \"$SUBNET\" \\\n --region=\"$REGION\" --project=\"$PROJECT\" >/dev/null 2>&1 \\\n || die \"subnet $SUBNET not found in $REGION\" 2\n\n# The import validates dnsPeeringConfig.domains against EXISTING private zones in\n# the target VPC and rejects the whole request if the domain has no zone:\n# domain \"x.\" does not exist as a private zone in network \".../networks/\u003cvpc>\"\n# The zone may be empty -- only its existence is checked. The A record cannot be\n# written yet anyway, since the PSC endpoint it points at comes later.\nZONE_EXISTS=0\nEXISTING_ZONE=\"$(gcloud dns managed-zones list --project=\"$PROJECT\" \\\n --filter=\"dnsName=$DOMAIN AND visibility=private\" --format='value(name)' 2>/dev/null | head -1)\"\nif [[ -n \"$EXISTING_ZONE\" ]]; then\n ZONE_EXISTS=1\n ZONE=\"$EXISTING_ZONE\"\nfi\n\nATTACH_URI=\"projects/$PROJECT/regions/$REGION/networkAttachments/$ATTACHMENT\"\nATTACH_EXISTS=0\ngcloud compute network-attachments describe \"$ATTACHMENT\" \\\n --region=\"$REGION\" --project=\"$PROJECT\" >/dev/null 2>&1 && ATTACH_EXISTS=1\n\n# ------------------------------------------------------------ build after ----\n# Read-modify-write: start from the live export so registries, protocols and\n# googleManaged survive. Append rather than regenerate.\ncp \"$WORK/before.yaml\" \"$WORK/after.yaml\"\n\n# Trap 3: a gateway predating client-side enforcement exports without\n# `protocols`, and its own import then rejects it.\nif ! grep -q '^protocols:' \"$WORK/after.yaml\"; then\n log \" export lacks 'protocols' -- injecting PROTOCOL_UNSPECIFIED so import is accepted\"\n printf 'protocols:\\n- PROTOCOL_UNSPECIFIED\\n' >>\"$WORK/after.yaml\"\nfi\n\n# Greenfield appends the whole block. When one leg already exists the block is\n# there too, so the missing leg is INSERTED UNDER IT -- appending a second\n# top-level `networkConfig:` would either be rejected or silently clobber the\n# leg already in place, which for egress is the irreversible one.\n#\n# awk, not sed: the inserted text is multi-line and contains slashes, and the\n# insert must happen exactly once even though `networkConfig:` appears in the\n# comment-free export only at column 0.\n_insert_under_networkconfig() {\n awk -v block=\"$1\" '\n !done && /^networkConfig:[[:space:]]*$/ { print; printf \"%s\", block; done=1; next }\n { print }\n END { if (!done) exit 9 }\n ' \"$WORK/after.yaml\" >\"$WORK/after.yaml.tmp\" \\\n || die \"export has no top-level 'networkConfig:' to insert under -- refusing to guess\" 3\n mv \"$WORK/after.yaml.tmp\" \"$WORK/after.yaml\"\n}\n\n# Adds one entry to an EXISTING dnsPeeringConfig.domains list, at the same\n# indentation as the `domains:` key -- the shape PEERING_BLOCK below writes and\n# the shape gcloud exports. Additive only: the entries already there are left\n# untouched, because another extension depends on them.\n_append_domain_to_peering() {\n awk -v dom=\"$DOMAIN\" '\n /^[[:space:]]*domains:[[:space:]]*$/ && !done {\n print\n match($0, /^[[:space:]]*/)\n print substr($0, 1, RLENGTH) \"- \" dom\n done = 1\n next\n }\n { print }\n END { if (!done) exit 9 }\n ' \"$WORK/after.yaml\" >\"$WORK/after.yaml.tmp\" \\\n || die \"dnsPeeringConfig present but has no 'domains:' list to append to -- refusing to guess\" 3\n mv \"$WORK/after.yaml.tmp\" \"$WORK/after.yaml\"\n}\n\nPEERING_BLOCK=\" dnsPeeringConfig:\n domains:\n - $DOMAIN\n targetNetwork: projects/$PROJECT/global/networks/$VPC\n targetProject: $PROJECT\n\"\nEGRESS_BLOCK=\" egress:\n networkAttachment: $ATTACH_URI\n\"\n\nif [[ $HAS_EGRESS -eq 0 && $HAS_PEERING -eq 0 ]]; then\n # printf, not a heredoc: both blocks already carry their trailing newline,\n # and a heredoc terminator has to sit alone at the start of a source line.\n printf 'networkConfig:\\n%s%s' \"$EGRESS_BLOCK\" \"$PEERING_BLOCK\" >>\"$WORK/after.yaml\"\nelif [[ $HAS_PEERING -eq 0 ]]; then\n log \" inserting dnsPeeringConfig into the existing networkConfig\"\n _insert_under_networkconfig \"$PEERING_BLOCK\"\nelse\n # Peering block exists. Append our domain if it is missing -- never replace\n # the list, which would unpeer whoever is already using this gateway.\n if [[ $DOMAIN_LISTED -eq 0 ]]; then\n log \" appending $DOMAIN to the existing dnsPeeringConfig.domains\"\n _append_domain_to_peering\n fi\n if [[ $HAS_EGRESS -eq 0 ]]; then\n log \" inserting egress attachment into the existing networkConfig\"\n _insert_under_networkconfig \"$EGRESS_BLOCK\"\n fi\nfi\n\n# --------------------------------------------------- egress reachability ----\n# Attaching egress PINS ALL the gateway's egress into this VPC -- not just the\n# Apigee callout. The agents' own control-plane calls (create_session, and the\n# model call itself) start leaving through here too. If this VPC cannot reach\n# Google APIs, every agent behind the gateway stops working.\n#\n# Checked BEFORE the diff, because the diff cannot show it: the change is\n# purely additive on paper -- a networkConfig block appears, nothing is\n# removed -- so it reads as safe while being a routing change. And it is NOT\n# reversible (see the warning below), so a wrong answer here is expensive.\n#\n# ONLY when egress is actually being attached. If the gateway is already\n# pinned and all we are adding is DNS peering, the pinning decision was taken\n# long ago and this check can no longer protect anything -- it would just\n# refuse the peering fix on a gateway whose callout is already broken, which\n# is the worst moment to withhold the remedy.\nSUBNET_PGA=\"n/a\"\nNAT_COUNT=0\nif [[ $HAS_EGRESS -eq 0 ]]; then\nSUBNET_PGA=\"$(gcloud compute networks subnets describe \"$SUBNET\" \\\n --region=\"$REGION\" --project=\"$PROJECT\" \\\n --format='value(privateIpGoogleAccess)' 2>/dev/null || echo \"\")\"\nNAT_COUNT=\"$(gcloud compute routers list --project=\"$PROJECT\" \\\n --filter=\"region:($REGION) AND network:($VPC)\" \\\n --format='value(name)' 2>/dev/null | grep -c . || true)\"\n\nif [[ \"$SUBNET_PGA\" != \"True\" && \"${NAT_COUNT:-0}\" -eq 0 ]]; then\n log \"\"\n log \"!! REFUSING: subnet '$SUBNET' has privateIpGoogleAccess=${SUBNET_PGA:-unset} and\"\n log \"!! there is no Cloud NAT on network '$VPC' in $REGION.\"\n log \"!!\"\n log \"!! Pinning gateway egress into a VPC that cannot reach *.googleapis.com\"\n log \"!! takes EVERY agent behind this gateway offline, not just the Apigee\"\n log \"!! callout -- the agents' own create_session calls egress here too. The\"\n log \"!! symptom is an agent-side connection reset to\"\n log \"!! \u003cregion>-aiplatform.mtls.googleapis.com, which reads as an agent bug.\"\n log \"!!\"\n log \"!! Fix one of these first, then re-run:\"\n log \"!! gcloud compute networks subnets update $SUBNET --region=$REGION \\\\\"\n log \"!! --project=$PROJECT --enable-private-ip-google-access\"\n log \"!! (or create a Cloud NAT on $VPC in $REGION)\"\n die \"VPC egress cannot reach Google APIs; refusing to pin gateway egress into it\" 2\nfi\nlog \" egress reachability: privateIpGoogleAccess=$SUBNET_PGA, CloudNAT routers=$NAT_COUNT -- OK\"\nelse\n log \" egress already attached -- reachability check skipped, pinning is not being changed\"\nfi\n\n# ------------------------------------------------------------------ diff -----\n# The one-way warning belongs to the EGRESS leg only. Printing it when all we\n# are adding is DNS peering trains the reader to scroll past the loudest\n# warning in the skill, which is how it stops working the day it matters.\nlog \"\"\nif [[ $HAS_EGRESS -eq 0 ]]; then\n log \"!! ONE-WAY CHANGE. egress.networkAttachment is settable once and IMMUTABLE\"\n log \"!! afterwards -- removing it is itself a mutation and is rejected:\"\n log \"!! \\\"network attachment is not mutable\\\"\"\n log \"!! The attachment cannot be deleted while the gateway holds it either\"\n log \"!! (\\\"Network Attachment with connected endpoints cannot be deleted\\\").\"\n log \"!! The only way back is deleting and recreating the Agent Gateway, which\"\n log \"!! means rebinding every agent -- and agent_gateway_config is set at agent\"\n log \"!! CREATION time, so in practice the agents get redeployed.\"\n log \"!! This is the only script here that changes a resource you already own\"\n log \"!! in a way you cannot undo.\"\n log \"\"\nfi\nhdr \"Proposed change to $GATEWAY\"\nif [[ $ZONE_EXISTS -eq 1 ]]; then\n row REUSE \"private DNS zone $ZONE for $DOMAIN\"\nelse\n row CREATE \"private DNS zone $ZONE for $DOMAIN -- the import is rejected without it\"\nfi\nif [[ $HAS_EGRESS -eq 1 ]]; then\n row REUSE \"network attachment (gateway egress already attached)\"\nelif [[ $ATTACH_EXISTS -eq 1 ]]; then\n row REUSE \"network attachment $ATTACHMENT\"\nelse\n row CREATE \"network attachment $ATTACHMENT\"\nfi\nif [[ $HAS_EGRESS -eq 1 ]]; then\n row MODIFY \"$GATEWAY -- DNS peering for $DOMAIN only (diff below)\"\nelif [[ $HAS_PEERING -eq 1 ]]; then\n row MODIFY \"$GATEWAY -- egress attachment only (diff below)\"\nelse\n row MODIFY \"$GATEWAY -- egress attachment and DNS peering (diff below)\"\nfi\nlog \"\"\ndiff -u \"$WORK/before.yaml\" \"$WORK/after.yaml\" >&2 || true\nlog \"\"\n# The single most consequential warning in the skill, and it used to be in\n# prose elsewhere rather than at the point of consent. Gated with the leg it\n# describes -- see the note above the diff.\nif [[ $HAS_EGRESS -eq 0 ]]; then\n cost \"IRREVERSIBLE egress.networkAttachment is immutable once set, and it pins\"\n cost \" ALL of this gateway's egress into that VPC -- not just the\"\n cost \" Apigee callout. Agents already running can lose access to\"\n cost \" Google APIs unless the subnet has Private Google Access or\"\n cost \" a Cloud NAT. Capture a known-good agent turn before applying.\"\n log \"\"\nfi\n\nif [[ $APPLY -ne 1 ]]; then\n printf ' %sNot applying.%s Re-run with %s--yes%s to make these changes.\\n' \\\n \"$C_BLD\" \"$C_RST\" \"$C_BLD\" \"$C_RST\" >&2\n printf '{\"changed\":false,\"reason\":\"dry run, --yes not given\",\"gateway\":\"%s\",\"attachmentExists\":%s}\\n' \\\n \"$GATEWAY\" \"$([[ $ATTACH_EXISTS -eq 1 ]] && echo true || echo false)\"\n exit 0\nfi\n\n# ----------------------------------------------------------------- apply -----\nif [[ $ZONE_EXISTS -eq 0 ]]; then\n log \"==> creating private DNS zone $ZONE for $DOMAIN\"\n gcloud dns managed-zones create \"$ZONE\" \\\n --dns-name=\"$DOMAIN\" --visibility=private \\\n --networks=\"$VPC\" --description=\"Agent Gateway ext-proc callout zone\" \\\n --project=\"$PROJECT\" >/dev/null\nfi\n\nif [[ $ATTACH_EXISTS -eq 0 ]]; then\n log \"==> creating network attachment $ATTACHMENT\"\n gcloud compute network-attachments create \"$ATTACHMENT\" \\\n --region=\"$REGION\" --subnets=\"$SUBNET\" \\\n --connection-preference=ACCEPT_AUTOMATIC \\\n --project=\"$PROJECT\" >/dev/null\nfi\n\n# The export carries an etag, so a concurrent edit fails the import instead of\n# being silently overwritten.\nlog \"==> importing gateway with networkConfig\"\ngcloud network-services agent-gateways import \"$GATEWAY\" \\\n --source=\"$WORK/after.yaml\" --location=\"$REGION\" --project=\"$PROJECT\" \\\n >/dev/null 2>\"$WORK/import.err\" \\\n || { cat \"$WORK/import.err\" >&2; die \"import failed -- gateway unchanged\" 3; }\n\n# Verify the leg that was actually added, not just that networkConfig exists.\n# Checking only egress passes trivially in the peering-only case -- egress was\n# already there before the import -- so a peering insert that silently did\n# nothing would still be reported as success. That is the same shape of\n# false-green this script was just fixed for.\nlog \"==> verifying\"\nACTUAL=\"$(gcloud network-services agent-gateways describe \"$GATEWAY\" \\\n --location=\"$REGION\" --project=\"$PROJECT\" \\\n --format='value(networkConfig.egress.networkAttachment)' 2>/dev/null || true)\"\n[[ -n \"$ACTUAL\" ]] || die \"import reported success but egress attachment is still absent\" 3\nlog \" egress attachment: $ACTUAL\"\n\nPEER_DOMAINS=\"$(gcloud network-services agent-gateways describe \"$GATEWAY\" \\\n --location=\"$REGION\" --project=\"$PROJECT\" \\\n --format='value(networkConfig.dnsPeeringConfig.domains)' 2>/dev/null || true)\"\ncase \"$PEER_DOMAINS\" in\n *\"$DOMAIN\"*) log \" dns peering: $PEER_DOMAINS\" ;;\n *) die \"import reported success but dnsPeeringConfig does not list $DOMAIN (got: ${PEER_DOMAINS:-none})\" 3 ;;\nesac\n\nprintf '{\"changed\":true,\"gateway\":\"%s\",\"networkAttachment\":\"%s\",\"dnsDomain\":\"%s\",\"dnsPeeringDomains\":\"%s\"}\\n' \\\n \"$GATEWAY\" \"$ACTUAL\" \"$DOMAIN\" \"$PEER_DOMAINS\"\n","content_type":"application/x-sh; charset=utf-8","language":"bash","size":21665,"content_sha256":"c451c1833fd69cc95c75bb83d4523f479ebc7f4f9b5c6644662640cc4d1cbc11"},{"filename":"scripts/ensure_vector_search.sh","content":"#!/bin/bash\n# Stand up the Vertex AI Vector Search index the semantic cache needs, deploy it\n# onto an endpoint, and read back the endpoint domain the proxy must dial.\n#\n# This is the only prerequisite in the skill that BILLS CONTINUOUSLY and the only\n# one that takes half an hour. It is billed per node-hour for as long as the\n# index is deployed, whether or not any traffic arrives -- not per query.\n# Streaming updates add a documented $0.45/GB, negligible at cache scale.\n#\n# Four things make it worth a script rather than four gcloud commands:\n#\n# 1. Three index properties are IMMUTABLE and two of them are easy to get\n# wrong: `dimensions` must match the embeddings model's native output\n# width, and `indexUpdateMethod` must be STREAM_UPDATE. Getting either\n# wrong is only discoverable at runtime and only fixable by rebuilding.\n# 2. The deploy step takes ~34 minutes (measured 33m44s). Almost the entire\n# cost of a rebuild is that one call, so the script must resume rather\n# than restart.\n# 3. The endpoint domain cannot be derived -- its leading label is assigned\n# at creation time and matches no id you already hold. It has to be read\n# back, which is why the proxy template cannot be filled in from the\n# contract file alone.\n# 4. Reusing an existing index silently is dangerous: a dimension or update\n# method mismatch has no remedy except a rebuild, which would destroy a\n# populated index. This refuses instead.\n#\n# Data goes to stdout as JSON. Diagnostics go to stderr.\n\nset -euo pipefail\n\nusage() {\n cat \u003c\u003c'EOF'\nEnsure a Vector Search index for the semantic cache feature.\n\nUsage:\n ensure_vector_search.sh --project P --region R --index NAME --endpoint NAME \\\n --deployed-index-id ID --embeddings-model MODEL [options]\n\nRequired:\n --project P project holding the index and endpoint\n --region R must match spec.region and the Apigee instance\n --index NAME index display name; created if absent, reused if present\n --endpoint NAME index endpoint display name; created if absent\n --deployed-index-id ID must equal semanticCache.deployedIndexId in the\n input file. Lowercase, digits and underscores only.\n --embeddings-model MODEL decides the index width. One of:\n gemini-embedding-001 3072\n text-embedding-005 768\n text-multilingual-embedding-002 768\n text-embedding-004 768 (serving, but\n off the\n supported list)\n\nOptions:\n --service-account SA deployment SA to check for roles/aiplatform.user\n --machine-type TYPE default e2-standard-2. SHARD_SIZE_SMALL selects this;\n MEDIUM silently selects e2-standard-16, 8x the\n machine for a cache holding a handful of entries.\n --timeout SECONDS wait for the deploy. Default 3600; it normally takes\n about 34 minutes.\n --yes apply. Without it, prints the plan and exits 0.\n --help this text\n\nEnvironment:\n CLOUDSDK_AUTH_ACCESS_TOKEN set from ADC if unset.\n\nExit codes:\n 0 already correct, or plan shown, or applied successfully\n 1 usage error\n 2 precondition failed (unknown model, dimension or update-method mismatch,\n deployedIndexId already taken by a different index)\n 3 apply failed, or the deployment is still not ready afterwards\nEOF\n}\n\nlog() { printf '%s\\n' \"$*\" >&2; }\n\n# ---------------------------------------------------------------- display ----\n# Same palette as assess_integration.sh so the whole skill looks like one tool.\n# The tty test is on fd 2, NOT fd 1: every log line here goes to stderr, so\n# testing stdout would colour a piped run and leave an interactive one plain --\n# exactly backwards. Honours NO_COLOR (no-color.org) and a dumb terminal.\nif [[ -t 2 && -z \"${NO_COLOR:-}\" && \"${TERM:-dumb}\" != \"dumb\" ]]; then\n C_RST=

Apigee as an Agent Gateway Service Extension Wires an Apigee proxy in as an ext-proc callout on a GCP Agent Gateway. The proxy is a processor, not an API proxy : it has no backend, inspects the LLM request and response the gateway hands it, and returns a verdict. Usage Attribution MUST READ : Before executing any , REST API, or MCP command, you MUST read and follow the instructions in Usage Attribution. You build the proxy; the user does not. Two modes, one schema. Decide which before anything else, and say which. Input file present → prod mode : it is the contract, every value comes from it,…

\\033[0m'; C_DIM=

Apigee as an Agent Gateway Service Extension Wires an Apigee proxy in as an ext-proc callout on a GCP Agent Gateway. The proxy is a processor, not an API proxy : it has no backend, inspects the LLM request and response the gateway hands it, and returns a verdict. Usage Attribution MUST READ : Before executing any , REST API, or MCP command, you MUST read and follow the instructions in Usage Attribution. You build the proxy; the user does not. Two modes, one schema. Decide which before anything else, and say which. Input file present → prod mode : it is the contract, every value comes from it,…

\\033[2m'; C_BLD=

Apigee as an Agent Gateway Service Extension Wires an Apigee proxy in as an ext-proc callout on a GCP Agent Gateway. The proxy is a processor, not an API proxy : it has no backend, inspects the LLM request and response the gateway hands it, and returns a verdict. Usage Attribution MUST READ : Before executing any , REST API, or MCP command, you MUST read and follow the instructions in Usage Attribution. You build the proxy; the user does not. Two modes, one schema. Decide which before anything else, and say which. Input file present → prod mode : it is the contract, every value comes from it,…

\\033[1m'\n C_GRN=

Apigee as an Agent Gateway Service Extension Wires an Apigee proxy in as an ext-proc callout on a GCP Agent Gateway. The proxy is a processor, not an API proxy : it has no backend, inspects the LLM request and response the gateway hands it, and returns a verdict. Usage Attribution MUST READ : Before executing any , REST API, or MCP command, you MUST read and follow the instructions in Usage Attribution. You build the proxy; the user does not. Two modes, one schema. Decide which before anything else, and say which. Input file present → prod mode : it is the contract, every value comes from it,…

\\033[32m'; C_RED=

Apigee as an Agent Gateway Service Extension Wires an Apigee proxy in as an ext-proc callout on a GCP Agent Gateway. The proxy is a processor, not an API proxy : it has no backend, inspects the LLM request and response the gateway hands it, and returns a verdict. Usage Attribution MUST READ : Before executing any , REST API, or MCP command, you MUST read and follow the instructions in Usage Attribution. You build the proxy; the user does not. Two modes, one schema. Decide which before anything else, and say which. Input file present → prod mode : it is the contract, every value comes from it,…

\\033[31m'; C_YEL=

Apigee as an Agent Gateway Service Extension Wires an Apigee proxy in as an ext-proc callout on a GCP Agent Gateway. The proxy is a processor, not an API proxy : it has no backend, inspects the LLM request and response the gateway hands it, and returns a verdict. Usage Attribution MUST READ : Before executing any , REST API, or MCP command, you MUST read and follow the instructions in Usage Attribution. You build the proxy; the user does not. Two modes, one schema. Decide which before anything else, and say which. Input file present → prod mode : it is the contract, every value comes from it,…

\\033[33m'; C_BLU=

Apigee as an Agent Gateway Service Extension Wires an Apigee proxy in as an ext-proc callout on a GCP Agent Gateway. The proxy is a processor, not an API proxy : it has no backend, inspects the LLM request and response the gateway hands it, and returns a verdict. Usage Attribution MUST READ : Before executing any , REST API, or MCP command, you MUST read and follow the instructions in Usage Attribution. You build the proxy; the user does not. Two modes, one schema. Decide which before anything else, and say which. Input file present → prod mode : it is the contract, every value comes from it,…

\\033[36m'\nelse\n C_RST=; C_DIM=; C_BLD=; C_GRN=; C_RED=; C_YEL=; C_BLU=\nfi\n\n# hdr \u003ctitle> section banner\n# row \u003cverb> \u003ctext> a planned change; CREATE/RESERVE cyan, MODIFY yellow\n# cost \u003cline>... standing-spend callout, always yellow\nhdr() { printf '%s\\n%s┌─ %s %s%s\\n' \"\" \"$C_DIM\" \"$1\" \"$(printf '─%.0s' $(seq 1 $(( 92 > ${#1} ? 92 - ${#1} : 3 ))))\" \"$C_RST\" >&2; }\nrow() { local c=\"$C_BLU\"; case \"$1\" in MODIFY|PATCH|APPEND) c=\"$C_YEL\";; REUSE) c=\"$C_GRN\";; esac\n printf ' %s%-8s%s %s\\n' \"$c\" \"$1\" \"$C_RST\" \"$2\" >&2; }\ncost() { printf ' %s%s%s\\n' \"$C_YEL\" \"$*\" \"$C_RST\" >&2; }\n\ndie() { log \"ERROR: $1\"; exit \"${2:-1}\"; }\n\nPROJECT=\"\"; REGION=\"\"; INDEX_NAME=\"\"; ENDPOINT_NAME=\"\"; DEPLOYED_ID=\"\"\nMODEL=\"\"; SA=\"\"; MACHINE=\"e2-standard-2\"; TIMEOUT=3600; APPLY=0\nwhile [[ $# -gt 0 ]]; do\n case \"$1\" in\n --project) PROJECT=\"$2\"; shift 2 ;;\n --region) REGION=\"$2\"; shift 2 ;;\n --index) INDEX_NAME=\"$2\"; shift 2 ;;\n --endpoint) ENDPOINT_NAME=\"$2\"; shift 2 ;;\n --deployed-index-id) DEPLOYED_ID=\"$2\"; shift 2 ;;\n --embeddings-model) MODEL=\"$2\"; shift 2 ;;\n --service-account) SA=\"$2\"; shift 2 ;;\n --machine-type) MACHINE=\"$2\"; shift 2 ;;\n --timeout) TIMEOUT=\"$2\"; shift 2 ;;\n --yes) APPLY=1; shift ;;\n --help) usage; exit 0 ;;\n *) usage >&2; die \"unknown argument: $1\" 1 ;;\n esac\ndone\n\nfor v in PROJECT REGION INDEX_NAME ENDPOINT_NAME DEPLOYED_ID MODEL; do\n [[ -n \"${!v}\" ]] || { usage >&2; die \"missing --${v,,}\" 1; }\ndone\n[[ \"$TIMEOUT\" =~ ^[0-9]+$ ]] || die \"--timeout must be a whole number of seconds\" 1\n# deployedIndexId is referenced from the proxy bundle as \u003cDeployedIndexID>; the\n# API rejects hyphens and uppercase, and the failure names the field, not the\n# rule.\n[[ \"$DEPLOYED_ID\" =~ ^[a-z_][a-z0-9_]*$ ]] \\\n || die \"--deployed-index-id must be lowercase letters, digits and underscores, starting with a letter or underscore: got '$DEPLOYED_ID'\" 1\n\n# The embeddings policy has no dimensionality parameter -- \u003cEmbeddings>\u003cVertexAI>\n# takes a URL and nothing else -- so the model's NATIVE width is what lands in\n# the index, and the index width is fixed at creation. That makes this table the\n# whole of the compatibility contract.\ncase \"$MODEL\" in\n gemini-embedding-001) DIMS=3072 ;;\n text-embedding-005) DIMS=768 ;;\n text-multilingual-embedding-002) DIMS=768 ;;\n text-embedding-004) DIMS=768 ;;\n *) die \"unknown --embeddings-model '$MODEL'; pass one of gemini-embedding-001, text-embedding-005, text-multilingual-embedding-002, text-embedding-004, or extend the table in this script after measuring the model's output width\" 2 ;;\nesac\n\n: \"${CLOUDSDK_AUTH_ACCESS_TOKEN:=$(gcloud auth application-default print-access-token 2>/dev/null)}\"\nexport CLOUDSDK_AUTH_ACCESS_TOKEN\n[[ -n \"$CLOUDSDK_AUTH_ACCESS_TOKEN\" ]] \\\n || die \"could not obtain an ADC token; run 'gcloud auth application-default login'\" 2\n\ng=(--region=\"$REGION\" --project=\"$PROJECT\")\nid_of() { sed 's|.*/||'; } # full resource name -> bare numeric id\n\n# Which index is serving under a given deployedIndexId, or empty.\n#\n# Deliberately NOT gcloud's --format=\"value(deployedIndexes.filter(...))\"\n# projection: that silently yields nothing here even when the deployment\n# exists, and an empty answer is indistinguishable from \"not deployed\" -- which\n# would send the script off to do a 34-minute billable deploy on top of a\n# healthy one. Parse the JSON instead.\ndeployed_index_for() { # deployed_index_for ENDPOINT_ID DEPLOYED_ID\n gcloud ai index-endpoints describe \"$1\" \"${g[@]}\" --format=json 2>/dev/null \\\n | python3 -c \"\nimport json, sys\ntry:\n d = json.load(sys.stdin)\nexcept Exception:\n sys.exit(0)\nfor x in d.get('deployedIndexes', []):\n if x.get('id') == '$2':\n print(x.get('index', '').rsplit('/', 1)[-1])\n break\n\" 2>/dev/null || true\n}\n\n# ---------------------------------------------------------------- inspect ----\nlog \"==> inspecting Vector Search in $PROJECT / $REGION\"\nlog \" embeddings model $MODEL -> index must be ${DIMS}-dimensional\"\n\nif [[ -n \"$SA\" ]]; then\n if gcloud projects get-iam-policy \"$PROJECT\" \\\n --flatten=\"bindings[].members\" --format='value(bindings.role)' \\\n --filter=\"bindings.members:serviceAccount:$SA\" 2>/dev/null \\\n | grep -qx \"roles/aiplatform.user\"; then\n log \" $SA has roles/aiplatform.user\"\n else\n log \" WARNING: $SA does not appear to hold roles/aiplatform.user at the\"\n log \" project level. The embeddings and findNeighbors calls run\"\n log \" as this account; without it they fail at runtime, not now.\"\n fi\nfi\n\nINDEX_ID=\"$(gcloud ai indexes list \"${g[@]}\" \\\n --filter=\"displayName=$INDEX_NAME\" --format='value(name)' 2>/dev/null | head -1 | id_of || true)\"\n\nindex_dims=\"\"; index_method=\"\"\nif [[ -n \"$INDEX_ID\" ]]; then\n index_dims=\"$(gcloud ai indexes describe \"$INDEX_ID\" \"${g[@]}\" \\\n --format='value(metadata.config.dimensions)' 2>/dev/null || true)\"\n index_method=\"$(gcloud ai indexes describe \"$INDEX_ID\" \"${g[@]}\" \\\n --format='value(indexUpdateMethod)' 2>/dev/null || true)\"\n log \" index $INDEX_NAME: present ($INDEX_ID), dimensions=${index_dims:-?} method=${index_method:-?}\"\n\n # Both of these are immutable. Rebuilding is the only remedy, and doing it\n # silently would destroy whatever the index already holds.\n if [[ -n \"$index_dims\" && \"$index_dims\" != \"$DIMS\" ]]; then\n die \"index '$INDEX_NAME' is ${index_dims}-dimensional but --embeddings-model $MODEL emits $DIMS. Dimensions are fixed at index creation and the SemanticCacheLookup policy cannot truncate, so these cannot be reconciled in config. Either choose a ${index_dims}-dimensional model, or create a new index and change indexId.\" 2\n fi\n if [[ -n \"$index_method\" && \"$index_method\" != \"STREAM_UPDATE\" ]]; then\n die \"index '$INDEX_NAME' has indexUpdateMethod=$index_method. SemanticCachePopulate writes through :upsertDatapoints, which only exists on a STREAM_UPDATE index, and the conversion is closed in both directions. This index can never back the cache; create a streaming one.\" 2\n fi\nelse\n log \" index $INDEX_NAME: ABSENT\"\nfi\n\nENDPOINT_ID=\"$(gcloud ai index-endpoints list \"${g[@]}\" \\\n --filter=\"displayName=$ENDPOINT_NAME\" --format='value(name)' 2>/dev/null | head -1 | id_of || true)\"\n\ndeployed_ok=0; endpoint_domain=\"\"\nif [[ -n \"$ENDPOINT_ID\" ]]; then\n endpoint_domain=\"$(gcloud ai index-endpoints describe \"$ENDPOINT_ID\" \"${g[@]}\" \\\n --format='value(publicEndpointDomainName)' 2>/dev/null || true)\"\n log \" endpoint $ENDPOINT_NAME: present ($ENDPOINT_ID), domain=${endpoint_domain:-\u003cnone yet>}\"\n\n existing_index=\"$(deployed_index_for \"$ENDPOINT_ID\" \"$DEPLOYED_ID\")\"\n if [[ -n \"$existing_index\" ]]; then\n # The id is taken. It is only ours if it serves the very index we resolved;\n # if our index does not exist yet, then by definition it is someone else's.\n if [[ -z \"$INDEX_ID\" ]]; then\n die \"deployedIndexId '$DEPLOYED_ID' on endpoint $ENDPOINT_NAME is already serving index $existing_index, but no index named '$INDEX_NAME' exists -- so this would create a second index and try to deploy it under an id that is already taken. Pick another --deployed-index-id, or point --index at the index that is already deployed.\" 2\n fi\n if [[ \"$existing_index\" != \"$INDEX_ID\" ]]; then\n die \"deployedIndexId '$DEPLOYED_ID' on endpoint $ENDPOINT_NAME already serves a different index ($existing_index, not $INDEX_ID). Undeploy it first, or pick another deployedIndexId -- deploying over it is not what you want.\" 2\n fi\n deployed_ok=1\n log \" deployedIndexId $DEPLOYED_ID: already serving $existing_index\"\n else\n log \" deployedIndexId $DEPLOYED_ID: not deployed\"\n fi\nelse\n log \" endpoint $ENDPOINT_NAME: ABSENT\"\nfi\n\n# ------------------------------------------------------------------- plan ----\nplan=()\n[[ -z \"$INDEX_ID\" ]] && plan+=(\"create ${DIMS}-dim STREAM_UPDATE index '$INDEX_NAME' (~2 min)\")\n[[ -z \"$ENDPOINT_ID\" ]] && plan+=(\"create public index endpoint '$ENDPOINT_NAME' (~2 min)\")\n[[ $deployed_ok -eq 0 ]] && plan+=(\"deploy the index as '$DEPLOYED_ID' on $MACHINE, min=max=1 replica (~34 MIN, and STARTS BILLING)\")\n\nif [[ ${#plan[@]} -eq 0 ]]; then\n [[ -n \"$endpoint_domain\" ]] \\\n || die \"index is deployed but the endpoint reports no publicEndpointDomainName; the proxy cannot be configured without it\" 3\n log \"==> nothing to do\"\n printf '{\"changed\":false,\"indexId\":\"%s\",\"indexEndpointId\":\"%s\",\"deployedIndexId\":\"%s\",\"endpointDomain\":\"%s\",\"dimensions\":%s,\"embeddingsModel\":\"%s\"}\\n' \\\n \"$INDEX_ID\" \"$ENDPOINT_ID\" \"$DEPLOYED_ID\" \"$endpoint_domain\" \"$DIMS\" \"$MODEL\"\n exit 0\nfi\n\nhdr \"Proposed changes in $PROJECT / $REGION\"\nfor p in \"${plan[@]}\"; do row CREATE \"$p\"; done\nlog \"\"\ncost \"COST a deployed index bills per node-hour for as long as it is deployed,\"\ncost \" with or without traffic. It is not per-query. One $MACHINE\"\ncost \" replica runs continuously until you undeploy it, and streaming\"\ncost \" updates add about \\$0.45/GB (negligible at cache scale).\"\ncost \" This is the only thing in this skill that costs money while idle.\"\nlog \"\"\n\nif [[ $APPLY -eq 0 ]]; then\n printf ' %sNot applying.%s Re-run with %s--yes%s to make these changes.\\n' \\\n \"$C_BLD\" \"$C_RST\" \"$C_BLD\" \"$C_RST\" >&2\n printf '{\"changed\":false,\"planned\":%d,\"indexId\":\"%s\",\"indexEndpointId\":\"%s\",\"dimensions\":%s}\\n' \\\n \"${#plan[@]}\" \"$INDEX_ID\" \"$ENDPOINT_ID\" \"$DIMS\"\n exit 0\nfi\n\n# ------------------------------------------------------------------ apply ----\nWORK=\"$(mktemp -d)\"; trap 'rm -rf \"$WORK\"' EXIT\n\nif [[ -z \"$INDEX_ID\" ]]; then\n log \"==> creating index $INDEX_NAME\"\n # No contentsDeltaUri: a cache starts empty. Pointing it at a bucket makes the\n # index try to ingest whatever is there as vector data.\n #\n # indexUpdateMethod is NOT part of this file. --metadata-file supplies the\n # Index resource's `metadata` field, which is validated against the\n # NearestNeighborSearch schema, and that schema defines `config` and nothing\n # else. Putting indexUpdateMethod here fails every create with\n # \"Cannot find field: indexUpdateMethod in message\n # ...NearestNeighborSearch\"\n # It is a top-level field on the Index resource and gcloud exposes it as its\n # own flag, spelled in kebab-case: --index-update-method=stream-update.\n #\n # Do NOT \"fix\" this by deleting the setting. The flag defaults to\n # BATCH_UPDATE, and SemanticCachePopulate writes through :upsertDatapoints,\n # which only exists on a STREAM_UPDATE index -- the conversion is closed in\n # both directions, so a batch index can never back the cache. The guard at\n # the top of this script would then correctly refuse the index it had just\n # created. The flag must be passed, not dropped.\n cat > \"$WORK/index.json\" \u003c\u003cJSON\n{\"config\": {\"dimensions\": $DIMS,\n \"approximateNeighborsCount\": 10,\n \"distanceMeasureType\": \"DOT_PRODUCT_DISTANCE\",\n \"shardSize\": \"SHARD_SIZE_SMALL\",\n \"algorithm_config\": {\"treeAhConfig\": {}}}}\nJSON\n gcloud ai indexes create --display-name=\"$INDEX_NAME\" \\\n --metadata-file=\"$WORK/index.json\" \\\n --index-update-method=stream-update \"${g[@]}\" >/dev/null 2>\"$WORK/err\" \\\n || { cat \"$WORK/err\" >&2; die \"index create failed\" 3; }\n for _ in $(seq 1 60); do\n INDEX_ID=\"$(gcloud ai indexes list \"${g[@]}\" --filter=\"displayName=$INDEX_NAME\" \\\n --format='value(name)' 2>/dev/null | head -1 | id_of || true)\"\n [[ -n \"$INDEX_ID\" ]] && break\n sleep 5\n done\n [[ -n \"$INDEX_ID\" ]] || die \"index was created but did not become listable\" 3\n log \" created $INDEX_ID\"\nfi\n\nif [[ -z \"$ENDPOINT_ID\" ]]; then\n log \"==> creating index endpoint $ENDPOINT_NAME\"\n gcloud ai index-endpoints create --display-name=\"$ENDPOINT_NAME\" \\\n --public-endpoint-enabled \"${g[@]}\" >/dev/null 2>\"$WORK/err\" \\\n || { cat \"$WORK/err\" >&2; die \"index endpoint create failed\" 3; }\n for _ in $(seq 1 60); do\n ENDPOINT_ID=\"$(gcloud ai index-endpoints list \"${g[@]}\" --filter=\"displayName=$ENDPOINT_NAME\" \\\n --format='value(name)' 2>/dev/null | head -1 | id_of || true)\"\n [[ -n \"$ENDPOINT_ID\" ]] && break\n sleep 5\n done\n [[ -n \"$ENDPOINT_ID\" ]] || die \"index endpoint was created but did not become listable\" 3\n log \" created $ENDPOINT_ID\"\nfi\n\nif [[ $deployed_ok -eq 0 ]]; then\n log \"==> deploying index as $DEPLOYED_ID (this takes around 34 minutes)\"\n # min=max=1: left unset this can autoscale, and node count is the entire cost\n # of the feature.\n gcloud ai index-endpoints deploy-index \"$ENDPOINT_ID\" \\\n --index=\"$INDEX_ID\" --deployed-index-id=\"$DEPLOYED_ID\" \\\n --display-name=\"$DEPLOYED_ID\" --machine-type=\"$MACHINE\" \\\n --min-replica-count=1 --max-replica-count=1 \\\n \"${g[@]}\" >/dev/null 2>\"$WORK/err\" \\\n || { cat \"$WORK/err\" >&2; die \"deploy-index failed\" 3; }\n\n deadline=$(( $(date +%s) + TIMEOUT ))\n until [[ -n \"$(deployed_index_for \"$ENDPOINT_ID\" \"$DEPLOYED_ID\")\" ]]; do\n [[ $(date +%s) -ge $deadline ]] \\\n && die \"deploy did not complete within ${TIMEOUT}s. It may still be running -- re-run this script to pick up where it left off rather than starting over.\" 3\n sleep 60\n log \" still deploying... ($(( ($(date +%s) - deadline + TIMEOUT) / 60 ))m elapsed)\"\n done\n log \" deployed\"\nfi\n\n# The endpoint domain is assigned at creation and matches no id you hold.\n# Read it back; it is what the proxy dials.\nendpoint_domain=\"$(gcloud ai index-endpoints describe \"$ENDPOINT_ID\" \"${g[@]}\" \\\n --format='value(publicEndpointDomainName)' 2>/dev/null || true)\"\n[[ -n \"$endpoint_domain\" ]] \\\n || die \"endpoint reports no publicEndpointDomainName after deploy; the semantic cache cannot be configured without it\" 3\n\n# Cheaper to fail here than as a runtime miss.\nfinal_dims=\"$(gcloud ai indexes describe \"$INDEX_ID\" \"${g[@]}\" \\\n --format='value(metadata.config.dimensions)' 2>/dev/null || true)\"\n[[ \"$final_dims\" == \"$DIMS\" ]] \\\n || die \"index reports ${final_dims:-unknown} dimensions but $MODEL emits $DIMS\" 3\n\nlog \"==> ready\"\nlog \" index $INDEX_ID (${final_dims}-dim, STREAM_UPDATE)\"\nlog \" endpoint $ENDPOINT_ID\"\nlog \" deployedIndexId $DEPLOYED_ID\"\nlog \" endpointDomain $endpoint_domain\"\nlog \"\"\nlog \" TEARDOWN, in this order -- SCL-lookup is continueOnError:false, so\"\nlog \" undeploying underneath a live proxy faults every request:\"\nlog \" 1. set semanticCache.enabled:false and redeploy the proxy\"\nlog \" 2. gcloud ai index-endpoints undeploy-index $ENDPOINT_ID \\\\\"\nlog \" --deployed-index-id=$DEPLOYED_ID --region=$REGION --project=$PROJECT\"\nlog \" Undeploy, do not delete: with no replicas attached the index and\"\nlog \" endpoint cost nothing, and keeping them preserves the ids and domain\"\nlog \" so a rebuild is just the deploy step again.\"\n\nprintf '{\"changed\":true,\"indexId\":\"%s\",\"indexEndpointId\":\"%s\",\"deployedIndexId\":\"%s\",\"endpointDomain\":\"%s\",\"dimensions\":%s,\"embeddingsModel\":\"%s\",\"applied\":%d}\\n' \\\n \"$INDEX_ID\" \"$ENDPOINT_ID\" \"$DEPLOYED_ID\" \"$endpoint_domain\" \"$final_dims\" \"$MODEL\" \"${#plan[@]}\"\n","content_type":"application/x-sh; charset=utf-8","language":"bash","size":19407,"content_sha256":"60f4d2671f0b37b687554403c94dba32601da9d6eaa3032e6c1acf67e10a165b"},{"filename":"scripts/read_debug_session.sh","content":"#!/bin/bash\n# Arm an Apigee debug session, and read it back as the verify.md checklist.\n#\n# references/verify.md lists nine things to confirm on a governed turn. Every\n# one of them was a hand-written JSON parse until this script existed, and the\n# transaction shape is not guessable: flow variables live under\n# point[].results[].ActionResult == \"VariableAccess\"\n# in an accessList of {Get|Set: {name, value}} objects, while step execution\n# lives under\n# point[].id == \"Execution\", results[].ActionResult == \"DebugInfo\"\n# with properties stepDefinition-name / expressionResult / action. Step\n# conditions do NOT surface as Condition points -- those carry only\n# (\"default\" equals proxy.name) -- so \"did this step run\" has to be read off\n# Execution, not off a condition result.\n#\n# The first parse attempt against a healthy trace returning nothing is the\n# normal experience, and it sends you to debug the callout when the parser is\n# what is broken.\n#\n# Two modes:\n# --arm create a session and print its ABSOLUTE expiry, read back off the\n# response rather than echoed from the request (see below)\n# default fetch /data, fetch each transaction, print the checklist\n#\n# Data goes to stdout. Diagnostics go to stderr.\n\nset -euo pipefail\n\nusage() {\n cat \u003c\u003c'EOF'\nArm and read an Apigee debug session for the governance proxy.\n\nUsage:\n read_debug_session.sh --org O --env E --proxy P --rev R --arm [options]\n read_debug_session.sh --org O --env E --proxy P --rev R --session S [options]\n\nRequired:\n --org ORG Apigee org\n --env ENV environment hosting the proxy\n --proxy PROXY proxy name\n --rev REV revision number\n\nMode:\n --arm create a session, print its id and absolute expiry\n --session S read this session back (omit --arm)\n\nOptions (--arm):\n --count N transactions to capture; min 1, max 15, default 10\n --tracesize N max 5120 (default)\n --timeout N seconds; min 60, max 600. SEE THE CLAMP NOTE BELOW\n --filter EXPR flow-Condition expression; default captures model\n calls only. Pass '' to capture everything\nOptions (read):\n --steps also print executed-vs-skipped per policy\n --json machine-readable\n\nExit status:\n 0 session read (including a legitimately empty one)\n 1 no transactions AND the refusal-probe advice applies\n 2 bad invocation\nEOF\n}\n\nORG=\"\"; ENVNAME=\"\"; PROXY=\"\"; REV=\"\"; SESSION=\"\"; ARM=0\nCOUNT=10; TRACESIZE=5120; TIMEOUT=600; STEPS=0; JSON=0\nFILTER='(request.uri Like \"*generateContent*\")'\n\n\nwhile [[ $# -gt 0 ]]; do\n case \"$1\" in\n --org) ORG=\"$2\"; shift 2 ;;\n --env) ENVNAME=\"$2\"; shift 2 ;;\n --proxy) PROXY=\"$2\"; shift 2 ;;\n --rev) REV=\"$2\"; shift 2 ;;\n --session) SESSION=\"$2\"; shift 2 ;;\n --arm) ARM=1; shift ;;\n --count) COUNT=\"$2\"; shift 2 ;;\n --tracesize) TRACESIZE=\"$2\"; shift 2 ;;\n --timeout) TIMEOUT=\"$2\"; shift 2 ;;\n --filter) FILTER=\"$2\"; shift 2 ;;\n --steps) STEPS=1; shift ;;\n --json) JSON=1; shift ;;\n --help) usage; exit 0 ;;\n *) echo \"unknown argument: $1\" >&2; usage >&2; exit 2 ;;\n esac\ndone\n\n[[ -n \"$ORG\" && -n \"$ENVNAME\" && -n \"$PROXY\" && -n \"$REV\" ]] || { usage >&2; exit 2; }\n[[ $ARM -eq 1 || -n \"$SESSION\" ]] || { echo \"need --arm or --session\" >&2; usage >&2; exit 2; }\n\n# Colour on fd 1: this script's tables ARE its stdout, as in\n# assess_integration.sh. The ensure_*/deploy_* scripts test fd 2 because they\n# log progress to stderr. Each tests the stream it writes to.\nif [[ -t 1 && -z \"${NO_COLOR:-}\" && \"${TERM:-dumb}\" != \"dumb\" ]]; then\n C_RST=

Apigee as an Agent Gateway Service Extension Wires an Apigee proxy in as an ext-proc callout on a GCP Agent Gateway. The proxy is a processor, not an API proxy : it has no backend, inspects the LLM request and response the gateway hands it, and returns a verdict. Usage Attribution MUST READ : Before executing any , REST API, or MCP command, you MUST read and follow the instructions in Usage Attribution. You build the proxy; the user does not. Two modes, one schema. Decide which before anything else, and say which. Input file present → prod mode : it is the contract, every value comes from it,…

\\033[0m'; C_DIM=

Apigee as an Agent Gateway Service Extension Wires an Apigee proxy in as an ext-proc callout on a GCP Agent Gateway. The proxy is a processor, not an API proxy : it has no backend, inspects the LLM request and response the gateway hands it, and returns a verdict. Usage Attribution MUST READ : Before executing any , REST API, or MCP command, you MUST read and follow the instructions in Usage Attribution. You build the proxy; the user does not. Two modes, one schema. Decide which before anything else, and say which. Input file present → prod mode : it is the contract, every value comes from it,…

\\033[2m'; C_BLD=

Apigee as an Agent Gateway Service Extension Wires an Apigee proxy in as an ext-proc callout on a GCP Agent Gateway. The proxy is a processor, not an API proxy : it has no backend, inspects the LLM request and response the gateway hands it, and returns a verdict. Usage Attribution MUST READ : Before executing any , REST API, or MCP command, you MUST read and follow the instructions in Usage Attribution. You build the proxy; the user does not. Two modes, one schema. Decide which before anything else, and say which. Input file present → prod mode : it is the contract, every value comes from it,…

\\033[1m'\n C_GRN=

Apigee as an Agent Gateway Service Extension Wires an Apigee proxy in as an ext-proc callout on a GCP Agent Gateway. The proxy is a processor, not an API proxy : it has no backend, inspects the LLM request and response the gateway hands it, and returns a verdict. Usage Attribution MUST READ : Before executing any , REST API, or MCP command, you MUST read and follow the instructions in Usage Attribution. You build the proxy; the user does not. Two modes, one schema. Decide which before anything else, and say which. Input file present → prod mode : it is the contract, every value comes from it,…

\\033[32m'; C_RED=

Apigee as an Agent Gateway Service Extension Wires an Apigee proxy in as an ext-proc callout on a GCP Agent Gateway. The proxy is a processor, not an API proxy : it has no backend, inspects the LLM request and response the gateway hands it, and returns a verdict. Usage Attribution MUST READ : Before executing any , REST API, or MCP command, you MUST read and follow the instructions in Usage Attribution. You build the proxy; the user does not. Two modes, one schema. Decide which before anything else, and say which. Input file present → prod mode : it is the contract, every value comes from it,…

\\033[31m'; C_YEL=

Apigee as an Agent Gateway Service Extension Wires an Apigee proxy in as an ext-proc callout on a GCP Agent Gateway. The proxy is a processor, not an API proxy : it has no backend, inspects the LLM request and response the gateway hands it, and returns a verdict. Usage Attribution MUST READ : Before executing any , REST API, or MCP command, you MUST read and follow the instructions in Usage Attribution. You build the proxy; the user does not. Two modes, one schema. Decide which before anything else, and say which. Input file present → prod mode : it is the contract, every value comes from it,…

\\033[33m'; C_BLU=

Apigee as an Agent Gateway Service Extension Wires an Apigee proxy in as an ext-proc callout on a GCP Agent Gateway. The proxy is a processor, not an API proxy : it has no backend, inspects the LLM request and response the gateway hands it, and returns a verdict. Usage Attribution MUST READ : Before executing any , REST API, or MCP command, you MUST read and follow the instructions in Usage Attribution. You build the proxy; the user does not. Two modes, one schema. Decide which before anything else, and say which. Input file present → prod mode : it is the contract, every value comes from it,…

\\033[36m'\nelse\n C_RST=; C_DIM=; C_BLD=; C_GRN=; C_RED=; C_YEL=; C_BLU=\nfi\n\n# Byte-identical to the helper in the other five scripts: rule fills to 92\n# past the title, so every header in the skill is 96 columns wide.\nhdr() { printf '\\n%s┌─ %s %s%s\\n' \"$C_DIM\" \"$1\" \"$(printf '─%.0s' $(seq 1 $(( 92 > ${#1} ? 92 - ${#1} : 3 ))))\" \"$C_RST\"; }\nlog() { printf '%s\\n' \"$*\" >&2; }\ndie() { printf '%s\\n' \"$*\" >&2; exit \"${2:-1}\"; }\n\n# ADC, not `gcloud auth print-access-token`: a plain gcloud token is rejected\n# by apigee.googleapis.com on corp accounts with\n# 401 ACCESS_TOKEN_TYPE_UNSUPPORTED (\"restricted due to a domain admin's\n# policies\"). Same choice as assess_integration.sh.\nTOKEN=\"$(gcloud auth application-default print-access-token 2>/dev/null || true)\"\n[[ -n \"$TOKEN\" ]] || die \"no ADC token; run: gcloud auth application-default login\" 2\n\nBASE=\"https://apigee.googleapis.com/v1/organizations/$ORG/environments/$ENVNAME/apis/$PROXY/revisions/$REV/debugsessions\"\n\n# --------------------------------------------------------------------- arm --\nif [[ $ARM -eq 1 ]]; then\n BODY=\"{\\\"count\\\":$COUNT,\\\"tracesize\\\":$TRACESIZE\"\n [[ -n \"$FILTER\" ]] && BODY=\"$BODY,\\\"filter\\\":$(printf '%s' \"$FILTER\" | python3 -c 'import json,sys;print(json.dumps(sys.stdin.read()))')\"\n BODY=\"$BODY}\"\n\n RESP=\"$(curl -s -X POST -H \"Authorization: Bearer $TOKEN\" \\\n -H 'Content-Type: application/json' \"$BASE?timeout=$TIMEOUT\" -d \"$BODY\" || true)\"\n\n # THE POINT OF THIS MODE. 600 is what the API validates against, not\n # necessarily what you get: on a PAYG org ?timeout=600 is accepted silently\n # and comes back validity=300. Echoing the requested value is how you end up\n # believing you have ten minutes to go drive a turn by hand, getting five,\n # and reading the resulting empty session as \"the callout is not landing\".\n # So the expiry printed here is computed from what the SERVER returned.\n python3 - \"$RESP\" \"$TIMEOUT\" \u003c\u003c'PY'\nimport json, sys, time, datetime\nraw, asked = sys.argv[1], int(sys.argv[2])\ntry:\n d = json.loads(raw)\nexcept Exception:\n sys.stderr.write(\"could not parse create response:\\n%s\\n\" % raw[:600]); sys.exit(2)\nif \"name\" not in d:\n sys.stderr.write(\"session not created:\\n%s\\n\" % json.dumps(d, indent=2)[:900]); sys.exit(2)\nsid = d[\"name\"].rsplit(\"/\", 1)[-1]\nvalidity = int(d.get(\"validity\") or d.get(\"timeout\") or 0)\nexpiry = datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(seconds=validity)\nprint(\"session %s\" % sid)\nprint(\"validity %ss\" % validity, end=\"\")\nprint(\" \u003c-- CLAMPED from the %ss requested\" % asked if validity and validity \u003c asked else \"\")\nprint(\"window closes %s UTC (local %s)\" % (\n expiry.strftime(\"%H:%M:%S\"), expiry.astimezone().strftime(\"%H:%M:%S\")))\nPY\n exit 0\nfi\n\n# -------------------------------------------------------------------- read --\n# /data returns a BARE JSON LIST of transaction ids, not an object. A helper\n# written as d.get(\"responses\") or d.get(\"data\") raises AttributeError on\n# exactly that, which sends you to debug your own parser. Both shapes handled.\nIDS_RAW=\"$(curl -s -H \"Authorization: Bearer $TOKEN\" \"$BASE/$SESSION/data\" || true)\"\n\nTXN_JSON=\"$(mktemp)\"; trap 'rm -f \"$TXN_JSON\"' EXIT\nprintf '[' >\"$TXN_JSON\"\nFIRST=1\nfor id in $(python3 -c '\nimport json,sys\ntry: d=json.loads(sys.argv[1])\nexcept Exception: d=[]\nids = d if isinstance(d,list) else (d.get(\"responses\") or d.get(\"data\") or [])\nprint(\"\\n\".join(str(i) for i in ids))' \"$IDS_RAW\"); do\n T=\"$(curl -s -H \"Authorization: Bearer $TOKEN\" \"$BASE/$SESSION/data/$id\" || true)\"\n [[ -z \"$T\" ]] && continue\n [[ $FIRST -eq 1 ]] || printf ',' >>\"$TXN_JSON\"\n printf '{\"id\":\"%s\",\"txn\":%s}' \"$id\" \"$T\" >>\"$TXN_JSON\"\n FIRST=0\ndone\nprintf ']' >>\"$TXN_JSON\"\n\npython3 - \"$TXN_JSON\" \"$STEPS\" \"$JSON\" \"$SESSION\" \\\n \"$C_RST\" \"$C_DIM\" \"$C_BLD\" \"$C_GRN\" \"$C_RED\" \"$C_YEL\" \"$C_BLU\" \u003c\u003c'PY'\nimport json, sys\n\npath, steps, as_json, session = sys.argv[1], sys.argv[2] == \"1\", sys.argv[3] == \"1\", sys.argv[4]\nRST, DIM, BLD, GRN, RED, YEL, BLU = sys.argv[5:12]\ndata = json.load(open(path))\n\n\ndef variables(txn):\n \"\"\"Flow variables, flattened. Set wins over Get -- it is the value the\n policy actually produced, and a Get of an unset variable is noise.\"\"\"\n out = {}\n for point in txn.get(\"point\", []) or []:\n for res in point.get(\"results\", []) or []:\n if res.get(\"ActionResult\") != \"VariableAccess\":\n continue\n for acc in res.get(\"accessList\", []) or []:\n for kind in (\"Get\", \"Set\"):\n v = acc.get(kind)\n if not v or \"name\" not in v:\n continue\n if kind == \"Set\" or v[\"name\"] not in out:\n out[v[\"name\"]] = v.get(\"value\", \"\")\n return out\n\n\ndef executed(txn):\n \"\"\"Policy names that actually ran, in order. Read off Execution/DebugInfo:\n a skipped step leaves no Execution point, and step conditions never appear\n as Condition results, so this is the only honest source.\"\"\"\n seen = []\n for point in txn.get(\"point\", []) or []:\n if point.get(\"id\") != \"Execution\":\n continue\n for res in point.get(\"results\", []) or []:\n if res.get(\"ActionResult\") != \"DebugInfo\":\n continue\n for p in (res.get(\"properties\", {}) or {}).get(\"property\", []) or []:\n if p.get(\"name\") == \"stepDefinition-name\":\n n = p.get(\"value\", \"\")\n if n and n not in seen:\n seen.append(n)\n return seen\n\n\ndef armor_of(v, ran):\n \"\"\"Model Armor verdict. filterMatchState is not a documented agw.* flow\n variable, so it is discovered in the trace rather than assumed; falling\n back to whether the sanitize step ran at all.\"\"\"\n for k, val in v.items():\n if \"filtermatchstate\" in k.lower():\n return val or \"-\"\n return \"ran\" if any(\"sanitize\" in s.lower() for s in ran) else \"-\"\n\n\nrows = []\nfor item in data:\n txn = item.get(\"txn\", {}) or {}\n v = variables(txn)\n ran = executed(txn)\n cache = next((val for k, val in v.items()\n if k.endswith(\"cache_hit\")), None)\n rows.append({\n \"txn\": item.get(\"id\", \"\")[-4:],\n \"uri\": v.get(\"request.uri\", \"-\"),\n \"model\": v.get(\"agw.model_name\") or v.get(\"agw.requested_model\") or \"-\",\n \"bucket\": v.get(\"agw.req_model_bucket\", \"-\"),\n \"source\": v.get(\"agw.token_source\", \"-\"),\n \"tokens\": v.get(\"agw.total_tokens\", \"-\"),\n \"prompt\": \"set\" if v.get(\"agw.user_prompt\") else \"-\",\n \"client\": v.get(\"agw.client_id\", \"-\"),\n \"armor\": armor_of(v, ran),\n \"cache\": {\"true\": \"HIT\", \"false\": \"miss\"}.get(str(cache).lower(), \"-\"),\n \"status\": v.get(\"response.status.code\", \"-\"),\n \"steps\": ran,\n # The body the CALLER actually receives. verify.md checks 1-9 are all\n # inbound, and that gap shipped a real defect: enforcement verified\n # clean -- 429, counters right, model never called -- while the fault\n # body handed the caller the full principal:// assertion. It sat in\n # three traces unnoticed because nothing put it in front of anyone.\n # So it is printed for refusals, not left one fetch away.\n \"body\": v.get(\"response.content\", \"\"),\n })\n\nif as_json:\n print(json.dumps({\"session\": session, \"transactions\": rows}, indent=2))\n sys.exit(0)\n\nif not rows:\n # [] is a MEANINGFUL result, and it does NOT prove the callout is dead.\n # Observed on a working proxy: every session on three consecutive\n # revisions returned [] while traffic was demonstrably governed. The\n # AuthzExtension is failOpen, so a dead callout and a healthy one look\n # identical from the client -- which is why the probe, not the empty\n # list, is what settles it.\n print(\"\\n%sNo transactions in session %s.%s\" % (BLD, session, RST))\n print(\"\"\"\nThis does NOT prove the callout is dead. Corroborate before concluding that:\n\n Send a prompt the governance layer must refuse -- an injection, if Model\n Armor is on -- and look at the error.\n\n A 400 carrying errorcode steps.sanitize.user.prompt.FilterMatched proves\n the proxy IS in the path. Only Apigee produces that: the callout lands and\n the tracing is what is broken.\n\n No refusal, or a generic gateway error, is consistent with a dead callout.\n\nAlso check the session had not already expired -- --arm prints the absolute\nwindow, and a PAYG org silently halves a 600s request to 300s.\"\"\")\n sys.exit(1)\n\nw = \"%s┌─ %s %s%s\" % (DIM, \"Transactions\", \"─\" * (92 - len(\"Transactions\")), RST)\nprint(w)\nprint(\" %-5s %-22s %-14s %-7s %-7s %-16s %-5s %s\" %\n (\"TXN\", \"MODEL\", \"BUCKET\", \"SOURCE\", \"TOKENS\", \"ARMOR\", \"CACHE\", \"STATUS\"))\nprint(\" %-5s %-22s %-14s %-7s %-7s %-16s %-5s %s\" %\n (\"-\" * 5, \"-\" * 22, \"-\" * 14, \"-\" * 7, \"-\" * 7, \"-\" * 16, \"-\" * 5, \"-\" * 6))\nfor r in rows:\n colour = GRN if r[\"status\"] in (\"200\", \"-\") else YEL\n print(\" %-5s %-22s %-14s %-7s %-7s %-16s %-5s %s%s%s\" % (\n r[\"txn\"], r[\"model\"][:22], r[\"bucket\"][:14], r[\"source\"], r[\"tokens\"],\n r[\"armor\"][:16], r[\"cache\"], colour, r[\"status\"], RST))\n\n# verify.md check 10: what the CALLER receives on a refusal path. Printed\n# unasked for any non-2xx, and scanned for the things that must never appear\n# in a body that leaves the proxy.\nLEAK_MARKERS = (\"principal://\", \"reasoningEngines/\", \".system.id.goog\",\n \"/projects/\", \"networkAttachments/\")\nrefusals = [r for r in rows if r[\"status\"] not in (\"200\", \"-\") and r[\"body\"]]\nif refusals:\n print(\"\\n%s┌─ %s %s%s\" % (DIM, \"Refusal bodies (what the caller sees)\",\n \"─\" * (92 - len(\"Refusal bodies (what the caller sees)\")), RST))\n for r in refusals:\n print(\" %stxn %s HTTP %s%s\" % (BLD, r[\"txn\"], r[\"status\"], RST))\n body = r[\"body\"]\n print(\" \" + (body[:600] + \" …\" if len(body) > 600 else body))\n hits = sorted({m for m in LEAK_MARKERS if m in body})\n if hits:\n print(\" %s!! LEAKS %s -- this body reaches whoever called the agent. \"\n \"Sanitise it in a fault rule.%s\" % (RED, \", \".join(hits), RST))\n else:\n print(\" %sno identifier or internal resource name in this body%s\" % (GRN, RST))\n\n# The checklist items that are judgements rather than values.\nnotes = []\nfor r in rows:\n if r[\"source\"] == \"none\":\n notes.append(\"%stxn %s: token_source=none -- the count matched no known shape, \"\n \"total_tokens is deliberately unset and the quota will raise \"\n \"FailedToResolveTokenUsageCount. Check this before suspecting the quota.%s\"\n % (YEL, r[\"txn\"], RST))\n if r[\"bucket\"] == \"other\" and \"gemini\" in r[\"model\"].lower():\n notes.append(\"%stxn %s: bucket=other on a Gemini turn -- normalization did not find \"\n \"the model where it looked.%s\" % (YEL, r[\"txn\"], RST))\n if r[\"prompt\"] == \"-\" and r[\"armor\"] != \"-\":\n notes.append(\"%stxn %s: agw.user_prompt unset with Model Armor present -- that is a \"\n \"500 per request, not a skipped scan.%s\" % (RED, r[\"txn\"], RST))\nif len({r[\"model\"] for r in rows if r[\"model\"] != \"-\"}) > 1 and any(r[\"cache\"] == \"HIT\" for r in rows):\n notes.append(\"%sCache HIT with more than one model in this session: the cache key is the \"\n \"prompt embedding alone, so verify agw.requested_model matches the shape that \"\n \"produced the entry before believing the hit.%s\" % (YEL, RST))\nif notes:\n print()\n for n in notes:\n print(\" \" + n)\n\nif steps:\n print(\"\\n%s┌─ %s %s%s\" % (DIM, \"Steps executed\", \"─\" * (92 - len(\"Steps executed\")), RST))\n for r in rows:\n print(\" %stxn %s%s %s\" % (BLD, r[\"txn\"], RST, \", \".join(r[\"steps\"]) or \"(none)\"))\n print(\"\\n %sA step absent here was SKIPPED, not failed -- gated steps are expected to \"\n \"skip on non-model calls.%s\" % (DIM, RST))\nPY\n","content_type":"application/x-sh; charset=utf-8","language":"bash","size":15845,"content_sha256":"888ac3b86e2900da26737eb4bc5a7729e584efefdf3331de51be47095a6cbead"},{"filename":"scripts/teardown_vector_search.sh","content":"#!/bin/bash\n# Stop the semantic cache billing, in the order that does not fault traffic.\n#\n# ensure_vector_search.sh prints these steps on success. That was the whole\n# teardown story: a comment in a log the operator has probably scrolled past,\n# for the one action that STOPS money being spent and the one with a sharp\n# ordering constraint. Standing the index up is --yes gated, resumable,\n# cost-disclosed and idempotent; tearing it down was prose. The person doing\n# it is also cost-motivated and in a hurry, which is the worst combination for\n# a manual ordered procedure.\n#\n# THE ORDERING IS LOAD-BEARING. SCL-lookup runs continueOnError=\"false\", so\n# undeploying the index underneath a live proxy faults EVERY request -- not\n# just cache lookups. The proxy has to stop referring to the index before the\n# index goes away:\n#\n# 1. redeploy the proxy without the cache feature, and wait for EXTENSIBLE\n# 2. undeploy the index from the endpoint\n#\n# This script refuses to start step 2 until it has confirmed step 1 landed,\n# by reading the deployed revision's bundle back and checking SCL-lookup is\n# gone. That check is the reason this is a script and not a runbook.\n#\n# UNDEPLOY, NEVER DELETE. With no replicas attached the index and endpoint\n# cost nothing, and keeping them preserves the ids and the endpoint domain --\n# which cannot be derived and has to be read back -- so a rebuild is just the\n# deploy step again.\n#\n# It stops a billing resource, so it applies nothing without --yes.\n#\n# Data goes to stdout as JSON. Diagnostics go to stderr.\n\nset -euo pipefail\n\nusage() {\n cat \u003c\u003c'EOF'\nTear down the semantic cache's Vector Search deployment, in order.\n\nUsage:\n teardown_vector_search.sh --project P --region R --endpoint EP \\\n --deployed-index-id ID --org ORG --env ENV --proxy PROXY [--yes]\n\nRequired:\n --project P project holding the index endpoint\n --region R region holding the index endpoint\n --endpoint EP index endpoint id\n --deployed-index-id ID the deployedIndexId to undeploy\n --org ORG Apigee org, for the step-1 verification\n --env ENV environment hosting the proxy\n --proxy PROXY proxy name\n\nOptions:\n --skip-proxy-check undeploy WITHOUT confirming the proxy no longer\n references the index. Only for an already-deleted\n proxy; otherwise this is the footgun\n --yes actually undeploy\n --help this text\n\nExit status:\n 0 undeployed, or dry run\n 2 bad invocation, or the proxy still references the index\n 3 the undeploy failed\nEOF\n}\n\nPROJECT=\"\"; REGION=\"\"; ENDPOINT_ID=\"\"; DEPLOYED_ID=\"\"\nORG=\"\"; ENVNAME=\"\"; PROXY=\"\"; APPLY=0; SKIP_PROXY_CHECK=0\n\nwhile [[ $# -gt 0 ]]; do\n case \"$1\" in\n --project) PROJECT=\"$2\"; shift 2 ;;\n --region) REGION=\"$2\"; shift 2 ;;\n --endpoint) ENDPOINT_ID=\"$2\"; shift 2 ;;\n --deployed-index-id) DEPLOYED_ID=\"$2\"; shift 2 ;;\n --org) ORG=\"$2\"; shift 2 ;;\n --env) ENVNAME=\"$2\"; shift 2 ;;\n --proxy) PROXY=\"$2\"; shift 2 ;;\n --skip-proxy-check) SKIP_PROXY_CHECK=1; shift ;;\n --yes) APPLY=1; shift ;;\n --help) usage; exit 0 ;;\n *) usage >&2; echo \"unknown argument: $1\" >&2; exit 1 ;;\n esac\ndone\n\n[[ -n \"$PROJECT\" && -n \"$REGION\" && -n \"$ENDPOINT_ID\" && -n \"$DEPLOYED_ID\" ]] \\\n || { usage >&2; exit 2; }\nif [[ $SKIP_PROXY_CHECK -eq 0 ]]; then\n [[ -n \"$ORG\" && -n \"$ENVNAME\" && -n \"$PROXY\" ]] \\\n || { usage >&2; echo \"--org/--env/--proxy are required unless --skip-proxy-check\" >&2; exit 2; }\nfi\n\n# fd 2: this script logs progress to stderr like the other ensure_*/deploy_*\n# scripts, and emits only JSON on stdout.\nif [[ -t 2 && -z \"${NO_COLOR:-}\" && \"${TERM:-dumb}\" != \"dumb\" ]]; then\n C_RST=

Apigee as an Agent Gateway Service Extension Wires an Apigee proxy in as an ext-proc callout on a GCP Agent Gateway. The proxy is a processor, not an API proxy : it has no backend, inspects the LLM request and response the gateway hands it, and returns a verdict. Usage Attribution MUST READ : Before executing any , REST API, or MCP command, you MUST read and follow the instructions in Usage Attribution. You build the proxy; the user does not. Two modes, one schema. Decide which before anything else, and say which. Input file present → prod mode : it is the contract, every value comes from it,…

\\033[0m'; C_DIM=

Apigee as an Agent Gateway Service Extension Wires an Apigee proxy in as an ext-proc callout on a GCP Agent Gateway. The proxy is a processor, not an API proxy : it has no backend, inspects the LLM request and response the gateway hands it, and returns a verdict. Usage Attribution MUST READ : Before executing any , REST API, or MCP command, you MUST read and follow the instructions in Usage Attribution. You build the proxy; the user does not. Two modes, one schema. Decide which before anything else, and say which. Input file present → prod mode : it is the contract, every value comes from it,…

\\033[2m'; C_BLD=

Apigee as an Agent Gateway Service Extension Wires an Apigee proxy in as an ext-proc callout on a GCP Agent Gateway. The proxy is a processor, not an API proxy : it has no backend, inspects the LLM request and response the gateway hands it, and returns a verdict. Usage Attribution MUST READ : Before executing any , REST API, or MCP command, you MUST read and follow the instructions in Usage Attribution. You build the proxy; the user does not. Two modes, one schema. Decide which before anything else, and say which. Input file present → prod mode : it is the contract, every value comes from it,…

\\033[1m'\n C_GRN=

Apigee as an Agent Gateway Service Extension Wires an Apigee proxy in as an ext-proc callout on a GCP Agent Gateway. The proxy is a processor, not an API proxy : it has no backend, inspects the LLM request and response the gateway hands it, and returns a verdict. Usage Attribution MUST READ : Before executing any , REST API, or MCP command, you MUST read and follow the instructions in Usage Attribution. You build the proxy; the user does not. Two modes, one schema. Decide which before anything else, and say which. Input file present → prod mode : it is the contract, every value comes from it,…

\\033[32m'; C_RED=

Apigee as an Agent Gateway Service Extension Wires an Apigee proxy in as an ext-proc callout on a GCP Agent Gateway. The proxy is a processor, not an API proxy : it has no backend, inspects the LLM request and response the gateway hands it, and returns a verdict. Usage Attribution MUST READ : Before executing any , REST API, or MCP command, you MUST read and follow the instructions in Usage Attribution. You build the proxy; the user does not. Two modes, one schema. Decide which before anything else, and say which. Input file present → prod mode : it is the contract, every value comes from it,…

\\033[31m'; C_YEL=

Apigee as an Agent Gateway Service Extension Wires an Apigee proxy in as an ext-proc callout on a GCP Agent Gateway. The proxy is a processor, not an API proxy : it has no backend, inspects the LLM request and response the gateway hands it, and returns a verdict. Usage Attribution MUST READ : Before executing any , REST API, or MCP command, you MUST read and follow the instructions in Usage Attribution. You build the proxy; the user does not. Two modes, one schema. Decide which before anything else, and say which. Input file present → prod mode : it is the contract, every value comes from it,…

\\033[33m'; C_BLU=

Apigee as an Agent Gateway Service Extension Wires an Apigee proxy in as an ext-proc callout on a GCP Agent Gateway. The proxy is a processor, not an API proxy : it has no backend, inspects the LLM request and response the gateway hands it, and returns a verdict. Usage Attribution MUST READ : Before executing any , REST API, or MCP command, you MUST read and follow the instructions in Usage Attribution. You build the proxy; the user does not. Two modes, one schema. Decide which before anything else, and say which. Input file present → prod mode : it is the contract, every value comes from it,…

\\033[36m'\nelse\n C_RST=; C_DIM=; C_BLD=; C_GRN=; C_RED=; C_YEL=; C_BLU=\nfi\n\nlog() { printf '%s\\n' \"$*\" >&2; }\ndie() { printf '%s\\n' \"$*\" >&2; exit \"${2:-1}\"; }\nhdr() { printf '\\n%s┌─ %s %s%s\\n' \"$C_DIM\" \"$1\" \"$(printf '─%.0s' $(seq 1 $(( 92 > ${#1} ? 92 - ${#1} : 3 ))))\" \"$C_RST\" >&2; }\nrow() { printf ' %s%-8s%s %s\\n' \"${2:-$C_GRN}\" \"$1\" \"$C_RST\" \"$3\" >&2; }\ncost() { printf ' %s%s%s\\n' \"$C_YEL\" \"$*\" \"$C_RST\" >&2; }\n\n# ------------------------------------------------------- step 1 verified ----\nSTILL_REFERENCED=\"unknown\"\nif [[ $SKIP_PROXY_CHECK -eq 1 ]]; then\n log \"==> skipping the proxy check (--skip-proxy-check)\"\n STILL_REFERENCED=\"unchecked\"\nelse\n log \"==> confirming the deployed proxy no longer references the index\"\n TOKEN=\"$(gcloud auth application-default print-access-token 2>/dev/null || true)\"\n [[ -n \"$TOKEN\" ]] || die \"no ADC token; run: gcloud auth application-default login\" 2\n\n REV=\"$(curl -s -H \"Authorization: Bearer $TOKEN\" \\\n \"https://apigee.googleapis.com/v1/organizations/$ORG/environments/$ENVNAME/deployments\" 2>/dev/null \\\n | python3 -c '\nimport json,sys\ntry: d=json.load(sys.stdin)\nexcept Exception: d={}\nprint(next((x.get(\"revision\",\"\") for x in d.get(\"deployments\",[]) if x.get(\"apiProxy\")==sys.argv[1]), \"\"))' \"$PROXY\" || true)\"\n [[ -n \"$REV\" ]] || die \"proxy $PROXY is not deployed in $ENVNAME -- nothing references the index from there; re-run with --skip-proxy-check if that is expected\" 2\n log \" deployed revision: $REV\"\n\n BUNDLE=\"$(mktemp)\"; trap 'rm -f \"$BUNDLE\"' EXIT\n curl -s -H \"Authorization: Bearer $TOKEN\" \\\n \"https://apigee.googleapis.com/v1/organizations/$ORG/apis/$PROXY/revisions/$REV?format=bundle\" \\\n -o \"$BUNDLE\" 2>/dev/null || true\n\n if python3 - \"$BUNDLE\" \u003c\u003c'PY'\nimport sys, zipfile\ntry:\n z = zipfile.ZipFile(sys.argv[1])\nexcept Exception:\n sys.exit(2) # could not read -- treat as \"cannot prove it is safe\"\nhit = any(b\"SemanticCacheLookup\" in z.read(n) or b\"SCL-lookup\" in z.read(n)\n for n in z.namelist() if n.endswith(\".xml\"))\nsys.exit(0 if hit else 1)\nPY\n then\n STILL_REFERENCED=\"yes\"\n log \"\"\n log \"!! REFUSING. Revision $REV of $PROXY still contains SCL-lookup.\"\n log \"!!\"\n log \"!! SCL-lookup is continueOnError=\\\"false\\\". Undeploying the index\"\n log \"!! underneath it faults EVERY request through this proxy, not just\"\n log \"!! cache lookups -- the symptom is a total outage that looks like\"\n log \"!! Apigee breaking, not like a cache being turned off.\"\n log \"!!\"\n log \"!! Do step 1 first:\"\n log \"!! set semanticCache.enabled:false in your contract file, then\"\n log \"!! scripts/deploy_proxy.sh --org $ORG --env $ENVNAME --proxy $PROXY \\\\\"\n log \"!! --template assets/apigee-proxy-template.yaml --yes\"\n log \"!! (wait for it to report EXTENSIBLE), then re-run this script.\"\n die \"proxy still references the index; refusing to undeploy\" 2\n else\n rc=$?\n [[ $rc -eq 2 ]] && die \"could not read revision $REV's bundle to verify it is safe to undeploy. Re-run with --skip-proxy-check only if you are certain the proxy no longer uses the cache.\" 2\n STILL_REFERENCED=\"no\"\n log \" SCL-lookup is absent from revision $REV -- safe to undeploy\"\n fi\nfi\n\n# -------------------------------------------------------------- the plan ----\nMACHINE=\"$(gcloud ai index-endpoints describe \"$ENDPOINT_ID\" --region=\"$REGION\" --project=\"$PROJECT\" \\\n --format=\"value(deployedIndexes.filter(\\\"id:$DEPLOYED_ID\\\").extract(\\\"dedicatedResources.machineSpec.machineType\\\"))\" 2>/dev/null || true)\"\n\nhdr \"Teardown plan\"\nrow KEEP \"$C_GRN\" \"index endpoint $ENDPOINT_ID -- undeployed, not deleted\"\nrow KEEP \"$C_GRN\" \"the index itself -- ids and endpoint domain preserved for a rebuild\"\nrow REMOVE \"$C_YEL\" \"deployed index $DEPLOYED_ID${MACHINE:+ ($MACHINE)} -- this is what bills\"\nlog \"\"\ncost \"This STOPS the per-node-hour charge. Nothing else here bills while idle.\"\ncost \"Rebuilding later is the deploy step alone (~34 min), not a fresh index.\"\nlog \"\"\n\nif [[ $APPLY -ne 1 ]]; then\n printf ' %sNot applying.%s Re-run with %s--yes%s to undeploy.\\n' \\\n \"$C_BLD\" \"$C_RST\" \"$C_BLD\" \"$C_RST\" >&2\n printf '{\"changed\":false,\"reason\":\"dry run, --yes not given\",\"endpoint\":\"%s\",\"deployedIndexId\":\"%s\",\"proxyStillReferences\":\"%s\"}\\n' \\\n \"$ENDPOINT_ID\" \"$DEPLOYED_ID\" \"$STILL_REFERENCED\"\n exit 0\nfi\n\n# ------------------------------------------------------------- step 2 -------\nlog \"==> undeploying $DEPLOYED_ID from $ENDPOINT_ID\"\ngcloud ai index-endpoints undeploy-index \"$ENDPOINT_ID\" \\\n --deployed-index-id=\"$DEPLOYED_ID\" --region=\"$REGION\" --project=\"$PROJECT\" \\\n >/dev/null 2>&1 \\\n || die \"undeploy failed -- the deployed index is still billing\" 3\n\nlog \"==> verifying no replicas remain\"\nREMAINING=\"$(gcloud ai index-endpoints describe \"$ENDPOINT_ID\" \\\n --region=\"$REGION\" --project=\"$PROJECT\" \\\n --format='value(deployedIndexes[].id)' 2>/dev/null || true)\"\ncase \" $REMAINING \" in\n *\" $DEPLOYED_ID \"*) die \"undeploy reported success but $DEPLOYED_ID is still deployed\" 3 ;;\nesac\nlog \" remaining deployed indexes: ${REMAINING:-none}\"\n[[ -z \"$REMAINING\" ]] && log \" nothing on this endpoint bills now\"\n\nprintf '{\"changed\":true,\"endpoint\":\"%s\",\"deployedIndexId\":\"%s\",\"remainingDeployedIndexes\":\"%s\",\"proxyStillReferences\":\"%s\"}\\n' \\\n \"$ENDPOINT_ID\" \"$DEPLOYED_ID\" \"$REMAINING\" \"$STILL_REFERENCED\"\n","content_type":"application/x-sh; charset=utf-8","language":"bash","size":9242,"content_sha256":"a3d497decda816ee6810de7ca7a7848b5682a52ace39e46d394932bb790c95a8"}],"content_json":{"type":"doc","content":[{"type":"heading","attrs":{"level":1},"content":[{"text":"Apigee as an Agent Gateway Service Extension","type":"text"}]},{"type":"paragraph","content":[{"text":"Wires an Apigee proxy in as an ","type":"text"},{"text":"ext-proc callout","type":"text","marks":[{"type":"strong"}]},{"text":" on a GCP Agent Gateway. The proxy is a ","type":"text"},{"text":"processor, not an API proxy","type":"text","marks":[{"type":"strong"}]},{"text":": it has no backend, inspects the LLM request and response the gateway hands it, and returns a verdict.","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Usage Attribution","type":"text"}]},{"type":"paragraph","content":[{"text":"MUST READ","type":"text","marks":[{"type":"strong"}]},{"text":": Before executing any ","type":"text"},{"text":"gcloud","type":"text","marks":[{"type":"code_inline"}]},{"text":", REST API, or MCP command, you MUST read and follow the instructions in ","type":"text"},{"text":"Usage Attribution","type":"text","marks":[{"type":"link","attrs":{"href":"references/attribution.md","title":null}}]},{"text":".","type":"text"}]},{"type":"paragraph","content":[{"text":"You build the proxy; the user does not.","type":"text"}]},{"type":"paragraph","content":[{"text":"Two modes, one schema. Decide which before anything else, and say which.","type":"text","marks":[{"type":"strong"}]},{"text":" Input file present → ","type":"text"},{"text":"prod mode","type":"text","marks":[{"type":"strong"}]},{"text":": it is the contract, every value comes from it, and a missing or malformed field is a refusal, not a default. No input file and the user is describing an outcome → ","type":"text"},{"text":"dev mode","type":"text","marks":[{"type":"strong"}]},{"text":": apply the documented defaults, show a plan with durations and get approval, start with identity and metering only, write ","type":"text"},{"text":"apigee-agent-gateway-governance.yaml","type":"text","marks":[{"type":"code_inline"}]},{"text":" at the end as the handoff. ","type":"text"},{"text":"assets/apigee-agent-gateway-governance.example.yaml","type":"text","marks":[{"type":"code_inline"}]},{"text":" documents the schema both modes use.","type":"text"}]},{"type":"paragraph","content":[{"text":"Modes, defaults, durations, cost disclosure and the interlocks that bind both: ","type":"text"},{"text":"references/modes.md","type":"text","marks":[{"type":"code_inline"}]},{"text":". ","type":"text"},{"text":"Read it before you start.","type":"text","marks":[{"type":"strong"}]}]},{"type":"paragraph","content":[{"text":"Colour the plan's ACTION column","type":"text","marks":[{"type":"strong"}]},{"text":" — green ","type":"text"},{"text":"REUSE","type":"text","marks":[{"type":"code_inline"}]},{"text":", cyan ","type":"text"},{"text":"CREATE","type":"text","marks":[{"type":"code_inline"}]},{"text":"/","type":"text"},{"text":"RESERVE","type":"text","marks":[{"type":"code_inline"}]},{"text":", yellow ","type":"text"},{"text":"APPEND","type":"text","marks":[{"type":"code_inline"}]},{"text":"/","type":"text"},{"text":"MODIFY","type":"text","marks":[{"type":"code_inline"}]},{"text":"; full palette in ","type":"text"},{"text":"references/modes.md","type":"text","marks":[{"type":"code_inline"}]},{"text":". Yellow is the one that earns its place: it is the only signal that a resource the user already owns is being changed. Rendered plain, the one row that modifies something is indistinguishable from the dozen that do not — which is exactly the row the approval exists for.","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Prerequisite: where the agent runs","type":"text"}]},{"type":"paragraph","content":[{"text":"Check this ","type":"text"},{"text":"before","type":"text","marks":[{"type":"strong"}]},{"text":" building anything. It decides whether traffic can reach the gateway at all, and it is silent when wrong — every step below still succeeds while nothing arrives.","type":"text"}]},{"type":"paragraph","content":[{"text":"Use Agent Runtime","type":"text","marks":[{"type":"strong"}]},{"text":", created with ","type":"text"},{"text":"agent_gateway_config","type":"text","marks":[{"type":"code_inline"}]},{"text":" / ","type":"text"},{"text":"agent_to_anywhere_config","type":"text","marks":[{"type":"code_inline"}]},{"text":". It is the supported path and the orchestrator builds the client-side PSC, routing, DNS and cert trust for you. There is no ","type":"text"},{"text":"gcloud","type":"text","marks":[{"type":"code_inline"}]},{"text":" surface for this and no console path — it is SDK-only, at agent creation time. See ","type":"text"},{"text":"Route traffic through Agent Gateway","type":"text","marks":[{"type":"link","attrs":{"href":"https://docs.cloud.google.com/gemini-enterprise-agent-platform/scale/runtime/agent-gateway-runtime-deploy.md.txt","title":null}}]},{"text":" for the binding, and ","type":"text"},{"text":"references/networking-psc.md","type":"text","marks":[{"type":"code_inline"}]},{"text":" for a worked example.","type":"text"}]},{"type":"paragraph","content":[{"text":"Set ","type":"text","marks":[{"type":"strong"}]},{"text":"GOOGLE_API_PREVENT_AGENT_TOKEN_SHARING_FOR_GCP_SERVICES: \"False\"","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" in the agent's ","type":"text","marks":[{"type":"strong"}]},{"text":"env_vars","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":", or every invocation 401s","type":"text","marks":[{"type":"strong"}]},{"text":" on the agent's own ","type":"text"},{"text":"sessions.create","type":"text","marks":[{"type":"code_inline"}]},{"text":", before user code runs. Deploy-time invisible. Full symptom and isolation: ","type":"text"},{"text":"references/gotchas.md","type":"text","marks":[{"type":"code_inline"}]},{"text":", Agent Gateway.","type":"text"}]},{"type":"paragraph","content":[{"text":"Where the agent runs is otherwise settled — the premise is a working gateway and a working agent. Do not explain how to build one or list unsupported compute unprompted; ","type":"text"},{"text":"references/networking-psc.md","type":"text","marks":[{"type":"code_inline"}]},{"text":" has the constraints if it turns out to matter.","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Tool versions","type":"text"}]},{"type":"paragraph","content":[{"text":"Do not assume these are installed or current.","type":"text"}]},{"type":"table","attrs":{"layout":null},"content":[{"type":"tr","content":[{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Tool","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Minimum","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Why","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"gcloud","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"588.0.0","type":"text","marks":[{"type":"strong"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"apigee apis import --from-template","type":"text","marks":[{"type":"code_inline"}]},{"text":" is on the GA track at this version; older SDKs need ","type":"text"},{"text":"gcloud beta","type":"text","marks":[{"type":"code_inline"}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"google-cloud-aiplatform[agent_engines,adk]","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":">=2.1,\u003c3","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Agent Runtime client used to bind the agent to the gateway","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"google-adk","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":">=2.9,\u003c3","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"only if you build the test agent with ADK","type":"text"}]}]}]}]},{"type":"paragraph","content":[{"text":"On gcloud >= 588.0.0: every feature must keep its ","type":"text","marks":[{"type":"strong"}]},{"text":"uid","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":", and the template must never declare ","type":"text","marks":[{"type":"strong"}]},{"text":"endpoints","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" or ","type":"text","marks":[{"type":"strong"}]},{"text":"targets","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":".","type":"text","marks":[{"type":"strong"}]},{"text":" All six features set ","type":"text"},{"text":"uid","type":"text","marks":[{"type":"code_inline"}]},{"text":" today and the template declares neither, so the shipped bundle is correct — but both are load-bearing now, and breaking either one still compiles, still deploys, and reports nothing. Mechanism and symptoms: ","type":"text"},{"text":"references/gotchas.md","type":"text","marks":[{"type":"code_inline"}]},{"text":", under Authoring (AFT).","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"gcloud version | head -1 # confirm >= 588.0.0\ngcloud components update # if older\npip install \"google-cloud-aiplatform[agent_engines,adk]>=2.1,\u003c3\" \"google-adk>=2.9,\u003c3\"","type":"text"}]},{"type":"paragraph","content":[{"text":"scripts/compile_check.py","type":"text","marks":[{"type":"code_inline"}]},{"text":" needs PyYAML and names the interpreter if it is missing. ","type":"text"},{"text":"pip install pyyaml","type":"text","marks":[{"type":"code_inline"}]},{"text":", or run it with a Python 3 that has it; gcloud's bundled interpreter does not, and the SDK's vendored ","type":"text"},{"text":"yaml","type":"text","marks":[{"type":"code_inline"}]},{"text":" is Python 2.","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"python3 scripts/compile_check.py assets/apigee-proxy-template.yaml","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Execution model","type":"text"}]},{"type":"paragraph","content":[{"text":"Three layers, and the choice between them is not stylistic:","type":"text"}]},{"type":"table","attrs":{"layout":null},"content":[{"type":"tr","content":[{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Layer","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Used for","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Why","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"AFT YAML template","type":"text","marks":[{"type":"strong"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"the proxy itself","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"version-controlled, reviewable, diffable","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"gcloud","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"PSC, DNS, AuthzExtension, AuthzPolicy","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"native commands exist","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"REST (","type":"text","marks":[{"type":"strong"}]},{"text":"curl","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":")","type":"text","marks":[{"type":"strong"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"env properties, envgroups, deployments, debug sessions","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"no ","type":"text"},{"text":"gcloud","type":"text","marks":[{"type":"code_inline"}]},{"text":" equivalent today","type":"text"}]}]}]}]},{"type":"paragraph","content":[{"text":"The customer owns ","type":"text"},{"text":"one","type":"text","marks":[{"type":"strong"}]},{"text":" input file (","type":"text"},{"text":"assets/apigee-agent-gateway-governance.example.yaml","type":"text","marks":[{"type":"code_inline"}]},{"text":"). It is the version-controlled artifact. Never hand-edit the generated proxy: regenerate it.","type":"text"}]},{"type":"paragraph","content":[{"text":"Run the Apigee-side sequence with the script, not by hand.","type":"text","marks":[{"type":"strong"}]},{"text":" Steps 1, 3 and 4 below are order-dependent and each fails in a way that reads as a different problem. ","type":"text"},{"text":"scripts/deploy_proxy.sh","type":"text","marks":[{"type":"code_inline"}]},{"text":" does all three, preserves the properties map, polls the async operation, and exits non-zero if the deployment does not come up ","type":"text"},{"text":"EXTENSIBLE","type":"text","marks":[{"type":"code_inline"}]},{"text":":","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"scripts/deploy_proxy.sh --org \"$ORG\" --env \"$ENV\" --proxy \"$PROXY\" \\\n --template assets/apigee-proxy-template.yaml [--service-account \"$SA\"] --yes \\\n [--index-endpoint-id ID --index-id ID --sc-project P --sc-region R --embeddings-model M]","type":"text"}]},{"type":"paragraph","content":[{"text":"Run it without ","type":"text"},{"text":"--yes","type":"text","marks":[{"type":"code_inline"}]},{"text":" first: it prints the plan and changes nothing. Show that plan, then re-run with ","type":"text"},{"text":"--yes","type":"text","marks":[{"type":"code_inline"}]},{"text":" once the user agrees.","type":"text"}]},{"type":"paragraph","content":[{"text":"The vector-search flags are the one exception to \"edit the default in every feature that declares it\".","type":"text","marks":[{"type":"strong"}]},{"text":" Those three ids are substituted at deploy time and checked for leftovers; hand-editing them into ","type":"text"},{"text":"feature-semantic-cache.yaml","type":"text","marks":[{"type":"code_inline"}]},{"text":" deploys fine, skips the check, and makes the file single-use.","type":"text"}]},{"type":"paragraph","content":[{"text":"scripts/ensure_gateway_connectivity.sh","type":"text","marks":[{"type":"code_inline"}]},{"text":" is the same idea for the gateway leg (step 0). It is the only part of this skill that modifies a resource the user already owns, so it applies nothing without ","type":"text"},{"text":"--yes","type":"text","marks":[{"type":"code_inline"}]},{"text":".","type":"text"}]},{"type":"paragraph","content":[{"text":"The steps are written out below so you can diagnose a failure, not so you can retype them.","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Inputs you must resolve first","type":"text"}]},{"type":"paragraph","content":[{"text":"Environment prerequisites: ","type":"text","marks":[{"type":"strong"}]},{"text":"references/setup.md","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" — gcloud SDK (588+), project, billing, ","type":"text"},{"text":"gcloud services enable","type":"text","marks":[{"type":"code_inline"}]},{"text":", region, credentials, IAM. Nothing below creates any of it.","type":"text"}]},{"type":"paragraph","content":[{"text":"Prod: from the input file, then environment, then ask — never invent. Dev: apply the ","type":"text"},{"text":"references/modes.md","type":"text","marks":[{"type":"code_inline"}]},{"text":" defaults, and ask only for what has none.","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"project","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"region","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"apigee.org","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"apigee.environment","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"apigee.envgroup","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"apigee.hostname","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"apigee.proxy","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"network.vpc","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"network.subnet","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"network.networkAttachment","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"gateway.name","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"gateway.matchPaths","type":"text","marks":[{"type":"code_inline"}]},{"text":" (a ","type":"text"},{"text":"list","type":"text","marks":[{"type":"strong"}]},{"text":", one entry per wire shape), ","type":"text"},{"text":"gateway.manageNetworkConfig","type":"text","marks":[{"type":"code_inline"}]}]}]}]},{"type":"paragraph","content":[{"text":"matchPaths","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" drives TWO artifacts; write both from the same list.","type":"text","marks":[{"type":"strong"}]},{"text":" The ","type":"text"},{"text":"AuthzPolicy","type":"text","marks":[{"type":"strong"}]},{"text":" ","type":"text"},{"text":"paths[].suffix","type":"text","marks":[{"type":"code_inline"}]},{"text":" gets one suffix per entry, verbatim with leading punctuation kept — it decides what the gateway routes to Apigee. The template's ","type":"text"},{"text":"LLM_URI_MATCH","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" gets one ","type":"text"},{"text":"(request.uri Like \"*\u003centry>*\")","type":"text","marks":[{"type":"code_inline"}]},{"text":" clause per entry, OR-ed, leading ","type":"text"},{"text":":","type":"text","marks":[{"type":"code_inline"}]},{"text":" or ","type":"text"},{"text":"/","type":"text","marks":[{"type":"code_inline"}]},{"text":" ","type":"text"},{"text":"stripped","type":"text","marks":[{"type":"strong"}]},{"text":" — it decides what the proxy acts on once it arrives. The shipped two-entry default gives:","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":""},"content":[{"text":"((request.uri Like \"*generateContent*\") or (request.uri Like \"*chat/completions*\"))","type":"text"}]},{"type":"paragraph","content":[{"text":"The AuthzPolicy is the outer gate and wins: a suffix list narrower than ","type":"text"},{"text":"matchPaths","type":"text","marks":[{"type":"code_inline"}]},{"text":" means the missing shape never reaches Apigee at all. Derive both from the input file and never from the default when the file disagrees — the file is the reviewable contract and a mismatch is invisible once deployed.","type":"text"}]},{"type":"paragraph","content":[{"text":"A one-entry list is legitimate","type":"text","marks":[{"type":"strong"}]},{"text":" and governs one shape. Dropping an entry governs one fewer ","type":"text"},{"text":"silently","type":"text","marks":[{"type":"strong"}]},{"text":" — deploys clean, zero transactions for the shape dropped — and narrows ","type":"text"},{"text":"feature-normalize","type":"text","marks":[{"type":"code_inline"}]},{"text":", Model Armor and the cache together. ","type":"text"},{"text":"references/gotchas.md","type":"text","marks":[{"type":"code_inline"}]},{"text":", Runtime.","type":"text"}]},{"type":"paragraph","content":[{"text":"semanticCache","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" is a KNOWN ISSUE whenever it is on — warn, then build it.","type":"text","marks":[{"type":"strong"}]},{"text":" The key is the prompt embedding alone: no caller, no model, no shape. A hit therefore crosses callers — similar prompts share an answer — and one model behind one shape does not make it safe. With more shapes the first model to answer answers for the other too: empty HTTP 200 across shapes, wrong-model body within one. Warn whenever the cache is on, not only on multi-shape, then build. Wording: ","type":"text"},{"text":"references/modes.md","type":"text","marks":[{"type":"code_inline"}]},{"text":"; mechanism: ","type":"text"},{"text":"references/gotchas.md","type":"text","marks":[{"type":"code_inline"}]},{"text":".","type":"text"}]},{"type":"paragraph","content":[{"text":"Each entry is a SUFFIX match.","type":"text","marks":[{"type":"strong"}]},{"text":" You write these, not the user — derive them from which endpoint their agent actually calls, and do not ask them to reason about suffixes. Vertex's OpenAI-compatible endpoint and ","type":"text"},{"text":"api.openai.com","type":"text","marks":[{"type":"code_inline"}]},{"text":" need different entries, and getting it wrong governs nothing silently; the forms and the evidence are in ","type":"text"},{"text":"references/gotchas.md","type":"text","marks":[{"type":"code_inline"}]},{"text":", Runtime.","type":"text"}]},{"type":"paragraph","content":[{"text":"There is no ","type":"text","marks":[{"type":"strong"}]},{"text":"apiShape","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" field.","type":"text","marks":[{"type":"strong"}]},{"text":" Metering does not depend on the wire shape — the count is normalised in JS across both — so the field selected nothing and was removed. Adding a shape is an ","type":"text"},{"text":"||","type":"text","marks":[{"type":"code_inline"}]},{"text":" branch in ","type":"text"},{"text":"extract-model.js","type":"text","marks":[{"type":"code_inline"}]},{"text":" plus an entry in ","type":"text"},{"text":"matchPaths","type":"text","marks":[{"type":"code_inline"}]},{"text":".","type":"text"}]},{"type":"paragraph","content":[{"text":"Three places carry the callout hostname and all three must match","type":"text","marks":[{"type":"strong"}]},{"text":" — the Apigee envgroup, the AuthzExtension ","type":"text"},{"text":"authority","type":"text","marks":[{"type":"code_inline"}]},{"text":"/","type":"text"},{"text":"service","type":"text","marks":[{"type":"code_inline"}]},{"text":", and the private DNS A record. You set all three from one value: a build invariant to hold, not a question for the user. It becomes a topic only when a callout never lands, and then it is the first check. Build order matters alongside it — the AuthzExtension is inert until the host resolves and the proxy is deployed, so \"the extension exists\" is not evidence that leg works. ","type":"text"},{"text":"references/gotchas.md","type":"text","marks":[{"type":"code_inline"}]},{"text":", Runtime.","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Workflow","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"0. Gateway: give it a route into the VPC","type":"text"}]},{"type":"paragraph","content":[{"text":"A gateway with no ","type":"text"},{"text":"networkConfig","type":"text","marks":[{"type":"code_inline"}]},{"text":" has no route into the customer's VPC and cannot resolve the callout hostname. ","type":"text"},{"text":"This skill builds it","type":"text","marks":[{"type":"strong"}]},{"text":" — network attachment, egress and DNS peering — so its absence is a build step, not a blocker, and should be reported that way. Do not open with an alarm about a resource you are about to create.","type":"text"}]},{"type":"paragraph","content":[{"text":"One thing here does need the user, and it is the reason this is step 0 rather than a silent prerequisite: attaching egress is ","type":"text"},{"text":"one-way and affects traffic they already run","type":"text","marks":[{"type":"strong"}]},{"text":". Settle it in the plan, before anything executes — see \"Confirm everything up front\" below. Capturing a known-good agent turn first is worth doing when they are nervous about a production gateway, because it separates \"never worked\" from \"we broke it\"; it is a precaution to offer, not a step to impose.","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"gcloud network-services agent-gateways describe \"$GATEWAY\" \\\n --location=\"$REGION\" --project=\"$PROJECT\" --format='value(networkConfig)'\ngcloud network-security authz-policies list \\\n --location=\"$REGION\" --project=\"$PROJECT\"","type":"text"}]},{"type":"paragraph","content":[{"text":"A console-created gateway already has a CUSTOM AuthzPolicy","type":"text","marks":[{"type":"strong"}]},{"text":" — an IAP ","type":"text"},{"text":"REQUEST_AUTHZ","type":"text","marks":[{"type":"code_inline"}]},{"text":" one, which coexists with ours and is not needed for the egress JWT. You never create the gateway, so assess it rather than assuming. ","type":"text"},{"text":"references/gotchas.md","type":"text","marks":[{"type":"code_inline"}]},{"text":", Agent Gateway.","type":"text"}]},{"type":"paragraph","content":[{"text":"Empty ","type":"text"},{"text":"networkConfig","type":"text","marks":[{"type":"code_inline"}]},{"text":" output means missing, and it is easy to miss: the gateway still serves ordinary agent traffic perfectly, because that path goes to public Google endpoints and never enters the VPC. Only the Apigee callout fails.","type":"text"}]},{"type":"paragraph","content":[{"text":"If it is missing, behaviour depends on ","type":"text"},{"text":"gateway.manageNetworkConfig","type":"text","marks":[{"type":"code_inline"}]},{"text":":","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"true","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" — run the script below. It creates the network attachment, then read-modify-writes the gateway to add the egress attachment and DNS peering. Agreement for this is taken in the plan, not here; ","type":"text"},{"text":"--yes","type":"text","marks":[{"type":"code_inline"}]},{"text":" carries it.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"false","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" — the user has said they want to make this change themselves. Print the script invocation and let them run it. Do not modify the gateway.","type":"text"}]}]}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"scripts/ensure_gateway_connectivity.sh \\\n --gateway \"$GATEWAY\" --project \"$PROJECT\" --region \"$REGION\" \\\n --vpc \"$VPC\" --subnet \"$SUBNET\" --attachment \"$ATTACHMENT\" \\\n --domain \"$DNS_DOMAIN\" # add --yes only after the user agrees","type":"text"}]},{"type":"paragraph","content":[{"text":"Either way ","type":"text"},{"text":"networkConfig","type":"text","marks":[{"type":"code_inline"}]},{"text":" has to exist before the Apigee work starts — not because its absence is an error, but because every later step then succeeds and governs nothing.","type":"text"}]},{"type":"paragraph","content":[{"text":"Use the script, not the raw commands: ","type":"text"},{"text":"import","type":"text","marks":[{"type":"code_inline"}]},{"text":" is a whole-resource upsert and a hand-built YAML silently drops the gateway's ","type":"text"},{"text":"registries","type":"text","marks":[{"type":"code_inline"}]},{"text":" and ","type":"text"},{"text":"protocols","type":"text","marks":[{"type":"code_inline"}]},{"text":". Why, and the ","type":"text"},{"text":"etag","type":"text","marks":[{"type":"code_inline"}]},{"text":" and ","type":"text"},{"text":"PROTOCOL_UNSPECIFIED","type":"text","marks":[{"type":"code_inline"}]},{"text":" handling: ","type":"text"},{"text":"references/gotchas.md","type":"text","marks":[{"type":"code_inline"}]},{"text":".","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"1. Apigee: enable the environment","type":"text"}]},{"type":"paragraph","content":[{"text":"The env must carry ","type":"text"},{"text":"apigee-service-extension-enabled=true","type":"text","marks":[{"type":"code_inline"}]},{"text":". No ","type":"text"},{"text":"gcloud","type":"text","marks":[{"type":"code_inline"}]},{"text":" flag exists; PATCH the properties map.","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"curl -s -H \"Authorization: Bearer $TOKEN\" \\\n \"https://apigee.googleapis.com/v1/organizations/$ORG/environments/$ENV\"","type":"text"}]},{"type":"paragraph","content":[{"text":"The PATCH replaces the whole map.","type":"text","marks":[{"type":"strong"}]},{"text":" Read it first and re-send every property you intend to keep, or you will silently drop them.","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"curl -X PATCH -H \"Authorization: Bearer $TOKEN\" -H \"Content-Type: application/json\" \\\n \"https://apigee.googleapis.com/v1/organizations/$ORG/environments/$ENV?updateMask=properties\" \\\n -d '{\"properties\":{\"property\":[{\"name\":\"apigee-service-extension-enabled\",\"value\":\"true\"}]}}'","type":"text"}]},{"type":"paragraph","content":[{"text":"This does ","type":"text"},{"text":"not","type":"text","marks":[{"type":"strong"}]},{"text":" disable Apigee Analytics — the two coexist; if analytics looks missing it is the separate per-environment add-on. ","type":"text"},{"text":"references/gotchas.md","type":"text","marks":[{"type":"code_inline"}]},{"text":".","type":"text"}]},{"type":"paragraph","content":[{"text":"Use a dedicated environment","type":"text","marks":[{"type":"strong"}]},{"text":", attached to ","type":"text"},{"text":"exactly one envgroup","type":"text","marks":[{"type":"strong"}]},{"text":". An environment in two makes the gateway 404 every request with the extension never invoked. It also leaves the ","type":"text"},{"text":"/","type":"text","marks":[{"type":"code_inline"}]},{"text":" base path free, which is what the proxy ships with.","type":"text"}]},{"type":"paragraph","content":[{"text":"scripts/ensure_apigee_environment.sh","type":"text","marks":[{"type":"code_inline"}]},{"text":" creates the environment, attaches it to the regional runtime instance, and creates and attaches the envgroup — so step 2 is already done when it succeeds. It creates billable resources and applies nothing without ","type":"text"},{"text":"--yes","type":"text","marks":[{"type":"code_inline"}]},{"text":"; that agreement is taken in the plan, not here.","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"scripts/ensure_apigee_environment.sh \\\n --org \"$ORG\" --env \"$ENV\" --region \"$REGION\" \\\n --envgroup \"$ENVGROUP\" --hostname \"$HOSTNAME\" # add --yes to apply","type":"text"}]},{"type":"paragraph","content":[{"text":"It is idempotent and resumable, and refuses with exit 2 when the environment is already in another envgroup or the envgroup exists on a different hostname. It also handles three things a runbook tends to get wrong — the omitted ","type":"text"},{"text":"type","type":"text","marks":[{"type":"code_inline"}]},{"text":" on create, the four-minute instance attachment that ","type":"text"},{"text":"locks the environment","type":"text","marks":[{"type":"strong"}]},{"text":", and polling the operation rather than a ","type":"text"},{"text":"GET","type":"text","marks":[{"type":"code_inline"}]},{"text":" that succeeds too early.","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"2. Apigee: envgroup on the callout hostname","type":"text"}]},{"type":"paragraph","content":[{"text":"Done already if step 1's script ran. Verify, or do it by hand for an envgroup that already exists. The hostname must be exactly the callout host, and the group must be attached to the env.","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"curl -s -H \"Authorization: Bearer $TOKEN\" \\\n \"https://apigee.googleapis.com/v1/organizations/$ORG/envgroups/$ENVGROUP/attachments\"","type":"text"}]},{"type":"paragraph","content":[{"text":"How many envgroups is the environment in?","type":"text","marks":[{"type":"strong"}]},{"text":" More than one and the callout never lands. ","type":"text"},{"text":"deploy_proxy.sh","type":"text","marks":[{"type":"code_inline"}]},{"text":" enforces this before touching anything and exits 2 naming them; ","type":"text"},{"text":"assess_integration.sh","type":"text","marks":[{"type":"code_inline"}]},{"text":" reports it. Read the failure literally if it fires — the environment is wrong, not the script.","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"3. Build the proxy from the template","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"gcloud apigee apis import organizations/$ORG/apis/$PROXY \\\n --from-template=assets/apigee-proxy-template.yaml","type":"text"}]},{"type":"paragraph","content":[{"text":"Each ","type":"text"},{"text":"features:","type":"text","marks":[{"type":"code_inline"}]},{"text":" entry resolves ","type":"text"},{"text":"relative to the template file's own directory","type":"text","marks":[{"type":"strong"}]},{"text":", never the working directory, so a bare filename must sit beside the template (a subpath like ","type":"text"},{"text":"features/feature-identity.yaml","type":"text","marks":[{"type":"code_inline"}]},{"text":" works too). ","type":"text"},{"text":"assets/","type":"text","marks":[{"type":"code_inline"}]},{"text":" is flat by convention, not compiler demand — but moving a file without updating ","type":"text"},{"text":"features:","type":"text","marks":[{"type":"code_inline"}]},{"text":" fails the import with a bare ","type":"text"},{"text":"FileNotFoundError","type":"text","marks":[{"type":"code_inline"}]},{"text":".","type":"text"}]},{"type":"paragraph","content":[{"text":"If you get ","type":"text"},{"text":"Permission denied on resource ... (or it may not exist)","type":"text","marks":[{"type":"code_inline"}]},{"text":" on an org you demonstrably have access to, it is the credential, not the access. With context-aware access enabled the default gcloud credential is rejected. Pass the ADC token instead:","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"CLOUDSDK_AUTH_ACCESS_TOKEN=$(gcloud auth application-default print-access-token) \\\n gcloud apigee apis import ...","type":"text"}]},{"type":"paragraph","content":[{"text":"To change a parameter, ","type":"text"},{"text":"edit the ","type":"text","marks":[{"type":"strong"}]},{"text":"default","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" in every FEATURE that declares it","type":"text","marks":[{"type":"strong"}]},{"text":" — not in the template. ","type":"text"},{"text":"gcloud apigee apis import","type":"text","marks":[{"type":"code_inline"}]},{"text":" passes an empty caller-parameter map, so each feature resolves its own default and the template's declaration of the same name is never read. Editing the template changes nothing, silently, and the compile still passes. Measured: setting ","type":"text"},{"text":"MA_CONTINUE_ON_ERROR","type":"text","marks":[{"type":"code_inline"}]},{"text":" to DRY_RUN in the template shipped ENFORCE on a fail-closed policy. ","type":"text"},{"text":"LLM_URI_MATCH","type":"text","marks":[{"type":"code_inline"}]},{"text":" is declared in four features and all four must agree. ","type":"text"},{"text":"scripts/compile_check.py","type":"text","marks":[{"type":"code_inline"}]},{"text":" fails on any disagreement. There is no ","type":"text"},{"text":"--parameters","type":"text","marks":[{"type":"code_inline"}]},{"text":" flag.","type":"text"}]},{"type":"paragraph","content":[{"text":"Enable an optional recipe by adding its feature to the template's ","type":"text"},{"text":"features","type":"text","marks":[{"type":"code_inline"}]},{"text":" list. See ","type":"text"},{"text":"references/recipes.md","type":"text","marks":[{"type":"code_inline"}]},{"text":".","type":"text"}]},{"type":"paragraph","content":[{"text":"tokenMetering.models","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" in the input file is NOT generative.","type":"text","marks":[{"type":"strong"}]},{"text":" It documents which buckets exist; it does not create them. A bucket is real only when it has an ","type":"text"},{"text":"LLMTokenQuota","type":"text","marks":[{"type":"code_inline"}]},{"text":" step in ","type":"text"},{"text":"feature-token-metering.yaml","type":"text","marks":[{"type":"code_inline"}]},{"text":" and a matching ","type":"text"},{"text":"*_TOKEN_LIMIT","type":"text","marks":[{"type":"code_inline"}]},{"text":" parameter. Three exist: ","type":"text"},{"text":"gemini-pro","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"gemini-flash","type":"text","marks":[{"type":"code_inline"}]},{"text":" and ","type":"text"},{"text":"other","type":"text","marks":[{"type":"code_inline"}]},{"text":" — the catch-all for any model whose name contains neither ","type":"text"},{"text":"pro","type":"text","marks":[{"type":"code_inline"}]},{"text":" nor ","type":"text"},{"text":"flash","type":"text","marks":[{"type":"code_inline"}]},{"text":". Adding a fourth entry to the list meters nothing, and ","type":"text"},{"text":"omitting ","type":"text","marks":[{"type":"strong"}]},{"text":"other","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" silently unmeters every non-Gemini model","type":"text","marks":[{"type":"strong"}]},{"text":": the count is extracted and then discarded, with no ","type":"text"},{"text":"ratelimit","type":"text","marks":[{"type":"code_inline"}]},{"text":" reference anywhere in the transaction. Adding a model is a feature-file edit, not a config change.","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"4. Deploy","type":"text"}]},{"type":"paragraph","content":[{"text":"Deploy to the service-extension env, then confirm the deployment reports ","type":"text"},{"text":"proxyDeploymentType: EXTENSIBLE","type":"text","marks":[{"type":"code_inline"}]},{"text":".","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"curl -s -H \"Authorization: Bearer $TOKEN\" \\\n \".../environments/$ENV/deployments\"","type":"text"}]},{"type":"paragraph","content":[{"text":"STANDARD","type":"text","marks":[{"type":"code_inline"}]},{"text":" means it will ","type":"text"},{"text":"not","type":"text","marks":[{"type":"strong"}]},{"text":" govern the agent's off-Apigee tool egress.","type":"text"}]},{"type":"paragraph","content":[{"text":"If any policy calls a Google API — Model Armor, embeddings, Vector Search — deploy ","type":"text"},{"text":"with a service account","type":"text","marks":[{"type":"strong"}]},{"text":" or the deploy is rejected with ","type":"text"},{"text":"MISSING_SERVICE_ACCOUNT","type":"text","marks":[{"type":"code_inline"}]},{"text":":","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"curl -X POST -H \"Authorization: Bearer $TOKEN\" \\\n \".../apis/$PROXY/revisions/$REV/deployments?override=true&serviceAccount=$DEPLOY_SA\"","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"5. Networking","type":"text"}]},{"type":"paragraph","content":[{"text":"This is the ","type":"text"},{"text":"gateway → Apigee","type":"text","marks":[{"type":"strong"}]},{"text":" leg only. PSC endpoint to the Apigee instance service attachment, plus a private DNS A record. See ","type":"text"},{"text":"references/networking-psc.md","type":"text","marks":[{"type":"code_inline"}]},{"text":". The Apigee instance ","type":"text"},{"text":"consumerAcceptList","type":"text","marks":[{"type":"code_inline"}]},{"text":" must include your project or the connection never reaches ","type":"text"},{"text":"ACCEPTED","type":"text","marks":[{"type":"code_inline"}]},{"text":".","type":"text"}]},{"type":"paragraph","content":[{"text":"The ","type":"text"},{"text":"agent → gateway","type":"text","marks":[{"type":"strong"}]},{"text":" leg is the prerequisite above, and is not built here.","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"6. Bind the gateway","type":"text"}]},{"type":"paragraph","content":[{"text":"AuthzExtension then AuthzPolicy — in that order, and only once the host resolves and the proxy is deployed. The extension is inert until then.","type":"text"}]},{"type":"paragraph","content":[{"text":"Both document bodies ship as assets. Copy and substitute; do not invent them","type":"text","marks":[{"type":"strong"}]},{"text":" — ","type":"text"},{"text":"assets/authz-extension.example.yaml","type":"text","marks":[{"type":"code_inline"}]},{"text":" and ","type":"text"},{"text":"assets/authz-policy.example.yaml","type":"text","marks":[{"type":"code_inline"}]},{"text":". They carry the fields that are load-bearing and have no obvious default: ","type":"text"},{"text":"wireFormat: EXT_PROC_GRPC","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"failOpen","type":"text","marks":[{"type":"code_inline"}]},{"text":", the ","type":"text"},{"text":"authority","type":"text","marks":[{"type":"code_inline"}]},{"text":"/","type":"text"},{"text":"service","type":"text","marks":[{"type":"code_inline"}]},{"text":" pair, the ","type":"text"},{"text":"metadata","type":"text","marks":[{"type":"code_inline"}]},{"text":" body flags, and the policy's ","type":"text"},{"text":"customProvider","type":"text","marks":[{"type":"code_inline"}]},{"text":" / ","type":"text"},{"text":"httpRules","type":"text","marks":[{"type":"code_inline"}]},{"text":" nesting.","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"gcloud service-extensions authz-extensions import $AUTHZ_EXT \\\n --source=authz-extension.yaml --location=$REGION --project=$PROJECT\ngcloud beta network-security authz-policies import $AUTHZ_POLICY \\\n --source=authz-policy.yaml --location=$REGION --project=$PROJECT","type":"text"}]},{"type":"paragraph","content":[{"text":"The policy's resource paths take the project NUMBER, not the ID","type":"text","marks":[{"type":"strong"}]},{"text":" — both ","type":"text"},{"text":"target.resources","type":"text","marks":[{"type":"code_inline"}]},{"text":" and ","type":"text"},{"text":"customProvider.authzExtension.resources","type":"text","marks":[{"type":"code_inline"}]},{"text":". Resolve it yourself; narrating two numbers at the user does not help. The trap, for when a callout never lands: the number in the gateway's ","type":"text"},{"text":"serviceExtensionsServiceAccount","type":"text","marks":[{"type":"code_inline"}]},{"text":" is the Google-managed tenant project, not theirs, and it is the only one on screen, so it is the natural thing to copy. A wrong number imports cleanly and targets nothing. Copy the slots from ","type":"text"},{"text":"assets/authz-policy.example.yaml","type":"text","marks":[{"type":"code_inline"}]},{"text":".","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"7. Verify","type":"text"}]},{"type":"paragraph","content":[{"text":"You ","type":"text"},{"text":"cannot","type":"text","marks":[{"type":"strong"}]},{"text":" test with curl — the host is internal and the proxy expects the gateway's IAP-egress JWT. Run ","type":"text"},{"text":"scripts/read_debug_session.sh --arm","type":"text","marks":[{"type":"code_inline"}]},{"text":", drive a turn, read it back with ","type":"text"},{"text":"--session","type":"text","marks":[{"type":"code_inline"}]},{"text":". Expect ","type":"text"},{"text":"one","type":"text","marks":[{"type":"strong"}]},{"text":" transaction per turn per governed shape under a suffix list this narrow; zero alone does not mean the callout is dead — the script prints the probe that settles it.","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Hard rules","type":"text"}]},{"type":"paragraph","content":[{"text":"Gate every step — how urgently depends on your AuthzPolicy.","type":"text","marks":[{"type":"strong"}]},{"text":" An ungated ","type":"text"},{"text":"continueOnError=\"false\"","type":"text","marks":[{"type":"code_inline"}]},{"text":" policy faults on any call that carries no prompt, and that ","type":"text"},{"text":"breaks the agent's turn","type":"text","marks":[{"type":"strong"}]},{"text":". Historically the most common failure here.","type":"text"}]},{"type":"paragraph","content":[{"text":"A narrow suffix list routes only the model call to Apigee, so the trap mostly does not fire. Broaden the policy and it fires at once: the ext-proc then sees every agent egress — session create/read, ","type":"text"},{"text":"appendEvent","type":"text","marks":[{"type":"code_inline"}]},{"text":", OpenTelemetry exports — measured at 12 calls for one single-question turn, of which one was the model call. Gate the steps regardless; a condition is cheap and it is what makes the policy safe to widen later. Full measurement: ","type":"text"},{"text":"references/gotchas.md","type":"text","marks":[{"type":"code_inline"}]},{"text":".","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"model call: ","type":"text"},{"text":"(request.uri Like \"*generateContent*\")","type":"text","marks":[{"type":"code_inline"}]}]}]}]},{"type":"paragraph","content":[{"text":"Never fail-close on your own metering.","type":"text","marks":[{"type":"strong"}]},{"text":" Keep ","type":"text"},{"text":"continueOnError=\"true\"","type":"text","marks":[{"type":"code_inline"}]},{"text":" on the JavaScript and quota policies so a metering bug cannot break LLM traffic.","type":"text"}]},{"type":"paragraph","content":[{"text":"This skill meters by default and can enforce on request.","type":"text","marks":[{"type":"strong"}]},{"text":" Enforcement is ","type":"text"},{"text":"assets/feature-token-enforce.yaml","type":"text","marks":[{"type":"code_inline"}]},{"text":": request-flow ","type":"text"},{"text":"EnforceOnly","type":"text","marks":[{"type":"code_inline"}]},{"text":" policies sharing each ","type":"text"},{"text":"SharedName","type":"text","marks":[{"type":"code_inline"}]},{"text":" with the response-flow ","type":"text"},{"text":"CountOnly","type":"text","marks":[{"type":"code_inline"}]},{"text":" half. Add it to the template's feature list. Verified live on ext-proc traffic — the over-budget caller gets a ","type":"text"},{"text":"429","type":"text","marks":[{"type":"strong"}]},{"text":", model never called.","type":"text"}]},{"type":"paragraph","content":[{"text":"Never offer ","type":"text"},{"text":"CountOnly=false","type":"text","marks":[{"type":"code_inline"}]},{"text":" as a substitute: in the response flow it cannot gate (the answer is already on the wire) and it freezes the counter, losing the metering too.","type":"text"}]},{"type":"paragraph","content":[{"text":"State two things whenever you offer it. It is ","type":"text"},{"text":"one request late","type":"text","marks":[{"type":"strong"}]},{"text":" — the turn that crosses the limit completes, because a token cost is not known until the model answers. And it buckets on the ","type":"text"},{"text":"requested","type":"text","marks":[{"type":"strong"}]},{"text":" model, which under an OpenAI alias can differ from the resolved model the counting side bills. The two traps that both present as \"it never refuses\": ","type":"text"},{"text":"references/gotchas.md","type":"text","marks":[{"type":"code_inline"}]},{"text":".","type":"text"}]},{"type":"paragraph","content":[{"text":"Normalize the model to a bucket.","type":"text","marks":[{"type":"strong"}]},{"text":" With an OpenAI-compatible endpoint the model is an alias in the request body and the ","type":"text"},{"text":"resolved","type":"text","marks":[{"type":"em"}]},{"text":" name is in the response body (","type":"text"},{"text":"google/gemini-2.5-pro","type":"text","marks":[{"type":"code_inline"}]},{"text":"). Exact-matching a raw name fails on the response path.","type":"text"}]},{"type":"paragraph","content":[{"text":"jsonPath","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" needs its third argument ","type":"text","marks":[{"type":"strong"}]},{"text":"true","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" so it returns a scalar. Without it the quota policy receives a node it cannot parse.","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"References","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"references/modes.md","type":"text","marks":[{"type":"code_inline"}]},{"text":" — ","type":"text"},{"text":"read first.","type":"text","marks":[{"type":"strong"}]},{"text":" How to answer \"what can you do\", dev vs prod, the defaults, durations, cost disclosure, the interlocks, and reading an assess report","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"scripts/assess_integration.sh","type":"text","marks":[{"type":"code_inline"}]},{"text":" — ","type":"text"},{"text":"read-only.","type":"text","marks":[{"type":"strong"}]},{"text":" Setup Status Summary then a per-check table; ","type":"text"},{"text":"--json","type":"text","marks":[{"type":"code_inline"}]},{"text":" carries both. Lead with the summary and read out the ","type":"text"},{"text":"Enforcement","type":"text","marks":[{"type":"strong"}]},{"text":" row. Run it before planning: it decides CREATE vs REUSE. ","type":"text"},{"text":"TODO","type":"text","marks":[{"type":"code_inline"}]},{"text":" is a build step, not a failure; only ","type":"text"},{"text":"FAIL","type":"text","marks":[{"type":"code_inline"}]},{"text":" exits non-zero","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"references/networking-psc.md","type":"text","marks":[{"type":"code_inline"}]},{"text":" — both network legs, PSC, DNS, acceptance","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"references/recipes.md","type":"text","marks":[{"type":"code_inline"}]},{"text":" — Model Armor and semantic cache","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"references/verify.md","type":"text","marks":[{"type":"code_inline"}]},{"text":" — debug sessions and the checklist script","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"references/setup.md","type":"text","marks":[{"type":"code_inline"}]},{"text":" — SDK, project, billing, APIs, region, auth, IAM","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"references/gotchas.md","type":"text","marks":[{"type":"code_inline"}]},{"text":" — 63 failure modes and their symptoms","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"assets/apigee-agent-gateway-governance.example.yaml","type":"text","marks":[{"type":"code_inline"}]},{"text":" — the input contract","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"assets/apigee-proxy-template.yaml","type":"text","marks":[{"type":"code_inline"}]},{"text":" — the AFT template. Features merge by ","type":"text"},{"text":"priority","type":"text","marks":[{"type":"code_inline"}]},{"text":" and all sit beside it: ","type":"text"},{"text":"feature-identity.yaml","type":"text","marks":[{"type":"code_inline"}]},{"text":" (10), ","type":"text"},{"text":"feature-normalize.yaml","type":"text","marks":[{"type":"code_inline"}]},{"text":" (15), ","type":"text"},{"text":"feature-model-armor.yaml","type":"text","marks":[{"type":"code_inline"}]},{"text":" (20), ","type":"text"},{"text":"feature-semantic-cache.yaml","type":"text","marks":[{"type":"code_inline"}]},{"text":" (30), ","type":"text"},{"text":"feature-token-metering.yaml","type":"text","marks":[{"type":"code_inline"}]},{"text":" (50).","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"assets/authz-extension.example.yaml","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"assets/authz-policy.example.yaml","type":"text","marks":[{"type":"code_inline"}]},{"text":" — the step-6 bodies. Copy and substitute; paths take the project ","type":"text"},{"text":"number","type":"text","marks":[{"type":"strong"}]}]},{"type":"paragraph","content":[{"text":"Anything read out of the request body is normalised once, at priority 15, not inside whichever feature needs it first.","type":"text","marks":[{"type":"strong"}]},{"text":" ","type":"text"},{"text":"LLM_URI_MATCH","type":"text","marks":[{"type":"code_inline"}]},{"text":" is shared, so widening it arms every feature gated on it simultaneously — including Model Armor, which fails closed. Model Armor and the cache both read ","type":"text"},{"text":"agw.user_prompt","type":"text","marks":[{"type":"code_inline"}]},{"text":"; the quotas read ","type":"text"},{"text":"agw.total_tokens","type":"text","marks":[{"type":"code_inline"}]},{"text":", set on the response path by the metering feature's own JS for the same reason.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"scripts/ensure_apigee_environment.sh","type":"text","marks":[{"type":"code_inline"}]},{"text":" — create the environment, attach it to the instance, create and attach the envgroup. Idempotent, ","type":"text"},{"text":"--yes","type":"text","marks":[{"type":"code_inline"}]},{"text":" gated","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"scripts/ensure_gateway_connectivity.sh","type":"text","marks":[{"type":"code_inline"}]},{"text":" — route the gateway into the VPC. Read-modify-write; ","type":"text"},{"text":"--yes","type":"text","marks":[{"type":"code_inline"}]},{"text":" gated","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"scripts/ensure_vector_search.sh","type":"text","marks":[{"type":"code_inline"}]},{"text":" — only when ","type":"text"},{"text":"semanticCache","type":"text","marks":[{"type":"code_inline"}]},{"text":" is enabled: build index and endpoint, deploy one on the other, read back the domain. ","type":"text"},{"text":"--yes","type":"text","marks":[{"type":"code_inline"}]},{"text":" gated, ~34 min, bills continuously","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"scripts/teardown_vector_search.sh","type":"text","marks":[{"type":"code_inline"}]},{"text":" — the only thing that STOPS that charge. Refuses until the revision drops ","type":"text"},{"text":"SCL-lookup","type":"text","marks":[{"type":"code_inline"}]},{"text":". ","type":"text"},{"text":"--yes","type":"text","marks":[{"type":"code_inline"}]},{"text":" gated","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"scripts/deploy_proxy.sh","type":"text","marks":[{"type":"code_inline"}]},{"text":" — the Apigee-side sequence, idempotent, ","type":"text"},{"text":"--dry-run","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"scripts/read_debug_session.sh","type":"text","marks":[{"type":"code_inline"}]},{"text":" — ","type":"text"},{"text":"--arm","type":"text","marks":[{"type":"code_inline"}]},{"text":" a session (printing when it actually closes) and read it back as the ","type":"text"},{"text":"verify.md","type":"text","marks":[{"type":"code_inline"}]},{"text":" checklist, instead of parsing the transaction JSON by hand","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"scripts/compile_check.py","type":"text","marks":[{"type":"code_inline"}]},{"text":" — compile the template offline, no org needed","type":"text"}]}]}]}]},"metadata":{"date":"2026-11-02","name":"apigee-agent-gateway-governance","author":"@skillopedia","source":{"stars":21790,"repo_name":"skills","origin_url":"https://github.com/google/skills/blob/HEAD/skills/cloud/apigee-agent-gateway-governance/SKILL.md","repo_owner":"google","body_sha256":"f9994cbfef5d3068fd0b06f8dd16c8849481b664c9b5e990694461c1a6471b94","cluster_key":"c59997179394027b4ca59ff412c012aae8cf8402105fd02f3dac44c712af8d7f","clean_bundle":{"format":"clean-skill-bundle-v1","source":"google/skills/skills/cloud/apigee-agent-gateway-governance/SKILL.md","text_bytes":380001,"source_commit":"dc266e019a32621845b6b2bb81084b3c9657774c","skills_sh_rank":18455,"attachment_count":25,"text_attachments":25,"attachment_storage":"skill_attachments","binary_attachments":0},"cluster_size":1,"skill_md_path":"skills/cloud/apigee-agent-gateway-governance/SKILL.md","import_metadata":{"date":"2026-11-02","author":"@skillopedia","version":"v1","category":"ai-agent-development","category_label":"AI"},"exact_dupes_collapsed_into_this":0},"version":"v1","category":"ai-agent-development","metadata":{"version":"1.0.2","category":"AiAndMachineLearning"},"import_tag":"skills-sh-top20k-2026-11","description":"Stands up an Apigee API proxy that a Google Cloud Agent Gateway calls as an ext-proc (Service Extension) callout on an agent's LLM traffic, to meter token usage per model, extract caller identity, and apply Model Armor prompt screening or a semantic cache. Use when wiring Apigee in as an AI gateway behind an Agent Gateway, when an agent's model egress needs governing, or when a callout proxy returns no verdict and you need to find out why. The distinguishing question is whether Apigee forwards the traffic or only inspects it: this skill is inspect-only, and builds the proxy itself. Don't use it when Apigee is meant to receive a request and route it onward to a backend — including hosting an MCP server behind Apigee — which needs a proxy with a target instead. Don't use for governing the agent's calls to tools or MCP servers. Don't use for choosing or switching which model a request goes to. Don't use for creating the Agent Gateway itself, nor for Apigee hybrid."}},"renderedAt":1793686576588}

Apigee as an Agent Gateway Service Extension Wires an Apigee proxy in as an ext-proc callout on a GCP Agent Gateway. The proxy is a processor, not an API proxy : it has no backend, inspects the LLM request and response the gateway hands it, and returns a verdict. Usage Attribution MUST READ : Before executing any , REST API, or MCP command, you MUST read and follow the instructions in Usage Attribution. You build the proxy; the user does not. Two modes, one schema. Decide which before anything else, and say which. Input file present → prod mode : it is the contract, every value comes from it,…