Let API callers supply a token for gr.OAuthToken endpoints - #13667
Conversation
🪼 branch checks and previews
Install Gradio from this PR pip install https://huggingface.co/buckets/gradio/pypi-previews/resolve/7811ad0bd07403fa9951696787af44f98a2b5730/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@7811ad0bd07403fa9951696787af44f98a2b5730#subdirectory=client/python"Import Gradio JS Client from this PR via CDN import { Client } from "https://huggingface.co/buckets/gradio/npm-previews/resolve/7811ad0bd07403fa9951696787af44f98a2b5730/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( |
There was a problem hiding this comment.
Good catch — fixed in 7811ad0, though not quite as suggested, because a purely conditional pop reintroduces a different bug.
When this was conditional, sending oauth_token to an endpoint that declares none reached construct_args as an unknown argument and came back as a bare 500. Since oauth_token is a documented field of the request body, that is a rough edge. I had made the pop unconditional to fix it — which is the state you reviewed, and you are right that it then swallows the value of any endpoint whose own parameter is named oauth_token.
So it now distinguishes three cases rather than two:
oauth_token = None
if endpoint_info.get("oauth_token"):
oauth_token = body.pop("oauth_token", None)
elif not any(p.get("parameter_name") == "oauth_token" for p in parameters_info):
# Not this endpoint's to receive, and not one of its parameters
# either, so drop it rather than report an unknown argument.
body.pop("oauth_token", None)Verified all five paths against a running app:
| case | result |
|---|---|
| declares a token, token sent | token:hf_abc |
| declares a token, none sent | none |
| no token declared, token sent | echo (ignored, no 500) |
own parameter named oauth_token, given |
param:MINE |
own parameter named oauth_token, omitted |
param:orig |
The last two are the regression you spotted; on the reviewed commit the first of them returned param:orig. Both are now covered by TestCurlEndpointWithOAuthToken in test/test_routes.py, and I confirmed the test fails with the unconditional pop restored.
| data = { | ||
| "data": data or [], | ||
| "fn_index": self.fn_index, | ||
| **self.oauth_token_payload(), | ||
| **kwargs, | ||
| } |
There was a problem hiding this comment.
The ordering half of this was already fixed in b756483 — **self.oauth_token_payload() now comes after **kwargs, so the computed payload wins rather than being overridden.
On the remaining half — a caller passing oauth_token= as an ordinary kwarg to reach an endpoint that declared none — I don't think that path is reachable, so I've left it as is rather than add a guard for it. _predict's kwargs is never populated from the public API: Client.submit() folds keyword arguments into positionals via construct_args and then invokes the endpoint with positional args only:
args = utils.construct_args(endpoint.parameters_info, args, kwargs)
...
future = self.executor.submit(copy_context().run, end_to_end_fn, *args)construct_args rejects any name that is not a declared parameter, so the attempt fails before _predict is reached:
>>> client.predict(4, "add", 2, oauth_token="hf_SMUGGLE", api_name="/calculator")
TypeError: Parameter `oauth_token` is not a valid key-word argument.
make_predict has one call site (make_end_to_end_fn), which itself has one, so there is no other route in. Happy to add the defensive kwargs.pop if you'd still prefer the invariant enforced locally, but as written it would be dead code.
| """ "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.""" |
There was a problem hiding this comment.
Already resolved — the docstring was rewritten while trimming this branch's comments and no longer opens with the stray quote:
def _get_oauth_token_requirement(self) -> str | None:
"""Returns "required", "optional", or None for this endpoint's gr.OAuthToken."""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>
The per-endpoint `oauth_token` requirement reached `/info` but stopped there,
so nothing downstream showed it or acted on it:
- `transform_api_info()` rebuilt each endpoint as {parameters, returns, type},
dropping `oauth_token`. `submit()`'s gate therefore never matched and the JS
client never sent a token at all.
- The view-API page said nothing about which endpoints act on the caller's
behalf, and its snippets omitted `oauth_token` so copy-paste didn't work.
- Sending `oauth_token` to an endpoint that takes no token 500'd on
`construct_args`; it is now stripped from the args either way and only
honored where the fn declares one.
Also trims the explanatory inline comments added across the branch.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The first paragraph inherited --text-md while .desc set --text-lg, so the subdued explanation rendered larger than the primary statement. Both are prose now at --text-lg, and the inline mono spans drop a step to sit optically level with it — matching the sibling parameters section (h4 14px, prose 16px, mono 14px). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Popping it unconditionally fixed the 500 on endpoints that take no token, but it also swallowed the value of any endpoint whose own parameter is named oauth_token (github-pilot caught this). The name is now reserved only where the fn declares a gr.OAuthToken; elsewhere it stays an ordinary argument if the endpoint has one by that name, and is dropped if it does not — so neither the 500 nor the swallowing happens. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
#13667 landed, so a caller can now hand a workflow a token via the request body. Subgraph endpoints already declare one — `_build_endpoint_fn` synthesizes `token: Optional[OAuthToken]` into the signature, so `/info` reports `oauth_token: "optional"` for them — but the workflow's own View API panel said nothing about it, which was the last item blocking API access to Space-hosted workflows from being usable. `describe_workflow_api` now reports the requirement, asked of the same builder that registers the endpoints so the panel can't drift from `/info`, and the panel adds a note plus `oauth_token` in all three snippets. Also fixes the panel's file-parameter examples, which no client accepted: the curl body needs the FileData payload (`{"path": ..., "meta": {...}}`) and the JS snippet needs `handle_file`, where both previously emitted a bare URL string. Verified by running the generated request: upload → call → the workflow's function receives OAuthToken(token='hf_DOCS_CHECK'). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…errors, and document the API oauth_token param (#13685) * Workflow: auto-create nodes for models, add an Output button, copyable errors Addresses three of the remaining items in #13665: - Adding a model node from the picker now spawns its input and output components and wires them up, the same ready-to-run subgraph that Space nodes have always produced. Previously a fresh model node appeared with nothing attached, so it looked like it ran but produced nothing. - The bottom bar gains an "Output" button alongside "Input" (output nodes were only reachable by dragging from a port), and "Data" is renamed "Dataset" to match its icon, tooltip and DATASET_MODALITY constant. - Node error banners get a "copy" button, so a failure can be pasted somewhere useful — canvas nodes swallow text selection for dragging. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Workflow: document the API oauth_token param in the View API panel #13667 landed, so a caller can now hand a workflow a token via the request body. Subgraph endpoints already declare one — `_build_endpoint_fn` synthesizes `token: Optional[OAuthToken]` into the signature, so `/info` reports `oauth_token: "optional"` for them — but the workflow's own View API panel said nothing about it, which was the last item blocking API access to Space-hosted workflows from being usable. `describe_workflow_api` now reports the requirement, asked of the same builder that registers the endpoints so the panel can't drift from `/info`, and the panel adds a note plus `oauth_token` in all three snippets. Also fixes the panel's file-parameter examples, which no client accepted: the curl body needs the FileData payload (`{"path": ..., "meta": {...}}`) and the JS snippet needs `handle_file`, where both previously emitted a bare URL string. Verified by running the generated request: upload → call → the workflow's function receives OAuthToken(token='hf_DOCS_CHECK'). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Workflow: one "Component" button, with roles derived from wiring Replaces the bottom bar's Input/Output pair with a single "Component" button. A component's direction was never really the user's to declare: `WorkflowNodeSF` already picks between an editable widget and a read-only output tile purely from whether the node's input port is connected, and the port-drag path already infers the role from drag direction. The bar was the one place that made the user pre-commit. `reconcileComponentRoles` makes the role a function of the edge set — driven components are subjects, undriven ones are references — and runs on every edge mutation plus on load. Without it the collections could disagree with the rendering, and that mattered: `workflow_api.py` builds endpoint parameters from `references` (skipping any with an incoming edge) and endpoints themselves from `subjects`, so a node that rendered as an output while still filed under `references` contributed no endpoint at all. That was reachable before this change — wire a model into an Input node and its subgraph silently vanished from the API — and the new Output button would only have widened the target. Flipped nodes append rather than merge in place, since `subject_groups` fixes the API's output-tuple order from `subjects` order. API panel, from review feedback: - the `oauth_token` note moves out of the per-endpoint cards to a single note beneath all of them, so it doesn't read as one of the endpoint's own parameters, and is cut down to one sentence with a link - Copy moves onto the code block it copies - long snippets scroll instead of being clipped: `overflow: hidden` on the endpoint card resolved its flex minimum size to 0, so cards shrank to fit the panel and cut their code mid-line Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Remove verbose workflow API panel CSS comments --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This PR lets API callers supply a token for gr.OAuthToken endpoints. Previously,
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":"..."}