File size: 4,187 Bytes
17b22d9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
"""Which token pays for a request.

The rule this file exists to enforce: **bill the viewer, not the Space owner,
whenever we possibly can.** A harness that quietly spends the owner's inference
credits on every visitor is one shared link away from an unpleasant invoice.

Precedence, highest first:

1. the signed-in viewer's Hugging Face OAuth token   -> billed to the viewer
2. a key the viewer pasted into the UI this session  -> billed to the viewer
3. the Space's own ``HF_TOKEN`` secret               -> billed to the owner

Step 3 is off unless ``ASE_ALLOW_SPACE_CREDITS`` is set, so the fallback is a
deliberate act rather than a default. Locally it is on, because there is no
OAuth off-Space and a dev loop that cannot make a request is useless.
"""

from __future__ import annotations

import os
from dataclasses import dataclass
from pathlib import Path


@dataclass(frozen=True)
class Credential:
    token: str | None
    mode: str          # oauth | user-key | space-secret | none
    who: str | None = None
    detail: str = ""

    @property
    def bills_viewer(self) -> bool:
        return self.mode in ("oauth", "user-key")

    def to_json(self) -> dict:
        return {
            "mode": self.mode,
            "who": self.who,
            "billsViewer": self.bills_viewer,
            "detail": self.detail,
        }


def on_space() -> bool:
    return bool(os.environ.get("SPACE_ID"))


def allow_space_credits() -> bool:
    """Falling back to the owner's token is opt-in on a Space, on by default off it."""
    flag = os.environ.get("ASE_ALLOW_SPACE_CREDITS")
    if flag is not None:
        return flag.strip().lower() in ("1", "true", "yes", "on")
    return not on_space()


def resolve(oauth_token: str | None = None,
            oauth_user: str | None = None,
            user_key: str | None = None) -> Credential:
    """Pick the credential for one request. Never logs or returns the value."""
    if oauth_token:
        return Credential(oauth_token, "oauth", oauth_user,
                          "Signed in with Hugging Face; inference is billed to you.")

    if user_key:
        return Credential(user_key, "user-key", None,
                          "Using the key you supplied; it is held for this session only.")

    secret = (os.environ.get("HF_TOKEN")
              or os.environ.get("HUGGING_FACE_HUB_TOKEN")
              or _dev_key_file())
    if secret and allow_space_credits():
        return Credential(
            secret, "space-secret", None,
            "Falling back to the Space's own token — this bills the Space owner.",
        )

    if secret:
        return Credential(
            None, "none", None,
            "A Space token exists but owner-billed requests are disabled. "
            "Sign in, or supply your own key.",
        )

    return Credential(None, "none", None,
                      "No credential available. Supply a Hugging Face token.")


def _dev_key_file() -> str | None:
    """The shared ``key.txt`` this workspace keeps for local development.

    Only consulted off a Space. On a Space the token arrives as a secret in the
    environment and this path does not exist, so the lookup is dead code there
    rather than a second way in.
    """
    if on_space():
        return None
    explicit = os.environ.get("ASE_KEY_FILE")
    if explicit:
        # Authoritative when set, including when it points at nothing. The
        # search below would otherwise reach outside a test's tmp_path and
        # find the developer's real key, which is a test that passes for the
        # wrong reason and a suite that behaves differently on CI.
        candidates = [Path(explicit)]
    else:
        # The workspace deliberately keeps one gitignored key above the
        # projects so several of them can share it. Walk up to find it.
        here = Path(__file__).resolve()
        candidates = [parent / "key.txt" for parent in here.parents[:5]]
    for path in candidates:
        try:
            if path.is_file():
                token = path.read_text().strip()
                if token:
                    return token
        except OSError:
            continue
    return None