codex·agy OAuth 계정 연결 지원 (ChatGPT/Antigravity 네이티브 어댑터)
Some checks failed
API contract / OpenAPI type drift (push) Failing after 1m0s

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가 구조화 자격증명을 전달
This commit is contained in:
Yun Chan 2026-09-11 19:01:35 +09:00
parent 1a925b33e0
commit 45b84faa0d
10 changed files with 581 additions and 18 deletions

View file

@ -19,7 +19,9 @@ EngineProvider = Literal[
"claude_cli", "claude_cli",
"claude_api", "claude_api",
"codex_cli", "codex_cli",
"codex_api",
"agy_cli", "agy_cli",
"antigravity_api",
"openai", "openai",
"openrouter", "openrouter",
"solar", "solar",
@ -40,7 +42,9 @@ ENGINE_PROVIDERS: tuple[EngineProvider, ...] = (
"claude_cli", "claude_cli",
"claude_api", "claude_api",
"codex_cli", "codex_cli",
"codex_api",
"agy_cli", "agy_cli",
"antigravity_api",
"openai", "openai",
"openrouter", "openrouter",
"solar", "solar",
@ -57,7 +61,9 @@ ENGINE_PROVIDER_DEFAULTS: dict[EngineProvider, tuple[str, Optional[ReasoningEffo
"claude_cli": (ENGINE_GATEWAY_DEFAULT_MODEL_SENTINEL, "high"), "claude_cli": (ENGINE_GATEWAY_DEFAULT_MODEL_SENTINEL, "high"),
"claude_api": (ENGINE_GATEWAY_DEFAULT_MODEL_SENTINEL, "high"), "claude_api": (ENGINE_GATEWAY_DEFAULT_MODEL_SENTINEL, "high"),
"codex_cli": ("gpt-5.6-terra", "medium"), "codex_cli": ("gpt-5.6-terra", "medium"),
"codex_api": (ENGINE_GATEWAY_DEFAULT_MODEL_SENTINEL, None),
"agy_cli": ("gemini-3.6-flash-high", "high"), "agy_cli": ("gemini-3.6-flash-high", "high"),
"antigravity_api": (ENGINE_GATEWAY_DEFAULT_MODEL_SENTINEL, None),
"openai": (ENGINE_GATEWAY_DEFAULT_MODEL_SENTINEL, None), "openai": (ENGINE_GATEWAY_DEFAULT_MODEL_SENTINEL, None),
"openrouter": (ENGINE_GATEWAY_DEFAULT_MODEL_SENTINEL, None), "openrouter": (ENGINE_GATEWAY_DEFAULT_MODEL_SENTINEL, None),
"solar": (ENGINE_GATEWAY_DEFAULT_MODEL_SENTINEL, None), "solar": (ENGINE_GATEWAY_DEFAULT_MODEL_SENTINEL, None),

View file

@ -14,6 +14,7 @@ from __future__ import annotations
import base64 import base64
import hashlib import hashlib
import hmac import hmac
import json
import os import os
import time import time
from dataclasses import dataclass from dataclasses import dataclass
@ -223,6 +224,16 @@ class StoredCredential:
last_verified_at: float | None last_verified_at: float | None
last_verify_ok: bool | None last_verify_ok: bool | None
last_verify_error: str | 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: async def ensure_table() -> None:
@ -234,10 +245,7 @@ async def ensure_table() -> None:
""" """
pool = get_pool() pool = get_pool()
async with pool.acquire() as conn: async with pool.acquire() as conn:
exists = await conn.fetchval( if await _credential_table_ready(conn):
"SELECT to_regclass('app.admin_provider_credential') IS NOT NULL"
)
if exists:
return return
await conn.execute( await conn.execute(
""" """
@ -251,10 +259,19 @@ async def ensure_table() -> None:
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
last_verified_at TIMESTAMPTZ, last_verified_at TIMESTAMPTZ,
last_verify_ok BOOLEAN, 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: 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_verified_at=_row_ts(row["last_verified_at"]),
last_verify_ok=row["last_verify_ok"], last_verify_ok=row["last_verify_ok"],
last_verify_error=row["last_verify_error"], 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( 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: ) -> StoredCredential:
if provider not in PROVIDER_CODES: if provider not in PROVIDER_CODES:
raise ProviderCredentialError(f"지원하지 않는 provider입니다: {provider}") raise ProviderCredentialError(f"지원하지 않는 provider입니다: {provider}")
@ -314,26 +341,32 @@ async def save_credential(
row = await conn.fetchrow( row = await conn.fetchrow(
""" """
INSERT INTO app.admin_provider_credential ( 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 ON CONFLICT (provider) DO UPDATE SET
token_encrypted = EXCLUDED.token_encrypted, token_encrypted = EXCLUDED.token_encrypted,
token_hint = EXCLUDED.token_hint, token_hint = EXCLUDED.token_hint,
auth_kind = EXCLUDED.auth_kind, auth_kind = EXCLUDED.auth_kind,
updated_by = EXCLUDED.updated_by, updated_by = EXCLUDED.updated_by,
updated_at = now(), 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_verified_at = NULL,
last_verify_ok = NULL, last_verify_ok = NULL,
last_verify_error = NULL last_verify_error = NULL
RETURNING provider, token_hint, auth_kind, updated_by, updated_at, 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, provider,
encrypt_token(plain), encrypt_token(plain),
token_hint(plain), token_hint(plain),
auth_kind, auth_kind,
updated_by, updated_by,
encrypt_token(refresh_token) if refresh_token and refresh_token.strip() else None,
json.dumps(extra or {}),
) )
return StoredCredential( return StoredCredential(
provider=row["provider"], provider=row["provider"],
@ -344,6 +377,10 @@ async def save_credential(
last_verified_at=_row_ts(row["last_verified_at"]), last_verified_at=_row_ts(row["last_verified_at"]),
last_verify_ok=row["last_verify_ok"], last_verify_ok=row["last_verify_ok"],
last_verify_error=row["last_verify_error"], 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() pool = get_pool()
async with pool.acquire() as conn: async with pool.acquire() as conn:
rows = await conn.fetch( 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 = { providers = {
row["provider"]: { row["provider"]: {
"token": decrypt_token(row["token_encrypted"]), "token": decrypt_token(row["token_encrypted"]),
"auth_kind": row["auth_kind"], "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 for row in rows
if row["provider"] in PROVIDER_CODES if row["provider"] in PROVIDER_CODES

View file

@ -16,6 +16,7 @@ from __future__ import annotations
import base64 import base64
import hashlib import hashlib
import json
import secrets import secrets
import time import time
from dataclasses import dataclass from dataclasses import dataclass
@ -49,7 +50,31 @@ _OPENROUTER_OAUTH = {
"key_label": "Vignette 관리자 연결", "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) @dataclass(frozen=True, slots=True)
@ -109,13 +134,39 @@ def start_oauth(provider: str, admin_email: str) -> dict[str, Any]:
"&code_challenge_method=S256" "&code_challenge_method=S256"
f"&state={state}" f"&state={state}"
) )
else: elif provider == "openrouter":
authorize_url = ( authorize_url = (
f"{_OPENROUTER_OAUTH['authorize_url']}" f"{_OPENROUTER_OAUTH['authorize_url']}"
f"?code_challenge={code_challenge}" f"?code_challenge={code_challenge}"
"&code_challenge_method=S256" "&code_challenge_method=S256"
f"&key_label={_OPENROUTER_OAUTH['key_label']}" 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 { return {
"authorize_url": authorize_url, "authorize_url": authorize_url,
"state": state, "state": state,
@ -171,6 +222,57 @@ async def finish_oauth(provider: str, code: str, state: str, admin_email: str) -
if not token: if not token:
raise ProviderCredentialError("Claude가 액세스 토큰을 반환하지 않았습니다.") raise ProviderCredentialError("Claude가 액세스 토큰을 반환하지 않았습니다.")
auth_kind = "oauth_token" 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: else:
try: try:
async with httpx.AsyncClient(timeout=30) as client: 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: if not token:
raise ProviderCredentialError("OpenRouter가 API 키를 반환하지 않았습니다.") raise ProviderCredentialError("OpenRouter가 API 키를 반환하지 않았습니다.")
auth_kind = "api_key" auth_kind = "api_key"
refresh_token = None
extra = {}
stored = await save_credential( stored = await save_credential(
provider=provider, provider=provider,
token=token, token=token,
auth_kind=auth_kind, auth_kind=auth_kind,
updated_by=admin_email, updated_by=admin_email,
refresh_token=refresh_token,
extra=extra,
) )
sync = await push_credentials_to_gateway() sync = await push_credentials_to_gateway()
return {"stored": stored, "gateway_sync": sync} return {"stored": stored, "gateway_sync": sync}

View file

@ -47,7 +47,7 @@ class StartOAuthTest(unittest.TestCase):
def test_unsupported_provider_rejected(self): def test_unsupported_provider_rejected(self):
with self.assertRaises(svc.ProviderCredentialError): 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): def test_claude_authorize_url_contains_pkce_and_state(self):
result = svc.start_oauth("claude", "admin@example.com") result = svc.start_oauth("claude", "admin@example.com")

View file

@ -45,7 +45,7 @@ from engine_gateway.provider_registry import (
clear_capability_cache, clear_capability_cache,
discover_capabilities, discover_capabilities,
generate_with_provider, generate_with_provider,
set_provider_auth_kind, set_provider_credential,
stream_with_provider, stream_with_provider,
) )
@ -454,6 +454,8 @@ _PROVIDER_CREDENTIAL_ENV_VARS: dict[str, tuple[str, ...]] = {
class ProviderCredentialEntry(BaseModel): class ProviderCredentialEntry(BaseModel):
token: str token: str
auth_kind: str = "api_key" auth_kind: str = "api_key"
refresh_token: str = ""
extra: dict[str, Any] = {}
class ProviderCredentialsPush(BaseModel): class ProviderCredentialsPush(BaseModel):
@ -495,9 +497,19 @@ async def internal_push_provider_credentials(payload: ProviderCredentialsPush):
else: else:
os.environ.pop("ANTHROPIC_AUTH_TOKEN", None) os.environ.pop("ANTHROPIC_AUTH_TOKEN", None)
os.environ["ANTHROPIC_API_KEY"] = token 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: else:
os.environ[env_names[0]] = token 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) applied.append(provider)
clear_capability_cache() clear_capability_cache()
return { return {

View file

@ -96,7 +96,9 @@
"claude_cli", "claude_cli",
"claude_api", "claude_api",
"codex_cli", "codex_cli",
"codex_api",
"agy_cli", "agy_cli",
"antigravity_api",
"openai", "openai",
"openrouter", "openrouter",
"solar" "solar"

View file

@ -87,6 +87,32 @@ _CAPABILITY_LOCK = asyncio.Lock()
# os.environ으로만 들어가고 여기엔 어떤 헤더로 보낼지 판정하는 힌트만 둔다. # os.environ으로만 들어가고 여기엔 어떤 헤더로 보낼지 판정하는 힌트만 둔다.
_PROVIDER_AUTH_KINDS: dict[str, str] = {} _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: def set_provider_auth_kind(provider: str, auth_kind: str) -> None:
if auth_kind in {"api_key", "oauth_token"}: 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: def clear_capability_cache() -> None:
_CAPABILITY_CACHE.clear() _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: async def _discover(provider: EngineProvider) -> EngineCapabilitiesResponse:
if provider == "claude_cli": if provider == "claude_cli":
return await _discover_claude_cli() return await _discover_claude_cli()
@ -828,8 +1201,12 @@ async def _discover(provider: EngineProvider) -> EngineCapabilitiesResponse:
return await _discover_openrouter() return await _discover_openrouter()
if provider == "codex_cli": if provider == "codex_cli":
return await _discover_codex_cli() return await _discover_codex_cli()
if provider == "codex_api":
return await _discover_codex_api()
if provider == "agy_cli": if provider == "agy_cli":
return await _discover_agy_cli() return await _discover_agy_cli()
if provider == "antigravity_api":
return await _discover_antigravity_api()
return _unavailable(provider, f"{provider} 어댑터는 아직 모델 탐색을 지원하지 않습니다.") return _unavailable(provider, f"{provider} 어댑터는 아직 모델 탐색을 지원하지 않습니다.")
@ -1312,6 +1689,10 @@ async def generate_with_provider(
return await _generate_claude_api(req, system_prompt) return await _generate_claude_api(req, system_prompt)
if provider == "openai": if provider == "openai":
return await _generate_openai(req, system_prompt, user_payload) 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": if provider == "openrouter":
return await _generate_openrouter(req, system_prompt, user_payload) return await _generate_openrouter(req, system_prompt, user_payload)
raise ProviderError(f"이 게이트웨이에서 실행할 수 없는 provider입니다: {provider}") raise ProviderError(f"이 게이트웨이에서 실행할 수 없는 provider입니다: {provider}")

View file

@ -2798,7 +2798,7 @@ export interface components {
* Engine Mode * Engine Mode
* @enum {string} * @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 */
engine_url: string; engine_url: string;
/** Model */ /** Model */
@ -5312,7 +5312,7 @@ export interface components {
* Provider * Provider
* @enum {string} * @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 * Source
* @enum {string} * @enum {string}

View file

@ -17,7 +17,9 @@ const ENGINE_MODES = [
"claude_cli", "claude_cli",
"claude_api", "claude_api",
"codex_cli", "codex_cli",
"codex_api",
"agy_cli", "agy_cli",
"antigravity_api",
"openai", "openai",
"openrouter", "openrouter",
"solar", "solar",
@ -28,7 +30,9 @@ const ENGINE_MODE_LABEL: Record<string, string> = {
messages_api: "클라우드 연결", messages_api: "클라우드 연결",
claude_api: "Anthropic API", claude_api: "Anthropic API",
codex_cli: "Codex CLI 게이트웨이", codex_cli: "Codex CLI 게이트웨이",
codex_api: "ChatGPT 계정 (Codex API)",
agy_cli: "Agy CLI 게이트웨이", agy_cli: "Agy CLI 게이트웨이",
antigravity_api: "Google 계정 (Antigravity)",
openai: "OpenAI 호환", openai: "OpenAI 호환",
openrouter: "OpenRouter", openrouter: "OpenRouter",
solar: "Solar", solar: "Solar",

View file

@ -89,9 +89,15 @@ CREATE TABLE IF NOT EXISTS app.admin_provider_credential (
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
last_verified_at TIMESTAMPTZ, last_verified_at TIMESTAMPTZ,
last_verify_ok BOOLEAN, 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 ( CREATE TABLE IF NOT EXISTS app.admin_health_event (
id BIGSERIAL PRIMARY KEY, id BIGSERIAL PRIMARY KEY,
observed_at TIMESTAMPTZ NOT NULL DEFAULT now(), observed_at TIMESTAMPTZ NOT NULL DEFAULT now(),