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
|
|
@ -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),
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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}")
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue