granny-devops 0.14.0__tar.gz → 0.15.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (111) hide show
  1. {granny_devops-0.14.0 → granny_devops-0.15.0}/PKG-INFO +1 -1
  2. {granny_devops-0.14.0 → granny_devops-0.15.0}/granny/__init__.py +1 -1
  3. granny_devops-0.15.0/granny/aws/bedrock.py +90 -0
  4. {granny_devops-0.14.0 → granny_devops-0.15.0}/granny/azure/_client.py +10 -0
  5. granny_devops-0.15.0/granny/azure/openai.py +311 -0
  6. granny_devops-0.15.0/granny/azure/resourcegroup.py +52 -0
  7. {granny_devops-0.14.0 → granny_devops-0.15.0}/granny/cli/aws.py +79 -0
  8. {granny_devops-0.14.0 → granny_devops-0.15.0}/granny/cli/azure.py +243 -9
  9. {granny_devops-0.14.0 → granny_devops-0.15.0}/pyproject.toml +1 -1
  10. granny_devops-0.14.0/granny/azure/openai.py +0 -147
  11. {granny_devops-0.14.0 → granny_devops-0.15.0}/.gitignore +0 -0
  12. {granny_devops-0.14.0 → granny_devops-0.15.0}/LICENSE +0 -0
  13. {granny_devops-0.14.0 → granny_devops-0.15.0}/README.md +0 -0
  14. {granny_devops-0.14.0 → granny_devops-0.15.0}/granny/analyze/__init__.py +0 -0
  15. {granny_devops-0.14.0 → granny_devops-0.15.0}/granny/analyze/costs.py +0 -0
  16. {granny_devops-0.14.0 → granny_devops-0.15.0}/granny/analyze/credits.py +0 -0
  17. {granny_devops-0.14.0 → granny_devops-0.15.0}/granny/analyze/gpu_pricing.py +0 -0
  18. {granny_devops-0.14.0 → granny_devops-0.15.0}/granny/analyze/gpus.py +0 -0
  19. {granny_devops-0.14.0 → granny_devops-0.15.0}/granny/analyze/lambdas.py +0 -0
  20. {granny_devops-0.14.0 → granny_devops-0.15.0}/granny/analyze/vpcs.py +0 -0
  21. {granny_devops-0.14.0 → granny_devops-0.15.0}/granny/authentik/__init__.py +0 -0
  22. {granny_devops-0.14.0 → granny_devops-0.15.0}/granny/authentik/client.py +0 -0
  23. {granny_devops-0.14.0 → granny_devops-0.15.0}/granny/authentik/provision.py +0 -0
  24. {granny_devops-0.14.0 → granny_devops-0.15.0}/granny/aws/__init__.py +0 -0
  25. {granny_devops-0.14.0 → granny_devops-0.15.0}/granny/aws/quota.py +0 -0
  26. {granny_devops-0.14.0 → granny_devops-0.15.0}/granny/azure/__init__.py +0 -0
  27. {granny_devops-0.14.0 → granny_devops-0.15.0}/granny/azure/account.py +0 -0
  28. {granny_devops-0.14.0 → granny_devops-0.15.0}/granny/azure/deployment.py +0 -0
  29. {granny_devops-0.14.0 → granny_devops-0.15.0}/granny/azure/quota.py +0 -0
  30. {granny_devops-0.14.0 → granny_devops-0.15.0}/granny/azure/role.py +0 -0
  31. {granny_devops-0.14.0 → granny_devops-0.15.0}/granny/azure/sku.py +0 -0
  32. {granny_devops-0.14.0 → granny_devops-0.15.0}/granny/azure/vm.py +0 -0
  33. {granny_devops-0.14.0 → granny_devops-0.15.0}/granny/azure/webapp.py +0 -0
  34. {granny_devops-0.14.0 → granny_devops-0.15.0}/granny/cdn/__init__.py +0 -0
  35. {granny_devops-0.14.0 → granny_devops-0.15.0}/granny/cdn/bunny.py +0 -0
  36. {granny_devops-0.14.0 → granny_devops-0.15.0}/granny/cli/__init__.py +0 -0
  37. {granny_devops-0.14.0 → granny_devops-0.15.0}/granny/cli/analyze.py +0 -0
  38. {granny_devops-0.14.0 → granny_devops-0.15.0}/granny/cli/authentik.py +0 -0
  39. {granny_devops-0.14.0 → granny_devops-0.15.0}/granny/cli/cdn.py +0 -0
  40. {granny_devops-0.14.0 → granny_devops-0.15.0}/granny/cli/cloudflare.py +0 -0
  41. {granny_devops-0.14.0 → granny_devops-0.15.0}/granny/cli/create.py +0 -0
  42. {granny_devops-0.14.0 → granny_devops-0.15.0}/granny/cli/credentials.py +0 -0
  43. {granny_devops-0.14.0 → granny_devops-0.15.0}/granny/cli/dns.py +0 -0
  44. {granny_devops-0.14.0 → granny_devops-0.15.0}/granny/cli/docker.py +0 -0
  45. {granny_devops-0.14.0 → granny_devops-0.15.0}/granny/cli/edge.py +0 -0
  46. {granny_devops-0.14.0 → granny_devops-0.15.0}/granny/cli/elk.py +0 -0
  47. {granny_devops-0.14.0 → granny_devops-0.15.0}/granny/cli/email.py +0 -0
  48. {granny_devops-0.14.0 → granny_devops-0.15.0}/granny/cli/indexing.py +0 -0
  49. {granny_devops-0.14.0 → granny_devops-0.15.0}/granny/cli/main.py +0 -0
  50. {granny_devops-0.14.0 → granny_devops-0.15.0}/granny/cli/serverless.py +0 -0
  51. {granny_devops-0.14.0 → granny_devops-0.15.0}/granny/cli/storage.py +0 -0
  52. {granny_devops-0.14.0 → granny_devops-0.15.0}/granny/cli/zitadel.py +0 -0
  53. {granny_devops-0.14.0 → granny_devops-0.15.0}/granny/cloudflare/__init__.py +0 -0
  54. {granny_devops-0.14.0 → granny_devops-0.15.0}/granny/cloudflare/d1.py +0 -0
  55. {granny_devops-0.14.0 → granny_devops-0.15.0}/granny/cloudflare/r2.py +0 -0
  56. {granny_devops-0.14.0 → granny_devops-0.15.0}/granny/cloudflare/workers.py +0 -0
  57. {granny_devops-0.14.0 → granny_devops-0.15.0}/granny/create/__init__.py +0 -0
  58. {granny_devops-0.14.0 → granny_devops-0.15.0}/granny/create/auto_certificate.py +0 -0
  59. {granny_devops-0.14.0 → granny_devops-0.15.0}/granny/create/cloudfront-security-headers.js +0 -0
  60. {granny_devops-0.14.0 → granny_devops-0.15.0}/granny/create/manage-dns.sh +0 -0
  61. {granny_devops-0.14.0 → granny_devops-0.15.0}/granny/create/manage_mailjet_contacts.py +0 -0
  62. {granny_devops-0.14.0 → granny_devops-0.15.0}/granny/create/registrars.py +0 -0
  63. {granny_devops-0.14.0 → granny_devops-0.15.0}/granny/create/setup_aws_cloudfront.py +0 -0
  64. {granny_devops-0.14.0 → granny_devops-0.15.0}/granny/create/setup_bunny_edge_script.py +0 -0
  65. {granny_devops-0.14.0 → granny_devops-0.15.0}/granny/create/setup_bunny_storage.py +0 -0
  66. {granny_devops-0.14.0 → granny_devops-0.15.0}/granny/create/setup_cognito_identity_pool.py +0 -0
  67. {granny_devops-0.14.0 → granny_devops-0.15.0}/granny/create/setup_hetzner_bunny.py +0 -0
  68. {granny_devops-0.14.0 → granny_devops-0.15.0}/granny/create/setup_mailjet_dns.py +0 -0
  69. {granny_devops-0.14.0 → granny_devops-0.15.0}/granny/create/setup_private_cdn.py +0 -0
  70. {granny_devops-0.14.0 → granny_devops-0.15.0}/granny/create/setup_s3_website.py +0 -0
  71. {granny_devops-0.14.0 → granny_devops-0.15.0}/granny/create/setup_scaleway_container.py +0 -0
  72. {granny_devops-0.14.0 → granny_devops-0.15.0}/granny/create/setup_scaleway_faas.py +0 -0
  73. {granny_devops-0.14.0 → granny_devops-0.15.0}/granny/create/setup_workmail.py +0 -0
  74. {granny_devops-0.14.0 → granny_devops-0.15.0}/granny/create/www-redirect-function.js +0 -0
  75. {granny_devops-0.14.0 → granny_devops-0.15.0}/granny/credentials/__init__.py +0 -0
  76. {granny_devops-0.14.0 → granny_devops-0.15.0}/granny/credentials/secrets.py +0 -0
  77. {granny_devops-0.14.0 → granny_devops-0.15.0}/granny/dns/__init__.py +0 -0
  78. {granny_devops-0.14.0 → granny_devops-0.15.0}/granny/dns/base.py +0 -0
  79. {granny_devops-0.14.0 → granny_devops-0.15.0}/granny/dns/bunny.py +0 -0
  80. {granny_devops-0.14.0 → granny_devops-0.15.0}/granny/dns/cloudflare.py +0 -0
  81. {granny_devops-0.14.0 → granny_devops-0.15.0}/granny/dns/cloudns.py +0 -0
  82. {granny_devops-0.14.0 → granny_devops-0.15.0}/granny/dns/desec.py +0 -0
  83. {granny_devops-0.14.0 → granny_devops-0.15.0}/granny/dns/factory.py +0 -0
  84. {granny_devops-0.14.0 → granny_devops-0.15.0}/granny/dns/hetzner.py +0 -0
  85. {granny_devops-0.14.0 → granny_devops-0.15.0}/granny/dns/inwx.py +0 -0
  86. {granny_devops-0.14.0 → granny_devops-0.15.0}/granny/dns/manual.py +0 -0
  87. {granny_devops-0.14.0 → granny_devops-0.15.0}/granny/dns/records.py +0 -0
  88. {granny_devops-0.14.0 → granny_devops-0.15.0}/granny/docker/__init__.py +0 -0
  89. {granny_devops-0.14.0 → granny_devops-0.15.0}/granny/docker/build_base.py +0 -0
  90. {granny_devops-0.14.0 → granny_devops-0.15.0}/granny/edge/__init__.py +0 -0
  91. {granny_devops-0.14.0 → granny_devops-0.15.0}/granny/edge/bunny.py +0 -0
  92. {granny_devops-0.14.0 → granny_devops-0.15.0}/granny/elk/__init__.py +0 -0
  93. {granny_devops-0.14.0 → granny_devops-0.15.0}/granny/elk/client.py +0 -0
  94. {granny_devops-0.14.0 → granny_devops-0.15.0}/granny/email/__init__.py +0 -0
  95. {granny_devops-0.14.0 → granny_devops-0.15.0}/granny/email/mailjet.py +0 -0
  96. {granny_devops-0.14.0 → granny_devops-0.15.0}/granny/email/mailjet_contacts.py +0 -0
  97. {granny_devops-0.14.0 → granny_devops-0.15.0}/granny/email/ses_forwarding.py +0 -0
  98. {granny_devops-0.14.0 → granny_devops-0.15.0}/granny/email/stalwart.py +0 -0
  99. {granny_devops-0.14.0 → granny_devops-0.15.0}/granny/email/workmail.py +0 -0
  100. {granny_devops-0.14.0 → granny_devops-0.15.0}/granny/indexing/__init__.py +0 -0
  101. {granny_devops-0.14.0 → granny_devops-0.15.0}/granny/indexing/google.py +0 -0
  102. {granny_devops-0.14.0 → granny_devops-0.15.0}/granny/indexing/indexnow.py +0 -0
  103. {granny_devops-0.14.0 → granny_devops-0.15.0}/granny/report.py +0 -0
  104. {granny_devops-0.14.0 → granny_devops-0.15.0}/granny/serverless/__init__.py +0 -0
  105. {granny_devops-0.14.0 → granny_devops-0.15.0}/granny/serverless/scaleway.py +0 -0
  106. {granny_devops-0.14.0 → granny_devops-0.15.0}/granny/storage/__init__.py +0 -0
  107. {granny_devops-0.14.0 → granny_devops-0.15.0}/granny/storage/aws.py +0 -0
  108. {granny_devops-0.14.0 → granny_devops-0.15.0}/granny/storage/bunny.py +0 -0
  109. {granny_devops-0.14.0 → granny_devops-0.15.0}/granny/storage/hetzner.py +0 -0
  110. {granny_devops-0.14.0 → granny_devops-0.15.0}/granny/zitadel/__init__.py +0 -0
  111. {granny_devops-0.14.0 → granny_devops-0.15.0}/granny/zitadel/client.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: granny-devops
