Skip to content

Let API callers supply a token for gr.OAuthToken endpoints - #13667

Merged
dawoodkhan82 merged 8 commits into
mainfrom
fix-oauth-token-for-api-callers
Jul 29, 2026
Merged

Let API callers supply a token for gr.OAuthToken endpoints#13667
dawoodkhan82 merged 8 commits into
mainfrom
fix-oauth-token-for-api-callers

Conversation

@abidlabs

@abidlabs abidlabs commented Jul 24, 2026

Copy link
Copy Markdown
Member

This PR lets API callers supply a token for gr.OAuthToken endpoints. Previously, gr.OAuthToken is only ever populated from the OAuth session cookie, so it is always None for 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 to gr.Workflow; a plain gr.Interface shows it. It surfaced through a deployed workflow, whose model nodes reached Inference Providers with no token and failed with Sign in with your HF account to use this model.

Why the body, not a header

gradio_client already forwards token= as x-hf-authorization, but I confirmed against a real Space that HF's proxy strips x-hf-* — the app only receives x-ip-token, an opaque JWT whose user claim is encrypted and which is rejected by both whoami ("Invalid user token") and Inference Providers. A custom header like x-gradio-token does 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_token field. Because it rides beside data rather than in it:

  • it never becomes a positional argument and never appears in an endpoint's parameter schema, so no existing call signature changes;
  • it can't be captured by flagging or cached examples, which only see component values;
  • it works from curl and any other client with no client-side support at all.

A token only goes where the app asked for it

get_api_info now reports oauth_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, and view_api() says plainly which endpoints act on the caller's behalf:

 - predict(api_name="/report") -> value_1
    Acts on your behalf: this endpoint takes your Hugging Face token (optional).
    Pass it with Client(..., oauth_token=...) to grant it.

oauth_token= is deliberately separate from token=: 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 (/report takes a gr.OAuthToken, /calculator doesn't):

call result
oauth_token= set, /report user:<name> — usable token arrived
oauth_token= set, /calculator 6 — token not sent
oauth_token= unset, /report none
curl with {"data":[],"oauth_token":"..."} token arrived

@gradio-pr-bot

gradio-pr-bot commented Jul 24, 2026

Copy link
Copy Markdown
Collaborator

🪼 branch checks and previews

Name Status URL
Spaces ready! Spaces preview
Website ready! Website preview
Storybook ready! Storybook preview
🦄 Changes detected! Details

Install Gradio from this PR

pip install https://huggingface.co/buckets/gradio/pypi-previews/resolve/7811ad0bd07403fa9951696787af44f98a2b5730/gradio-6.20.0-py3-none-any.whl

Install 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";

@gradio-pr-bot

gradio-pr-bot commented Jul 24, 2026

Copy link
Copy Markdown
Collaborator

🦄 change detected

This Pull Request includes changes to the following packages.

Package Version
@gradio/client minor
gradio minor
gradio_client minor

  • oauth: let API callers supply a token for endpoints that take a gr.OAuthToken, via oauth_token on the Python and JS clients — sent only to endpoints that declare they need one

Something isn't right?

  • Maintainers can change the version label to modify the version bump.
  • If the bot has failed to detect any changes, or if this pull request needs to update multiple packages to different versions or requires a more comprehensive changelog entry, maintainers can update the changelog file directly.

`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>
@abidlabs
abidlabs force-pushed the fix-oauth-token-for-api-callers branch from c883b4a to e9bad59 Compare July 24, 2026 22:32
@abidlabs abidlabs changed the title Inject a caller-supplied token into gr.OAuthToken so OAuth apps work through the API Let API callers supply a token for gr.OAuthToken endpoints Jul 24, 2026
@abidlabs
abidlabs requested a review from Copilot July 24, 2026 22:34

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_token field through predict request models and injects it into special_args() as an OAuthToken when 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.

Comment thread gradio/utils.py Outdated
Comment on lines +1226 to +1244
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

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread gradio/routes.py Outdated
Comment on lines 1341 to 1346
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(

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines 1196 to 1201
data = {
"data": data or [],
"fn_index": self.fn_index,
**self.oauth_token_payload(),
**kwargs,
}

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread client/python/gradio_client/client.py Outdated
Comment on lines +1067 to +1069
""" "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."""

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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."""

abidlabs and others added 4 commits July 24, 2026 15:40
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>
@abidlabs
abidlabs requested review from dawoodkhan82 and hysts July 29, 2026 05:54
abidlabs and others added 3 commits July 28, 2026 23:16
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>

@dawoodkhan82 dawoodkhan82 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

tested and lgtm!

@dawoodkhan82
dawoodkhan82 merged commit 2d753d0 into main Jul 29, 2026
26 of 27 checks passed
@dawoodkhan82
dawoodkhan82 deleted the fix-oauth-token-for-api-callers branch July 29, 2026 15:48
abidlabs added a commit that referenced this pull request Jul 29, 2026
#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>
abidlabs added a commit that referenced this pull request Jul 30, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants