dsh-gemini-oauth-bridge
English | 简体中文
Bridges a Google AI subscription (AI Pro / AI Ultra, via the Antigravity / Code Assist OAuth surface) into an OpenAI-compatible endpoint that DeepSeek Harness (DSH) can use directly as a model provider.
Important boundary: Google AI subscription developer quotas only cover the AI Studio web UI and OAuth login inside official CLI tools (Gemini CLI / Antigravity); API-key usage is billed separately. This plugin speaks the same OAuth surface as the official client, so it consumes subscription quota rather than API credit — but that also means it is not a Google-sanctioned third-party integration. Account risk / breakage is possible; evaluate for yourself.
Features
- Login and go: one-click Google OAuth from Settings → Gemini OAuth (authorization code + loopback callback), with automatic
loadCodeAssistregistration /onboardUseronboarding and upstream model discovery. - OpenAI-compatible endpoint:
/gemini-oauth-bridge/v1/chat/completions(streaming SSE + non-streaming) and/v1/models(so DSH can discover models). - Full protocol translation: system messages, multimodal (data-URL images), function calling (tool_calls ↔ functionCall/functionResponse),
reasoning_content(thinking stream), usage stats, finish-reason mapping. - Gemini 3 thought-signature replay: the server remembers upstream
thoughtSignaturevalues by tool-call id and re-attaches them to functionCall parts on the next turn — OpenAI format carries no signatures, and Gemini 3 multi-turn tool calls fail without replay. - Subscription-friendly (anti-drift): dynamically tracks the latest shipping Antigravity client version for the User-Agent (stale versions are rejected for newer models); requests carry
requestType: "agent", a stable derivedsessionId, andagent-<uuid>request IDs; keep-alive connections withoutConnection: close. - 429 cooldowns: parses upstream
Retry-After/retryDelay, enters a per-model cooldown window, and refuses requests locally during it instead of hammering upstream. - Catalog honesty: an entry the upstream catalog flags
disabled: true— e.g.gemini-4-argon-disabled("Unavailable" / "available to Ultra and paid Pro plan subscribers") — is never published as a DSH model, so the picker cannot offer something that can only fail. A request for an id the account cannot use returns the upstream's own explanation plus a refresh hint instead of an opaque404 Requested entity was not found. Per-model context/output limits are read from the upstreammaxTokens/maxOutputTokensfields. - Browser-trust fence: a plugin prefix route is dispatched before DSH's own Host/Origin fence, so the bridge enforces its own — the
Hostmust be an authority it serves (loopback by default), aSec-Fetch-Site: cross-siterequest, an opaqueOrigin: null, or anOriginthat disagrees with theHostis refused with 403, and a request carrying noHostat all is only accepted from a loopback peer. - Tabbed settings page: the section splits into Overview / Quota / Usage / Models panels, each carrying only its own actions (refresh client version, refresh quota, refresh model list, sign out). All four panels survive a sign-out: Quota and Usage explain what signing in would reveal, and Models shows a sign-in hint instead of the model list, because available models depend on the account's plan and the list endpoint needs a token — so losing the token never collapses the page back to the single-pane layout, and never leaves a stale list from a previous account on screen.
- Subscription quota display: queries
v1internal:retrieveUserQuotaSummarywhen opening Settings → Gemini OAuth and, at most once a minute, after a completed chat turn; a two-minute cache backs the status poll, so a busy conversation does not spend one upstream request per turn. Each pool shows its Weekly limit (weekly) and 5-hour limit (5h) remaining percentage, progress bar, reset time and refresh timestamp, with a one-click Refresh quota action. - Model family & thinking-effort grouping: collapses multi-effort upstream variants (
*-low,*-medium,*-high, etc.) into a single base model entry withreasoningEffortsin DSH (llm-pi-ai), so you pick the model first and the thinking effort second;/v1/chat/completionsresolves(model, reasoning_effort)back to the concrete upstream ID. - One catalogue, two doors:
GET /v1/modelsapplies the same publishable filter and the same effort grouping as the provider document and reports each entry's context/completion limits, so a discovery pass can never hand DSH a different list than the route actually serves. - Local usage dashboard: records per-call requests (succeeded / failed / 429 rate-limited) and prompt/completion tokens into local day and hour buckets, then renders KPI cards, a token bar + call-count line trend chart, a per-model share donut, and a per-model table. The chart drills down by click — weeks → days → the day's 24 hours — via a single Back up link. No tooltip layer sits on top: hovering a bar feeds that bucket's numbers to the KPI cards and the panel heading, and leaving the chart restores the period totals.
- Single-flight token refresh: access tokens auto-renew 120 s before expiry; concurrent requests share one refresh.
Install
dsh plugin --profile web add github:hyqhyq3/dsh-gemini-oauth-bridge
# or a local checkout
dsh plugin --profile web add link:/path/to/dsh-gemini-oauth-bridge
Restart dsh --profile web, then open Settings → Gemini OAuth.
Usage
- Click 登录 Google 账号 (Log in with Google) and finish the flow in your browser. The plugin temporarily listens on
127.0.0.1:51121for the loopback callback — this exact redirect URI is registered for the Antigravity client and the port cannot change. Port conflicts are reported explicitly. - After login the page shows the account, project ID, tier, and model list.
- Register the bridge as a DSH provider in
~/.dsh/settings.yaml(port matches your DSH web URL, default 3080):
llm-pi-ai:
providers:
gemini-oauth:
api: openai-completions
baseURL: http://127.0.0.1:3080/gemini-oauth-bridge/v1
apiKeyEnv: GEMINI_OAUTH_API_KEY
models:
- id: gemini-3.1-pro-high
- id: gemini-3.7-flash-high
- Pick the models in the model selector. Step 3 is usually unnecessary: logging in and refreshing the model list write this block automatically (the
baseURLport follows the address you use to open the DSH UI). On DSH ≥ 0.2 the plugin edits thellm-pi-aientry through the settings service — the same path the DSH Models page uses — so the refreshed model list is hot-applied to the running process and persisted into the active profile'scordis.patch.yml; no restart is needed. Older DSH versions get the legacy~/.dsh/settings.yamlwrite, which takes effect at the next restart. DSH refuses a provider profile that declares no credential, so the plugin also provisions the matchingGEMINI_OAUTH_API_KEYentry in~/.dsh/.credentials.yaml(mode 0600) with the fixed placeholderdsh-gemini-oauth-bridge— an existing entry is never overwritten, and the bridge accepts any bearer token. To use your own key instead, set it through the Models page; to add local auth to the bridge itself, set"apiKey": "some-string"in~/.dsh/gemini-oauth-bridge.json— requests must then carryAuthorization: Bearer <value>.
Local state
~/.dsh/gemini-oauth-bridge.json (mode 0600, contains OAuth tokens — treat as a secret):
| Field | Meaning |
|---|---|
tokens |
access/refresh tokens and expiry |
email / project / tier |
login account and registration result |
models |
upstream model cache (refreshable from the UI) |
modelCaps |
per-model contextLength / maxCompletionTokens read from the upstream catalog |
modelsUnavailable |
ids the upstream catalog marks disabled, with the upstream explanation shown when such a model is requested |
trustedHosts |
extra host:port authorities the browser-trust fence accepts (for non-loopback deployments); loopback is always accepted |
signatures |
thoughtSignature replay cache (by tool-call id, cap 800) |
apiKey |
optional local auth for the bridge endpoint |
apiConfig |
optional upstream overrides (generateBase / loadBase / onboardBase); defaults match CLIProxyAPI: generation via daily, registration via prod |
Design notes (anti-drift)
Protocol details follow the public implementation in CLIProxyAPI (Apache-2.0):
- Live version tracking: the latest Antigravity version is fetched from the official updater hub (UA
electron-builder, like the real updater), semver-validated, and cached for 6 hours — refreshed on the first request that finds the cache stale, not on a timer; failures fall back to the cache, then to the2.9.1floor (Cloud Code rejects clients below 2.9.0 for newer models). - Per-surface User-Agents: generate/stream/loadCodeAssist use
antigravity/hub/<version> darwin/arm64; onboardUser appendsgoogle-api-nodejs-client/10.3.0; OAuth token refresh usesGo-http-client/2.0— each surface matches the real client. - Envelope semantics:
requestType: "agent",requestId: "agent-<uuid>",userAgent: "antigravity",request.sessionIdderived deterministically from the conversation's first message (one session per conversation);safetySettingsstripped;generationConfig.maxOutputTokensremoved for gemini-3 models. - Connection fingerprint: no
Connection: close; keep-alive reuse. - Backoff: on 429, reads the
Retry-Afterheader or in-bodyretryDelay, enters a per-model cooldown (capped at 30 min) with no upstream traffic during it. - Subscription tier resolution: request entitlements follow
paidTier.id(Google AI Pro →g1-pro-tier); afree-tiercurrentTieris just the registration state and does not limit quota or model access. - Automatic provider configuration: on login and on 刷新模型列表 (refresh models), the
llm-pi-ai.providers.gemini-oauthroute (with the latest model ids and theGEMINI_OAUTH_API_KEYcredential reference) is written through the DSH settings service when available — the same path the Models page uses — so the change hot-applies to the running process and persists into the active profile'scordis.patch.yml; only the gemini-oauth route is touched and the edit is validated by the adapter's own schema. When the settings service is unavailable or the update fails, a fallback writes the legacy~/.dsh/settings.yamldocument instead (surgical merge, one-time backup atsettings.yaml.bak-gemini-oauth-bridge), which older deployments load at boot; once the hot path has succeeded that document is deliberately left alone, because on DSH ≥ 0.2 the next boot imports it and renames it oversettings.yaml.imported, so rewriting it would clobber that archive. The matching placeholder key is provisioned in~/.dsh/.credentials.yaml(append-only; a version-1 document whose shape cannot be proven is left untouched and reported instead). Internalchat_*/tab_*ids are filtered out. Set"providerSync": falsein the state file to opt out. - Authority self-healing: DSH may bind a different port on every launch, and
llm-pi-aiaccepts only a literalbaseURL— no interpolation — so a stored route can outlive the listener it names, and every model request then dies withConnection errorwhile the bridge itself is perfectly healthy. Once a provider block has been published, the plugin re-points it at the listener this process actually owns: at startup (the port is only readable after the socket binds, so the attempt is retried), and again whenever an inbound request proves the recorded authority stale. A fresh install that never published a provider is left alone. - Public client credentials: the OAuth client credentials are public values embedded in the Antigravity app (published verbatim in CLIProxyAPI's repo); they are stored base64-encoded in this repository only to keep GitHub push protection quiet, and decoded at runtime.
Security
- OAuth tokens live in
~/.dsh/gemini-oauth-bridge.json(mode 0600) and are never returned by any endpoint;/api/statusexposes only account metadata (email, project, tier) and the model list. - DSH applies a Host/Origin fence to its own
/api/*routes, but a pluginwebServer.register({kind: 'prefix'})route is dispatched before that fence, so the bridge enforces the same rules itself: a request is refused with 403 when itsHostis not an authority the bridge serves, whenSec-Fetch-Site: cross-site, whenOriginis the opaquenulla sandboxed frame sends, whenOrigindisagrees withHost, or when a request arrives with noHostat all from anything other than a loopback peer. This blocks DNS-rebinding reads of/api/statusand/v1/*, quota-burning calls through/v1/chat/completions, and blind-CSRF of the mutating POSTs (/api/login,/api/logout,/api/refresh-models) — a cross-siteContent-Type: text/plainPOST needs no CORS preflight, so without the fence the browser would deliver it. /v1/*is unauthenticated by default because DSH calls it locally; set"apiKey"in the state file to requireAuthorization: Bearer <value>, and"trustedHosts": ["host:port"]to serve DSH on a non-loopback address.
Limitations
- Unofficial usage: relies on the Antigravity client's public OAuth credentials and a non-public
v1internalsurface; Google may change or restrict it at any time. Intended for individual subscribers' own use. - Single account (no rotation);
http(s)://image URLs unsupported (data URLs only); the signature cache is persisted but LRU-evicts past 800 entries. - The login callback occupies
127.0.0.1:51121; logging in while the Antigravity desktop app is also logging in may conflict. - Generation goes to the
daily-cloudcode-pachannel (matching CLIProxyAPI's default); override viaapiConfigif needed. - Some catalog models cannot generate. Upstream advertises entries it will not serve for your account — the Gemini 4 Argon family appears only as
gemini-4-argon-disabledwithdisabled: true,tagTitle: "Unavailable"andtagDescription: "Gemini 4 Argon is currently available to Ultra and paid Pro plan subscribers.", and every id variant returns404 NOT_FOUNDeven on a paidg1-pro-tier(Google AI Pro) account. Catalog membership is therefore not proof that a model can generate; the plugin hidesdisabledentries, but no client-side change can enable them.
Development
Zero dependencies, plain ESM JavaScript (Node ≥ 22); lib/*.js ships as written, no build step.
node --check lib/index.js && node --check lib/protocol.js && node --check lib/client.js
node --test test/protocol.test.js test/host.smoke.test.js test/bridge.e2e.test.js
Test coverage: protocol translation units (OAuth URLs / envelope / stream fragments / signature replay), host-route smoke (mocked cordis ctx + real HTTP + loopback login error paths), and bridge end-to-end (mocked upstream verifying the envelope contract, SSE translation, and 429 cooldown).
License
MIT
Language (0.3.0)
The Settings section is bilingual (English / Simplified Chinese), using DSH's
geminiOauth locale namespace and framework-injected t seat. Change Language in
Settings → General; every plugin API request sends the active ?lang=zh|en.
The root summary page and OpenAI-compatible bridge also accept this parameter.
Bridge errors with locale descriptors use the resolved language; raw errors,
status codes, and response envelopes are preserved. A valid explicit language
wins over locale.preference in $DSH_HOME/settings.yaml (default home:
~/.dsh); absent or unreadable preferences fall back to Chinese. The OAuth
callback remembers the language of the login request, since Google's navigation
does not carry lang. Google/upstream text is preserved verbatim. This requires
DSH's @deepseek-ai/dsh-client-locale service, included in the web profile.
Run npm test for protocol, HTTP integration, and locale coverage. No build step
or runtime dependencies are needed.
No comments yet. Be the first to write one.