From 15c2774bb8e18fe7d656ab1f5b53538939def0b0 Mon Sep 17 00:00:00 2001 From: Mark Liffiton Date: Tue, 30 Jul 2024 15:32:56 -0500 Subject: [PATCH] Report on tokens remaining, add clarifications. --- src/codehelp/helper.py | 13 +++---- src/codehelp/templates/help_form.html | 10 ++++-- src/gened/openai.py | 42 +++++++++++++--------- src/gened/templates/free_query_dialog.html | 9 +++++ src/gened/templates/profile_view.html | 16 ++++++--- tests/test_demo_links.py | 31 +++++++++------- 6 files changed, 79 insertions(+), 42 deletions(-) create mode 100644 src/gened/templates/free_query_dialog.html diff --git a/src/codehelp/helper.py b/src/codehelp/helper.py index c942875..e0da360 100644 --- a/src/codehelp/helper.py +++ b/src/codehelp/helper.py @@ -46,7 +46,8 @@ @bp.route("/ctx//") @login_required @class_enabled_required -def help_form(query_id: int | None = None, class_id: int | None = None, ctx_name: str | None = None) -> str | Response: +@with_llm(spend_token=False) # get information on the selected LLM, tokens remaining +def help_form(llm: LLMConfig, query_id: int | None = None, class_id: int | None = None, ctx_name: str | None = None) -> str | Response: db = get_db() auth = get_auth() @@ -99,7 +100,7 @@ def help_form(query_id: int | None = None, class_id: int | None = None, ctx_name history = get_history() - return render_template("help_form.html", query=query_row, history=history, contexts=contexts, selected_context_name=selected_context_name) + return render_template("help_form.html", llm=llm, query=query_row, history=history, contexts=contexts, selected_context_name=selected_context_name) @bp.route("/view/") @@ -224,7 +225,7 @@ def record_response(query_id: int, responses: list[dict[str, str]], texts: dict[ @bp.route("/request", methods=["POST"]) @login_required @class_enabled_required -@with_llm() +@with_llm(spend_token=True) def help_request(llm: LLMConfig) -> Response: if 'context' in request.form: context = get_context_by_name(request.form['context']) @@ -246,7 +247,7 @@ def help_request(llm: LLMConfig) -> Response: @bp.route("/load_test", methods=["POST"]) @admin_required -@with_llm(use_system_key=True) # get a populated LLMConfig +@with_llm(use_system_key=True) # get a populated LLMConfig; not actually used (API is mocked) def load_test(llm: LLMConfig) -> Response: # Require that we're logged in as the load_test admin user auth = get_auth() @@ -284,7 +285,7 @@ def post_helpful() -> str: @bp.route("/topics/html/", methods=["GET", "POST"]) @login_required @tester_required -@with_llm() +@with_llm(spend_token=False) def get_topics_html(llm: LLMConfig, query_id: int) -> str: topics = get_topics(llm, query_id) if not topics: @@ -296,7 +297,7 @@ def get_topics_html(llm: LLMConfig, query_id: int) -> str: @bp.route("/topics/raw/", methods=["GET", "POST"]) @login_required @tester_required -@with_llm() +@with_llm(spend_token=False) def get_topics_raw(llm: LLMConfig, query_id: int) -> list[str]: topics = get_topics(llm, query_id) return topics diff --git a/src/codehelp/templates/help_form.html b/src/codehelp/templates/help_form.html index 6fed395..af76d1c 100644 --- a/src/codehelp/templates/help_form.html +++ b/src/codehelp/templates/help_form.html @@ -25,6 +25,13 @@ {{ auth['class_name'] }} + {% elif llm.tokens_remaining != None %} +
+ Using free queries: + {{ llm.tokens_remaining }} queries remaining. +
+ {% include "free_query_dialog.html" %} {% endif %} {% if contexts %} @@ -37,8 +44,7 @@ {% endif %}
-
- +

Copy just the most relevant part of your code here. Responses will be more helpful when you include only code relevant to your issue.

