افزودن ارائه‌دهندهٔ agy (Google Antigravity) با لاگین headless و پول چرخشی اکانت

- Adapter اکانت‌محور با چرخش round-robin و بنچ‌شدن خودکار روی ۴۲۹/۴۰۱
- ورود ادمین از طریق URL بازگشتی گوگل (PKCE) و ذخیرهٔ رمزنگاری‌شدهٔ refresh token
- صفحهٔ مدیریت اکانت‌های agy در پنل ادمین به همراه تست و فعال/غیرفعال‌سازی
- مهاجرت Alembic b1c2d3e4f5a6 برای ستون‌های اکانت agy
- seed همیشه کاتالوگ agy را می‌سازد و فقط با AI_DEFAULT_PROVIDER=agy مسیریابی می‌شود
This commit is contained in:
mjalmoudy94
2026-10-13 08:20:27 +03:30
parent 54074f10c3
commit 3d1e98f4fb
25 changed files with 3073 additions and 10 deletions
+126 -1
View File
@@ -2,7 +2,9 @@
Run with ``pnpm db:seed``. Creates a platform owner, a demo user, a sample
organization that both of them own, the system agent templates, routing
policies, rate cards and a mock provider with two models.
policies, rate cards and a mock provider with two models. The agy (Google
Antigravity) provider and its model catalog are seeded too; agy models become
routable only when ``AI_DEFAULT_PROVIDER=agy``.
Safe to run repeatedly: every step checks for existing rows first.
@@ -18,6 +20,7 @@ import logging
from sqlalchemy import select
from app.core.config import settings
from app.core.database import Base, SessionFactory, engine
from app.core.security import hash_password
from app.domain.ai_control.models import (
@@ -60,6 +63,42 @@ DEMO_PASSWORD = "demo12345"
DEMO_ORG_NAME = "سازمان نمونه"
DEMO_ORG_SLUG = "sample-org"
#: The agy (Google Antigravity) model catalog. ``internal_name`` is the model id
#: the Antigravity backend expects; the display name is admin-only. Seeded for
#: every deployment, activated for routing only when agy is the default
#: provider — see the seed step that consumes this table.
AGY_MODELS: list[tuple[str, str, list[Capability]]] = [
(
"gemini-3-flash",
"Gemini 3 Flash",
[Capability.TEXT, Capability.CODE, Capability.LONG_CONTEXT],
),
(
"gemini-3-pro-high",
"Gemini 3 Pro",
[
Capability.TEXT, Capability.REASONING, Capability.CODE,
Capability.VISION, Capability.LONG_CONTEXT,
],
),
(
"claude-sonnet-4-5",
"Claude Sonnet 4.5",
[Capability.TEXT, Capability.CODE, Capability.TOOLS],
),
(
"claude-sonnet-4-5-thinking",
"Claude Sonnet 4.5 (Thinking)",
[Capability.TEXT, Capability.CODE, Capability.REASONING],
),
(
"claude-opus-4-5-thinking",
"Claude Opus 4.5 (Thinking)",
[Capability.TEXT, Capability.REASONING, Capability.CODE, Capability.LONG_CONTEXT],
),
("gpt-oss-120b-medium", "GPT-OSS 120B Medium", [Capability.TEXT, Capability.CODE]),
]
AGENT_TEMPLATES: list[tuple[str, RoleKey, str, list[str]]] = [
(
"مدیر سازمان", RoleKey.MANAGER,
@@ -269,6 +308,92 @@ async def seed() -> None:
)
logger.info("created mock provider + 2 models")
# The agy catalog (Google Antigravity). It is seeded for every
# deployment — the admin console needs the provider and its models to
# exist before any account is logged in. Routing activation is
# reconciled on *every* seed run below, not only at insert time, so
# flipping AI_DEFAULT_PROVIDER and re-seeding reshapes routing without
# a fresh database.
agy_is_default = settings.ai_default_provider.strip().lower() == "agy"
agy_provider_row = (
await session.execute(
select(AiProviderRow).where(AiProviderRow.name == "agy")
)
).scalars().first()
if agy_provider_row is None:
agy_provider = Provider(name="agy", type=ProviderType.AGY, base_url="")
agy_provider_row = AiProviderRow(
id=agy_provider.id, name=agy_provider.name,
type=agy_provider.type.value, base_url="", is_active=True,
)
session.add(agy_provider_row)
await session.flush()
for internal, display, caps in AGY_MODELS:
existing = (
await session.execute(
select(AiModelRow).where(
AiModelRow.provider_id == agy_provider_row.id,
AiModelRow.internal_name == internal,
)
)
).scalars().first()
if existing is not None:
continue
model = AiModel(
provider_id=agy_provider_row.id,
internal_name=internal,
display_name_for_admin=display,
capabilities=[str(c) for c in caps],
# agy is billed through the Google AI subscription, so the
# internal token cost estimate is zero; the operator sets the
# customer-facing price on the rate card.
input_price_per_1m=0.0,
output_price_per_1m=0.0,
)
session.add(
AiModelRow(
id=model.id, provider_id=model.provider_id,
internal_name=model.internal_name,
display_name_for_admin=model.display_name_for_admin,
capabilities=model.capabilities,
input_price_per_1m=model.input_price_per_1m,
output_price_per_1m=model.output_price_per_1m,
is_active=False,
)
)
# Reconcile routing with the configured default on every run: agy models
# are routable only when agy is the default, and the mock catalog must
# not win when it is not — a model without accounts fails at call time.
agy_model_rows = (
await session.execute(
select(AiModelRow).where(
AiModelRow.provider_id == agy_provider_row.id
)
)
).scalars()
for row in agy_model_rows:
row.is_active = agy_is_default
if agy_is_default:
mock_provider_row = (
await session.execute(
select(AiProviderRow).where(AiProviderRow.name == "mock")
)
).scalars().first()
if mock_provider_row is not None:
mock_model_rows = (
await session.execute(
select(AiModelRow).where(
AiModelRow.provider_id == mock_provider_row.id
)
)
).scalars()
for row in mock_model_rows:
row.is_active = False
logger.info(
"agy is the default provider; agy catalog activated "
"(accounts are added from the admin console)"
)
if not (await session.execute(select(AgentTemplateRow))).scalars().first():
for name, role_key, description, tools in AGENT_TEMPLATES:
template = AgentTemplate(