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

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