typesafe-unofficial-cli 1.0.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.
- typesafe_unofficial_cli/__init__.py +3 -0
- typesafe_unofficial_cli/__main__.py +3 -0
- typesafe_unofficial_cli/_version.py +3 -0
- typesafe_unofficial_cli/auth/__init__.py +0 -0
- typesafe_unofficial_cli/auth/credentials.py +75 -0
- typesafe_unofficial_cli/auth/env_store.py +28 -0
- typesafe_unofficial_cli/auth/file_store.py +51 -0
- typesafe_unofficial_cli/auth/keyring_store.py +64 -0
- typesafe_unofficial_cli/auth/resolver.py +35 -0
- typesafe_unofficial_cli/commands/__init__.py +0 -0
- typesafe_unofficial_cli/commands/ask.py +149 -0
- typesafe_unofficial_cli/commands/auth.py +193 -0
- typesafe_unofficial_cli/commands/config.py +134 -0
- typesafe_unofficial_cli/commands/models.py +49 -0
- typesafe_unofficial_cli/config/__init__.py +0 -0
- typesafe_unofficial_cli/config/paths.py +25 -0
- typesafe_unofficial_cli/config/providers.py +37 -0
- typesafe_unofficial_cli/config/settings.py +112 -0
- typesafe_unofficial_cli/config/store.py +56 -0
- typesafe_unofficial_cli/main.py +114 -0
- typesafe_unofficial_cli/output/__init__.py +0 -0
- typesafe_unofficial_cli/output/columns.py +35 -0
- typesafe_unofficial_cli/output/formats.py +82 -0
- typesafe_unofficial_cli/output/registry.py +29 -0
- typesafe_unofficial_cli/output/renderer.py +70 -0
- typesafe_unofficial_cli/runtime/__init__.py +0 -0
- typesafe_unofficial_cli/runtime/client_factory.py +76 -0
- typesafe_unofficial_cli/runtime/context.py +100 -0
- typesafe_unofficial_cli/runtime/errors.py +85 -0
- typesafe_unofficial_cli/runtime/exit_codes.py +15 -0
- typesafe_unofficial_cli/runtime/params.py +42 -0
- typesafe_unofficial_cli/runtime/request_options.py +57 -0
- typesafe_unofficial_cli/services/__init__.py +0 -0
- typesafe_unofficial_cli/services/answers.py +69 -0
- typesafe_unofficial_cli/services/batch.py +57 -0
- typesafe_unofficial_cli/services/inputs.py +103 -0
- typesafe_unofficial_cli/services/validation.py +22 -0
- typesafe_unofficial_cli-1.0.1.dist-info/METADATA +118 -0
- typesafe_unofficial_cli-1.0.1.dist-info/RECORD +42 -0
- typesafe_unofficial_cli-1.0.1.dist-info/WHEEL +4 -0
- typesafe_unofficial_cli-1.0.1.dist-info/entry_points.txt +2 -0
- typesafe_unofficial_cli-1.0.1.dist-info/licenses/LICENSE +21 -0
|
File without changes
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from enum import StrEnum
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
from typing import Final
|
|
8
|
+
from typing import Literal
|
|
9
|
+
from typing import Protocol
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from typesafe_unofficial_cli.config.providers import Provider
|
|
13
|
+
|
|
14
|
+
_VISIBLE_SUFFIX: Final = 4
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class CredentialSource(StrEnum):
|
|
18
|
+
ENVIRONMENT = "environment"
|
|
19
|
+
KEYRING = "keyring"
|
|
20
|
+
FILE = "file"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
# Both providers authenticate with a bearer API key. The tagged encoding leaves room for another
|
|
24
|
+
# credential kind without migrating what is already stored.
|
|
25
|
+
@dataclass(frozen=True, slots=True)
|
|
26
|
+
class ApiKeyCredential:
|
|
27
|
+
api_key: str
|
|
28
|
+
kind: Literal["api_key"] = "api_key"
|
|
29
|
+
|
|
30
|
+
def masked(self) -> str:
|
|
31
|
+
if len(self.api_key) <= _VISIBLE_SUFFIX:
|
|
32
|
+
return "*" * len(self.api_key)
|
|
33
|
+
return "*" * 8 + self.api_key[-_VISIBLE_SUFFIX:]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
type Credential = ApiKeyCredential # pylint: disable=gajaguar-module-const-naming
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True, slots=True)
|
|
40
|
+
class ResolvedCredential:
|
|
41
|
+
credential: Credential
|
|
42
|
+
source: CredentialSource
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def encode(credential: Credential) -> str:
|
|
46
|
+
return json.dumps({"kind": credential.kind, "api_key": credential.api_key})
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def decode(raw: str) -> Credential | None:
|
|
50
|
+
try:
|
|
51
|
+
payload: object = json.loads(raw)
|
|
52
|
+
except ValueError:
|
|
53
|
+
return None
|
|
54
|
+
if not isinstance(payload, dict) or payload.get("kind") != "api_key":
|
|
55
|
+
return None
|
|
56
|
+
api_key = payload.get("api_key")
|
|
57
|
+
if not isinstance(api_key, str) or not api_key:
|
|
58
|
+
return None
|
|
59
|
+
return ApiKeyCredential(api_key=api_key)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
# `provider` only matters to the environment store, which reads a different variable per provider.
|
|
63
|
+
class CredentialStore(Protocol):
|
|
64
|
+
@property
|
|
65
|
+
def source(self) -> CredentialSource: ...
|
|
66
|
+
|
|
67
|
+
def get(self, profile: str, provider: Provider) -> Credential | None: ...
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class WritableCredentialStore(CredentialStore, Protocol):
|
|
71
|
+
def available(self) -> bool: ...
|
|
72
|
+
|
|
73
|
+
def set(self, profile: str, credential: Credential) -> None: ...
|
|
74
|
+
|
|
75
|
+
def delete(self, profile: str) -> bool: ...
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING
|
|
4
|
+
|
|
5
|
+
from typesafe_unofficial_cli.auth.credentials import ApiKeyCredential
|
|
6
|
+
from typesafe_unofficial_cli.auth.credentials import CredentialSource
|
|
7
|
+
from typesafe_unofficial_cli.config.providers import spec_for
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from collections.abc import Mapping
|
|
11
|
+
|
|
12
|
+
from typesafe_unofficial_cli.config.providers import Provider
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
# The environment key is profile-agnostic: it overrides whatever profile is selected, for CI. It is
|
|
16
|
+
# looked up by the profile's provider, so each host only ever receives its own key.
|
|
17
|
+
class EnvCredentialStore:
|
|
18
|
+
def __init__(self, environ: Mapping[str, str]) -> None:
|
|
19
|
+
self._environ = environ
|
|
20
|
+
|
|
21
|
+
@property
|
|
22
|
+
def source(self) -> CredentialSource:
|
|
23
|
+
return CredentialSource.ENVIRONMENT
|
|
24
|
+
|
|
25
|
+
def get(self, profile: str, provider: Provider) -> ApiKeyCredential | None:
|
|
26
|
+
del profile
|
|
27
|
+
api_key = self._environ.get(spec_for(provider).api_key_env_var, "").strip()
|
|
28
|
+
return ApiKeyCredential(api_key=api_key) if api_key else None
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING
|
|
4
|
+
|
|
5
|
+
from typesafe_unofficial_cli.auth.credentials import CredentialSource
|
|
6
|
+
from typesafe_unofficial_cli.auth.credentials import decode
|
|
7
|
+
from typesafe_unofficial_cli.auth.credentials import encode
|
|
8
|
+
from typesafe_unofficial_cli.config.store import read_toml
|
|
9
|
+
from typesafe_unofficial_cli.config.store import write_private_toml
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from typesafe_unofficial_cli.auth.credentials import Credential
|
|
15
|
+
from typesafe_unofficial_cli.config.providers import Provider
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
# Plaintext fallback for hosts without a keyring backend; only written on explicit opt-in.
|
|
19
|
+
class FileCredentialStore:
|
|
20
|
+
def __init__(self, path: Path) -> None:
|
|
21
|
+
self.path = path
|
|
22
|
+
|
|
23
|
+
@property
|
|
24
|
+
def source(self) -> CredentialSource:
|
|
25
|
+
return CredentialSource.FILE
|
|
26
|
+
|
|
27
|
+
@staticmethod
|
|
28
|
+
def available() -> bool:
|
|
29
|
+
return True
|
|
30
|
+
|
|
31
|
+
def get(self, profile: str, provider: Provider) -> Credential | None:
|
|
32
|
+
del provider
|
|
33
|
+
raw = self._entries().get(profile)
|
|
34
|
+
return decode(raw) if isinstance(raw, str) else None
|
|
35
|
+
|
|
36
|
+
def set(self, profile: str, credential: Credential) -> None:
|
|
37
|
+
entries = self._entries()
|
|
38
|
+
entries[profile] = encode(credential)
|
|
39
|
+
write_private_toml(self.path, {"profiles": entries})
|
|
40
|
+
|
|
41
|
+
def delete(self, profile: str) -> bool:
|
|
42
|
+
entries = self._entries()
|
|
43
|
+
if profile not in entries:
|
|
44
|
+
return False
|
|
45
|
+
del entries[profile]
|
|
46
|
+
write_private_toml(self.path, {"profiles": entries})
|
|
47
|
+
return True
|
|
48
|
+
|
|
49
|
+
def _entries(self) -> dict[str, object]:
|
|
50
|
+
profiles = read_toml(self.path).get("profiles")
|
|
51
|
+
return dict(profiles) if isinstance(profiles, dict) else {}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING
|
|
4
|
+
from typing import Final
|
|
5
|
+
|
|
6
|
+
import keyring
|
|
7
|
+
from keyring.backends.fail import Keyring as FailKeyring
|
|
8
|
+
from keyring.errors import KeyringError
|
|
9
|
+
from keyring.errors import PasswordDeleteError
|
|
10
|
+
|
|
11
|
+
from typesafe_unofficial_cli.auth.credentials import CredentialSource
|
|
12
|
+
from typesafe_unofficial_cli.auth.credentials import decode
|
|
13
|
+
from typesafe_unofficial_cli.auth.credentials import encode
|
|
14
|
+
from typesafe_unofficial_cli.runtime.errors import CliError
|
|
15
|
+
from typesafe_unofficial_cli.runtime.exit_codes import ExitCode
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from typesafe_unofficial_cli.auth.credentials import Credential
|
|
19
|
+
from typesafe_unofficial_cli.config.providers import Provider
|
|
20
|
+
|
|
21
|
+
SERVICE_NAME: Final = "typesafe-cli"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class KeyringCredentialStore:
|
|
25
|
+
def __init__(self, service: str = SERVICE_NAME) -> None:
|
|
26
|
+
self._service = service
|
|
27
|
+
|
|
28
|
+
@property
|
|
29
|
+
def source(self) -> CredentialSource:
|
|
30
|
+
return CredentialSource.KEYRING
|
|
31
|
+
|
|
32
|
+
@staticmethod
|
|
33
|
+
def available() -> bool:
|
|
34
|
+
return not isinstance(keyring.get_keyring(), FailKeyring)
|
|
35
|
+
|
|
36
|
+
def get(self, profile: str, provider: Provider) -> Credential | None:
|
|
37
|
+
del provider
|
|
38
|
+
if not self.available():
|
|
39
|
+
return None
|
|
40
|
+
try:
|
|
41
|
+
raw = keyring.get_password(self._service, profile)
|
|
42
|
+
except KeyringError:
|
|
43
|
+
return None
|
|
44
|
+
return decode(raw) if raw else None
|
|
45
|
+
|
|
46
|
+
def set(self, profile: str, credential: Credential) -> None:
|
|
47
|
+
try:
|
|
48
|
+
keyring.set_password(self._service, profile, encode(credential))
|
|
49
|
+
except KeyringError as error:
|
|
50
|
+
message = f"Could not write to the system keyring: {error}"
|
|
51
|
+
raise CliError(
|
|
52
|
+
message,
|
|
53
|
+
exit_code=ExitCode.CONFIGURATION,
|
|
54
|
+
hint="Retry with --insecure-storage to use a 0600 file instead.",
|
|
55
|
+
) from error
|
|
56
|
+
|
|
57
|
+
def delete(self, profile: str) -> bool:
|
|
58
|
+
if not self.available():
|
|
59
|
+
return False
|
|
60
|
+
try:
|
|
61
|
+
keyring.delete_password(self._service, profile)
|
|
62
|
+
except PasswordDeleteError:
|
|
63
|
+
return False
|
|
64
|
+
return True
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import TYPE_CHECKING
|
|
5
|
+
|
|
6
|
+
from typesafe_unofficial_cli.auth.credentials import ResolvedCredential
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
from typesafe_unofficial_cli.auth.credentials import CredentialStore
|
|
10
|
+
from typesafe_unofficial_cli.auth.credentials import WritableCredentialStore
|
|
11
|
+
from typesafe_unofficial_cli.auth.env_store import EnvCredentialStore
|
|
12
|
+
from typesafe_unofficial_cli.auth.file_store import FileCredentialStore
|
|
13
|
+
from typesafe_unofficial_cli.auth.keyring_store import KeyringCredentialStore
|
|
14
|
+
from typesafe_unofficial_cli.config.providers import Provider
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True, slots=True)
|
|
18
|
+
class CredentialStores:
|
|
19
|
+
environment: EnvCredentialStore
|
|
20
|
+
keyring: KeyringCredentialStore
|
|
21
|
+
file: FileCredentialStore
|
|
22
|
+
|
|
23
|
+
# Precedence order: the first store holding a credential for the profile wins.
|
|
24
|
+
def chain(self) -> tuple[CredentialStore, ...]:
|
|
25
|
+
return (self.environment, self.keyring, self.file)
|
|
26
|
+
|
|
27
|
+
def writable(self) -> tuple[WritableCredentialStore, ...]:
|
|
28
|
+
return (self.keyring, self.file)
|
|
29
|
+
|
|
30
|
+
def resolve(self, profile: str, provider: Provider) -> ResolvedCredential | None:
|
|
31
|
+
for store in self.chain():
|
|
32
|
+
credential = store.get(profile, provider)
|
|
33
|
+
if credential is not None:
|
|
34
|
+
return ResolvedCredential(credential=credential, source=store.source)
|
|
35
|
+
return None
|
|
File without changes
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import TYPE_CHECKING
|
|
4
|
+
from typing import Annotated
|
|
5
|
+
from typing import Final
|
|
6
|
+
from typing import cast
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
|
|
10
|
+
from typesafe_unofficial_cli.output.columns import ANSWERS
|
|
11
|
+
from typesafe_unofficial_cli.output.columns import ANSWERS_BATCH
|
|
12
|
+
from typesafe_unofficial_cli.output.renderer import Dataset
|
|
13
|
+
from typesafe_unofficial_cli.runtime.context import get_app_context
|
|
14
|
+
from typesafe_unofficial_cli.runtime.errors import CliError
|
|
15
|
+
from typesafe_unofficial_cli.runtime.errors import exit_code_for
|
|
16
|
+
from typesafe_unofficial_cli.runtime.errors import handle_errors
|
|
17
|
+
from typesafe_unofficial_cli.runtime.errors import report
|
|
18
|
+
from typesafe_unofficial_cli.runtime.exit_codes import ExitCode
|
|
19
|
+
from typesafe_unofficial_cli.runtime.params import options_from
|
|
20
|
+
from typesafe_unofficial_cli.runtime.request_options import RequestOptions
|
|
21
|
+
from typesafe_unofficial_cli.services.answers import answer_records
|
|
22
|
+
from typesafe_unofficial_cli.services.answers import batch_usage_line
|
|
23
|
+
from typesafe_unofficial_cli.services.answers import usage_line
|
|
24
|
+
from typesafe_unofficial_cli.services.batch import BatchRequest
|
|
25
|
+
from typesafe_unofficial_cli.services.batch import run_batch
|
|
26
|
+
from typesafe_unofficial_cli.services.inputs import STDIN_MARKER
|
|
27
|
+
from typesafe_unofficial_cli.services.inputs import StateFormat
|
|
28
|
+
from typesafe_unofficial_cli.services.inputs import read_extra_body
|
|
29
|
+
from typesafe_unofficial_cli.services.inputs import read_questions
|
|
30
|
+
from typesafe_unofficial_cli.services.inputs import read_state
|
|
31
|
+
from typesafe_unofficial_cli.services.inputs import read_states
|
|
32
|
+
|
|
33
|
+
if TYPE_CHECKING:
|
|
34
|
+
from typesafe_sdk import JSONValue
|
|
35
|
+
from typesafe_sdk import QuestionModel
|
|
36
|
+
from typesafe_sdk import TypeSafeError
|
|
37
|
+
|
|
38
|
+
from typesafe_unofficial_cli.runtime.context import AppContext
|
|
39
|
+
from typesafe_unofficial_cli.runtime.request_options import RequestOverrides
|
|
40
|
+
|
|
41
|
+
APP: Final = typer.Typer()
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(frozen=True, slots=True)
|
|
45
|
+
class _AskOptions(RequestOptions):
|
|
46
|
+
state: Annotated[str | None, typer.Option("--state", help="The text to evaluate.")] = None
|
|
47
|
+
state_file: Annotated[
|
|
48
|
+
Path | None,
|
|
49
|
+
typer.Option("--state-file", help="Read the state from a file; `-` reads standard input."),
|
|
50
|
+
] = None
|
|
51
|
+
state_format: Annotated[
|
|
52
|
+
StateFormat,
|
|
53
|
+
typer.Option(
|
|
54
|
+
"--state-format", case_sensitive=False, help="Parse the state as plain text or as a JSON object/array."
|
|
55
|
+
),
|
|
56
|
+
] = StateFormat.TEXT
|
|
57
|
+
questions_file: Annotated[
|
|
58
|
+
Path | None,
|
|
59
|
+
typer.Option(
|
|
60
|
+
"--questions-file",
|
|
61
|
+
help='JSON object of named questions, each {"type": "noul|choice|score", ...}; `-` reads standard input.',
|
|
62
|
+
),
|
|
63
|
+
] = None
|
|
64
|
+
states_file: Annotated[
|
|
65
|
+
Path | None,
|
|
66
|
+
typer.Option(
|
|
67
|
+
"--states-file",
|
|
68
|
+
help="Batch mode: one JSON state (string, object or array) per line; `-` reads standard input.",
|
|
69
|
+
),
|
|
70
|
+
] = None
|
|
71
|
+
concurrency: Annotated[
|
|
72
|
+
int, typer.Option("--concurrency", min=1, help="Batch mode: requests in flight at once.")
|
|
73
|
+
] = 4
|
|
74
|
+
extra_body: Annotated[
|
|
75
|
+
Path | None,
|
|
76
|
+
typer.Option("--extra-body", help="JSON object of extra top-level request fields; `-` reads standard input."),
|
|
77
|
+
] = None
|
|
78
|
+
model: Annotated[
|
|
79
|
+
str | None, typer.Option("--model", help="Model name; defaults to the profile's, else jev-latest.")
|
|
80
|
+
] = None
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@APP.command(help="Answer named questions (noul, choice, score) about a state with a System One model.")
|
|
84
|
+
@handle_errors
|
|
85
|
+
@options_from(_AskOptions)
|
|
86
|
+
def ask(ctx: typer.Context, options: _AskOptions) -> None:
|
|
87
|
+
if options.questions_file is None:
|
|
88
|
+
message = "--questions-file is required."
|
|
89
|
+
raise CliError(message, exit_code=ExitCode.USAGE)
|
|
90
|
+
stdin_sources = [
|
|
91
|
+
flag
|
|
92
|
+
for flag, source in (
|
|
93
|
+
("--state-file", options.state_file),
|
|
94
|
+
("--states-file", options.states_file),
|
|
95
|
+
("--questions-file", options.questions_file),
|
|
96
|
+
("--extra-body", options.extra_body),
|
|
97
|
+
)
|
|
98
|
+
if str(source) == STDIN_MARKER
|
|
99
|
+
]
|
|
100
|
+
if len(stdin_sources) > 1:
|
|
101
|
+
message = f"Only one of {', '.join(stdin_sources)} can read standard input."
|
|
102
|
+
raise CliError(message, exit_code=ExitCode.USAGE)
|
|
103
|
+
if options.states_file is not None and (options.state is not None or options.state_file is not None):
|
|
104
|
+
message = "--states-file cannot be combined with --state or --state-file."
|
|
105
|
+
raise CliError(message, exit_code=ExitCode.USAGE)
|
|
106
|
+
app_context = get_app_context(ctx)
|
|
107
|
+
overrides = options.overrides()
|
|
108
|
+
questions = read_questions(options.questions_file)
|
|
109
|
+
extra_body = None if options.extra_body is None else read_extra_body(options.extra_body)
|
|
110
|
+
if options.states_file is not None:
|
|
111
|
+
_ask_batch(app_context, options, overrides, questions, extra_body)
|
|
112
|
+
return
|
|
113
|
+
content = read_state(text=options.state, file=options.state_file, state_format=options.state_format)
|
|
114
|
+
response = app_context.client(overrides).system_one(content, questions, model=options.model, extra_body=extra_body)
|
|
115
|
+
app_context.notify(usage_line(response))
|
|
116
|
+
app_context.render(Dataset(records=answer_records(response), columns=ANSWERS, id_key="question"))
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
# Answers are rendered first, then each failed state is reported and the first failure sets the exit code.
|
|
120
|
+
def _ask_batch(
|
|
121
|
+
app_context: AppContext,
|
|
122
|
+
options: _AskOptions,
|
|
123
|
+
overrides: RequestOverrides,
|
|
124
|
+
questions: dict[str, QuestionModel],
|
|
125
|
+
extra_body: dict[str, JSONValue | None] | None,
|
|
126
|
+
) -> None:
|
|
127
|
+
request = BatchRequest(
|
|
128
|
+
states=read_states(cast("Path", options.states_file)),
|
|
129
|
+
questions=questions,
|
|
130
|
+
model=options.model,
|
|
131
|
+
extra_body=extra_body,
|
|
132
|
+
concurrency=options.concurrency,
|
|
133
|
+
)
|
|
134
|
+
items = run_batch(app_context.async_client(overrides), request)
|
|
135
|
+
answered = [item.response for item in items if item.response is not None]
|
|
136
|
+
records = [
|
|
137
|
+
record
|
|
138
|
+
for item in items
|
|
139
|
+
if item.response is not None
|
|
140
|
+
for record in answer_records(item.response, index=item.index)
|
|
141
|
+
]
|
|
142
|
+
app_context.notify(batch_usage_line(answered))
|
|
143
|
+
app_context.render(Dataset(records=records, columns=ANSWERS_BATCH, id_key="question"))
|
|
144
|
+
failures = [item for item in items if item.error is not None]
|
|
145
|
+
for item in failures:
|
|
146
|
+
report(f"state {item.index}: {item.error}")
|
|
147
|
+
if failures:
|
|
148
|
+
message = f"{len(failures)} of {len(items)} states failed."
|
|
149
|
+
raise CliError(message, exit_code=exit_code_for(cast("TypeSafeError", failures[0].error)))
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
from typing import TYPE_CHECKING
|
|
4
|
+
from typing import Annotated
|
|
5
|
+
from typing import Final
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
from pydantic import ValidationError
|
|
9
|
+
|
|
10
|
+
from typesafe_unofficial_cli.auth.credentials import ApiKeyCredential
|
|
11
|
+
from typesafe_unofficial_cli.config.providers import Provider
|
|
12
|
+
from typesafe_unofficial_cli.config.providers import spec_for
|
|
13
|
+
from typesafe_unofficial_cli.config.settings import Profile
|
|
14
|
+
from typesafe_unofficial_cli.output.columns import AUTH_STATUS
|
|
15
|
+
from typesafe_unofficial_cli.output.renderer import single
|
|
16
|
+
from typesafe_unofficial_cli.runtime.client_factory import ClientRequest
|
|
17
|
+
from typesafe_unofficial_cli.runtime.context import get_app_context
|
|
18
|
+
from typesafe_unofficial_cli.runtime.errors import CliError
|
|
19
|
+
from typesafe_unofficial_cli.runtime.errors import handle_errors
|
|
20
|
+
from typesafe_unofficial_cli.runtime.exit_codes import ExitCode
|
|
21
|
+
from typesafe_unofficial_cli.runtime.params import options_from
|
|
22
|
+
from typesafe_unofficial_cli.services.validation import verify_key
|
|
23
|
+
|
|
24
|
+
if TYPE_CHECKING:
|
|
25
|
+
from typesafe_unofficial_cli.auth.credentials import WritableCredentialStore
|
|
26
|
+
from typesafe_unofficial_cli.auth.resolver import CredentialStores
|
|
27
|
+
|
|
28
|
+
APP: Final = typer.Typer(help="Log in, inspect, and remove API credentials.", no_args_is_help=True)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _read_api_key(provider: Provider, *, with_token: bool) -> str:
|
|
32
|
+
# No --api-key flag on purpose: a key passed as an argument ends up in shell history.
|
|
33
|
+
raw = (
|
|
34
|
+
sys.stdin.read()
|
|
35
|
+
if with_token
|
|
36
|
+
else typer.prompt(f"{spec_for(provider).display_name} API key", hide_input=True, err=True)
|
|
37
|
+
)
|
|
38
|
+
api_key = str(raw).strip()
|
|
39
|
+
if not api_key:
|
|
40
|
+
message = "The API key is empty."
|
|
41
|
+
raise CliError(message, exit_code=ExitCode.USAGE)
|
|
42
|
+
return api_key
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _select_store(stores: CredentialStores, *, insecure_storage: bool) -> WritableCredentialStore:
|
|
46
|
+
if insecure_storage:
|
|
47
|
+
return stores.file
|
|
48
|
+
if stores.keyring.available():
|
|
49
|
+
return stores.keyring
|
|
50
|
+
message = "No system keyring backend is available."
|
|
51
|
+
raise CliError(
|
|
52
|
+
message,
|
|
53
|
+
exit_code=ExitCode.CONFIGURATION,
|
|
54
|
+
hint="Retry with --insecure-storage, or set the provider's API key variable in the environment.",
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _status_record(profile_name: str, profile: Profile, source: str, masked_key: str) -> dict[str, object]:
|
|
59
|
+
return {
|
|
60
|
+
"profile": profile_name,
|
|
61
|
+
"provider": profile.provider,
|
|
62
|
+
"base_url": profile.effective_base_url(),
|
|
63
|
+
"model": profile.model,
|
|
64
|
+
"source": source,
|
|
65
|
+
"api_key": masked_key,
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass(frozen=True, slots=True)
|
|
70
|
+
class _LoginOptions:
|
|
71
|
+
provider: Annotated[
|
|
72
|
+
Provider | None,
|
|
73
|
+
typer.Option(
|
|
74
|
+
"--provider",
|
|
75
|
+
case_sensitive=False,
|
|
76
|
+
help="Where the key is valid; defaults to the profile's, else typesafe.",
|
|
77
|
+
),
|
|
78
|
+
] = None
|
|
79
|
+
base_url: Annotated[
|
|
80
|
+
str | None,
|
|
81
|
+
typer.Option("--base-url", help="Override the provider's API root, e.g. for a proxy."),
|
|
82
|
+
] = None
|
|
83
|
+
with_token: Annotated[bool, typer.Option("--with-token", help="Read the API key from standard input.")] = False
|
|
84
|
+
insecure_storage: Annotated[
|
|
85
|
+
bool,
|
|
86
|
+
typer.Option("--insecure-storage", help="Store the key in a 0600 plaintext file instead of the keyring."),
|
|
87
|
+
] = False
|
|
88
|
+
skip_validation: Annotated[
|
|
89
|
+
bool,
|
|
90
|
+
typer.Option("--skip-validation", help="Store the key without calling the provider."),
|
|
91
|
+
] = False
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@APP.command(
|
|
95
|
+
help=(
|
|
96
|
+
"Validate an API key and store it for the selected profile. "
|
|
97
|
+
"A TypeSafe key is checked for free; an OpenRouter key needs one minimal billed request "
|
|
98
|
+
"(skip it with --skip-validation). Environment variables for automation: "
|
|
99
|
+
"TYPESAFE_API_KEY (provider typesafe) and OPENROUTER_API_KEY (provider openrouter)."
|
|
100
|
+
),
|
|
101
|
+
)
|
|
102
|
+
@handle_errors
|
|
103
|
+
@options_from(_LoginOptions)
|
|
104
|
+
def login(ctx: typer.Context, options: _LoginOptions) -> None:
|
|
105
|
+
app_context = get_app_context(ctx)
|
|
106
|
+
stores = app_context.services.credentials
|
|
107
|
+
profile_name = app_context.options.profile_name
|
|
108
|
+
current = app_context.options.profile
|
|
109
|
+
resolved_provider = options.provider or current.provider
|
|
110
|
+
# A different provider invalidates the old override and default model, which belong to the old host.
|
|
111
|
+
keeps_settings = resolved_provider is current.provider
|
|
112
|
+
try:
|
|
113
|
+
profile = Profile(
|
|
114
|
+
provider=resolved_provider,
|
|
115
|
+
base_url=options.base_url or (current.base_url if keeps_settings else None),
|
|
116
|
+
model=current.model if keeps_settings else None,
|
|
117
|
+
)
|
|
118
|
+
except ValidationError as error:
|
|
119
|
+
message = error.errors()[0]["msg"].removeprefix("Value error, ")
|
|
120
|
+
raise CliError(message, exit_code=ExitCode.USAGE) from error
|
|
121
|
+
credential = ApiKeyCredential(api_key=_read_api_key(resolved_provider, with_token=options.with_token))
|
|
122
|
+
if not options.skip_validation:
|
|
123
|
+
request = ClientRequest(
|
|
124
|
+
credential=credential,
|
|
125
|
+
base_url=profile.effective_base_url(),
|
|
126
|
+
model=profile.model,
|
|
127
|
+
verbose=app_context.options.verbose,
|
|
128
|
+
)
|
|
129
|
+
with app_context.services.clients(request) as client:
|
|
130
|
+
verify_key(client, resolved_provider)
|
|
131
|
+
store = _select_store(stores, insecure_storage=options.insecure_storage)
|
|
132
|
+
store.set(profile_name, credential)
|
|
133
|
+
for other in stores.writable():
|
|
134
|
+
if other is not store:
|
|
135
|
+
other.delete(profile_name)
|
|
136
|
+
settings = app_context.services.settings.load()
|
|
137
|
+
if not settings.profiles:
|
|
138
|
+
settings = settings.model_copy(update={"default_profile": profile_name})
|
|
139
|
+
app_context.services.settings.save(settings.with_profile(profile_name, profile))
|
|
140
|
+
app_context.notify(
|
|
141
|
+
f"Logged in to profile '{profile_name}' ({spec_for(resolved_provider).display_name}, {store.source})."
|
|
142
|
+
)
|
|
143
|
+
app_context.render(single(_status_record(profile_name, profile, store.source, credential.masked()), AUTH_STATUS))
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
@APP.command(help="Show the active credential; --check also validates it with the provider.")
|
|
147
|
+
@handle_errors
|
|
148
|
+
def status(
|
|
149
|
+
ctx: typer.Context,
|
|
150
|
+
*,
|
|
151
|
+
check: Annotated[
|
|
152
|
+
bool, typer.Option("--check", help="Validate the key with the provider (OpenRouter bills one request).")
|
|
153
|
+
] = False,
|
|
154
|
+
) -> None:
|
|
155
|
+
app_context = get_app_context(ctx)
|
|
156
|
+
resolved = app_context.credential()
|
|
157
|
+
if check:
|
|
158
|
+
verify_key(app_context.client(), app_context.options.profile.provider)
|
|
159
|
+
record = _status_record(
|
|
160
|
+
app_context.options.profile_name,
|
|
161
|
+
app_context.options.profile,
|
|
162
|
+
resolved.source,
|
|
163
|
+
resolved.credential.masked(),
|
|
164
|
+
)
|
|
165
|
+
app_context.render(single(record, AUTH_STATUS))
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
@APP.command(help="Remove the stored credential and profile.")
|
|
169
|
+
@handle_errors
|
|
170
|
+
def logout(ctx: typer.Context) -> None:
|
|
171
|
+
app_context = get_app_context(ctx)
|
|
172
|
+
services = app_context.services
|
|
173
|
+
profile_name = app_context.options.profile_name
|
|
174
|
+
removed = [store.source for store in services.credentials.writable() if store.delete(profile_name)]
|
|
175
|
+
settings = services.settings.load()
|
|
176
|
+
had_profile = profile_name in settings.profiles
|
|
177
|
+
if had_profile:
|
|
178
|
+
services.settings.save(settings.without_profile(profile_name))
|
|
179
|
+
if not removed and not had_profile:
|
|
180
|
+
message = f"No stored credentials for profile '{profile_name}'."
|
|
181
|
+
raise CliError(message, exit_code=ExitCode.CONFIGURATION)
|
|
182
|
+
app_context.notify(f"Logged out of profile '{profile_name}'.")
|
|
183
|
+
provider = app_context.options.profile.provider
|
|
184
|
+
if services.credentials.environment.get(profile_name, provider) is not None:
|
|
185
|
+
variable = spec_for(provider).api_key_env_var
|
|
186
|
+
app_context.notify(f"{variable} is still set in the environment and will keep being used.")
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
@APP.command(help="Print the raw API key of the active credential.")
|
|
190
|
+
@handle_errors
|
|
191
|
+
def token(ctx: typer.Context) -> None:
|
|
192
|
+
resolved = get_app_context(ctx).credential()
|
|
193
|
+
typer.echo(resolved.credential.api_key)
|