codex·agy OAuth 계정 연결 지원 (ChatGPT/Antigravity 네이티브 어댑터)
Some checks failed
API contract / OpenAPI type drift (push) Failing after 1m0s
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:
parent
1a925b33e0
commit
45b84faa0d
10 changed files with 581 additions and 18 deletions
|
|
@ -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}")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue