corethink 1.0.0__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.
Files changed (48) hide show
  1. corethink/__init__.py +17 -0
  2. corethink/__main__.py +3 -0
  3. corethink/_version.py +3 -0
  4. corethink/account.py +470 -0
  5. corethink/cli.py +1058 -0
  6. corethink/client.py +253 -0
  7. corethink/commands/__init__.py +0 -0
  8. corethink/commands/admin.py +703 -0
  9. corethink/commands/api.py +59 -0
  10. corethink/commands/archive.py +433 -0
  11. corethink/commands/auth.py +375 -0
  12. corethink/commands/billing.py +314 -0
  13. corethink/commands/capture.py +564 -0
  14. corethink/commands/connection.py +1266 -0
  15. corethink/commands/endpoint.py +1124 -0
  16. corethink/commands/env.py +1966 -0
  17. corethink/commands/hub.py +269 -0
  18. corethink/commands/library.py +347 -0
  19. corethink/commands/models.py +258 -0
  20. corethink/commands/train.py +2655 -0
  21. corethink/commands/workspace.py +622 -0
  22. corethink/config.py +181 -0
  23. corethink/context.py +46 -0
  24. corethink/env_template.py +117 -0
  25. corethink/environment_http_store.py +96 -0
  26. corethink/experiment_cli.py +2187 -0
  27. corethink/hosted_profiles.py +44 -0
  28. corethink/hosted_rl.py +688 -0
  29. corethink/hosted_rl_archive.py +74 -0
  30. corethink/hosted_rl_attribution.py +44 -0
  31. corethink/hosted_rl_billing.py +103 -0
  32. corethink/hosted_rl_state.py +182 -0
  33. corethink/jobs_cli.py +839 -0
  34. corethink/main.py +204 -0
  35. corethink/output.py +201 -0
  36. corethink/py.typed +0 -0
  37. corethink/rl_cli.py +997 -0
  38. corethink/rl_inspect.py +521 -0
  39. corethink/rl_playground.py +129 -0
  40. corethink/rl_reassessment.py +214 -0
  41. corethink/rl_settings.py +43 -0
  42. corethink/sdk.py +331 -0
  43. corethink/settings.py +162 -0
  44. corethink-1.0.0.dist-info/METADATA +120 -0
  45. corethink-1.0.0.dist-info/RECORD +48 -0
  46. corethink-1.0.0.dist-info/WHEEL +5 -0
  47. corethink-1.0.0.dist-info/entry_points.txt +2 -0
  48. corethink-1.0.0.dist-info/top_level.txt +1 -0
