Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/shy-tokens-arrive.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@gradio/client": minor
"gradio": minor
"gradio_client": minor
---

feat: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
5 changes: 4 additions & 1 deletion client/js/src/helpers/api_info.ts
Original file line number Diff line number Diff line change
Expand Up @@ -189,7 +189,10 @@ export function transform_api_info(
returns: returns.map((r: ApiData) =>
transform_type(r, r?.component, r?.serializer, "return")
),
type: dependencyTypes
type: dependencyTypes,
...(endpoint_info?.oauth_token
? { oauth_token: endpoint_info.oauth_token }
: {})
};
}
);
Expand Down
37 changes: 37 additions & 0 deletions client/js/src/test/api_info.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -726,4 +726,41 @@ describe("transform_api_info", () => {
expect(result.named_endpoints["/predict"].parameters).toEqual([]);
expect(result.named_endpoints["/predict"].returns).toEqual([]);
});

it("keeps oauth_token, which submit() needs to decide where a token may be sent", () => {
const api_info = {
named_endpoints: {
"/report": { parameters: [], returns: [], oauth_token: "optional" },
"/calculator": { parameters: [], returns: [] }
},
unnamed_endpoints: {}
} as any;
const config = {
dependencies: [
{
id: 0,
api_name: "report",
inputs: [],
outputs: [],
types: { generator: false, cancel: false }
},
{
id: 1,
api_name: "calculator",
inputs: [],
outputs: [],
types: { generator: false, cancel: false }
}
],
components: []
} as any;

const result = transform_api_info(api_info, config, {
report: 0,
calculator: 1
});

expect(result.named_endpoints["/report"].oauth_token).toBe("optional");
expect(result.named_endpoints["/calculator"].oauth_token).toBeUndefined();
});
});
10 changes: 10 additions & 0 deletions client/js/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ export interface EndpointInfo<T extends ApiData | JsApiData> {
parameters: T[];
returns: T[];
type?: DependencyTypes;
/** Set when the endpoint's function takes a `gr.OAuthToken`. */
oauth_token?: "required" | "optional";
}

