From 45b84faa0ddb7963eb7ca6d60678bd124b0511ef Mon Sep 17 00:00:00 2001 From: Yun Chan Date: Fri, 11 Sep 2026 19:01:35 +0900 Subject: [PATCH] =?UTF-8?q?codex=C2=B7agy=20OAuth=20=EA=B3=84=EC=A0=95=20?= =?UTF-8?q?=EC=97=B0=EA=B2=B0=20=EC=A7=80=EC=9B=90=20(ChatGPT/Antigravity?= =?UTF-8?q?=20=EB=84=A4=EC=9D=B4=ED=8B=B0=EB=B8=8C=20=EC=96=B4=EB=8C=91?= =?UTF-8?q?=ED=84=B0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit omniroute와 동일하게 공개 클라이언트 자격증명으로 서버사이드 OAuth 교환을 제공한다. 관리자가 제공자 로그인 후 브라우저에 남는 code를 붙여넣으면 토큰·refresh token·메타데이터(account-id, Code Assist project)를 저장하고 게이트웨이에 push한다. - codex_api 어댑터: chatgpt.com/backend-api/codex/responses (Responses SSE, 401 시 refresh token으로 자가 갱신) - antigravity_api 어댑터: cloudcode-pa v1internal:streamGenerateContent (Gemini 형식 SSE, loadCodeAssist로 프로젝트 발급, 401 자가 갱신) - 자격증명 저장소에 refresh_token_encrypted·extra 컬럼 추가(부트스트랩 SQL 포함), 게이트웨이 push가 구조화 자격증명을 전달 --- apps/api/app/contracts/engine_gateway.py | 6 + apps/api/app/services/provider_credentials.py | 66 ++- apps/api/app/services/provider_oauth.py | 110 ++++- apps/api/app/test_provider_oauth.py | 2 +- apps/api/engine_gateway/gateway.py | 16 +- .../golden/engine_gateway_schema.v1.json | 2 + apps/api/engine_gateway/provider_registry.py | 381 ++++++++++++++++++ apps/web/src/lib/api.gen.ts | 4 +- apps/web/src/pages/AdminAi.tsx | 4 + infra/db/init/05_runtime_auth.sql | 8 +- 10 files changed, 581 insertions(+), 18 deletions(-) diff --git a/apps/api/app/contracts/engine_gateway.py b/apps/api/app/contracts/engine_gateway.py index 83fe6ff..e62b8f4 100644 --- a/apps/api/app/contracts/engine_gateway.py +++ b/apps/api/app/contracts/engine_gateway.py @@ -19,7 +19,9 @@ EngineProvider = Literal[ "claude_cli", "claude_api", "codex_cli", + "codex_api", "agy_cli", + "antigravity_api", "openai", "openrouter", "solar", @@ -40,7 +42,9 @@ ENGINE_PROVIDERS: tuple[EngineProvider, ...] = ( "claude_cli", "claude_api", "codex_cli", + "codex_api", "agy_cli", + "antigravity_api", "openai", "openrouter", "solar", @@ -57,7 +61,9 @@ ENGINE_PROVIDER_DEFAULTS: dict[EngineProvider, tuple[str, Optional[ReasoningEffo "claude_cli": (ENGINE_GATEWAY_DEFAULT_MODEL_SENTINEL, "high"), "claude_api": (ENGINE_GATEWAY_DEFAULT_MODEL_SENTINEL, "high"), "codex_cli": ("gpt-5.6-terra", "medium"), + "codex_api": (ENGINE_GATEWAY_DEFAULT_MODEL_SENTINEL, None), "agy_cli": ("gemini-3.6-flash-high", "high"), + "antigravity_api": (ENGINE_GATEWAY_DEFAULT_MODEL_SENTINEL, None), "openai": (ENGINE_GATEWAY_DEFAULT_MODEL_SENTINEL, None), "openrouter": (ENGINE_GATEWAY_DEFAULT_MODEL_SENTINEL, None), "solar": (ENGINE_GATEWAY_DEFAULT_MODEL_SENTINEL, None), diff --git a/apps/api/app/services/provider_credentials.py b/apps/api/app/services/provider_credentials.py index f0a4875..9c25fbe 100644 --- a/apps/api/app/services/provider_credentials.py +++ b/apps/api/app/services/provider_credentials.py @@ -14,6 +14,7 @@ from __future__ import annotations import base64 import hashlib import hmac +import json import os import time from dataclasses import dataclass @@ -223,6 +224,16 @@ class StoredCredential: last_verified_at: float | None last_verify_ok: bool | None last_verify_error: str | None + refresh_token: str | None = None + extra: dict[str, Any] | None = None + + +async def _credential_table_ready(conn) -> bool: + return bool( + await conn.fetchval( + "SELECT to_regclass('app.admin_provider_credential') IS NOT NULL" + ) + ) async def ensure_table() -> None: @@ -234,10 +245,7 @@ async def ensure_table() -> None: """ pool = get_pool() async with pool.acquire() as conn: - exists = await conn.fetchval( - "SELECT to_regclass('app.admin_provider_credential') IS NOT NULL" - ) - if exists: + if await _credential_table_ready(conn): return await conn.execute( """ @@ -251,10 +259,19 @@ async def ensure_table() -> None: updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), last_verified_at TIMESTAMPTZ, last_verify_ok BOOLEAN, - last_verify_error TEXT + last_verify_error TEXT, + refresh_token_encrypted TEXT, + extra JSONB NOT NULL DEFAULT '{}'::jsonb ) """ ) + await conn.execute( + """ + ALTER TABLE app.admin_provider_credential + ADD COLUMN IF NOT EXISTS refresh_token_encrypted TEXT, + ADD COLUMN IF NOT EXISTS extra JSONB NOT NULL DEFAULT '{}'::jsonb + """ + ) async def get_credential_row(provider: str) -> StoredCredential | None: @@ -281,6 +298,10 @@ async def get_credential_row(provider: str) -> StoredCredential | None: last_verified_at=_row_ts(row["last_verified_at"]), last_verify_ok=row["last_verify_ok"], last_verify_error=row["last_verify_error"], + refresh_token=decrypt_token(row["refresh_token_encrypted"]) + if row.get("refresh_token_encrypted") + else None, + extra=row.get("extra") if isinstance(row.get("extra"), dict) else None, ) @@ -299,7 +320,13 @@ async def get_plain_token(provider: str) -> tuple[str, str] | None: async def save_credential( - *, provider: str, token: str, auth_kind: str, updated_by: str + *, + provider: str, + token: str, + auth_kind: str, + updated_by: str, + refresh_token: str | None = None, + extra: dict[str, Any] | None = None, ) -> StoredCredential: if provider not in PROVIDER_CODES: raise ProviderCredentialError(f"지원하지 않는 provider입니다: {provider}") @@ -314,26 +341,32 @@ async def save_credential( row = await conn.fetchrow( """ INSERT INTO app.admin_provider_credential ( - provider, token_encrypted, token_hint, auth_kind, updated_by, updated_at + provider, token_encrypted, token_hint, auth_kind, updated_by, updated_at, + refresh_token_encrypted, extra ) - VALUES ($1, $2, $3, $4, $5, now()) + VALUES ($1, $2, $3, $4, $5, now(), $6, $7::jsonb) ON CONFLICT (provider) DO UPDATE SET token_encrypted = EXCLUDED.token_encrypted, token_hint = EXCLUDED.token_hint, auth_kind = EXCLUDED.auth_kind, updated_by = EXCLUDED.updated_by, updated_at = now(), + refresh_token_encrypted = COALESCE(EXCLUDED.refresh_token_encrypted, refresh_token_encrypted), + extra = CASE WHEN EXCLUDED.extra::text = '{}'::jsonb THEN extra ELSE EXCLUDED.extra END, last_verified_at = NULL, last_verify_ok = NULL, last_verify_error = NULL RETURNING provider, token_hint, auth_kind, updated_by, updated_at, - last_verified_at, last_verify_ok, last_verify_error + last_verified_at, last_verify_ok, last_verify_error, + refresh_token_encrypted, extra """, provider, encrypt_token(plain), token_hint(plain), auth_kind, updated_by, + encrypt_token(refresh_token) if refresh_token and refresh_token.strip() else None, + json.dumps(extra or {}), ) return StoredCredential( provider=row["provider"], @@ -344,6 +377,10 @@ async def save_credential( last_verified_at=_row_ts(row["last_verified_at"]), last_verify_ok=row["last_verify_ok"], last_verify_error=row["last_verify_error"], + refresh_token=decrypt_token(row["refresh_token_encrypted"]) + if row.get("refresh_token_encrypted") + else None, + extra=row.get("extra") if isinstance(row.get("extra"), dict) else None, ) @@ -395,12 +432,21 @@ async def push_credentials_to_gateway() -> dict[str, Any]: pool = get_pool() async with pool.acquire() as conn: rows = await conn.fetch( - "SELECT provider, token_encrypted, auth_kind FROM app.admin_provider_credential" + "SELECT provider, token_encrypted, auth_kind, refresh_token_encrypted, extra " + "FROM app.admin_provider_credential" ) providers = { row["provider"]: { "token": decrypt_token(row["token_encrypted"]), "auth_kind": row["auth_kind"], + **( + { + "refresh_token": decrypt_token(row["refresh_token_encrypted"]), + } + if row["refresh_token_encrypted"] + else {} + ), + "extra": row["extra"] if isinstance(row["extra"], dict) else {}, } for row in rows if row["provider"] in PROVIDER_CODES diff --git a/apps/api/app/services/provider_oauth.py b/apps/api/app/services/provider_oauth.py index 6bdd774..a7b32d9 100644 --- a/apps/api/app/services/provider_oauth.py +++ b/apps/api/app/services/provider_oauth.py @@ -16,6 +16,7 @@ from __future__ import annotations import base64 import hashlib +import json import secrets import time from dataclasses import dataclass @@ -49,7 +50,31 @@ _OPENROUTER_OAUTH = { "key_label": "Vignette 관리자 연결", } -OAUTH_PROVIDERS = ("claude", "openrouter") +# codex: Codex CLI의 공개 Auth0 클라이언트. 콜백은 로컬 루프백이라 아무것도 +# 수신하지 않는다 — 브라우저 주소창에 남는 code(필요하면 state)를 복사한다. +# 토큰은 api.openai.com이 아니라 ChatGPT 백엔드(chatgpt.com/backend-api/codex)에서만 쓴다. +_CODEX_OAUTH = { + "authorize_url": "https://auth.openai.com/oauth/authorize", + "token_url": "https://auth.openai.com/oauth/token", + "redirect_uri": "http://localhost:1455/auth/callback", + "client_id": "app_EMoamEEZ73f0CkXaXp7hrann", + "scope": "openid profile email offline_access", +} + +# agy(Antigravity CLI): 구글 공개 네이티브 클라이언트. 콜백은 루프백이므로 +# 브라우저 주소창의 code를 복사한다. 교환 후 Code Assist 프로젝트를 발급받아 metadata로 저장한다. +_AGY_OAUTH = { + "authorize_url": "https://accounts.google.com/o/oauth2/v2/auth", + "token_url": "https://oauth2.googleapis.com/token", + "redirect_uri": "http://localhost:1455/auth/callback", + "client_id": "1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com", + "client_secret": "GOCSPX-K58FWR486LdLJ1mLB8sXC4zqDAf", + "scope": "auth/cloud-platform auth/userinfo.email auth/userinfo.profile auth/cclog auth/experimentsandconfigs", + "user_agent": "antigravity/cli/1.0.0 (aidev_client; os_type=windows; arch=amd64; auth_method=consumer)", + "code_assist_base": "https://cloudcode-pa.googleapis.com", +} + +OAUTH_PROVIDERS = ("claude", "openrouter", "codex", "agy") @dataclass(frozen=True, slots=True) @@ -109,13 +134,39 @@ def start_oauth(provider: str, admin_email: str) -> dict[str, Any]: "&code_challenge_method=S256" f"&state={state}" ) - else: + elif provider == "openrouter": authorize_url = ( f"{_OPENROUTER_OAUTH['authorize_url']}" f"?code_challenge={code_challenge}" "&code_challenge_method=S256" f"&key_label={_OPENROUTER_OAUTH['key_label']}" ) + elif provider == "codex": + authorize_url = ( + f"{_CODEX_OAUTH['authorize_url']}" + "?response_type=code" + f"&client_id={_CODEX_OAUTH['client_id']}" + f"&redirect_uri={_CODEX_OAUTH['redirect_uri']}" + f"&scope={_CODEX_OAUTH['scope'].replace(' ', '%20')}" + f"&code_challenge={code_challenge}" + "&code_challenge_method=S256" + f"&state={state}" + "&id_token_add_organizations=true" + "&codex_cli_simplified_flow=true" + "&originator=codex_cli_rs" + "&prompt=login" + ) + else: + authorize_url = ( + f"{_AGY_OAUTH['authorize_url']}" + "?response_type=code" + "&access_type=offline" + "&prompt=consent" + f"&client_id={_AGY_OAUTH['client_id']}" + f"&redirect_uri={_AGY_OAUTH['redirect_uri']}" + f"&scope={_AGY_OAUTH['scope'].replace(' ', '%20')}" + f"&state={state}" + ) return { "authorize_url": authorize_url, "state": state, @@ -171,6 +222,57 @@ async def finish_oauth(provider: str, code: str, state: str, admin_email: str) - if not token: raise ProviderCredentialError("Claude가 액세스 토큰을 반환하지 않았습니다.") auth_kind = "oauth_token" + refresh_token: str | None = body.get("refresh_token") + extra: dict[str, Any] = {} + elif provider == "codex": + auth_code = _extract_code_from_paste(pasted) + try: + async with httpx.AsyncClient(timeout=30) as client: + response = await client.post( + _CODEX_OAUTH["token_url"], + data={ + "grant_type": "authorization_code", + "code": auth_code, + "redirect_uri": _CODEX_OAUTH["redirect_uri"], + "client_id": _CODEX_OAUTH["client_id"], + "code_verifier": attempt.code_verifier, + }, + ) + response.raise_for_status() + body = response.json() + except (httpx.HTTPError, ValueError) as exc: + raise ProviderCredentialError(f"Codex(ChatGPT) 토큰 교환 실패: {exc}") from exc + token = str(body.get("access_token") or "").strip() + if not token: + raise ProviderCredentialError("Codex가 액세스 토큰을 반환하지 않았습니다.") + auth_kind = "oauth_token" + refresh_token = str(body.get("refresh_token") or "") or None + extra = {"chatgpt_account_id": _chatgpt_account_id(body.get("id_token"))} + elif provider == "agy": + auth_code = _extract_code_from_paste(pasted) + try: + async with httpx.AsyncClient(timeout=30) as client: + response = await client.post( + _AGY_OAUTH["token_url"], + data={ + "code": auth_code, + "client_id": _AGY_OAUTH["client_id"], + "client_secret": _AGY_OAUTH["client_secret"], + "redirect_uri": _AGY_OAUTH["redirect_uri"], + "grant_type": "authorization_code", + "access_type": "offline", + }, + ) + response.raise_for_status() + body = response.json() + except (httpx.HTTPError, ValueError) as exc: + raise ProviderCredentialError(f"Agy(Google) 토큰 교환 실패: {exc}") from exc + token = str(body.get("access_token") or "").strip() + if not token: + raise ProviderCredentialError("Google이 액세스 토큰을 반환하지 않았습니다.") + auth_kind = "oauth_token" + refresh_token = str(body.get("refresh_token") or "") or None + extra = {"project_id": await _antigravity_project_id(token)} else: try: async with httpx.AsyncClient(timeout=30) as client: @@ -191,12 +293,16 @@ async def finish_oauth(provider: str, code: str, state: str, admin_email: str) - if not token: raise ProviderCredentialError("OpenRouter가 API 키를 반환하지 않았습니다.") auth_kind = "api_key" + refresh_token = None + extra = {} stored = await save_credential( provider=provider, token=token, auth_kind=auth_kind, updated_by=admin_email, + refresh_token=refresh_token, + extra=extra, ) sync = await push_credentials_to_gateway() return {"stored": stored, "gateway_sync": sync} diff --git a/apps/api/app/test_provider_oauth.py b/apps/api/app/test_provider_oauth.py index 4b945f0..8401c32 100644 --- a/apps/api/app/test_provider_oauth.py +++ b/apps/api/app/test_provider_oauth.py @@ -47,7 +47,7 @@ class StartOAuthTest(unittest.TestCase): def test_unsupported_provider_rejected(self): with self.assertRaises(svc.ProviderCredentialError): - svc.start_oauth("codex", "admin@example.com") + svc.start_oauth("solar", "admin@example.com") def test_claude_authorize_url_contains_pkce_and_state(self): result = svc.start_oauth("claude", "admin@example.com") diff --git a/apps/api/engine_gateway/gateway.py b/apps/api/engine_gateway/gateway.py index 138adcc..2d9b54d 100644 --- a/apps/api/engine_gateway/gateway.py +++ b/apps/api/engine_gateway/gateway.py @@ -45,7 +45,7 @@ from engine_gateway.provider_registry import ( clear_capability_cache, discover_capabilities, generate_with_provider, - set_provider_auth_kind, + set_provider_credential, stream_with_provider, ) @@ -454,6 +454,8 @@ _PROVIDER_CREDENTIAL_ENV_VARS: dict[str, tuple[str, ...]] = { class ProviderCredentialEntry(BaseModel): token: str auth_kind: str = "api_key" + refresh_token: str = "" + extra: dict[str, Any] = {} class ProviderCredentialsPush(BaseModel): @@ -495,9 +497,19 @@ async def internal_push_provider_credentials(payload: ProviderCredentialsPush): else: os.environ.pop("ANTHROPIC_AUTH_TOKEN", None) os.environ["ANTHROPIC_API_KEY"] = token + elif provider in {"codex", "agy"} and entry.auth_kind == "oauth_token": + # ChatGPT/Google OAuth 토큰은 OPENAI/AGY API 키로 쓸 수 없다. + # 구조화 자격증명으로만 보관하고 해당 어댑터가 읽는다. + pass else: os.environ[env_names[0]] = token - set_provider_auth_kind(provider, entry.auth_kind) + set_provider_credential( + provider, + token=token, + auth_kind=entry.auth_kind, + refresh_token=entry.refresh_token, + extra=entry.extra, + ) applied.append(provider) clear_capability_cache() return { diff --git a/apps/api/engine_gateway/golden/engine_gateway_schema.v1.json b/apps/api/engine_gateway/golden/engine_gateway_schema.v1.json index b3d9ab9..b549d69 100644 --- a/apps/api/engine_gateway/golden/engine_gateway_schema.v1.json +++ b/apps/api/engine_gateway/golden/engine_gateway_schema.v1.json @@ -96,7 +96,9 @@ "claude_cli", "claude_api", "codex_cli", + "codex_api", "agy_cli", + "antigravity_api", "openai", "openrouter", "solar" diff --git a/apps/api/engine_gateway/provider_registry.py b/apps/api/engine_gateway/provider_registry.py index 6193887..5a24b7c 100644 --- a/apps/api/engine_gateway/provider_registry.py +++ b/apps/api/engine_gateway/provider_registry.py @@ -87,6 +87,32 @@ _CAPABILITY_LOCK = asyncio.Lock() # os.environ으로만 들어가고 여기엔 어떤 헤더로 보낼지 판정하는 힌트만 둔다. _PROVIDER_AUTH_KINDS: dict[str, str] = {} +# OAuth 연결(claude/codex/agy)의 구조화 자격증명. 값은 내부 push 엔드포인트로만 +# 들어오고 재시작 시 초기화된다(재push는 boot_id 불일치 감지로 자동). 토큰 만료 시 +# refresh_token으로 자가 갱신한다. +_PROVIDER_CREDENTIALS: dict[str, dict[str, Any]] = {} + + +def set_provider_credential( + provider: str, + *, + token: str, + auth_kind: str = "api_key", + refresh_token: str = "", + extra: dict[str, Any] | None = None, +) -> None: + _PROVIDER_CREDENTIALS[provider] = { + "token": token, + "auth_kind": auth_kind, + "refresh_token": refresh_token, + "extra": dict(extra or {}), + } + set_provider_auth_kind(provider, auth_kind) + + +def get_provider_credential(provider: str) -> dict[str, Any] | None: + return _PROVIDER_CREDENTIALS.get(provider) + def set_provider_auth_kind(provider: str, auth_kind: str) -> None: if auth_kind in {"api_key", "oauth_token"}: @@ -112,6 +138,44 @@ def _anthropic_headers(api_key: str) -> dict[str, str]: } +# ── OAuth 계정 어댑터(codex_api / antigravity_api) 공용 유틸 ─────────── + +async def _refresh_oauth_token(provider: str) -> str | None: + """401 시 저장된 refresh_token으로 액세스 토큰을 갱신하고 새 값을 돌려준다.""" + cred = _PROVIDER_CREDENTIALS.get(provider) + if not cred or not cred.get("refresh_token"): + return None + if provider == "codex": + url = "https://auth.openai.com/oauth/token" + data = { + "grant_type": "refresh_token", + "refresh_token": cred["refresh_token"], + "client_id": "app_EMoamEEZ73f0CkXaXp7hrann", + } + else: + url = "https://oauth2.googleapis.com/token" + data = { + "grant_type": "refresh_token", + "refresh_token": cred["refresh_token"], + "client_id": "1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com", + "client_secret": "GOCSPX-K58FWR486LdLJ1mLB8sXC4zqDAf", + } + try: + async with httpx.AsyncClient(timeout=30) as client: + response = await client.post(url, data=data) + response.raise_for_status() + payload = response.json() + except (httpx.HTTPError, ValueError): + return None + token = str(payload.get("access_token") or "").strip() + if token: + cred["token"] = token + rotated = str(payload.get("refresh_token") or "").strip() + if rotated: + cred["refresh_token"] = rotated + return token or None + + def clear_capability_cache() -> None: _CAPABILITY_CACHE.clear() @@ -817,6 +881,315 @@ async def _generate_openrouter( ) +CODEX_API_BASE = "https://chatgpt.com/backend-api/codex" +ANTIGRAVITY_API_BASE = "https://cloudcode-pa.googleapis.com" + + +def _configured_codex_api_models() -> list[str]: + raw = os.environ.get("CODEX_API_ENGINE_MODELS", "").strip() + models = ( + [item.strip() for item in raw.split(",") if item.strip()] + if raw + else ["gpt-5.1-codex", "gpt-5.1-codex-mini", "gpt-5.1", "gpt-5.1-mini"] + ) + result: list[str] = [] + for model in models: + if model not in result: + result.append(model) + return result + + +def _configured_antigravity_models() -> list[str]: + raw = os.environ.get("AGY_API_ENGINE_MODELS", "").strip() + models = ( + [item.strip() for item in raw.split(",") if item.strip()] + if raw + else ["gemini-3.6-flash-high", "gemini-3.6-flash-low", "gemini-3.6-pro-high"] + ) + result: list[str] = [] + for model in models: + if model not in result: + result.append(model) + return result + + +async def _discover_codex_api() -> EngineCapabilitiesResponse: + if get_provider_credential("codex") is None: + return _unavailable("codex_api", "ChatGPT 계정 OAuth 연결이 없습니다.") + models: list[EngineModelOption] = [] + allowed = _configured_codex_api_models() + default_model = allowed[0] if allowed else "" + for model in allowed: + efforts = _openai_reasoning_efforts(model) + models.append( + EngineModelOption( + id=model, + label=model, + description="관리자가 연결한 ChatGPT 계정(Codex 백엔드)에서 사용 가능한 모델입니다.", + reasoning_efforts=efforts, + default_reasoning_effort="medium" if "medium" in efforts else None, + is_default=model == default_model, + ) + ) + if not models: + return _unavailable("codex_api", "CODEX_API_ENGINE_MODELS 설정이 비어 있습니다.") + return EngineCapabilitiesResponse( + provider="codex_api", + available=True, + source="static_cli", + models=models, + default_model=default_model, + default_reasoning_effort=None, + detail="ChatGPT 계정 연결이 확인되어 정적 모델 목록을 제공합니다.", + fetched_at=_now(), + ) + + +def _antigravity_headers(access_token: str) -> dict[str, str]: + return { + "Authorization": f"Bearer {access_token}", + "Content-Type": "application/json", + "User-Agent": "antigravity/cli/1.0.0 (aidev_client; os_type=windows; arch=amd64; auth_method=consumer)", + "Accept": "text/event-stream", + } + + +def _split_effort_suffix(model_id: str) -> tuple[str, ReasoningEffort | None]: + suffix = model_id.rsplit("-", 1)[-1] + if suffix in {"low", "medium", "high"}: + return model_id[: -(len(suffix) + 1)], cast(ReasoningEffort, suffix) + return model_id, None + + +async def _discover_antigravity_api() -> EngineCapabilitiesResponse: + if get_provider_credential("agy") is None: + return _unavailable("antigravity_api", "Agy(Google 계정) OAuth 연결이 없습니다.") + models: list[EngineModelOption] = [] + allowed = _configured_antigravity_models() + default_model = allowed[0] if allowed else "" + for model in allowed: + base_model, effort = _split_effort_suffix(model) + models.append( + EngineModelOption( + id=model, + label=f"{base_model} ({effort.capitalize()})" if effort else base_model, + description="관리자가 연결한 Google 계정(Antigravity Code Assist) 모델입니다.", + reasoning_efforts=_efforts(("low", "medium", "high")), + default_reasoning_effort=effort, + is_default=model == default_model, + ) + ) + if not models: + return _unavailable("antigravity_api", "AGY_API_ENGINE_MODELS 설정이 비어 있습니다.") + return EngineCapabilitiesResponse( + provider="antigravity_api", + available=True, + source="static_cli", + models=models, + default_model=default_model, + default_reasoning_effort=None, + detail="Google 계정 연결이 확인되어 정적 모델 목록을 제공합니다.", + fetched_at=_now(), + ) + + +async def _generate_codex_api( + req: GenerateRequest, + system_prompt: str, + user_payload: str, +) -> ProviderGenerateResult: + """ChatGPT 계정(codex 백엔드)으로 Responses API 형식을 SSE로 소비한다.""" + pricing_started_at = _utcnow() + cred = get_provider_credential("codex") + if cred is None: + raise ProviderError("ChatGPT 계정 OAuth 연결이 없습니다.") + model, effort = await _resolve_selection(req, "codex_api") + if req.ai_role == "client": + input_items: list[dict[str, str]] = [{"role": "user", "content": user_payload}] + else: + input_items = [ + {"role": message.role, "content": message.content} + for message in req.messages + if message.role != "system" + ] + payload: dict[str, Any] = { + "model": model, + "instructions": system_prompt or None, + "input": input_items, + "stream": True, + "store": False, + } + if effort: + payload["reasoning"] = {"effort": effort} + headers = { + "Authorization": f"Bearer {cred['token']}", + "Content-Type": "application/json", + "Accept": "text/event-stream", + "Openai-Beta": "responses=experimental", + "originator": "codex_cli_rs", + "User-Agent": "codex-cli/0.153.2 (Windows; amd64)", + } + account_id = (cred.get("extra") or {}).get("chatgpt_account_id") + if account_id: + headers["chatgpt-account-id"] = str(account_id) + if req.session_id: + headers["session_id"] = req.session_id + + text, usage = await _post_sse_json( + f"{CODEX_API_BASE}/responses", + headers=headers, + json_body=payload, + provider="codex_api", + refresh_token_provider="codex", + refresh_headers=lambda token: { + **headers, + "Authorization": f"Bearer {token}", + }, + extract=(lambda data: str(data.get("delta") or "") if data.get("type") == "response.output_text.delta" else None), + usage_extractor=(lambda data: (data.get("response") or {}).get("usage") if data.get("type") == "response.completed" else None), + ) + if not text.strip(): + raise ProviderError("Codex 백엔드가 텍스트 응답을 반환하지 않았습니다.") + tokens_in = int(usage.get("input_tokens") or 0) + tokens_out = int(usage.get("output_tokens") or 0) + estimate = estimate_reference_cost( + provider="openai", + model=model, + tokens_in=tokens_in, + tokens_out=tokens_out, + priced_at=pricing_started_at, + ) + return ProviderGenerateResult( + text=text, + model=model, + provider="codex_api", + tokens_in=tokens_in, + tokens_out=tokens_out, + cost_usd=estimate.cost_usd if estimate is not None else 0.0, + structured=_structured_or_none(text, req), + ) + + +async def _generate_antigravity_api( + req: GenerateRequest, + system_prompt: str, + user_payload: str, +) -> ProviderGenerateResult: + """Google 계정(Antigravity Code Assist)으로 Gemini 형식을 SSE로 소비한다.""" + pricing_started_at = _utcnow() + cred = get_provider_credential("agy") + if cred is None: + raise ProviderError("Agy(Google 계정) OAuth 연결이 없습니다.") + selected_model, effort = await _resolve_selection(req, "antigravity_api") + base_model, _suffix_effort = _split_effort_suffix(selected_model) + if req.ai_role == "client": + contents = [{"role": "user", "parts": [{"text": user_payload}]}] + else: + contents = [ + { + "role": message.role, + "parts": [{"text": message.content}], + } + for message in req.messages + if message.role != "system" + ] + project_id = str((cred.get("extra") or {}).get("project_id") or "") + payload: dict[str, Any] = { + "model": base_model, + "project": project_id, + "requestType": "agent", + "userAgent": "antigravity", + "contents": contents, + "enabledCreditTypes": ["GOOGLE_ONE_AI"], + } + if system_prompt: + payload["systemInstruction"] = {"parts": [{"text": system_prompt}]} + if effort: + payload["generationConfig"] = {"effort": effort} + + def usage_extractor(data: dict[str, Any]) -> dict[str, Any] | None: + return data.get("usageMetadata") or None + + text, usage = await _post_sse_json( + f"{ANTIGRAVITY_API_BASE}/v1internal:streamGenerateContent?alt=sse", + headers=_antigravity_headers(cred["token"]), + json_body=payload, + provider="antigravity_api", + refresh_token_provider="agy", + refresh_headers=lambda token: _antigravity_headers(token), + extract=lambda data: "".join( + str(part.get("text") or "") + for part in ((data.get("candidates") or [{}])[0].get("content") or {}).get("parts") or [] + if isinstance(part, dict) + ) or None, + usage_extractor=usage_extractor, + ) + if not text.strip(): + raise ProviderError("Antigravity 백엔드가 텍스트 응답을 반환하지 않았습니다.") + tokens_in = int(usage.get("promptTokenCount") or 0) + tokens_out = int(usage.get("candidatesTokenCount") or 0) + return ProviderGenerateResult( + text=text, + model=base_model, + provider="antigravity_api", + tokens_in=tokens_in, + tokens_out=tokens_out, + cost_usd=0.0, + structured=_structured_or_none(text, req), + ) + + +async def _post_sse_json( + url: str, + *, + headers: dict[str, str], + json_body: dict[str, Any], + provider: str, + refresh_token_provider: str, + refresh_headers, + extract, + usage_extractor, +) -> tuple[str, dict[str, Any]]: + """SSE(JSON 이벤트)를 소비해 누적 텍스트와 마지막 usage를 돌려준다. 401은 한 번 갱신해 재시도한다.""" + text_parts: list[str] = [] + usage: dict[str, Any] = {} + for attempt in range(2): + try: + async with httpx.AsyncClient(timeout=CLI_TIMEOUT_SECONDS) as client: + async with client.stream("POST", url, headers=headers, json=json_body) as response: + if response.status_code == 401 and attempt == 0: + refreshed = await _refresh_oauth_token(refresh_token_provider) + if refreshed: + headers = refresh_headers(refreshed) + continue + raise ProviderError(f"{provider} 인증이 만료되었습니다. 관리자 화면에서 다시 로그인해 주세요.") + response.raise_for_status() + async for line in response.aiter_lines(): + if not line.startswith("data:"): + continue + raw = line[len("data:"):].strip() + if not raw or raw == "[DONE]": + continue + try: + data = json.loads(raw) + except json.JSONDecodeError: + continue + if not isinstance(data, dict): + continue + delta = extract(data) + if delta is not None: + text_parts.append(delta) + extracted_usage = usage_extractor(data) + if extracted_usage: + usage = extracted_usage + break + except httpx.HTTPStatusError as exc: + raise ProviderError(f"{provider} 백엔드 호출 실패: HTTP {exc.response.status_code}") from exc + except httpx.HTTPError as exc: + raise ProviderError(f"{provider} 백엔드 전송 실패: {exc}") from exc + return "".join(text_parts), usage + + async def _discover(provider: EngineProvider) -> EngineCapabilitiesResponse: if provider == "claude_cli": return await _discover_claude_cli() @@ -828,8 +1201,12 @@ async def _discover(provider: EngineProvider) -> EngineCapabilitiesResponse: return await _discover_openrouter() if provider == "codex_cli": return await _discover_codex_cli() + if provider == "codex_api": + return await _discover_codex_api() if provider == "agy_cli": return await _discover_agy_cli() + if provider == "antigravity_api": + return await _discover_antigravity_api() return _unavailable(provider, f"{provider} 어댑터는 아직 모델 탐색을 지원하지 않습니다.") @@ -1312,6 +1689,10 @@ async def generate_with_provider( return await _generate_claude_api(req, system_prompt) if provider == "openai": return await _generate_openai(req, system_prompt, user_payload) + if provider == "codex_api": + return await _generate_codex_api(req, system_prompt, user_payload) + if provider == "antigravity_api": + return await _generate_antigravity_api(req, system_prompt, user_payload) if provider == "openrouter": return await _generate_openrouter(req, system_prompt, user_payload) raise ProviderError(f"이 게이트웨이에서 실행할 수 없는 provider입니다: {provider}") diff --git a/apps/web/src/lib/api.gen.ts b/apps/web/src/lib/api.gen.ts index f591782..e03d002 100644 --- a/apps/web/src/lib/api.gen.ts +++ b/apps/web/src/lib/api.gen.ts @@ -2798,7 +2798,7 @@ export interface components { * Engine Mode * @enum {string} */ - engine_mode: "claude_cli" | "claude_api" | "codex_cli" | "agy_cli" | "openai" | "openrouter" | "solar"; + engine_mode: "claude_cli" | "claude_api" | "codex_cli" | "codex_api" | "agy_cli" | "antigravity_api" | "openai" | "openrouter" | "solar"; /** Engine Url */ engine_url: string; /** Model */ @@ -5312,7 +5312,7 @@ export interface components { * Provider * @enum {string} */ - provider: "claude_cli" | "claude_api" | "codex_cli" | "agy_cli" | "openai" | "openrouter" | "solar"; + provider: "claude_cli" | "claude_api" | "codex_cli" | "codex_api" | "agy_cli" | "antigravity_api" | "openai" | "openrouter" | "solar"; /** * Source * @enum {string} diff --git a/apps/web/src/pages/AdminAi.tsx b/apps/web/src/pages/AdminAi.tsx index 754b1ed..f19f957 100644 --- a/apps/web/src/pages/AdminAi.tsx +++ b/apps/web/src/pages/AdminAi.tsx @@ -17,7 +17,9 @@ const ENGINE_MODES = [ "claude_cli", "claude_api", "codex_cli", + "codex_api", "agy_cli", + "antigravity_api", "openai", "openrouter", "solar", @@ -28,7 +30,9 @@ const ENGINE_MODE_LABEL: Record = { messages_api: "클라우드 연결", claude_api: "Anthropic API", codex_cli: "Codex CLI 게이트웨이", + codex_api: "ChatGPT 계정 (Codex API)", agy_cli: "Agy CLI 게이트웨이", + antigravity_api: "Google 계정 (Antigravity)", openai: "OpenAI 호환", openrouter: "OpenRouter", solar: "Solar", diff --git a/infra/db/init/05_runtime_auth.sql b/infra/db/init/05_runtime_auth.sql index c5ea13c..32c64c1 100644 --- a/infra/db/init/05_runtime_auth.sql +++ b/infra/db/init/05_runtime_auth.sql @@ -89,9 +89,15 @@ CREATE TABLE IF NOT EXISTS app.admin_provider_credential ( updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), last_verified_at TIMESTAMPTZ, last_verify_ok BOOLEAN, - last_verify_error TEXT + last_verify_error TEXT, + refresh_token_encrypted TEXT, + extra JSONB NOT NULL DEFAULT '{}'::jsonb ); +ALTER TABLE app.admin_provider_credential + ADD COLUMN IF NOT EXISTS refresh_token_encrypted TEXT, + ADD COLUMN IF NOT EXISTS extra JSONB NOT NULL DEFAULT '{}'::jsonb; + CREATE TABLE IF NOT EXISTS app.admin_health_event ( id BIGSERIAL PRIMARY KEY, observed_at TIMESTAMPTZ NOT NULL DEFAULT now(),