2026-06-16 08:07:42 +03:00
|
|
|
|
import asyncio
|
|
|
|
|
|
from types import SimpleNamespace
|
|
|
|
|
|
|
2026-06-03 10:14:34 +05:00
|
|
|
|
import pytest
|
2026-06-05 13:27:10 -05:00
|
|
|
|
from fastapi import HTTPException
|
|
|
|
|
|
|
2026-06-16 08:07:42 +03:00
|
|
|
|
import routes.chat_helpers as chat_helpers
|
2026-06-06 14:30:16 +04:00
|
|
|
|
from routes.chat_helpers import (
|
|
|
|
|
|
_enforce_chat_privileges,
|
2026-06-16 08:07:42 +03:00
|
|
|
|
_session_is_research_spinoff,
|
|
|
|
|
|
auto_name_session,
|
|
|
|
|
|
build_chat_context,
|
2026-06-06 14:30:16 +04:00
|
|
|
|
clean_thinking_for_save,
|
|
|
|
|
|
needs_auto_name,
|
2026-06-16 08:07:42 +03:00
|
|
|
|
PreprocessedMessage,
|
|
|
|
|
|
PresetInfo,
|
2026-06-06 14:30:16 +04:00
|
|
|
|
save_assistant_response,
|
|
|
|
|
|
)
|
2026-06-05 13:27:10 -05:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class _AuthManager:
|
|
|
|
|
|
def __init__(self, privileges):
|
|
|
|
|
|
self._privileges = privileges
|
|
|
|
|
|
|
|
|
|
|
|
def get_privileges(self, username):
|
|
|
|
|
|
assert username == "alice"
|
|
|
|
|
|
return self._privileges
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class _Request:
|
|
|
|
|
|
def __init__(self, privileges):
|
|
|
|
|
|
self.app = type("App", (), {})()
|
|
|
|
|
|
self.app.state = type("State", (), {"auth_manager": _AuthManager(privileges)})()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class _Session:
|
|
|
|
|
|
def __init__(self, model):
|
|
|
|
|
|
self.model = model
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_allowed_models_legacy_empty_list_remains_unrestricted(monkeypatch):
|
fix(api): attribute bearer-token actions to the token owner on owner-scoped routes (#4054)
* fix(api): attribute bearer-token actions to the token owner on owner-scoped routes
Owner-scoped chat, session, and upload routes called
get_current_user(), which resolves a bearer ody_ API token to the
sandboxed "api" pseudo-user. A paired API-token client (companion, CLI,
IDE extension) therefore saw and created a separate "api"-owned silo
instead of the owner's data.
effective_user() already exists for exactly this: it attributes a token's
actions to request.state.api_token_owner, is identical to
get_current_user() for cookie sessions, and falls back safely when a
token has no owner. session_routes.py was already migrated; this
completes the migration for the remaining owner-scoped routes:
- chat_helpers.py: chat-privilege enforcement, message attribution, prefs/context
- chat_routes.py: orphaned-endpoint owner, session-auth owner, message search
- upload_routes.py: upload owner attribution + access checks
The /api/models swap is intentionally omitted: #4292 already migrated it
to effective_user (plus the chat-scope gate and ownerless-token 403), so
this PR keeps dev's version of routes/model_routes.py unchanged.
chat_routes.py keeps importing get_current_user for the workspace owner
gate; session_routes.py drops the now-unused import.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* test: target effective_user in auth monkeypatches and owner-scope assertion
The owner-scoped routes now call effective_user() instead of
get_current_user(), so the tests that stubbed get_current_user (or
asserted on it) follow suit:
- test_chat_helpers.py, test_review_regressions.py,
test_kv_cache_invalidation_2927.py: monkeypatch effective_user
- test_session_endpoint_owner_scope.py: assert the owner-scope guard uses
effective_user(request)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 22:56:22 +01:00
|
|
|
|
monkeypatch.setattr("routes.chat_helpers.effective_user", lambda request: "alice")
|
2026-06-05 13:27:10 -05:00
|
|
|
|
|
|
|
|
|
|
_enforce_chat_privileges(
|
|
|
|
|
|
_Request({"allowed_models": [], "max_messages_per_day": 0}),
|
|
|
|
|
|
_Session("provider/model-a"),
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_allowed_models_explicit_empty_restricted_list_blocks_all_models(monkeypatch):
|
fix(api): attribute bearer-token actions to the token owner on owner-scoped routes (#4054)
* fix(api): attribute bearer-token actions to the token owner on owner-scoped routes
Owner-scoped chat, session, and upload routes called
get_current_user(), which resolves a bearer ody_ API token to the
sandboxed "api" pseudo-user. A paired API-token client (companion, CLI,
IDE extension) therefore saw and created a separate "api"-owned silo
instead of the owner's data.
effective_user() already exists for exactly this: it attributes a token's
actions to request.state.api_token_owner, is identical to
get_current_user() for cookie sessions, and falls back safely when a
token has no owner. session_routes.py was already migrated; this
completes the migration for the remaining owner-scoped routes:
- chat_helpers.py: chat-privilege enforcement, message attribution, prefs/context
- chat_routes.py: orphaned-endpoint owner, session-auth owner, message search
- upload_routes.py: upload owner attribution + access checks
The /api/models swap is intentionally omitted: #4292 already migrated it
to effective_user (plus the chat-scope gate and ownerless-token 403), so
this PR keeps dev's version of routes/model_routes.py unchanged.
chat_routes.py keeps importing get_current_user for the workspace owner
gate; session_routes.py drops the now-unused import.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* test: target effective_user in auth monkeypatches and owner-scope assertion
The owner-scoped routes now call effective_user() instead of
get_current_user(), so the tests that stubbed get_current_user (or
asserted on it) follow suit:
- test_chat_helpers.py, test_review_regressions.py,
test_kv_cache_invalidation_2927.py: monkeypatch effective_user
- test_session_endpoint_owner_scope.py: assert the owner-scope guard uses
effective_user(request)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 22:56:22 +01:00
|
|
|
|
monkeypatch.setattr("routes.chat_helpers.effective_user", lambda request: "alice")
|
2026-06-05 13:27:10 -05:00
|
|
|
|
|
|
|
|
|
|
with pytest.raises(HTTPException) as exc:
|
|
|
|
|
|
_enforce_chat_privileges(
|
|
|
|
|
|
_Request({
|
|
|
|
|
|
"allowed_models": [],
|
|
|
|
|
|
"allowed_models_restricted": True,
|
|
|
|
|
|
"max_messages_per_day": 0,
|
|
|
|
|
|
}),
|
|
|
|
|
|
_Session("provider/model-a"),
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
assert exc.value.status_code == 403
|
|
|
|
|
|
assert "provider/model-a" in exc.value.detail
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_allowed_models_nonempty_list_still_restricts_without_new_flag(monkeypatch):
|
fix(api): attribute bearer-token actions to the token owner on owner-scoped routes (#4054)
* fix(api): attribute bearer-token actions to the token owner on owner-scoped routes
Owner-scoped chat, session, and upload routes called
get_current_user(), which resolves a bearer ody_ API token to the
sandboxed "api" pseudo-user. A paired API-token client (companion, CLI,
IDE extension) therefore saw and created a separate "api"-owned silo
instead of the owner's data.
effective_user() already exists for exactly this: it attributes a token's
actions to request.state.api_token_owner, is identical to
get_current_user() for cookie sessions, and falls back safely when a
token has no owner. session_routes.py was already migrated; this
completes the migration for the remaining owner-scoped routes:
- chat_helpers.py: chat-privilege enforcement, message attribution, prefs/context
- chat_routes.py: orphaned-endpoint owner, session-auth owner, message search
- upload_routes.py: upload owner attribution + access checks
The /api/models swap is intentionally omitted: #4292 already migrated it
to effective_user (plus the chat-scope gate and ownerless-token 403), so
this PR keeps dev's version of routes/model_routes.py unchanged.
chat_routes.py keeps importing get_current_user for the workspace owner
gate; session_routes.py drops the now-unused import.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* test: target effective_user in auth monkeypatches and owner-scope assertion
The owner-scoped routes now call effective_user() instead of
get_current_user(), so the tests that stubbed get_current_user (or
asserted on it) follow suit:
- test_chat_helpers.py, test_review_regressions.py,
test_kv_cache_invalidation_2927.py: monkeypatch effective_user
- test_session_endpoint_owner_scope.py: assert the owner-scope guard uses
effective_user(request)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 22:56:22 +01:00
|
|
|
|
monkeypatch.setattr("routes.chat_helpers.effective_user", lambda request: "alice")
|
2026-06-05 13:27:10 -05:00
|
|
|
|
|
|
|
|
|
|
_enforce_chat_privileges(
|
|
|
|
|
|
_Request({"allowed_models": ["provider/model-a"], "max_messages_per_day": 0}),
|
|
|
|
|
|
_Session("provider/model-a"),
|
|
|
|
|
|
)
|
|
|
|
|
|
with pytest.raises(HTTPException):
|
|
|
|
|
|
_enforce_chat_privileges(
|
|
|
|
|
|
_Request({"allowed_models": ["provider/model-a"], "max_messages_per_day": 0}),
|
|
|
|
|
|
_Session("provider/model-b"),
|
|
|
|
|
|
)
|
2026-06-03 10:14:34 +05:00
|
|
|
|
|
|
|
|
|
|
|
2026-06-08 17:52:39 -03:00
|
|
|
|
def test_no_restriction_allows_any_model(monkeypatch):
|
fix(api): attribute bearer-token actions to the token owner on owner-scoped routes (#4054)
* fix(api): attribute bearer-token actions to the token owner on owner-scoped routes
Owner-scoped chat, session, and upload routes called
get_current_user(), which resolves a bearer ody_ API token to the
sandboxed "api" pseudo-user. A paired API-token client (companion, CLI,
IDE extension) therefore saw and created a separate "api"-owned silo
instead of the owner's data.
effective_user() already exists for exactly this: it attributes a token's
actions to request.state.api_token_owner, is identical to
get_current_user() for cookie sessions, and falls back safely when a
token has no owner. session_routes.py was already migrated; this
completes the migration for the remaining owner-scoped routes:
- chat_helpers.py: chat-privilege enforcement, message attribution, prefs/context
- chat_routes.py: orphaned-endpoint owner, session-auth owner, message search
- upload_routes.py: upload owner attribution + access checks
The /api/models swap is intentionally omitted: #4292 already migrated it
to effective_user (plus the chat-scope gate and ownerless-token 403), so
this PR keeps dev's version of routes/model_routes.py unchanged.
chat_routes.py keeps importing get_current_user for the workspace owner
gate; session_routes.py drops the now-unused import.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* test: target effective_user in auth monkeypatches and owner-scope assertion
The owner-scoped routes now call effective_user() instead of
get_current_user(), so the tests that stubbed get_current_user (or
asserted on it) follow suit:
- test_chat_helpers.py, test_review_regressions.py,
test_kv_cache_invalidation_2927.py: monkeypatch effective_user
- test_session_endpoint_owner_scope.py: assert the owner-scope guard uses
effective_user(request)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 22:56:22 +01:00
|
|
|
|
monkeypatch.setattr("routes.chat_helpers.effective_user", lambda request: "alice")
|
2026-06-08 17:52:39 -03:00
|
|
|
|
|
|
|
|
|
|
privs = {"allowed_models": [], "block_all_models": False, "max_messages_per_day": 0}
|
|
|
|
|
|
_enforce_chat_privileges(_Request(privs), _Session("provider/model-a"))
|
|
|
|
|
|
_enforce_chat_privileges(_Request(privs), _Session("provider/model-z"))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_specific_allowlist_blocks_models_outside_it(monkeypatch):
|
fix(api): attribute bearer-token actions to the token owner on owner-scoped routes (#4054)
* fix(api): attribute bearer-token actions to the token owner on owner-scoped routes
Owner-scoped chat, session, and upload routes called
get_current_user(), which resolves a bearer ody_ API token to the
sandboxed "api" pseudo-user. A paired API-token client (companion, CLI,
IDE extension) therefore saw and created a separate "api"-owned silo
instead of the owner's data.
effective_user() already exists for exactly this: it attributes a token's
actions to request.state.api_token_owner, is identical to
get_current_user() for cookie sessions, and falls back safely when a
token has no owner. session_routes.py was already migrated; this
completes the migration for the remaining owner-scoped routes:
- chat_helpers.py: chat-privilege enforcement, message attribution, prefs/context
- chat_routes.py: orphaned-endpoint owner, session-auth owner, message search
- upload_routes.py: upload owner attribution + access checks
The /api/models swap is intentionally omitted: #4292 already migrated it
to effective_user (plus the chat-scope gate and ownerless-token 403), so
this PR keeps dev's version of routes/model_routes.py unchanged.
chat_routes.py keeps importing get_current_user for the workspace owner
gate; session_routes.py drops the now-unused import.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* test: target effective_user in auth monkeypatches and owner-scope assertion
The owner-scoped routes now call effective_user() instead of
get_current_user(), so the tests that stubbed get_current_user (or
asserted on it) follow suit:
- test_chat_helpers.py, test_review_regressions.py,
test_kv_cache_invalidation_2927.py: monkeypatch effective_user
- test_session_endpoint_owner_scope.py: assert the owner-scope guard uses
effective_user(request)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 22:56:22 +01:00
|
|
|
|
monkeypatch.setattr("routes.chat_helpers.effective_user", lambda request: "alice")
|
2026-06-08 17:52:39 -03:00
|
|
|
|
|
|
|
|
|
|
privs = {
|
|
|
|
|
|
"allowed_models": ["gpt-4"],
|
|
|
|
|
|
"block_all_models": False,
|
|
|
|
|
|
"max_messages_per_day": 0,
|
|
|
|
|
|
}
|
|
|
|
|
|
_enforce_chat_privileges(_Request(privs), _Session("gpt-4"))
|
|
|
|
|
|
with pytest.raises(HTTPException) as exc:
|
|
|
|
|
|
_enforce_chat_privileges(_Request(privs), _Session("gpt-3.5"))
|
|
|
|
|
|
assert exc.value.status_code == 403
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_block_all_models_blocks_regardless_of_allowed_models_contents(monkeypatch):
|
fix(api): attribute bearer-token actions to the token owner on owner-scoped routes (#4054)
* fix(api): attribute bearer-token actions to the token owner on owner-scoped routes
Owner-scoped chat, session, and upload routes called
get_current_user(), which resolves a bearer ody_ API token to the
sandboxed "api" pseudo-user. A paired API-token client (companion, CLI,
IDE extension) therefore saw and created a separate "api"-owned silo
instead of the owner's data.
effective_user() already exists for exactly this: it attributes a token's
actions to request.state.api_token_owner, is identical to
get_current_user() for cookie sessions, and falls back safely when a
token has no owner. session_routes.py was already migrated; this
completes the migration for the remaining owner-scoped routes:
- chat_helpers.py: chat-privilege enforcement, message attribution, prefs/context
- chat_routes.py: orphaned-endpoint owner, session-auth owner, message search
- upload_routes.py: upload owner attribution + access checks
The /api/models swap is intentionally omitted: #4292 already migrated it
to effective_user (plus the chat-scope gate and ownerless-token 403), so
this PR keeps dev's version of routes/model_routes.py unchanged.
chat_routes.py keeps importing get_current_user for the workspace owner
gate; session_routes.py drops the now-unused import.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* test: target effective_user in auth monkeypatches and owner-scope assertion
The owner-scoped routes now call effective_user() instead of
get_current_user(), so the tests that stubbed get_current_user (or
asserted on it) follow suit:
- test_chat_helpers.py, test_review_regressions.py,
test_kv_cache_invalidation_2927.py: monkeypatch effective_user
- test_session_endpoint_owner_scope.py: assert the owner-scope guard uses
effective_user(request)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 22:56:22 +01:00
|
|
|
|
monkeypatch.setattr("routes.chat_helpers.effective_user", lambda request: "alice")
|
2026-06-08 17:52:39 -03:00
|
|
|
|
|
|
|
|
|
|
# Even if allowed_models contains entries, block_all_models wins.
|
|
|
|
|
|
privs = {
|
|
|
|
|
|
"allowed_models": ["gpt-4", "gpt-3.5"],
|
|
|
|
|
|
"block_all_models": True,
|
|
|
|
|
|
"max_messages_per_day": 0,
|
|
|
|
|
|
}
|
|
|
|
|
|
with pytest.raises(HTTPException) as exc:
|
|
|
|
|
|
_enforce_chat_privileges(_Request(privs), _Session("gpt-4"))
|
|
|
|
|
|
assert exc.value.status_code == 403
|
|
|
|
|
|
|
|
|
|
|
|
with pytest.raises(HTTPException):
|
|
|
|
|
|
_enforce_chat_privileges(_Request(privs), _Session("anything-else"))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_admin_user_is_never_blocked(monkeypatch):
|
|
|
|
|
|
from core.auth import ADMIN_PRIVILEGES
|
|
|
|
|
|
|
fix(api): attribute bearer-token actions to the token owner on owner-scoped routes (#4054)
* fix(api): attribute bearer-token actions to the token owner on owner-scoped routes
Owner-scoped chat, session, and upload routes called
get_current_user(), which resolves a bearer ody_ API token to the
sandboxed "api" pseudo-user. A paired API-token client (companion, CLI,
IDE extension) therefore saw and created a separate "api"-owned silo
instead of the owner's data.
effective_user() already exists for exactly this: it attributes a token's
actions to request.state.api_token_owner, is identical to
get_current_user() for cookie sessions, and falls back safely when a
token has no owner. session_routes.py was already migrated; this
completes the migration for the remaining owner-scoped routes:
- chat_helpers.py: chat-privilege enforcement, message attribution, prefs/context
- chat_routes.py: orphaned-endpoint owner, session-auth owner, message search
- upload_routes.py: upload owner attribution + access checks
The /api/models swap is intentionally omitted: #4292 already migrated it
to effective_user (plus the chat-scope gate and ownerless-token 403), so
this PR keeps dev's version of routes/model_routes.py unchanged.
chat_routes.py keeps importing get_current_user for the workspace owner
gate; session_routes.py drops the now-unused import.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* test: target effective_user in auth monkeypatches and owner-scope assertion
The owner-scoped routes now call effective_user() instead of
get_current_user(), so the tests that stubbed get_current_user (or
asserted on it) follow suit:
- test_chat_helpers.py, test_review_regressions.py,
test_kv_cache_invalidation_2927.py: monkeypatch effective_user
- test_session_endpoint_owner_scope.py: assert the owner-scope guard uses
effective_user(request)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 22:56:22 +01:00
|
|
|
|
monkeypatch.setattr("routes.chat_helpers.effective_user", lambda request: "admin")
|
2026-06-08 17:52:39 -03:00
|
|
|
|
|
|
|
|
|
|
class _AdminAuthManager:
|
|
|
|
|
|
def get_privileges(self, username):
|
|
|
|
|
|
assert username == "admin"
|
|
|
|
|
|
return dict(ADMIN_PRIVILEGES)
|
|
|
|
|
|
|
|
|
|
|
|
class _AdminRequest:
|
|
|
|
|
|
def __init__(self):
|
|
|
|
|
|
self.app = type("App", (), {})()
|
|
|
|
|
|
self.app.state = type("State", (), {"auth_manager": _AdminAuthManager()})()
|
|
|
|
|
|
|
|
|
|
|
|
_enforce_chat_privileges(_AdminRequest(), _Session("provider/model-a"))
|
|
|
|
|
|
_enforce_chat_privileges(_AdminRequest(), _Session("anything-else"))
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-06-06 14:30:16 +04:00
|
|
|
|
class _FakeSession:
|
|
|
|
|
|
def __init__(self, model="selected-model"):
|
|
|
|
|
|
self.model = model
|
|
|
|
|
|
self.history = []
|
|
|
|
|
|
|
|
|
|
|
|
def add_message(self, message):
|
|
|
|
|
|
self.history.append(message)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-06-03 10:14:34 +05:00
|
|
|
|
@pytest.mark.parametrize("name,expected", [
|
|
|
|
|
|
# 24h format (the bug this PR fixes)
|
|
|
|
|
|
("deepseek-v4-flash 14:05:33", True),
|
|
|
|
|
|
("qwq 17:46:02", True),
|
|
|
|
|
|
("gemma3 23:59:59", True),
|
|
|
|
|
|
("claude-sonnet 4 0:00:00", True),
|
|
|
|
|
|
|
|
|
|
|
|
# 12h format (was already working)
|
|
|
|
|
|
("deepseek-v4-flash 2:05:33 PM", True),
|
|
|
|
|
|
("qwq 06:46:02 AM", True),
|
|
|
|
|
|
("claude-sonnet-4 8:05:17 am", True),
|
|
|
|
|
|
|
|
|
|
|
|
# empty / default
|
|
|
|
|
|
("", True),
|
|
|
|
|
|
(" ", False),
|
|
|
|
|
|
("Chat: something", True),
|
|
|
|
|
|
|
|
|
|
|
|
# custom titles – should NOT trigger auto-naming
|
|
|
|
|
|
("custom title", False),
|
|
|
|
|
|
("CW Decoder for STM32", False),
|
|
|
|
|
|
("my chat about python", False),
|
|
|
|
|
|
("Fix the login bug", False),
|
|
|
|
|
|
])
|
|
|
|
|
|
def test_needs_auto_name(name, expected):
|
|
|
|
|
|
assert needs_auto_name(name) == expected, f"needs_auto_name({name!r}) should be {expected}"
|
2026-06-04 20:26:58 +03:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_clean_thinking_for_save_extracts_gemma4_thought_channel():
|
|
|
|
|
|
content, metadata = clean_thinking_for_save(
|
|
|
|
|
|
"<|channel>thought\ninternal reasoning<channel|>Final answer.",
|
|
|
|
|
|
{"model": "google/gemma-4-31B-it"},
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
assert content == "Final answer."
|
|
|
|
|
|
assert metadata["thinking"] == "internal reasoning"
|
|
|
|
|
|
assert metadata["model"] == "google/gemma-4-31B-it"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_clean_thinking_for_save_strips_empty_gemma4_thought_channel():
|
|
|
|
|
|
content, metadata = clean_thinking_for_save(
|
|
|
|
|
|
"<|channel>thought\n<channel|>Final answer.",
|
|
|
|
|
|
{"model": "google/gemma-4-31B-it"},
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
assert content == "Final answer."
|
|
|
|
|
|
assert "thinking" not in metadata
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_clean_thinking_for_save_unwraps_gemma4_response_channel():
|
|
|
|
|
|
content, metadata = clean_thinking_for_save(
|
|
|
|
|
|
"<|channel>thought\ninternal reasoning<channel|><|channel>response\nFinal answer.<channel|>",
|
|
|
|
|
|
{"model": "google/gemma-4-31B-it"},
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
assert content == "Final answer."
|
|
|
|
|
|
assert metadata["thinking"] == "internal reasoning"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_clean_thinking_for_save_extracts_thought_tag():
|
|
|
|
|
|
content, metadata = clean_thinking_for_save(
|
|
|
|
|
|
"<thought>internal reasoning</thought>Final answer.",
|
|
|
|
|
|
{},
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
assert content == "Final answer."
|
|
|
|
|
|
assert metadata["thinking"] == "internal reasoning"
|
2026-06-06 14:30:16 +04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_save_assistant_response_preserves_actual_and_requested_model():
|
|
|
|
|
|
sess = _FakeSession("selected-model")
|
|
|
|
|
|
|
|
|
|
|
|
save_assistant_response(
|
|
|
|
|
|
sess,
|
|
|
|
|
|
session_manager=None,
|
|
|
|
|
|
session_id="s1",
|
|
|
|
|
|
full_response="hello",
|
|
|
|
|
|
last_metrics={"model": "actual-model", "input_tokens": 1, "output_tokens": 2},
|
|
|
|
|
|
incognito=True,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
assert sess.history[-1].metadata["requested_model"] == "selected-model"
|
|
|
|
|
|
assert sess.history[-1].metadata["model"] == "actual-model"
|
2026-06-15 11:31:57 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class _SpinMsg:
|
|
|
|
|
|
def __init__(self, role, metadata=None):
|
|
|
|
|
|
self.role = role
|
|
|
|
|
|
self.metadata = metadata
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_spinoff_detected_from_chatmessage_history():
|
|
|
|
|
|
sess = SimpleNamespace(history=[
|
|
|
|
|
|
_SpinMsg("system", {"research_spinoff_from": "rp-1"}),
|
|
|
|
|
|
_SpinMsg("user", None),
|
|
|
|
|
|
])
|
|
|
|
|
|
assert _session_is_research_spinoff(sess) is True
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-06-16 08:07:42 +03:00
|
|
|
|
def test_auto_name_session_passes_session_fallback_to_task_resolver(monkeypatch):
|
|
|
|
|
|
import src.llm_core as llm_core
|
|
|
|
|
|
import src.task_endpoint as task_endpoint
|
|
|
|
|
|
|
|
|
|
|
|
resolver_calls = []
|
|
|
|
|
|
llm_calls = []
|
|
|
|
|
|
|
|
|
|
|
|
def fake_resolve_task_endpoint(
|
|
|
|
|
|
fallback_url=None,
|
|
|
|
|
|
fallback_model=None,
|
|
|
|
|
|
fallback_headers=None,
|
|
|
|
|
|
owner=None,
|
|
|
|
|
|
):
|
|
|
|
|
|
resolver_calls.append((fallback_url, fallback_model, fallback_headers, owner))
|
|
|
|
|
|
return fallback_url, fallback_model, fallback_headers
|
|
|
|
|
|
|
|
|
|
|
|
async def fake_llm_call(url, model, messages, **kwargs):
|
|
|
|
|
|
llm_calls.append((url, model, messages, kwargs))
|
|
|
|
|
|
return "Focused Fix"
|
|
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(task_endpoint, "resolve_task_endpoint", fake_resolve_task_endpoint)
|
|
|
|
|
|
monkeypatch.setattr(llm_core, "llm_call_async", fake_llm_call)
|
|
|
|
|
|
|
|
|
|
|
|
session_headers = {"Authorization": "Bearer session"}
|
|
|
|
|
|
sess = SimpleNamespace(
|
|
|
|
|
|
id="session-1",
|
|
|
|
|
|
owner="alice",
|
|
|
|
|
|
endpoint_url="http://session.example/v1/chat/completions",
|
|
|
|
|
|
model="session-model",
|
|
|
|
|
|
headers=session_headers,
|
|
|
|
|
|
history=[SimpleNamespace(role="user", content="Please fix the endpoint fallback bug.")],
|
|
|
|
|
|
)
|
|
|
|
|
|
updates = []
|
|
|
|
|
|
session_manager = SimpleNamespace(
|
|
|
|
|
|
update_session_name=lambda session_id, title: updates.append((session_id, title))
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
asyncio.run(auto_name_session(session_manager, sess))
|
|
|
|
|
|
|
|
|
|
|
|
assert resolver_calls == [(
|
|
|
|
|
|
"http://session.example/v1/chat/completions",
|
|
|
|
|
|
"session-model",
|
|
|
|
|
|
session_headers,
|
|
|
|
|
|
"alice",
|
|
|
|
|
|
)]
|
|
|
|
|
|
assert llm_calls[0][0] == "http://session.example/v1/chat/completions"
|
|
|
|
|
|
assert llm_calls[0][1] == "session-model"
|
|
|
|
|
|
assert llm_calls[0][3]["headers"] == session_headers
|
|
|
|
|
|
assert updates == [("session-1", "Focused Fix")]
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-06-15 11:31:57 +00:00
|
|
|
|
def test_spinoff_detected_from_dict_history():
|
|
|
|
|
|
sess = SimpleNamespace(history=[
|
|
|
|
|
|
{"role": "system", "metadata": {"research_spinoff_from": "rp-2"}},
|
|
|
|
|
|
{"role": "user", "content": "hi"},
|
|
|
|
|
|
])
|
|
|
|
|
|
assert _session_is_research_spinoff(sess) is True
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_non_spinoff_plain_session_is_false():
|
|
|
|
|
|
sess = SimpleNamespace(history=[
|
|
|
|
|
|
_SpinMsg("system", {"compacted": True}),
|
|
|
|
|
|
_SpinMsg("user", None),
|
|
|
|
|
|
])
|
|
|
|
|
|
assert _session_is_research_spinoff(sess) is False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_metadata_on_non_system_message_ignored():
|
|
|
|
|
|
sess = SimpleNamespace(history=[_SpinMsg("user", {"research_spinoff_from": "rp-3"})])
|
|
|
|
|
|
assert _session_is_research_spinoff(sess) is False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_empty_or_missing_history():
|
|
|
|
|
|
assert _session_is_research_spinoff(SimpleNamespace(history=[])) is False
|
|
|
|
|
|
assert _session_is_research_spinoff(SimpleNamespace()) is False
|
2026-06-16 08:07:42 +03:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def _build_context_owner_probe(monkeypatch, request_state):
|
|
|
|
|
|
captured = {
|
|
|
|
|
|
"prefs_owner": None,
|
|
|
|
|
|
"preface_owner": None,
|
|
|
|
|
|
"compact_owner": None,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async def fake_preprocess(chat_handler, message, att_ids, sess, **kwargs):
|
|
|
|
|
|
return PreprocessedMessage(
|
|
|
|
|
|
enhanced_message=message,
|
|
|
|
|
|
user_content=message,
|
|
|
|
|
|
text_for_context=message,
|
|
|
|
|
|
youtube_transcripts=[],
|
|
|
|
|
|
attachment_meta=[],
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
def fake_extract_preset(chat_handler, preset_id):
|
|
|
|
|
|
return PresetInfo(
|
|
|
|
|
|
temperature=0.7,
|
|
|
|
|
|
max_tokens=1024,
|
|
|
|
|
|
system_prompt=None,
|
|
|
|
|
|
character_name=None,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
def fake_add_user_message(sess, chat_handler, preprocessed, incognito=False):
|
|
|
|
|
|
sess.messages.append({"role": "user", "content": preprocessed.user_content})
|
|
|
|
|
|
|
|
|
|
|
|
def fake_load_prefs(owner):
|
|
|
|
|
|
captured["prefs_owner"] = owner
|
|
|
|
|
|
return {"memory_enabled": True, "skills_enabled": True}
|
|
|
|
|
|
|
|
|
|
|
|
def fake_build_context_preface(**kwargs):
|
|
|
|
|
|
captured["preface_owner"] = kwargs["owner"]
|
|
|
|
|
|
return [], [], []
|
|
|
|
|
|
|
|
|
|
|
|
async def fake_maybe_compact(sess, endpoint_url, model, messages, headers, owner=None):
|
|
|
|
|
|
captured["compact_owner"] = owner
|
|
|
|
|
|
return messages, 8192, False
|
|
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(chat_helpers, "preprocess", fake_preprocess)
|
|
|
|
|
|
monkeypatch.setattr(chat_helpers, "extract_preset", fake_extract_preset)
|
|
|
|
|
|
monkeypatch.setattr(chat_helpers, "add_user_message", fake_add_user_message)
|
|
|
|
|
|
monkeypatch.setattr(chat_helpers, "load_prefs_for_user", fake_load_prefs)
|
|
|
|
|
|
monkeypatch.setattr(chat_helpers, "_normalize_model_id_from_cache", lambda sess: None)
|
|
|
|
|
|
monkeypatch.setattr(chat_helpers, "normalize_model_id", lambda endpoint_url, model, **kwargs: None)
|
|
|
|
|
|
monkeypatch.setattr(chat_helpers, "maybe_compact", fake_maybe_compact)
|
|
|
|
|
|
monkeypatch.setattr(chat_helpers, "trim_for_context", lambda messages, context_length: messages)
|
|
|
|
|
|
|
|
|
|
|
|
import src.user_time as user_time
|
|
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
|
user_time,
|
|
|
|
|
|
"current_datetime_context_message",
|
|
|
|
|
|
lambda now_utc=None: {"role": "user", "content": "[Context - current date/time]"},
|
|
|
|
|
|
raising=False,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
sess = SimpleNamespace(
|
|
|
|
|
|
endpoint_url="http://model.local/v1/chat/completions",
|
|
|
|
|
|
model="test-model",
|
|
|
|
|
|
headers={},
|
|
|
|
|
|
history=[],
|
|
|
|
|
|
messages=[],
|
|
|
|
|
|
)
|
|
|
|
|
|
sess.get_context_messages = lambda: list(sess.messages)
|
|
|
|
|
|
|
|
|
|
|
|
request = SimpleNamespace(state=SimpleNamespace(**request_state))
|
|
|
|
|
|
ctx = await build_chat_context(
|
|
|
|
|
|
sess=sess,
|
|
|
|
|
|
request=request,
|
|
|
|
|
|
chat_handler=SimpleNamespace(),
|
|
|
|
|
|
chat_processor=SimpleNamespace(build_context_preface=fake_build_context_preface),
|
|
|
|
|
|
message="hello",
|
|
|
|
|
|
session_id="session-1",
|
|
|
|
|
|
incognito=True,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
return ctx, captured
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
|
async def test_build_chat_context_uses_api_token_owner_for_compaction_scope(monkeypatch):
|
|
|
|
|
|
ctx, captured = await _build_context_owner_probe(
|
|
|
|
|
|
monkeypatch,
|
|
|
|
|
|
{
|
|
|
|
|
|
"api_token": True,
|
|
|
|
|
|
"api_token_owner": "alice",
|
|
|
|
|
|
"current_user": "api",
|
|
|
|
|
|
},
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
assert ctx.user == "alice"
|
|
|
|
|
|
assert captured == {
|
|
|
|
|
|
"prefs_owner": "alice",
|
|
|
|
|
|
"preface_owner": "alice",
|
|
|
|
|
|
"compact_owner": "alice",
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
|
async def test_build_chat_context_keeps_cookie_user_owner_scope(monkeypatch):
|
|
|
|
|
|
ctx, captured = await _build_context_owner_probe(
|
|
|
|
|
|
monkeypatch,
|
|
|
|
|
|
{
|
|
|
|
|
|
"api_token": False,
|
|
|
|
|
|
"current_user": "bob",
|
|
|
|
|
|
},
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
assert ctx.user == "bob"
|
|
|
|
|
|
assert captured == {
|
|
|
|
|
|
"prefs_owner": "bob",
|
|
|
|
|
|
"preface_owner": "bob",
|
|
|
|
|
|
"compact_owner": "bob",
|
|
|
|
|
|
}
|