world-model-optimizer 0.2.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.
- llm_waterfall/LICENSE +21 -0
- llm_waterfall/__init__.py +53 -0
- llm_waterfall/adapters/__init__.py +36 -0
- llm_waterfall/adapters/anthropic.py +105 -0
- llm_waterfall/adapters/aws_mantle.py +47 -0
- llm_waterfall/adapters/azure_openai.py +71 -0
- llm_waterfall/adapters/base.py +51 -0
- llm_waterfall/adapters/bedrock.py +309 -0
- llm_waterfall/adapters/openai.py +130 -0
- llm_waterfall/classify.py +184 -0
- llm_waterfall/pricing.py +110 -0
- llm_waterfall/py.typed +0 -0
- llm_waterfall/types.py +295 -0
- llm_waterfall/waterfall.py +255 -0
- wmo/__init__.py +38 -0
- wmo/agents/__init__.py +7 -0
- wmo/agents/default.py +29 -0
- wmo/agents/meta.py +55 -0
- wmo/agents/optimizer.py +55 -0
- wmo/agents/project.py +928 -0
- wmo/cli/__init__.py +5 -0
- wmo/cli/agent_session.py +1123 -0
- wmo/cli/app.py +2489 -0
- wmo/cli/e2b_cmds.py +212 -0
- wmo/cli/eval_closed_loop.py +207 -0
- wmo/cli/harness_app.py +1147 -0
- wmo/cli/harness_distill.py +659 -0
- wmo/cli/hosted_session.py +880 -0
- wmo/cli/ingest_cmd.py +165 -0
- wmo/cli/model_roles.py +82 -0
- wmo/cli/platform_cmds.py +372 -0
- wmo/cli/route_app.py +274 -0
- wmo/cli/session_state.py +243 -0
- wmo/cli/ui.py +1107 -0
- wmo/cli/workspace_sync.py +504 -0
- wmo/config/__init__.py +60 -0
- wmo/config/card.py +129 -0
- wmo/config/config.py +367 -0
- wmo/config/dotenv.py +67 -0
- wmo/config/settings.py +128 -0
- wmo/config/store.py +177 -0
- wmo/conftest.py +19 -0
- wmo/connect/__init__.py +88 -0
- wmo/connect/apps.py +78 -0
- wmo/connect/brave.py +284 -0
- wmo/connect/connector.py +79 -0
- wmo/connect/credentials.py +164 -0
- wmo/connect/github.py +321 -0
- wmo/connect/google.py +627 -0
- wmo/connect/notion.py +790 -0
- wmo/connect/oauth.py +461 -0
- wmo/connect/slack.py +555 -0
- wmo/connect/store.py +199 -0
- wmo/connect/types.py +156 -0
- wmo/core/__init__.py +21 -0
- wmo/core/parsing.py +281 -0
- wmo/core/render.py +271 -0
- wmo/core/text.py +40 -0
- wmo/core/types.py +116 -0
- wmo/distill/__init__.py +14 -0
- wmo/distill/agents.py +140 -0
- wmo/distill/config.py +1006 -0
- wmo/distill/cost.py +437 -0
- wmo/distill/data.py +921 -0
- wmo/distill/deadlines.py +254 -0
- wmo/distill/fake_tinker.py +734 -0
- wmo/distill/gate.py +122 -0
- wmo/distill/loop.py +3499 -0
- wmo/distill/renderers.py +399 -0
- wmo/distill/rendering.py +620 -0
- wmo/distill/rollouts.py +726 -0
- wmo/distill/samples.py +195 -0
- wmo/distill/store.py +829 -0
- wmo/distill/teacher.py +714 -0
- wmo/distill/tokens.py +535 -0
- wmo/distill/tracking.py +552 -0
- wmo/distill/tripwire.py +411 -0
- wmo/distill/xtoken/byte_offsets.py +152 -0
- wmo/distill/xtoken/chunks.py +457 -0
- wmo/distill/xtoken/prompt_logprobs.py +475 -0
- wmo/distill/xtoken/teacher_render.py +346 -0
- wmo/engine/__init__.py +28 -0
- wmo/engine/autoconfig.py +367 -0
- wmo/engine/build.py +346 -0
- wmo/engine/demo.py +77 -0
- wmo/engine/eval_suites.py +245 -0
- wmo/engine/grounding.py +491 -0
- wmo/engine/knowledge.py +291 -0
- wmo/engine/loader.py +36 -0
- wmo/engine/play.py +92 -0
- wmo/engine/prompts.py +99 -0
- wmo/engine/replay.py +443 -0
- wmo/engine/reporting.py +58 -0
- wmo/engine/workspace.py +468 -0
- wmo/engine/world_model.py +568 -0
- wmo/env/__init__.py +22 -0
- wmo/env/base.py +121 -0
- wmo/env/closed_loop.py +229 -0
- wmo/env/episode.py +107 -0
- wmo/env/llm_agent.py +93 -0
- wmo/env/scenarios.py +73 -0
- wmo/evals/__init__.py +52 -0
- wmo/evals/agreement.py +110 -0
- wmo/evals/base.py +45 -0
- wmo/evals/closed_loop.py +480 -0
- wmo/evals/failover.py +96 -0
- wmo/evals/gold.py +127 -0
- wmo/evals/grid.py +394 -0
- wmo/evals/grid_plot.py +205 -0
- wmo/evals/harbor/__init__.py +27 -0
- wmo/evals/harbor/agent.py +573 -0
- wmo/evals/harbor/ctrf.py +171 -0
- wmo/evals/harbor/e2b_environment.py +587 -0
- wmo/evals/harbor/e2b_template_policy.py +144 -0
- wmo/evals/harbor/scorer.py +875 -0
- wmo/evals/harbor/tasks.py +140 -0
- wmo/evals/open_loop.py +194 -0
- wmo/evals/tasks.py +53 -0
- wmo/harness/__init__.py +51 -0
- wmo/harness/code_runtime.py +288 -0
- wmo/harness/create.py +1191 -0
- wmo/harness/delta.py +220 -0
- wmo/harness/doc.py +556 -0
- wmo/harness/e2b_ledger.py +342 -0
- wmo/harness/e2b_reap.py +476 -0
- wmo/harness/e2b_sandbox.py +350 -0
- wmo/harness/environment.py +35 -0
- wmo/harness/live_session.py +543 -0
- wmo/harness/mutate.py +343 -0
- wmo/harness/pi_e2b.py +1710 -0
- wmo/harness/pi_entry/entry.ts +268 -0
- wmo/harness/pi_entry/runner_frames.ts +92 -0
- wmo/harness/pi_entry/runner_live.ts +587 -0
- wmo/harness/pi_entry/runner_service.ts +270 -0
- wmo/harness/pi_entry/runner_stdio.ts +374 -0
- wmo/harness/pi_entry/runner_termination.ts +142 -0
- wmo/harness/pi_local.py +262 -0
- wmo/harness/pi_runtime.py +495 -0
- wmo/harness/pi_vendor.py +65 -0
- wmo/harness/population.py +509 -0
- wmo/harness/project_proposer.py +569 -0
- wmo/harness/proposer.py +977 -0
- wmo/harness/runner_link.py +619 -0
- wmo/harness/runtime.py +389 -0
- wmo/harness/scoring.py +247 -0
- wmo/harness/skills.py +116 -0
- wmo/harness/source_tree.py +319 -0
- wmo/harness/store.py +176 -0
- wmo/harness/tools.py +105 -0
- wmo/harness/vendor/manifest.sha256 +58 -0
- wmo/harness/vendor/pi-agent/CHANGELOG.md +556 -0
- wmo/harness/vendor/pi-agent/LICENSE +21 -0
- wmo/harness/vendor/pi-agent/README.md +488 -0
- wmo/harness/vendor/pi-agent/VENDOR.md +39 -0
- wmo/harness/vendor/pi-agent/docs/agent-harness.md +486 -0
- wmo/harness/vendor/pi-agent/docs/durable-harness.md +212 -0
- wmo/harness/vendor/pi-agent/docs/hooks.md +445 -0
- wmo/harness/vendor/pi-agent/docs/models.md +966 -0
- wmo/harness/vendor/pi-agent/docs/observability.md +376 -0
- wmo/harness/vendor/pi-agent/package.json +60 -0
- wmo/harness/vendor/pi-agent/src/agent-loop.ts +748 -0
- wmo/harness/vendor/pi-agent/src/agent.ts +575 -0
- wmo/harness/vendor/pi-agent/src/harness/agent-harness.ts +1029 -0
- wmo/harness/vendor/pi-agent/src/harness/compaction/branch-summarization.ts +261 -0
- wmo/harness/vendor/pi-agent/src/harness/compaction/compaction.ts +747 -0
- wmo/harness/vendor/pi-agent/src/harness/compaction/utils.ts +144 -0
- wmo/harness/vendor/pi-agent/src/harness/env/nodejs.ts +550 -0
- wmo/harness/vendor/pi-agent/src/harness/messages.ts +164 -0
- wmo/harness/vendor/pi-agent/src/harness/prompt-templates.ts +267 -0
- wmo/harness/vendor/pi-agent/src/harness/session/jsonl-repo.ts +177 -0
- wmo/harness/vendor/pi-agent/src/harness/session/jsonl-storage.ts +293 -0
- wmo/harness/vendor/pi-agent/src/harness/session/memory-repo.ts +50 -0
- wmo/harness/vendor/pi-agent/src/harness/session/memory-storage.ts +131 -0
- wmo/harness/vendor/pi-agent/src/harness/session/repo-utils.ts +51 -0
- wmo/harness/vendor/pi-agent/src/harness/session/session.ts +267 -0
- wmo/harness/vendor/pi-agent/src/harness/session/uuid.ts +54 -0
- wmo/harness/vendor/pi-agent/src/harness/skills.ts +375 -0
- wmo/harness/vendor/pi-agent/src/harness/system-prompt.ts +34 -0
- wmo/harness/vendor/pi-agent/src/harness/types.ts +836 -0
- wmo/harness/vendor/pi-agent/src/harness/utils/shell-output.ts +135 -0
- wmo/harness/vendor/pi-agent/src/harness/utils/truncate.ts +344 -0
- wmo/harness/vendor/pi-agent/src/index.ts +44 -0
- wmo/harness/vendor/pi-agent/src/node.ts +2 -0
- wmo/harness/vendor/pi-agent/src/proxy.ts +367 -0
- wmo/harness/vendor/pi-agent/src/types.ts +428 -0
- wmo/harness/vendor/pi-agent/test/agent-loop.test.ts +1351 -0
- wmo/harness/vendor/pi-agent/test/agent.test.ts +699 -0
- wmo/harness/vendor/pi-agent/test/e2e.test.ts +404 -0
- wmo/harness/vendor/pi-agent/test/harness/agent-harness-stream.test.ts +213 -0
- wmo/harness/vendor/pi-agent/test/harness/agent-harness.test.ts +608 -0
- wmo/harness/vendor/pi-agent/test/harness/compaction.test.ts +655 -0
- wmo/harness/vendor/pi-agent/test/harness/nodejs-env.test.ts +321 -0
- wmo/harness/vendor/pi-agent/test/harness/prompt-templates.test.ts +90 -0
- wmo/harness/vendor/pi-agent/test/harness/repo.test.ts +68 -0
- wmo/harness/vendor/pi-agent/test/harness/resource-formatting.test.ts +24 -0
- wmo/harness/vendor/pi-agent/test/harness/session-test-utils.ts +55 -0
- wmo/harness/vendor/pi-agent/test/harness/session-uuid.test.ts +50 -0
- wmo/harness/vendor/pi-agent/test/harness/session.test.ts +156 -0
- wmo/harness/vendor/pi-agent/test/harness/skills.test.ts +116 -0
- wmo/harness/vendor/pi-agent/test/harness/storage.test.ts +299 -0
- wmo/harness/vendor/pi-agent/test/harness/system-prompt.test.ts +66 -0
- wmo/harness/vendor/pi-agent/test/harness/truncate.test.ts +169 -0
- wmo/harness/vendor/pi-agent/test/scratch/simple.ts +72 -0
- wmo/harness/vendor/pi-agent/test/utils/calculate.ts +32 -0
- wmo/harness/vendor/pi-agent/test/utils/get-current-time.ts +46 -0
- wmo/harness/vendor/pi-agent/tsconfig.build.json +13 -0
- wmo/harness/vendor/pi-agent/vitest.config.ts +19 -0
- wmo/harness/vendor/pi-agent/vitest.harness.config.ts +28 -0
- wmo/harness/vendor/vendor_pi.sh +59 -0
- wmo/harness/workspace_patch.py +270 -0
- wmo/ingest/__init__.py +47 -0
- wmo/ingest/adapter.py +72 -0
- wmo/ingest/base.py +114 -0
- wmo/ingest/braintrust.py +339 -0
- wmo/ingest/detect.py +126 -0
- wmo/ingest/langfuse.py +291 -0
- wmo/ingest/langsmith.py +444 -0
- wmo/ingest/mastra.py +330 -0
- wmo/ingest/messages.py +170 -0
- wmo/ingest/normalize.py +679 -0
- wmo/ingest/otel_genai.py +69 -0
- wmo/ingest/otel_writer.py +100 -0
- wmo/ingest/phoenix.py +150 -0
- wmo/ingest/postgres.py +246 -0
- wmo/ingest/posthog.py +320 -0
- wmo/ingest/quality.py +28 -0
- wmo/ingest/stream.py +209 -0
- wmo/ingest/testdata/sample_otlp.json +60 -0
- wmo/ingest/testdata/sample_spans.jsonl +3 -0
- wmo/optimize/__init__.py +25 -0
- wmo/optimize/base.py +143 -0
- wmo/optimize/gepa.py +806 -0
- wmo/optimize/judge.py +262 -0
- wmo/optimize/judge_quality.py +359 -0
- wmo/optimize/knn.py +468 -0
- wmo/optimize/numeric.py +152 -0
- wmo/optimize/outcomes.py +103 -0
- wmo/optimize/policy.py +669 -0
- wmo/optimize/report.py +231 -0
- wmo/optimize/reward.py +129 -0
- wmo/optimize/routing.py +373 -0
- wmo/platform/__init__.py +6 -0
- wmo/platform/auth.py +115 -0
- wmo/platform/client.py +551 -0
- wmo/platform/credentials.py +126 -0
- wmo/platform/transfer.py +158 -0
- wmo/providers/__init__.py +40 -0
- wmo/providers/_bedrock_chat.py +155 -0
- wmo/providers/_openai_common.py +182 -0
- wmo/providers/_responses_common.py +472 -0
- wmo/providers/anthropic.py +134 -0
- wmo/providers/azure_openai.py +296 -0
- wmo/providers/base.py +300 -0
- wmo/providers/bedrock.py +312 -0
- wmo/providers/models.py +205 -0
- wmo/providers/openai.py +143 -0
- wmo/providers/openai_responses.py +240 -0
- wmo/providers/pool.py +170 -0
- wmo/providers/registry.py +73 -0
- wmo/providers/retry.py +151 -0
- wmo/providers/tinker.py +936 -0
- wmo/providers/waterfall.py +336 -0
- wmo/research/__init__.py +81 -0
- wmo/research/ablation.py +133 -0
- wmo/research/concurrency_plot.py +523 -0
- wmo/research/concurrency_run.py +240 -0
- wmo/research/concurrency_scaling.py +270 -0
- wmo/research/gepa_scaling.py +274 -0
- wmo/research/pipeline.py +198 -0
- wmo/research/scaling_split.py +82 -0
- wmo/research/scenario_fidelity.py +198 -0
- wmo/research/scenario_recovery.py +92 -0
- wmo/research/seed_stability.py +90 -0
- wmo/research/trace_scaling.py +348 -0
- wmo/retrieval/__init__.py +6 -0
- wmo/retrieval/embedders.py +105 -0
- wmo/retrieval/leakfree.py +52 -0
- wmo/retrieval/retriever.py +173 -0
- wmo/scenarios/__init__.py +58 -0
- wmo/scenarios/builder.py +152 -0
- wmo/scenarios/mining/__init__.py +27 -0
- wmo/scenarios/mining/clustering.py +171 -0
- wmo/scenarios/mining/facets.py +226 -0
- wmo/scenarios/mining/selection.py +220 -0
- wmo/scenarios/synthesis/__init__.py +6 -0
- wmo/scenarios/synthesis/scenario_set.py +63 -0
- wmo/scenarios/synthesis/synthesizer.py +85 -0
- wmo/scenarios/verification/__init__.py +17 -0
- wmo/scenarios/verification/judge.py +97 -0
- wmo/scenarios/verification/verify.py +135 -0
- wmo/serving/__init__.py +5 -0
- wmo/serving/builds.py +451 -0
- wmo/serving/chat.py +878 -0
- wmo/serving/endpoint_config.py +64 -0
- wmo/serving/savings.py +250 -0
- wmo/serving/server.py +553 -0
- wmo/serving/traces_source.py +206 -0
- wmo/telemetry.py +213 -0
- wmo/tracking/__init__.py +36 -0
- wmo/tracking/clock.py +24 -0
- wmo/tracking/metered.py +125 -0
- wmo/tracking/pricing.py +99 -0
- wmo/tracking/store.py +31 -0
- wmo/tracking/tracker.py +149 -0
- world_model_optimizer-0.2.0.dist-info/METADATA +203 -0
- world_model_optimizer-0.2.0.dist-info/RECORD +308 -0
- world_model_optimizer-0.2.0.dist-info/WHEEL +4 -0
- world_model_optimizer-0.2.0.dist-info/entry_points.txt +2 -0
wmo/platform/client.py
ADDED
|
@@ -0,0 +1,551 @@
|
|
|
1
|
+
"""Typed HTTP client for the platform's CLI registry surface.
|
|
2
|
+
|
|
3
|
+
Every call carries the org API key as a bearer credential; the platform scopes
|
|
4
|
+
reads and writes to that key's organization at member strength. Error payloads
|
|
5
|
+
are the platform's uniform ``{"error": message}`` shape, surfaced as
|
|
6
|
+
:class:`PlatformError` with the HTTP status attached.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import hashlib
|
|
12
|
+
import json
|
|
13
|
+
from importlib import metadata
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Literal
|
|
16
|
+
|
|
17
|
+
import httpx
|
|
18
|
+
from llm_waterfall import ChatRequest, ChatResponse
|
|
19
|
+
from pydantic import BaseModel
|
|
20
|
+
|
|
21
|
+
from wmo.core.types import Action, JsonValue, Observation
|
|
22
|
+
|
|
23
|
+
_TIMEOUT_SECONDS = 120.0
|
|
24
|
+
_WORKSPACE_TIMEOUT_SECONDS = 300.0
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class PlatformError(RuntimeError):
|
|
28
|
+
"""A platform request failed; carries the HTTP status when one exists."""
|
|
29
|
+
|
|
30
|
+
def __init__(self, message: str, *, status_code: int | None = None) -> None:
|
|
31
|
+
super().__init__(message)
|
|
32
|
+
self.status_code = status_code
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class ActorInfo(BaseModel):
|
|
36
|
+
"""Who the platform resolved the credential to."""
|
|
37
|
+
|
|
38
|
+
kind: str # "api_key" | "user"
|
|
39
|
+
id: str
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class OrgInfo(BaseModel):
|
|
43
|
+
"""One organization visible to the credential."""
|
|
44
|
+
|
|
45
|
+
id: str
|
|
46
|
+
slug: str
|
|
47
|
+
name: str
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class WhoAmI(BaseModel):
|
|
51
|
+
"""Response of ``GET /api/whoami``."""
|
|
52
|
+
|
|
53
|
+
actor: ActorInfo
|
|
54
|
+
orgs: list[OrgInfo]
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class RemoteWorldModel(BaseModel):
|
|
58
|
+
"""The slice of a world-model row the CLI presents."""
|
|
59
|
+
|
|
60
|
+
id: str
|
|
61
|
+
name: str
|
|
62
|
+
display_name: str | None = None
|
|
63
|
+
status: str
|
|
64
|
+
updated_at: str | None = None
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class RemoteHarness(BaseModel):
|
|
68
|
+
"""The slice of a registry harness row the CLI presents."""
|
|
69
|
+
|
|
70
|
+
id: str
|
|
71
|
+
name: str
|
|
72
|
+
latest_version: int
|
|
73
|
+
updated_at: str | None = None
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class RemoteHarnessVersion(BaseModel):
|
|
77
|
+
"""One doc-less entry of a harness's version lineage."""
|
|
78
|
+
|
|
79
|
+
version: int
|
|
80
|
+
doc_hash: str
|
|
81
|
+
created_at: str | None = None
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class HarnessVersionDoc(BaseModel):
|
|
85
|
+
"""One full harness version, doc included."""
|
|
86
|
+
|
|
87
|
+
version: int
|
|
88
|
+
doc: dict[str, JsonValue]
|
|
89
|
+
doc_hash: str
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class PushedHarnessVersion(BaseModel):
|
|
93
|
+
"""Response of a harness push: the version the doc landed as."""
|
|
94
|
+
|
|
95
|
+
name: str
|
|
96
|
+
version: int
|
|
97
|
+
doc_hash: str
|
|
98
|
+
created: bool # False when the push was an idempotent repeat of the tip
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class RunTarget(BaseModel):
|
|
102
|
+
"""A platform id resolved to one executable resource kind."""
|
|
103
|
+
|
|
104
|
+
id: str
|
|
105
|
+
kind: Literal["world_model", "agent"]
|
|
106
|
+
org_id: str
|
|
107
|
+
name: str
|
|
108
|
+
display_name: str | None = None
|
|
109
|
+
status: str
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class RemoteWorldModelSession(BaseModel):
|
|
113
|
+
"""The slice of a hosted world-model session needed by ``wmo run``."""
|
|
114
|
+
|
|
115
|
+
id: str
|
|
116
|
+
world_model_id: str
|
|
117
|
+
status: str
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class RemoteAgentSession(BaseModel):
|
|
121
|
+
"""Hosted E2B agent session state needed by the CLI driver."""
|
|
122
|
+
|
|
123
|
+
id: str
|
|
124
|
+
agent_id: str
|
|
125
|
+
status: str
|
|
126
|
+
workspace_sync: bool
|
|
127
|
+
launched_from: str
|
|
128
|
+
starting_detail: str | None = None
|
|
129
|
+
ended_reason: str | None = None
|
|
130
|
+
error: str | None = None
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
class RemoteAgentSessionEvent(BaseModel):
|
|
134
|
+
"""One durable event from a hosted agent session transcript."""
|
|
135
|
+
|
|
136
|
+
seq: int
|
|
137
|
+
kind: Literal[
|
|
138
|
+
"user_message",
|
|
139
|
+
"assistant_message",
|
|
140
|
+
"tool_call",
|
|
141
|
+
"tool_output",
|
|
142
|
+
"tool_result",
|
|
143
|
+
"submit",
|
|
144
|
+
"state",
|
|
145
|
+
"status",
|
|
146
|
+
"error",
|
|
147
|
+
"workspace_patch",
|
|
148
|
+
]
|
|
149
|
+
payload: dict[str, JsonValue]
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
class RemoteAgentEventPage(BaseModel):
|
|
153
|
+
"""One poll page of hosted transcript events and current session status."""
|
|
154
|
+
|
|
155
|
+
events: list[RemoteAgentSessionEvent]
|
|
156
|
+
last_seq: int
|
|
157
|
+
status: str
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
class WorkspacePatchResult(BaseModel):
|
|
161
|
+
"""Paths accepted or rejected while applying a live workspace patch."""
|
|
162
|
+
|
|
163
|
+
applied: list[str]
|
|
164
|
+
conflicts: list[str]
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
class LocalPiRunInfo(BaseModel):
|
|
168
|
+
"""An org-scoped platform usage record for the built-in local pi harness."""
|
|
169
|
+
|
|
170
|
+
id: str
|
|
171
|
+
org_id: str
|
|
172
|
+
status: str
|
|
173
|
+
worker_provider: str
|
|
174
|
+
worker_model: str
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def fetch_cli_config(web_url: str, *, transport: httpx.BaseTransport | None = None) -> str | None:
|
|
178
|
+
"""Ask the web app which backend host the CLI should call.
|
|
179
|
+
|
|
180
|
+
``GET {web_url}/api/cli/config`` is public: the backend URL is not a
|
|
181
|
+
secret (every Endpoints page shows it) and everything behind it is
|
|
182
|
+
bearer-gated.
|
|
183
|
+
"""
|
|
184
|
+
with httpx.Client(timeout=30.0, transport=transport) as client:
|
|
185
|
+
response = client.get(f"{web_url.rstrip('/')}/api/cli/config")
|
|
186
|
+
if response.status_code != 200:
|
|
187
|
+
msg = f"platform discovery failed with HTTP {response.status_code} at {web_url}"
|
|
188
|
+
raise PlatformError(msg, status_code=response.status_code)
|
|
189
|
+
api_url = response.json().get("apiUrl")
|
|
190
|
+
return str(api_url).rstrip("/") if api_url else None
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
class PlatformClient:
|
|
194
|
+
"""Requests against the platform registry, authenticated with an org API key."""
|
|
195
|
+
|
|
196
|
+
def __init__(
|
|
197
|
+
self,
|
|
198
|
+
api_url: str,
|
|
199
|
+
token: str,
|
|
200
|
+
*,
|
|
201
|
+
transport: httpx.BaseTransport | None = None,
|
|
202
|
+
) -> None:
|
|
203
|
+
try:
|
|
204
|
+
version = metadata.version("world-model-optimizer")
|
|
205
|
+
except metadata.PackageNotFoundError:
|
|
206
|
+
version = "dev"
|
|
207
|
+
self._client = httpx.Client(
|
|
208
|
+
base_url=api_url.rstrip("/"),
|
|
209
|
+
headers={
|
|
210
|
+
"Authorization": f"Bearer {token}",
|
|
211
|
+
"User-Agent": f"wmo/{version}",
|
|
212
|
+
},
|
|
213
|
+
timeout=_TIMEOUT_SECONDS,
|
|
214
|
+
transport=transport,
|
|
215
|
+
)
|
|
216
|
+
# Bundle bytes move directly against storage's signed URLs; that
|
|
217
|
+
# client carries no platform credential.
|
|
218
|
+
self._transfer = httpx.Client(
|
|
219
|
+
headers={"User-Agent": f"wmo/{version}"},
|
|
220
|
+
timeout=_TIMEOUT_SECONDS,
|
|
221
|
+
transport=transport,
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
def __enter__(self) -> PlatformClient:
|
|
225
|
+
return self
|
|
226
|
+
|
|
227
|
+
def __exit__(self, *exc_info: object) -> None:
|
|
228
|
+
self.close()
|
|
229
|
+
|
|
230
|
+
def close(self) -> None:
|
|
231
|
+
self._client.close()
|
|
232
|
+
self._transfer.close()
|
|
233
|
+
|
|
234
|
+
# -- identity ------------------------------------------------------------------------------
|
|
235
|
+
|
|
236
|
+
def whoami(self) -> WhoAmI:
|
|
237
|
+
response = self._client.get("/api/whoami")
|
|
238
|
+
self._raise_for_error(response)
|
|
239
|
+
return WhoAmI.model_validate(response.json())
|
|
240
|
+
|
|
241
|
+
# -- unified runs --------------------------------------------------------------------------
|
|
242
|
+
|
|
243
|
+
def resolve_run_target(self, target_id: str) -> RunTarget:
|
|
244
|
+
"""Resolve an opaque platform id without guessing from failed requests."""
|
|
245
|
+
response = self._client.get(f"/api/run-targets/{target_id}")
|
|
246
|
+
self._raise_for_error(response)
|
|
247
|
+
return RunTarget.model_validate(response.json())
|
|
248
|
+
|
|
249
|
+
def create_world_model_session(
|
|
250
|
+
self, world_model_id: str, *, task: str | None = None
|
|
251
|
+
) -> RemoteWorldModelSession:
|
|
252
|
+
"""Open a hosted session for a platform world model."""
|
|
253
|
+
response = self._client.post(
|
|
254
|
+
f"/api/world-models/{world_model_id}/sessions", json={"task": task}
|
|
255
|
+
)
|
|
256
|
+
self._raise_for_error(response)
|
|
257
|
+
return RemoteWorldModelSession.model_validate(response.json())
|
|
258
|
+
|
|
259
|
+
def step_world_model_session(self, session_id: str, action: Action) -> Observation:
|
|
260
|
+
"""Advance a hosted world-model session by one action."""
|
|
261
|
+
response = self._client.post(
|
|
262
|
+
f"/api/sessions/{session_id}/step",
|
|
263
|
+
json={"action": action.model_dump(mode="json")},
|
|
264
|
+
)
|
|
265
|
+
self._raise_for_error(response)
|
|
266
|
+
return Observation.model_validate(response.json()["observation"])
|
|
267
|
+
|
|
268
|
+
def create_agent_session(
|
|
269
|
+
self,
|
|
270
|
+
agent_id: str,
|
|
271
|
+
*,
|
|
272
|
+
workspace: bytes | None,
|
|
273
|
+
instruction: str | None = None,
|
|
274
|
+
) -> RemoteAgentSession:
|
|
275
|
+
"""Create a hosted agent session, optionally staging a local snapshot."""
|
|
276
|
+
payload: dict[str, JsonValue] = {"instruction": instruction}
|
|
277
|
+
if workspace is not None:
|
|
278
|
+
upload = self._client.post(
|
|
279
|
+
f"/api/agents/{agent_id}/workspace-uploads",
|
|
280
|
+
files={"workspace": ("workspace.tar.gz", workspace, "application/gzip")},
|
|
281
|
+
timeout=_WORKSPACE_TIMEOUT_SECONDS,
|
|
282
|
+
)
|
|
283
|
+
self._raise_for_error(upload)
|
|
284
|
+
payload["workspace_upload_id"] = str(upload.json()["id"])
|
|
285
|
+
response = self._client.post(
|
|
286
|
+
f"/api/agents/{agent_id}/sessions",
|
|
287
|
+
json=payload,
|
|
288
|
+
)
|
|
289
|
+
self._raise_for_error(response)
|
|
290
|
+
return RemoteAgentSession.model_validate(response.json())
|
|
291
|
+
|
|
292
|
+
def get_agent_session(self, agent_id: str, session_id: str) -> RemoteAgentSession:
|
|
293
|
+
"""Read current hosted agent session state."""
|
|
294
|
+
response = self._client.get(f"/api/agents/{agent_id}/sessions/{session_id}")
|
|
295
|
+
self._raise_for_error(response)
|
|
296
|
+
return RemoteAgentSession.model_validate(response.json())
|
|
297
|
+
|
|
298
|
+
def resolve_agent_session(self, session_id: str) -> RemoteAgentSession:
|
|
299
|
+
"""Resolve a bare session id to its owning agent and current state."""
|
|
300
|
+
response = self._client.get(f"/api/agent-sessions/{session_id}")
|
|
301
|
+
self._raise_for_error(response)
|
|
302
|
+
return RemoteAgentSession.model_validate(response.json())
|
|
303
|
+
|
|
304
|
+
def end_agent_session(self, agent_id: str, session_id: str) -> RemoteAgentSession:
|
|
305
|
+
"""Request an end, reconciling directly when the hosted driver is gone."""
|
|
306
|
+
response = self._client.post(f"/api/agents/{agent_id}/sessions/{session_id}/end")
|
|
307
|
+
self._raise_for_error(response)
|
|
308
|
+
return RemoteAgentSession.model_validate(response.json())
|
|
309
|
+
|
|
310
|
+
def list_agent_session_events(
|
|
311
|
+
self, agent_id: str, session_id: str, *, after: int
|
|
312
|
+
) -> RemoteAgentEventPage:
|
|
313
|
+
"""Poll hosted transcript events after one durable sequence cursor."""
|
|
314
|
+
response = self._client.get(
|
|
315
|
+
f"/api/agents/{agent_id}/sessions/{session_id}/events",
|
|
316
|
+
params={"after": after},
|
|
317
|
+
)
|
|
318
|
+
self._raise_for_error(response)
|
|
319
|
+
return RemoteAgentEventPage.model_validate(response.json())
|
|
320
|
+
|
|
321
|
+
def post_agent_session_command(
|
|
322
|
+
self, agent_id: str, session_id: str, kind: str, *, text: str | None = None
|
|
323
|
+
) -> None:
|
|
324
|
+
"""Steer, interrupt, or end one hosted agent session."""
|
|
325
|
+
response = self._client.post(
|
|
326
|
+
f"/api/agents/{agent_id}/sessions/{session_id}/commands",
|
|
327
|
+
json={"kind": kind, "text": text},
|
|
328
|
+
)
|
|
329
|
+
self._raise_for_error(response)
|
|
330
|
+
|
|
331
|
+
def upload_agent_workspace_patch(
|
|
332
|
+
self, agent_id: str, session_id: str, content: bytes
|
|
333
|
+
) -> WorkspacePatchResult:
|
|
334
|
+
"""Apply local changes conditionally to a running hosted workspace."""
|
|
335
|
+
response = self._client.post(
|
|
336
|
+
f"/api/agents/{agent_id}/sessions/{session_id}/workspace/patches",
|
|
337
|
+
files={"patch": ("workspace-patch.tar.gz", content, "application/gzip")},
|
|
338
|
+
timeout=_WORKSPACE_TIMEOUT_SECONDS,
|
|
339
|
+
)
|
|
340
|
+
self._raise_for_error(response)
|
|
341
|
+
return WorkspacePatchResult.model_validate(response.json())
|
|
342
|
+
|
|
343
|
+
def download_agent_workspace_patch(
|
|
344
|
+
self, agent_id: str, session_id: str, revision: str
|
|
345
|
+
) -> bytes:
|
|
346
|
+
"""Download one remote-to-local live workspace patch."""
|
|
347
|
+
response = self._client.get(
|
|
348
|
+
f"/api/agents/{agent_id}/sessions/{session_id}/workspace/patches/{revision}",
|
|
349
|
+
timeout=_WORKSPACE_TIMEOUT_SECONDS,
|
|
350
|
+
)
|
|
351
|
+
self._raise_for_error(response)
|
|
352
|
+
return response.content
|
|
353
|
+
|
|
354
|
+
def acknowledge_agent_workspace_patch(
|
|
355
|
+
self, agent_id: str, session_id: str, revision: str
|
|
356
|
+
) -> None:
|
|
357
|
+
"""Remove a remote patch after it is safely reflected or reported locally."""
|
|
358
|
+
response = self._client.post(
|
|
359
|
+
f"/api/agents/{agent_id}/sessions/{session_id}/workspace/patches/{revision}/ack"
|
|
360
|
+
)
|
|
361
|
+
self._raise_for_error(response)
|
|
362
|
+
|
|
363
|
+
def download_agent_workspace(self, agent_id: str, session_id: str) -> bytes:
|
|
364
|
+
"""Download a terminal hosted session's final E2B workspace snapshot."""
|
|
365
|
+
response = self._client.get(
|
|
366
|
+
f"/api/agents/{agent_id}/sessions/{session_id}/workspace",
|
|
367
|
+
timeout=_WORKSPACE_TIMEOUT_SECONDS,
|
|
368
|
+
)
|
|
369
|
+
self._raise_for_error(response)
|
|
370
|
+
return response.content
|
|
371
|
+
|
|
372
|
+
def acknowledge_agent_workspace(self, agent_id: str, session_id: str) -> None:
|
|
373
|
+
"""Confirm the final archive is safe locally so platform objects can be removed."""
|
|
374
|
+
response = self._client.post(f"/api/agents/{agent_id}/sessions/{session_id}/workspace/ack")
|
|
375
|
+
self._raise_for_error(response)
|
|
376
|
+
|
|
377
|
+
# -- world models --------------------------------------------------------------------------
|
|
378
|
+
|
|
379
|
+
def list_world_models(self, org_id: str) -> list[RemoteWorldModel]:
|
|
380
|
+
response = self._client.get(f"/api/orgs/{org_id}/world-models")
|
|
381
|
+
self._raise_for_error(response)
|
|
382
|
+
rows = response.json().get("world_models", [])
|
|
383
|
+
return [RemoteWorldModel.model_validate(row) for row in rows]
|
|
384
|
+
|
|
385
|
+
def push_model_bundle(
|
|
386
|
+
self,
|
|
387
|
+
org_id: str,
|
|
388
|
+
name: str,
|
|
389
|
+
bundle_path: Path,
|
|
390
|
+
sha256: str,
|
|
391
|
+
byte_size: int,
|
|
392
|
+
meta: dict[str, JsonValue],
|
|
393
|
+
) -> RemoteWorldModel:
|
|
394
|
+
"""Push a packed bundle file: ticket, direct PUT to storage, finalize.
|
|
395
|
+
|
|
396
|
+
The bundle bytes stream from disk straight to the signed staging URL;
|
|
397
|
+
only the finalize declaration (digest + size + serve metadata) goes
|
|
398
|
+
through the API.
|
|
399
|
+
"""
|
|
400
|
+
ticket_response = self._client.post(
|
|
401
|
+
f"/api/orgs/{org_id}/world-models/{name}/bundle/uploads"
|
|
402
|
+
)
|
|
403
|
+
self._raise_for_error(ticket_response)
|
|
404
|
+
ticket = ticket_response.json()
|
|
405
|
+
upload_url = str(ticket["upload_url"])
|
|
406
|
+
with bundle_path.open("rb") as fh:
|
|
407
|
+
upload_response = self._transfer.put(
|
|
408
|
+
upload_url,
|
|
409
|
+
content=fh,
|
|
410
|
+
headers={
|
|
411
|
+
"Content-Type": "application/gzip",
|
|
412
|
+
"x-upsert": "false",
|
|
413
|
+
"Authorization": f"Bearer {ticket.get('token', '')}",
|
|
414
|
+
},
|
|
415
|
+
)
|
|
416
|
+
if not upload_response.is_success:
|
|
417
|
+
msg = (
|
|
418
|
+
f"bundle upload to storage failed with HTTP {upload_response.status_code}: "
|
|
419
|
+
f"{upload_response.text[:200]}"
|
|
420
|
+
)
|
|
421
|
+
raise PlatformError(msg, status_code=upload_response.status_code)
|
|
422
|
+
|
|
423
|
+
finalize = self._client.post(
|
|
424
|
+
f"/api/orgs/{org_id}/world-models/{name}/bundle",
|
|
425
|
+
json={
|
|
426
|
+
"staging_path": ticket["staging_path"],
|
|
427
|
+
"sha256": sha256,
|
|
428
|
+
"byte_size": byte_size,
|
|
429
|
+
"meta": meta,
|
|
430
|
+
},
|
|
431
|
+
)
|
|
432
|
+
self._raise_for_error(finalize)
|
|
433
|
+
return RemoteWorldModel.model_validate(finalize.json())
|
|
434
|
+
|
|
435
|
+
def download_model_bundle(self, org_id: str, name: str, dest: Path) -> str:
|
|
436
|
+
"""Stream a model's bundle from storage to ``dest``, verifying its digest.
|
|
437
|
+
|
|
438
|
+
The API hands back an expiring signed URL plus the recorded sha256;
|
|
439
|
+
the bytes come straight from storage's CDN and are hashed as they
|
|
440
|
+
stream to disk.
|
|
441
|
+
|
|
442
|
+
Returns:
|
|
443
|
+
The verified sha256 hex digest.
|
|
444
|
+
"""
|
|
445
|
+
response = self._client.get(f"/api/orgs/{org_id}/world-models/{name}/bundle")
|
|
446
|
+
self._raise_for_error(response)
|
|
447
|
+
payload = response.json()
|
|
448
|
+
declared = str(payload["sha256"])
|
|
449
|
+
|
|
450
|
+
digest = hashlib.sha256()
|
|
451
|
+
part_path = dest.with_name(f"{dest.name}.part")
|
|
452
|
+
with self._transfer.stream("GET", str(payload["url"])) as stream:
|
|
453
|
+
if not stream.is_success:
|
|
454
|
+
msg = f"bundle download failed with HTTP {stream.status_code}"
|
|
455
|
+
raise PlatformError(msg, status_code=stream.status_code)
|
|
456
|
+
with part_path.open("wb") as fh:
|
|
457
|
+
for chunk in stream.iter_bytes():
|
|
458
|
+
digest.update(chunk)
|
|
459
|
+
fh.write(chunk)
|
|
460
|
+
actual = digest.hexdigest()
|
|
461
|
+
if actual != declared:
|
|
462
|
+
part_path.unlink(missing_ok=True)
|
|
463
|
+
msg = f"bundle digest mismatch for {name}: expected {declared}, got {actual}"
|
|
464
|
+
raise PlatformError(msg)
|
|
465
|
+
part_path.replace(dest)
|
|
466
|
+
return actual
|
|
467
|
+
|
|
468
|
+
# -- harnesses -----------------------------------------------------------------------------
|
|
469
|
+
|
|
470
|
+
def list_harnesses(self, org_id: str) -> list[RemoteHarness]:
|
|
471
|
+
response = self._client.get(f"/api/orgs/{org_id}/harnesses")
|
|
472
|
+
self._raise_for_error(response)
|
|
473
|
+
rows = response.json().get("harnesses", [])
|
|
474
|
+
return [RemoteHarness.model_validate(row) for row in rows]
|
|
475
|
+
|
|
476
|
+
def get_harness(
|
|
477
|
+
self, org_id: str, name: str
|
|
478
|
+
) -> tuple[RemoteHarness, list[RemoteHarnessVersion]]:
|
|
479
|
+
response = self._client.get(f"/api/orgs/{org_id}/harnesses/{name}")
|
|
480
|
+
self._raise_for_error(response)
|
|
481
|
+
payload = response.json()
|
|
482
|
+
harness = RemoteHarness.model_validate(payload["harness"])
|
|
483
|
+
versions = [RemoteHarnessVersion.model_validate(row) for row in payload["versions"]]
|
|
484
|
+
return harness, versions
|
|
485
|
+
|
|
486
|
+
def get_harness_version(self, org_id: str, name: str, version: int) -> HarnessVersionDoc:
|
|
487
|
+
response = self._client.get(f"/api/orgs/{org_id}/harnesses/{name}/versions/{version}")
|
|
488
|
+
self._raise_for_error(response)
|
|
489
|
+
return HarnessVersionDoc.model_validate(response.json())
|
|
490
|
+
|
|
491
|
+
def push_harness_version(
|
|
492
|
+
self,
|
|
493
|
+
org_id: str,
|
|
494
|
+
name: str,
|
|
495
|
+
doc: dict[str, JsonValue],
|
|
496
|
+
doc_hash: str,
|
|
497
|
+
) -> PushedHarnessVersion:
|
|
498
|
+
response = self._client.post(
|
|
499
|
+
f"/api/orgs/{org_id}/harnesses/{name}/versions",
|
|
500
|
+
json={"doc": doc, "doc_hash": doc_hash},
|
|
501
|
+
)
|
|
502
|
+
self._raise_for_error(response)
|
|
503
|
+
return PushedHarnessVersion.model_validate(response.json())
|
|
504
|
+
|
|
505
|
+
# -- built-in local pi runs ---------------------------------------------------------------
|
|
506
|
+
|
|
507
|
+
def create_local_pi_run(self, org_id: str) -> LocalPiRunInfo:
|
|
508
|
+
"""Open a metered platform run for WMO's built-in local pi harness."""
|
|
509
|
+
response = self._client.post(f"/api/orgs/{org_id}/local-pi-runs")
|
|
510
|
+
self._raise_for_error(response)
|
|
511
|
+
return LocalPiRunInfo.model_validate(response.json())
|
|
512
|
+
|
|
513
|
+
def complete_local_pi_worker(
|
|
514
|
+
self, org_id: str, run_id: str, request: ChatRequest
|
|
515
|
+
) -> ChatResponse:
|
|
516
|
+
"""Answer one built-in pi worker turn through the platform."""
|
|
517
|
+
response = self._client.post(
|
|
518
|
+
f"/api/orgs/{org_id}/local-pi-runs/{run_id}/worker-completion",
|
|
519
|
+
json=request.model_dump(mode="json", exclude_none=True),
|
|
520
|
+
)
|
|
521
|
+
self._raise_for_error(response)
|
|
522
|
+
return ChatResponse.model_validate(response.json())
|
|
523
|
+
|
|
524
|
+
def finish_local_pi_run(
|
|
525
|
+
self,
|
|
526
|
+
org_id: str,
|
|
527
|
+
run_id: str,
|
|
528
|
+
*,
|
|
529
|
+
status: str,
|
|
530
|
+
ended_reason: str,
|
|
531
|
+
error: str | None = None,
|
|
532
|
+
) -> None:
|
|
533
|
+
"""Report the terminal transition of a built-in local pi run."""
|
|
534
|
+
response = self._client.post(
|
|
535
|
+
f"/api/orgs/{org_id}/local-pi-runs/{run_id}/finish",
|
|
536
|
+
json={"status": status, "ended_reason": ended_reason, "error": error},
|
|
537
|
+
)
|
|
538
|
+
self._raise_for_error(response)
|
|
539
|
+
|
|
540
|
+
# -- internals -----------------------------------------------------------------------------
|
|
541
|
+
|
|
542
|
+
def _raise_for_error(self, response: httpx.Response) -> None:
|
|
543
|
+
if response.is_success:
|
|
544
|
+
return
|
|
545
|
+
try:
|
|
546
|
+
message = response.json().get("error", response.text)
|
|
547
|
+
except (json.JSONDecodeError, ValueError):
|
|
548
|
+
message = response.text or f"HTTP {response.status_code}"
|
|
549
|
+
if response.status_code == 401:
|
|
550
|
+
message = f"{message} — run `wmo login` (or check WMO_PLATFORM_TOKEN)"
|
|
551
|
+
raise PlatformError(str(message), status_code=response.status_code)
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""Platform login credentials, stored once per user.
|
|
2
|
+
|
|
3
|
+
Unlike everything else in wmo (project-local under `./.wmo/`), the platform credential is
|
|
4
|
+
user-global: `~/.wmo/credentials.toml`, directory overridable via `$WMO_HOME`. Environment
|
|
5
|
+
variables override the file so CI and headless runs never need one written to disk.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import tempfile
|
|
12
|
+
import tomllib
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
import tomli_w
|
|
16
|
+
from pydantic import BaseModel
|
|
17
|
+
|
|
18
|
+
ENV_HOME = "WMO_HOME"
|
|
19
|
+
ENV_WEB_URL = "WMO_PLATFORM_URL"
|
|
20
|
+
ENV_API_URL = "WMO_PLATFORM_API_URL"
|
|
21
|
+
ENV_TOKEN = "WMO_PLATFORM_TOKEN"
|
|
22
|
+
ENV_ORG = "WMO_PLATFORM_ORG"
|
|
23
|
+
|
|
24
|
+
CREDENTIALS_FILENAME = "credentials.toml"
|
|
25
|
+
|
|
26
|
+
# The hosted platform a bare `wmo login` connects to; `--url` (previews,
|
|
27
|
+
# self-hosted, the local stack) and saved credentials both take precedence.
|
|
28
|
+
DEFAULT_WEB_URL = "https://platform.experientiallabs.ai"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class PlatformCredentials(BaseModel):
|
|
32
|
+
"""The saved connection: where the platform lives and which key acts for us."""
|
|
33
|
+
|
|
34
|
+
web_url: str | None = None # the browser-facing app (login page, keys page)
|
|
35
|
+
api_url: str | None = None # the backend host requests go to
|
|
36
|
+
token: str | None = None # org API key (xpl_…)
|
|
37
|
+
default_org: str | None = None # organization id used when --org is omitted
|
|
38
|
+
|
|
39
|
+
def is_complete(self) -> bool:
|
|
40
|
+
"""Whether requests can be made without further configuration."""
|
|
41
|
+
return bool(self.api_url and self.token)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def wmo_home() -> Path:
|
|
45
|
+
"""The user-global wmo directory (`$WMO_HOME` or `~/.wmo`)."""
|
|
46
|
+
override = os.environ.get(ENV_HOME)
|
|
47
|
+
return Path(override) if override else Path.home() / ".wmo"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def credentials_path() -> Path:
|
|
51
|
+
"""Where the credential file lives."""
|
|
52
|
+
return wmo_home() / CREDENTIALS_FILENAME
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def load_credentials() -> PlatformCredentials:
|
|
56
|
+
"""Read the credential file, then apply environment overrides.
|
|
57
|
+
|
|
58
|
+
Env alone is sufficient (no file needed); a set-but-empty env var is
|
|
59
|
+
treated as unset rather than clearing a file value.
|
|
60
|
+
"""
|
|
61
|
+
data: dict[str, str] = {}
|
|
62
|
+
path = credentials_path()
|
|
63
|
+
if path.exists():
|
|
64
|
+
section = tomllib.loads(path.read_text(encoding="utf-8")).get("platform", {})
|
|
65
|
+
data = {key: value for key, value in section.items() if isinstance(value, str)}
|
|
66
|
+
# Files written before the platform's org-only change carry the old
|
|
67
|
+
# default_project key. A project id is NOT an org id, so carrying the
|
|
68
|
+
# value over would send a guaranteed-miss id to /api/orgs/{org_id}/...;
|
|
69
|
+
# discard it instead, so the user gets the clear "pass --org" prompt
|
|
70
|
+
# (or the sole-org auto-pick at the next login) rather than a
|
|
71
|
+
# confusing org-not-found. The stale key drops on the next save.
|
|
72
|
+
data.pop("default_project", None)
|
|
73
|
+
credentials = PlatformCredentials.model_validate(data)
|
|
74
|
+
overrides = {
|
|
75
|
+
"web_url": os.environ.get(ENV_WEB_URL),
|
|
76
|
+
"api_url": os.environ.get(ENV_API_URL),
|
|
77
|
+
"token": os.environ.get(ENV_TOKEN),
|
|
78
|
+
"default_org": os.environ.get(ENV_ORG),
|
|
79
|
+
}
|
|
80
|
+
updates = {key: value for key, value in overrides.items() if value}
|
|
81
|
+
return credentials.model_copy(update=updates) if updates else credentials
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def save_credentials(credentials: PlatformCredentials) -> Path:
|
|
85
|
+
"""Persist the credential file with owner-only permissions.
|
|
86
|
+
|
|
87
|
+
Mirrors the dotenv writer: refuses symlinks (a credential rewrite must never
|
|
88
|
+
land in whatever a link points at), writes through a 0600 mkstemp, and swaps
|
|
89
|
+
into place atomically.
|
|
90
|
+
|
|
91
|
+
Raises:
|
|
92
|
+
ValueError: If the target path is a symlink.
|
|
93
|
+
"""
|
|
94
|
+
path = credentials_path()
|
|
95
|
+
if path.is_symlink():
|
|
96
|
+
msg = (
|
|
97
|
+
f"refusing to write credentials through the symlink {path}; "
|
|
98
|
+
"remove the link or point $WMO_HOME elsewhere"
|
|
99
|
+
)
|
|
100
|
+
raise ValueError(msg)
|
|
101
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
102
|
+
payload = {
|
|
103
|
+
"platform": {
|
|
104
|
+
key: value
|
|
105
|
+
for key, value in credentials.model_dump(mode="json").items()
|
|
106
|
+
if value is not None
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
fd, tmp_name = tempfile.mkstemp(dir=path.parent, prefix=f"{path.name}.")
|
|
110
|
+
try:
|
|
111
|
+
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
|
112
|
+
fh.write(tomli_w.dumps(payload))
|
|
113
|
+
os.replace(tmp_name, path)
|
|
114
|
+
except BaseException:
|
|
115
|
+
os.unlink(tmp_name)
|
|
116
|
+
raise
|
|
117
|
+
return path
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def clear_credentials() -> bool:
|
|
121
|
+
"""Delete the credential file; returns whether one existed."""
|
|
122
|
+
path = credentials_path()
|
|
123
|
+
if not path.exists():
|
|
124
|
+
return False
|
|
125
|
+
path.unlink()
|
|
126
|
+
return True
|