بهروزرسانی: استخراج خودکار و پویای فهرست مدلها و سطوح تلاش پردازش از ابزار خط فرمان و ارتقای نسخه به ۰.۱.۴
This commit is contained in:
+126
-50
@@ -27,9 +27,7 @@ from tests.conftest import auth_header, signup
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
GOOD_GENERATE: dict = {
|
||||
"candidates": [
|
||||
{"content": {"parts": [{"text": "سلام"}]}, "finishReason": "STOP"}
|
||||
],
|
||||
"candidates": [{"content": {"parts": [{"text": "سلام"}]}, "finishReason": "STOP"}],
|
||||
"usageMetadata": {
|
||||
"promptTokenCount": 7,
|
||||
"candidatesTokenCount": 3,
|
||||
@@ -192,9 +190,7 @@ async def test_agy_login_flow_end_to_end(client, google, platform_owner_token):
|
||||
headers=auth_header(platform_owner_token),
|
||||
json={
|
||||
"login_id": login_id,
|
||||
"callback_url": (
|
||||
f"http://localhost:51121/oauth-callback?code=auth-code&state={state}"
|
||||
),
|
||||
"callback_url": (f"http://localhost:51121/oauth-callback?code=auth-code&state={state}"),
|
||||
},
|
||||
)
|
||||
assert completed.status_code == 201, completed.text
|
||||
@@ -209,9 +205,7 @@ async def test_agy_login_flow_end_to_end(client, google, platform_owner_token):
|
||||
assert "agy-proj-123" in completed.text # project id is admin metadata
|
||||
|
||||
# 3. the account is listed.
|
||||
listed = await client.get(
|
||||
"/api/admin/agy/accounts", headers=auth_header(platform_owner_token)
|
||||
)
|
||||
listed = await client.get("/api/admin/agy/accounts", headers=auth_header(platform_owner_token))
|
||||
assert listed.status_code == 200
|
||||
emails = [a["email"] for a in listed.json()]
|
||||
assert emails == ["person@gmail.com"]
|
||||
@@ -232,9 +226,7 @@ async def test_agy_complete_login_rejects_unrelated_url(client, google, platform
|
||||
)
|
||||
assert completed.status_code == 422
|
||||
# The failed attempt must not have created an account.
|
||||
listed = await client.get(
|
||||
"/api/admin/agy/accounts", headers=auth_header(platform_owner_token)
|
||||
)
|
||||
listed = await client.get("/api/admin/agy/accounts", headers=auth_header(platform_owner_token))
|
||||
assert listed.json() == []
|
||||
|
||||
|
||||
@@ -249,9 +241,7 @@ async def test_agy_account_test_and_delete(client, google, platform_owner_token)
|
||||
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}"
|
||||
),
|
||||
"callback_url": (f"http://localhost:51121/oauth-callback?code=c&state={state}"),
|
||||
},
|
||||
)
|
||||
account_id = created.json()["id"]
|
||||
@@ -270,9 +260,7 @@ async def test_agy_account_test_and_delete(client, google, platform_owner_token)
|
||||
headers=auth_header(platform_owner_token),
|
||||
)
|
||||
assert deleted.status_code == 204
|
||||
listed = await client.get(
|
||||
"/api/admin/agy/accounts", headers=auth_header(platform_owner_token)
|
||||
)
|
||||
listed = await client.get("/api/admin/agy/accounts", headers=auth_header(platform_owner_token))
|
||||
assert listed.json() == []
|
||||
|
||||
|
||||
@@ -289,15 +277,11 @@ async def test_agy_same_account_twice_replaces(client, google, platform_owner_to
|
||||
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}"
|
||||
),
|
||||
"callback_url": (f"http://localhost:51121/oauth-callback?code=c&state={state}"),
|
||||
},
|
||||
)
|
||||
assert done.status_code == 201
|
||||
listed = await client.get(
|
||||
"/api/admin/agy/accounts", headers=auth_header(platform_owner_token)
|
||||
)
|
||||
listed = await client.get("/api/admin/agy/accounts", headers=auth_header(platform_owner_token))
|
||||
assert len(listed.json()) == 1
|
||||
|
||||
|
||||
@@ -390,9 +374,7 @@ async def test_seed_leaves_no_legacy_providers(client, seeded_install, session_f
|
||||
def test_auth_url_shape():
|
||||
challenge = agy.AgyAuthClient().begin()
|
||||
query = parse_qs(urlparse(challenge.auth_url).query)
|
||||
assert challenge.auth_url.startswith(
|
||||
"https://accounts.google.com/o/oauth2/v2/auth"
|
||||
)
|
||||
assert challenge.auth_url.startswith("https://accounts.google.com/o/oauth2/v2/auth")
|
||||
assert query["client_id"][0] == (
|
||||
"1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com"
|
||||
)
|
||||
@@ -410,9 +392,7 @@ async def test_paste_flow_exchanges_and_resolves_account(google):
|
||||
"""start → paste callback URL → account material, verifier stays server-side."""
|
||||
login_id, _auth_url = await agy.begin_login()
|
||||
challenge = agy._CHALLENGES[login_id]
|
||||
callback = (
|
||||
f"http://localhost:51121/oauth-callback?code=auth-code&state={challenge.state}"
|
||||
)
|
||||
callback = f"http://localhost:51121/oauth-callback?code=auth-code&state={challenge.state}"
|
||||
result = await agy.redeem_login(login_id, callback)
|
||||
assert result.email == "person@gmail.com"
|
||||
assert result.project_id == "agy-proj-123"
|
||||
@@ -446,11 +426,10 @@ async def test_google_error_page_is_rejected(google):
|
||||
|
||||
async def test_refresh_token_missing_is_actionable(monkeypatch):
|
||||
"""A token response without ``refresh_token`` fails loudly and tells why."""
|
||||
|
||||
async def handler(method, url, json_body, headers, data):
|
||||
if url.endswith("/token"):
|
||||
return _FakeResponse(
|
||||
json_data={"access_token": "a", "expires_in": 3600}
|
||||
)
|
||||
return _FakeResponse(json_data={"access_token": "a", "expires_in": 3600})
|
||||
return await default_handler(method, url, json_body, headers, data)
|
||||
|
||||
patch_google(monkeypatch, handler)
|
||||
@@ -617,7 +596,8 @@ def test_response_parsing_skips_thoughts():
|
||||
async def test_complete_happy_path(google, decrypted):
|
||||
adapter = agy.AgyModelAdapter()
|
||||
response = await adapter.complete(
|
||||
model=_MODEL, credentials=[_agy_credential(email="one@gmail.com")],
|
||||
model=_MODEL,
|
||||
credentials=[_agy_credential(email="one@gmail.com")],
|
||||
messages=_MESSAGES,
|
||||
)
|
||||
assert response.content == "سلام"
|
||||
@@ -631,6 +611,7 @@ async def test_complete_happy_path(google, decrypted):
|
||||
|
||||
async def test_rate_limited_account_is_rotated_and_benched(monkeypatch, decrypted):
|
||||
"""A 429 on one account must not fail the request: the pool moves on."""
|
||||
|
||||
async def handler(method, url, json_body, headers, data):
|
||||
if url.endswith("/token"):
|
||||
# Each account's refresh token maps to its own access token.
|
||||
@@ -642,9 +623,7 @@ async def test_rate_limited_account_is_rotated_and_benched(monkeypatch, decrypte
|
||||
auth = headers.get("Authorization", "")
|
||||
if auth.endswith("access-one"):
|
||||
# Both base URLs hit the same quota.
|
||||
return _FakeResponse(
|
||||
status_code=429, text="quota", headers={"Retry-After": "120"}
|
||||
)
|
||||
return _FakeResponse(status_code=429, text="quota", headers={"Retry-After": "120"})
|
||||
return _FakeResponse(json_data=GOOD_GENERATE)
|
||||
return await default_handler(method, url, json_body, headers, data)
|
||||
|
||||
@@ -659,9 +638,7 @@ async def test_rate_limited_account_is_rotated_and_benched(monkeypatch, decrypte
|
||||
adapter._state.cursor = -1
|
||||
one = _agy_credential(email="one@gmail.com", refresh_token="refresh-1")
|
||||
two = _agy_credential(email="two@gmail.com", refresh_token="refresh-2")
|
||||
response = await adapter.complete(
|
||||
model=_MODEL, credentials=[one, two], messages=_MESSAGES
|
||||
)
|
||||
response = await adapter.complete(model=_MODEL, credentials=[one, two], messages=_MESSAGES)
|
||||
assert response.content == "سلام"
|
||||
|
||||
cooldowns = [r for r in reported if r[1] == "cooldown"]
|
||||
@@ -696,6 +673,7 @@ async def test_every_account_rate_limited_raises(monkeypatch, decrypted):
|
||||
|
||||
async def test_rejected_grant_marks_account_error(monkeypatch, decrypted):
|
||||
"""401 means the OAuth grant died — bench as error, move to next account."""
|
||||
|
||||
async def handler(method, url, json_body, headers, data):
|
||||
if url.endswith("/token"):
|
||||
access = {"refresh-1": "access-one", "refresh-2": "access-two"}[
|
||||
@@ -719,9 +697,7 @@ async def test_rejected_grant_marks_account_error(monkeypatch, decrypted):
|
||||
adapter._state.cursor = -1
|
||||
dead = _agy_credential(email="dead@gmail.com", refresh_token="refresh-1")
|
||||
alive = _agy_credential(email="alive@gmail.com", refresh_token="refresh-2")
|
||||
response = await adapter.complete(
|
||||
model=_MODEL, credentials=[dead, alive], messages=_MESSAGES
|
||||
)
|
||||
response = await adapter.complete(model=_MODEL, credentials=[dead, alive], messages=_MESSAGES)
|
||||
assert response.content == "سلام"
|
||||
errors = [r for r in reported if r[1] == "error"]
|
||||
assert errors and errors[0][0] == dead.id
|
||||
@@ -731,6 +707,7 @@ async def test_rejected_grant_marks_account_error(monkeypatch, decrypted):
|
||||
|
||||
async def test_server_error_falls_through_to_next_backend(monkeypatch, decrypted):
|
||||
"""5xx on one backend URL is not the account's fault — try the other."""
|
||||
|
||||
async def handler(method, url, json_body, headers, data):
|
||||
if url.endswith("/token"):
|
||||
return _token_response()
|
||||
@@ -743,7 +720,8 @@ async def test_server_error_falls_through_to_next_backend(monkeypatch, decrypted
|
||||
patch_google(monkeypatch, handler)
|
||||
adapter = agy.AgyModelAdapter()
|
||||
response = await adapter.complete(
|
||||
model=_MODEL, credentials=[_agy_credential(email="one@gmail.com")],
|
||||
model=_MODEL,
|
||||
credentials=[_agy_credential(email="one@gmail.com")],
|
||||
messages=_MESSAGES,
|
||||
)
|
||||
assert response.content == "سلام"
|
||||
@@ -782,9 +760,7 @@ async def test_available_models(google, decrypted):
|
||||
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")
|
||||
)
|
||||
models = await adapter.available_models_for(_agy_credential(email="one@gmail.com"))
|
||||
assert [m.id for m in models] == ["claude-sonnet-4-5", "gemini-3-flash"]
|
||||
assert models[1].supported_efforts == ["low", "medium", "high"]
|
||||
# The list is fetched from Google every call — no cached catalog.
|
||||
@@ -818,9 +794,7 @@ async def test_account_models_endpoint(client, google, platform_owner_token):
|
||||
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}"
|
||||
),
|
||||
"callback_url": (f"http://localhost:51121/oauth-callback?code=c&state={state}"),
|
||||
},
|
||||
)
|
||||
).json()["id"]
|
||||
@@ -843,6 +817,7 @@ async def test_account_models_endpoint(client, google, platform_owner_token):
|
||||
|
||||
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()
|
||||
@@ -868,3 +843,104 @@ async def test_completion_carries_the_model_effort(monkeypatch, decrypted):
|
||||
"thinkingBudget": 4096,
|
||||
"includeThoughts": True,
|
||||
}
|
||||
|
||||
|
||||
def test_parse_cli_models_output():
|
||||
"""Verify parsing raw output from agy models CLI."""
|
||||
sample_output = (
|
||||
"Fetching available models...\n"
|
||||
"gemini-3.8-flash-high\tGemini 3.8 Flash (High)\n"
|
||||
"gemini-3.8-flash-medium\tGemini 3.8 Flash (Medium)\n"
|
||||
"gemini-3.8-flash-low\tGemini 3.8 Flash (Low)\n"
|
||||
"claude-sonnet-4-6\tClaude Sonnet 4.6 (Thinking)\n"
|
||||
"claude-opus-4-6-thinking\tClaude Opus 4.6 (Thinking)\n"
|
||||
"gpt-oss-120b-medium\tGPT-OSS 120B (Medium)\n"
|
||||
)
|
||||
models = agy.AgyModelAdapter._parse_cli_models_output(sample_output)
|
||||
assert len(models) == 6
|
||||
|
||||
m_high = next(m for m in models if m.id == "gemini-3.8-flash-high")
|
||||
assert m_high.display_name == "Gemini 3.8 Flash (High)"
|
||||
assert m_high.supports_thinking is True
|
||||
assert m_high.supported_efforts == ["high"]
|
||||
assert m_high.default_effort == "high"
|
||||
|
||||
m_med = next(m for m in models if m.id == "gemini-3.8-flash-medium")
|
||||
assert m_med.supported_efforts == ["medium"]
|
||||
assert m_med.default_effort == "medium"
|
||||
|
||||
m_low = next(m for m in models if m.id == "gemini-3.8-flash-low")
|
||||
assert m_low.supported_efforts == ["low"]
|
||||
assert m_low.default_effort == "low"
|
||||
|
||||
m_claude = next(m for m in models if m.id == "claude-sonnet-4-6")
|
||||
assert m_claude.supports_thinking is True
|
||||
|
||||
m_gpt = next(m for m in models if m.id == "gpt-oss-120b-medium")
|
||||
assert m_gpt.supported_efforts == ["medium"]
|
||||
|
||||
|
||||
def test_build_generate_payload_tiered_mapping():
|
||||
"""Verify virtual CLI models map to underlying tiered model with appropriate budget."""
|
||||
account = agy.AgyAccount(
|
||||
credential_id="cred-1",
|
||||
refresh_token="rt-1",
|
||||
email="test@example.com",
|
||||
project_id="proj-123",
|
||||
)
|
||||
messages = [ChatMessage(role="user", content="Hello")]
|
||||
|
||||
payload_low = agy._build_generate_payload(
|
||||
account,
|
||||
"gemini-3.8-flash-low",
|
||||
messages,
|
||||
system="",
|
||||
temperature=0.7,
|
||||
max_tokens=4096,
|
||||
)
|
||||
assert payload_low["model"] == "gemini-3.8-flash-tiered"
|
||||
assert payload_low["request"]["generationConfig"]["thinkingConfig"] == {
|
||||
"thinkingBudget": 4096,
|
||||
"includeThoughts": True,
|
||||
}
|
||||
|
||||
payload_high = agy._build_generate_payload(
|
||||
account,
|
||||
"gemini-3.8-flash-high",
|
||||
messages,
|
||||
system="",
|
||||
temperature=0.7,
|
||||
max_tokens=4096,
|
||||
)
|
||||
assert payload_high["model"] == "gemini-3.8-flash-tiered"
|
||||
assert payload_high["request"]["generationConfig"]["thinkingConfig"] == {
|
||||
"thinkingBudget": 16384,
|
||||
"includeThoughts": True,
|
||||
}
|
||||
|
||||
|
||||
async def test_fetch_models_via_cli_mocked(monkeypatch):
|
||||
"""Verify _fetch_models_via_cli invokes the CLI binary and handles output."""
|
||||
account = agy.AgyAccount(
|
||||
credential_id="cred-1",
|
||||
refresh_token="rt-test-123",
|
||||
email="test@example.com",
|
||||
project_id="proj-123",
|
||||
)
|
||||
|
||||
class FakeProc:
|
||||
returncode = 0
|
||||
|
||||
async def communicate(self):
|
||||
return b"gemini-3.8-flash-high\tGemini 3.8 Flash (High)\n", b""
|
||||
|
||||
async def fake_create_subprocess_exec(*args, **kwargs):
|
||||
return FakeProc()
|
||||
|
||||
monkeypatch.setattr(agy.asyncio, "create_subprocess_exec", fake_create_subprocess_exec)
|
||||
monkeypatch.setattr(agy.shutil, "which", lambda cmd: "/usr/bin/agy")
|
||||
|
||||
models = await agy.AgyModelAdapter._fetch_models_via_cli(account)
|
||||
assert len(models) == 1
|
||||
assert models[0].id == "gemini-3.8-flash-high"
|
||||
assert models[0].display_name == "Gemini 3.8 Flash (High)"
|
||||
|
||||
Reference in New Issue
Block a user