Let API callers supply a token for gr.OAuthToken endpoints - #13667
Let API callers supply a token for gr.OAuthToken endpoints#13667abidlabs wants to merge 4 commits into
gr.OAuthToken endpoints#13667Conversation
🪼 branch checks and previews
Install Gradio from this PR pip install https://huggingface.co/buckets/gradio/pypi-previews/resolve/64fc4bc819a68b4a0c2d7591282f1b7d44ff7169/gradio-6.20.0-py3-none-any.whlInstall Gradio Python Client from this PR pip install "gradio-client @ git+https://github.com/gradio-app/gradio@64fc4bc819a68b4a0c2d7591282f1b7d44ff7169#subdirectory=client/python"Import Gradio JS Client from this PR via CDN import { Client } from "https://huggingface.co/buckets/gradio/npm-previews/resolve/64fc4bc819a68b4a0c2d7591282f1b7d44ff7169/browser.js"; |
🦄 change detectedThis Pull Request includes changes to the following packages.
|
`gr.OAuthToken` was only ever populated from the OAuth session cookie, so it was always `None` for an API caller — no browser, no session. Any app whose function takes one could not be driven programmatically at all, which is how this surfaced: a deployed `gr.Workflow` reached its model nodes with no token and failed with "Sign in with your HF account to use this model". Callers can now pass `Client(..., oauth_token=...)`, which travels in the request body as a reserved `oauth_token` field rather than a header — a Space sits behind a proxy that strips `x-hf-*`, so a header never arrives. Because it rides beside `data` instead of in it, it never becomes a positional argument or appears in an endpoint's parameter schema, and it can't be captured by flagging or cached examples. It also works from `curl` and any other client, with no client-side support needed. A token is sent only to endpoints that declare they take one: `get_api_info` reports `oauth_token: "required" | "optional"` per endpoint, derived from the function signature, and the client consults that before including the field. So an app cannot collect tokens from calls that had no reason to carry one, and `view_api()` states plainly which endpoints act on the caller's behalf. `oauth_token=` is deliberately separate from `token=`, which only authenticates the caller to the app, and is never inferred from a locally saved token. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
c883b4a to
e9bad59
Compare
gr.OAuthToken so OAuth apps work through the APIgr.OAuthToken endpoints
There was a problem hiding this comment.
Pull request overview
This PR enables programmatic (non-browser) API callers to supply a Hugging Face user token to Gradio endpoints whose functions accept gr.OAuthToken, by carrying the token in a reserved oauth_token request-body field and only sending it to endpoints that declare they accept it.
Changes:
- Backend: plumbs an optional
oauth_tokenfield through predict request models and injects it intospecial_args()as anOAuthTokenwhen present. - API introspection: reports per-endpoint
oauth_token: "required" | "optional"so callers can see which endpoints act on their behalf. - Python client + test: adds
Client(..., oauth_token=...), selectively includes the field per endpoint, and adds a real-space e2e test.
Reviewed changes
Copilot reviewed 8 out of 8 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
test/test_oauth_api_access.py |
Flaky e2e test verifying body-carried oauth_token reaches only endpoints that declare gr.OAuthToken. |
gradio/utils.py |
Adds helper to detect whether an endpoint takes gr.OAuthToken and whether it’s optional/required. |
gradio/routes.py |
Threads oauth_token from /call/v2 request bodies into PredictBody. |
gradio/route_utils.py |
Wraps body oauth_token into an OAuthToken object and passes it into Blocks.process_api(). |
gradio/data_classes.py |
Extends request body models and API info typing to include oauth_token. |
gradio/blocks.py |
Propagates an oauth_token parameter through function calling and exposes requirement in get_api_info(). |
client/python/gradio_client/client.py |
Adds oauth_token= to Client, surfaces it in view_api(), and includes it in request bodies when appropriate. |
.changeset/shy-tokens-arrive.md |
Announces minor releases for gradio and gradio_client with the new feature. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| def oauth_token_requirement(fn: Callable | None) -> str | None: | ||
| """Whether `fn` takes a gr.OAuthToken, and whether it insists on one. | ||
|
|
||
| Returns "required" for `gr.OAuthToken`, "optional" for `gr.OAuthToken | None`, | ||
| and None when the function never receives one. Reported per endpoint so a | ||
| caller can see which endpoints act on their behalf, and so clients only send | ||
| a token where the app asked for it. | ||
| """ | ||
| from gradio.oauth import OAuthToken | ||
|
|
||
| if fn is None: | ||
| return None | ||
| hints = get_type_hints(fn) or getattr(fn, "__annotations__", {}) or {} | ||
| for hint in hints.values(): | ||
| if hint is OAuthToken: | ||
| return "required" | ||
| if hint == Optional[OAuthToken]: | ||
| return "optional" | ||
| return None |
There was a problem hiding this comment.
Fixed the first half: the scan now looks only at parameter annotations, so a function that returns an OAuthToken is no longer reported as one that receives it.
On the second half — missing OAuthToken | None — that one already worked. Optional[X] and PEP 604 X | None compare equal at runtime, so the single hint == Optional[OAuthToken] check matches both forms:
>>> (OAuthToken | None) == typing.Optional[OAuthToken]
True
Verified against both syntaxes before and after the change (def a(t: OAuthToken | None) and def b(t: Optional[OAuthToken]) both report optional), and special_args relies on the same equality today. I left the check as-is and added a comment noting it covers both, rather than adding a second branch that can never be reached.
| parameters_info = app.api_info["named_endpoints"]["/" + api_name][ # type: ignore | ||
| "parameters" | ||
| ] | ||
| body = dict(body) | ||
| oauth_token = body.pop("oauth_token", None) | ||
| processed_args = client_utils.construct_args( |
| data = { | ||
| "data": data or [], | ||
| "fn_index": self.fn_index, | ||
| **self.oauth_token_payload(), | ||
| **kwargs, | ||
| } |
| """ "required"/"optional" when this endpoint's function takes a | ||
| gr.OAuthToken, else None. A token is only ever sent to endpoints that | ||
| say they take one.""" |
The JS client gains the same `oauth_token` option and the same gating: it is included in the payload only for endpoints whose api_info declares they take a gr.OAuthToken, so parity with the Python client holds and an app still cannot collect tokens from calls that had no reason to carry one. The end-to-end check moves into test_external.py, which already hits real Spaces and is marked flaky and serial, rather than living in a file of its own. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…he gate `oauth_token_requirement` scanned every annotation, including the return type, so a function that *returned* an OAuthToken was reported as one that receives one. It now looks only at parameters. `/call/v2` popped `oauth_token` from every request body, which reserved the name globally and would swallow a real parameter that happened to be called that. It is only treated as reserved for endpoints that declare they take a token. In the client, `**kwargs` expanded after the gated payload, so passing `oauth_token=` as an ordinary keyword argument would have sent a token to an endpoint that never asked for one. The computed payload now goes last and wins. Also reworded a docstring that opened with a quoted word, which `ruff format` had to space away from the opening triple quote. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`oauth_token_requirement` returned plain `str`, which cannot be assigned to the `Literal["required", "optional"]` key on the APIEndpointInfo TypedDict, and the e2e test subscripted `view_api()` (overloaded on return_format) and iterated client.endpoints without narrowing the value type. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
gr.OAuthTokenis only ever populated from the OAuth session cookie, so it is alwaysNonefor an API caller — there's no browser and no session. Any app whose function takes one therefore cannot be driven programmatically at all. This is not specific togr.Workflow; a plaingr.Interfaceshows it. It surfaced through a deployed workflow, whose model nodes reached Inference Providers with no token and failed withSign in with your HF account to use this model.Why the body, not a header
gradio_clientalready forwardstoken=asx-hf-authorization, but I confirmed against a real Space that HF's proxy stripsx-hf-*— the app only receivesx-ip-token, an opaque JWT whoseuserclaim is encrypted and which is rejected by bothwhoami("Invalid user token") and Inference Providers. A custom header likex-gradio-tokendoes survive, but that's undocumented infra behaviour that could change silently and would present as "OAuth randomly stopped working."So the token travels in the request body as a reserved
oauth_tokenfield. Because it rides besidedatarather than in it:curland any other client with no client-side support at all.A token only goes where the app asked for it
get_api_infonow reportsoauth_token: "required" | "optional"per endpoint, derived from the function signature, and the client consults that before including the field. An app can't collect tokens from calls that had no reason to carry one, andview_api()says plainly which endpoints act on the caller's behalf:oauth_token=is deliberately separate fromtoken=: the latter only authenticates you to the app, so accessing a private Space no longer implies handing its code an act-as-you credential. It is never inferred from a locally saved token — if you don't pass it, nothing is granted.Verified
Against the real OAuth Space
gradio-tests/test-calculator-1(/reporttakes agr.OAuthToken,/calculatordoesn't):oauth_token=set,/reportuser:<name>— usable token arrivedoauth_token=set,/calculator6— token not sentoauth_token=unset,/reportnonecurlwith{"data":[],"oauth_token":"..."}One flaky e2e test covers this, in
test_external.pyalongside the other real-Space tests. Existing suites pass:test_routes,test_blocks,test_helpers,test_workflow,test_workflow_api(376 tests).I also audited the paths that could persist a body field: analytics send only launch metadata, and
error_payloadreturns only the message — neither touches request payloads.The JS client has the same
oauth_tokenoption and the same per-endpoint gating.Follow-ups, not in this PR
build_hf_headers()attaches the locally saved token to every client request, including to127.0.0.1and non-HF hosts. Unrelated to this change and not something it makes worse, but it may deserve its own issue.🤖 Generated with Claude Code