agy تنها ارائه‌دهندهٔ مدل؛ حذف بخش ارائه‌دهنده‌ها و افزودن زنجیرهٔ انتخاب مدل

- حذف کامل providerهای mock و openai_compatible: دامنه، آداپوترها، رجیستری، کانفیگ، seed و رابطٔ مدیریت
- بخش «ارائه‌دهنده‌ها» از کنسول حذف شد؛ ورود اکانت‌ها فقط از صفحهٔ اکانت‌های agy
- صفحهٔ مدل‌ها بازطراحی شد: ارائه‌دهنده → اکانت → مدل (کاتالوگ لحظه‌ای و زندهٔ همان اکانت از Google) → افورت استدلال؛ بدون هیچ ورود دستی نام مدل
- مدل‌ها ستون reasoning_effort گرفتند؛ آداپتور آن را به thinkingConfig (بودجهٔ ۱۰۲۴ تا ۳۲۷۶۸) ترجمه می‌کند و فقط به مدل‌های استدلالی می‌فرستد
- مهاجرت c2d3e4f5b6a7: افزودن reasoning_effort و حذف ردیف‌های providerهای قدیمی
- تست‌ها: ۲۴۶ تست بک‌اند و ۵۱ تست فرانت سبز؛ ruff و mypy و tsc و eslint تمیز
This commit is contained in:
mjalmoudy94
2026-10-13 10:01:11 +03:30
parent a2284db4f4
commit da5a2a5d1a
29 changed files with 906 additions and 995 deletions
+173 -21
View File
@@ -344,41 +344,42 @@ async def test_seed_creates_agy_catalog(client, seeded_install, session_factory)
"claude-opus-4-5-thinking",
"gpt-oss-120b-medium",
}
# Default deployment keeps routing on mock: agy models wait inactive.
assert all(active is False for _, active in models)
async def test_seed_activates_agy_when_it_is_default(
monkeypatch, client, seeded_install, session_factory
):
from app.core.config import settings
monkeypatch.setattr(settings, "ai_default_provider", "agy")
await seeded_install.seed()
models = await _agy_models(session_factory)
# agy is the only provider: the catalog is routable from the first run.
assert all(active is True for _, active in models)
async def test_seed_deactivates_mock_when_agy_is_default(
monkeypatch, client, seeded_install, session_factory
):
async def test_seed_round_trips_efforts(client, seeded_install, session_factory):
"""The seed's effort column is what the adapter later sends to Google."""
from sqlalchemy import select
from app.core.config import settings
from app.infrastructure.db.models import AiModelRow, AiProviderRow
monkeypatch.setattr(settings, "ai_default_provider", "agy")
await seeded_install.seed()
async with session_factory() as session:
rows = (
await session.execute(
select(AiModelRow)
.join(AiProviderRow, AiModelRow.provider_id == AiProviderRow.id)
.where(AiProviderRow.name == "mock")
.where(AiProviderRow.name == "agy")
)
).scalars()
assert all(row.is_active is False for row in rows)
efforts = {row.internal_name: row.reasoning_effort for row in rows}
assert efforts["gemini-3-pro-high"] == "high"
assert efforts["claude-sonnet-4-5-thinking"] == "high"
assert efforts["gemini-3-flash"] == ""
async def test_seed_leaves_no_legacy_providers(client, seeded_install, session_factory):
"""agy is the only provider row a seeded install may carry."""
from sqlalchemy import select
from app.infrastructure.db.models import AiProviderRow
async with session_factory() as session:
rows = (await session.execute(select(AiProviderRow))).scalars()
types = {row.type for row in rows}
names = {row.name for row in rows}
assert types == {"agy"}
assert "mock" not in names
# ---------------------------------------------------------------------------
@@ -519,6 +520,64 @@ def test_payload_translation(decrypted):
assert all(s["threshold"] == "OFF" for s in payload["request"]["safetySettings"])
def test_effort_becomes_a_thinking_config_on_a_thinking_model():
"""Effort is translated, not forwarded: high → a 16384-token budget."""
account = agy.AgyAccount(
credential_id="c1", email="a@b.c", project_id="proj-1", refresh_token="refresh-1"
)
payload = agy._build_generate_payload(
account,
"claude-sonnet-4-5-thinking",
[ChatMessage(role="user", content="سلام")],
system="",
temperature=0.4,
max_tokens=512,
reasoning_effort="high",
)
generation = payload["request"]["generationConfig"]
assert generation["thinkingConfig"] == {
"thinkingBudget": 16384,
"includeThoughts": True,
}
# The budget must fit inside the answer window.
assert generation["maxOutputTokens"] == 16384 + 8000
def test_effort_is_not_sent_to_a_non_thinking_model():
"""``thinkingConfig`` on a model that cannot think is a hard 400."""
account = agy.AgyAccount(
credential_id="c1", email="a@b.c", project_id="proj-1", refresh_token="refresh-1"
)
payload = agy._build_generate_payload(
account,
"claude-sonnet-4-5",
[ChatMessage(role="user", content="سلام")],
system="",
temperature=0.4,
max_tokens=512,
reasoning_effort="high",
)
assert "thinkingConfig" not in payload["request"]["generationConfig"]
assert payload["request"]["generationConfig"]["maxOutputTokens"] == 512
def test_unknown_effort_falls_back_to_medium():
account = agy.AgyAccount(
credential_id="c1", email="a@b.c", project_id="proj-1", refresh_token="refresh-1"
)
payload = agy._build_generate_payload(
account,
"claude-opus-4-5-thinking",
[ChatMessage(role="user", content="سلام")],
system="",
temperature=0.4,
max_tokens=512,
reasoning_effort="turbo",
)
budget = payload["request"]["generationConfig"]["thinkingConfig"]["thinkingBudget"]
assert budget == 8192
def test_response_parsing_skips_thoughts():
model = AiModel(provider_id="p", internal_name="m", display_name_for_admin="m")
response = agy._parse_generate_response(
@@ -713,3 +772,96 @@ async def test_available_models(google, decrypted):
adapter = agy.AgyModelAdapter(loader=loader)
models = await adapter.available_models()
assert models == ["claude-sonnet-4-5", "gemini-3-flash"]
# ---------------------------------------------------------------------------
# Live per-account model catalog (the console's model dropdown source)
# ---------------------------------------------------------------------------
async def test_available_models_for_one_account(google, decrypted):
"""The console asks for a specific account, so the fetch is per-account."""
adapter = agy.AgyModelAdapter()
models = await adapter.available_models_for(
_agy_credential(email="one@gmail.com")
)
assert models == ["claude-sonnet-4-5", "gemini-3-flash"]
# The list is fetched from Google every call — no cached catalog.
fetches = [c for c in google if c["url"].endswith("fetchAvailableModels")]
assert len(fetches) == 1
async def test_available_models_for_account_without_material():
"""A credential with no refresh token can never list models."""
adapter = agy.AgyModelAdapter()
credential = ModelCredential(
provider_id="p",
name="empty@gmail.com",
encrypted_api_key="PLAINTEXT::{}",
email="empty@gmail.com",
)
with pytest.raises(ProviderUnavailableError):
await adapter.available_models_for(credential)
async def test_account_models_endpoint(client, google, platform_owner_token):
"""GET /accounts/{id}/models returns that account's live catalog."""
started = await client.post(
"/api/admin/agy/accounts/login/start",
headers=auth_header(platform_owner_token),
)
state = parse_qs(urlparse(started.json()["auth_url"]).query)["state"][0]
account_id = (
await client.post(
"/api/admin/agy/accounts/login/complete",
headers=auth_header(platform_owner_token),
json={
"login_id": started.json()["login_id"],
"callback_url": (
f"http://localhost:51121/oauth-callback?code=c&state={state}"
),
},
)
).json()["id"]
response = await client.get(
f"/api/admin/agy/accounts/{account_id}/models",
headers=auth_header(platform_owner_token),
)
assert response.status_code == 200, response.text
assert response.json() == ["claude-sonnet-4-5", "gemini-3-flash"]
missing = await client.get(
"/api/admin/agy/accounts/does-not-exist/models",
headers=auth_header(platform_owner_token),
)
assert missing.status_code == 404
async def test_completion_carries_the_model_effort(monkeypatch, decrypted):
"""The end-to-end path: effort on the model row reaches the payload."""
async def handler(method, url, json_body, headers, data):
if url.endswith("/token"):
return _token_response()
if url.endswith("/v1internal:generateContent"):
return _FakeResponse(json_data=GOOD_GENERATE)
return await default_handler(method, url, json_body, headers, data)
calls = patch_google(monkeypatch, handler)
model = AiModel(
provider_id="p",
internal_name="claude-sonnet-4-5-thinking",
display_name_for_admin="f",
reasoning_effort="low",
)
adapter = agy.AgyModelAdapter()
await adapter.complete(
model=model,
credentials=[_agy_credential(email="one@gmail.com")],
messages=_MESSAGES,
)
generate = [c for c in calls if c["url"].endswith("generateContent")]
assert generate[0]["json"]["request"]["generationConfig"]["thinkingConfig"] == {
"thinkingBudget": 4096,
"includeThoughts": True,
}