corethink/__init__.py ADDED
@@ -0,0 +1,17 @@
1
+ """CoreThink: the Workbench from your terminal (`corethink --help`), and the older evaluation SDK.
2
+
3
+ The SDK names (Environment, TaskInstance, Template) are loaded only when asked for: they need the evaluation packages,
4
+ which a plain `pip install corethink` does not bring.
5
+ """
6
+
7
+ from corethink._version import __version__
8
+
9
+ __all__ = ["Environment", "TaskInstance", "Template", "__version__"]
10
+
11
+
12
+ def __getattr__(name: str):
13
+ if name in {"Environment", "TaskInstance", "Template"}:
14
+ from corethink import sdk
15
+
16
+ return getattr(sdk, name)
17
+ raise AttributeError(f"module 'corethink' has no attribute {name!r}")
corethink/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from corethink.main import main
2
+
3
+ raise SystemExit(main())
corethink/_version.py ADDED
@@ -0,0 +1,3 @@
1
+ """The version of the `corethink` package (read by the build and by `corethink --version`)."""
2
+
3
+ __version__ = "1.0.0"
corethink/account.py ADDED
@@ -0,0 +1,470 @@
1
+ """`corethink login` — a CoreThink account, not a model provider's key.
2
+
3
+ Before this, `login` stored whichever provider key an evaluation would send to a
4
+ model endpoint, and `CORETHINK_API_KEY` named that. Nothing authenticated a
5
+ *CoreThink* account at all: hosted work needed a Google login and a shared
6
+ operator token that no customer can be given. This module is the account half,
7
+ and it keeps the two apart on purpose — the failure it is written to prevent is a
8
+ provider key being sent to CoreThink, or a CoreThink key to a provider.
9
+
10
+ Two rules make that concrete:
11
+
12
+ * **A CoreThink credential is recognised by its prefix.** `ctu_` for a browser
13
+ session, `ctk_` for an API key. A value that is neither is refused before any
14
+ request is made, with a message naming where a provider key actually goes. So
15
+ an exported `CORETHINK_API_KEY=sk-...` from the old arrangement produces an
16
+ explanation rather than a leak.
17
+ * **The session lives in its own file**, not in `config.json`, because
18
+ `config list` prints that file and a session token must never be printed.
19
+
20
+ The flow is RFC 8628 device authorization. A browser is the only place the Google
21
+ sign-in can happen, the CLI never sees the Google credential, and the code the
22
+ CLI holds is short-lived, single-use, and worth nothing until somebody signed in
23
+ approves it.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import contextlib
29
+ import json
30
+ import os
31
+ import ssl
32
+ import time
33
+ import urllib.error
34
+ import urllib.parse
35
+ import urllib.request
36
+ import webbrowser
37
+ from pathlib import Path
38
+ from typing import Any
39
+
40
+ __all__ = ["AccountError", "CoreThinkClient", "credentials_path", "load_credential", "register"]
41
+
42
+ #: Where the public API lives unless a deployment or a test says otherwise.
43
+ DEFAULT_API_URL = "https://corethink-api-qtb56vjxea-uc.a.run.app"
44
+
45
+ #: What a CoreThink credential looks like. See the module docstring.
46
+ CREDENTIAL_PREFIXES = ("ctu_", "ctk_")
47
+
48
+ #: How long `login` waits for the browser before giving up on its own.
49
+ LOGIN_TIMEOUT_SECONDS = 600
50
+
51
+
52
+ class AccountError(RuntimeError):
53
+ """Something the person at the terminal can act on."""
54
+
55
+
56
+ # -- where the credential lives ------------------------------------------
57
+
58
+
59
+ def credentials_path() -> Path:
60
+ """The session file, beside the config and overridable the same way.
61
+
62
+ `CORETHINK_CREDENTIALS` exists so a test never touches a developer's real
63
+ session, and so two projects on one machine can hold different accounts.
64
+ """
65
+
66
+ override = os.environ.get("CORETHINK_CREDENTIALS")
67
+ if override:
68
+ return Path(override).expanduser()
69
+ base = os.environ.get("XDG_CONFIG_HOME") or "~/.config"
70
+ return Path(base).expanduser() / "corethink" / "credentials.json"
71
+
72
+
73
+ def save_credential(payload: dict[str, Any]) -> Path:
74
+ """Write the session with the permissions a credential deserves.
75
+
76
+ Mode before content, so there is no window in which the token exists in a
77
+ world-readable file.
78
+ """
79
+
80
+ target = credentials_path()
81
+ target.parent.mkdir(parents=True, exist_ok=True)
82
+ os.chmod(target.parent, 0o700)
83
+ target.touch(mode=0o600, exist_ok=True)
84
+ os.chmod(target, 0o600)
85
+ target.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8")
86
+ return target
87
+
88
+
89
+ def clear_credential() -> bool:
90
+ target = credentials_path()
91
+ if not target.is_file():
92
+ return False
93
+ target.unlink()
94
+ return True
95
+
96
+
97
+ def load_credential() -> dict[str, Any] | None:
98
+ target = credentials_path()
99
+ if not target.is_file():
100
+ return None
101
+ try:
102
+ stored = json.loads(target.read_text(encoding="utf-8"))
103
+ except (OSError, json.JSONDecodeError):
104
+ return None
105
+ return stored if isinstance(stored, dict) and stored.get("access_token") else None
106
+
107
+
108
+ def resolve_api_url(override: str | None = None) -> str:
109
+ from corethink.config import ConfigError, resolve
110
+
111
+ if override:
112
+ return override.rstrip("/")
113
+ try:
114
+ stored = resolve("api_url")
115
+ except ConfigError: # pragma: no cover - a broken config must not block login
116
+ stored = None
117
+ saved = (load_credential() or {}).get("api_url")
118
+ return str(stored or saved or DEFAULT_API_URL).rstrip("/")
119
+
120
+
121
+ def resolve_token(override: str | None = None) -> str | None:
122
+ """The CoreThink credential in force: flag, environment, then the session file.
123
+
124
+ The prefix check is the migration guard. `CORETHINK_API_KEY` used to name a
125
+ *model provider's* key; a value that is not a CoreThink credential is refused
126
+ here, before any request, rather than sent to an endpoint it was never meant
127
+ for.
128
+ """
129
+
130
+ candidate = (override or os.environ.get("CORETHINK_API_KEY", "")).strip()
131
+ if candidate:
132
+ if not candidate.startswith(CREDENTIAL_PREFIXES):
133
+ raise AccountError(
134
+ "CORETHINK_API_KEY does not hold a CoreThink credential (they start with "
135
+ "ctk_ or ctu_). A model provider's key belongs in "
136
+ "`corethink config set provider_api_key`, or in the variable named by "
137
+ "--model-api-key-env; it is never sent to CoreThink."
138
+ )
139
+ return candidate
140
+ stored = load_credential()
141
+ return str(stored["access_token"]) if stored else None
142
+
143
+
144
+ # -- talking to the API ---------------------------------------------------
145
+
146
+
147
+ def _ssl_context() -> ssl.SSLContext:
148
+ """Verify TLS properly even where the interpreter has no CA bundle.
149
+
150
+ Shared with `jobs_cli` rather than re-derived: a framework Python on macOS
151
+ ships without a CA bundle, and the fallback is the difference between working
152
+ and CERTIFICATE_VERIFY_FAILED on every HTTPS call.
153
+ """
154
+
155
+ from corethink.jobs_cli import _ssl_context as shared
156
+
157
+ return shared()
158
+
159
+
160
+ def request(
161
+ api_url: str,
162
+ method: str,
163
+ path: str,
164
+ *,
165
+ payload: dict[str, Any] | None = None,
166
+ token: str | None = None,
167
+ timeout: float = 60.0,
168
+ ) -> tuple[int, Any]:
169
+ """One API call. Returns (status, decoded body) rather than raising on 4xx.
170
+
171
+ The device-flow poll needs the body of a 400 — `authorization_pending` is a
172
+ normal state, not an error — so the status comes back as data and each caller
173
+ decides what it means.
174
+ """
175
+
176
+ headers = {"accept": "application/json"}
177
+ if token:
178
+ headers["authorization"] = f"Bearer {token}"
179
+ data = None
180
+ if payload is not None:
181
+ data = json.dumps(payload).encode("utf-8")
182
+ headers["content-type"] = "application/json"
183
+ call = urllib.request.Request( # noqa: S310 - the base URL comes from explicit config
184
+ f"{api_url}{path}", data=data, headers=headers, method=method
185
+ )
186
+ try:
187
+ with urllib.request.urlopen(call, timeout=timeout, context=_ssl_context()) as response: # noqa: S310
188
+ body = response.read().decode("utf-8")
189
+ return response.status, (json.loads(body) if body.strip() else {})
190
+ except urllib.error.HTTPError as error:
191
+ raw = error.read().decode("utf-8", errors="replace")
192
+ try:
193
+ return error.code, json.loads(raw)
194
+ except json.JSONDecodeError:
195
+ return error.code, {"error": {"message": raw.strip() or error.reason}}
196
+ except urllib.error.URLError as error:
197
+ raise AccountError(f"cannot reach {api_url}: {error.reason}") from None
198
+
199
+
200
+ def _message(body: Any) -> str:
201
+ if isinstance(body, dict):
202
+ error = body.get("error")
203
+ if isinstance(error, dict):
204
+ return str(error.get("message") or error.get("code") or body)
205
+ if isinstance(error, str):
206
+ return str(body.get("error_description") or error)
207
+ return str(body)
208
+
209
+
210
+ class CoreThinkClient:
211
+ """An authenticated caller for every command that reaches the platform.
212
+
213
+ One place where a 401 becomes a sentence somebody can act on, so no command
214
+ has to guess whether a refusal means expired, revoked or under-scoped.
215
+ """
216
+
217
+ def __init__(self, *, api_url: str | None = None, token: str | None = None) -> None:
218
+ self.api_url = resolve_api_url(api_url)
219
+ self._token = resolve_token(token)
220
+ if not self._token:
221
+ raise AccountError(
222
+ "not signed in to CoreThink. Run `corethink login`, or set "
223
+ "CORETHINK_API_KEY to an API key from the Workbench's Account page."
224
+ )
225
+
226
+ def call(
227
+ self,
228
+ method: str,
229
+ path: str,
230
+ *,
231
+ payload: dict[str, Any] | None = None,
232
+ timeout: float = 60.0,
233
+ ) -> Any:
234
+ status, body = request(
235
+ self.api_url, method, path, payload=payload, token=self._token, timeout=timeout
236
+ )
237
+ if status == 401:
238
+ raise AccountError(
239
+ f"{_message(body)}\n"
240
+ "Your CoreThink session may have expired or been revoked. "
241
+ "Run `corethink login` to sign in again."
242
+ )
243
+ if status == 429:
244
+ raise AccountError(_message(body))
245
+ if status >= 400:
246
+ raise AccountError(f"{method} {path} failed [{status}]: {_message(body)}")
247
+ return body
248
+
249
+
250
+ # -- the commands ---------------------------------------------------------
251
+
252
+
253
+ def _print_account(payload: dict[str, Any]) -> None:
254
+ account = payload.get("account") or {}
255
+ credential = payload.get("credential") or {}
256
+ print(f" account {account.get('email', 'unknown')}")
257
+ print(f" workspace {account.get('workspace', 'unknown')}")
258
+ if credential:
259
+ print(f" credential {credential.get('kind', 'session')} ({credential.get('id', '')})")
260
+ scopes = payload.get("scopes") or []
261
+ if scopes:
262
+ print(f" access {', '.join(scopes)}")
263
+
264
+
265
+ def run_login(args: Any) -> int:
266
+ """Authorize this CLI installation through the browser you already sign in with."""
267
+
268
+ api_url = resolve_api_url(getattr(args, "api_url", None))
269
+ status, started = request(
270
+ api_url, "POST", "/v1/auth/device", payload={"client": "corethink-cli"}
271
+ )
272
+ if status != 200:
273
+ raise AccountError(f"cannot start sign-in: {_message(started)}")
274
+
275
+ # The plain page, never one with the code already in it. The browser half
276
+ # exists to have a person check this code against their own terminal, and a
277
+ # link that fills it in for them is the device-code phishing shape: someone
278
+ # else sends the link, the page pre-fills their code, one click authorizes
279
+ # their laptop as you.
280
+ verification = started.get("verification_uri")
281
+ print("Sign in to CoreThink")
282
+ print(f" code {started['user_code']}")
283
+ print(f" open {verification}")
284
+ print(" type that code on the page; it is not filled in for you")
285
+ if not getattr(args, "no_browser", False):
286
+ # Failure here is ordinary — a headless machine, no session bus — and the
287
+ # printed URL is the fallback, so it is never an error.
288
+ with contextlib.suppress(Exception): # pragma: no cover - platform dependent
289
+ webbrowser.open(verification)
290
+ print("Waiting for approval… (ctrl-c to cancel)")
291
+
292
+ interval = float(started.get("interval") or 5)
293
+ deadline = time.monotonic() + min(
294
+ float(started.get("expires_in") or 600), LOGIN_TIMEOUT_SECONDS
295
+ )
296
+ while time.monotonic() < deadline:
297
+ time.sleep(interval)
298
+ status, body = request(
299
+ api_url,
300
+ "POST",
301
+ "/v1/auth/device/token",
302
+ payload={"device_code": started["device_code"]},
303
+ )
304
+ if status == 200:
305
+ saved = {
306
+ "api_url": api_url,
307
+ "access_token": body["access_token"],
308
+ "account": body.get("account", {}),
309
+ "scopes": body.get("scopes", []),
310
+ "expires_at": body.get("expires_at"),
311
+ }
312
+ written = save_credential(saved)
313
+ print("Signed in.")
314
+ _print_account({"account": saved["account"], "scopes": saved["scopes"]})
315
+ print(f" stored {written} (mode 0600)")
316
+ return 0
317
+ code = body.get("error") if isinstance(body, dict) else None
318
+ if code == "authorization_pending":
319
+ continue
320
+ if code == "slow_down":
321
+ interval += 2
322
+ continue
323
+ if code == "access_denied":
324
+ raise AccountError(
325
+ "the sign-in was declined, or that Google account is not admitted to this "
326
+ "CoreThink workspace. Ask an operator to add it, then run `corethink login` again."
327
+ )
328
+ if code == "expired_token":
329
+ raise AccountError("the sign-in request expired. Run `corethink login` again.")
330
+ raise AccountError(f"sign-in failed: {_message(body)}")
331
+ raise AccountError("timed out waiting for browser approval. Run `corethink login` again.")
332
+
333
+
334
+ def run_whoami(args: Any) -> int:
335
+ client = CoreThinkClient(api_url=getattr(args, "api_url", None))
336
+ payload = client.call("GET", "/v1/account")
337
+ if getattr(args, "json", False):
338
+ print(json.dumps(payload, indent=2, sort_keys=True))
339
+ return 0
340
+ print(f"Signed in to {client.api_url}")
341
+ _print_account(payload)
342
+ return 0
343
+
344
+
345
+ def run_logout(args: Any) -> int:
346
+ """End the session on the server, then remove it from this machine.
347
+
348
+ In that order, and the local file goes whatever the server says. A CLI that
349
+ kept the credential because logout could not reach the network would leave a
350
+ usable token on a laptop somebody is trying to clean.
351
+ """
352
+
353
+ stored = load_credential()
354
+ if not stored and not os.environ.get("CORETHINK_API_KEY"):
355
+ print("not signed in")
356
+ return 0
357
+ remote = "not contacted"
358
+ try:
359
+ client = CoreThinkClient(api_url=getattr(args, "api_url", None))
360
+ client.call("POST", "/v1/account/logout")
361
+ remote = "session invalidated"
362
+ except AccountError as error:
363
+ remote = f"server not updated ({error})"
364
+ removed = clear_credential()
365
+ print(f"signed out: {remote}")
366
+ print(f" local credential {'removed' if removed else 'not present'}")
367
+ if os.environ.get("CORETHINK_API_KEY"):
368
+ print(" note: CORETHINK_API_KEY is still set in this shell and still authenticates")
369
+ return 0
370
+
371
+
372
+ def _print_keys(keys: list[dict[str, Any]]) -> None:
373
+ if not keys:
374
+ print("no API keys. Create one with `corethink keys create --name <name>`.")
375
+ return
376
+ print(f"{'ID':<18}{'NAME':<24}{'CREATED':<22}{'LAST USED':<22}STATUS")
377
+ for key in keys:
378
+ status = "revoked" if key.get("revoked_at") else "active"
379
+ print(
380
+ f"{key['id']:<18}{key['name'][:22]:<24}"
381
+ f"{_stamp(key.get('created_at')):<22}{_stamp(key.get('last_used_at')):<22}{status}"
382
+ )
383
+ print(f"{'':<18}scopes: {', '.join(key.get('scopes') or [])}")
384
+
385
+
386
+ def _stamp(value: Any) -> str:
387
+ if not isinstance(value, (int, float)):
388
+ return "never"
389
+ return time.strftime("%Y-%m-%d %H:%M", time.localtime(value))
390
+
391
+
392
+ def run_keys_list(args: Any) -> int:
393
+ client = CoreThinkClient(api_url=getattr(args, "api_url", None))
394
+ payload = client.call("GET", "/v1/account/keys")
395
+ if getattr(args, "json", False):
396
+ print(json.dumps(payload, indent=2, sort_keys=True))
397
+ return 0
398
+ _print_keys(payload.get("keys") or [])
399
+ return 0
400
+
401
+
402
+ def run_keys_create(args: Any) -> int:
403
+ client = CoreThinkClient(api_url=getattr(args, "api_url", None))
404
+ body: dict[str, Any] = {"name": args.name}
405
+ if args.scope:
406
+ body["scopes"] = list(args.scope)
407
+ if args.expires_in_days:
408
+ body["expires_in"] = int(args.expires_in_days) * 86400
409
+ created = client.call("POST", "/v1/account/keys", payload=body)
410
+ if getattr(args, "json", False):
411
+ print(json.dumps(created, indent=2, sort_keys=True))
412
+ return 0
413
+ print(f"created key {created['id']} ({created['name']})")
414
+ print(f" scopes {', '.join(created.get('scopes') or [])}")
415
+ print(
416
+ f" expires {_stamp(created.get('expires_at')) if created.get('expires_at') else 'never'}"
417
+ )
418
+ print()
419
+ print(created["secret"])
420
+ print()
421
+ # Said plainly because it is true: the server stores only a hash, so there is
422
+ # no path by which this can be shown again.
423
+ print("This is the only time the secret is shown. Store it now; it cannot be recovered.")
424
+ return 0
425
+
426
+
427
+ def run_keys_revoke(args: Any) -> int:
428
+ client = CoreThinkClient(api_url=getattr(args, "api_url", None))
429
+ revoked = client.call("DELETE", f"/v1/account/keys/{urllib.parse.quote(args.key_id)}")
430
+ print(f"revoked {revoked['id']} ({revoked.get('name', '')})")
431
+ return 0
432
+
433
+
434
+ def register(commands: Any) -> None:
435
+ """Add the account commands to the CLI's subparsers."""
436
+
437
+ whoami = commands.add_parser("whoami", help="the CoreThink account this CLI is signed in as")
438
+ whoami.add_argument("--api-url", help="the CoreThink API to ask (default: the stored one)")
439
+ whoami.add_argument("--json", action="store_true")
440
+ whoami.set_defaults(handler=run_whoami)
441
+
442
+ keys = commands.add_parser("keys", help="API keys for scripts and integrations")
443
+ key_commands = keys.add_subparsers(dest="keys_command", required=True)
444
+
445
+ listing = key_commands.add_parser("list", help="every key on this account")
446
+ listing.add_argument("--api-url")
447
+ listing.add_argument("--json", action="store_true")
448
+ listing.set_defaults(handler=run_keys_list)
449
+
450
+ create = key_commands.add_parser("create", help="create a key; the secret is shown once")
451
+ create.add_argument("--name", required=True, help="what this key is for")
452
+ create.add_argument(
453
+ "--scope",
454
+ action="append",
455
+ help="one access scope; repeatable. Defaults to your own access, and can never exceed it.",
456
+ )
457
+ create.add_argument(
458
+ "--expires-in-days",
459
+ type=int,
460
+ help="how long the key lives. A key that never expires is the one nobody "
461
+ "remembers to revoke.",
462
+ )
463
+ create.add_argument("--api-url")
464
+ create.add_argument("--json", action="store_true")
465
+ create.set_defaults(handler=run_keys_create)
466
+
467
+ revoke = key_commands.add_parser("revoke", help="revoke a key, effective immediately")
468
+ revoke.add_argument("key_id")
469
+ revoke.add_argument("--api-url")
470
+ revoke.set_defaults(handler=run_keys_revoke)