Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# Changelog

## 0.4.0

### Added

- `halvorai-ingest init` (also `python -m halvorai_ingest init`) and `run_init()`: generate a source key, register it with `POST /v1/sources`, write `.env.local` and send a test event, as `npx @halvorai/ingest init` does. Python users no longer need Node.js to connect to HalvorAI.

## 0.2.0 (unreleased)

### Breaking
Expand Down
10 changes: 9 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,15 @@ halvor.flush(timeout=5)

## Send to HalvorAI (early access)

With a sandbox key (`hk_test_…`) and a source key registered in the dashboard:
With a sandbox key (`hk_test_…`), register this machine as a source. `init` creates an Ed25519 key here, registers its public half, writes `HALVOR_API_KEY` and `HALVOR_SOURCE_KEY` to `.env.local`, sends one test event and prints a dashboard link:

```bash
halvorai-ingest init # or: python -m halvorai_ingest init
```

It asks for the key unless `--api-key` or `HALVOR_API_KEY` is set. Other options: `--endpoint`, `--name`, `--cwd`, `--dashboard`. The private key never leaves your machine; keep it in your secret manager.

Then load `.env.local` (or set the same variables) and create the client:

```python
import os
Expand Down
3 changes: 3 additions & 0 deletions halvorai_ingest/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from .client import ClientStats, HalvorClient
from .context import agent_context, current_context
from .init import InitResult, run_init
from .hash import evidence_sha256, hash_instruction
from .redact import redact_deep, redact_text
from .sign import generate_source_key, sign_event, verify_event
Expand All @@ -21,4 +22,6 @@
"generate_source_key",
"sign_event",
"verify_event",
"run_init",
"InitResult",
]
5 changes: 5 additions & 0 deletions halvorai_ingest/__main__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import sys

from .cli import main

sys.exit(main())
43 changes: 43 additions & 0 deletions halvorai_ingest/cli.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
"""Command line: `halvorai-ingest init` (or `python -m halvorai_ingest init`)."""

from __future__ import annotations

import argparse
import getpass
import os
import sys

from .init import run_init


def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(prog="halvorai-ingest")
commands = parser.add_subparsers(dest="command", required=True)
init = commands.add_parser(
"init",
help="register this machine as a source, write .env.local and send a test event",
)
init.add_argument("--api-key", help="hk_test_… or hk_live_… (default: HALVOR_API_KEY, else asks)")
init.add_argument("--endpoint", help="API base URL (default: HALVOR_ENDPOINT, else from the key)")
init.add_argument("--dashboard", help="dashboard origin for the printed link")
init.add_argument("--name", help="source name (default: this machine's hostname)")
init.add_argument("--cwd", default=".", help="folder for .env.local (default: current folder)")
args = parser.parse_args(argv)

api_key = args.api_key or os.environ.get("HALVOR_API_KEY") or getpass.getpass("API key: ").strip()
try:
run_init(
api_key,
cwd=args.cwd,
endpoint=args.endpoint or os.environ.get("HALVOR_ENDPOINT") or None,
name=args.name,
dashboard=args.dashboard,
)
except Exception as exc:
print(f"halvorai-ingest init failed: {exc}", file=sys.stderr)
return 1
return 0


if __name__ == "__main__":
sys.exit(main())
169 changes: 169 additions & 0 deletions halvorai_ingest/init.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
"""`halvorai-ingest init`: register this machine as a source, as `npx @halvorai/ingest init` does."""

from __future__ import annotations

import json
import re
import socket
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Callable
from urllib.parse import quote, urlsplit

import httpx

from .client import API_KEY_PATTERN, HalvorClient, default_endpoint
from .sign import generate_source_key

DASHBOARD_URL = "https://app.halvorai.com"
SANDBOX_DASHBOARD_URL = "https://app.sandbox.halvorai.com"


@dataclass
class InitResult:
source_id: str
event_id: str
env_path: Path
dashboard_url: str
endpoint: str
environment: str


def dashboard_for(endpoint: str) -> str:
"""The dashboard that belongs to an API endpoint, as in the Node.js SDK."""
url = urlsplit(endpoint)
host = url.hostname or ""
if host == "api.halvorai.com" or not url.scheme:
return DASHBOARD_URL
if host == "api.sandbox.halvorai.com":
return SANDBOX_DASHBOARD_URL
# The local dashboard is signed in to at localhost:3000, whatever loopback name the API has.
if url.port == 8787:
loopback = host in ("127.0.0.1", "localhost", "::1")
return f"{url.scheme}://{'localhost' if loopback else host}:3000"
if host.startswith("api."):
return f"{url.scheme}://app.{host[4:]}"
return f"{url.scheme}://{url.netloc}"


def upsert_env_file(path: Path, values: dict[str, str]) -> None:
"""Set KEY=value lines in a dotenv file, keeping every other line."""
path.parent.mkdir(parents=True, exist_ok=True)
content = path.read_text(encoding="utf-8") if path.exists() else ""
if content and not content.endswith("\n"):
content += "\n"
for key, value in values.items():
quoted = (
"'" + value.replace("'", "'\\''") + "'"
if re.search(r"[\s\"'`#]", value)
else value
)
line = f"{key}={quoted}"
pattern = re.compile(rf"^{re.escape(key)}=.*$", re.MULTILINE)
if pattern.search(content):
content = pattern.sub(lambda _: line, content, count=1)
else:
content += line + "\n"
path.write_text(content, encoding="utf-8")


