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_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),

View file

@ -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

View file

@ -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}

View file

@ -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")

View file

@ -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 {

View file

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

View file

@ -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}")

View file

@ -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}

View file

@ -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<string, string> = {
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",

View file

@ -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(),