diff --git a/src/gened/openai.py b/src/gened/openai.py index fad9a53..3d92f5c 100644 --- a/src/gened/openai.py +++ b/src/gened/openai.py @@ -33,9 +33,10 @@ class NoTokensError(Exception): class LLMConfig: client: AsyncOpenAI model: str + tokens_remaining: int | None = None # None if current user is not using tokens -def _get_llm(*, use_system_key: bool) -> LLMConfig: +def _get_llm(*, use_system_key: bool, spend_token: bool) -> LLMConfig: ''' Get model details and an initialized OpenAI client based on the arguments and the current user and class. @@ -46,11 +47,10 @@ def _get_llm(*, use_system_key: bool) -> LLMConfig: b) User class config is in the user class. c) If there is a current class but it is disabled or has no key, raise an error. 3) If the user is a local-auth user, the system API key and GPT-3.5 is used. - 4) Otherwise, we use tokens. - The user must have 1 or more tokens remaining. + 4) Otherwise, we use tokens and the system API key / model. + If spend_token is True, the user must have 1 or more tokens remaining. If they have 0 tokens, raise an error. - Otherwise, their token count is decremented, and the system API - key is used with GPT-3.5. + Otherwise, their token count is decremented. Returns: LLMConfig with an OpenAI client and model name. @@ -59,7 +59,7 @@ def _get_llm(*, use_system_key: bool) -> LLMConfig: ''' db = get_db() - def make_system_client() -> LLMConfig: + def make_system_client(tokens_remaining: int | None = None) -> LLMConfig: """ Factory function to initialize a default client (using the system key) only if/when needed. """ @@ -70,6 +70,7 @@ def make_system_client() -> LLMConfig: return LLMConfig( client=AsyncOpenAI(api_key=system_key), model=system_model, + tokens_remaining=tokens_remaining, ) if use_system_key: @@ -124,13 +125,17 @@ def make_system_client() -> LLMConfig: return make_system_client() tokens = user_row['query_tokens'] + if tokens == 0: raise NoTokensError - # user.tokens > 0, so decrement it and use the system key - db.execute("UPDATE users SET query_tokens=query_tokens-1 WHERE id=?", [auth['user_id']]) - db.commit() - return make_system_client() + if spend_token: + # user.tokens > 0, so decrement it and use the system key + db.execute("UPDATE users SET query_tokens=query_tokens-1 WHERE id=?", [auth['user_id']]) + db.commit() + tokens -= 1 + + return make_system_client(tokens_remaining = tokens) # For decorator type hints @@ -138,32 +143,35 @@ def make_system_client() -> LLMConfig: R = TypeVar('R') -def with_llm(*, use_system_key: bool = False) -> Callable[[Callable[P, R]], Callable[P, str | R]]: +def with_llm(*, use_system_key: bool = False, spend_token: bool = False) -> Callable[[Callable[P, R]], Callable[P, str | R]]: '''Decorate a view function that requires an LLM and API key. Assigns an 'llm' named argument. Checks that the current user has access to an LLM and API key (configured in an LTI consumer or user-created class), then passes the appropriate - model info and API key to the wrapped view function, if granted. + LLM config to the wrapped view function, if granted. Arguments: use_system_key: If True, all users can access this, and they use the - system API key and GPT-3.5. + system API key and model. + spend_token: If True *and* the user is using tokens, then check + that they have tokens remaining and decrement their + tokens. ''' def decorator(f: Callable[P, R]) -> Callable[P, str | R]: @wraps(f) def decorated_function(*args: P.args, **kwargs: P.kwargs) -> str | R: try: - llm = _get_llm(use_system_key=use_system_key) + llm = _get_llm(use_system_key=use_system_key, spend_token=spend_token) except ClassDisabledError: - flash("Error: The current class is archived or disabled. Request cannot be submitted.") + flash("Error: The current class is archived or disabled.") return render_template("error.html") except NoKeyFoundError: - flash("Error: No API key set. Request cannot be submitted.") + flash("Error: No API key set. An API key must be set by the instructor before this page can be used.") return render_template("error.html") except NoTokensError: - flash("You have used all of your free tokens. If you are using this application in a class, please connect using the link from your class. Otherwise, you can create a class and add an OpenAI API key or contact us if you want to continue using this application.", "warning") + flash("You have used all of your free queries. If you are using this application in a class, please connect using the link from your class for continued access. Otherwise, you can create a class and add an OpenAI API key or contact us if you want to continue using this application.", "warning") return render_template("error.html") kwargs['llm'] = llm diff --git a/src/gened/templates/free_query_dialog.html b/src/gened/templates/free_query_dialog.html new file mode 100644 index 0000000..779092b --- /dev/null +++ b/src/gened/templates/free_query_dialog.html @@ -0,0 +1,9 @@ + +
+

