macomb-bedrock 0.1.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.
@@ -0,0 +1,54 @@
1
+ Metadata-Version: 2.5
2
+ Name: macomb-bedrock
3
+ Version: 0.1.0
4
+ Summary: Amazon Bedrock helpers for ITIA 1510 lab activities
5
+ Requires-Python: >=3.12
6
+ Requires-Dist: boto3
7
+ Description-Content-Type: text/markdown
8
+
9
+ # macomb-bedrock
10
+
11
+ Amazon Bedrock helpers for the ITIA 1510 lab activities at Macomb Community College. The package sends prompts to Amazon Nova Micro (`amazon.nova-micro-v1:0`, us-east-1) through the Amazon Bedrock Converse API.
12
+
13
+ ## Install
14
+
15
+ ```
16
+ python3.13 -m pip install macomb-bedrock==0.1.0
17
+ ```
18
+
19
+ ## Use
20
+
21
+ ```python
22
+ from macomb_bedrock import ask, ask_history
23
+
24
+ print(ask("What makes a password strong?"))
25
+
26
+ conversation = [
27
+ "Is Summer2024! a strong password?",
28
+ "It is weak: it follows a season and year pattern.",
29
+ "Why does that pattern matter?",
30
+ ]
31
+ print(ask_history(conversation))
32
+ ```
33
+
34
+ | Name | What it does |
35
+ |---|---|
36
+ | `ask(prompt, system, client)` | Sends one prompt and returns the reply text |
37
+ | `ask_history(messages, system, client)` | Sends a conversation (user, assistant, user, ...) and returns the reply text |
38
+ | `call_cost(input_tokens, output_tokens)` | Returns the cost of one call in US dollars |
39
+ | `make_client()` | Returns a boto3 `bedrock-runtime` client for us-east-1 |
40
+ | `FakeClient(replies)` | A stand-in client for tests; returns the given replies in order and records each request |
41
+ | `fake_response(text, ...)` | A dictionary shaped like a Converse response |
42
+ | `BedrockError` | Raised with a plain explanation when a call fails or the reply is blocked by the content filter |
43
+
44
+ The lab server's IAM role grants the permission to call the model. The package holds no keys or passwords.
45
+
46
+ ## Build and publish
47
+
48
+ ```
49
+ py -m build
50
+ py -m twine upload --repository testpypi dist/*
51
+ py -m twine upload dist/*
52
+ ```
53
+
54
+ A version number can be uploaded to PyPI only once. Change `version` in `pyproject.toml` and `__version__` in `src/macomb_bedrock/__init__.py` before each release.
@@ -0,0 +1,46 @@
1
+ # macomb-bedrock
2
+
3
+ Amazon Bedrock helpers for the ITIA 1510 lab activities at Macomb Community College. The package sends prompts to Amazon Nova Micro (`amazon.nova-micro-v1:0`, us-east-1) through the Amazon Bedrock Converse API.
4
+
5
+ ## Install
6
+
7
+ ```
8
+ python3.13 -m pip install macomb-bedrock==0.1.0
9
+ ```
10
+
11
+ ## Use
12
+
13
+ ```python
14
+ from macomb_bedrock import ask, ask_history
15
+
16
+ print(ask("What makes a password strong?"))
17
+
18
+ conversation = [
19
+ "Is Summer2024! a strong password?",
20
+ "It is weak: it follows a season and year pattern.",
21
+ "Why does that pattern matter?",
22
+ ]
23
+ print(ask_history(conversation))
24
+ ```
25
+
26
+ | Name | What it does |
27
+ |---|---|
28
+ | `ask(prompt, system, client)` | Sends one prompt and returns the reply text |
29
+ | `ask_history(messages, system, client)` | Sends a conversation (user, assistant, user, ...) and returns the reply text |
30
+ | `call_cost(input_tokens, output_tokens)` | Returns the cost of one call in US dollars |
31
+ | `make_client()` | Returns a boto3 `bedrock-runtime` client for us-east-1 |
32
+ | `FakeClient(replies)` | A stand-in client for tests; returns the given replies in order and records each request |
33
+ | `fake_response(text, ...)` | A dictionary shaped like a Converse response |
34
+ | `BedrockError` | Raised with a plain explanation when a call fails or the reply is blocked by the content filter |
35
+
36
+ The lab server's IAM role grants the permission to call the model. The package holds no keys or passwords.
37
+
38
+ ## Build and publish
39
+
40
+ ```
41
+ py -m build
42
+ py -m twine upload --repository testpypi dist/*
43
+ py -m twine upload dist/*
44
+ ```
45
+
46
+ A version number can be uploaded to PyPI only once. Change `version` in `pyproject.toml` and `__version__` in `src/macomb_bedrock/__init__.py` before each release.
@@ -0,0 +1,19 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "macomb-bedrock"
7
+ version = "0.1.0"
8
+ description = "Amazon Bedrock helpers for ITIA 1510 lab activities"
9
+ readme = "README.md"
10
+ requires-python = ">=3.12"
11
+ dependencies = ["boto3"]
12
+
13
+ # Only these go into the source archive. Everything else in the folder
14
+ # (examples with their solutions, any personal files) stays off PyPI.
15
+ [tool.hatch.build.targets.sdist]
16
+ only-include = ["src", "tests", "README.md", "pyproject.toml"]
17
+
18
+ [tool.hatch.build.targets.wheel]
19
+ packages = ["src/macomb_bedrock"]
@@ -0,0 +1,156 @@
1
+ """
2
+ macomb_bedrock -- Amazon Bedrock helpers for ITIA 1510 lab activities.
3
+
4
+ Every function here sends a request to Amazon Nova Micro through the Amazon
5
+ Bedrock Converse API, or pretends to. The Converse call itself is in ask_history(),
6
+ so stepping into ask() with the debugger shows the whole request and response.
7
+
8
+ ask(prompt) send one prompt, return the reply text
9
+ ask_history(messages) send a whole conversation, return the reply text
10
+ call_cost(tokens...) the cost of one call in US dollars
11
+ FakeClient a stand-in for the real client, used by tests
12
+
13
+ The lab server already has permission to call Nova Micro. There is no key or
14
+ password in this package, and there never should be.
15
+ """
16
+
17
+ from typing import Any
18
+
19
+ import boto3
20
+ from botocore.exceptions import ClientError
21
+
22
+ __version__ = "0.1.0"
23
+
24
+ MODEL_ID = "amazon.nova-micro-v1:0"
25
+ REGION = "us-east-1"
26
+ MAX_TOKENS = 300
27
+
28
+ SYSTEM_PROMPT = (
29
+ "You are a cybersecurity tutor helping an IT team. "
30
+ "Answer in one short paragraph."
31
+ )
32
+
33
+ # US dollars per token: $0.035 per million input tokens, $0.14 per million
34
+ # output tokens.
35
+ INPUT_PRICE = 0.035 / 1_000_000
36
+ OUTPUT_PRICE = 0.14 / 1_000_000
37
+
38
+ FILTERED_MESSAGE = (
39
+ "The reply was blocked by the model's content filter. "
40
+ "Change the wording of the prompt or the system prompt and try again."
41
+ )
42
+
43
+ ERROR_MESSAGES = {
44
+ "ThrottlingException": (
45
+ "Too many requests in a short time. Wait a few seconds and try again."
46
+ ),
47
+ "AccessDeniedException": (
48
+ "This lab is not allowed to call the model. Either the lab's $1 "
49
+ "spending limit was reached, or the lab has no Bedrock permission."
50
+ ),
51
+ "ValidationException": (
52
+ "Bedrock refused the request because its shape is wrong. The "
53
+ "details are below."
54
+ ),
55
+ }
56
+
57
+
58
+ class BedrockError(Exception):
59
+ """A failed model call, explained in plain words."""
60
+
61
+
62
+ def make_client() -> Any:
63
+ """Return a client that sends requests to Amazon Bedrock in us-east-1."""
64
+ return boto3.client("bedrock-runtime", region_name=REGION)
65
+
66
+
67
+ def ask(prompt: str, system: str = SYSTEM_PROMPT, client: Any = None) -> str:
68
+ """Send one prompt to the model and return the text of its reply."""
69
+ return ask_history([prompt], system, client)
70
+
71
+
72
+ def ask_history(messages: list[str], system: str = SYSTEM_PROMPT,
73
+ client: Any = None) -> str:
74
+ """Send a conversation to the model and return the text of its reply.
75
+
76
+ messages holds the conversation so far as plain strings. The first is
77
+ from the user, the next is the model's reply, and so on, so the list
78
+ must have an odd length: it ends with the user's newest message.
79
+ """
80
+ if len(messages) % 2 == 0:
81
+ raise ValueError(
82
+ "messages must start and end with a user message, so it needs "
83
+ "an odd number of items. It has " + str(len(messages)) + "."
84
+ )
85
+
86
+ history = []
87
+ for i in range(len(messages)):
88
+ if i % 2 == 0:
89
+ role = "user"
90
+ else:
91
+ role = "assistant"
92
+ history.append({"role": role, "content": [{"text": messages[i]}]})
93
+
94
+ if client is None:
95
+ client = make_client()
96
+
97
+ try:
98
+ response = client.converse(
99
+ modelId=MODEL_ID,
100
+ messages=history,
101
+ system=[{"text": system}],
102
+ inferenceConfig={"maxTokens": MAX_TOKENS, "temperature": 0},
103
+ )
104
+ except ClientError as error:
105
+ code = error.response["Error"]["Code"]
106
+ explanation = ERROR_MESSAGES.get(code, "The model call failed.")
107
+ raise BedrockError(explanation + "\n" + str(error)) from error
108
+
109
+ if response["stopReason"] == "content_filtered":
110
+ raise BedrockError(FILTERED_MESSAGE)
111
+
112
+ return response["output"]["message"]["content"][0]["text"]
113
+
114
+
115
+ def call_cost(input_tokens: int, output_tokens: int) -> float:
116
+ """Return the cost of one call in US dollars."""
117
+ return input_tokens * INPUT_PRICE + output_tokens * OUTPUT_PRICE
118
+
119
+
120
+ def fake_response(text: str, stop_reason: str = "end_turn",
121
+ input_tokens: int = 10, output_tokens: int = 5) -> dict[str, Any]:
122
+ """Return a dictionary shaped like a real Converse response."""
123
+ return {
124
+ "output": {
125
+ "message": {"role": "assistant", "content": [{"text": text}]},
126
+ },
127
+ "stopReason": stop_reason,
128
+ "usage": {
129
+ "inputTokens": input_tokens,
130
+ "outputTokens": output_tokens,
131
+ "totalTokens": input_tokens + output_tokens,
132
+ },
133
+ }
134
+
135
+
136
+ class FakeClient:
137
+ """A stand-in for the real client, so tests never call the model.
138
+
139
+ It returns the replies it was given, one per call, in order, and keeps
140
+ every request it received in requests, so a test can check what was sent.
141
+ """
142
+
143
+ def __init__(self, replies: list[str]) -> None:
144
+ self.replies = replies
145
+ self.requests: list[dict[str, Any]] = []
146
+
147
+ def converse(self, **request: Any) -> dict[str, Any]:
148
+ """Record the request and return the next reply."""
149
+ self.requests.append(request)
150
+ if len(self.requests) > len(self.replies):
151
+ raise AssertionError(
152
+ "FakeClient was called " + str(len(self.requests))
153
+ + " times but was given only " + str(len(self.replies))
154
+ + " replies."
155
+ )
156
+ return fake_response(self.replies[len(self.requests) - 1])
@@ -0,0 +1,78 @@
1
+ """Tests for macomb_bedrock. They use FakeClient, so they never call the model."""
2
+
3
+ import pytest
4
+ from botocore.exceptions import ClientError
5
+
6
+ import macomb_bedrock
7
+ from macomb_bedrock import (
8
+ BedrockError,
9
+ FakeClient,
10
+ ask,
11
+ ask_history,
12
+ call_cost,
13
+ fake_response,
14
+ )
15
+
16
+
17
+ def test_ask_returns_reply_text() -> None:
18
+ client = FakeClient(["Use a long passphrase."])
19
+ assert ask("What makes a password strong?", client=client) == "Use a long passphrase."
20
+
21
+
22
+ def test_ask_sends_one_user_message() -> None:
23
+ client = FakeClient(["ok"])
24
+ ask("Hello", client=client)
25
+ request = client.requests[0]
26
+ assert request["modelId"] == "amazon.nova-micro-v1:0"
27
+ assert request["messages"] == [{"role": "user", "content": [{"text": "Hello"}]}]
28
+ assert request["system"] == [{"text": macomb_bedrock.SYSTEM_PROMPT}]
29
+ assert request["inferenceConfig"]["temperature"] == 0
30
+
31
+
32
+ def test_ask_uses_given_system_prompt() -> None:
33
+ client = FakeClient(["ok"])
34
+ ask("Hello", system="Answer in one word.", client=client)
35
+ assert client.requests[0]["system"] == [{"text": "Answer in one word."}]
36
+
37
+
38
+ def test_ask_history_alternates_roles() -> None:
39
+ client = FakeClient(["third reply"])
40
+ ask_history(["first", "second", "third"], client=client)
41
+ roles = [message["role"] for message in client.requests[0]["messages"]]
42
+ assert roles == ["user", "assistant", "user"]
43
+
44
+
45
+ def test_ask_history_rejects_even_length() -> None:
46
+ with pytest.raises(ValueError):
47
+ ask_history(["first", "second"], client=FakeClient(["ok"]))
48
+
49
+
50
+ def test_content_filter_raises_bedrock_error() -> None:
51
+ class FilteredClient:
52
+ def converse(self, **request: object) -> dict:
53
+ return fake_response("blocked", stop_reason="content_filtered")
54
+
55
+ with pytest.raises(BedrockError, match="content filter"):
56
+ ask("Hello", client=FilteredClient())
57
+
58
+
59
+ def test_access_denied_explains_spending_limit() -> None:
60
+ class DeniedClient:
61
+ def converse(self, **request: object) -> dict:
62
+ error = {"Error": {"Code": "AccessDeniedException", "Message": "denied"}}
63
+ raise ClientError(error, "Converse")
64
+
65
+ with pytest.raises(BedrockError, match="spending limit"):
66
+ ask("Hello", client=DeniedClient())
67
+
68
+
69
+ def test_fake_client_runs_out_of_replies() -> None:
70
+ client = FakeClient(["only one"])
71
+ ask("first", client=client)
72
+ with pytest.raises(AssertionError):
73
+ ask("second", client=client)
74
+
75
+
76
+ def test_call_cost() -> None:
77
+ assert call_cost(1_000_000, 1_000_000) == pytest.approx(0.175)
78
+ assert call_cost(473, 69) == pytest.approx(0.000026215)