3
- Version: 0.14.0
3
+ Version: 0.15.0
4
4
  Summary: Cloud tools collection -- AWS infrastructure, CDN, and DevOps automation
5
5
  Author-email: Martin Wieser <martin.wieser@pseekoo.com>
6
6
  License: MIT License
@@ -1,6 +1,6 @@
1
1
  """Granny -- Cloud tools collection for AWS infrastructure and DevOps automation."""
2
2
 
3
- __version__ = "0.14.0"
3
+ __version__ = "0.15.0"
4
4
  __all__ = [
5
5
  "get_secret",
6
6
  "load_secrets_into_env",
@@ -0,0 +1,90 @@
1
+ """Amazon Bedrock foundation-model inspection helpers.
2
+
3
+ Thin boto3 wrapper around the Bedrock control-plane (``bedrock``) client.
4
+ ``list-models`` / ``check-access`` need ``bedrock:ListFoundationModels`` /
5
+ ``bedrock:GetFoundationModel``.
6
+
7
+ Bedrock has no per-model "deployment" object and **no public API to grant
8
+ on-demand foundation-model access** — that is done once in the console (Model
9
+ access). Once granted, callers invoke a model directly by id
10
+ (e.g. ``anthropic.claude-sonnet-4-20250514-v1:0``); the gateway routes it as
11
+ ``bedrock/<model-id>``. These helpers therefore discover model ids and report a
12
+ model's availability/lifecycle so a caller can build a route; they do not (and
13
+ cannot) enable access. The only API-driven "deployment" is Provisioned
14
+ Throughput (``create_provisioned_model_throughput``), which is a paid,
15
+ committed capacity resource and out of scope here.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from dataclasses import dataclass
21
+ from typing import Any
22
+
23
+ import boto3
24
+
25
+
26
+ @dataclass
27
+ class FoundationModel:
28
+ model_id: str
29
+ provider_name: str | None
30
+ model_name: str | None
31
+ input_modalities: list[str]
32
+ output_modalities: list[str]
33
+ response_streaming: bool | None
34
+ inference_types: list[str]
35
+ lifecycle: str | None
36
+ raw: dict[str, Any]
37
+
38
+
39
+ def _client(profile: str | None, region: str | None) -> Any:
40
+ session = boto3.Session(
41
+ profile_name=profile if profile and profile != "default" else None,
42
+ region_name=region,
43
+ )
44
+ return session.client("bedrock")
45
+
46
+
47
+ def _parse_model(item: dict[str, Any]) -> FoundationModel:
48
+ lifecycle = item.get("modelLifecycle", {})
49
+ return FoundationModel(
50
+ model_id=item.get("modelId", ""),
51
+ provider_name=item.get("providerName"),
52
+ model_name=item.get("modelName"),
53
+ input_modalities=list(item.get("inputModalities", [])),
54
+ output_modalities=list(item.get("outputModalities", [])),
55
+ response_streaming=item.get("responseStreamingSupported"),
56
+ inference_types=list(item.get("inferenceTypesSupported", [])),
57
+ lifecycle=lifecycle.get("status") if isinstance(lifecycle, dict) else None,
58
+ raw=item,
59
+ )
60
+
61
+
62
+ def list_foundation_models(
63
+ *,
64
+ profile: str | None = None,
65
+ region: str | None = None,
66
+ by_provider: str | None = None,
67
+ ) -> list[FoundationModel]:
68
+ """List Bedrock foundation models available in the region."""
69
+ client = _client(profile, region)
70
+ kwargs: dict[str, Any] = {}
71
+ if by_provider:
72
+ kwargs["byProvider"] = by_provider
73
+ resp = client.list_foundation_models(**kwargs)
74
+ return [_parse_model(m) for m in resp.get("modelSummaries", [])]
75
+
76
+
77
+ def get_foundation_model(
78
+ *,
79
+ model_id: str,
80
+ profile: str | None = None,
81
+ region: str | None = None,
82
+ ) -> FoundationModel:
83
+ """Fetch one foundation model's details (id, modalities, lifecycle).
84
+
85
+ Reports the model's availability, not the account's access grant — Bedrock
86
+ exposes no per-account on-demand access status via API.
87
+ """
88
+ client = _client(profile, region)
89
+ resp = client.get_foundation_model(modelIdentifier=model_id)
90
+ return _parse_model(resp.get("modelDetails", {}))
@@ -85,6 +85,16 @@ def post(
85
85
  return response.status_code
86
86
 
87
87
 
88
+ def post_json(
89
+ url: str, body: dict[str, Any] | None = None, *, token: str | None = None
90
+ ) -> dict[str, Any]:
91
+ """POST an ARM action endpoint (e.g. listKeys) and return the JSON body."""
92
+ response = requests.post(url, headers=headers(token), json=body, timeout=60)
93
+ if response.status_code not in (200, 201, 202):
94
+ raise_response_error("POST", url, response)
95
+ return response.json() if response.content else {}
96
+
97
+
88
98
  def paginate(url: str, *, token: str | None = None) -> Iterator[dict[str, Any]]:
89
99
  """Yield each item from a paginated ARM ``value``/``nextLink`` collection."""
90
100
  bearer = token if token is not None else get_management_token()
@@ -0,0 +1,311 @@
1
+ """Azure Cognitive Services (incl. Azure OpenAI) account and deployment listing."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from datetime import datetime, timedelta, timezone
7
+ from typing import Any
8
+
9
+ from granny.azure import _client
10
+
11
+ _API_VERSION = "2023-05-01"
12
+ # Deployment create/update needs a newer API version than the read path so the
13
+ # GlobalStandard / DataZoneStandard deployment SKUs are accepted.
14
+ _DEPLOY_API_VERSION = "2024-10-01"
15
+
16
+
17
+ @dataclass
18
+ class CognitiveAccount:
19
+ subscription: str
20
+ resource_group: str | None
21
+ name: str
22
+ kind: str | None
23
+ sku: str | None
24
+ location: str | None
25
+ endpoint: str | None
26
+ provisioning_state: str | None
27
+ id: str
28
+ raw: dict[str, Any]
29
+
30
+
31
+ @dataclass
32
+ class CognitiveDeployment:
33
+ subscription: str
34
+ resource_group: str | None
35
+ account_name: str
36
+ name: str
37
+ model_name: str | None
38
+ model_version: str | None
39
+ sku: str | None
40
+ sku_capacity: int | None
41
+ provisioning_state: str | None
42
+ created_at: str | None
43
+ last_used: str | None
44
+ id: str
45
+ raw: dict[str, Any]
46
+
47
+
48
+ def list_accounts(subscription: str) -> list[CognitiveAccount]:
49
+ """List Cognitive Services accounts in the subscription."""
50
+ url = (
51
+ f"{_client.MANAGEMENT_URL}/subscriptions/{subscription}"
52
+ f"/providers/Microsoft.CognitiveServices/accounts"
53
+ f"?api-version={_API_VERSION}"
54
+ )
55
+ return [
56
+ _parse_account(subscription=subscription, item=item)
57
+ for item in _client.paginate(url)
58
+ ]
59
+
60
+
61
+ def list_account_deployments(
62
+ subscription: str,
63
+ resource_group: str,
64
+ account_name: str,
65
+ ) -> list[CognitiveDeployment]:
66
+ """List model deployments under one Cognitive Services account."""
67
+ url = (
68
+ f"{_client.MANAGEMENT_URL}/subscriptions/{subscription}"
69
+ f"/resourceGroups/{resource_group}"
70
+ f"/providers/Microsoft.CognitiveServices/accounts/{account_name}/deployments"
71
+ f"?api-version={_API_VERSION}"
72
+ )
73
+ return [
74
+ _parse_deployment(
75
+ subscription=subscription,
76
+ resource_group=resource_group,
77
+ account_name=account_name,
78
+ item=item,
79
+ )
80
+ for item in _client.paginate(url)
81
+ ]
82
+
83
+
84
+ def list_all_deployments(
85
+ subscription: str,
86
+ kind_filter: str | None = None,
87
+ ) -> list[CognitiveDeployment]:
88
+ """List every model deployment across every Cognitive Services account."""
89
+ deployments: list[CognitiveDeployment] = []
90
+ for account in list_accounts(subscription):
91
+ if kind_filter and (account.kind or "").lower() != kind_filter.lower():
92
+ continue
93
+ if not account.resource_group:
94
+ continue
95
+ deployments.extend(
96
+ list_account_deployments(
97
+ subscription=subscription,
98
+ resource_group=account.resource_group,
99
+ account_name=account.name,
100
+ )
101
+ )
102
+ return deployments
103
+
104
+
105
+ def account_deployment_usage(
106
+ subscription: str,
107
+ resource_group: str,
108
+ account_name: str,
109
+ days: int = 90,
110
+ ) -> dict[str, str | None]:
111
+ """Return ``{deployment_name: last_used_iso}`` from Azure Monitor.
112
+
113
+ Uses the ``ModelRequests`` metric (Total, daily, split by
114
+ ``ModelDeploymentName``) over the last ``days`` (platform-metric retention
115
+ is ~93 days). ``None`` means no requests in the window. Requires
116
+ ``Microsoft.Insights/metrics/read`` on the account.
117
+ """
118
+ end = datetime.now(timezone.utc)
119
+ start = end - timedelta(days=days)
120
+ fmt = "%Y-%m-%dT%H:%M:%SZ"
121
+ rid = (
122
+ f"/subscriptions/{subscription}/resourceGroups/{resource_group}"
123
+ f"/providers/Microsoft.CognitiveServices/accounts/{account_name}"
124
+ )
125
+ url = (
126
+ f"{_client.MANAGEMENT_URL}{rid}/providers/microsoft.insights/metrics"
127
+ f"?api-version=2019-07-01&metricnames=ModelRequests&aggregation=Total"
128
+ f"&interval=P1D&timespan={start.strftime(fmt)}/{end.strftime(fmt)}"
129
+ f"&$filter=ModelDeploymentName eq '*'"
130
+ )
131
+ data = _client.get(url)
132
+ out: dict[str, str | None] = {}
133
+ for metric in data.get("value", []):
134
+ for ts in metric.get("timeseries", []):
135
+ dep = None
136
+ for md in ts.get("metadatavalues", []):
137
+ mn = md.get("name", {})
138
+ if (mn.get("value") if isinstance(mn, dict) else mn) == "ModelDeploymentName":
139
+ dep = md.get("value")
140
+ if dep is None:
141
+ continue
142
+ last = None
143
+ for pt in ts.get("data", []):
144
+ if pt.get("total"):
145
+ last = pt.get("timeStamp")
146
+ out[dep] = last
147
+ return out
148
+
149
+
150
+ def get_account(
151
+ subscription: str,
152
+ resource_group: str,
153
+ account_name: str,
154
+ ) -> CognitiveAccount:
155
+ """Fetch a single Cognitive Services account (incl. its endpoint)."""
156
+ url = (
157
+ f"{_client.MANAGEMENT_URL}/subscriptions/{subscription}"
158
+ f"/resourceGroups/{resource_group}"
159
+ f"/providers/Microsoft.CognitiveServices/accounts/{account_name}"
160
+ f"?api-version={_API_VERSION}"
161
+ )
162
+ return _parse_account(subscription=subscription, item=_client.get(url))
163
+
164
+
165
+ def get_account_keys(
166
+ subscription: str,
167
+ resource_group: str,
168
+ account_name: str,
169
+ ) -> dict[str, str]:
170
+ """Return the account's access keys (``{"key1": ..., "key2": ...}``)."""
171
+ url = (
172
+ f"{_client.MANAGEMENT_URL}/subscriptions/{subscription}"
173
+ f"/resourceGroups/{resource_group}"
174
+ f"/providers/Microsoft.CognitiveServices/accounts/{account_name}"
175
+ f"/listKeys?api-version={_API_VERSION}"
176
+ )
177
+ keys = _client.post_json(url)
178
+ return {k: v for k, v in keys.items() if k in ("key1", "key2")}
179
+
180
+
181
+ def create_account(
182
+ *,
183
+ subscription: str,
184
+ resource_group: str,
185
+ account_name: str,
186
+ location: str,
187
+ custom_subdomain: str | None = None,
188
+ sku_name: str = "S0",
189
+ ) -> CognitiveAccount:
190
+ """Create (or update) an Azure OpenAI (Cognitive Services) account via ARM PUT.
191
+
192
+ ``custom_subdomain`` must be globally unique (it becomes the ``*.openai.azure.com``
193
+ host); defaults to ``account_name``. The create is async — the returned account
194
+ may show provisioningState ``Accepted``/``Creating``; poll :func:`get_account`
195
+ until ``Succeeded`` before creating deployments.
196
+ """
197
+ url = (
198
+ f"{_client.MANAGEMENT_URL}/subscriptions/{subscription}"
199
+ f"/resourceGroups/{resource_group}"
200
+ f"/providers/Microsoft.CognitiveServices/accounts/{account_name}"
201
+ f"?api-version={_API_VERSION}"
202
+ )
203
+ body = {
204
+ "location": location,
205
+ "kind": "OpenAI",
206
+ "sku": {"name": sku_name},
207
+ "properties": {
208
+ "customSubDomainName": custom_subdomain or account_name,
209
+ "publicNetworkAccess": "Enabled",
210
+ },
211
+ }
212
+ item = _client.put(url, body)
213
+ return _parse_account(subscription=subscription, item=item)
214
+
215
+
216
+ def create_deployment(
217
+ *,
218
+ subscription: str,
219
+ resource_group: str,
220
+ account_name: str,
221
+ deployment_name: str,
222
+ model_name: str,
223
+ model_version: str | None = None,
224
+ model_format: str = "OpenAI",
225
+ sku_name: str = "GlobalStandard",
226
+ sku_capacity: int = 1,
227
+ ) -> CognitiveDeployment:
228
+ """Create (or update) an Azure OpenAI model deployment via ARM PUT.
229
+
230
+ ``deployment_name`` is the name callers request at inference time;
231
+ ``model_name`` (+ optional ``model_version``) is the underlying model, e.g.
232
+ ``gpt-4o`` / ``2024-08-06``. ``sku_capacity`` is in thousands of TPM for the
233
+ standard SKUs. Idempotent: PUT on an existing deployment updates it.
234
+ """
235
+ url = (
236
+ f"{_client.MANAGEMENT_URL}/subscriptions/{subscription}"
237
+ f"/resourceGroups/{resource_group}"
238
+ f"/providers/Microsoft.CognitiveServices/accounts/{account_name}"
239
+ f"/deployments/{deployment_name}"
240
+ f"?api-version={_DEPLOY_API_VERSION}"
241
+ )
242
+ model: dict[str, Any] = {"format": model_format, "name": model_name}
243
+ if model_version:
244
+ model["version"] = model_version
245
+ body = {
246
+ "sku": {"name": sku_name, "capacity": sku_capacity},
247
+ "properties": {"model": model},
248
+ }
249
+ item = _client.put(url, body)
250
+ return _parse_deployment(
251
+ subscription=subscription,
252
+ resource_group=resource_group,
253
+ account_name=account_name,
254
+ item=item,
255
+ )
256
+
257
+
258
+ def _parse_account(subscription: str, item: dict[str, Any]) -> CognitiveAccount:
259
+ properties = item.get("properties", {}) if isinstance(item, dict) else {}
260
+ sku = item.get("sku", {}) if isinstance(item, dict) else {}
261
+ resource_id = item.get("id", "")
262
+ return CognitiveAccount(
263
+ subscription=subscription,
264
+ resource_group=_resource_group_from_id(resource_id),
265
+ name=item.get("name", ""),
266
+ kind=item.get("kind"),
267
+ sku=sku.get("name") if isinstance(sku, dict) else None,
268
+ location=item.get("location"),
269
+ endpoint=properties.get("endpoint"),
270
+ provisioning_state=properties.get("provisioningState"),
271
+ id=resource_id,
272
+ raw=item,
273
+ )
274
+
275
+
276
+ def _parse_deployment(
277
+ subscription: str,
278
+ resource_group: str,
279
+ account_name: str,
280
+ item: dict[str, Any],
281
+ ) -> CognitiveDeployment:
282
+ properties = item.get("properties", {}) if isinstance(item, dict) else {}
283
+ model = properties.get("model", {}) if isinstance(properties, dict) else {}
284
+ sku = item.get("sku", {}) if isinstance(item, dict) else {}
285
+ system = item.get("systemData", {}) if isinstance(item, dict) else {}
286
+ return CognitiveDeployment(
287
+ subscription=subscription,
288
+ resource_group=resource_group,
289
+ account_name=account_name,
290
+ name=item.get("name", ""),
291
+ model_name=model.get("name") if isinstance(model, dict) else None,
292
+ model_version=model.get("version") if isinstance(model, dict) else None,
293
+ sku=sku.get("name") if isinstance(sku, dict) else None,
294
+ sku_capacity=sku.get("capacity") if isinstance(sku, dict) else None,
295
+ provisioning_state=properties.get("provisioningState"),
296
+ created_at=system.get("createdAt") if isinstance(system, dict) else None,
297
+ last_used=None, # filled in on demand via account_deployment_usage()
298
+ id=item.get("id", ""),
299
+ raw=item,
300
+ )
301
+
302
+
303
+ def _resource_group_from_id(resource_id: str) -> str | None:
304
+ parts = resource_id.lower().split("/")
305
+ try:
306
+ idx = parts.index("resourcegroups")
307
+ except ValueError:
308
+ return None
309
+ if idx + 1 < len(parts):
310
+ return resource_id.split("/")[idx + 1]
311
+ return None
@@ -0,0 +1,52 @@
1
+ """Azure resource-group create/list helpers (ARM REST via ``_client``)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import Any
7
+
8
+ from granny.azure import _client
9
+
10
+ _API_VERSION = "2021-04-01"
11
+
12
+
13
+ @dataclass
14
+ class ResourceGroup:
15
+ subscription: str
16
+ name: str
17
+ location: str | None
18
+ provisioning_state: str | None
19
+ id: str
20
+ raw: dict[str, Any]
21
+
22
+
23
+ def _parse(subscription: str, item: dict[str, Any]) -> ResourceGroup:
24
+ props = item.get("properties", {}) if isinstance(item, dict) else {}
25
+ return ResourceGroup(
26
+ subscription=subscription,
27
+ name=item.get("name", ""),
28
+ location=item.get("location"),
29
+ provisioning_state=props.get("provisioningState") if isinstance(props, dict) else None,
30
+ id=item.get("id", ""),
31
+ raw=item,
32
+ )
33
+
34
+
35
+ def create_resource_group(
36
+ *, subscription: str, name: str, location: str
37
+ ) -> ResourceGroup:
38
+ """Create (or update) a resource group. Idempotent."""
39
+ url = (
40
+ f"{_client.MANAGEMENT_URL}/subscriptions/{subscription}"
41
+ f"/resourcegroups/{name}?api-version={_API_VERSION}"
42
+ )
43
+ return _parse(subscription, _client.put(url, {"location": location}))
44
+
45
+
46
+ def list_resource_groups(subscription: str) -> list[ResourceGroup]:
47
+ """List resource groups in the subscription."""
48
+ url = (
49
+ f"{_client.MANAGEMENT_URL}/subscriptions/{subscription}"
50
+ f"/resourcegroups?api-version={_API_VERSION}"
51
+ )
52
+ return [_parse(subscription, item) for item in _client.paginate(url)]
@@ -168,6 +168,85 @@ def quota_request(
168
168
  click.echo(f"Case ID: {result.case_id}")
169
169
 
170
170
 
171
+ @aws.group()
172
+ def bedrock() -> None:
173
+ """Inspect and enable Amazon Bedrock foundation models."""
174
+
175
+
176
+ @bedrock.command("list-models")
177
+ @click.option("--by-provider", default=None, help="Filter by provider, e.g. Anthropic / Meta / Amazon")
178
+ @click.option("--profile", default=None, help="AWS profile name")
179
+ @click.option("--region", default=None, help="AWS region, e.g. eu-central-1")
180
+ @click.option("--json-output", "as_json", is_flag=True, help="Output as JSON")
181
+ def bedrock_list_models(
182
+ by_provider: str | None,
183
+ profile: str | None,
184
+ region: str | None,
185
+ as_json: bool,
186
+ ) -> None:
187
+ """List Bedrock foundation models available in the region."""
188
+ from dataclasses import asdict
189
+
190
+ from granny.aws.bedrock import list_foundation_models
191
+
192
+ with _friendly_errors():
193
+ rows = list_foundation_models(
194
+ profile=profile, region=region, by_provider=by_provider
195
+ )
196
+ if as_json:
197
+ click.echo(json.dumps([asdict(r) for r in rows], indent=2, default=str))
198
+ return
199
+
200
+ if not rows:
201
+ click.echo("No Bedrock foundation models found.")
202
+ return
203
+
204
+ click.echo(f"\n{'Model ID':<52} {'Provider':<16} Streaming")
205
+ click.echo("-" * 90)
206
+ for row in rows:
207
+ streaming = "-" if row.response_streaming is None else ("yes" if row.response_streaming else "no")
208
+ click.echo(f"{row.model_id:<52} {(row.provider_name or ''):<16} {streaming}")
209
+
210
+
211
+ @bedrock.command("check-access")
212
+ @click.option("--model-id", required=True, help="Foundation model id, e.g. anthropic.claude-sonnet-4-20250514-v1:0")
213
+ @click.option("--profile", default=None, help="AWS profile name")
214
+ @click.option("--region", default=None, help="AWS region, e.g. eu-central-1")
215
+ @click.option("--json-output", "as_json", is_flag=True, help="Output as JSON")
216
+ def bedrock_check_access(
217
+ model_id: str,
218
+ profile: str | None,
219
+ region: str | None,
220
+ as_json: bool,
221
+ ) -> None:
222
+ """Report a Bedrock foundation model's availability and on-demand support.
223
+
224
+ Bedrock has no API to grant on-demand access — that is a one-time console
225
+ step (Model access). This confirms the model exists, is ACTIVE, and supports
226
+ ON_DEMAND, then tells you the route id. If a later invoke returns
227
+ AccessDenied, enable the model in the console.
228
+ """
229
+ from dataclasses import asdict
230
+
231
+ from granny.aws.bedrock import get_foundation_model
232
+
233
+ with _friendly_errors():
234
+ fm = get_foundation_model(model_id=model_id, profile=profile, region=region)
235
+ on_demand = "ON_DEMAND" in fm.inference_types
236
+ if as_json:
237
+ out = asdict(fm)
238
+ out["on_demand"] = on_demand
239
+ out["route_model"] = f"bedrock/{fm.model_id}"
240
+ click.echo(json.dumps(out, indent=2, default=str))
241
+ return
242
+ click.echo(f"Model: {fm.model_id}")
243
+ click.echo(f"Provider: {fm.provider_name or '-'}")
244
+ click.echo(f"Lifecycle: {fm.lifecycle or '-'}")
245
+ click.echo(f"On-demand: {'yes' if on_demand else 'no'}")
246
+ click.echo(f"Route as: bedrock/{fm.model_id}")
247
+ click.echo("Access is granted in the Bedrock console (Model access); no public API.")
248
+
249
+
171
250
  @quota.command("status")
172
251
  @click.argument("quota_ref", default="gpu")
173
252
  @click.option("--service-code", default=None, help="Override service code (default: ec2)")