diff --git a/docs-site/src/content/docs/fr/guides/providers.md b/docs-site/src/content/docs/fr/guides/providers.md index 59381edd60..a7daf61b9e 100644 --- a/docs-site/src/content/docs/fr/guides/providers.md +++ b/docs-site/src/content/docs/fr/guides/providers.md @@ -61,7 +61,7 @@ registre intégré classe séparément les préréglages locaux ; ceux-ci omette | --- | --- | --- | | `key` | Envoie votre clé API (`Authorization: Bearer …`, ou `x-api-key` / `api-key` par adaptateur). La clé peut être un littéral ou une référence `${ENV_VAR}`. | La plupart des fournisseurs. | | `forward` | Transmet **à l'identique vos en-têtes d'authentification Codex entrants** au fournisseur, sans enregistrer de clé. Il s'agit du transfert de la connexion ChatGPT. | OpenAI (adaptateur `openai-responses`). | -| `oauth` | Résout un jeton d'accès OAuth enregistré — automatiquement actualisé avant son expiration — et l'utilise comme jeton porteur. | xAI, Anthropic, Kimi, Kiro, Google Antigravity, Cursor, Command Code, GitHub Copilot, Nous Portal. | +| `oauth` | Résout un jeton d'accès OAuth enregistré — automatiquement actualisé avant son expiration — et l'utilise comme jeton porteur. | xAI, Anthropic, Kimi, Kiro, Google Antigravity, Gemini, Cursor, Command Code, GitHub Copilot, Nous Portal. | La relance d'une requête 429 avec la même clé, configurée par [`retryOn429`](/fr/reference/configuration/), s'applique uniquement aux fournisseurs à clé API @@ -94,7 +94,7 @@ Le catalogue du transfert ChatGPT ajoute également les identifiants non qualifi ## 2. Connexion au compte (OAuth) -Huit préréglages de fournisseurs utilisent une connexion OAuth. GitHub Copilot s'y ajoute au moyen d'un pont +Dix préréglages de fournisseurs utilisent une connexion OAuth. GitHub Copilot s'y ajoute au moyen d'un pont expérimental et non officiel reposant sur un flux d'autorisation d'appareil. opencodex enregistre leurs identifiants dans `~/.opencodex/auth.json` et les actualise automatiquement. La CLI de connexion accepte également `chatgpt` ; elle obtient un identifiant ChatGPT tout en créant une entrée de fournisseur en mode `forward`. @@ -106,6 +106,8 @@ ocx login kimi # Moonshot Kimi ocx login nous # Nous Portal (device grant; free + paid models) ocx login kiro # import kiro-cli credentials (or token fallback) ocx login google-antigravity +ocx login gemini-cli # OAuth Gemini (compte Google) — sous-type Code Assist +ocx login gemini-ai-studio # OAuth Gemini (compte Google) — sous-type AI Studio ocx login cursor # standalone Cursor PKCE login ocx login command-code # Command Code browser OAuth (or import ~/.commandcode/auth.json) ocx login github-copilot # GitHub device flow → Copilot token (Copilot Pro/Business) @@ -121,6 +123,8 @@ ocx logout | `nous` | `openai-chat` | `https://inference-api.nousresearch.com/v1` | Passerelle d'abonnement Nous Research (le même service en amont que celui utilisé par Hermes Agent). Connexion par autorisation d'appareil auprès de `portal.nousresearch.com` ; le jeton d'accès est le JWT d'inférence envoyé avec chaque requête. Le catalogue mixte de modèles payants et `:free` (`tencent/hy3:free`, `stepfun/step-3.7-flash:free`, ...) est découvert en direct pour le compte connecté. Les jetons d'actualisation sont à usage unique et renouvelés à chaque actualisation. | | `kiro` | `kiro` | `https://runtime.us-east-1.kiro.dev` | La connexion initiale importe la session de l'installation locale de `kiro-cli`, déjà authentifiée (sous Unix, installez avec `curl -fsSL https://cli.kiro.dev/install` | `bash`; sous Windows PowerShell, utilisez `irm 'https://cli.kiro.dev/install.ps1'` | `iex`; puis exécutez `kiro-cli login`). **Ajouter un compte** déconnecte `kiro-cli`, lance une nouvelle connexion dans le navigateur qui change le compte utilisé par `kiro-cli`, puis enregistre les métadonnées propres au profil. Les comptes OpenCodex existants sont préservés ; une annulation ou un échec restaure la session `kiro-cli` précédente. | | `google-antigravity` | `google` | `https://daily-cloudcode-pa.googleapis.com` | Google OAuth avec le protocole Cloud Code Assist. La découverte en direct utilise le point de terminaison CCA authentifié `v1internal:fetchAvailableModels` et publie les modèles d'agent accessibles au compte connecté ; le catalogue maintenu reste la solution de repli. | +| `gemini-cli` | `google` | `https://cloudcode-pa.googleapis.com` | OAuth Gemini (compte Google), sous-type **Code Assist**. Fonctionne avec les forfaits Google One AI Pro/Ultra. Même hôte qu'Antigravity mais une famille de clients différente — voir « Connexion OAuth (Gemini) » ci-dessous. | +| `gemini-ai-studio` | `google` | `https://generativelanguage.googleapis.com` | OAuth Gemini (compte Google), sous-type **AI Studio** : l'API Generative Language avec un jeton bearer au lieu d'une clé API. Nécessite votre propre client OAuth enregistré. | | `cursor` | `cursor` | `https://api2.cursor.sh` | Connexion PKCE expérimentale, transport HTTP/2 en direct et découverte de modèles filtrés par compte. | | `github-copilot` | `openai-chat` | `https://api.githubcopilot.com` | Expérimental. Flux d'appareil GitHub et échange `copilot_internal` (client OAuth de VS Code). Nécessite un abonnement Copilot actif ; il ne s'agit pas d'une API tierce officielle. | @@ -146,6 +150,66 @@ profil. `chatgpt` n'utilise toujours qu'un seul emplacement, car les comptes du registre distinct. Les jetons restent dans `~/.opencodex/auth.json` ; `/api/oauth/accounts` ne renvoie que des métadonnées masquées. +### Connexion OAuth (Gemini) + +Autorisez avec un compte Google et choisissez un **sous-type OAuth**. Chaque sous-type est un +fournisseur distinct avec son propre ensemble de comptes, car Google les place derrière des clients +OAuth et des portées différents — un identifiant émis pour l'un n'est pas accepté par l'autre. + +| Sous-type | Id du fournisseur | De quoi il s'agit | Quand le choisir | +| --- | --- | --- | --- | +| **Code Assist** | `gemini-cli` | Cloud Code Assist, le backend qu'utilise le Gemini CLI de Google. La connexion découvre (et intègre si nécessaire) un projet Code Assist, ensuite envoyé avec chaque requête. | Par défaut. Un compte Google ordinaire, y compris les forfaits Google One AI Pro / Ultra. | +| **AI Studio** | `gemini-ai-studio` | L'API Generative Language atteinte avec un jeton bearer au lieu d'un `x-goog-api-key`. | Vous avez enregistré votre propre client OAuth Google et préférez OAuth à une clé API. | + +**Depuis le tableau de bord.** Ouvrez **Providers → Ajouter un fournisseur → Accounts**. Deux lignes +apparaissent — *Gemini (Code Assist)* et *Gemini (AI Studio)* — chacune étiquetée avec son sous-type. +Cliquez sur celle voulue, complétez l'écran de consentement Google dans le navigateur qui s'ouvre, et +la ligne affiche alors l'adresse e-mail du compte connecté. **Ajouter un compte** sur la même ligne +autorise un second compte Google sans déconnecter le premier. + +**Depuis la CLI.** + +```bash +ocx login gemini-cli # sous-type Code Assist +ocx login gemini-ai-studio # sous-type AI Studio +ocx logout gemini-cli +``` + +La connexion ouvre votre navigateur et écoute sur `http://127.0.0.1:51122/callback`. Si le navigateur +ne peut pas joindre l'écouteur en boucle locale, collez l'URL de redirection (ou le `code` seul) dans +l'invite. + +**Découverte du projet Code Assist.** Après l'échange du jeton, le sous-type Code Assist appelle +`loadCodeAssist` puis, pour un compte sans projet, `onboardUser`. L'id du projet découvert est +enregistré avec l'identifiant et revérifié à chaque actualisation. Si aucun projet ne peut être +découvert, **la connexion échoue** au lieu d'enregistrer un identifiant dont chaque requête serait +rejetée — le compte apparaîtrait sinon comme « connecté » alors que rien ne fonctionne. Vérifiez que +le compte Google dispose bien de l'accès à Gemini Code Assist, puis réessayez. + +**AI Studio exige votre propre client OAuth.** Le client Gemini CLI intégré de Google n'est pas +enregistré pour les portées generative-language ; ce sous-type échoue donc de manière explicite tant +que vous ne fournissez pas les identifiants d'un client issu de votre propre projet Google Cloud +(type de client OAuth *Application de bureau*, avec `http://127.0.0.1:51122/callback` comme URI de +redirection autorisée) : + +```bash +export GEMINI_AI_STUDIO_OAUTH_CLIENT_ID=".apps.googleusercontent.com" +export GEMINI_AI_STUDIO_OAUTH_CLIENT_SECRET="" +ocx login gemini-ai-studio +``` + +Le sous-type Code Assist ne demande aucune configuration : il utilise les identifiants de client +publics que Google distribue dans le Gemini CLI. `GEMINI_CLI_OAUTH_CLIENT_ID` / +`GEMINI_CLI_OAUTH_CLIENT_SECRET` les remplacent si vous préférez votre propre client. + +:::caution[Conditions d'utilisation] +Le sous-type Code Assist présente les identifiants du client Gemini CLI propriétaire de Google depuis +un proxy plutôt que depuis la CLI elle-même. Comme les autres passerelles non officielles de cette +page, cela peut entrer en conflit avec les conditions de Google, et la détection d'abus peut +suspendre l'accès. Le sous-type AI Studio ne comporte pas ce risque — il autorise un client que vous +avez enregistré vous-même. +::: + ### Importation Cockpit Tools Antigravity Dans la v1, OpenCodex importe uniquement les exportations JSON **Cockpit Tools Antigravity** destinées au diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index e1451dcfbd..e520eef9d0 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -57,7 +57,7 @@ labels local presets separately; those normally omit both `authMode` and `apiKey | --- | --- | --- | | `key` | Sends your API key (`Authorization: Bearer …`, or `x-api-key` / `api-key` per adapter). The key may be a literal or an `${ENV_VAR}` reference. | Most providers. | | `forward` | Relays **your incoming Codex auth headers** verbatim to the provider — no key stored. This is the ChatGPT-login passthrough. | OpenAI (`openai-responses` adapter). | -| `oauth` | Resolves a stored OAuth access token (auto-refreshed before expiry) and uses it as the bearer key. | xAI, Anthropic, Kimi, Kiro, Google Antigravity, Cursor, Command Code, GitHub Copilot, Nous Portal. | +| `oauth` | Resolves a stored OAuth access token (auto-refreshed before expiry) and uses it as the bearer key. | xAI, Anthropic, Kimi, Kiro, Google Antigravity, Gemini, Cursor, Command Code, GitHub Copilot, Nous Portal. | The [`retryOn429`](/reference/configuration/) same-key 429 replay applies only to API-key providers (`authMode: "key"`). OAuth, forward, and local presets are excluded — their @@ -89,7 +89,7 @@ The ChatGPT passthrough catalog also layers in the bare GPT-5.6 Sol/Terra/Luna s ## 2. Account login (OAuth) -Eight provider presets use OAuth login — plus GitHub Copilot via an experimental unofficial +Ten provider presets use OAuth login — plus GitHub Copilot via an experimental unofficial device-flow bridge. opencodex stores their credentials in `~/.opencodex/auth.json` and refreshes them automatically. `chatgpt` is also accepted by the login CLI; it acquires a ChatGPT credential while creating a `forward`-mode provider entry. @@ -101,6 +101,8 @@ ocx login kimi # Moonshot Kimi ocx login nous # Nous Portal (device grant; free + paid models) ocx login kiro # import kiro-cli credentials (or token fallback) ocx login google-antigravity +ocx login gemini-cli # Gemini OAuth (Google account) — Code Assist subtype +ocx login gemini-ai-studio # Gemini OAuth (Google account) — AI Studio subtype ocx login cursor # standalone Cursor PKCE login ocx login command-code # Command Code browser OAuth (or import ~/.commandcode/auth.json) ocx login github-copilot # GitHub device flow → Copilot token (Copilot Pro/Business) @@ -116,6 +118,8 @@ ocx logout | `nous` | `openai-chat` | `https://inference-api.nousresearch.com/v1` | Nous Research subscription gateway (same backend Hermes Agent uses). Device-grant login against `portal.nousresearch.com`; the access token is the per-request inference JWT. Mixed paid + `:free` model catalog (`tencent/hy3:free`, `stepfun/step-3.7-flash:free`, ...) discovered live from the signed-in account. Refresh tokens are single-use and rotated on every refresh. | | `kiro` | `kiro` | `https://runtime.us-east-1.kiro.dev` | Initial login imports the installed, signed-in `kiro-cli` session (on Unix, install with `curl -fsSL https://cli.kiro.dev/install` | `bash`; on Windows PowerShell, use `irm 'https://cli.kiro.dev/install.ps1'` | `iex`; then run `kiro-cli login`). **Add account** logs `kiro-cli` out, starts a fresh browser login that switches the account used by `kiro-cli`, and stores account-scoped profile metadata. Existing OpenCodex accounts are preserved, and cancellation or failure restores the previous `kiro-cli` session. | | `google-antigravity` | `google` | `https://daily-cloudcode-pa.googleapis.com` | Google OAuth over the Cloud Code Assist wire. Live discovery uses CCA's authenticated `v1internal:fetchAvailableModels` endpoint and publishes the agent models available to the signed-in account; the maintained catalog remains the fallback. | +| `gemini-cli` | `google` | `https://cloudcode-pa.googleapis.com` | Gemini OAuth (Google account), **Code Assist** subtype. Works with Google One AI Pro/Ultra plans. Same host as Antigravity but a different client family — see [OAuth login (Gemini)](#oauth-login-gemini). | +| `gemini-ai-studio` | `google` | `https://generativelanguage.googleapis.com` | Gemini OAuth (Google account), **AI Studio** subtype: the Generative Language API with a bearer token instead of an API key. Requires your own registered OAuth client. | | `cursor` | `cursor` | `https://api2.cursor.sh` | Experimental PKCE login, live HTTP/2 transport with an opt-in HTTP/1.1 compatibility path, and account-filtered model discovery. | | `github-copilot` | `openai-chat` | `https://api.githubcopilot.com` | Experimental. GitHub device flow + `copilot_internal` exchange (VS Code OAuth client). Requires an active Copilot subscription; not an official third-party API. | @@ -140,6 +144,63 @@ distinct one. Kiro accounts are keyed by profile ARN. `chatgpt` is always single pool accounts have a separate ledger. Tokens stay in `~/.opencodex/auth.json`; `/api/oauth/accounts` returns masked metadata only. +### OAuth login (Gemini) + +Authorize with a Google account and pick an **OAuth subtype**. Each subtype is its own provider +with its own account set, because Google issues the two behind different OAuth clients and scopes — +a credential minted for one is not accepted by the other. + +| Subtype | Provider id | What it is | When to pick it | +| --- | --- | --- | --- | +| **Code Assist** | `gemini-cli` | Cloud Code Assist, the backend Google's own Gemini CLI uses. Login discovers (and if needed onboards) a Code Assist project, which is then sent with every request. | Default. A plain Google account, including Google One AI Pro / Ultra plans. | +| **AI Studio** | `gemini-ai-studio` | The Generative Language API reached with a bearer token instead of an `x-goog-api-key`. | You have registered your own Google OAuth client and want OAuth rather than an API key. | + +**From the dashboard.** Open **Providers → Add provider → Accounts**. Two rows appear — +*Gemini (Code Assist)* and *Gemini (AI Studio)* — each labelled with its subtype. Click the one you +want, complete the Google consent screen in the browser that opens, and the row switches to the +signed-in account's email. **Add account** on the same row authorizes a second Google account +without logging the first one out. + +**From the CLI.** + +```bash +ocx login gemini-cli # Code Assist subtype +ocx login gemini-ai-studio # AI Studio subtype +ocx logout gemini-cli +``` + +Login opens your browser and listens on `http://127.0.0.1:51122/callback`. If the browser cannot +reach the loopback listener, paste the redirect URL (or the bare `code`) back into the prompt. + +**Code Assist project discovery.** After the token exchange, the Code Assist subtype calls +`loadCodeAssist` and, for an account with no project yet, `onboardUser`. The discovered project id is +stored with the credential and re-checked on refresh. If no project can be discovered, **the login +fails** rather than saving a credential whose every request would be rejected — the account would +otherwise read as "logged in" while nothing worked. Make sure the Google account actually has Gemini +Code Assist access, then retry. + +**AI Studio needs your own OAuth client.** Google's built-in Gemini CLI client is not registered for +the generative-language scopes, so this subtype fails closed with an actionable message until you +supply client credentials from your own Google Cloud project (OAuth client type *Desktop app*, with +`http://127.0.0.1:51122/callback` as an authorized redirect URI): + +```bash +export GEMINI_AI_STUDIO_OAUTH_CLIENT_ID=".apps.googleusercontent.com" +export GEMINI_AI_STUDIO_OAUTH_CLIENT_SECRET="" +ocx login gemini-ai-studio +``` + +The Code Assist subtype needs no configuration: it uses the public client identifiers Google ships +inside the Gemini CLI. `GEMINI_CLI_OAUTH_CLIENT_ID` / `GEMINI_CLI_OAUTH_CLIENT_SECRET` override them +if you would rather use a client of your own. + +:::caution[Terms of Service] +The Code Assist subtype presents Google's first-party Gemini CLI client identifiers from a proxy +rather than from the CLI itself. Like the other unofficial bridges here, that may conflict with +Google's terms and abuse detection may suspend access. The AI Studio subtype does not carry this +risk — it authorizes a client you registered yourself. +::: + ### Cockpit Tools Antigravity import For v1, OpenCodex imports only a **Cockpit Tools Antigravity** JSON export for the `google-antigravity` provider. In the Providers dashboard, choose the local JSON file from that provider's Accounts tab. The dashboard does not show the file contents or credential values; it reports only imported, updated, failed, and unsupported counts. Other Cockpit providers are rejected in v1. diff --git a/docs-site/src/content/docs/ja/guides/providers.md b/docs-site/src/content/docs/ja/guides/providers.md index 81a19e5e5b..8291ada362 100644 --- a/docs-site/src/content/docs/ja/guides/providers.md +++ b/docs-site/src/content/docs/ja/guides/providers.md @@ -52,7 +52,7 @@ Codex login を Pool モードで使うと、Providers の概要には任意の | --- | --- | --- | | `key` | API キーを送信します(`Authorization: Bearer …`、またはアダプターにより `x-api-key` / `api-key`)。キーはリテラルまたは `${ENV_VAR}` 参照です。 | 大半のプロバイダー。 | | `forward` | **受け取った Codex 認証ヘッダーを**プロバイダーにそのまま中継します — キーを保存しません。ChatGPT ログインのパススルーです。 | OpenAI(`openai-responses` アダプター)。 | -| `oauth` | 保存された OAuth アクセストークンを読み込み bearer キーとして使い、期限切れ前に自動更新します。 | xAI、Anthropic、Kimi、Kiro、Google Antigravity、Cursor、Command Code、GitHub Copilot、Nous Portal。 | +| `oauth` | 保存された OAuth アクセストークンを読み込み bearer キーとして使い、期限切れ前に自動更新します。 | xAI、Anthropic、Kimi、Kiro、Google Antigravity、Gemini、Cursor、Command Code、GitHub Copilot、Nous Portal。 | [`retryOn429`](/ja/reference/configuration/)(同一キーでの 429 リトライ)は API キー プロバイダー (`authMode: "key"`)のみに適用されます。OAuth・forward・ローカル プリセットは除外されます — @@ -84,7 +84,7 @@ ChatGPT パススルーカタログには GPT-5.6 Sol/Terra/Luna の名前空間 ## 2. アカウントログイン(OAuth) -OAuth ログインを使うプロバイダープリセットは 8 つで、これに実験的な非公式デバイスフロー +OAuth ログインを使うプロバイダープリセットは 10 個で、これに実験的な非公式デバイスフロー ブリッジ経由の GitHub Copilot が加わります。認証情報は `~/.opencodex/auth.json` に保存され、 自動更新されます。ログイン CLI は `chatgpt` も受け付けます。このコマンドは ChatGPT 認証情報を 発行し `forward` モードのプロバイダーエントリを作成します。 @@ -96,6 +96,8 @@ ocx login kimi # Moonshot Kimi ocx login nous # Nous Portal (デバイスグラント; 無料 + 有料モデル) ocx login kiro # kiro-cli 認証情報の取り込み(トークンフォールバック対応) ocx login google-antigravity +ocx login gemini-cli # Gemini OAuth (Google アカウント) — Code Assist サブタイプ +ocx login gemini-ai-studio # Gemini OAuth (Google アカウント) — AI Studio サブタイプ ocx login cursor # Cursor 専用 PKCE ログイン ocx login command-code # Command Code のブラウザ OAuth (または ~/.commandcode/auth.json を取り込み) ocx login github-copilot # GitHub デバイスフロー → Copilot トークン (Copilot Pro/Business) @@ -111,6 +113,8 @@ ocx logout | `nous` | `openai-chat` | `https://inference-api.nousresearch.com/v1` | Nous Research サブスクリプションゲートウェイ(Hermes Agent と同じバックエンド)。`portal.nousresearch.com` へのデバイスグラントログイン; access トークンはリクエストごとの inference JWT。有料 + `:free` モデルの混在カタログ(`tencent/hy3:free`、`stepfun/step-3.7-flash:free` など)はサインイン中のアカウントからライブ探索されます。Refresh トークンは単回使用で、更新のたびにローテーションされます。 | | `kiro` | `kiro` | `https://runtime.us-east-1.kiro.dev` | 初回ログインは、インストール済みでサインインした `kiro-cli` セッションを取り込みます(Unix では `curl -fsSL https://cli.kiro.dev/install` | `bash`、Windows PowerShell では `irm 'https://cli.kiro.dev/install.ps1'` | `iex` でインストールしてから `kiro-cli login` を実行)。**アカウントを追加**は `kiro-cli` をログアウトして新しいブラウザログインを開始し、`kiro-cli` 自体のアカウントを切り替えてアカウント別プロファイルメタデータを保存します。既存の OpenCodex アカウントは保持され、キャンセルまたは失敗時には以前の `kiro-cli` セッションが復元されます。 | | `google-antigravity` | `google` | `https://daily-cloudcode-pa.googleapis.com` | Google OAuth を Cloud Code Assist wire で使用。ライブ探索は認証済みの CCA `v1internal:fetchAvailableModels` エンドポイントを使用し、ログイン中のアカウントで利用可能な agent モデルのみを公開します。管理されたカタログはフォールバックとして残ります。 | +| `gemini-cli` | `google` | `https://cloudcode-pa.googleapis.com` | Gemini OAuth (Google アカウント)、**Code Assist** サブタイプ。Google One AI Pro/Ultra プランで利用できます。ホストは Antigravity と同じですが、クライアントファミリーが異なります — 下記の「OAuth ログイン (Gemini)」を参照。 | +| `gemini-ai-studio` | `google` | `https://generativelanguage.googleapis.com` | Gemini OAuth (Google アカウント)、**AI Studio** サブタイプ: API キーではなく bearer トークンで Generative Language API を使用します。自分で登録した OAuth クライアントが必要です。 | | `cursor` | `cursor` | `https://api2.cursor.sh` | 実験的 PKCE ログイン、HTTP/2 トランスポート、アカウント別モデル探索をサポート。 | | `github-copilot` | `openai-chat` | `https://api.githubcopilot.com` | 実験的。GitHub デバイスフロー + `copilot_internal` 交換(VS Code OAuth クライアント)。有効な Copilot サブスクリプションが必要で、公式のサードパーティ API ではありません。 | @@ -133,6 +137,65 @@ Providers ページでアカウントを追加し、別アカウントをログ `chatgpt` は Codex アカウントプールに別の保存場所があり、常に単一スロットのみ書き込みます。トークンは `~/.opencodex/auth.json` に保存され、 `/api/oauth/accounts` はマスク済みメタデータのみを返します。 +### OAuth ログイン (Gemini) + +Google アカウントで認可し、**OAuth サブタイプ**を選びます。Google は 2 つを異なる OAuth クライアント +とスコープの背後に置いているため、各サブタイプは独自のアカウント集合を持つ別々のプロバイダーです +— 一方に発行された資格情報はもう一方では受け付けられません。 + +| サブタイプ | プロバイダー id | 内容 | 選ぶ基準 | +| --- | --- | --- | --- | +| **Code Assist** | `gemini-cli` | Google 公式 Gemini CLI が使うバックエンドである Cloud Code Assist。ログイン時に Code Assist プロジェクトを検出(必要なら onboard)し、以降すべてのリクエストに添えて送ります。 | 既定。Google One AI Pro / Ultra プランを含む通常の Google アカウント。 | +| **AI Studio** | `gemini-ai-studio` | `x-goog-api-key` ではなく bearer トークンで到達する Generative Language API。 | 自分の Google OAuth クライアントを登録済みで、API キーではなく OAuth を使いたい場合。 | + +**ダッシュボードから。** **Providers → プロバイダーを追加 → Accounts** を開きます。*Gemini (Code +Assist)* と *Gemini (AI Studio)* の 2 行が、それぞれサブタイプのラベル付きで表示されます。使いたい +行をクリックし、開いたブラウザーで Google の同意画面を完了すると、その行はログインしたアカウントの +メールアドレスに変わります。同じ行の**アカウントを追加**を使うと、最初のアカウントをログアウトさせ +ずに 2 つ目の Google アカウントを認可できます。 + +**CLI から。** + +```bash +ocx login gemini-cli # Code Assist サブタイプ +ocx login gemini-ai-studio # AI Studio サブタイプ +ocx logout gemini-cli +``` + +ログインはブラウザーを開き、`http://127.0.0.1:51122/callback` で待ち受けます。ブラウザーがループ +バックのリスナーに到達できない場合は、リダイレクト URL(または `code` のみ)をプロンプトに貼り +付けてください。 + +**Code Assist のプロジェクト検出。** トークン交換のあと、Code Assist サブタイプは `loadCodeAssist` +を呼び、プロジェクトがまだ無いアカウントでは `onboardUser` も呼びます。検出したプロジェクト id は +資格情報とともに保存され、更新時に再確認されます。プロジェクトを検出できない場合は、すべての +リクエストが拒否される資格情報を保存する代わりに**ログインを失敗させます** — さもないとアカウント +は「ログイン済み」に見えるのに何も動きません。その Google アカウントに Gemini Code Assist の +アクセス権があることを確認してから再試行してください。 + +**AI Studio には自分の OAuth クライアントが必要です。** Google 内蔵の Gemini CLI クライアントは +generative-language スコープ向けに登録されていないため、自分の Google Cloud プロジェクトの +クライアント資格情報(OAuth クライアントの種類は *デスクトップ アプリ*、承認済みリダイレクト URI に +`http://127.0.0.1:51122/callback`)を渡すまで、このサブタイプは実行可能なメッセージとともに +fail closed します: + +```bash +export GEMINI_AI_STUDIO_OAUTH_CLIENT_ID=".apps.googleusercontent.com" +export GEMINI_AI_STUDIO_OAUTH_CLIENT_SECRET="" +ocx login gemini-ai-studio +``` + +Code Assist サブタイプに設定は不要です。Google が Gemini CLI に同梱している公開クライアント識別子を +使います。自分のクライアントを使いたい場合は `GEMINI_CLI_OAUTH_CLIENT_ID` / +`GEMINI_CLI_OAUTH_CLIENT_SECRET` で上書きできます。 + +:::caution[利用規約] +Code Assist サブタイプは、CLI 自体ではなくプロキシから Google のファーストパーティ Gemini CLI +クライアント識別子を提示します。このページの他の非公式ブリッジと同様、Google の規約に抵触する +可能性があり、不正利用検知によってアクセスが停止されることがあります。AI Studio サブタイプに +このリスクはありません — 自分で登録したクライアントを認可するためです。 +::: + ### Cockpit Tools Antigravity のインポート v1 で OpenCodex がインポートできるのは、`google-antigravity` プロバイダー向けの **Cockpit Tools Antigravity** JSON エクスポートのみです。Providers ダッシュボードでそのプロバイダーの Accounts タブを開き、ローカル JSON ファイルを選択します。ダッシュボードはファイル内容や認証情報の値を表示せず、インポート、更新、失敗、未対応の件数だけを表示します。他の Cockpit プロバイダーは v1 では未対応です。 diff --git a/docs-site/src/content/docs/ko/guides/providers.md b/docs-site/src/content/docs/ko/guides/providers.md index 4f57dab7cc..78e1453d3c 100644 --- a/docs-site/src/content/docs/ko/guides/providers.md +++ b/docs-site/src/content/docs/ko/guides/providers.md @@ -51,7 +51,7 @@ shipped v1 config는 marker 2의 단일 옵션 행으로 자동 이관됩니다. | --- | --- | --- | | `key` | API 키를 전송합니다(`Authorization: Bearer …`, 또는 어댑터에 따라 `x-api-key` / `api-key`). 키는 리터럴이거나 `${ENV_VAR}` 참조일 수 있습니다. | 대부분의 프로바이더. | | `forward` | **수신된 Codex 인증 헤더를** 프로바이더에 그대로 중계합니다 — 키를 저장하지 않습니다. ChatGPT 로그인 패스스루입니다. | OpenAI (`openai-responses` 어댑터). | -| `oauth` | 저장된 OAuth 액세스 토큰을 불러와 bearer 키로 사용하며, 만료 전에 자동 갱신합니다. | xAI, Anthropic, Kimi, Kiro, Google Antigravity, Cursor, Command Code, GitHub Copilot, Nous Portal. | +| `oauth` | 저장된 OAuth 액세스 토큰을 불러와 bearer 키로 사용하며, 만료 전에 자동 갱신합니다. | xAI, Anthropic, Kimi, Kiro, Google Antigravity, Gemini, Cursor, Command Code, GitHub Copilot, Nous Portal. | [`retryOn429`](/ko/reference/configuration/)(동일 키 429 재시도)는 API 키 프로바이더 (`authMode: "key"`)에만 적용됩니다. OAuth·forward·로컬 프리셋은 제외됩니다 — 같은 토큰을 @@ -83,7 +83,7 @@ ChatGPT 패스스루 카탈로그에는 GPT-5.6 Sol/Terra/Luna의 네임스페 ## 2. 계정 로그인 (OAuth) -OAuth 로그인을 사용하는 프로바이더 프리셋은 여덟 개이며, 여기에 실험적 비공식 디바이스 플로우 +OAuth 로그인을 사용하는 프로바이더 프리셋은 열 개이며, 여기에 실험적 비공식 디바이스 플로우 브리지를 쓰는 GitHub Copilot이 추가됩니다. 자격 증명은 `~/.opencodex/auth.json`에 저장되고 자동으로 갱신됩니다. 로그인 CLI는 `chatgpt`도 받습니다. 이 명령은 ChatGPT 자격 증명을 발급받고 `forward` 모드 프로바이더 항목을 만듭니다. @@ -95,6 +95,8 @@ ocx login kimi # Moonshot Kimi ocx login nous # Nous Portal (디바이스 그랜트; 무료 + 유료 모델) ocx login kiro # kiro-cli 자격 증명 가져오기(토큰 폴백 지원) ocx login google-antigravity +ocx login gemini-cli # Gemini OAuth (Google 계정) — Code Assist 서브타입 +ocx login gemini-ai-studio # Gemini OAuth (Google 계정) — AI Studio 서브타입 ocx login cursor # Cursor 전용 PKCE 로그인 ocx login command-code # Command Code 브라우저 OAuth (또는 ~/.commandcode/auth.json 가져오기) ocx login github-copilot # GitHub 디바이스 플로우 → Copilot 토큰 (Copilot Pro/Business) @@ -110,6 +112,8 @@ ocx logout | `nous` | `openai-chat` | `https://inference-api.nousresearch.com/v1` | Nous Research 구독 게이트웨이(Hermes Agent와 동일한 백엔드). `portal.nousresearch.com`에 대한 디바이스 그랜트 로그인; access 토큰은 요청별 inference JWT. 유료 + `:free` 모델 혼합 카탈로그(`tencent/hy3:free`, `stepfun/step-3.7-flash:free` 등)는 로그인한 계정에서 실시간으로 발견됩니다. Refresh 토큰은 단회 사용이며, 갱신할 때마다 회전됩니다. | | `kiro` | `kiro` | `https://runtime.us-east-1.kiro.dev` | 최초 로그인은 설치하고 로그인한 `kiro-cli` 세션을 가져옵니다(Unix에서는 `curl -fsSL https://cli.kiro.dev/install` | `bash`, Windows PowerShell에서는 `irm 'https://cli.kiro.dev/install.ps1'` | `iex`로 설치한 뒤 `kiro-cli login` 실행). **계정 추가**는 `kiro-cli`에서 로그아웃한 뒤 새 브라우저 로그인을 시작하여 `kiro-cli` 자체의 계정을 전환하고, 계정별 프로필 메타데이터를 저장합니다. 기존 OpenCodex 계정은 유지되며, 취소되거나 실패하면 이전 `kiro-cli` 세션을 복원합니다. | | `google-antigravity` | `google` | `https://daily-cloudcode-pa.googleapis.com` | Google OAuth를 Cloud Code Assist wire로 사용합니다. 실시간 탐색은 인증된 CCA `v1internal:fetchAvailableModels` 엔드포인트를 사용하며 로그인한 계정에서 사용할 수 있는 agent 모델만 게시합니다. 유지 관리되는 카탈로그는 폴백으로 남습니다. | +| `gemini-cli` | `google` | `https://cloudcode-pa.googleapis.com` | Gemini OAuth (Google 계정), **Code Assist** 서브타입. Google One AI Pro/Ultra 플랜에서 작동합니다. 호스트는 Antigravity와 같지만 클라이언트 계열이 다릅니다 — 아래의 「OAuth 로그인 (Gemini)」 참조. | +| `gemini-ai-studio` | `google` | `https://generativelanguage.googleapis.com` | Gemini OAuth (Google 계정), **AI Studio** 서브타입: API 키 대신 bearer 토큰으로 Generative Language API를 사용합니다. 직접 등록한 OAuth 클라이언트가 필요합니다. | | `cursor` | `cursor` | `https://api2.cursor.sh` | 실험적 PKCE 로그인, HTTP/2 전송, 계정별 모델 탐색을 지원합니다. | | `github-copilot` | `openai-chat` | `https://api.githubcopilot.com` | 실험적. GitHub 디바이스 플로우 + `copilot_internal` 교환(VS Code OAuth 클라이언트). 활성 Copilot 구독 필요; 공식 서드파티 API가 아닙니다. | @@ -132,6 +136,63 @@ Providers 페이지에서 계정을 추가하고, 다른 계정을 로그아웃 `chatgpt`는 Codex 계정 풀에 별도 저장소가 있어 항상 단일 슬롯만 씁니다. 토큰은 `~/.opencodex/auth.json`에 저장되고, `/api/oauth/accounts`는 마스킹된 메타데이터만 반환합니다. +### OAuth 로그인 (Gemini) + +Google 계정으로 인증하고 **OAuth 서브타입**을 선택합니다. Google이 두 서브타입을 서로 다른 OAuth +클라이언트와 스코프 뒤에 두기 때문에, 각 서브타입은 자체 계정 집합을 가진 별개의 프로바이더입니다 +— 한쪽에 발급된 자격 증명은 다른 쪽에서 받아들여지지 않습니다. + +| 서브타입 | 프로바이더 id | 설명 | 선택 기준 | +| --- | --- | --- | --- | +| **Code Assist** | `gemini-cli` | Google 공식 Gemini CLI가 사용하는 백엔드인 Cloud Code Assist. 로그인 시 Code Assist 프로젝트를 발견하고(필요하면 onboard) 이후 모든 요청에 함께 보냅니다. | 기본값. Google One AI Pro / Ultra 플랜을 포함한 일반 Google 계정. | +| **AI Studio** | `gemini-ai-studio` | `x-goog-api-key` 대신 bearer 토큰으로 접근하는 Generative Language API. | 자체 Google OAuth 클라이언트를 등록했고 API 키 대신 OAuth를 쓰고 싶은 경우. | + +**대시보드에서.** **Providers → 프로바이더 추가 → Accounts** 를 엽니다. *Gemini (Code Assist)* 와 +*Gemini (AI Studio)* 두 행이 각각 서브타입 라벨과 함께 나타납니다. 원하는 행을 클릭하고 열린 +브라우저에서 Google 동의 화면을 완료하면, 그 행이 로그인한 계정의 이메일로 바뀝니다. 같은 행의 +**계정 추가** 는 첫 번째 계정을 로그아웃시키지 않고 두 번째 Google 계정을 인증합니다. + +**CLI에서.** + +```bash +ocx login gemini-cli # Code Assist 서브타입 +ocx login gemini-ai-studio # AI Studio 서브타입 +ocx logout gemini-cli +``` + +로그인은 브라우저를 열고 `http://127.0.0.1:51122/callback` 에서 대기합니다. 브라우저가 루프백 +리스너에 도달할 수 없으면 리디렉션 URL(또는 `code` 값만)을 프롬프트에 붙여 넣으세요. + +**Code Assist 프로젝트 발견.** 토큰 교환 후 Code Assist 서브타입은 `loadCodeAssist` 를 호출하고, +프로젝트가 아직 없는 계정이라면 `onboardUser` 도 호출합니다. 발견한 프로젝트 id는 자격 증명과 함께 +저장되고 갱신 시 다시 확인됩니다. 프로젝트를 발견할 수 없으면, 모든 요청이 거부될 자격 증명을 +저장하는 대신 **로그인이 실패합니다** — 그렇지 않으면 계정이 "로그인됨"으로 보이지만 아무것도 +동작하지 않습니다. 해당 Google 계정에 Gemini Code Assist 접근 권한이 있는지 확인한 뒤 다시 +시도하세요. + +**AI Studio는 자체 OAuth 클라이언트가 필요합니다.** Google에 내장된 Gemini CLI 클라이언트는 +generative-language 스코프로 등록되어 있지 않으므로, 본인의 Google Cloud 프로젝트에서 발급한 +클라이언트 자격 증명(OAuth 클라이언트 유형 *데스크톱 앱*, 승인된 리디렉션 URI에 +`http://127.0.0.1:51122/callback`)을 제공하기 전까지 이 서브타입은 조치 가능한 메시지와 함께 +fail closed 합니다: + +```bash +export GEMINI_AI_STUDIO_OAUTH_CLIENT_ID=".apps.googleusercontent.com" +export GEMINI_AI_STUDIO_OAUTH_CLIENT_SECRET="" +ocx login gemini-ai-studio +``` + +Code Assist 서브타입은 별도 설정이 필요 없습니다. Google이 Gemini CLI에 함께 배포하는 공개 클라이언트 +식별자를 사용합니다. 직접 등록한 클라이언트를 쓰려면 `GEMINI_CLI_OAUTH_CLIENT_ID` / +`GEMINI_CLI_OAUTH_CLIENT_SECRET` 로 덮어쓸 수 있습니다. + +:::caution[서비스 약관] +Code Assist 서브타입은 CLI 자체가 아니라 프록시에서 Google의 퍼스트파티 Gemini CLI 클라이언트 +식별자를 제시합니다. 이 페이지의 다른 비공식 브리지와 마찬가지로 Google 약관과 충돌할 수 있고, +남용 탐지로 접근이 정지될 수 있습니다. AI Studio 서브타입에는 이 위험이 없습니다 — 직접 등록한 +클라이언트를 인증하기 때문입니다. +::: + ### Cockpit Tools Antigravity 가져오기 v1에서 OpenCodex는 `google-antigravity` 공급자의 **Cockpit Tools Antigravity** JSON 내보내기만 가져옵니다. Providers 대시보드에서 해당 공급자의 Accounts 탭을 열고 로컬 JSON 파일을 선택하세요. 대시보드는 파일 내용이나 자격 증명 값을 표시하지 않으며 가져온, 업데이트된, 실패한, 지원되지 않는 항목의 수만 보고합니다. 다른 Cockpit 공급자는 v1에서 지원되지 않습니다. diff --git a/docs-site/src/content/docs/reference/adapters.md b/docs-site/src/content/docs/reference/adapters.md index 0895ad07e8..58abdbf113 100644 --- a/docs-site/src/content/docs/reference/adapters.md +++ b/docs-site/src/content/docs/reference/adapters.md @@ -106,10 +106,25 @@ of the HTTP retry loop. ## `google` -**Targets:** Google **Gemini**, **Vertex AI**, and Antigravity **Cloud Code Assist**. AI Studio uses +**Targets:** Google **Gemini**, **Vertex AI**, and **Cloud Code Assist** (both the Antigravity IDE +and the Gemini CLI client families). AI Studio uses `/v1beta/models/{model}:streamGenerateContent`; the other modes use their native Google endpoints. -**Auth:** API key, Vertex ADC, or Google Antigravity OAuth, selected by `googleMode`. - +**Auth:** API key, Vertex ADC, or Google OAuth, selected by `googleMode`. + +| `googleMode` | Endpoint | Credential | +| --- | --- | --- | +| `ai-studio` (default) | `generativelanguage.googleapis.com` | API key in `x-goog-api-key`, or an OAuth bearer when `authMode` is `oauth` (the `gemini-ai-studio` preset). | +| `vertex` | Vertex AI project/location endpoints | GCP ADC, or `x-goog-api-key`. | +| `cloud-code-assist` | `cloudcode-pa.googleapis.com/v1internal:{action}` | Antigravity OAuth. | +| `gemini-cli` | `cloudcode-pa.googleapis.com/v1internal:{action}` | Gemini CLI OAuth (the `gemini-cli` preset). | + +- The two Cloud Code Assist modes share the `v1internal` endpoint, the `response` envelope wrapper, + and the retry/error classification, but they are **not** interchangeable: `cloud-code-assist` + sends the Antigravity IDE envelope (`{model, userAgent, requestType, project, requestId, request}`) + with an `antigravity/ide/` User-Agent, while `gemini-cli` sends the CLI's plain + `{model, project, request}` with a `GeminiCLI/` User-Agent. Each token is issued to one + client family and rejected by the other. Their reasoning-replay caches are namespaced separately + for the same reason. - System prompt → `systemInstruction`; messages → `contents[]` (assistant → `model`); tools → `functionDeclarations`. Data-URL images → `inline_data`. - Tool-call ids are synthesized when Gemini omits them. Vertex and Antigravity preserve and replay diff --git a/docs-site/src/content/docs/ru/guides/providers.md b/docs-site/src/content/docs/ru/guides/providers.md index 1966d9db63..12b69ba944 100644 --- a/docs-site/src/content/docs/ru/guides/providers.md +++ b/docs-site/src/content/docs/ru/guides/providers.md @@ -60,7 +60,7 @@ description: Все способы, которыми opencodex аутентиф | --- | --- | --- | | `key` | Отправляет ваш API-ключ (`Authorization: Bearer …` либо `x-api-key` / `api-key` в зависимости от адаптера). Ключ может быть литералом или ссылкой вида `${ENV_VAR}`. | Большинство провайдеров. | | `forward` | Передаёт провайдеру **входящие заголовки аутентификации Codex** без изменений — ключ не хранится. Это сквозной режим (passthrough) входа через ChatGPT. | OpenAI (адаптер `openai-responses`). | -| `oauth` | Берёт сохранённый OAuth-токен доступа (автоматически обновляется до истечения срока) и использует его как bearer-ключ. | xAI, Anthropic, Kimi, Kiro, Google Antigravity, Cursor, Command Code, GitHub Copilot, Nous Portal. | +| `oauth` | Берёт сохранённый OAuth-токен доступа (автоматически обновляется до истечения срока) и использует его как bearer-ключ. | xAI, Anthropic, Kimi, Kiro, Google Antigravity, Gemini, Cursor, Command Code, GitHub Copilot, Nous Portal. | Повтор при 429 на том же ключе ([`retryOn429`](/ru/reference/configuration/)) применим только к провайдерам с API-ключом (`authMode: "key"`). Пресеты OAuth, forward и local исключены — их @@ -93,7 +93,7 @@ account id, OpenAI beta/originator/session — см. [Адаптеры](/ru/refe ## 2. Вход по аккаунту (OAuth) -Восемь пресетов провайдеров используют вход через OAuth — плюс GitHub Copilot через +Десять пресетов провайдеров используют вход через OAuth — плюс GitHub Copilot через экспериментальный неофициальный мост device flow. opencodex хранит их учётные данные в `~/.opencodex/auth.json` и обновляет их автоматически. CLI входа также принимает `chatgpt`: эта команда получает учётные данные ChatGPT и одновременно создаёт запись провайдера в режиме `forward`. @@ -105,6 +105,8 @@ ocx login kimi # Moonshot Kimi ocx login nous # Nous Portal (device grant; модели free + paid) ocx login kiro # импорт учётных данных kiro-cli (с фолбэком на токен) ocx login google-antigravity +ocx login gemini-cli # Gemini OAuth (аккаунт Google) — подтип Code Assist +ocx login gemini-ai-studio # Gemini OAuth (аккаунт Google) — подтип AI Studio ocx login cursor # отдельный PKCE-вход Cursor ocx login command-code # браузерный OAuth Command Code (или импорт ~/.commandcode/auth.json) ocx login github-copilot # device flow GitHub → токен Copilot (Copilot Pro/Business) @@ -120,6 +122,8 @@ ocx logout | `nous` | `openai-chat` | `https://inference-api.nousresearch.com/v1` | Шлюз подписки Nous Research (тот же бэкенд, что использует Hermes Agent). Вход по device grant против `portal.nousresearch.com`; access-токен — это JWT для каждого запроса к inference. Смешанный каталог платных + `:free` моделей (`tencent/hy3:free`, `stepfun/step-3.7-flash:free`, …) обнаруживается вживую по авторизованному аккаунту. Refresh-токены одноразовые и ротируются при каждом обновлении. | | `kiro` | `kiro` | `https://runtime.us-east-1.kiro.dev` | Первый вход импортирует существующую сессию после установки Kiro CLI (в Unix: `curl -fsSL https://cli.kiro.dev/install` | `bash`; в Windows PowerShell: `irm 'https://cli.kiro.dev/install.ps1'` | `iex`; затем выполните `kiro-cli login`). **Добавить аккаунт** выполняет выход из `kiro-cli`, запускает новый вход через браузер, переключает аккаунт самого `kiro-cli` и сохраняет метаданные профиля отдельно для каждого аккаунта. Существующие аккаунты OpenCodex сохраняются; при отмене или сбое восстанавливается предыдущая сессия `kiro-cli`. | | `google-antigravity` | `google` | `https://daily-cloudcode-pa.googleapis.com` | Google OAuth поверх протокола Cloud Code Assist. Живое обнаружение использует аутентифицированный CCA-эндпоинт `v1internal:fetchAvailableModels` и публикует только agent-модели, доступные текущему аккаунту; поддерживаемый каталог остаётся резервным вариантом. | +| `gemini-cli` | `google` | `https://cloudcode-pa.googleapis.com` | Gemini OAuth (аккаунт Google), подтип **Code Assist**. Работает с планами Google One AI Pro/Ultra. Тот же хост, что у Antigravity, но другое семейство клиентов — см. раздел «Вход OAuth (Gemini)» ниже. | +| `gemini-ai-studio` | `google` | `https://generativelanguage.googleapis.com` | Gemini OAuth (аккаунт Google), подтип **AI Studio**: Generative Language API с bearer-токеном вместо API-ключа. Требуется собственный зарегистрированный OAuth-клиент. | | `cursor` | `cursor` | `https://api2.cursor.sh` | Экспериментальный PKCE-вход, живой транспорт HTTP/2 и обнаружение моделей с фильтрацией по аккаунту. | | `github-copilot` | `openai-chat` | `https://api.githubcopilot.com` | Экспериментально. Device flow GitHub + обмен `copilot_internal` (OAuth-клиент VS Code). Требуется активная подписка Copilot; это не официальный сторонний API. | @@ -144,6 +148,64 @@ OAuth-провайдеры, чьи учётные данные содержат `chatgpt` всегда занимает один слот, поскольку у пула аккаунтов Codex отдельный реестр. Токены остаются в `~/.opencodex/auth.json`; `/api/oauth/accounts` возвращает только маскированные метаданные. +### Вход OAuth (Gemini) + +Авторизуйтесь с аккаунтом Google и выберите **подтип OAuth**. Каждый подтип — отдельный провайдер +со своим набором аккаунтов, потому что Google размещает их за разными OAuth-клиентами и областями +доступа: учётные данные, выпущенные для одного, не принимаются другим. + +| Подтип | Id провайдера | Что это | Когда выбирать | +| --- | --- | --- | --- | +| **Code Assist** | `gemini-cli` | Cloud Code Assist — бэкенд, который использует собственный Gemini CLI от Google. При входе обнаруживается (а при необходимости создаётся) проект Code Assist, который затем отправляется с каждым запросом. | По умолчанию. Обычный аккаунт Google, включая планы Google One AI Pro / Ultra. | +| **AI Studio** | `gemini-ai-studio` | Generative Language API, доступ к которому идёт по bearer-токену вместо `x-goog-api-key`. | Вы зарегистрировали собственный OAuth-клиент Google и хотите OAuth вместо API-ключа. | + +**Из панели.** Откройте **Providers → Добавить провайдера → Accounts**. Появятся две строки — +*Gemini (Code Assist)* и *Gemini (AI Studio)*, каждая с меткой своего подтипа. Нажмите нужную, +завершите экран согласия Google в открывшемся браузере, и строка сменится на адрес электронной +почты вошедшего аккаунта. **Добавить аккаунт** в той же строке авторизует второй аккаунт Google, не +выходя из первого. + +**Из CLI.** + +```bash +ocx login gemini-cli # подтип Code Assist +ocx login gemini-ai-studio # подтип AI Studio +ocx logout gemini-cli +``` + +Вход открывает браузер и слушает `http://127.0.0.1:51122/callback`. Если браузер не может достучаться +до петлевого слушателя, вставьте URL перенаправления (или один `code`) обратно в приглашение. + +**Обнаружение проекта Code Assist.** После обмена токена подтип Code Assist вызывает +`loadCodeAssist`, а для аккаунта без проекта — ещё и `onboardUser`. Найденный id проекта сохраняется +вместе с учётными данными и перепроверяется при обновлении. Если проект обнаружить не удалось, +**вход завершается ошибкой**, а не сохранением учётных данных, каждый запрос по которым отклонялся +бы: иначе аккаунт выглядел бы как «вошедший», хотя ничего не работает. Убедитесь, что у аккаунта +Google действительно есть доступ к Gemini Code Assist, и повторите попытку. + +**Для AI Studio нужен ваш собственный OAuth-клиент.** Встроенный клиент Gemini CLI от Google не +зарегистрирован для областей generative-language, поэтому этот подтип осознанно завершается ошибкой +с понятным сообщением, пока вы не укажете учётные данные клиента из собственного проекта Google +Cloud (тип OAuth-клиента *Десктопное приложение*, с `http://127.0.0.1:51122/callback` в списке +разрешённых URI перенаправления): + +```bash +export GEMINI_AI_STUDIO_OAUTH_CLIENT_ID=".apps.googleusercontent.com" +export GEMINI_AI_STUDIO_OAUTH_CLIENT_SECRET="" +ocx login gemini-ai-studio +``` + +Подтипу Code Assist настройка не нужна: он использует публичные идентификаторы клиента, которые +Google поставляет внутри Gemini CLI. `GEMINI_CLI_OAUTH_CLIENT_ID` / +`GEMINI_CLI_OAUTH_CLIENT_SECRET` переопределяют их, если вы предпочитаете свой клиент. + +:::caution[Условия использования] +Подтип Code Assist предъявляет идентификаторы первого лица — клиента Gemini CLI от Google — из +прокси, а не из самого CLI. Как и другие неофициальные мосты на этой странице, это может +противоречить условиям Google, а система обнаружения злоупотреблений может приостановить доступ. +Подтип AI Studio такого риска не несёт — он авторизует клиента, зарегистрированного вами. +::: + ### Импорт Cockpit Tools Antigravity В v1 OpenCodex импортирует только JSON-экспорт **Cockpit Tools Antigravity** для провайдера `google-antigravity`. На вкладке «Аккаунты» этого провайдера в панели Providers выберите локальный JSON-файл. Панель не показывает содержимое файла или значения учётных данных: она выводит только числа импортированных, обновлённых, ошибочных и неподдерживаемых записей. Другие провайдеры Cockpit в v1 не поддерживаются. diff --git a/docs-site/src/content/docs/tr/guides/providers.md b/docs-site/src/content/docs/tr/guides/providers.md index ee153a0780..68b9f7eda0 100644 --- a/docs-site/src/content/docs/tr/guides/providers.md +++ b/docs-site/src/content/docs/tr/guides/providers.md @@ -71,7 +71,7 @@ etiketler; bunlar normalde hem `authMode` hem de `apiKey`'i atlar. | --- | --- | --- | | `key` | API anahtarınızı gönderir (`Authorization: Bearer …` veya adaptör başına `x-api-key` / `api-key`). Anahtar bir sabit değer veya bir `${ENV_VAR}` başvurusu olabilir. | Çoğu sağlayıcı. | | `forward` | **Gelen Codex kimlik doğrulama başlıklarınızı** birebir sağlayıcıya iletir — hiçbir anahtar saklanmaz. Bu, ChatGPT girişi doğrudan geçişidir. | OpenAI (`openai-responses` adaptörü). | -| `oauth` | Saklanan bir OAuth erişim belirtecini çözer (süresi dolmadan önce otomatik olarak yenilenir) ve bunu taşıyıcı anahtar olarak kullanır. | xAI, Anthropic, Kimi, Kiro, Google Antigravity, Cursor, Command Code, GitHub Copilot, Nous Portal. | +| `oauth` | Saklanan bir OAuth erişim belirtecini çözer (süresi dolmadan önce otomatik olarak yenilenir) ve bunu taşıyıcı anahtar olarak kullanır. | xAI, Anthropic, Kimi, Kiro, Google Antigravity, Gemini, Cursor, Command Code, GitHub Copilot, Nous Portal. | [`retryOn429`](/tr/reference/configuration/) aynı anahtarla 429 yeniden oynatma özelliği yalnızca API anahtarı sağlayıcıları için geçerlidir (`authMode: @@ -107,7 +107,7 @@ GPT-5.6 Sol/Terra/Luna slug'larını (`gpt-5.6-sol`, `gpt-5.6-terra`, ## 2. Hesap girişi (OAuth) -Sekiz sağlayıcı önayarı OAuth girişini kullanır — artı deneysel resmi olmayan +On sağlayıcı önayarı OAuth girişini kullanır — artı deneysel resmi olmayan bir cihaz akışı köprüsü aracılığıyla GitHub Copilot. opencodex bunların kimlik bilgilerini `~/.opencodex/auth.json` içinde saklar ve otomatik olarak yeniler. `chatgpt` ayrıca oturum açma CLI'sı tarafından kabul edilir; bir `forward` modu @@ -120,6 +120,8 @@ ocx login kimi # Moonshot Kimi ocx login nous # Nous Portal (cihaz yetkisi; ücretsiz + ücretli modeller) ocx login kiro # kiro-cli kimlik bilgilerini içe aktarın (veya belirteç geri dönüşü) ocx login google-antigravity +ocx login gemini-cli # Gemini OAuth (Google hesabı) — Code Assist alt türü +ocx login gemini-ai-studio # Gemini OAuth (Google hesabı) — AI Studio alt türü ocx login cursor # bağımsız Cursor PKCE girişi ocx login command-code # Command Code tarayıcı OAuth (veya ~/.commandcode/auth.json içe aktarma) ocx login github-copilot # GitHub cihaz akışı → Copilot belirteci (Copilot Pro/Business) @@ -135,6 +137,8 @@ ocx logout | `nous` | `openai-chat` | `https://inference-api.nousresearch.com/v1` | Nous Research abonelik ağ geçidi (Hermes Agent'ın kullandığı aynı arka uç). `portal.nousresearch.com`'a karşı cihaz yetkilendirmesi girişi; erişim belirteci istek başına çıkarım JWT'sidir. Oturum açmış hesaptan canlı olarak keşfedilen karışık ücretli + `:free` model kataloğu (`tencent/hy3:free`, `stepfun/step-3.7-flash:free`, ...). Yenileme belirteçleri tek kullanımlıktır ve her yenilemede döndürülür. | | `kiro` | `kiro` | `https://runtime.us-east-1.kiro.dev` | İlk oturum açma, kurulu ve oturum açılmış `kiro-cli` oturumunu içe aktarır (Unix'te `curl -fsSL https://cli.kiro.dev/install` | `bash` ile kurun; Windows PowerShell'de `irm 'https://cli.kiro.dev/install.ps1'` | `iex` kullanın; ardından `kiro-cli login` çalıştırın). **Hesap ekle**, `kiro-cli` oturumunu kapatır, `kiro-cli` tarafından kullanılan hesabı değiştiren yeni bir tarayıcı girişi başlatır ve hesap kapsamlı profil meta verilerini saklar. Mevcut OpenCodex hesapları korunur ve iptal veya başarısızlık önceki `kiro-cli` oturumunu geri yükler. | | `google-antigravity` | `google` | `https://daily-cloudcode-pa.googleapis.com` | Cloud Code Assist hattı üzerinden Google OAuth. Canlı keşif CCA'nın kimlik doğrulamalı `v1internal:fetchAvailableModels` uç noktasını kullanır ve oturum açmış hesap için kullanılabilir olan ajan modellerini yayınlar; sürdürülen katalog geri dönüş olarak kalır. | +| `gemini-cli` | `google` | `https://cloudcode-pa.googleapis.com` | Gemini OAuth (Google hesabı), **Code Assist** alt türü. Google One AI Pro/Ultra planlarıyla çalışır. Antigravity ile aynı ana bilgisayar ancak farklı bir istemci ailesi — aşağıdaki “OAuth girişi (Gemini)” bölümüne bakın. | +| `gemini-ai-studio` | `google` | `https://generativelanguage.googleapis.com` | Gemini OAuth (Google hesabı), **AI Studio** alt türü: API anahtarı yerine bearer belirteciyle Generative Language API. Kendi kayıtlı OAuth istemcinizi gerektirir. | | `cursor` | `cursor` | `https://api2.cursor.sh` | Deneysel PKCE girişi, canlı HTTP/2 aktarımı ve hesap filtreli model keşfi. | | `github-copilot` | `openai-chat` | `https://api.githubcopilot.com` | Deneysel. GitHub cihaz akışı + `copilot_internal` değişimi (VS Code OAuth istemcisi). Aktif bir Copilot aboneliği gerektirir; resmi bir üçüncü taraf API değildir. | @@ -165,6 +169,65 @@ anahtarlanır. `chatgpt` her zaman tek yuvalıdır çünkü Codex havuz hesaplar ayrı bir defteri vardır. Belirteçler `~/.opencodex/auth.json` içinde kalır; `/api/oauth/accounts` yalnızca maskelenmiş meta verileri döndürür. +### OAuth girişi (Gemini) + +Bir Google hesabıyla yetkilendirin ve bir **OAuth alt türü** seçin. Google ikisini farklı OAuth +istemcileri ve kapsamları arkasında sunduğu için her alt tür, kendi hesap kümesine sahip ayrı bir +sağlayıcıdır — biri için üretilen kimlik bilgisi diğeri tarafından kabul edilmez. + +| Alt tür | Sağlayıcı id | Nedir | Ne zaman seçilir | +| --- | --- | --- | --- | +| **Code Assist** | `gemini-cli` | Google'ın kendi Gemini CLI'ının kullandığı arka uç olan Cloud Code Assist. Giriş bir Code Assist projesi keşfeder (gerekirse oluşturur) ve bu proje her istekle birlikte gönderilir. | Varsayılan. Google One AI Pro / Ultra planları dahil sıradan bir Google hesabı. | +| **AI Studio** | `gemini-ai-studio` | `x-goog-api-key` yerine bir bearer belirteciyle erişilen Generative Language API. | Kendi Google OAuth istemcinizi kaydettiniz ve API anahtarı yerine OAuth kullanmak istiyorsunuz. | + +**Panelden.** **Providers → Sağlayıcı ekle → Accounts** bölümünü açın. Her biri alt türüyle +etiketlenmiş iki satır görünür: *Gemini (Code Assist)* ve *Gemini (AI Studio)*. İstediğiniz satıra +tıklayın, açılan tarayıcıda Google onay ekranını tamamlayın; satır oturum açan hesabın e-posta +adresine dönüşür. Aynı satırdaki **Hesap ekle**, ilkinden çıkış yapmadan ikinci bir Google hesabını +yetkilendirir. + +**CLI'dan.** + +```bash +ocx login gemini-cli # Code Assist alt türü +ocx login gemini-ai-studio # AI Studio alt türü +ocx logout gemini-cli +``` + +Giriş tarayıcınızı açar ve `http://127.0.0.1:51122/callback` adresini dinler. Tarayıcı geri döngü +dinleyicisine ulaşamazsa yönlendirme URL'sini (veya yalnızca `code` değerini) istemciye geri +yapıştırın. + +**Code Assist proje keşfi.** Belirteç değişiminden sonra Code Assist alt türü `loadCodeAssist` +çağrısını, henüz projesi olmayan bir hesap içinse `onboardUser` çağrısını yapar. Keşfedilen proje id +kimlik bilgisiyle birlikte saklanır ve yenilemede yeniden denetlenir. Hiçbir proje keşfedilemezse, +her isteği reddedilecek bir kimlik bilgisini kaydetmek yerine **giriş başarısız olur** — aksi halde +hesap "giriş yapılmış" görünürken hiçbir şey çalışmaz. Google hesabının gerçekten Gemini Code Assist +erişimine sahip olduğundan emin olup yeniden deneyin. + +**AI Studio kendi OAuth istemcinizi gerektirir.** Google'ın yerleşik Gemini CLI istemcisi +generative-language kapsamları için kayıtlı değildir; bu nedenle kendi Google Cloud projenizden +istemci kimlik bilgileri sağlayana kadar bu alt tür, eyleme dönüştürülebilir bir mesajla kapalı +biçimde başarısız olur (OAuth istemci türü *Masaüstü uygulaması*, yetkili yönlendirme URI'si olarak +`http://127.0.0.1:51122/callback`): + +```bash +export GEMINI_AI_STUDIO_OAUTH_CLIENT_ID=".apps.googleusercontent.com" +export GEMINI_AI_STUDIO_OAUTH_CLIENT_SECRET="" +ocx login gemini-ai-studio +``` + +Code Assist alt türü yapılandırma gerektirmez: Google'ın Gemini CLI içinde dağıttığı genel istemci +tanımlayıcılarını kullanır. Kendi istemcinizi tercih ederseniz `GEMINI_CLI_OAUTH_CLIENT_ID` / +`GEMINI_CLI_OAUTH_CLIENT_SECRET` bunları geçersiz kılar. + +:::caution[Hizmet Şartları] +Code Assist alt türü, Google'ın birinci taraf Gemini CLI istemci tanımlayıcılarını CLI'ın kendisi +yerine bir proxy üzerinden sunar. Bu sayfadaki diğer resmi olmayan köprüler gibi bu da Google'ın +şartlarıyla çelişebilir ve kötüye kullanım tespiti erişimi askıya alabilir. AI Studio alt türü bu +riski taşımaz — kendi kaydettiğiniz bir istemciyi yetkilendirir. +::: + ### Cockpit Tools Antigravity içe aktarma v1 için OpenCodex, yalnızca `google-antigravity` sağlayıcısı için bir **Cockpit diff --git a/docs-site/src/content/docs/zh-cn/guides/providers.md b/docs-site/src/content/docs/zh-cn/guides/providers.md index 4e924458ee..166510bb2a 100644 --- a/docs-site/src/content/docs/zh-cn/guides/providers.md +++ b/docs-site/src/content/docs/zh-cn/guides/providers.md @@ -48,7 +48,7 @@ shipped v1 配置自动迁移到 marker 2 的单一选项行。原配置只保 | --- | --- | --- | | `key` | 发送你的 API 密钥(`Authorization: Bearer …`,或按 adapter 使用 `x-api-key` / `api-key`)。密钥可以是字面值,也可以是 `${ENV_VAR}` 引用。 | 大多数提供商。 | | `forward` | 将**你传入的 Codex 认证请求头**原样转发给提供商——不存储任何密钥。这就是 ChatGPT 登录的透传方式。 | OpenAI(`openai-responses` adapter)。 | -| `oauth` | 读取已存储的 OAuth 访问令牌(过期前自动刷新),并将其用作 bearer 密钥。 | xAI、Anthropic、Kimi、Kiro、Google Antigravity、Cursor、Command Code、GitHub Copilot、Nous Portal。 | +| `oauth` | 读取已存储的 OAuth 访问令牌(过期前自动刷新),并将其用作 bearer 密钥。 | xAI、Anthropic、Kimi、Kiro、Google Antigravity、Gemini、Cursor、Command Code、GitHub Copilot、Nous Portal。 | [`retryOn429`](/zh-cn/reference/configuration/)(同 key 的 429 重试)仅适用于 API-key 提供商 (`authMode: "key"`)。OAuth、forward 与本地预设均被排除——同一 token 绝不可重放,本地运行时 @@ -75,7 +75,7 @@ ChatGPT 透传目录也会加入 GPT-5.6 Sol/Terra/Luna 的裸 slug(`gpt-5.6-s ## 2. 账号登录(OAuth) -有八个提供商预设使用 OAuth 登录,另加通过实验性非官方设备流桥接的 GitHub Copilot。 +有十个提供商预设使用 OAuth 登录,另加通过实验性非官方设备流桥接的 GitHub Copilot。 opencodex 会把凭据存入 `~/.opencodex/auth.json` 并自动刷新。登录 CLI 也接受 `chatgpt`: 它会获取一份 ChatGPT 凭据,并创建一个 `forward` 模式的提供商条目。 @@ -86,6 +86,8 @@ ocx login kimi # Moonshot Kimi ocx login nous # Nous Portal(设备授权;免费 + 付费模型) ocx login kiro # 导入 kiro-cli 凭据(支持令牌回退) ocx login google-antigravity +ocx login gemini-cli # Gemini OAuth(Google 账号)—— Code Assist 子类型 +ocx login gemini-ai-studio # Gemini OAuth(Google 账号)—— AI Studio 子类型 ocx login cursor # 独立的 Cursor PKCE 登录 ocx login command-code # Command Code 浏览器 OAuth(或导入 ~/.commandcode/auth.json) ocx login github-copilot # GitHub 设备流 → Copilot 令牌(Copilot Pro/Business) @@ -101,6 +103,8 @@ ocx logout | `nous` | `openai-chat` | `https://inference-api.nousresearch.com/v1` | Nous Research 订阅网关(与 Hermes Agent 使用同一后端)。通过设备授权登录 `portal.nousresearch.com`;access 令牌是每个请求的 inference JWT。付费 + `:free` 模型混合目录(`tencent/hy3:free`、`stepfun/step-3.7-flash:free` 等)会从已登录账户实时发现。Refresh 令牌是单次使用,每次刷新都会轮换。 | | `kiro` | `kiro` | `https://runtime.us-east-1.kiro.dev` | 首次登录会导入已安装并已登录的 Kiro CLI 会话(Unix 使用 `curl -fsSL https://cli.kiro.dev/install` | `bash`;Windows PowerShell 使用 `irm 'https://cli.kiro.dev/install.ps1'` | `iex`;然后运行 `kiro-cli login`)。**添加账户**会先退出 `kiro-cli`,再启动新的浏览器登录,从而切换 `kiro-cli` 自身使用的账户,并保存账户范围的配置文件元数据。现有 OpenCodex 账户会保留;如果取消或失败,则恢复之前的 `kiro-cli` 会话。 | | `google-antigravity` | `google` | `https://daily-cloudcode-pa.googleapis.com` | 通过 Cloud Code Assist 协议使用 Google OAuth。实时发现调用已认证的 CCA `v1internal:fetchAvailableModels` 端点,并仅发布当前登录账户可用的 agent 模型;维护中的目录仍作为回退。 | +| `gemini-cli` | `google` | `https://cloudcode-pa.googleapis.com` | Gemini OAuth(Google 账号),**Code Assist** 子类型。适用于 Google One AI Pro/Ultra 套餐。与 Antigravity 使用同一主机,但属于不同的客户端族——参见下方「OAuth 授权(Gemini)」。 | +| `gemini-ai-studio` | `google` | `https://generativelanguage.googleapis.com` | Gemini OAuth(Google 账号),**AI Studio** 子类型:使用 bearer 令牌而非 API 密钥访问 Generative Language API。需自备已注册的 OAuth 客户端。 | | `cursor` | `cursor` | `https://api2.cursor.sh` | 实验性 PKCE 登录、带可选 HTTP/1.1 兼容路径的 HTTP/2 传输,以及按账号筛选的模型发现。 | | `github-copilot` | `openai-chat` | `https://api.githubcopilot.com` | 实验性。GitHub 设备流 + `copilot_internal` 交换(VS Code OAuth 客户端)。需要有效的 Copilot 订阅;不是官方第三方 API。 | @@ -122,6 +126,56 @@ OAuth 凭据中带有稳定账号 id 或邮箱的提供商可以保存多个登 `chatgpt` 始终只有一个 slot,因为 Codex 账号池使用独立存储。令牌仍保存在 `~/.opencodex/auth.json` 中;`/api/oauth/accounts` 只返回脱敏后的 metadata。 +### OAuth 授权(Gemini) + +使用 Google 账号授权,并选择 **OAuth 子类型**。两个子类型各自是独立的提供商、拥有各自的账号集合, +因为 Google 通过不同的 OAuth 客户端与作用域来区分它们——为其中一个签发的凭据不会被另一个接受。 + +| 子类型 | 提供商 id | 说明 | 何时选择 | +| --- | --- | --- | --- | +| **Code Assist** | `gemini-cli` | Cloud Code Assist,即 Google 官方 Gemini CLI 使用的后端。登录时会发现(必要时自动 onboard)一个 Code Assist 项目,之后每个请求都会带上它。 | 默认。普通 Google 账号,包括 Google One AI Pro / Ultra 套餐。 | +| **AI Studio** | `gemini-ai-studio` | 使用 bearer 令牌而非 `x-goog-api-key` 访问 Generative Language API。 | 你已注册自己的 Google OAuth 客户端,并希望用 OAuth 而不是 API 密钥。 | + +**在仪表板中操作。** 打开 **Providers → 添加提供方 → Accounts**。会看到两行——*Gemini (Code Assist)* +和 *Gemini (AI Studio)*,各自标注了所属子类型。点击需要的一行,在打开的浏览器中完成 Google 授权, +该行随即会显示已登录账号的邮箱。同一行上的**添加账号**可以在不登出第一个账号的前提下授权第二个 +Google 账号。 + +**在 CLI 中操作。** + +```bash +ocx login gemini-cli # Code Assist 子类型 +ocx login gemini-ai-studio # AI Studio 子类型 +ocx logout gemini-cli +``` + +登录会打开浏览器,并在 `http://127.0.0.1:51122/callback` 上监听。如果浏览器无法访问该回环监听端口, +把重定向 URL(或纯 `code`)粘回提示符即可。 + +**Code Assist 的项目发现。** 令牌交换完成后,Code Assist 子类型会调用 `loadCodeAssist`;若账号尚无 +项目,则再调用 `onboardUser`。发现到的项目 id 会随凭据一起保存,并在刷新时重新校验。如果无法发现 +任何项目,**登录会直接失败**,而不是保存一份每个请求都会被拒绝的凭据——否则账号会显示为「已登录」 +但实际全部不可用。请确认该 Google 账号确实具备 Gemini Code Assist 权限后重试。 + +**AI Studio 需要自备 OAuth 客户端。** Google 内置的 Gemini CLI 客户端并未注册 generative-language +作用域,因此在你提供自己 Google Cloud 项目中的客户端凭据之前,该子类型会带着可操作的提示直接失败 +(OAuth 客户端类型选 *桌面应用*,并把 `http://127.0.0.1:51122/callback` 加为已授权重定向 URI): + +```bash +export GEMINI_AI_STUDIO_OAUTH_CLIENT_ID=".apps.googleusercontent.com" +export GEMINI_AI_STUDIO_OAUTH_CLIENT_SECRET="" +ocx login gemini-ai-studio +``` + +Code Assist 子类型无需任何配置:它使用 Google 随 Gemini CLI 一起分发的公开客户端标识。若你希望改用 +自己的客户端,可通过 `GEMINI_CLI_OAUTH_CLIENT_ID` / `GEMINI_CLI_OAUTH_CLIENT_SECRET` 覆盖。 + +:::caution[服务条款] +Code Assist 子类型是从代理侧、而非 CLI 本身出示 Google 第一方 Gemini CLI 的客户端标识。与本页其他 +非官方桥接一样,这可能与 Google 的条款冲突,滥用检测也可能导致账号被停用。AI Studio 子类型不存在 +这一风险——它授权的是你自己注册的客户端。 +::: + ### Cockpit Tools Antigravity 导入 v1 中 OpenCodex 仅支持为 `google-antigravity` 提供商导入 **Cockpit Tools Antigravity** JSON 导出文件。在 Providers 仪表板中打开该提供商的 Accounts 标签并选择本地 JSON 文件。仪表板不会显示文件内容或凭据值,只报告已导入、已更新、失败和不支持的数量。v1 会拒绝其他 Cockpit 提供商的导入。 diff --git a/docs-site/src/content/docs/zh-tw/guides/providers.md b/docs-site/src/content/docs/zh-tw/guides/providers.md index ee7d709880..4dab601090 100644 --- a/docs-site/src/content/docs/zh-tw/guides/providers.md +++ b/docs-site/src/content/docs/zh-tw/guides/providers.md @@ -52,7 +52,7 @@ preset 通常同時省略 `authMode` 與 `apiKey`。 | --- | --- | --- | | `key` | 傳送 API 金鑰(`Authorization: Bearer …`,或依 adapter 使用 `x-api-key` / `api-key`)。金鑰可以是字面值,也可以是 `${ENV_VAR}` 引用。 | 大多數供應商。 | | `forward` | 只轉送允許清單中的 incoming Codex 認證標頭,不儲存任何金鑰。這是 ChatGPT 登入的 passthrough。 | OpenAI(`openai-responses` adapter)。 | -| `oauth` | 讀取已儲存的 OAuth access token(到期前自動 refresh),並把它當成 bearer key 使用。 | xAI、Anthropic、Kimi、Kiro、Google Antigravity、Cursor、Command Code、GitHub Copilot、Nous Portal。 | +| `oauth` | 讀取已儲存的 OAuth access token(到期前自動 refresh),並把它當成 bearer key 使用。 | xAI、Anthropic、Kimi、Kiro、Google Antigravity、Gemini、Cursor、Command Code、GitHub Copilot、Nous Portal。 | [`retryOn429`](/zh-tw/reference/configuration/) 的 same-key 429 replay 只適用於 API-key provider (`authMode: "key"`)。OAuth、forward 與 local preset 都被排除:它們的 credential 絕不能在同一 token @@ -83,7 +83,7 @@ ChatGPT passthrough catalog 也會加入 GPT-5.6 Sol/Terra/Luna 的裸 slug:`g ## 2. 帳號登入(OAuth) -有八個 provider preset 使用 OAuth 登入,另加透過實驗性非官方 device-flow bridge 的 GitHub Copilot。 +有十個 provider preset 使用 OAuth 登入,另加透過實驗性非官方 device-flow bridge 的 GitHub Copilot。 opencodex 會把 credential 存在 `~/.opencodex/auth.json` 並自動 refresh。登入 CLI 也接受 `chatgpt`; 它會取得 ChatGPT credential,同時建立 `forward` 模式的 provider 條目。 @@ -94,6 +94,8 @@ ocx login kimi # Moonshot Kimi ocx login nous # Nous Portal(device grant;免費 + 付費模型) ocx login kiro # 匯入 kiro-cli credential(或 token fallback) ocx login google-antigravity +ocx login gemini-cli # Gemini OAuth(Google 帳號)— Code Assist 子類型 +ocx login gemini-ai-studio # Gemini OAuth(Google 帳號)— AI Studio 子類型 ocx login cursor # 獨立 Cursor PKCE 登入 ocx login command-code # Command Code browser OAuth(或匯入 ~/.commandcode/auth.json) ocx login github-copilot # GitHub device flow → Copilot token(Copilot Pro/Business) @@ -109,6 +111,8 @@ ocx logout | `nous` | `openai-chat` | `https://inference-api.nousresearch.com/v1` | Nous Research 訂閱 gateway(Hermes Agent 使用相同 backend)。透過 `portal.nousresearch.com` 做 device-grant 登入;access token 是每次請求使用的 inference JWT。混合付費與 `:free` 模型 catalog(`tencent/hy3:free`、`stepfun/step-3.7-flash:free` 等)會從已登入帳號即時探索。Refresh token 為單次使用,每次 refresh 都會輪換。 | | `kiro` | `kiro` | `https://runtime.us-east-1.kiro.dev` | 初次登入會匯入已安裝且已登入的 `kiro-cli` session。Unix 可用 `curl -fsSL https://cli.kiro.dev/install` | `bash` 安裝;Windows PowerShell 使用 `irm 'https://cli.kiro.dev/install.ps1'` | `iex`,再執行 `kiro-cli login`。**Add account** 會先登出 `kiro-cli`、啟動新的 browser login,切換 `kiro-cli` 所使用的帳號並保存 account-scoped profile metadata。既有 OpenCodex 帳號會保留;取消或失敗時會恢復先前的 `kiro-cli` session。 | | `google-antigravity` | `google` | `https://daily-cloudcode-pa.googleapis.com` | 透過 Cloud Code Assist wire 使用 Google OAuth。即時探索使用 CCA 經認證的 `v1internal:fetchAvailableModels` 端點,發布目前登入帳號可用的 agent 模型;維護中的 catalog 作為 fallback。 | +| `gemini-cli` | `google` | `https://cloudcode-pa.googleapis.com` | Gemini OAuth(Google 帳號),**Code Assist** 子類型。可搭配 Google One AI Pro/Ultra 方案。與 Antigravity 同一 host,但屬於不同的 client family — 參見下方「OAuth 登入(Gemini)」。 | +| `gemini-ai-studio` | `google` | `https://generativelanguage.googleapis.com` | Gemini OAuth(Google 帳號),**AI Studio** 子類型:以 bearer token 而非 API key 存取 Generative Language API。需要你自行註冊的 OAuth client。 | | `cursor` | `cursor` | `https://api2.cursor.sh` | 實驗性 PKCE 登入、即時 HTTP/2 transport 與按帳號篩選的模型探索。 | | `github-copilot` | `openai-chat` | `https://api.githubcopilot.com` | 實驗性。GitHub device flow + `copilot_internal` exchange(VS Code OAuth client)。需要有效 Copilot 訂閱;不是官方第三方 API。 | @@ -130,6 +134,57 @@ profile ARN 作為 key。`chatgpt` 始終是 single-slot,因為 Codex pool 帳號使用獨立 ledger。Token 仍存放在 `~/.opencodex/auth.json`;`/api/oauth/accounts` 只回傳 遮蔽後的 metadata。 +### OAuth 登入(Gemini) + +使用 Google 帳號授權,並選擇 **OAuth 子類型**。兩個子類型各自是獨立的 provider、擁有各自的帳號集合, +因為 Google 以不同的 OAuth client 與 scope 區隔它們——為其中一個簽發的 credential 不會被另一個接受。 + +| 子類型 | Provider id | 說明 | 何時選擇 | +| --- | --- | --- | --- | +| **Code Assist** | `gemini-cli` | Cloud Code Assist,也就是 Google 官方 Gemini CLI 使用的後端。登入時會探索(必要時自動 onboard)一個 Code Assist 專案,之後每個請求都會帶上它。 | 預設。一般 Google 帳號,包含 Google One AI Pro / Ultra 方案。 | +| **AI Studio** | `gemini-ai-studio` | 以 bearer token 而非 `x-goog-api-key` 存取 Generative Language API。 | 你已註冊自己的 Google OAuth client,且想用 OAuth 而不是 API key。 | + +**從儀表板操作。** 開啟 **Providers → 新增 provider → Accounts**。會看到兩列——*Gemini (Code +Assist)* 與 *Gemini (AI Studio)*,各自標註所屬子類型。點擊需要的那一列,在開啟的瀏覽器中完成 Google +同意畫面,該列隨即顯示已登入帳號的 email。同一列上的**新增帳號**可在不登出第一個帳號的前提下授權 +第二個 Google 帳號。 + +**從 CLI 操作。** + +```bash +ocx login gemini-cli # Code Assist 子類型 +ocx login gemini-ai-studio # AI Studio 子類型 +ocx logout gemini-cli +``` + +登入會開啟瀏覽器,並在 `http://127.0.0.1:51122/callback` 上監聽。若瀏覽器無法連到該 loopback +listener,把 redirect URL(或純 `code`)貼回提示字元即可。 + +**Code Assist 的專案探索。** token 交換完成後,Code Assist 子類型會呼叫 `loadCodeAssist`;若帳號尚無 +專案,則再呼叫 `onboardUser`。探索到的專案 id 會隨 credential 一起儲存,並在 refresh 時重新檢查。 +若無法探索到任何專案,**登入會直接失敗**,而不是存下一份每個請求都會被拒絕的 credential——否則帳號 +會顯示為「已登入」但實際完全不能用。請先確認該 Google 帳號確實具備 Gemini Code Assist 存取權再重試。 + +**AI Studio 需要你自己的 OAuth client。** Google 內建的 Gemini CLI client 並未註冊 generative-language +scope,因此在你提供自己 Google Cloud 專案的 client credential 之前,此子類型會帶著可操作的訊息直接 +fail closed(OAuth client 類型選 *桌面應用程式*,並把 `http://127.0.0.1:51122/callback` 加入已授權的 +redirect URI): + +```bash +export GEMINI_AI_STUDIO_OAUTH_CLIENT_ID=".apps.googleusercontent.com" +export GEMINI_AI_STUDIO_OAUTH_CLIENT_SECRET="" +ocx login gemini-ai-studio +``` + +Code Assist 子類型不需要任何設定:它使用 Google 隨 Gemini CLI 一起散布的公開 client 識別碼。若你想改用 +自己的 client,可用 `GEMINI_CLI_OAUTH_CLIENT_ID` / `GEMINI_CLI_OAUTH_CLIENT_SECRET` 覆寫。 + +:::caution[服務條款] +Code Assist 子類型是從 proxy、而非 CLI 本身出示 Google 第一方 Gemini CLI 的 client 識別碼。與本頁其他 +非官方橋接一樣,這可能與 Google 的條款衝突,濫用偵測也可能導致存取被停權。AI Studio 子類型沒有這個 +風險——它授權的是你自己註冊的 client。 +::: + ### Cockpit Tools Antigravity 匯入 目前 v1 只會為 `google-antigravity` provider 匯入 **Cockpit Tools Antigravity** JSON export。在 Providers diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index d6c503134a..0855f95ca5 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -1544,6 +1544,8 @@ export const de: Record = { "modal.accountCodexPool": "ChatGPT-Kontopool", "modal.accountLoggedIn": "Angemeldet", "modal.accountLoggedOut": "Nicht angemeldet", + "modal.accountGeminiCodeAssist": "Google-Konto · Subtyp Code Assist", + "modal.accountGeminiAiStudio": "Google-Konto · Subtyp AI Studio (eigener OAuth-Client erforderlich)", "quota.fiveHourLimit": "5-Stunden-Limit", "quota.weeklyLimit": "Wochenlimit", "quota.monthlyLimit": "30-Tage-Limit", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 87892599ce..db850c432e 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -1046,6 +1046,8 @@ export const en = { "modal.accountCodexPool": "ChatGPT account pool", "modal.accountLoggedIn": "Logged in", "modal.accountLoggedOut": "Not logged in", + "modal.accountGeminiCodeAssist": "Google account · Code Assist subtype", + "modal.accountGeminiAiStudio": "Google account · AI Studio subtype (needs your own OAuth client)", "quota.fiveHourLimit": "5-hour limit", "quota.weeklyLimit": "Weekly limit", "quota.monthlyLimit": "30-day limit", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index 4da3bc33f1..9f48c7834d 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -1019,6 +1019,8 @@ export const fr: Record = { "modal.accountCodexPool": "Groupe de comptes ChatGPT", "modal.accountLoggedIn": "Connecté", "modal.accountLoggedOut": "Non connecté", + "modal.accountGeminiCodeAssist": "Compte Google · sous-type Code Assist", + "modal.accountGeminiAiStudio": "Compte Google · sous-type AI Studio (client OAuth personnel requis)", "quota.fiveHourLimit": "Limite sur 5 heures", "quota.weeklyLimit": "Limite hebdomadaire", "quota.monthlyLimit": "Limite sur 30 jours", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index a65cc76d6b..bf5cca38c4 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -989,6 +989,8 @@ export const ja: Record = { "modal.accountCodexPool": "ChatGPT アカウントプール", "modal.accountLoggedIn": "ログイン済み", "modal.accountLoggedOut": "未ログイン", + "modal.accountGeminiCodeAssist": "Google アカウント · Code Assist サブタイプ", + "modal.accountGeminiAiStudio": "Google アカウント · AI Studio サブタイプ(独自の OAuth クライアントが必要)", "quota.fiveHourLimit": "5 時間上限", "quota.weeklyLimit": "週間上限", "quota.monthlyLimit": "30 日上限", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index ba19d179e8..231f2e51e9 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -1571,6 +1571,8 @@ export const ko: Record = { "modal.accountCodexPool": "ChatGPT 계정 풀", "modal.accountLoggedIn": "로그인됨", "modal.accountLoggedOut": "로그인 안 됨", + "modal.accountGeminiCodeAssist": "Google 계정 · Code Assist 하위 유형", + "modal.accountGeminiAiStudio": "Google 계정 · AI Studio 하위 유형(자체 OAuth 클라이언트 필요)", "quota.fiveHourLimit": "5시간 한도", "quota.weeklyLimit": "주간 한도", "quota.monthlyLimit": "30일 한도", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index f41af16d00..3f402b3426 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -1030,6 +1030,8 @@ export const ru: Record = { "modal.accountCodexPool": "Пул аккаунтов ChatGPT", "modal.accountLoggedIn": "Вход выполнен", "modal.accountLoggedOut": "Вход не выполнен", + "modal.accountGeminiCodeAssist": "Аккаунт Google · подтип Code Assist", + "modal.accountGeminiAiStudio": "Аккаунт Google · подтип AI Studio (нужен свой OAuth-клиент)", "quota.fiveHourLimit": "5-часовой лимит", "quota.weeklyLimit": "Недельный лимит", "quota.monthlyLimit": "30-дневный лимит", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index 101dc684c4..25471e93d2 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -1037,6 +1037,8 @@ export const tr: Record = { "modal.accountCodexPool": "ChatGPT hesap havuzu", "modal.accountLoggedIn": "Giriş yapıldı", "modal.accountLoggedOut": "Giriş yapılmadı", + "modal.accountGeminiCodeAssist": "Google hesabı · Code Assist alt türü", + "modal.accountGeminiAiStudio": "Google hesabı · AI Studio alt türü (kendi OAuth istemciniz gerekir)", "quota.fiveHourLimit": "5 saatlik limit", "quota.weeklyLimit": "Haftalık limit", "quota.monthlyLimit": "30 günlük limit", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index 89b891d603..ca2c7b93ae 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -832,6 +832,8 @@ export const zhTW: Record = { "modal.accountCodexPool": "ChatGPT 帳號池", "modal.accountLoggedIn": "已登入", "modal.accountLoggedOut": "未登入", + "modal.accountGeminiCodeAssist": "Google 帳號 · Code Assist 子類型", + "modal.accountGeminiAiStudio": "Google 帳號 · AI Studio 子類型(需自備 OAuth 用戶端)", "quota.fiveHourLimit": "5 小時限額", "quota.weeklyLimit": "每週限額", "quota.monthlyLimit": "30 天限額", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index c5d588efa0..afc39037dc 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -1564,6 +1564,8 @@ export const zh: Record = { "modal.accountCodexPool": "ChatGPT 账户池", "modal.accountLoggedIn": "已登录", "modal.accountLoggedOut": "未登录", + "modal.accountGeminiCodeAssist": "Google 账号 · Code Assist 子类型", + "modal.accountGeminiAiStudio": "Google 账号 · AI Studio 子类型(需自备 OAuth 客户端)", "quota.fiveHourLimit": "5 小时限额", "quota.weeklyLimit": "每周限额", "quota.monthlyLimit": "30 天限额", diff --git a/gui/src/oauth-tos-risk.ts b/gui/src/oauth-tos-risk.ts index d894d1f121..cdc9eb675d 100644 --- a/gui/src/oauth-tos-risk.ts +++ b/gui/src/oauth-tos-risk.ts @@ -8,7 +8,11 @@ export type OAuthTosRiskLevel = "high" | "elevated"; const HIGH_RISK = new Set(["anthropic", "google-antigravity"]); -const ELEVATED_RISK = new Set(["github-copilot", "cursor"]); +// gemini-cli reuses Google's first-party Gemini CLI client id from a proxy rather than from the +// CLI itself — an unofficial bridge, same class as the two below. gemini-ai-studio is deliberately +// NOT listed: that subtype authorizes the operator's OWN registered OAuth client, so it carries no +// first-party-impersonation risk. +const ELEVATED_RISK = new Set(["github-copilot", "cursor", "gemini-cli"]); export function oauthTosRisk(providerId: string): OAuthTosRiskLevel | null { const id = providerId.trim().toLowerCase(); diff --git a/gui/src/pages/providers-page-utils.ts b/gui/src/pages/providers-page-utils.ts index 1b4ef8a346..83a05844fe 100644 --- a/gui/src/pages/providers-page-utils.ts +++ b/gui/src/pages/providers-page-utils.ts @@ -1,10 +1,21 @@ import type { AccountLoginRow, AccountLoginStatus } from "../components/provider-catalog/ProviderCatalog"; import type { TFn } from "../i18n/shared"; +import type { TKey } from "../i18n/en"; import { formatProviderDisplayName } from "../provider-icons"; import { codexAccountProviderNames } from "../provider-payload"; import type { OAuthStatus, ProvidersConfig } from "./providers-shared"; import { oauthLabel } from "./providers-shared"; +/** + * Logged-out sub-text for OAuth rows whose provider id alone does not say what the account is. + * The Gemini rows are two OAuth subtypes of one Google account, so the row needs to say which + * one it authorizes; a live status (email or error) always wins over this hint. + */ +const OAUTH_ROW_HINT_KEYS: Record = { + "gemini-cli": "modal.accountGeminiCodeAssist", + "gemini-ai-studio": "modal.accountGeminiAiStudio", +}; + export function buildAddModalAccountRows( config: ProvidersConfig, oauthProviders: string[], @@ -20,7 +31,15 @@ export function buildAddModalAccountRows( })), ...oauthProviders .toSorted((a, b) => a.localeCompare(b)) - .map(id => ({ id, label: oauthLabel(id), kind: "oauth" as const })), + .map(id => { + const hintKey = OAUTH_ROW_HINT_KEYS[id]; + return { + id, + label: oauthLabel(id), + kind: "oauth" as const, + ...(hintKey ? { statusLabel: t(hintKey) } : {}), + }; + }), ]; } diff --git a/gui/src/pages/providers-shared.ts b/gui/src/pages/providers-shared.ts index b87b66bd9e..c63732a917 100644 --- a/gui/src/pages/providers-shared.ts +++ b/gui/src/pages/providers-shared.ts @@ -49,6 +49,10 @@ const OAUTH_LABELS: Record = { anthropic: "Anthropic (Claude)", kimi: "Kimi (Moonshot)", "google-antigravity": "Google Antigravity", + // Gemini OAuth subtypes. Product names, so they stay untranslated like the rows around them; + // the explanatory copy lives in i18n (providers.oauthGeminiSubtypeHint). + "gemini-cli": "Gemini (Code Assist)", + "gemini-ai-studio": "Gemini (AI Studio)", "github-copilot": "GitHub Copilot", cursor: "Cursor", }; diff --git a/gui/tests/gemini-oauth-account-rows.test.ts b/gui/tests/gemini-oauth-account-rows.test.ts new file mode 100644 index 0000000000..42c7742621 --- /dev/null +++ b/gui/tests/gemini-oauth-account-rows.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, test } from "bun:test"; +import { buildAddModalAccountRows } from "../src/pages/providers-page-utils"; +import { oauthTosRisk } from "../src/oauth-tos-risk"; +import { en } from "../src/i18n/en"; +import type { TFn } from "../src/i18n"; +import type { ProvidersConfig } from "../src/types"; + +// Resolve through the real English catalog so a row hint pointing at a missing key fails here. +const t: TFn = ((key: string) => (en as Record)[key] ?? key) as TFn; + +const emptyConfig = { providers: {} } as unknown as ProvidersConfig; + +function row(oauthProviders: string[], id: string) { + return buildAddModalAccountRows(emptyConfig, oauthProviders, t).find(r => r.id === id); +} + +describe("Add provider → Accounts: Gemini OAuth rows", () => { + test("both subtypes render as OAuth rows with product labels", () => { + const codeAssist = row(["gemini-cli", "gemini-ai-studio"], "gemini-cli"); + expect(codeAssist?.kind).toBe("oauth"); + expect(codeAssist?.label).toBe("Gemini (Code Assist)"); + + const aiStudio = row(["gemini-cli", "gemini-ai-studio"], "gemini-ai-studio"); + expect(aiStudio?.kind).toBe("oauth"); + expect(aiStudio?.label).toBe("Gemini (AI Studio)"); + }); + + test("each row states which subtype it authorizes", () => { + // The two rows are subtypes of one Google account, so the label alone is ambiguous: without + // the hint a user cannot tell which one their account needs. + expect(row(["gemini-cli"], "gemini-cli")?.statusLabel).toBe(en["modal.accountGeminiCodeAssist"]); + expect(row(["gemini-ai-studio"], "gemini-ai-studio")?.statusLabel) + .toBe(en["modal.accountGeminiAiStudio"]); + // Hints are translated strings, not raw keys leaking through. + expect(row(["gemini-cli"], "gemini-cli")?.statusLabel).not.toContain("modal.account"); + }); + + test("rows without a subtype ambiguity carry no hint", () => { + expect(row(["anthropic"], "anthropic")?.statusLabel).toBeUndefined(); + expect(row(["google-antigravity"], "google-antigravity")?.statusLabel).toBeUndefined(); + }); + + test("only the first-party-client subtype carries elevated ToS risk", () => { + // gemini-cli presents Google's own CLI client identifiers from a proxy; gemini-ai-studio + // authorizes the operator's own registered client, so it must not be warned about. + expect(oauthTosRisk("gemini-cli")).toBe("elevated"); + expect(oauthTosRisk("gemini-ai-studio")).toBeNull(); + }); +}); diff --git a/src/adapters/client-fingerprint.ts b/src/adapters/client-fingerprint.ts index 2bf5b11862..eb838594dc 100644 --- a/src/adapters/client-fingerprint.ts +++ b/src/adapters/client-fingerprint.ts @@ -63,3 +63,21 @@ export function antigravityUserAgent(version = ANTIGRAVITY_IDE_VERSION, authMeth const [osType, arch] = ANTIGRAVITY_IDE_PLATFORM.split("/"); return `antigravity/ide/${version} (os_type=${osType}; arch=${arch}; ${ANTIGRAVITY_IDE_CLIENT_NAME}; auth_method=${authMethod})`; } + +// ── Gemini CLI ── +/** Pinned Gemini CLI version used in the CLI User-Agent. */ +export const GEMINI_CLI_VERSION = "0.1.5"; + +/** + * Real Gemini CLI User-Agent: `GeminiCLI/${version} (${os}; ${arch})`. + * + * Deliberately the CLI family, NOT `antigravity/ide/...`: the Gemini CLI OAuth client and the + * Antigravity IDE client are different first-party clients, and the header must match the client + * family the token was minted for. That also means this UA does not unlock the IDE-gated agent + * models — which is correct, a CLI credential is not entitled to them. + */ +export function geminiCliUserAgent(version = GEMINI_CLI_VERSION): string { + const ov = process.env.GEMINI_CLI_USER_AGENT?.trim(); + if (ov) return ov; + return `GeminiCLI/${version} (${process.platform}; ${process.arch})`; +} diff --git a/src/adapters/google.ts b/src/adapters/google.ts index 5ce985dd4b..67b6646faa 100644 --- a/src/adapters/google.ts +++ b/src/adapters/google.ts @@ -21,6 +21,7 @@ import { fetchAntigravityWithRetry, fetchVertexWithRetry } from "./google-http"; import { safeAntigravityHttpErrorMessage, safeVertexHttpErrorMessage } from "./google-errors"; import { isVertexTruncatedTurn, vertexTruncationErrorMessage } from "./google-truncation"; import { ANTIGRAVITY_REQUEST_UA, antigravitySessionId, isLikelyRealThoughtSignature, sanitizeAntigravityClaudeSignatures } from "./google-antigravity-wire"; +import { geminiCliUserAgent } from "./client-fingerprint"; import { compileGoogleWireBody } from "./google-wire-compiler"; import { identifyRoutedModel } from "./identity"; import { antigravityUsesReplayCache, applyAntigravityReplay, clearAntigravityReplay, observeAntigravityReplay } from "./google-antigravity-replay"; @@ -383,19 +384,26 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte let vertexReplayModel: string | undefined; let vertexReplaySession: string | undefined; let restoreGoogleToolName = (name: string): string => name; + // Both CCA client families (Antigravity IDE and Gemini CLI) share the v1internal transport: + // the `response` envelope wrapper, the retry/error classification, and the replay namespace. + // They differ ONLY in the request envelope and User-Agent, handled in buildRequest. + const isCodeAssist = provider.googleMode === "cloud-code-assist" || provider.googleMode === "gemini-cli"; + // Names the client family in user-visible errors. Antigravity's wording is kept verbatim so + // existing troubleshooting docs and log searches keep matching; `googleMode` would rename it. + const codeAssistLabel = provider.googleMode === "gemini-cli" ? "gemini-cli" : "google-antigravity"; return { name: "google", - // Vertex + Antigravity get Kiro-style retry/timeout + classified, redacted errors. + // Vertex + CCA get Kiro-style retry/timeout + classified, redacted errors. // Direct AI-Studio uses the canonical server transport (fetchWithTransientRetry), which // retries transient 5xx responses through providerFetch while preserving multi-key pool // 429 rotation and raw error formatting. - ...(provider.googleMode === "vertex" || provider.googleMode === "cloud-code-assist" + ...(provider.googleMode === "vertex" || isCodeAssist ? { fetchResponse: (request: AdapterRequest, ctx?: AdapterFetchContext): Promise => - (provider.googleMode === "cloud-code-assist" ? fetchAntigravityWithRetry : fetchVertexWithRetry)(request, ctx), + (isCodeAssist ? fetchAntigravityWithRetry : fetchVertexWithRetry)(request, ctx), formatErrorBody: (status: number, _headers: Headers, payloadText: string): string => - (provider.googleMode === "cloud-code-assist" ? safeAntigravityHttpErrorMessage : safeVertexHttpErrorMessage)(status, payloadText), + (isCodeAssist ? safeAntigravityHttpErrorMessage : safeVertexHttpErrorMessage)(status, payloadText), } : {}), @@ -406,7 +414,9 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte mapReasoningEffort(provider, parsed.modelId, parsed.options.reasoning), provider.baseUrl, ).wireModelId - : provider.googleMode === "vertex" + // gemini-cli joins vertex in sending the bare id: the `-tiered` spelling is a direct + // AI-Studio deployment quirk, and Cloud Code Assist does not serve those wire names. + : provider.googleMode === "vertex" || provider.googleMode === "gemini-cli" ? parsed.modelId : resolveDirectGeminiWireModelId(parsed.modelId, provider.directGeminiWireRenames !== false); // AI Studio's `-tiered` spelling is wire-only; CCA aliases may migrate to another generation. @@ -434,6 +444,8 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte // current behavior; Vertex participates only through an explicitly configured ladder (the // seed google-vertex entry ships none). Image models are excluded — thinkingConfig would // suppress the responseModalities fallback below. CCA maps effort on its envelope path. + // gemini-cli is NOT excluded here: unlike Antigravity it has no effort-encoding wire-model + // suffixes, so a configured ladder must reach the wire through the standard thinkingConfig. const thinkingEligible = provider.googleMode !== "cloud-code-assist" && !isImageCapableModel(parsed.modelId) && (configuredReasoningEfforts(provider, parsed.modelId) !== undefined @@ -453,6 +465,39 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte const headers: Record = { "Content-Type": "application/json" }; if (provider.headers) Object.assign(headers, provider.headers); + if (provider.googleMode === "gemini-cli") { + // Gemini CLI (Cloud Code Assist): same v1internal endpoint as Antigravity, but the CLI + // sends a plain `{model, project, request}` envelope and its own User-Agent. The + // Antigravity-only fields (userAgent/requestType/requestId, effort wire-model suffixes, + // Claude VALIDATED tool mode) belong to the IDE client family and are deliberately absent. + const token = provider.apiKey?.trim(); + if (!token) throw new Error("gemini-cli oauth token missing — run ocx login gemini-cli"); + const base = provider.baseUrl?.trim(); + if (!base) throw new Error("gemini-cli requires a non-empty baseUrl"); + const project = provider.project; + if (!project) throw new Error("Gemini CLI requires a discovered Cloud Code Assist project id (re-run `ocx login gemini-cli`)."); + const sessionId = antigravitySessionId(parsed); + antigravityModel = `gemini-cli:${routedModelId}`; + antigravitySession = sessionId; + const compiled = compileGoogleWireBody(body); + const request = compiled.body; + restoreGoogleToolName = compiled.restoreToolName; + // Same bounded replay store as CCA/Vertex; the transport prefix on the model key keeps + // signatures from leaking across backends (#1254). + if (Array.isArray((request as { contents?: unknown[] }).contents)) { + applyAntigravityReplay(antigravityModel, sessionId, (request as { contents: unknown[] }).contents); + } + const url = `${base}/v1internal:${method}${streamParam}`; + headers["User-Agent"] = geminiCliUserAgent(); + headers["Authorization"] = `Bearer ${token}`; + return { + url, + method: "POST", + headers, + body: JSON.stringify({ model: routedModelId, project, request }), + }; + } + if (provider.googleMode === "cloud-code-assist") { // Google Antigravity (Cloud Code Assist): wrap the flat Gemini body in the CCA envelope. const token = provider.apiKey?.trim(); @@ -572,11 +617,14 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte return { url, method: "POST", headers, body: JSON.stringify(compiled.body) }; } - // ai-studio (default): Generative Language API + x-goog-api-key. + // ai-studio (default): Generative Language API. An API key travels in x-goog-api-key; an + // OAuth credential must travel as a Bearer instead — the Generative Language API rejects a + // bearer token presented in the api-key header (mirrors sub2api's AccountTypeOAuth path). const url = `${provider.baseUrl}/v1beta/models/${routedModelId}:${method}${streamParam}`; const apiKey = provider.apiKey?.trim(); if (!apiKey) throw new Error("google (AI Studio) requires a non-empty API key"); - headers["x-goog-api-key"] = apiKey; + if (provider.authMode === "oauth") headers["Authorization"] = `Bearer ${apiKey}`; + else headers["x-goog-api-key"] = apiKey; const compiled = compileGoogleWireBody(body); restoreGoogleToolName = compiled.restoreToolName; @@ -638,9 +686,9 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte const err = chunk.error as { message?: string } | undefined; // Clear-on-invalid: a signature rejection means our replayed thoughtSignatures are stale. // Drop the cache entry so the next turn starts clean instead of re-injecting a bad sig. - const replayModel = provider.googleMode === "cloud-code-assist" ? antigravityModel : vertexReplayModel; - const replaySession = provider.googleMode === "cloud-code-assist" ? antigravitySession : vertexReplaySession; - if ((provider.googleMode === "cloud-code-assist" || provider.googleMode === "vertex") + const replayModel = isCodeAssist ? antigravityModel : vertexReplayModel; + const replaySession = isCodeAssist ? antigravitySession : vertexReplaySession; + if ((isCodeAssist || provider.googleMode === "vertex") && replayModel && replaySession && /signature|invalid_argument|invalid argument/i.test(err?.message ?? "")) { clearAntigravityReplay(replayModel, replaySession); @@ -649,12 +697,12 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte return "terminate"; } - // Antigravity (CCA) nests the standard Gemini payload under `response`. + // Cloud Code Assist nests the standard Gemini payload under `response`. let root = chunk; - if (provider.googleMode === "cloud-code-assist") { + if (isCodeAssist) { const wrapped = chunk.response; if (!wrapped || typeof wrapped !== "object" || Array.isArray(wrapped)) { - yield { type: "error", message: "google-antigravity response missing response wrapper" }; + yield { type: "error", message: `${codeAssistLabel} response missing response wrapper` }; return "terminate"; } root = wrapped as Record; @@ -696,9 +744,9 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte const parts = candidate.content?.parts as GoogleResponsePart[] | undefined; // Record Gemini thought signatures for the next stateless tool-result turn. Vertex and // Antigravity use separate model namespaces so opaque provider state cannot cross routes. - const replayModel = provider.googleMode === "cloud-code-assist" ? antigravityModel : vertexReplayModel; - const replaySession = provider.googleMode === "cloud-code-assist" ? antigravitySession : vertexReplaySession; - if ((provider.googleMode === "cloud-code-assist" || provider.googleMode === "vertex") + const replayModel = isCodeAssist ? antigravityModel : vertexReplayModel; + const replaySession = isCodeAssist ? antigravitySession : vertexReplaySession; + if ((isCodeAssist || provider.googleMode === "vertex") && parts && replayModel && replaySession) { pendingStreamThoughtSig = observeAntigravityReplay( replayModel, @@ -807,7 +855,7 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte } // Fail-closed: a turn cut off mid tool call (MAX_TOKENS / MALFORMED_FUNCTION_CALL) surfaces // an error instead of a silently-incomplete done. Mirrors kiro-truncation. - if ((provider.googleMode === "vertex" || provider.googleMode === "cloud-code-assist") + if ((provider.googleMode === "vertex" || isCodeAssist) && isVertexTruncatedTurn(lastFinishReason, toolCallsStarted)) { yield { type: "error", message: vertexTruncationErrorMessage(lastFinishReason) }; return; @@ -913,12 +961,12 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte const err = raw.error as { message?: string }; return finish([{ type: "error", message: err.message ?? "upstream error" }]); } - // Antigravity (CCA) nests the standard Gemini payload under `response`; unwrap it. + // Cloud Code Assist nests the standard Gemini payload under `response`; unwrap it. let json = raw; - if (provider.googleMode === "cloud-code-assist") { + if (isCodeAssist) { const wrapped = raw.response; if (!wrapped || typeof wrapped !== "object" || Array.isArray(wrapped)) { - return finish([{ type: "error", message: "google-antigravity response missing response wrapper" }]); + return finish([{ type: "error", message: `${codeAssistLabel} response missing response wrapper` }]); } json = wrapped as Record; } @@ -933,9 +981,9 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte if (candidates?.[0]?.content?.parts) { // Non-streaming Google-family response: observe thought signatures for the next turn, // using the same transport-scoped namespace as the streaming path. - const replayModel = provider.googleMode === "cloud-code-assist" ? antigravityModel : vertexReplayModel; - const replaySession = provider.googleMode === "cloud-code-assist" ? antigravitySession : vertexReplaySession; - if ((provider.googleMode === "cloud-code-assist" || provider.googleMode === "vertex") + const replayModel = isCodeAssist ? antigravityModel : vertexReplayModel; + const replaySession = isCodeAssist ? antigravitySession : vertexReplaySession; + if ((isCodeAssist || provider.googleMode === "vertex") && replayModel && replaySession) { observeAntigravityReplay(replayModel, replaySession, candidates[0].content.parts as unknown[]); } @@ -978,7 +1026,7 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte // Fail-closed truncation, same as the stream path: a non-stream turn cut off mid tool call // (MAX_TOKENS / MALFORMED_FUNCTION_CALL) surfaces an error instead of a silent done. - if ((provider.googleMode === "vertex" || provider.googleMode === "cloud-code-assist") + if ((provider.googleMode === "vertex" || isCodeAssist) && isVertexTruncatedTurn(candidates?.[0]?.finishReason, toolCallsStarted)) { return finish([{ type: "error", message: vertexTruncationErrorMessage(candidates?.[0]?.finishReason) }]); } diff --git a/src/oauth/gemini-cli.ts b/src/oauth/gemini-cli.ts new file mode 100644 index 0000000000..4b86a257b5 --- /dev/null +++ b/src/oauth/gemini-cli.ts @@ -0,0 +1,427 @@ +/** + * Gemini (Google account) OAuth — Code Assist and AI Studio subtypes. + * + * Mirrors sub2api `internal/pkg/geminicli` + `internal/service/gemini_oauth_service.go`. Flow: + * standard Google OAuth (PKCE) → for the Code Assist subtype, discover the Cloud Code Assist + * project via `loadCodeAssist`, onboarding via `onboardUser` when the account has none yet. + * The discovered `projectId` is stored on the credential and injected into the CCA request + * envelope by the google adapter, exactly as the Antigravity flow does. + * + * Two subtypes exist because Google gates them behind different OAuth clients and scopes: + * + * - `code-assist` — the Gemini CLI public client. Talks to cloudcode-pa.googleapis.com and + * requires a CCA project. This is the subtype a Google account (including Google One / AI + * Pro / Ultra plans) uses, so it is the default. + * - `ai-studio` — generativelanguage.googleapis.com with an OAuth credential instead of an API + * key. Google's built-in CLI client is not registered for the generative-language scopes, so + * this subtype requires operator-supplied client credentials; without them login fails closed + * with an actionable message rather than sending a request Google rejects as + * `restricted_client`. + * + * The Gemini CLI client id/secret are the public OAuth client identifiers embedded in Google's + * own Gemini CLI (overridable via env), not user secrets. Tokens/refresh are never logged. + */ +import { OAuthCallbackFlow, type OAuthCallbackFlowOptions } from "./callback-server"; +import { generatePKCE } from "./pkce"; +import type { OAuthController, OAuthCredentials } from "./types"; + +/** OAuth subtype selected when adding the account. */ +export type GeminiOAuthSubtype = "code-assist" | "ai-studio"; + +export const GEMINI_OAUTH_SUBTYPES: readonly GeminiOAuthSubtype[] = ["code-assist", "ai-studio"]; + +/** Provider id per subtype: each subtype owns its own account set and provider entry. */ +export const GEMINI_CODE_ASSIST_PROVIDER = "gemini-cli"; +export const GEMINI_AI_STUDIO_PROVIDER = "gemini-ai-studio"; + +export function geminiSubtypeForProvider(provider: string): GeminiOAuthSubtype { + return provider === GEMINI_AI_STUDIO_PROVIDER ? "ai-studio" : "code-assist"; +} + +// Public Gemini CLI OAuth client (Google ships these in the CLI binary). Overridable so an +// operator can substitute their own registered client. +const CLI_CLIENT_ID = process.env.GEMINI_CLI_OAUTH_CLIENT_ID + || "681255809395-oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.googleusercontent.com"; +const CLI_CLIENT_SECRET = process.env.GEMINI_CLI_OAUTH_CLIENT_SECRET + || "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"; + +// AI Studio has no usable built-in client: Google's CLI client is not registered for the +// generative-language scopes, so the operator must register their own. +const AI_STUDIO_CLIENT_ID = process.env.GEMINI_AI_STUDIO_OAUTH_CLIENT_ID ?? ""; +const AI_STUDIO_CLIENT_SECRET = process.env.GEMINI_AI_STUDIO_OAUTH_CLIENT_SECRET ?? ""; + +const AUTH_ENDPOINT = "https://accounts.google.com/o/oauth2/v2/auth"; +const TOKEN_ENDPOINT = "https://oauth2.googleapis.com/token"; +const USERINFO_ENDPOINT = "https://www.googleapis.com/oauth2/v2/userinfo"; +const CODE_ASSIST_API = "https://cloudcode-pa.googleapis.com"; +const API_VERSION = "v1internal"; + +const CODE_ASSIST_SCOPES = [ + "https://www.googleapis.com/auth/cloud-platform", + "https://www.googleapis.com/auth/userinfo.email", + "https://www.googleapis.com/auth/userinfo.profile", +]; +// Google documents the retriever scope (rather than the older bare `generative-language`) for +// OAuth access to generativelanguage.googleapis.com. +const AI_STUDIO_SCOPES = [ + "https://www.googleapis.com/auth/cloud-platform", + "https://www.googleapis.com/auth/generative-language.retriever", +]; + +// Distinct from the Antigravity flow's 51121 so a Gemini login cannot collide with one already +// listening for an Antigravity callback. +const CALLBACK_PORT = 51122; +const CALLBACK_PATH = "/callback"; +// Keep provider-side margins small: the shared OAuth freshness gate applies an additional minute. +const REFRESH_SKEW_MS = 5 * 60 * 1000; +const REQUEST_TIMEOUT_MS = 30_000; +const ONBOARD_ATTEMPTS = 5; +// Transient 429/5xx retries get their own budget. Sharing one counter with the in-progress +// polls below meant a couple of 5xx responses could exhaust the budget before onboarding +// finished, and the login then blamed the account's entitlement for what was really a timeout. +const ONBOARD_TRANSIENT_ATTEMPTS = 3; +const ONBOARD_POLL_MS = 2_000; +// Matches the Gemini CLI's own UA so Code Assist sees a client shape it recognizes. +const GEMINI_CLI_USER_AGENT = "GeminiCLI/0.1.5 (Windows; AMD64)"; +const CODE_ASSIST_METADATA = { ideType: "IDE_UNSPECIFIED", platform: "PLATFORM_UNSPECIFIED", pluginType: "GEMINI" }; + +export class GeminiOAuthClientNotConfiguredError extends Error { + constructor() { + super( + "Gemini AI Studio OAuth requires your own Google OAuth client: set GEMINI_AI_STUDIO_OAUTH_CLIENT_ID and " + + "GEMINI_AI_STUDIO_OAUTH_CLIENT_SECRET, then retry. Google's built-in Gemini CLI client is not registered " + + "for the generative-language scopes.", + ); + this.name = "GeminiOAuthClientNotConfiguredError"; + } +} + +interface GeminiOAuthClient { + clientId: string; + clientSecret: string; + scopes: string[]; +} + +/** Client id/secret + scopes for a subtype; throws when AI Studio has no configured client. */ +export function geminiOAuthClient(subtype: GeminiOAuthSubtype): GeminiOAuthClient { + if (subtype === "ai-studio") { + const clientId = AI_STUDIO_CLIENT_ID.trim(); + const clientSecret = AI_STUDIO_CLIENT_SECRET.trim(); + if (!clientId || !clientSecret) throw new GeminiOAuthClientNotConfiguredError(); + return { clientId, clientSecret, scopes: [...AI_STUDIO_SCOPES] }; + } + return { clientId: CLI_CLIENT_ID, clientSecret: CLI_CLIENT_SECRET, scopes: [...CODE_ASSIST_SCOPES] }; +} + +/** Whether a subtype can start a login right now (drives the dashboard's disabled state). */ +export function isGeminiOAuthSubtypeConfigured(subtype: GeminiOAuthSubtype): boolean { + try { + geminiOAuthClient(subtype); + return true; + } catch { + return false; + } +} + +function requestSignal(signal: AbortSignal | undefined): AbortSignal { + const timeout = AbortSignal.timeout(REQUEST_TIMEOUT_MS); + return signal ? AbortSignal.any([signal, timeout]) : timeout; +} + +interface GoogleTokenPayload { + access_token?: unknown; + refresh_token?: unknown; + expires_in?: unknown; + id_token?: unknown; +} + +function decodeJwtPayload(token: string): Record | undefined { + const part = token.split(".")[1]; + if (!part) return undefined; + try { + return JSON.parse(Buffer.from(part, "base64url").toString("utf8")) as Record; + } catch { + return undefined; + } +} + +function emailFromToken(accessToken: string, idToken: string | undefined): string | undefined { + const payload = (idToken ? decodeJwtPayload(idToken) : undefined) ?? decodeJwtPayload(accessToken); + const email = payload?.email; + return typeof email === "string" && email.length > 0 ? email.toLowerCase() : undefined; +} + +async function postToken(body: Record, signal?: AbortSignal): Promise { + const response = await fetch(TOKEN_ENDPOINT, { + method: "POST", + headers: { Accept: "application/json", "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams(body).toString(), + signal: requestSignal(signal), + }); + if (!response.ok) { + // Status only — the body can carry grant/account details. + throw new Error(`Gemini token request failed: ${response.status}`); + } + return (await response.json()) as GoogleTokenPayload; +} + +/** Pull a Cloud Code Assist project id out of a loadCodeAssist/onboardUser response shape. */ +function extractProjectId(data: Record | undefined): string | undefined { + if (!data) return undefined; + for (const key of ["cloudaicompanionProject", "projectId", "project"]) { + const value = data[key]; + if (typeof value === "string" && value.length > 0) return value; + if (value && typeof value === "object" && typeof (value as { id?: unknown }).id === "string") { + return (value as { id: string }).id; + } + } + return undefined; +} + +/** + * The tier to onboard into: the default among `allowedTiers`, else the free tier Gemini CLI + * uses. Sending a tier the account is not entitled to makes `onboardUser` fail closed. + */ +function extractDefaultTierId(data: Record | undefined): string { + const tiers = data?.allowedTiers; + if (Array.isArray(tiers)) { + for (const tier of tiers) { + if (tier && typeof tier === "object" + && (tier as { isDefault?: unknown }).isDefault === true + && typeof (tier as { id?: unknown }).id === "string" + && (tier as { id: string }).id.length > 0) { + return (tier as { id: string }).id; + } + } + } + return "free-tier"; +} + +/** + * Why onboarding produced no project. `pending` means Google accepted the request and is still + * provisioning; `unavailable` means it refused or returned no project. The caller turns these + * into different login errors — telling a user to check Code Assist access when onboarding is + * merely slow sends them to the wrong place. + */ +type OnboardOutcome = + | { status: "ready"; projectId: string } + | { status: "pending" } + | { status: "unavailable" }; + +async function loadCodeAssist( + accessToken: string, + signal?: AbortSignal, +): Promise | undefined> { + const response = await fetch(`${CODE_ASSIST_API}/${API_VERSION}:loadCodeAssist`, { + method: "POST", + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: "*/*", + "Content-Type": "application/json", + "User-Agent": GEMINI_CLI_USER_AGENT, + }, + body: JSON.stringify({ metadata: CODE_ASSIST_METADATA }), + signal: requestSignal(signal), + }); + if (!response.ok) return undefined; + return (await response.json().catch(() => undefined)) as Record | undefined; +} + +async function onboardProject(accessToken: string, tierId: string, signal?: AbortSignal): Promise { + let polls = 0; + let transientRetries = 0; + while (polls < ONBOARD_ATTEMPTS) { + if (signal?.aborted) throw signal.reason ?? new Error("Gemini onboarding aborted"); + const response = await fetch(`${CODE_ASSIST_API}/${API_VERSION}:onboardUser`, { + method: "POST", + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: "*/*", + "Content-Type": "application/json", + "User-Agent": GEMINI_CLI_USER_AGENT, + }, + body: JSON.stringify({ tierId, metadata: CODE_ASSIST_METADATA }), + signal: requestSignal(signal), + }); + if (!response.ok) { + // Transient (429/5xx): retry on the transient budget so these do not eat the polling + // budget. Hard 4xx, or transient retries exhausted: give up now. + if ((response.status === 429 || response.status >= 500) && transientRetries < ONBOARD_TRANSIENT_ATTEMPTS) { + transientRetries += 1; + await new Promise(resolve => setTimeout(resolve, ONBOARD_POLL_MS)); + continue; + } + return { status: "unavailable" }; + } + const data = (await response.json().catch(() => ({}))) as Record; + if (data.done === true) { + const projectId = extractProjectId(data.response as Record | undefined); + return projectId ? { status: "ready", projectId } : { status: "unavailable" }; + } + polls += 1; + await new Promise(resolve => setTimeout(resolve, ONBOARD_POLL_MS)); + } + // Onboarding was accepted and is still running. Distinguished from "unavailable" so the + // login error does not blame the account's entitlement for a slow first-time provision. + return { status: "pending" }; +} + +/** + * Discover the CCA project for an access token (loadCodeAssist → onboardUser fallback), keeping + * the reason when there is none. `pending` is not a failure to report as missing access. + */ +export async function discoverGeminiProjectOutcome( + accessToken: string, + signal?: AbortSignal, +): Promise { + const loaded = await loadCodeAssist(accessToken, signal); + const existing = extractProjectId(loaded); + if (existing) return { status: "ready", projectId: existing }; + return await onboardProject(accessToken, extractDefaultTierId(loaded), signal); +} + +/** Discover the CCA project for an access token, or `undefined` when there is none yet. */ +export async function discoverGeminiProject(accessToken: string, signal?: AbortSignal): Promise { + const outcome = await discoverGeminiProjectOutcome(accessToken, signal); + return outcome.status === "ready" ? outcome.projectId : undefined; +} + +function credentialsFromPayload(payload: GoogleTokenPayload, refreshFallback = ""): OAuthCredentials { + if (typeof payload.access_token !== "string" || payload.access_token.length === 0) { + throw new Error("Gemini token response did not include an access token"); + } + const refresh = typeof payload.refresh_token === "string" && payload.refresh_token.length > 0 + ? payload.refresh_token + : refreshFallback; + if (!refresh) throw new Error("Gemini token response did not include a refresh token"); + const expiresIn = typeof payload.expires_in === "number" && Number.isFinite(payload.expires_in) ? payload.expires_in : 3600; + const idToken = typeof payload.id_token === "string" ? payload.id_token : undefined; + return { + refresh, + access: payload.access_token, + expires: Date.now() + expiresIn * 1000 - REFRESH_SKEW_MS, + email: emailFromToken(payload.access_token, idToken), + }; +} + +class GeminiOAuthFlow extends OAuthCallbackFlow { + #verifier = ""; + #subtype: GeminiOAuthSubtype; + #forceAccountSelect: boolean; + + constructor(ctrl: OAuthController, subtype: GeminiOAuthSubtype, opts?: { forceAccountSelect?: boolean }) { + super(ctrl, { + preferredPort: CALLBACK_PORT, + callbackPath: CALLBACK_PATH, + callbackHostname: "127.0.0.1", + callbackBindHostname: "127.0.0.1", + redirectUri: `http://127.0.0.1:${CALLBACK_PORT}${CALLBACK_PATH}`, + } satisfies OAuthCallbackFlowOptions); + this.#subtype = subtype; + this.#forceAccountSelect = opts?.forceAccountSelect === true; + } + + async generateAuthUrl(state: string, redirectUri: string): Promise<{ url: string; instructions?: string }> { + const client = geminiOAuthClient(this.#subtype); + const pkce = await generatePKCE(); + this.#verifier = pkce.verifier; + const params = new URLSearchParams({ + response_type: "code", + client_id: client.clientId, + redirect_uri: redirectUri, + scope: client.scopes.join(" "), + code_challenge: pkce.challenge, + code_challenge_method: "S256", + access_type: "offline", + // select_account lets the user pick a DIFFERENT Google account when adding a second one. + prompt: this.#forceAccountSelect ? "consent select_account" : "consent", + state, + }); + return { + url: `${AUTH_ENDPOINT}?${params.toString()}`, + instructions: "Complete Google login in your browser, then paste the redirect URL or code if prompted.", + }; + } + + async exchangeToken(code: string, _state: string, redirectUri: string): Promise { + if (!this.#verifier) throw new Error("Gemini OAuth PKCE verifier was not initialized"); + const client = geminiOAuthClient(this.#subtype); + const payload = await postToken({ + grant_type: "authorization_code", + client_id: client.clientId, + client_secret: client.clientSecret, + code, + redirect_uri: redirectUri, + code_verifier: this.#verifier, + }, this.ctrl.signal); + const creds = credentialsFromPayload(payload); + if (this.#subtype === "ai-studio") return creds; + this.ctrl.onProgress?.("Discovering Cloud Code Assist project"); + const outcome = await discoverGeminiProjectOutcome(creds.access, this.ctrl.signal); + if (outcome.status !== "ready") { + // Fail the login rather than persisting a credential that every request would reject for a + // missing CCA project — otherwise status shows "logged in" while all calls fail closed. + // Onboarding that is still running gets its own message: telling the user to check their + // Code Assist access would send them to look for a problem that does not exist. + throw new Error(outcome.status === "pending" + ? "Gemini login timed out waiting for Google to finish provisioning a Cloud Code Assist project for this account. This is normal for a first login — retry in a minute and it usually completes." + : "Gemini login could not discover a Cloud Code Assist project for this account. Ensure the Google account has Gemini Code Assist access and try again."); + } + return { ...creds, projectId: outcome.projectId }; + } +} + +export async function loginGemini( + ctrl: OAuthController, + subtype: GeminiOAuthSubtype, + opts?: { forceAccountSelect?: boolean }, +): Promise { + return new GeminiOAuthFlow(ctrl, subtype, opts).login(); +} + +export async function refreshGeminiToken( + refreshToken: string, + subtype: GeminiOAuthSubtype, + signal?: AbortSignal, +): Promise { + if (!refreshToken) throw new Error("Gemini credentials are expired and do not include a refresh token"); + const client = geminiOAuthClient(subtype); + const payload = await postToken({ + grant_type: "refresh_token", + client_id: client.clientId, + client_secret: client.clientSecret, + refresh_token: refreshToken, + }, signal); + const creds = credentialsFromPayload(payload, refreshToken); + if (subtype === "ai-studio") return creds; + // Re-discover the project on refresh so a newly-onboarded account fills in projectId. + const projectId = await discoverGeminiProject(creds.access, signal).catch(() => undefined); + return projectId ? { ...creds, projectId } : creds; +} + +/** + * Refresh and derive import identity from Google's pinned userinfo endpoint. Imports may not + * borrow the email written in a local file: that would let one valid token overwrite another + * account's slot when a refresh response has no id_token. + */ +export async function validateGeminiImportCredential( + refreshToken: string, + subtype: GeminiOAuthSubtype, + signal?: AbortSignal, +): Promise { + const credential = await refreshGeminiToken(refreshToken, subtype, signal); + const response = await fetch(USERINFO_ENDPOINT, { + method: "GET", + headers: { Accept: "application/json", Authorization: `Bearer ${credential.access}` }, + signal: requestSignal(signal), + }); + if (!response.ok) throw new Error(`Gemini identity request failed: ${response.status}`); + const body = (await response.json().catch(() => undefined)) as { email?: unknown; id?: unknown } | undefined; + if (typeof body?.email !== "string" || body.email.length === 0) { + throw new Error("Gemini identity response did not include an email"); + } + if (typeof body.id !== "string" || body.id.length === 0) { + throw new Error("Gemini identity response did not include an account id"); + } + return { ...credential, accountId: body.id, email: body.email.toLowerCase() }; +} diff --git a/src/oauth/index.ts b/src/oauth/index.ts index fe3abe7656..21bd398be9 100644 --- a/src/oauth/index.ts +++ b/src/oauth/index.ts @@ -11,6 +11,7 @@ import { loginKimi, refreshKimiToken } from "./kimi"; import { loginNous, NousTokenError, refreshNousToken, clearNousRefreshIntent, RefreshIntentIOError } from "./nous"; import { loginChatGPT, refreshChatGPTToken } from "./chatgpt"; import { loginAntigravity, refreshAntigravityToken } from "./google-antigravity"; +import { GEMINI_AI_STUDIO_PROVIDER, GEMINI_CODE_ASSIST_PROVIDER, loginGemini, refreshGeminiToken } from "./gemini-cli"; import { loginCursor, refreshCursorToken } from "./cursor"; import { loginGithubCopilot, refreshGithubCopilotToken, validateCopilotApiBaseUrl } from "./github-copilot"; import { loginCommandCode, refreshCommandCodeToken } from "./command-code"; @@ -223,6 +224,25 @@ export const OAUTH_PROVIDERS: Record = { providerConfig: oauthConfig("google-antigravity"), defaultModel: oauthDefaultModel("google-antigravity"), }, + // Gemini OAuth (Google account). The two subtypes are modeled as separate providers so each + // owns its own account set: a Code Assist credential and an AI Studio credential are minted by + // different OAuth clients with different scopes and are not interchangeable. + [GEMINI_CODE_ASSIST_PROVIDER]: { + login: (ctrl, opts) => loginGemini(ctrl, "code-assist", { forceAccountSelect: opts?.forceLogin === true }), + refresh: (rt, signal) => refreshGeminiToken(rt, "code-assist", signal), + providerConfig: oauthConfig(GEMINI_CODE_ASSIST_PROVIDER), + defaultModel: oauthDefaultModel(GEMINI_CODE_ASSIST_PROVIDER), + // Presents Google's first-party Gemini CLI client from a proxy, and each refresh also re-runs + // Code Assist project discovery — a proactive policy would multiply that traffic under client + // identifiers we do not own. Anchor lazy-only explicitly so it cannot drift to proactive. + defaultRefreshPolicy: "lazy-only", + }, + [GEMINI_AI_STUDIO_PROVIDER]: { + login: (ctrl, opts) => loginGemini(ctrl, "ai-studio", { forceAccountSelect: opts?.forceLogin === true }), + refresh: (rt, signal) => refreshGeminiToken(rt, "ai-studio", signal), + providerConfig: oauthConfig(GEMINI_AI_STUDIO_PROVIDER), + defaultModel: oauthDefaultModel(GEMINI_AI_STUDIO_PROVIDER), + }, cursor: { login: (ctrl, opts) => loginCursor(ctrl, undefined, { forceLogin: opts?.forceLogin }), refresh: refreshCursorToken, diff --git a/src/providers/derive.ts b/src/providers/derive.ts index 3db91f73df..9be77523a1 100644 --- a/src/providers/derive.ts +++ b/src/providers/derive.ts @@ -1,4 +1,4 @@ -import type { CodexAccountMode, OcxProviderConfig } from "../types"; +import type { CodexAccountMode, GoogleAdapterMode, OcxProviderConfig } from "../types"; import { cloneFastWire } from "./fastwire"; import { PROVIDER_REGISTRY, @@ -47,7 +47,7 @@ export interface DerivedKeyLoginProvider { thinkingToggleModels?: string[]; thinkingBudgetModels?: string[]; escapeBuiltinToolNames?: boolean; - googleMode?: "ai-studio" | "vertex" | "cloud-code-assist"; + googleMode?: GoogleAdapterMode; project?: string; location?: string; } diff --git a/src/providers/registry.ts b/src/providers/registry.ts index bd25a5ea3f..e11f83b26a 100644 --- a/src/providers/registry.ts +++ b/src/providers/registry.ts @@ -1,4 +1,4 @@ -import type { CodexAccountMode, FastWire, OcxProviderConfig } from "../types"; +import type { CodexAccountMode, FastWire, GoogleAdapterMode, OcxProviderConfig } from "../types"; import { fastWireDeclarationError } from "./fastwire"; import { KIRO_MODELS, KIRO_MODEL_CONTEXT_WINDOWS, KIRO_MODEL_REASONING_EFFORTS } from "./kiro-models"; import { ANTIGRAVITY_MODELS, ANTIGRAVITY_MODEL_CONTEXT_WINDOWS, ANTIGRAVITY_MODEL_EFFORTS, ANTIGRAVITY_MODEL_INPUT_MODALITIES } from "./antigravity-models"; @@ -279,7 +279,7 @@ export interface ProviderRegistryEntry { jawcodeBundle?: string; extraMetadataAliases?: string[]; metadataModelIdNormalize?: MetadataModelIdNormalize; - googleMode?: "ai-studio" | "vertex" | "cloud-code-assist"; + googleMode?: GoogleAdapterMode; project?: string; location?: string; } @@ -302,6 +302,28 @@ export type ProviderConfigSeed = Pick< const ANTHROPIC_MODELS = ["claude-fable-5", "claude-sonnet-5", "claude-opus-5", "claude-opus-4-8", "claude-opus-4-7", "claude-opus-4-6", "claude-sonnet-4-6", "claude-haiku-4-5"]; const ANTHROPIC_MODEL_CONTEXT_WINDOWS: Record = { "claude-sonnet-5": 1_000_000, "claude-fable-5": 1_000_000, "claude-opus-5": 1_000_000, "claude-opus-4-8": 1_000_000, "claude-opus-4-7": 1_000_000, "claude-opus-4-6": 1_000_000, "claude-sonnet-4-6": 1_000_000, "claude-haiku-4-5": 200_000 }; +// Gemini CLI (Cloud Code Assist) seed catalog. +// +// Deliberately the PUBLIC Gemini model ids, not the Antigravity wire ids: the CLI OAuth client +// talks to the same cloudcode-pa host but is a different client family, and the IDE-gated agent +// wire ids (gemini-pro-agent, claude-*, gpt-oss-*) answer 404 for a CLI credential. Live +// discovery is off for the same reason — CCA's :fetchAvailableModels is the IDE catalog. +const GEMINI_CLI_MODELS = ["gemini-3.5-flash", "gemini-3.6-flash", "gemini-3.1-pro-preview"]; +const GEMINI_CLI_MODEL_CONTEXT_WINDOWS: Record = { + "gemini-3.5-flash": 1_000_000, + "gemini-3.6-flash": 1_048_576, + "gemini-3.1-pro-preview": 1_048_576, +}; +const GEMINI_CLI_MODEL_INPUT_MODALITIES: Record = { + "gemini-3.6-flash": ["text", "image"], + "gemini-3.1-pro-preview": ["text", "image"], +}; +const GEMINI_CLI_MODEL_REASONING_EFFORTS: Record = { + "gemini-3.5-flash": ["minimal", "low", "medium", "high"], + "gemini-3.6-flash": ["minimal", "low", "medium", "high"], + "gemini-3.1-pro-preview": ["low", "medium", "high"], +}; + // 260814 GLM-5.3 is registered pre-emptively alongside 5.2 everywhere 5.2 appears. Z.AI's // devpack "How to Switch Models" page (docs.z.ai/devpack/latest-model) lists glm-5.3 and // glm-5.3[1m] as Coding Plan ids on the unchanged endpoints; the capability and pricing @@ -1522,6 +1544,39 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ // evidence from ai.google.dev does not establish Vertex publisher availability. { id: "google-vertex", label: "Google Vertex AI", adapter: "google", baseUrl: "https://aiplatform.googleapis.com", authKind: "key", dashboardUrl: "https://console.cloud.google.com/vertex-ai", defaultModel: "gemini-3-pro", googleMode: "vertex", jawcodeBundle: "google", extraMetadataAliases: ["gemini-vertex"] }, { id: "google-antigravity", label: "Google Antigravity", adapter: "google", baseUrl: "https://daily-cloudcode-pa.googleapis.com", authKind: "oauth", allowBaseUrlOverride: true, dashboardUrl: "https://antigravity.google", models: ANTIGRAVITY_MODELS, liveModels: true, defaultModel: "gemini-3.7-flash", modelContextWindows: ANTIGRAVITY_MODEL_CONTEXT_WINDOWS, modelInputModalities: ANTIGRAVITY_MODEL_INPUT_MODALITIES, modelReasoningEfforts: ANTIGRAVITY_MODEL_EFFORTS, googleMode: "cloud-code-assist", jawcodeBundle: "google", extraMetadataAliases: ["antigravity", "gemini-antigravity"] }, + // Gemini OAuth (Google account), Code Assist subtype — the Gemini CLI first-party client. + // Same cloudcode-pa host as Antigravity but a DIFFERENT client family: `googleMode: + // "gemini-cli"` selects the CLI's plain `{model, project, request}` envelope and the + // `GeminiCLI/` User-Agent. See src/oauth/gemini-cli.ts. + { + id: "gemini-cli", label: "Gemini (Code Assist)", adapter: "google", baseUrl: "https://cloudcode-pa.googleapis.com", + // No allowBaseUrlOverride: unlike Antigravity (daily/prod hosts) the CLI endpoint is a single + // fixed host, so pinning it keeps the Google OAuth bearer from reaching an operator-set URL. + authKind: "oauth", dashboardPreset: true, dashboardUrl: "https://aistudio.google.com", + models: GEMINI_CLI_MODELS, defaultModel: "gemini-3.5-flash", + modelContextWindows: GEMINI_CLI_MODEL_CONTEXT_WINDOWS, + modelInputModalities: GEMINI_CLI_MODEL_INPUT_MODALITIES, + modelReasoningEfforts: GEMINI_CLI_MODEL_REASONING_EFFORTS, + // jawcodeBundle routes model metadata to Google's catalog. No extraMetadataAliases: the + // "gemini" alias already belongs to the `google` entry and a second claim would shadow it. + googleMode: "gemini-cli", jawcodeBundle: "google", + note: "OAuth (Google account) — Code Assist subtype. Works with Google One AI Pro/Ultra plans.", + }, + // Gemini OAuth, AI Studio subtype: generativelanguage.googleapis.com with an OAuth bearer + // instead of an API key. Requires operator-registered client credentials (see oauth/gemini-cli.ts); + // login fails closed with an actionable message when they are unset. + { + id: "gemini-ai-studio", label: "Gemini (AI Studio OAuth)", adapter: "google", baseUrl: "https://generativelanguage.googleapis.com", + // Fixed Generative Language host; see the gemini-cli entry for why the bearer is not + // allowed to follow an operator-set baseUrl. + authKind: "oauth", dashboardPreset: true, dashboardUrl: "https://aistudio.google.com/apikey", + models: GEMINI_CLI_MODELS, defaultModel: "gemini-3.5-flash", + modelContextWindows: GEMINI_CLI_MODEL_CONTEXT_WINDOWS, + modelInputModalities: GEMINI_CLI_MODEL_INPUT_MODALITIES, + modelReasoningEfforts: GEMINI_CLI_MODEL_REASONING_EFFORTS, + googleMode: "ai-studio", jawcodeBundle: "google", + note: "OAuth (Google account) — AI Studio subtype. Needs GEMINI_AI_STUDIO_OAUTH_CLIENT_ID/SECRET.", + }, { id: "azure-openai", label: "Azure OpenAI", adapter: "azure-openai", baseUrl: "https://{resource}.openai.azure.com/openai", authKind: "key", featured: true, dashboardUrl: "https://portal.azure.com" }, { id: "ollama", label: "Ollama (local)", adapter: "openai-chat", baseUrl: "http://localhost:11434/v1", authKind: "local", allowPrivateNetworkByDefault: true, allowBaseUrlOverride: true, featured: true, note: "Local — key usually blank" }, { id: "vllm", label: "vLLM (local)", adapter: "openai-chat", baseUrl: "http://localhost:8000/v1", authKind: "local", allowPrivateNetworkByDefault: true, allowBaseUrlOverride: true, featured: true, note: "Local — key usually blank" }, @@ -2799,8 +2854,8 @@ export function providerCodexAccountMode(id: string, provider?: OcxProviderConfi */ export function effectiveGoogleMode( providerId: string, - prov: { adapter?: string; googleMode?: "ai-studio" | "vertex" | "cloud-code-assist" }, -): "ai-studio" | "vertex" | "cloud-code-assist" | null { + prov: { adapter?: string; googleMode?: GoogleAdapterMode }, +): GoogleAdapterMode | null { if (prov.adapter !== "google") return null; return prov.googleMode ?? getProviderRegistryEntry(providerId)?.googleMode ?? "ai-studio"; } diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index 3c7773e4e4..fcb61c1479 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -2290,10 +2290,13 @@ async function handleResponsesInner( // Only genuinely accountless adapter calls leave the context undefined and use local/env fallback. parsed._kiroAuthContext = { ...(resolved.kiro ?? {}) }; } - // Antigravity (cloud-code-assist) needs the discovered Cloud Code Assist project id in the - // CCA envelope. Keep it paired with the token snapshot so an account rotation cannot mix - // a fresh token with project metadata re-read from a different credential generation. - if (route.provider.googleMode === "cloud-code-assist" && !route.provider.project) { + // Both Cloud Code Assist client families (Antigravity IDE and Gemini CLI) need the + // discovered CCA project id in their request envelope. Keep it paired with the token + // snapshot so an account rotation cannot mix a fresh token with project metadata re-read + // from a different credential generation. + const codeAssistEnvelope = route.provider.googleMode === "cloud-code-assist" + || route.provider.googleMode === "gemini-cli"; + if (codeAssistEnvelope && !route.provider.project) { const projectId = resolved.projectId; if (projectId) route.provider = { ...route.provider, project: projectId }; } diff --git a/src/types.ts b/src/types.ts index f474695ca7..2c19459a40 100644 --- a/src/types.ts +++ b/src/types.ts @@ -83,6 +83,7 @@ export type { export type { RefreshPolicy, + GoogleAdapterMode, OpenRouterProviderRouting, ResponsesItemIdRepairConfig, RateLimitRetryPolicy, diff --git a/src/types/provider.ts b/src/types/provider.ts index 72fbc10033..802af16c2f 100644 --- a/src/types/provider.ts +++ b/src/types/provider.ts @@ -8,6 +8,17 @@ import type { UpstreamHttpVersion, ReasoningSummaryDelivery, CodexAccountMode } */ export type RefreshPolicy = "proactive" | "lazy-only" | "disabled"; +/** + * Google adapter mode. "ai-studio" (default) = Generative Language API + x-goog-api-key. + * "vertex" = Vertex AI project/location endpoints with GCP ADC (or x-goog-api-key). + * "cloud-code-assist" = Google Antigravity (Cloud Code Assist) OAuth + CCA envelope. + * "gemini-cli" = Gemini CLI OAuth against the same CCA endpoint, but with the CLI's plain + * `{model, project, request}` envelope and CLI User-Agent instead of the Antigravity IDE + * fingerprint. The last two are NOT interchangeable: sending the Antigravity envelope with a + * CLI credential mismatches the client family the token was issued to. + */ +export type GoogleAdapterMode = "ai-studio" | "vertex" | "cloud-code-assist" | "gemini-cli"; + export interface OpenRouterProviderRouting { /** OpenRouter provider slugs to try first, in priority order. */ order?: string[]; @@ -475,12 +486,8 @@ export interface OcxProviderConfig { * attached images are described by a gpt vision model and replaced with text before the call. */ noVisionModels?: string[]; - /** - * Google adapter mode. "ai-studio" (default) = Generative Language API + x-goog-api-key. - * "vertex" = Vertex AI project/location endpoints with GCP ADC (or x-goog-api-key). - * "cloud-code-assist" = Google Antigravity (Cloud Code Assist) OAuth + CCA envelope. - */ - googleMode?: "ai-studio" | "vertex" | "cloud-code-assist"; + /** Google adapter mode; see {@link GoogleAdapterMode}. */ + googleMode?: GoogleAdapterMode; /** Vertex AI GCP project id (or GOOGLE_CLOUD_PROJECT / GCLOUD_PROJECT env). */ project?: string; /** Vertex AI location, e.g. "us-central1" or "global" (or GOOGLE_CLOUD_LOCATION env). */ diff --git a/tests/gemini-cli-oauth.test.ts b/tests/gemini-cli-oauth.test.ts new file mode 100644 index 0000000000..cf7fbf5480 --- /dev/null +++ b/tests/gemini-cli-oauth.test.ts @@ -0,0 +1,401 @@ +import { afterEach, describe, expect, spyOn, test } from "bun:test"; +import { + GEMINI_AI_STUDIO_PROVIDER, + GEMINI_CODE_ASSIST_PROVIDER, + GeminiOAuthClientNotConfiguredError, + discoverGeminiProject, + discoverGeminiProjectOutcome, + geminiOAuthClient, + geminiSubtypeForProvider, + isGeminiOAuthSubtypeConfigured, + refreshGeminiToken, +} from "../src/oauth/gemini-cli"; +import { OAUTH_PROVIDERS, isPublicOAuthProvider } from "../src/oauth"; +import { PROVIDER_REGISTRY } from "../src/providers/registry"; +import { deriveOAuthIds } from "../src/providers/derive"; +import { geminiCliUserAgent } from "../src/adapters/client-fingerprint"; +import { createGoogleAdapter as createGoogleAdapterProduction } from "../src/adapters/google"; +import type { AdapterEvent, OcxParsedRequest, OcxProviderConfig } from "../src/types"; +import { withTestTranslatorBudget } from "./helpers/translator-budget"; + +const createGoogleAdapter = (...args: Parameters) => + withTestTranslatorBudget(createGoogleAdapterProduction(...args)); + +const realFetch = globalThis.fetch; +afterEach(() => { globalThis.fetch = realFetch; }); + +function routeFetch(handler: (url: string, init?: RequestInit) => Response | Promise): { calls: string[] } { + const calls: string[] = []; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = typeof input === "string" ? input : input instanceof URL ? input.href : (input as Request).url; + calls.push(url); + return handler(url, init); + }) as typeof fetch; + return { calls }; +} + +describe("gemini oauth subtypes", () => { + test("provider id maps to its subtype", () => { + expect(geminiSubtypeForProvider(GEMINI_CODE_ASSIST_PROVIDER)).toBe("code-assist"); + expect(geminiSubtypeForProvider(GEMINI_AI_STUDIO_PROVIDER)).toBe("ai-studio"); + }); + + test("code-assist carries the built-in CLI client", () => { + expect(isGeminiOAuthSubtypeConfigured("code-assist")).toBe(true); + const client = geminiOAuthClient("code-assist"); + expect(client.clientId).toBeTruthy(); + expect(client.clientSecret).toBeTruthy(); + expect(client.scopes).toContain("https://www.googleapis.com/auth/cloud-platform"); + }); + + test("ai-studio fails closed with an actionable error when no operator client is registered", () => { + // The built-in Gemini CLI client is not registered for the generative-language scopes, so + // this subtype must fail closed rather than send a request Google rejects as restricted_client. + // geminiOAuthClient reads module-level constants captured at import, so process.env cannot be + // mutated here; branch on the ambient value and assert the contract that holds either way. + const hasOperatorClient = !!process.env.GEMINI_AI_STUDIO_OAUTH_CLIENT_ID?.trim() + && !!process.env.GEMINI_AI_STUDIO_OAUTH_CLIENT_SECRET?.trim(); + if (hasOperatorClient) { + expect(geminiOAuthClient("ai-studio").clientId).toBeTruthy(); + return; + } + expect(isGeminiOAuthSubtypeConfigured("ai-studio")).toBe(false); + expect(() => geminiOAuthClient("ai-studio")).toThrow(GeminiOAuthClientNotConfiguredError); + // The message must name both variables, or the dashboard hint tells the user nothing. + expect(() => geminiOAuthClient("ai-studio")) + .toThrow(/GEMINI_AI_STUDIO_OAUTH_CLIENT_ID[\s\S]*GEMINI_AI_STUDIO_OAUTH_CLIENT_SECRET/); + }); + + test("both subtypes are registered as public OAuth providers derived from the registry", () => { + for (const id of [GEMINI_CODE_ASSIST_PROVIDER, GEMINI_AI_STUDIO_PROVIDER]) { + expect(OAUTH_PROVIDERS[id]).toBeDefined(); + expect(isPublicOAuthProvider(id)).toBe(true); + // Registry-derived: without an authKind "oauth" entry, providerConfig/defaultModel are + // undefined and the dashboard row would log in to nothing. + expect(deriveOAuthIds()).toContain(id); + expect(OAUTH_PROVIDERS[id]!.providerConfig).toBeDefined(); + expect(OAUTH_PROVIDERS[id]!.defaultModel).toBeTruthy(); + } + }); + + test("code-assist stays lazy-only so proactive refresh cannot multiply first-party-client traffic", () => { + // Each Code Assist refresh also re-runs project discovery against Google's own CLI client + // identifiers. Pinned here because the default when unset is silently "lazy-only" too, so a + // drift to "proactive" would otherwise pass unnoticed. + expect(OAUTH_PROVIDERS[GEMINI_CODE_ASSIST_PROVIDER]!.defaultRefreshPolicy).toBe("lazy-only"); + }); + + test("registry pins each subtype's googleMode and host", () => { + const codeAssist = PROVIDER_REGISTRY.find(e => e.id === GEMINI_CODE_ASSIST_PROVIDER); + expect(codeAssist?.googleMode).toBe("gemini-cli"); + expect(codeAssist?.baseUrl).toBe("https://cloudcode-pa.googleapis.com"); + const aiStudio = PROVIDER_REGISTRY.find(e => e.id === GEMINI_AI_STUDIO_PROVIDER); + expect(aiStudio?.googleMode).toBe("ai-studio"); + expect(aiStudio?.baseUrl).toBe("https://generativelanguage.googleapis.com"); + }); +}); + +describe("gemini code assist project discovery", () => { + test("loadCodeAssist returns the project and sends the CLI User-Agent", async () => { + let sawCliUa = false; + routeFetch((url, init) => { + if (url.includes(":loadCodeAssist")) { + const headers = (init?.headers ?? {}) as Record; + // Must be the CLI client family, never `antigravity/ide/...`: the token was minted for + // the Gemini CLI client and the header has to match it. + sawCliUa = /^GeminiCLI\//.test(headers["User-Agent"] ?? ""); + return new Response(JSON.stringify({ cloudaicompanionProject: "proj-A" }), { status: 200 }); + } + return new Response("no", { status: 404 }); + }); + expect(await discoverGeminiProject("tok")).toBe("proj-A"); + expect(sawCliUa).toBe(true); + }); + + test("extracts a project from the nested {id} shape", async () => { + routeFetch(url => url.includes(":loadCodeAssist") + ? new Response(JSON.stringify({ project: { id: "proj-nested" } }), { status: 200 }) + : new Response("no", { status: 404 })); + expect(await discoverGeminiProject("tok")).toBe("proj-nested"); + }); + + test("falls back to onboardUser and onboards into the default allowed tier", async () => { + let onboardBody: string | undefined; + let onboardCalls = 0; + routeFetch((url, init) => { + if (url.includes(":loadCodeAssist")) { + return new Response(JSON.stringify({ + allowedTiers: [{ id: "legacy-tier" }, { id: "standard-tier", isDefault: true }], + }), { status: 200 }); + } + if (url.includes(":onboardUser")) { + onboardCalls++; + onboardBody = typeof init?.body === "string" ? init.body : undefined; + if (onboardCalls === 1) return new Response(JSON.stringify({ done: false }), { status: 200 }); + return new Response(JSON.stringify({ done: true, response: { cloudaicompanionProject: "proj-onboarded" } }), { status: 200 }); + } + return new Response("no", { status: 404 }); + }); + expect(await discoverGeminiProject("tok")).toBe("proj-onboarded"); + // Sending a tier the account is not entitled to makes onboardUser fail closed, so the + // advertised default must win over the free-tier fallback. + expect(JSON.parse(onboardBody ?? "{}").tierId).toBe("standard-tier"); + }); + + test("uses the free tier when loadCodeAssist advertises no default", async () => { + let onboardBody: string | undefined; + routeFetch((url, init) => { + if (url.includes(":loadCodeAssist")) return new Response(JSON.stringify({}), { status: 200 }); + if (url.includes(":onboardUser")) { + onboardBody = typeof init?.body === "string" ? init.body : undefined; + return new Response(JSON.stringify({ done: true, response: { cloudaicompanionProject: "p" } }), { status: 200 }); + } + return new Response("no", { status: 404 }); + }); + await discoverGeminiProject("tok"); + expect(JSON.parse(onboardBody ?? "{}").tierId).toBe("free-tier"); + }); + + test("gives up on a hard 4xx from onboardUser", async () => { + routeFetch((url) => { + if (url.includes(":loadCodeAssist")) return new Response(JSON.stringify({}), { status: 200 }); + if (url.includes(":onboardUser")) return new Response("forbidden", { status: 403 }); + return new Response("no", { status: 404 }); + }); + expect(await discoverGeminiProject("tok")).toBeUndefined(); + }); + + test("retries a transient 503 from onboardUser", async () => { + let onboardCalls = 0; + routeFetch((url) => { + if (url.includes(":loadCodeAssist")) return new Response(JSON.stringify({}), { status: 200 }); + if (url.includes(":onboardUser")) { + onboardCalls++; + if (onboardCalls === 1) return new Response("busy", { status: 503 }); + return new Response(JSON.stringify({ done: true, response: { cloudaicompanionProject: "proj-T" } }), { status: 200 }); + } + return new Response("no", { status: 404 }); + }); + expect(await discoverGeminiProject("tok")).toBe("proj-T"); + expect(onboardCalls).toBe(2); + }); + + test("transient retries do not consume the in-progress polling budget", async () => { + // Regression: one shared counter let a couple of 5xx responses exhaust the budget before + // onboarding could finish, turning a slow first-time provision into a login failure. + let onboardCalls = 0; + routeFetch((url) => { + if (url.includes(":loadCodeAssist")) return new Response(JSON.stringify({}), { status: 200 }); + if (url.includes(":onboardUser")) { + onboardCalls++; + // Two transient failures, then four in-progress polls: more than the 5-attempt budget + // would allow if both drew from the same counter. + if (onboardCalls <= 2) return new Response("busy", { status: 503 }); + if (onboardCalls <= 6) return new Response(JSON.stringify({ done: false }), { status: 200 }); + return new Response(JSON.stringify({ done: true, response: { cloudaicompanionProject: "proj-slow" } }), { status: 200 }); + } + return new Response("no", { status: 404 }); + }); + expect(await discoverGeminiProject("tok")).toBe("proj-slow"); + // 2 transient sleeps + 4 in-progress polls at the real 2s ONBOARD_POLL_MS. + }, 20000); + + test("still-provisioning onboarding is reported as pending, not as missing access", async () => { + // The two outcomes send the user to different places, so they must not collapse into one. + routeFetch((url) => { + if (url.includes(":loadCodeAssist")) return new Response(JSON.stringify({}), { status: 200 }); + if (url.includes(":onboardUser")) return new Response(JSON.stringify({ done: false }), { status: 200 }); + return new Response("no", { status: 404 }); + }); + expect(await discoverGeminiProjectOutcome("tok")).toEqual({ status: "pending" }); + // Exhausts the full 5-poll budget at the real 2s ONBOARD_POLL_MS. + }, 20000); + + test("a hard 4xx is reported as unavailable rather than pending", async () => { + routeFetch((url) => { + if (url.includes(":loadCodeAssist")) return new Response(JSON.stringify({}), { status: 200 }); + if (url.includes(":onboardUser")) return new Response("forbidden", { status: 403 }); + return new Response("no", { status: 404 }); + }); + expect(await discoverGeminiProjectOutcome("tok")).toEqual({ status: "unavailable" }); + }); + + test("gives up once transient retries are exhausted", async () => { + let onboardCalls = 0; + routeFetch((url) => { + if (url.includes(":loadCodeAssist")) return new Response(JSON.stringify({}), { status: 200 }); + if (url.includes(":onboardUser")) { onboardCalls++; return new Response("busy", { status: 503 }); } + return new Response("no", { status: 404 }); + }); + expect(await discoverGeminiProjectOutcome("tok")).toEqual({ status: "unavailable" }); + // The transient budget bounds the retries; an unbounded loop would hang the login. + expect(onboardCalls).toBe(4); + // 3 transient sleeps at the real 2s ONBOARD_POLL_MS before giving up. + }, 20000); +}); + +describe("gemini refresh", () => { + test("code-assist refresh keeps the refresh token and re-discovers the project", async () => { + routeFetch((url) => { + if (url.includes("oauth2.googleapis.com/token")) { + return new Response(JSON.stringify({ access_token: "fresh-access", expires_in: 3600 }), { status: 200 }); + } + if (url.includes(":loadCodeAssist")) return new Response(JSON.stringify({ cloudaicompanionProject: "proj-R" }), { status: 200 }); + return new Response("no", { status: 404 }); + }); + const issuedAt = 1_900_000_000_000; + const nowSpy = spyOn(Date, "now").mockReturnValue(issuedAt); + try { + const cred = await refreshGeminiToken("refresh-tok", "code-assist"); + expect(cred.access).toBe("fresh-access"); + expect(cred.refresh).toBe("refresh-tok"); + expect(cred.projectId).toBe("proj-R"); + expect(cred.expires - issuedAt).toBe(55 * 60 * 1000); + } finally { + nowSpy.mockRestore(); + } + }); + + test("code-assist refresh survives a project-discovery outage", async () => { + routeFetch((url) => { + if (url.includes("oauth2.googleapis.com/token")) { + return new Response(JSON.stringify({ access_token: "fresh-access", expires_in: 3600 }), { status: 200 }); + } + // Discovery unavailable for this account: the token is still good, so refresh must not + // fail the whole account. A hard 4xx (not a retryable 5xx) so onboarding gives up at once. + return new Response("forbidden", { status: 403 }); + }); + const cred = await refreshGeminiToken("refresh-tok", "code-assist"); + expect(cred.access).toBe("fresh-access"); + expect(cred.projectId).toBeUndefined(); + }); + + test("refresh failure carries status only, not the response body", async () => { + routeFetch(url => url.includes("oauth2.googleapis.com/token") + ? new Response("invalid_grant secret-detail", { status: 400 }) + : new Response("no", { status: 404 })); + let caught: Error | undefined; + try { await refreshGeminiToken("refresh-tok", "code-assist"); } catch (e) { caught = e as Error; } + expect(caught).toBeDefined(); + expect(caught!.message).toContain("400"); + expect(caught!.message).not.toContain("secret-detail"); + }); + + test("an empty refresh token is rejected before any network call", async () => { + const { calls } = routeFetch(() => new Response("unexpected", { status: 500 })); + await expect(refreshGeminiToken("", "code-assist")).rejects.toThrow(/refresh token/i); + expect(calls).toEqual([]); + }); +}); + +function geminiCliProvider(overrides: Partial = {}): OcxProviderConfig { + return { + adapter: "google", + baseUrl: "https://cloudcode-pa.googleapis.com", + authMode: "oauth", + googleMode: "gemini-cli", + apiKey: "access-tok", + project: "proj-X", + defaultModel: "gemini-3.5-flash", + models: ["gemini-3.5-flash"], + ...overrides, + } as OcxProviderConfig; +} + +function parsedRequest(modelId = "gemini-3.5-flash", stream = false): OcxParsedRequest { + return { + modelId, + stream, + context: { messages: [{ role: "user", content: "hello world" }], systemPrompt: [], tools: [] }, + options: {}, + } as unknown as OcxParsedRequest; +} + +describe("gemini-cli adapter envelope", () => { + test("sends the CLI envelope and User-Agent, not the Antigravity IDE fingerprint", async () => { + const built = await createGoogleAdapter(geminiCliProvider()).buildRequest(parsedRequest()); + expect(built.url).toBe("https://cloudcode-pa.googleapis.com/v1internal:generateContent"); + expect(built.headers["Authorization"]).toBe("Bearer access-tok"); + expect(built.headers["User-Agent"]).toBe(geminiCliUserAgent()); + expect(built.headers["User-Agent"]).not.toContain("antigravity"); + + const envelope = JSON.parse(built.body) as Record; + // The CLI envelope is exactly {model, project, request}. The Antigravity-only fields belong to + // the IDE client family and would mismatch the client the CLI token was issued to. + expect(Object.keys(envelope).toSorted()).toEqual(["model", "project", "request"]); + expect(envelope.model).toBe("gemini-3.5-flash"); + expect(envelope.project).toBe("proj-X"); + expect(envelope.userAgent).toBeUndefined(); + expect(envelope.requestType).toBeUndefined(); + expect(envelope.requestId).toBeUndefined(); + expect((envelope.request as Record).contents).toBeDefined(); + }); + + test("sends the bare model id, not AI Studio's -tiered wire spelling", async () => { + // `-tiered` is a direct Generative Language deployment quirk; CCA does not serve those ids. + const built = await createGoogleAdapter(geminiCliProvider()).buildRequest(parsedRequest("gemini-3.6-flash")); + expect((JSON.parse(built.body) as { model: string }).model).toBe("gemini-3.6-flash"); + }); + + test("stream uses :streamGenerateContent?alt=sse", async () => { + const built = await createGoogleAdapter(geminiCliProvider()).buildRequest(parsedRequest("gemini-3.5-flash", true)); + expect(built.url).toBe("https://cloudcode-pa.googleapis.com/v1internal:streamGenerateContent?alt=sse"); + }); + + test("fails closed without a discovered Cloud Code Assist project", async () => { + await expect(createGoogleAdapter(geminiCliProvider({ project: undefined })).buildRequest(parsedRequest())) + .rejects.toThrow(/project/i); + }); + + test("fails closed without an OAuth token", async () => { + await expect(createGoogleAdapter(geminiCliProvider({ apiKey: "" })).buildRequest(parsedRequest())) + .rejects.toThrow(/token/i); + }); + + test("unwraps the CCA `response` wrapper like the Antigravity path does", async () => { + const adapter = createGoogleAdapter(geminiCliProvider()); + const events = await adapter.parseResponse!(new Response(JSON.stringify({ + response: { + candidates: [{ content: { role: "model", parts: [{ text: "hello" }] }, finishReason: "STOP" }], + usageMetadata: { promptTokenCount: 3, candidatesTokenCount: 4 }, + }, + }))); + const text = (events as AdapterEvent[]).filter(e => e.type === "text_delta") as Extract[]; + expect(text.map(e => e.text).join("")).toBe("hello"); + }); + + test("a payload missing the CCA wrapper is an error, not a silent empty turn", async () => { + const adapter = createGoogleAdapter(geminiCliProvider()); + const events = await adapter.parseResponse!(new Response(JSON.stringify({ + candidates: [{ content: { role: "model", parts: [{ text: "unwrapped" }] }, finishReason: "STOP" }], + }))); + expect((events as AdapterEvent[]).some(e => e.type === "error" && /response wrapper/.test(e.message))).toBe(true); + }); +}); + +describe("gemini ai-studio oauth transport", () => { + function aiStudioProvider(authMode: "oauth" | "key", apiKey: string): OcxProviderConfig { + return { + adapter: "google", + baseUrl: "https://generativelanguage.googleapis.com", + authMode, + googleMode: "ai-studio", + apiKey, + } as OcxProviderConfig; + } + + test("an OAuth credential travels as a Bearer, never in x-goog-api-key", async () => { + // The Generative Language API rejects a bearer token presented in the api-key header, so the + // gemini-ai-studio subtype would be dead on arrival if it reused the API-key header. + const built = await createGoogleAdapter(aiStudioProvider("oauth", "ya29.oauth-tok")).buildRequest(parsedRequest()); + expect(built.headers["Authorization"]).toBe("Bearer ya29.oauth-tok"); + expect(built.headers["x-goog-api-key"]).toBeUndefined(); + expect(built.url).toContain("/v1beta/models/gemini-3.5-flash:generateContent"); + }); + + test("an API key still travels in x-goog-api-key", async () => { + const built = await createGoogleAdapter(aiStudioProvider("key", "AIza-key")).buildRequest(parsedRequest()); + expect(built.headers["x-goog-api-key"]).toBe("AIza-key"); + expect(built.headers["Authorization"]).toBeUndefined(); + }); +}); diff --git a/tests/provider-registry-parity.test.ts b/tests/provider-registry-parity.test.ts index d2858f6f44..ea03e00321 100644 --- a/tests/provider-registry-parity.test.ts +++ b/tests/provider-registry-parity.test.ts @@ -829,6 +829,10 @@ describe("provider registry parity", () => { "google-antigravity": "google", "antigravity": "google", "gemini-antigravity": "google", + // Both Gemini OAuth subtypes serve Google models, so their metadata resolves through the + // same bundle. Neither claims the bare "gemini" alias — that stays with the `google` entry. + "gemini-cli": "google", + "gemini-ai-studio": "google", deepseek: "deepseek", moonshot: "moonshot", minimax: "minimax",