def _register_source(
http: httpx.Client, endpoint: str, api_key: str, name: str, public_jwk: dict[str, str]
) -> str:
res = http.post(
f"{endpoint}/v1/sources",
json={"name": name, "adapter": "custom", "public_jwk": public_jwk},
headers={"Authorization": f"Bearer {api_key}", "Accept": "application/json"},
)
if res.status_code != 201:
raise RuntimeError(f"POST /v1/sources failed ({res.status_code}): {res.text or res.reason_phrase}")
source_id = res.json().get("id")
if not isinstance(source_id, str) or not source_id.startswith("src_"):
raise RuntimeError("POST /v1/sources returned no source id")
return source_id


def run_init(
api_key: str,
*,
cwd: str | Path = ".",
endpoint: str | None = None,
name: str | None = None,
dashboard: str | None = None,
transport: httpx.BaseTransport | None = None,
log: Callable[[str], None] = print,
) -> InitResult:
"""
Generate an Ed25519 source key, register its public half (`POST /v1/sources`), write
`HALVOR_API_KEY` and `HALVOR_SOURCE_KEY` to `.env.local`, and send one test event.
The private key never leaves this machine.
"""
if not API_KEY_PATTERN.match(api_key):
raise ValueError("api key must look like hk_test_… or hk_live_…")
environment = "live" if api_key.startswith("hk_live_") else "test"
base = (endpoint or default_endpoint(api_key, environment)).rstrip("/")
source_name = ((name or "").strip() or socket.gethostname() or "local")[:100]

log("Generating Ed25519 source key…")
private_jwk: dict[str, Any] = dict(generate_source_key())
public_jwk = {k: v for k, v in private_jwk.items() if k not in ("d", "kid")}

log(f"Registering source at {base}…")
with httpx.Client(timeout=30.0, transport=transport) as http:
source_id = _register_source(http, base, api_key, source_name, public_jwk)
private_jwk["kid"] = source_id

env_path = Path(cwd).resolve() / ".env.local"
values = {"HALVOR_API_KEY": api_key, "HALVOR_SOURCE_KEY": json.dumps(private_jwk, separators=(",", ":"))}
# Saved only when chosen, so a later HalvorClient() talks to the same API.
if endpoint is not None:
values["HALVOR_ENDPOINT"] = base
upsert_env_file(env_path, values)
log(f"Wrote {env_path}")

log("Sending a test event…")
errors: list[str] = []
client = HalvorClient(
api_key=api_key,
source_key=private_jwk,
endpoint=base,
transport=transport,
exit_hook=False,
on_error=lambda e: errors.append(f"{e.get('code')}: {e.get('message')}"),
)
event_id = client.emit(
{
"type": "payment.settled",
"agent": {"id": "agt_halvor_init", "name": "HalvorAI init"},
"owner": {"id": "own_halvor_init", "type": "business", "display_name": "HalvorAI init"},
"payment": {
"payment_id": f"init:{source_id}",
"rail": "x402",
"amount": {
"value": "1",
"unit": "atomic",
"asset": {
"network": "eip155:84532",
"address": "0x036CbD53842c5426634e7929541eC2318f3dCF7e",
"symbol": "USDC",
"decimals": 6,
},
},
"status": "settled",
"description": "HalvorAI init test event",
"rail_ref": {"scheme": "exact", "network": "eip155:84532"},
},
"payee": {"type": "api_provider", "name": "HalvorAI init"},
}
)
flushed = client.flush(timeout=30)
client.close(timeout=1)
if not flushed["drained"] or errors:
raise RuntimeError(f"test event was not accepted: {'; '.join(errors) or 'flush timed out'}")

dashboard_url = f"{(dashboard or dashboard_for(base)).rstrip('/')}/payments?event={quote(event_id)}"
log("")
log(f"Source {source_id} is ready.")
log(f"See it in the dashboard: {dashboard_url}")
log(f"First event id: {event_id}")
return InitResult(source_id, event_id, env_path, dashboard_url, base, environment)
5 changes: 4 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "halvorai-ingest"
version = "0.3.0"
version = "0.4.0"
description = "Signed, attributed payment events for AI agents. Never in the payment path."
readme = "README.md"
requires-python = ">=3.10"
Expand Down Expand Up @@ -30,6 +30,9 @@ dependencies = [
"rfc8785>=0.1.4",
]

[project.scripts]
halvorai-ingest = "halvorai_ingest.cli:main"

[project.urls]
Homepage = "https://halvorai.com"
Documentation = "https://docs.halvorai.com"
Expand Down
Loading
Loading