Free Queries

+ +

You have a limited number of free queries to try out {{ config['APPLICATION_TITLE'] }} when you are not connected to a class.

+

If you are using this application in a class, please connect using the link from your class for unlimited use.

+

Otherwise, you can create a class and add an OpenAI API key or contact us if you want to continue using {{ config['APPLICATION_TITLE'] }}.

+
+
diff --git a/src/gened/templates/profile_view.html b/src/gened/templates/profile_view.html index aaf2087..91b09e6 100644 --- a/src/gened/templates/profile_view.html +++ b/src/gened/templates/profile_view.html @@ -8,7 +8,7 @@ {% block body %} @@ -50,8 +50,11 @@

Your Profile

Queries:
{{ user.num_queries }} total, {{ user.num_recent_queries }} in the past week.
{% if not auth['role'] %} -
Tokens:
-
{{ user.query_tokens }} remaining. (These are just for trying out {{ config['APPLICATION_TITLE'] }} -- each query will use one. They are only used when you do not have a class active.)
+
Free Queries:
+
+ {{ user.query_tokens }} remaining. +
{% endif %}

@@ -102,6 +105,8 @@

{% endif %} +

+ {% if user.provider_name not in ['lti', 'demo'] %}

Create a New Class

@@ -133,6 +138,9 @@

Create a New Class

-
+ {% endif %} + {% if not auth['role'] %} + {% include "free_query_dialog.html" %} + {% endif %} {% endblock %} diff --git a/tests/test_demo_links.py b/tests/test_demo_links.py index 00d3d9d..bea0863 100644 --- a/tests/test_demo_links.py +++ b/tests/test_demo_links.py @@ -24,30 +24,35 @@ def test_valid_demo_link(client): response = client.get("/demo/test_valid") assert "Invalid demo link." not in response.text + # test_data.sql assigns 3 tokens response = client.get("/help/") - assert response.status_code == 200 # unauthorized in all of these cases # Try 5 queries, verifying the tokens work (test_data.sql assigns 3 for this demo link) for i in range(5): + response1 = client.get("/help/") test_code = f"_test_code_{i}_" - response = client.post( + response2 = client.post( '/help/request', - data={'code': test_code, 'error': '_test_error_', 'issue': '_test_issue_'} + data={'code': test_code, 'error': f'_test_error_{i}_', 'issue': f'_test_issue_{i}_'} ) if i < 3: + assert response1.status_code == 200 # unauthorized in all of these cases + assert f"{3-i} queries remaining." in response1.text # successful requests should redirect to a response page with the same items - assert response.status_code == 302 # redirect - response = client.get(response.location) - assert test_code in response.text - assert '_test_error_' in response.text - assert '_test_issue_' in response.text + assert response2.status_code == 302 # redirect + response3 = client.get(response2.location) + assert test_code in response3.text + assert f'_test_error_{i}_' in response3.text + assert f'_test_issue_{i}_' in response3.text else: + assert response1.status_code == 200 # unauthorized in all of these cases + assert "You have used all of your free queries." in response1.text # those without tokens remaining return an error page directly - assert response.status_code == 200 - assert "You have used all of your free tokens." in response.text - assert test_code not in response.text - assert '_test_error_' not in response.text - assert '_test_issue_' not in response.text + assert response2.status_code == 200 + assert "You have used all of your free queries." in response2.text + assert test_code not in response2.text + assert '_test_error_' not in response2.text + assert '_test_issue_' not in response2.text def test_logged_in(auth, client):