iis-access 0.1.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
iis_access/__init__.py ADDED
@@ -0,0 +1,19 @@
1
+ """IIS Tools Python access library - optional auth integration for CLI tools."""
2
+
3
+ from ._types import AuthContext, ProviderResolution, NoProviderAvailable
4
+ from .auth import get_auth_context, is_authenticated
5
+ from .provider import resolve_provider, register_free_provider
6
+ from .managed_keys import get_managed_key
7
+ from .usage import report_usage
8
+
9
+ __all__ = [
10
+ "AuthContext",
11
+ "ProviderResolution",
12
+ "NoProviderAvailable",
13
+ "get_auth_context",
14
+ "is_authenticated",
15
+ "resolve_provider",
16
+ "register_free_provider",
17
+ "get_managed_key",
18
+ "report_usage",
19
+ ]
iis_access/_types.py ADDED
@@ -0,0 +1,23 @@
1
+ from dataclasses import dataclass
2
+
3
+
4
+ @dataclass
5
+ class AuthContext:
6
+ account_id: str
7
+ access_token: str
8
+ plan: str # "free", "pro", "enterprise"
9
+
10
+
11
+ @dataclass
12
+ class ProviderResolution:
13
+ provider: str # "openai", "elevenlabs", "edge-tts", etc.
14
+ method: str # "nexus", "managed_key", "local_key", "free"
15
+ api_key: str | None # The key to use (None for free providers)
16
+ endpoint: str | None # Override endpoint (for Nexus)
17
+ credits_estimated: float | None # Estimated credit cost
18
+
19
+
20
+ class NoProviderAvailable(Exception):
21
+ """Raised when no provider can fulfill the request."""
22
+
23
+ pass
iis_access/auth.py ADDED
@@ -0,0 +1,150 @@
1
+ import asyncio
2
+ import logging
3
+
4
+ from ._types import AuthContext
5
+ from .config import get_account_url, get_credentials, is_auth_disabled
6
+
7
+ logger = logging.getLogger("iis_access")
8
+
9
+ _cached_context: AuthContext | None = None
10
+ _refresh_lock: asyncio.Lock | None = None
11
+
12
+
13
+ def _get_lock() -> asyncio.Lock:
14
+ global _refresh_lock
15
+ if _refresh_lock is None:
16
+ _refresh_lock = asyncio.Lock()
17
+ return _refresh_lock
18
+
19
+
20
+ async def get_auth_context() -> AuthContext | None:
21
+ """Get authentication context. Returns None if not authenticated."""
22
+ global _cached_context
23
+
24
+ if is_auth_disabled():
25
+ return None
26
+
27
+ if _cached_context is not None:
28
+ return _cached_context
29
+
30
+ async with _get_lock():
31
+ # Double-check after acquiring lock
32
+ if _cached_context is not None:
33
+ return _cached_context
34
+
35
+ creds = get_credentials()
36
+ if not creds or "accountId" not in creds:
37
+ return None
38
+
39
+ account_id = creds["accountId"]
40
+ refresh_token = _get_refresh_token(account_id)
41
+ if not refresh_token:
42
+ return None
43
+
44
+ # Exchange refresh token for access token
45
+ try:
46
+ import aiohttp
47
+
48
+ account_url = get_account_url()
49
+ timeout = aiohttp.ClientTimeout(total=5)
50
+ async with aiohttp.ClientSession(timeout=timeout) as session:
51
+ resp = await session.post(
52
+ f"{account_url}/auth/refresh",
53
+ json={"refreshToken": refresh_token},
54
+ )
55
+ if resp.status != 200:
56
+ logger.info(
57
+ "Token refresh failed (status %d), continuing without auth",
58
+ resp.status,
59
+ )
60
+ return None
61
+
62
+ data = await resp.json()
63
+ access_token = data.get("accessToken")
64
+ new_refresh_token = data.get("refreshToken")
65
+ plan = data.get("plan") or _extract_plan_from_jwt(access_token)
66
+
67
+ if not access_token:
68
+ return None
69
+
70
+ # Store new refresh token back
71
+ if new_refresh_token:
72
+ _store_refresh_token(account_id, new_refresh_token)
73
+
74
+ _cached_context = AuthContext(
75
+ account_id=account_id,
76
+ access_token=access_token,
77
+ plan=plan,
78
+ )
79
+ return _cached_context
80
+
81
+ except Exception as e:
82
+ logger.warning("Auth failed (non-fatal): %s", e)
83
+ return None
84
+
85
+
86
+ def is_authenticated() -> bool:
87
+ """Synchronous check if there are credentials available."""
88
+ creds = get_credentials()
89
+ return creds is not None and "accountId" in creds
90
+
91
+
92
+ def _get_refresh_token(account_id: str) -> str | None:
93
+ """Try keyring first, then fall back to credentials file."""
94
+ # Try keyring (optional dependency)
95
+ kr = _get_keyring()
96
+ if kr:
97
+ try:
98
+ token = kr.get_password("iis.tools", account_id)
99
+ if token:
100
+ return token
101
+ except Exception:
102
+ pass
103
+
104
+ # Fall back to credentials file
105
+ creds = get_credentials()
106
+ if creds and "refreshToken" in creds:
107
+ return creds["refreshToken"]
108
+
109
+ return None
110
+
111
+
112
+ def _store_refresh_token(account_id: str, token: str) -> None:
113
+ """Store refresh token in keyring if available."""
114
+ kr = _get_keyring()
115
+ if kr:
116
+ try:
117
+ kr.set_password("iis.tools", account_id, token)
118
+ except Exception as e:
119
+ logger.debug("Failed to store refresh token in keyring: %s", e)
120
+
121
+
122
+ def _extract_plan_from_jwt(token: str | None) -> str:
123
+ """Extract plan claim from JWT payload without verifying signature."""
124
+ if not token:
125
+ return "free"
126
+ try:
127
+ import base64, json as _json
128
+ payload = token.split(".")[1]
129
+ payload += "=" * (4 - len(payload) % 4)
130
+ claims = _json.loads(base64.urlsafe_b64decode(payload))
131
+ return claims.get("plan", "free")
132
+ except Exception:
133
+ return "free"
134
+
135
+
136
+ def _get_keyring():
137
+ """Lazy import keyring (optional dependency)."""
138
+ try:
139
+ import keyring
140
+
141
+ return keyring
142
+ except ImportError:
143
+ return None
144
+
145
+
146
+ def _reset_auth_cache() -> None:
147
+ """Reset auth cache (for testing)."""
148
+ global _cached_context, _refresh_lock
149
+ _cached_context = None
150
+ _refresh_lock = None
iis_access/config.py ADDED
@@ -0,0 +1,45 @@
1
+ import json
2
+ import os
3
+ from pathlib import Path
4
+
5
+ _IIS_DIR = Path.home() / ".iis"
6
+
7
+
8
+ def get_account_url() -> str:
9
+ if url := os.environ.get("IIS_ACCOUNT_URL"):
10
+ return url.rstrip("/")
11
+ config = _load_config()
12
+ return config.get("accountUrl", "https://account.iis.tools").rstrip("/")
13
+
14
+
15
+ def get_nexus_url() -> str:
16
+ if url := os.environ.get("IIS_NEXUS_URL"):
17
+ return url.rstrip("/")
18
+ config = _load_config()
19
+ return config.get("nexusUrl", "http://localhost:9666").rstrip("/")
20
+
21
+
22
+ def is_auth_disabled() -> bool:
23
+ return os.environ.get("IIS_NO_AUTH") == "1"
24
+
25
+
26
+ def get_credentials() -> dict | None:
27
+ path = _IIS_DIR / "credentials.json"
28
+ if not path.exists():
29
+ return None
30
+ try:
31
+ with open(path) as f:
32
+ return json.load(f)
33
+ except (json.JSONDecodeError, OSError):
34
+ return None
35
+
36
+
37
+ def _load_config() -> dict:
38
+ path = _IIS_DIR / "config.json"
39
+ if not path.exists():
40
+ return {}
41
+ try:
42
+ with open(path) as f:
43
+ return json.load(f)
44
+ except (json.JSONDecodeError, OSError):
45
+ return {}
@@ -0,0 +1,54 @@
1
+ import logging
2
+
3
+ from .auth import get_auth_context
4
+ from .config import get_account_url
5
+
6
+ logger = logging.getLogger("iis_access")
7
+
8
+ # In-memory cache for managed keys (process lifetime)
9
+ _key_cache: dict[str, str] = {}
10
+
11
+
12
+ async def get_managed_key(provider: str) -> str | None:
13
+ """
14
+ Retrieve user's managed API key for a provider from iis-account.
15
+ Returns None if not authenticated, no key exists, or unreachable.
16
+ """
17
+ if provider in _key_cache:
18
+ return _key_cache[provider]
19
+
20
+ context = await get_auth_context()
21
+ if context is None:
22
+ return None
23
+
24
+ try:
25
+ import aiohttp
26
+
27
+ account_url = get_account_url()
28
+ timeout = aiohttp.ClientTimeout(total=5)
29
+ async with aiohttp.ClientSession(timeout=timeout) as session:
30
+ resp = await session.get(
31
+ f"{account_url}/keys/managed/{provider}",
32
+ headers={"Authorization": f"Bearer {context.access_token}"},
33
+ )
34
+ if resp.status == 200:
35
+ data = await resp.json()
36
+ key = data.get("key")
37
+ if key:
38
+ _key_cache[provider] = key
39
+ return key
40
+ elif resp.status == 404:
41
+ logger.debug("No managed key for provider '%s'", provider)
42
+ else:
43
+ logger.debug(
44
+ "Managed key retrieval failed (status %d)", resp.status
45
+ )
46
+ except Exception as e:
47
+ logger.debug("Managed key retrieval failed (non-fatal): %s", e)
48
+
49
+ return None
50
+
51
+
52
+ def _reset_key_cache() -> None:
53
+ """Reset managed key cache (for testing)."""
54
+ _key_cache.clear()
iis_access/nexus.py ADDED
@@ -0,0 +1,19 @@
1
+ import logging
2
+
3
+ from .config import get_nexus_url
4
+
5
+ logger = logging.getLogger("iis_access")
6
+
7
+
8
+ async def is_nexus_running() -> bool:
9
+ """Check if Nexus local agent is running and responsive."""
10
+ try:
11
+ import aiohttp
12
+
13
+ nexus_url = get_nexus_url()
14
+ timeout = aiohttp.ClientTimeout(total=1)
15
+ async with aiohttp.ClientSession(timeout=timeout) as session:
16
+ resp = await session.get(f"{nexus_url}/health")
17
+ return resp.status == 200
18
+ except Exception:
19
+ return False
iis_access/provider.py ADDED
@@ -0,0 +1,142 @@
1
+ import os
2
+ import logging
3
+ import shutil
4
+ from typing import Callable
5
+
6
+ from ._types import ProviderResolution, NoProviderAvailable
7
+ from .nexus import is_nexus_running
8
+ from .managed_keys import get_managed_key
9
+ from .auth import get_auth_context
10
+
11
+ logger = logging.getLogger("iis_access")
12
+
13
+ # Known env var names for local API keys
14
+ _ENV_KEY_MAP: dict[str, str] = {
15
+ "openai": "OPENAI_API_KEY",
16
+ "anthropic": "ANTHROPIC_API_KEY",
17
+ "elevenlabs": "ELEVENLABS_API_KEY",
18
+ "azure-openai": "AZURE_OPENAI_API_KEY",
19
+ "google-ai": "GOOGLE_AI_API_KEY",
20
+ "mistral": "MISTRAL_API_KEY",
21
+ "groq": "GROQ_API_KEY",
22
+ "deepseek": "DEEPSEEK_API_KEY",
23
+ }
24
+
25
+ # Free provider registry
26
+ _FREE_PROVIDERS: dict[str, list[tuple[str, Callable[[], bool] | None]]] = {
27
+ "tts": [
28
+ ("edge-tts", None), # Always available (pip package)
29
+ ("mac-say", lambda: shutil.which("say") is not None),
30
+ ],
31
+ "transcription": [
32
+ ("whisper-local", lambda: shutil.which("whisper") is not None),
33
+ ],
34
+ }
35
+
36
+
37
+ def register_free_provider(
38
+ task_type: str,
39
+ provider: str,
40
+ check: Callable[[], bool] | None = None,
41
+ ) -> None:
42
+ """Register a free provider for a task type."""
43
+ if task_type not in _FREE_PROVIDERS:
44
+ _FREE_PROVIDERS[task_type] = []
45
+ _FREE_PROVIDERS[task_type].append((provider, check))
46
+
47
+
48
+ async def resolve_provider(
49
+ task_type: str,
50
+ preferred: str | None = None,
51
+ require_capability: str | None = None,
52
+ ) -> ProviderResolution:
53
+ """
54
+ Resolve the best provider for the given task type.
55
+
56
+ Resolution order:
57
+ 1. Nexus with pooled credits
58
+ 2. Nexus with managed key
59
+ 3. Local API key (env var)
60
+ 4. Free provider
61
+ 5. Raise NoProviderAvailable
62
+ """
63
+ context = await get_auth_context()
64
+
65
+ # Branch 1 & 2: Nexus
66
+ if await is_nexus_running():
67
+ nexus_url = _get_nexus_endpoint()
68
+
69
+ # Branch 1: Nexus with pooled credits (if authenticated with plan)
70
+ if context and context.plan in ("pro", "enterprise"):
71
+ return ProviderResolution(
72
+ provider=preferred or "nexus-default",
73
+ method="nexus",
74
+ api_key=None,
75
+ endpoint=nexus_url,
76
+ credits_estimated=None, # Nexus handles pricing
77
+ )
78
+
79
+ # Branch 2: Nexus with managed key
80
+ if context and preferred:
81
+ managed_key = await get_managed_key(preferred)
82
+ if managed_key:
83
+ return ProviderResolution(
84
+ provider=preferred,
85
+ method="managed_key",
86
+ api_key=managed_key,
87
+ endpoint=nexus_url,
88
+ credits_estimated=None,
89
+ )
90
+
91
+ # Branch 3: Local API key
92
+ providers_to_check = [preferred] if preferred else list(_ENV_KEY_MAP.keys())
93
+ for provider in providers_to_check:
94
+ if not provider:
95
+ continue
96
+ env_var = _ENV_KEY_MAP.get(provider)
97
+ if env_var:
98
+ key = os.environ.get(env_var)
99
+ if key:
100
+ return ProviderResolution(
101
+ provider=provider,
102
+ method="local_key",
103
+ api_key=key,
104
+ endpoint=None,
105
+ credits_estimated=None,
106
+ )
107
+
108
+ # Also check managed key without Nexus (direct access)
109
+ if context and preferred:
110
+ managed_key = await get_managed_key(preferred)
111
+ if managed_key:
112
+ return ProviderResolution(
113
+ provider=preferred,
114
+ method="managed_key",
115
+ api_key=managed_key,
116
+ endpoint=None,
117
+ credits_estimated=None,
118
+ )
119
+
120
+ # Branch 4: Free provider
121
+ free_providers = _FREE_PROVIDERS.get(task_type, [])
122
+ for name, check_fn in free_providers:
123
+ if check_fn is None or check_fn():
124
+ return ProviderResolution(
125
+ provider=name,
126
+ method="free",
127
+ api_key=None,
128
+ endpoint=None,
129
+ credits_estimated=0,
130
+ )
131
+
132
+ # Branch 5: No provider available
133
+ raise NoProviderAvailable(
134
+ f"No provider available for task type '{task_type}'"
135
+ + (f" (preferred: {preferred})" if preferred else "")
136
+ )
137
+
138
+
139
+ def _get_nexus_endpoint() -> str:
140
+ from .config import get_nexus_url
141
+
142
+ return get_nexus_url()
iis_access/sync.py ADDED
@@ -0,0 +1,30 @@
1
+ import asyncio
2
+
3
+ from ._types import ProviderResolution
4
+
5
+
6
+ def resolve_provider_sync(
7
+ task_type: str,
8
+ preferred: str | None = None,
9
+ require_capability: str | None = None,
10
+ ) -> ProviderResolution:
11
+ """Sync wrapper for resolve_provider."""
12
+ from .provider import resolve_provider
13
+
14
+ return asyncio.run(resolve_provider(task_type, preferred, require_capability))
15
+
16
+
17
+ def get_managed_key_sync(provider: str) -> str | None:
18
+ """Sync wrapper for get_managed_key."""
19
+ from .managed_keys import get_managed_key
20
+
21
+ return asyncio.run(get_managed_key(provider))
22
+
23
+
24
+ def report_usage_sync(
25
+ service: str, credits: float = 0, requests: int = 1
26
+ ) -> None:
27
+ """Sync wrapper for report_usage."""
28
+ from .usage import report_usage
29
+
30
+ asyncio.run(report_usage(service, credits, requests))
iis_access/usage.py ADDED
@@ -0,0 +1,40 @@
1
+ import logging
2
+
3
+ from .auth import get_auth_context
4
+ from .config import get_account_url
5
+
6
+ logger = logging.getLogger("iis_access")
7
+
8
+
9
+ async def report_usage(
10
+ service: str,
11
+ credits: float = 0,
12
+ requests: int = 1,
13
+ ) -> None:
14
+ """
15
+ Fire-and-forget usage report to iis-account.
16
+ Never raises - logs warnings on failure.
17
+ Skipped if not authenticated.
18
+ """
19
+ context = await get_auth_context()
20
+ if context is None:
21
+ return
22
+
23
+ try:
24
+ import aiohttp
25
+
26
+ account_url = get_account_url()
27
+ timeout = aiohttp.ClientTimeout(total=5)
28
+ async with aiohttp.ClientSession(timeout=timeout) as session:
29
+ await session.post(
30
+ f"{account_url}/usage/record",
31
+ json={
32
+ "accountId": context.account_id,
33
+ "service": service,
34
+ "requests": requests,
35
+ "credits": credits,
36
+ },
37
+ headers={"Authorization": f"Bearer {context.access_token}"},
38
+ )
39
+ except Exception as e:
40
+ logger.debug("Usage reporting failed (non-fatal): %s", e)
@@ -0,0 +1,48 @@
1
+ Metadata-Version: 2.4
2
+ Name: iis-access
3
+ Version: 0.1.1
4
+ Summary: Python auth library for IIS tools ecosystem
5
+ Project-URL: Repository, https://github.com/hschmied/account
6
+ Project-URL: Homepage, https://iis.tools
7
+ Author-email: IIS Labs <dev@iis.tools>
8
+ License-Expression: MIT
9
+ Keywords: api-key,auth,iis,provider-resolution
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Programming Language :: Python :: 3.13
17
+ Requires-Python: >=3.11
18
+ Requires-Dist: aiohttp>=3.9
19
+ Provides-Extra: dev
20
+ Requires-Dist: aioresponses>=0.7; extra == 'dev'
21
+ Requires-Dist: pytest-asyncio>=0.24; extra == 'dev'
22
+ Requires-Dist: pytest>=8.0; extra == 'dev'
23
+ Provides-Extra: keyring
24
+ Requires-Dist: keyring>=25.0; extra == 'keyring'
25
+ Description-Content-Type: text/markdown
26
+
27
+ # iis-access
28
+
29
+ Python auth library for IIS tools ecosystem. Provides optional authentication, provider resolution, managed key retrieval, and usage reporting for Python-based IIS CLI tools (aria, sonar, iris).
30
+
31
+ ## Installation
32
+
33
+ ```bash
34
+ pip install iis-access
35
+ ```
36
+
37
+ ## Quick Start
38
+
39
+ ```python
40
+ from iis_access import resolve_provider, report_usage
41
+
42
+ # Resolve the best available provider for a task
43
+ resolution = await resolve_provider("tts", preferred="elevenlabs")
44
+ print(resolution.provider, resolution.method)
45
+
46
+ # Report usage (fire-and-forget, never raises)
47
+ await report_usage("aria", credits=1.5)
48
+ ```
@@ -0,0 +1,12 @@
1
+ iis_access/__init__.py,sha256=pOCjnVHB--w0Eq46f7akfQ2WHqO1y7HCKCg-8oa2Rzw,577
2
+ iis_access/_types.py,sha256=WwyOteO-VyDgRBclXhczSCa-d6g-kZuJfAAcOnT2WB8,613
3
+ iis_access/auth.py,sha256=P4IKxhDvzWPBGHevGs09LaoV3c6BiULL9yNlwieAzQg,4427
4
+ iis_access/config.py,sha256=II9rbpyiUNOq3u5FxjQsbjVaHALtvIO_5j1eHasj1aw,1091
5
+ iis_access/managed_keys.py,sha256=newufFQIbU0NspHWKKWWagWaQeVajDL4zEbbfqDt9Es,1652
6
+ iis_access/nexus.py,sha256=57Em0iS2fXF-eEdywp-iPpki3OfOf5TVFlz0OPUByQA,525
7
+ iis_access/provider.py,sha256=QY9OoE9vAYEiFOzMGncb_Q3iZzpKEIWtysMEOHKEPAE,4359
8
+ iis_access/sync.py,sha256=qqcqK_3XkMrrFN3joMwsU6d895N83vFrl1nveHOKIfA,804
9
+ iis_access/usage.py,sha256=5DNNHTTsbjnb2expBMM-kkXc2z6EfxsiezAPEzfQllI,1126
10
+ iis_access-0.1.1.dist-info/METADATA,sha256=p5f8p8D-5Ou_ifeIugffbQtvIGCD-ncSMInUOPg0CyM,1587
11
+ iis_access-0.1.1.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
12
+ iis_access-0.1.1.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any