5 |
6 | {% if user_code %}
7 |
8 | - To sign in, type {{ user_code }} into
9 | {{ auth_uri }}
10 | to authenticate.
11 |
12 | - And then proceed.
13 |
14 | {% else %}
15 |
16 | To use this application, you must
17 | sign in.
18 | {% endif %}
19 |
20 | {% if reset_password_url %}
21 |
22 |
Reset password
23 | {% endif %}
24 |
25 |
26 | {% endblock %}
27 |
--------------------------------------------------------------------------------
/src/quartapp/templates/index.html:
--------------------------------------------------------------------------------
1 | {% extends "base.html" %}
2 | {% block content %}
3 |
50 |
51 |
52 |
53 |
108 | {% endblock %}
109 |
--------------------------------------------------------------------------------
/src/requirements.txt:
--------------------------------------------------------------------------------
1 | #
2 | # This file is autogenerated by pip-compile with Python 3.12
3 | # by the following command:
4 | #
5 | # pip-compile --output-file=requirements.txt pyproject.toml
6 | #
7 | aiofiles==24.1.0
8 | # via quart
9 | aiohappyeyeballs==2.4.4
10 | # via aiohttp
11 | aiohttp==3.11.11
12 | # via quartapp (pyproject.toml)
13 | aiosignal==1.3.2
14 | # via aiohttp
15 | annotated-types==0.7.0
16 | # via pydantic
17 | anyio==4.4.0
18 | # via
19 | # httpx
20 | # openai
21 | # watchfiles
22 | attrs==24.3.0
23 | # via aiohttp
24 | azure-core==1.30.2
25 | # via
26 | # azure-identity
27 | # azure-keyvault-secrets
28 | azure-identity==1.17.1
29 | # via quartapp (pyproject.toml)
30 | azure-keyvault-secrets==4.8.0
31 | # via quartapp (pyproject.toml)
32 | blinker==1.8.2
33 | # via
34 | # flask
35 | # quart
36 | certifi==2024.7.4
37 | # via
38 | # httpcore
39 | # httpx
40 | # requests
41 | cffi==1.16.0
42 | # via cryptography
43 | charset-normalizer==3.3.2
44 | # via requests
45 | click==8.1.7
46 | # via
47 | # flask
48 | # quart
49 | # uvicorn
50 | cryptography==44.0.1
51 | # via
52 | # azure-identity
53 | # msal
54 | # pyjwt
55 | distro==1.9.0
56 | # via openai
57 | flask==3.0.3
58 | # via quart
59 | frozenlist==1.4.1
60 | # via
61 | # aiohttp
62 | # aiosignal
63 | gunicorn==23.0.0
64 | # via quartapp (pyproject.toml)
65 | h11==0.14.0
66 | # via
67 | # httpcore
68 | # hypercorn
69 | # uvicorn
70 | # wsproto
71 | h2==4.1.0
72 | # via hypercorn
73 | hiredis==2.3.2
74 | # via redis
75 | hpack==4.0.0
76 | # via h2
77 | httpcore==1.0.5
78 | # via httpx
79 | httptools==0.6.1
80 | # via uvicorn
81 | httpx==0.27.0
82 | # via openai
83 | hypercorn==0.17.3
84 | # via quart
85 | hyperframe==6.0.1
86 | # via h2
87 | identity[quart]==0.8.0
88 | # via quartapp (pyproject.toml)
89 | idna==3.10
90 | # via
91 | # anyio
92 | # httpx
93 | # requests
94 | # yarl
95 | isodate==0.6.1
96 | # via azure-keyvault-secrets
97 | itsdangerous==2.2.0
98 | # via
99 | # flask
100 | # quart
101 | jinja2==3.1.6
102 | # via
103 | # flask
104 | # quart
105 | markupsafe==2.1.5
106 | # via
107 | # jinja2
108 | # quart
109 | # werkzeug
110 | msal==1.29.0
111 | # via
112 | # azure-identity
113 | # identity
114 | # msal-extensions
115 | msal-extensions==1.2.0
116 | # via azure-identity
117 | multidict==6.1.0
118 | # via
119 | # aiohttp
120 | # yarl
121 | openai==1.35.10
122 | # via quartapp (pyproject.toml)
123 | packaging==24.1
124 | # via gunicorn
125 | portalocker==2.10.0
126 | # via msal-extensions
127 | priority==2.0.0
128 | # via hypercorn
129 | propcache==0.2.1
130 | # via
131 | # aiohttp
132 | # yarl
133 | pycparser==2.22
134 | # via cffi
135 | pydantic==2.8.2
136 | # via openai
137 | pydantic-core==2.20.1
138 | # via pydantic
139 | pyjwt[crypto]==2.8.0
140 | # via msal
141 | python-dotenv==1.0.1
142 | # via
143 | # quartapp (pyproject.toml)
144 | # uvicorn
145 | pyyaml==6.0.1
146 | # via
147 | # quartapp (pyproject.toml)
148 | # uvicorn
149 | quart==0.20.0
150 | # via
151 | # identity
152 | # quart-session
153 | # quartapp (pyproject.toml)
154 | quart-session==3.0.0
155 | # via identity
156 | redis[hiredis]==5.0.7
157 | # via quartapp (pyproject.toml)
158 | requests==2.32.3
159 | # via
160 | # azure-core
161 | # identity
162 | # msal
163 | six==1.16.0
164 | # via
165 | # azure-core
166 | # isodate
167 | sniffio==1.3.1
168 | # via
169 | # anyio
170 | # openai
171 | tqdm==4.66.4
172 | # via openai
173 | typing-extensions==4.12.2
174 | # via
175 | # azure-core
176 | # azure-identity
177 | # azure-keyvault-secrets
178 | # openai
179 | # pydantic
180 | # pydantic-core
181 | urllib3==2.2.2
182 | # via requests
183 | uvicorn[standard]==0.30.1
184 | # via quartapp (pyproject.toml)
185 | uvloop==0.19.0
186 | # via uvicorn
187 | watchfiles==0.22.0
188 | # via uvicorn
189 | websockets==12.0
190 | # via uvicorn
191 | werkzeug==3.0.6
192 | # via
193 | # flask
194 | # quart
195 | # quartapp (pyproject.toml)
196 | wsproto==1.2.0
197 | # via hypercorn
198 | yarl==1.18.3
199 | # via aiohttp
200 |
--------------------------------------------------------------------------------
/tests/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Azure-Samples/openai-chat-app-entra-auth-local/5148af1db0ffd98973a649df7cfb710a4fd22904/tests/__init__.py
--------------------------------------------------------------------------------
/tests/conftest.py:
--------------------------------------------------------------------------------
1 | from functools import wraps
2 |
3 | import identity.quart
4 | import openai
5 | import pytest
6 | import pytest_asyncio
7 | from azure.keyvault.secrets.aio import SecretClient
8 |
9 | import quartapp
10 |
11 | from . import mock_cred
12 |
13 |
14 | @pytest.fixture
15 | def mock_openai_chatcompletion(monkeypatch):
16 | class AsyncChatCompletionIterator:
17 | def __init__(self, answer: str):
18 | self.chunk_index = 0
19 | self.chunks = [
20 | # This is an Azure-specific chunk solely for prompt_filter_results
21 | openai.types.chat.ChatCompletionChunk(
22 | object="chat.completion.chunk",
23 | choices=[],
24 | id="",
25 | created=0,
26 | model="",
27 | prompt_filter_results=[
28 | {
29 | "prompt_index": 0,
30 | "content_filter_results": {
31 | "hate": {"filtered": False, "severity": "safe"},
32 | "self_harm": {"filtered": False, "severity": "safe"},
33 | "sexual": {"filtered": False, "severity": "safe"},
34 | "violence": {"filtered": False, "severity": "safe"},
35 | },
36 | }
37 | ],
38 | ),
39 | openai.types.chat.ChatCompletionChunk(
40 | id="test-123",
41 | object="chat.completion.chunk",
42 | choices=[
43 | openai.types.chat.chat_completion_chunk.Choice(
44 | delta=openai.types.chat.chat_completion_chunk.ChoiceDelta(content=None, role="assistant"),
45 | index=0,
46 | finish_reason=None,
47 | # Only Azure includes content_filter_results
48 | content_filter_results={},
49 | )
50 | ],
51 | created=1703462735,
52 | model="gpt-35-turbo",
53 | ),
54 | ]
55 | answer_deltas = answer.split(" ")
56 | for answer_index, answer_delta in enumerate(answer_deltas):
57 | # Completion chunks include whitespace, so we need to add it back in
58 | if answer_index > 0:
59 | answer_delta = " " + answer_delta
60 | self.chunks.append(
61 | openai.types.chat.ChatCompletionChunk(
62 | id="test-123",
63 | object="chat.completion.chunk",
64 | choices=[
65 | openai.types.chat.chat_completion_chunk.Choice(
66 | delta=openai.types.chat.chat_completion_chunk.ChoiceDelta(
67 | role=None, content=answer_delta
68 | ),
69 | finish_reason=None,
70 | index=0,
71 | logprobs=None,
72 | # Only Azure includes content_filter_results
73 | content_filter_results={
74 | "hate": {"filtered": False, "severity": "safe"},
75 | "self_harm": {"filtered": False, "severity": "safe"},
76 | "sexual": {"filtered": False, "severity": "safe"},
77 | "violence": {"filtered": False, "severity": "safe"},
78 | },
79 | )
80 | ],
81 | created=1703462735,
82 | model="gpt-35-turbo",
83 | )
84 | )
85 | self.chunks.append(
86 | openai.types.chat.ChatCompletionChunk(
87 | id="test-123",
88 | object="chat.completion.chunk",
89 | choices=[
90 | openai.types.chat.chat_completion_chunk.Choice(
91 | delta=openai.types.chat.chat_completion_chunk.ChoiceDelta(content=None, role=None),
92 | index=0,
93 | finish_reason="stop",
94 | # Only Azure includes content_filter_results
95 | content_filter_results={},
96 | )
97 | ],
98 | created=1703462735,
99 | model="gpt-35-turbo",
100 | )
101 | )
102 |
103 | def __aiter__(self):
104 | return self
105 |
106 | async def __anext__(self):
107 | if self.chunk_index < len(self.chunks):
108 | next_chunk = self.chunks[self.chunk_index]
109 | self.chunk_index += 1
110 | return next_chunk
111 | else:
112 | raise StopAsyncIteration
113 |
114 | async def mock_acreate(*args, **kwargs):
115 | # Only mock a stream=True completion
116 | last_message = kwargs.get("messages")[-1]["content"]
117 | if last_message == "What is the capital of France?":
118 | return AsyncChatCompletionIterator("The capital of France is Paris.")
119 | elif last_message == "What is the capital of Germany?":
120 | return AsyncChatCompletionIterator("The capital of Germany is Berlin.")
121 | else:
122 | raise ValueError(f"Unexpected message: {last_message}")
123 |
124 | monkeypatch.setattr("openai.resources.chat.AsyncCompletions.create", mock_acreate)
125 |
126 |
127 | @pytest.fixture
128 | def mock_defaultazurecredential(monkeypatch):
129 | monkeypatch.setattr("azure.identity.aio.DefaultAzureCredential", mock_cred.MockAzureCredential)
130 |
131 |
132 | @pytest.fixture
133 | def mock_keyvault_secretclient(monkeypatch):
134 | monkeypatch.setenv("AZURE_KEY_VAULT_NAME", "my_key_vault")
135 | monkeypatch.setenv("AZURE_AUTH_CLIENT_SECRET_NAME", "my_secret_name")
136 |
137 | async def get_secret(*args, **kwargs):
138 | if args[1] == "my_secret_name":
139 | return mock_cred.MockKeyVaultSecret("mysecret")
140 | raise Exception(f"Unexpected secret name: {args[1]}")
141 |
142 | monkeypatch.setattr(SecretClient, "get_secret", get_secret)
143 |
144 |
145 | @pytest.fixture
146 | def mock_login_required(monkeypatch):
147 | def login_required(self, f):
148 | context = {
149 | "user": {
150 | "name": "Namey McNameface",
151 | # Other fields have been omitted for brevity
152 | }
153 | }
154 |
155 | @wraps(f)
156 | async def decorated_function(*args, **kwargs):
157 | return await f(*args, context=context, **kwargs)
158 |
159 | return decorated_function
160 |
161 | monkeypatch.setattr(identity.quart.Auth, "login_required", login_required)
162 |
163 |
164 | @pytest_asyncio.fixture
165 | async def client(
166 | monkeypatch,
167 | mock_openai_chatcompletion,
168 | mock_defaultazurecredential,
169 | mock_keyvault_secretclient,
170 | mock_login_required,
171 | ):
172 | monkeypatch.setenv("AZURE_OPENAI_ENDPOINT", "test-openai-service.openai.azure.com")
173 | monkeypatch.setenv("AZURE_OPENAI_CHATGPT_DEPLOYMENT", "test-chatgpt")
174 |
175 | quart_app = quartapp.create_app()
176 |
177 | async with quart_app.test_app() as test_app:
178 | quart_app.config.update({"TESTING": True})
179 |
180 | yield test_app.test_client()
181 |
--------------------------------------------------------------------------------
/tests/mock_cred.py:
--------------------------------------------------------------------------------
1 | import azure.core.credentials_async
2 |
3 |
4 | class MockAzureCredential(azure.core.credentials_async.AsyncTokenCredential):
5 | pass
6 |
7 |
8 | class MockKeyVaultSecret:
9 | def __init__(self, value):
10 | self.value = value
11 |
--------------------------------------------------------------------------------
/tests/snapshots/test_app/test_chat_stream_text/result.json:
--------------------------------------------------------------------------------
1 | {"delta": {"content": null, "function_call": null, "role": "assistant", "tool_calls": null}, "finish_reason": null, "index": 0, "logprobs": null, "content_filter_results": {}}
2 | {"delta": {"content": "The", "function_call": null, "role": null, "tool_calls": null}, "finish_reason": null, "index": 0, "logprobs": null, "content_filter_results": {"hate": {"filtered": false, "severity": "safe"}, "self_harm": {"filtered": false, "severity": "safe"}, "sexual": {"filtered": false, "severity": "safe"}, "violence": {"filtered": false, "severity": "safe"}}}
3 | {"delta": {"content": " capital", "function_call": null, "role": null, "tool_calls": null}, "finish_reason": null, "index": 0, "logprobs": null, "content_filter_results": {"hate": {"filtered": false, "severity": "safe"}, "self_harm": {"filtered": false, "severity": "safe"}, "sexual": {"filtered": false, "severity": "safe"}, "violence": {"filtered": false, "severity": "safe"}}}
4 | {"delta": {"content": " of", "function_call": null, "role": null, "tool_calls": null}, "finish_reason": null, "index": 0, "logprobs": null, "content_filter_results": {"hate": {"filtered": false, "severity": "safe"}, "self_harm": {"filtered": false, "severity": "safe"}, "sexual": {"filtered": false, "severity": "safe"}, "violence": {"filtered": false, "severity": "safe"}}}
5 | {"delta": {"content": " France", "function_call": null, "role": null, "tool_calls": null}, "finish_reason": null, "index": 0, "logprobs": null, "content_filter_results": {"hate": {"filtered": false, "severity": "safe"}, "self_harm": {"filtered": false, "severity": "safe"}, "sexual": {"filtered": false, "severity": "safe"}, "violence": {"filtered": false, "severity": "safe"}}}
6 | {"delta": {"content": " is", "function_call": null, "role": null, "tool_calls": null}, "finish_reason": null, "index": 0, "logprobs": null, "content_filter_results": {"hate": {"filtered": false, "severity": "safe"}, "self_harm": {"filtered": false, "severity": "safe"}, "sexual": {"filtered": false, "severity": "safe"}, "violence": {"filtered": false, "severity": "safe"}}}
7 | {"delta": {"content": " Paris.", "function_call": null, "role": null, "tool_calls": null}, "finish_reason": null, "index": 0, "logprobs": null, "content_filter_results": {"hate": {"filtered": false, "severity": "safe"}, "self_harm": {"filtered": false, "severity": "safe"}, "sexual": {"filtered": false, "severity": "safe"}, "violence": {"filtered": false, "severity": "safe"}}}
8 | {"delta": {"content": null, "function_call": null, "role": null, "tool_calls": null}, "finish_reason": "stop", "index": 0, "logprobs": null, "content_filter_results": {}}
9 |
--------------------------------------------------------------------------------
/tests/snapshots/test_app/test_chat_stream_text_history/result.json:
--------------------------------------------------------------------------------
1 | {"delta": {"content": null, "function_call": null, "role": "assistant", "tool_calls": null}, "finish_reason": null, "index": 0, "logprobs": null, "content_filter_results": {}}
2 | {"delta": {"content": "The", "function_call": null, "role": null, "tool_calls": null}, "finish_reason": null, "index": 0, "logprobs": null, "content_filter_results": {"hate": {"filtered": false, "severity": "safe"}, "self_harm": {"filtered": false, "severity": "safe"}, "sexual": {"filtered": false, "severity": "safe"}, "violence": {"filtered": false, "severity": "safe"}}}
3 | {"delta": {"content": " capital", "function_call": null, "role": null, "tool_calls": null}, "finish_reason": null, "index": 0, "logprobs": null, "content_filter_results": {"hate": {"filtered": false, "severity": "safe"}, "self_harm": {"filtered": false, "severity": "safe"}, "sexual": {"filtered": false, "severity": "safe"}, "violence": {"filtered": false, "severity": "safe"}}}
4 | {"delta": {"content": " of", "function_call": null, "role": null, "tool_calls": null}, "finish_reason": null, "index": 0, "logprobs": null, "content_filter_results": {"hate": {"filtered": false, "severity": "safe"}, "self_harm": {"filtered": false, "severity": "safe"}, "sexual": {"filtered": false, "severity": "safe"}, "violence": {"filtered": false, "severity": "safe"}}}
5 | {"delta": {"content": " Germany", "function_call": null, "role": null, "tool_calls": null}, "finish_reason": null, "index": 0, "logprobs": null, "content_filter_results": {"hate": {"filtered": false, "severity": "safe"}, "self_harm": {"filtered": false, "severity": "safe"}, "sexual": {"filtered": false, "severity": "safe"}, "violence": {"filtered": false, "severity": "safe"}}}
6 | {"delta": {"content": " is", "function_call": null, "role": null, "tool_calls": null}, "finish_reason": null, "index": 0, "logprobs": null, "content_filter_results": {"hate": {"filtered": false, "severity": "safe"}, "self_harm": {"filtered": false, "severity": "safe"}, "sexual": {"filtered": false, "severity": "safe"}, "violence": {"filtered": false, "severity": "safe"}}}
7 | {"delta": {"content": " Berlin.", "function_call": null, "role": null, "tool_calls": null}, "finish_reason": null, "index": 0, "logprobs": null, "content_filter_results": {"hate": {"filtered": false, "severity": "safe"}, "self_harm": {"filtered": false, "severity": "safe"}, "sexual": {"filtered": false, "severity": "safe"}, "violence": {"filtered": false, "severity": "safe"}}}
8 | {"delta": {"content": null, "function_call": null, "role": null, "tool_calls": null}, "finish_reason": "stop", "index": 0, "logprobs": null, "content_filter_results": {}}
9 |
--------------------------------------------------------------------------------
/tests/test_app.py:
--------------------------------------------------------------------------------
1 | import pytest
2 |
3 | import quartapp
4 |
5 |
6 | @pytest.mark.asyncio
7 | async def test_index(client):
8 | response = await client.get("/")
9 | assert response.status_code == 200
10 | assert b"Namey McNameface" in await response.get_data()
11 |
12 |
13 | @pytest.mark.asyncio
14 | async def test_chat_stream_text(client, snapshot):
15 | response = await client.post(
16 | "/chat/stream",
17 | json={
18 | "messages": [
19 | {"role": "user", "content": "What is the capital of France?"},
20 | ]
21 | },
22 | )
23 | assert response.status_code == 200
24 | result = await response.get_data()
25 | snapshot.assert_match(result, "result.json")
26 |
27 |
28 | @pytest.mark.asyncio
29 | async def test_chat_stream_text_history(client, snapshot):
30 | response = await client.post(
31 | "/chat/stream",
32 | json={
33 | "messages": [
34 | {"role": "user", "content": "What is the capital of France?"},
35 | {"role": "assistant", "content": "Paris"},
36 | {"role": "user", "content": "What is the capital of Germany?"},
37 | ]
38 | },
39 | )
40 | assert response.status_code == 200
41 | result = await response.get_data()
42 | snapshot.assert_match(result, "result.json")
43 |
44 |
45 | @pytest.mark.asyncio
46 | async def test_openai_key(monkeypatch, mock_keyvault_secretclient):
47 | monkeypatch.setenv("AZURE_OPENAI_KEY", "test-key")
48 | monkeypatch.setenv("AZURE_OPENAI_ENDPOINT", "test-openai-service.openai.azure.com")
49 | monkeypatch.setenv("AZURE_OPENAI_CHATGPT_DEPLOYMENT", "test-chatgpt")
50 | monkeypatch.setenv("AZURE_OPENAI_VERSION", "2023-10-01-preview")
51 |
52 | quart_app = quartapp.create_app()
53 |
54 | async with quart_app.test_app():
55 | assert quart_app.blueprints["chat"].openai_client.api_key == "test-key"
56 | assert quart_app.blueprints["chat"].openai_client._azure_ad_token_provider is None
57 |
58 |
59 | @pytest.mark.asyncio
60 | async def test_openai_managedidentity(monkeypatch, mock_keyvault_secretclient):
61 | monkeypatch.setenv("AZURE_OPENAI_CLIENT_ID", "test-client-id")
62 | monkeypatch.setenv("AZURE_OPENAI_ENDPOINT", "test-openai-service.openai.azure.com")
63 | monkeypatch.setenv("AZURE_OPENAI_CHATGPT_DEPLOYMENT", "test-chatgpt")
64 | monkeypatch.setenv("AZURE_OPENAI_VERSION", "2023-10-01-preview")
65 |
66 | quart_app = quartapp.create_app()
67 |
68 | async with quart_app.test_app():
69 | assert quart_app.blueprints["chat"].openai_client._azure_ad_token_provider is not None
70 |
71 |
72 | @pytest.mark.asyncio
73 | async def test_openai_local(monkeypatch, mock_keyvault_secretclient):
74 | monkeypatch.setenv("LOCAL_OPENAI_ENDPOINT", "http://localhost:8080")
75 |
76 | quart_app = quartapp.create_app()
77 |
78 | async with quart_app.test_app():
79 | assert quart_app.blueprints["chat"].openai_client.api_key == "no-key-required"
80 | assert quart_app.blueprints["chat"].openai_client.base_url == "http://localhost:8080"
81 |
--------------------------------------------------------------------------------