export interface ApiInfo<T extends ApiData | JsApiData> {
Expand Down Expand Up @@ -301,6 +303,7 @@ export interface Payload {
time?: Date;
event_data?: unknown;
trigger_id?: number | null;
oauth_token?: string;
}

export interface PostResponse {
Expand Down Expand Up @@ -337,6 +340,13 @@ export interface ClientOptions {
session_hash?: string;
cookies?: string;
credentials?: RequestCredentials;
/**
* A Hugging Face token passed to the app's own code, for endpoints whose
* function takes a `gr.OAuthToken`. Unlike `token`, which only authenticates
* you to the app, this lets the app act on your behalf, so it is sent only to
* endpoints that declare they need it.
*/
oauth_token?: string;
}

export interface FileData {
Expand Down
5 changes: 4 additions & 1 deletion client/js/src/utils/submit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -184,7 +184,10 @@ export function submit(
data: input_data || [],
event_data,
fn_index,
trigger_id
trigger_id,
...(options.oauth_token && endpoint_info?.oauth_token
? { oauth_token: options.oauth_token }
: {})
};
if (skip_queue(fn_index, config)) {
fire_event({
Expand Down
25 changes: 25 additions & 0 deletions client/python/gradio_client/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ def __init__(
ssl_verify: bool = True,
_skip_components: bool = True, # internal parameter to skip values certain components (e.g. State) that do not need to be displayed to users.
analytics_enabled: bool = True,
oauth_token: str | None = None,
):
"""
Parameters:
Expand All @@ -102,9 +103,11 @@ def __init__(
ssl_verify: if False, skips certificate validation which allows the client to connect to Gradio apps that are using self-signed certificates.
httpx_kwargs: additional keyword arguments to pass to `httpx.Client`, `httpx.stream`, `httpx.get` and `httpx.post`. This can be used to set timeouts, proxies, http auth, etc.
analytics_enabled: Whether to allow basic telemetry. If None, will use GRADIO_ANALYTICS_ENABLED environment variable or default to True.
oauth_token: optional Hugging Face token for the app to act on your behalf, for endpoints whose function takes a `gr.OAuthToken`. Unlike `token`, which only authenticates you to the app, this is passed to the app's code, so it is sent only to endpoints that declare they need it — `view_api()` marks those. It is never sent anywhere else, and is not inferred from your locally saved token.
"""
self.verbose = verbose
self.token = token
self.oauth_token = oauth_token
self.download_files = download_files
self._skip_components = _skip_components
self.headers = build_hf_headers(
Expand Down Expand Up @@ -798,6 +801,12 @@ def _render_endpoints_info(
raise ValueError("name_or_index must be a string or integer")

human_info = f"\n - predict({rendered_parameters}{final_param}) -> {rendered_return_values}\n"
if endpoints_info.get("oauth_token"):
human_info += (
f" Acts on your behalf: this endpoint takes your Hugging Face "
f"token ({endpoints_info['oauth_token']}). Pass it with "
f"Client(..., oauth_token=...) to grant it.\n"
)
human_info += " Parameters:\n"
if parameter_info:
for info in parameter_info:
Expand Down Expand Up @@ -1019,6 +1028,7 @@ def __init__(
self._get_component_type(id_) for id_ in dependency["outputs"]
]
self.parameters_info = self._get_parameters_info()
self.oauth_token_requirement = self._get_oauth_token_requirement()
self.root_url = self.client.src_prefixed
self.backend_fn = dependency.get("backend_fn")

Expand Down Expand Up @@ -1051,6 +1061,19 @@ def _get_parameters_info(self) -> list[ParameterInfo] | None:
return self._info["named_endpoints"][self.api_name]["parameters"]
return None

def _get_oauth_token_requirement(self) -> str | None:
"""Returns "required", "optional", or None for this endpoint's gr.OAuthToken."""
if self.api_name in self._info["named_endpoints"]:
return self._info["named_endpoints"][self.api_name].get("oauth_token")
return None

def oauth_token_payload(self) -> dict[str, str]:
"""The oauth_token body field, if this endpoint takes one and the caller supplied one."""
token = self.client.oauth_token
if token and self.oauth_token_requirement is not None:
return {"oauth_token": token}
return {}

@staticmethod
def value_is_file(component: dict) -> bool:
# This is still hacky as it does not tell us which part of the payload is a file.
Expand Down Expand Up @@ -1169,6 +1192,8 @@ def _predict(*data, **kwargs) -> tuple:
"data": data or [],
"fn_index": self.fn_index,
**kwargs,
# Last, so an oauth_token= kwarg cannot bypass the per-endpoint gate.
**self.oauth_token_payload(),
}

hash_data = {
Expand Down
30 changes: 25 additions & 5 deletions client/python/gradio_client/snippet.py
Original file line number Diff line number Diff line change
Expand Up @@ -125,18 +125,27 @@ def _get_param_value(param: dict) -> Any:
return param.get("example_input")


OAUTH_TOKEN_PLACEHOLDER = "hf_..."


def generate_python_snippet(
api_name: str,
params: list[dict],
src: str,
oauth_token: str | None = None,
) -> str:
has_file = any(_has_file_data(p.get("example_input")) for p in params)
imports = "from gradio_client import Client"
if has_file:
imports += ", handle_file"

lines = [imports, ""]
lines.append(f'client = Client("{src}")')
if oauth_token:
lines.append(
f'client = Client("{src}", oauth_token="{OAUTH_TOKEN_PLACEHOLDER}")'
)
else:
lines.append(f'client = Client("{src}")')

predict_args = []
for p in params:
Expand All @@ -159,6 +168,7 @@ def generate_js_snippet(
api_name: str,
params: list[dict],
src: str,
oauth_token: str | None = None,
) -> str:
blob_params = [p for p in params if p.get("component") in BLOB_COMPONENTS]

Expand All @@ -175,7 +185,12 @@ def generate_js_snippet(
if blob_params:
lines.append("")

lines.append(f'const client = await Client.connect("{src}");')
if oauth_token:
lines.append(
f'const client = await Client.connect("{src}", {{ oauth_token: "{OAUTH_TOKEN_PLACEHOLDER}" }});'
)
else:
lines.append(f'const client = await Client.connect("{src}");')

blob_component_names = {bp.get("component") for bp in blob_params}

Expand Down Expand Up @@ -205,6 +220,7 @@ def generate_bash_snippet(
params: list[dict],
root: str,
api_prefix: str = "/",
oauth_token: str | None = None,
) -> str:
normalised_root = root.rstrip("/")
normalised_prefix = api_prefix if api_prefix else "/"
Expand Down Expand Up @@ -239,6 +255,9 @@ def generate_bash_snippet(
formatted = _represent_value(value, ptype, "bash")
data_dict[name] = formatted

if oauth_token:
data_dict["oauth_token"] = f'"{OAUTH_TOKEN_PLACEHOLDER}"'

data_entries = ", ".join(f'"{k}": {v}' for k, v in data_dict.items())
data_str = "{" + data_entries + "}"
for _ in file_param_names:
Expand Down Expand Up @@ -269,9 +288,10 @@ def generate_code_snippets(
) -> dict[str, str]:
params = endpoint_info.get("parameters", [])
src = space_id or root
oauth_token = endpoint_info.get("oauth_token")

return {
"python": generate_python_snippet(api_name, params, src),
"javascript": generate_js_snippet(api_name, params, src),
"bash": generate_bash_snippet(api_name, params, root, api_prefix),
"python": generate_python_snippet(api_name, params, src, oauth_token),
"javascript": generate_js_snippet(api_name, params, src, oauth_token),
"bash": generate_bash_snippet(api_name, params, root, api_prefix, oauth_token),
}
29 changes: 29 additions & 0 deletions client/python/test/test_snippet.py
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,35 @@ def add(a, b=10):
assert isinstance(namespace["result"], (int, float))


class TestOAuthTokenSnippets:
def test_snippets_ask_for_a_token_only_where_the_endpoint_takes_one(self):
def report(oauth_token: gr.OAuthToken | None) -> str:
return "none" if oauth_token is None else "token"

with gr.Blocks() as demo:
out = gr.Textbox()
gr.Button("Report").click(report, None, out, api_name="report")
name = gr.Textbox()
gr.Button("Greet").click(lambda n: n, name, out, api_name="greet")

info = demo.get_api_info()
assert info["named_endpoints"]["/report"]["oauth_token"] == "optional"
assert "oauth_token" not in info["named_endpoints"]["/greet"]

with_token = generate_code_snippets(
"/report", info["named_endpoints"]["/report"], "http://localhost:7860"
)
assert 'oauth_token="hf_..."' in with_token["python"]
assert 'oauth_token: "hf_..."' in with_token["javascript"]
assert '"oauth_token": "hf_..."' in with_token["bash"]

without_token = generate_code_snippets(
"/greet", info["named_endpoints"]["/greet"], "http://localhost:7860"
)
for snippet in without_token.values():
assert "oauth_token" not in snippet


class TestStringifyPy:
def test_datetime_in_nested_structure(self):
"""Non-JSON-native types like datetime should not raise TypeError."""
Expand Down
9 changes: 9 additions & 0 deletions gradio/blocks.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
analytics,
components,
networking,
oauth,
processing_utils,
queueing,
utils,
Expand Down Expand Up @@ -1615,6 +1616,7 @@ async def call_function(
event_data: EventData | None = None,
in_event_listener: bool = False,
state: SessionState | None = None,
oauth_token: oauth.OAuthToken | None = None,
):
"""
Calls function with given index and preprocessed input, and measures process time.
Expand Down Expand Up @@ -1665,6 +1667,7 @@ async def call_function(
request, # type: ignore
event_data, # type: ignore
component_props=component_props,
token=oauth_token,
)
progress_tracker = (
processed_input[progress_index] if progress_index is not None else None
Expand Down Expand Up @@ -2219,6 +2222,7 @@ async def process_api(
simple_format: bool = False,
explicit_call: bool = False,
root_path: str | None = None,
oauth_token: oauth.OAuthToken | None = None,
) -> dict[str, Any]:
"""
Processes API calls from the frontend. First preprocesses the data,
Expand Down Expand Up @@ -2282,6 +2286,7 @@ async def process_api(
event_data,
in_event_listener,
state,
oauth_token,
)
manual_cache_used = used_manual_cache()
preds = result["prediction"]
Expand Down Expand Up @@ -2317,6 +2322,7 @@ async def process_api(
event_data,
in_event_listener,
state,
oauth_token,
)
manual_cache_used = used_manual_cache()

Expand Down Expand Up @@ -3525,6 +3531,9 @@ def get_api_info(self, all_endpoints: bool = False) -> APIInfo:
Literal["public", "private", "undocumented"], fn.api_visibility
),
}
oauth_token_kind = utils.oauth_token_requirement(fn.fn)
if oauth_token_kind is not None:
dependency_info["oauth_token"] = oauth_token_kind
fn_info = utils.get_function_params(fn.fn)
if fn.api_description is False:
dependency_info["description"] = ""
Expand Down
6 changes: 6 additions & 0 deletions gradio/data_classes.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ class CancelBody(BaseModel):
class SimplePredictBody(BaseModel):
data: list[Any]
session_hash: str | None = None
oauth_token: str | None = None


class SimplePredictBodyV2(BaseModel):
Expand Down Expand Up @@ -91,6 +92,8 @@ class PredictBody(BaseModel):
session_hash: str | None = None
event_id: str | None = None
data: list[Any]
# Secret: a caller-supplied token for endpoints that take a gr.OAuthToken.
oauth_token: str | None = None
event_data: Any | None = None
fn_index: int | None = None
trigger_id: int | None = None
Expand All @@ -108,6 +111,7 @@ def __get_pydantic_json_schema__(cls, core_schema, handler):
"session_hash": {"type": "string"},
"event_id": {"type": "string"},
"data": {"type": "array", "items": {"type": "object"}},
"oauth_token": {"type": "string"},
"event_data": {"type": "object"},
"fn_index": {"type": "integer"},
"trigger_id": {"type": "integer"},
Expand Down Expand Up @@ -458,6 +462,8 @@ class APIEndpointInfo(TypedDict):
parameters: list[ParameterInfo]
returns: list[APIReturnInfo]
api_visibility: Literal["public", "private", "undocumented"]
# Set only when the function takes a gr.OAuthToken.
oauth_token: NotRequired[Literal["required", "optional"]]


class APIInfo(TypedDict):
Expand Down
Loading
Loading