agy تنها ارائهدهندهٔ مدل؛ حذف بخش ارائهدهندهها و افزودن زنجیرهٔ انتخاب مدل
- حذف کامل providerهای mock و openai_compatible: دامنه، آداپوترها، رجیستری، کانفیگ، seed و رابطٔ مدیریت - بخش «ارائهدهندهها» از کنسول حذف شد؛ ورود اکانتها فقط از صفحهٔ اکانتهای agy - صفحهٔ مدلها بازطراحی شد: ارائهدهنده → اکانت → مدل (کاتالوگ لحظهای و زندهٔ همان اکانت از Google) → افورت استدلال؛ بدون هیچ ورود دستی نام مدل - مدلها ستون reasoning_effort گرفتند؛ آداپتور آن را به thinkingConfig (بودجهٔ ۱۰۲۴ تا ۳۲۷۶۸) ترجمه میکند و فقط به مدلهای استدلالی میفرستد - مهاجرت c2d3e4f5b6a7: افزودن reasoning_effort و حذف ردیفهای providerهای قدیمی - تستها: ۲۴۶ تست بکاند و ۵۱ تست فرانت سبز؛ ruff و mypy و tsc و eslint تمیز
This commit is contained in:
+173
-21
@@ -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,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user