mirror of
https://github.com/hanzo-dart/hanzoai.git
synced 2026-09-11 18:41:33 +00:00
hanzoai: Dart client for the Hanzo Cloud /v1/ platform API
First-class services for agents, agent sessions, and machines with Hanzo IAM-native Bearer auth; raw send() escape hatch for the full /v1/cloud/* surface. Mirrors the @hanzo/cloud JavaScript client. dart format + analyze --fatal-infos clean, 22 tests green, pub publish dry-run: 0 warnings.
This commit is contained in:
@@ -0,0 +1,20 @@
|
||||
name: ci
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dart-lang/setup-dart@v1
|
||||
with:
|
||||
sdk: stable
|
||||
- run: dart pub get
|
||||
- run: dart format --output=none --set-exit-if-changed .
|
||||
- run: dart analyze --fatal-infos
|
||||
- run: dart test
|
||||
@@ -0,0 +1,19 @@
|
||||
name: publish
|
||||
|
||||
# Publishes to pub.dev via OIDC when a version tag is pushed.
|
||||
# Requires the package to have this repo configured as an automated
|
||||
# publisher on pub.dev. Dormant until the first tag.
|
||||
on:
|
||||
push:
|
||||
tags: ['v[0-9]+.[0-9]+.[0-9]+']
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
permissions:
|
||||
id-token: write
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dart-lang/setup-dart@v1
|
||||
- run: dart pub get
|
||||
- run: dart pub publish --force
|
||||
@@ -0,0 +1,6 @@
|
||||
# Dart / pub
|
||||
.dart_tool/
|
||||
.packages
|
||||
build/
|
||||
doc/api/
|
||||
pubspec.lock
|
||||
@@ -0,0 +1,8 @@
|
||||
# Changelog
|
||||
|
||||
## 0.1.0
|
||||
|
||||
- Initial release. Typed Dart client for the Hanzo Cloud `/v1/` platform API.
|
||||
- First-class services: `agents`, `sessions`, `machines`.
|
||||
- Hanzo IAM-native Bearer auth (`MemoryAuthStore` / `AsyncAuthStore`).
|
||||
- Raw `HanzoCloud.send()` escape hatch for the full `/v1/cloud/*` surface.
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Hanzo AI, Inc.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,110 @@
|
||||
# hanzoai
|
||||
|
||||
[](https://pub.dev/packages/hanzoai)
|
||||
|
||||
The Dart client for the [Hanzo Cloud](https://hanzo.ai) `/v1/` platform API at
|
||||
`api.hanzo.ai`. Auth is [Hanzo IAM](https://hanzo.id)-native: the client holds
|
||||
the IAM-issued Bearer JWT (or an API key) and sends it on every request.
|
||||
|
||||
First-class services for **agents**, **agent sessions**, and **machines**, plus
|
||||
a raw `send()` escape hatch that reaches the full `/v1/cloud/*` surface. Mirrors
|
||||
the [`@hanzo/cloud`](https://github.com/hanzo-js/cloud) JavaScript client.
|
||||
|
||||
## Where it sits
|
||||
|
||||
Orthogonal, IAM-native clients — `hanzoai` is the platform control plane (create
|
||||
an agent, run it, watch its sessions, provision its machine). It does not do
|
||||
inference itself; pair it with the Hanzo AI inference surface for completions.
|
||||
|
||||
## Install
|
||||
|
||||
```yaml
|
||||
dependencies:
|
||||
hanzoai: ^0.1.0
|
||||
```
|
||||
|
||||
```dart
|
||||
import 'package:hanzoai/hanzoai.dart';
|
||||
|
||||
final cloud = HanzoCloud(token: 'IAM_JWT'); // origin defaults to api.hanzo.ai
|
||||
```
|
||||
|
||||
## Agents
|
||||
|
||||
```dart
|
||||
final agent = await cloud.agents.create(
|
||||
name: 'researcher',
|
||||
model: 'zen-coder',
|
||||
instructions: 'You research and summarize.',
|
||||
tools: ['search', 'fetch'],
|
||||
);
|
||||
|
||||
final run = await cloud.agents.run('researcher', input: 'Summarize RFC 8628.');
|
||||
print('${run.status}: ${run.output}');
|
||||
|
||||
final agents = await cloud.agents.list();
|
||||
final detail = await cloud.agents.get('researcher');
|
||||
final history = await cloud.agents.runs('researcher', limit: 20);
|
||||
```
|
||||
|
||||
A long-running agent carries a 5-field cron and is invoked by the scheduler:
|
||||
|
||||
```dart
|
||||
await cloud.agents.create(
|
||||
name: 'nightly-digest',
|
||||
model: 'zen-coder',
|
||||
executionMode: 'long-running',
|
||||
schedule: '0 9 * * *',
|
||||
);
|
||||
```
|
||||
|
||||
## Agent sessions
|
||||
|
||||
```dart
|
||||
final session = await cloud.sessions.register(agent: 'researcher');
|
||||
await cloud.sessions.appendEvent(session.id, kind: 'log', payload: {'msg': 'started'});
|
||||
await cloud.sessions.pause(session.id);
|
||||
await cloud.sessions.resume(session.id);
|
||||
await cloud.sessions.message(session.id, message: 'focus on section 3');
|
||||
|
||||
final tree = await cloud.sessions.tree(session.id);
|
||||
```
|
||||
|
||||
## Machines
|
||||
|
||||
```dart
|
||||
final machines = await cloud.machines.list(pageSize: 50);
|
||||
final one = await cloud.machines.get('org/name');
|
||||
await cloud.machines.add(Machine(name: 'gpu-1', region: 'sfo3'));
|
||||
```
|
||||
|
||||
## The full surface
|
||||
|
||||
Every other `/v1/cloud/*` resource (nodes, pods, containers, providers,
|
||||
connections, stores, vectors, workflows, …) is reachable through `send()`:
|
||||
|
||||
```dart
|
||||
final providers = await cloud.send('/v1/cloud/get-providers');
|
||||
```
|
||||
|
||||
## Auth
|
||||
|
||||
```dart
|
||||
cloud.setToken(jwt); // seed or replace the Bearer token
|
||||
cloud.authStore.isValid; // token present and, if a JWT, unexpired
|
||||
cloud.signOut(); // clear it
|
||||
```
|
||||
|
||||
Errors are thrown as `HanzoException` with `statusCode` and `message`.
|
||||
|
||||
## Develop
|
||||
|
||||
```sh
|
||||
dart pub get
|
||||
dart analyze --fatal-infos
|
||||
dart test
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
MIT © Hanzo AI, Inc. See [LICENSE](LICENSE).
|
||||
@@ -0,0 +1,7 @@
|
||||
include: package:lints/recommended.yaml
|
||||
|
||||
analyzer:
|
||||
language:
|
||||
strict-casts: true
|
||||
strict-raw-types: true
|
||||
strict-inference: true
|
||||
@@ -0,0 +1,25 @@
|
||||
import 'package:hanzoai/hanzoai.dart';
|
||||
|
||||
/// Minimal end-to-end shape: create an agent, run it, watch its sessions.
|
||||
///
|
||||
/// Run against a real deployment with a Hanzo IAM token:
|
||||
/// HANZO_TOKEN=... dart run example/hanzoai_example.dart
|
||||
Future<void> main() async {
|
||||
final cloud = HanzoCloud(token: const String.fromEnvironment('HANZO_TOKEN'));
|
||||
|
||||
final agent = await cloud.agents.create(
|
||||
name: 'researcher',
|
||||
model: 'zen-coder',
|
||||
instructions: 'You research and summarize.',
|
||||
tools: ['search', 'fetch'],
|
||||
);
|
||||
print('created ${agent.name} (${agent.id})');
|
||||
|
||||
final run = await cloud.agents.run(agent.name, input: 'Summarize RFC 8628.');
|
||||
print('run ${run.status}: ${run.output}');
|
||||
|
||||
final sessions = await cloud.sessions.list(status: 'running');
|
||||
print('${sessions.length} running session(s)');
|
||||
|
||||
cloud.close();
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
/// Dart client for the [Hanzo Cloud](https://hanzo.ai) `/v1/` platform API.
|
||||
///
|
||||
/// First-class services for agents, agent sessions, and machines, with
|
||||
/// [Hanzo IAM](https://hanzo.id)-native Bearer auth. Mirrors the `@hanzo/cloud`
|
||||
/// JavaScript client.
|
||||
library;
|
||||
|
||||
export 'src/agents.dart' show AgentsService;
|
||||
export 'src/auth_store.dart'
|
||||
show AuthStore, MemoryAuthStore, AsyncAuthStore, AuthChangeCallback;
|
||||
export 'src/client.dart' show HanzoCloud, defaultCloudUrl;
|
||||
export 'src/exception.dart' show HanzoException;
|
||||
export 'src/jwt.dart' show getTokenPayload, isTokenValid;
|
||||
export 'src/machines.dart' show MachinesService;
|
||||
export 'src/models.dart'
|
||||
show
|
||||
Agent,
|
||||
AgentDetail,
|
||||
Run,
|
||||
Activity,
|
||||
Session,
|
||||
SessionDetail,
|
||||
SessionEvent,
|
||||
TreeNode,
|
||||
ControlResult,
|
||||
Machine,
|
||||
CloudResponse;
|
||||
export 'src/sessions.dart' show SessionsService;
|
||||
@@ -0,0 +1,136 @@
|
||||
import 'client.dart';
|
||||
import 'models.dart';
|
||||
|
||||
/// The `/v1/agents` surface.
|
||||
///
|
||||
/// Agents are per-org autonomous units (a model + instructions + tool names).
|
||||
/// A one-shot agent runs when passed to [run]; a long-running agent additionally
|
||||
/// carries a 5-field cron `schedule` and is invoked by the scheduler.
|
||||
class AgentsService {
|
||||
AgentsService(this._client);
|
||||
|
||||
final HanzoCloud _client;
|
||||
|
||||
/// List the org's agents, most-recently-updated first.
|
||||
Future<List<Agent>> list() async {
|
||||
final res = await _client.send('/v1/agents');
|
||||
final agents = (res is Map ? res['agents'] : null) as List? ?? const [];
|
||||
return agents
|
||||
.map((e) => Agent.fromJson((e as Map).cast<String, dynamic>()))
|
||||
.toList();
|
||||
}
|
||||
|
||||
/// Create an agent.
|
||||
Future<Agent> create({
|
||||
required String name,
|
||||
required String model,
|
||||
String? instructions,
|
||||
String? description,
|
||||
List<String>? tools,
|
||||
String? executionMode,
|
||||
String? schedule,
|
||||
String? computeRef,
|
||||
String? serviceAccountId,
|
||||
}) async {
|
||||
final res = await _client.send('/v1/agents',
|
||||
method: 'POST',
|
||||
body: _clean({
|
||||
'name': name,
|
||||
'model': model,
|
||||
'instructions': instructions,
|
||||
'description': description,
|
||||
'tools': tools,
|
||||
'executionMode': executionMode,
|
||||
'schedule': schedule,
|
||||
'computeRef': computeRef,
|
||||
'serviceAccountId': serviceAccountId,
|
||||
}));
|
||||
return Agent.fromJson((res as Map).cast<String, dynamic>());
|
||||
}
|
||||
|
||||
/// Get an agent by its public id (`agent_...`) or org-unique name.
|
||||
Future<AgentDetail> get(String ref) async {
|
||||
final res = await _client.send('/v1/agents/${Uri.encodeComponent(ref)}');
|
||||
return AgentDetail.fromJson((res as Map).cast<String, dynamic>());
|
||||
}
|
||||
|
||||
/// Update an agent's mutable fields (partial).
|
||||
Future<Agent> update(
|
||||
String ref, {
|
||||
String? model,
|
||||
String? instructions,
|
||||
String? description,
|
||||
List<String>? tools,
|
||||
String? executionMode,
|
||||
String? schedule,
|
||||
String? computeRef,
|
||||
String? serviceAccountId,
|
||||
}) async {
|
||||
final res = await _client.send(
|
||||
'/v1/agents/${Uri.encodeComponent(ref)}',
|
||||
method: 'PATCH',
|
||||
body: _clean({
|
||||
'model': model,
|
||||
'instructions': instructions,
|
||||
'description': description,
|
||||
'tools': tools,
|
||||
'executionMode': executionMode,
|
||||
'schedule': schedule,
|
||||
'computeRef': computeRef,
|
||||
'serviceAccountId': serviceAccountId,
|
||||
}),
|
||||
);
|
||||
return Agent.fromJson((res as Map).cast<String, dynamic>());
|
||||
}
|
||||
|
||||
/// Delete an agent and its run history.
|
||||
Future<void> delete(String ref) =>
|
||||
_client.send('/v1/agents/${Uri.encodeComponent(ref)}', method: 'DELETE');
|
||||
|
||||
/// Run the agent once and return the completed run.
|
||||
Future<Run> run(String ref, {String? input}) async {
|
||||
final res = await _client.send(
|
||||
'/v1/agents/${Uri.encodeComponent(ref)}/run',
|
||||
method: 'POST',
|
||||
body: input == null ? null : {'input': input},
|
||||
);
|
||||
return Run.fromJson((res as Map).cast<String, dynamic>());
|
||||
}
|
||||
|
||||
/// The agent's run history, newest first.
|
||||
Future<List<Run>> runs(String ref, {int? limit}) async {
|
||||
final res = await _client.send(
|
||||
'/v1/agents/${Uri.encodeComponent(ref)}/runs',
|
||||
query: {if (limit != null) 'limit': limit},
|
||||
);
|
||||
final runs = (res is Map ? res['runs'] : null) as List? ?? const [];
|
||||
return runs
|
||||
.map((e) => Run.fromJson((e as Map).cast<String, dynamic>()))
|
||||
.toList();
|
||||
}
|
||||
|
||||
/// Invocations-over-time metrics for the org's Agents dashboard.
|
||||
///
|
||||
/// [range] is one of `24H`, `7D`, `30D`. Returns the raw metrics map.
|
||||
Future<Map<String, dynamic>> metrics({String? range}) async {
|
||||
final res = await _client.send(
|
||||
'/v1/agents/metrics',
|
||||
query: {if (range != null) 'range': range},
|
||||
);
|
||||
return res is Map ? res.cast<String, dynamic>() : <String, dynamic>{};
|
||||
}
|
||||
|
||||
/// The org-wide recent-activity feed, newest first.
|
||||
Future<List<Activity>> activity() async {
|
||||
final res = await _client.send('/v1/agents/activity');
|
||||
final activity = (res is Map ? res['activity'] : null) as List? ?? const [];
|
||||
return activity
|
||||
.map((e) => Activity.fromJson((e as Map).cast<String, dynamic>()))
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, dynamic> _clean(Map<String, dynamic> m) {
|
||||
m.removeWhere((_, v) => v == null);
|
||||
return m;
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'jwt.dart';
|
||||
|
||||
/// Callback invoked whenever the stored Bearer token changes.
|
||||
typedef AuthChangeCallback = void Function(String token);
|
||||
|
||||
/// Holds the current Hanzo IAM-issued Bearer token (a JWT or an API key).
|
||||
///
|
||||
/// The default [MemoryAuthStore] keeps it in memory and validates a JWT's
|
||||
/// `exp` claim locally. Provide an [AsyncAuthStore] to persist the token
|
||||
/// (e.g. secure storage on mobile).
|
||||
abstract class AuthStore {
|
||||
String get token;
|
||||
|
||||
/// Whether a usable token is present (see [isTokenValid]).
|
||||
bool get isValid => isTokenValid(token);
|
||||
|
||||
void save(String token);
|
||||
void clear();
|
||||
|
||||
/// Register a change listener. Returns a function that removes it.
|
||||
void Function() onChange(AuthChangeCallback callback,
|
||||
{bool fireImmediately = false});
|
||||
}
|
||||
|
||||
/// In-memory auth store. The default when none is supplied.
|
||||
class MemoryAuthStore extends AuthStore {
|
||||
String _token = '';
|
||||
final _listeners = <AuthChangeCallback>{};
|
||||
|
||||
@override
|
||||
String get token => _token;
|
||||
|
||||
@override
|
||||
void save(String token) {
|
||||
_token = token;
|
||||
_notify();
|
||||
}
|
||||
|
||||
@override
|
||||
void clear() {
|
||||
_token = '';
|
||||
_notify();
|
||||
}
|
||||
|
||||
@override
|
||||
void Function() onChange(AuthChangeCallback callback,
|
||||
{bool fireImmediately = false}) {
|
||||
_listeners.add(callback);
|
||||
if (fireImmediately) {
|
||||
callback(_token);
|
||||
}
|
||||
return () => _listeners.remove(callback);
|
||||
}
|
||||
|
||||
void _notify() {
|
||||
for (final listener in _listeners.toList()) {
|
||||
listener(_token);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Auth store backed by an async persistence layer.
|
||||
///
|
||||
/// Supply [save] to persist the token and [initial] to seed the store from
|
||||
/// previously-saved state. The persisted value is an opaque JSON string.
|
||||
///
|
||||
/// ```dart
|
||||
/// AsyncAuthStore(
|
||||
/// save: (data) => storage.write(key: 'hanzo_cloud', value: data),
|
||||
/// initial: await storage.read(key: 'hanzo_cloud'),
|
||||
/// );
|
||||
/// ```
|
||||
class AsyncAuthStore extends AuthStore {
|
||||
AsyncAuthStore({
|
||||
required Future<void> Function(String data) save,
|
||||
String? initial,
|
||||
Future<void> Function()? clear,
|
||||
}) : _save = save,
|
||||
_clear = clear {
|
||||
if (initial != null && initial.isNotEmpty) {
|
||||
_load(initial);
|
||||
}
|
||||
}
|
||||
|
||||
final Future<void> Function(String data) _save;
|
||||
final Future<void> Function()? _clear;
|
||||
|
||||
String _token = '';
|
||||
final _listeners = <AuthChangeCallback>{};
|
||||
|
||||
@override
|
||||
String get token => _token;
|
||||
|
||||
@override
|
||||
void save(String token) {
|
||||
_token = token;
|
||||
_notify();
|
||||
unawaited(_save(jsonEncode({'token': token})));
|
||||
}
|
||||
|
||||
@override
|
||||
void clear() {
|
||||
_token = '';
|
||||
_notify();
|
||||
final clear = _clear;
|
||||
if (clear != null) {
|
||||
unawaited(clear());
|
||||
} else {
|
||||
unawaited(_save(''));
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void Function() onChange(AuthChangeCallback callback,
|
||||
{bool fireImmediately = false}) {
|
||||
_listeners.add(callback);
|
||||
if (fireImmediately) {
|
||||
callback(_token);
|
||||
}
|
||||
return () => _listeners.remove(callback);
|
||||
}
|
||||
|
||||
void _load(String raw) {
|
||||
try {
|
||||
final data = jsonDecode(raw);
|
||||
if (data is Map<String, dynamic>) {
|
||||
_token = (data['token'] ?? '').toString();
|
||||
}
|
||||
} catch (_) {
|
||||
// ignore corrupt persisted state
|
||||
}
|
||||
}
|
||||
|
||||
void _notify() {
|
||||
for (final listener in _listeners.toList()) {
|
||||
listener(_token);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
import 'agents.dart';
|
||||
import 'auth_store.dart';
|
||||
import 'exception.dart';
|
||||
import 'machines.dart';
|
||||
import 'sessions.dart';
|
||||
|
||||
/// Default Hanzo Cloud API origin.
|
||||
const String defaultCloudUrl = 'https://api.hanzo.ai';
|
||||
|
||||
/// The typed client for the Hanzo Cloud `/v1/` platform API.
|
||||
///
|
||||
/// ```dart
|
||||
/// final cloud = HanzoCloud(token: 'IAM_JWT');
|
||||
/// final agent = await cloud.agents.create(name: 'researcher', model: 'zen-coder');
|
||||
/// final run = await cloud.agents.run('researcher', input: 'Summarize RFC 8628.');
|
||||
/// ```
|
||||
///
|
||||
/// Auth is Hanzo IAM-native: [authStore] holds the IAM-issued Bearer JWT (or an
|
||||
/// API key), sent as `Authorization: Bearer <token>` on every request.
|
||||
class HanzoCloud {
|
||||
HanzoCloud({
|
||||
String baseUrl = defaultCloudUrl,
|
||||
String? token,
|
||||
AuthStore? authStore,
|
||||
this.lang = 'en-US',
|
||||
http.Client Function()? httpClientFactory,
|
||||
}) : baseUrl = baseUrl.endsWith('/')
|
||||
? baseUrl.substring(0, baseUrl.length - 1)
|
||||
: baseUrl,
|
||||
authStore = authStore ?? MemoryAuthStore() {
|
||||
_httpClient = (httpClientFactory ?? http.Client.new)();
|
||||
if (token != null && token.isNotEmpty) {
|
||||
this.authStore.save(token);
|
||||
}
|
||||
agents = AgentsService(this);
|
||||
sessions = SessionsService(this);
|
||||
machines = MachinesService(this);
|
||||
}
|
||||
|
||||
/// The Hanzo Cloud origin (e.g. `https://api.hanzo.ai`).
|
||||
final String baseUrl;
|
||||
|
||||
/// Holds the current Hanzo IAM Bearer token.
|
||||
final AuthStore authStore;
|
||||
|
||||
/// `Accept-Language` header sent with every request.
|
||||
String lang;
|
||||
|
||||
/// The `/v1/agents` surface.
|
||||
late final AgentsService agents;
|
||||
|
||||
/// The `/v1/agents/sessions` surface.
|
||||
late final SessionsService sessions;
|
||||
|
||||
/// The `/v1/cloud/*-machine(s)` surface.
|
||||
late final MachinesService machines;
|
||||
|
||||
late final http.Client _httpClient;
|
||||
|
||||
/// Seed or replace the Bearer token.
|
||||
void setToken(String token) => authStore.save(token);
|
||||
|
||||
/// Clear the Bearer token.
|
||||
void signOut() => authStore.clear();
|
||||
|
||||
/// Liveness probe (`GET /health`).
|
||||
Future<Map<String, dynamic>> health() async {
|
||||
final result = await send('/health');
|
||||
return result is Map<String, dynamic> ? result : <String, dynamic>{};
|
||||
}
|
||||
|
||||
/// Sends a request to the Hanzo Cloud API and returns the decoded JSON body.
|
||||
///
|
||||
/// Adds `Authorization: Bearer <token>` when a valid session exists. Throws
|
||||
/// [HanzoException] on non-2xx responses.
|
||||
Future<dynamic> send(
|
||||
String path, {
|
||||
String method = 'GET',
|
||||
Map<String, String> headers = const {},
|
||||
Map<String, dynamic> query = const {},
|
||||
Object? body,
|
||||
}) async {
|
||||
final url = _buildUrl(path, query);
|
||||
|
||||
final request = http.Request(method, url);
|
||||
if (body != null) {
|
||||
request.body = jsonEncode(body);
|
||||
request.headers['Content-Type'] = 'application/json';
|
||||
}
|
||||
|
||||
request.headers.addAll(headers);
|
||||
if (!request.headers.containsKey('Authorization') && authStore.isValid) {
|
||||
request.headers['Authorization'] = 'Bearer ${authStore.token}';
|
||||
}
|
||||
request.headers.putIfAbsent('Accept-Language', () => lang);
|
||||
|
||||
http.StreamedResponse streamed;
|
||||
try {
|
||||
streamed = await _httpClient.send(request);
|
||||
} on http.ClientException catch (e) {
|
||||
throw HanzoException(url: url, originalError: e, isAbort: true);
|
||||
} catch (e) {
|
||||
throw HanzoException(url: url, originalError: e);
|
||||
}
|
||||
|
||||
final responseBody = await streamed.stream.bytesToString();
|
||||
dynamic data;
|
||||
if (responseBody.isNotEmpty) {
|
||||
try {
|
||||
data = jsonDecode(responseBody);
|
||||
} catch (_) {
|
||||
data = responseBody;
|
||||
}
|
||||
}
|
||||
|
||||
if (streamed.statusCode >= 400) {
|
||||
throw HanzoException(
|
||||
url: url,
|
||||
statusCode: streamed.statusCode,
|
||||
response: data is Map<String, dynamic> ? data : const {},
|
||||
);
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
/// Closes the underlying HTTP client.
|
||||
void close() => _httpClient.close();
|
||||
|
||||
Uri _buildUrl(String path, Map<String, dynamic> query) {
|
||||
final normalizedQuery = <String, dynamic>{};
|
||||
query.forEach((key, value) {
|
||||
if (value == null) {
|
||||
return;
|
||||
}
|
||||
normalizedQuery[key] = value is Iterable
|
||||
? value.map((v) => v.toString()).toList()
|
||||
: value.toString();
|
||||
});
|
||||
|
||||
final uri = Uri.parse('$baseUrl$path');
|
||||
if (normalizedQuery.isEmpty) {
|
||||
return uri;
|
||||
}
|
||||
return uri.replace(queryParameters: {
|
||||
...uri.queryParametersAll,
|
||||
...normalizedQuery,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
/// Normalized error thrown by every Hanzo Cloud request.
|
||||
class HanzoException implements Exception {
|
||||
HanzoException({
|
||||
this.url,
|
||||
this.statusCode = 0,
|
||||
this.response = const {},
|
||||
this.originalError,
|
||||
this.isAbort = false,
|
||||
});
|
||||
|
||||
/// The request url that produced the error.
|
||||
final Uri? url;
|
||||
|
||||
/// The HTTP status code, or `0` for transport/connection errors.
|
||||
final int statusCode;
|
||||
|
||||
/// The parsed JSON error body, when present.
|
||||
final Map<String, dynamic> response;
|
||||
|
||||
/// The underlying error for transport failures.
|
||||
final Object? originalError;
|
||||
|
||||
/// Whether the request was aborted (connection dropped).
|
||||
final bool isAbort;
|
||||
|
||||
/// Server-provided error message, if any.
|
||||
String get message {
|
||||
final msg = response['message'] ?? response['msg'];
|
||||
if (msg is String && msg.isNotEmpty) {
|
||||
return msg;
|
||||
}
|
||||
return originalError?.toString() ?? 'HanzoException $statusCode';
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() => 'HanzoException($statusCode): $message';
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import 'dart:convert';
|
||||
|
||||
/// Decode a JWT payload without verifying its signature.
|
||||
///
|
||||
/// Returns an empty map for tokens that are not three-part JWTs (e.g. opaque
|
||||
/// API keys). Used to read the Hanzo IAM token's `exp` claim locally.
|
||||
Map<String, dynamic> getTokenPayload(String token) {
|
||||
final parts = token.split('.');
|
||||
if (parts.length != 3) {
|
||||
return const {};
|
||||
}
|
||||
try {
|
||||
final decoded =
|
||||
utf8.decode(base64Url.decode(base64Url.normalize(parts[1])));
|
||||
final data = jsonDecode(decoded);
|
||||
return data is Map<String, dynamic> ? data : const {};
|
||||
} catch (_) {
|
||||
return const {};
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a Bearer token is currently usable.
|
||||
///
|
||||
/// A three-part JWT is valid until its `exp` claim (minus
|
||||
/// [expirationThreshold] seconds) passes. An opaque API key — anything that is
|
||||
/// not a decodable JWT — is valid whenever it is non-empty, since it carries no
|
||||
/// local expiry.
|
||||
bool isTokenValid(String token, [int expirationThreshold = 0]) {
|
||||
if (token.isEmpty) {
|
||||
return false;
|
||||
}
|
||||
final payload = getTokenPayload(token);
|
||||
final exp = payload['exp'];
|
||||
if (exp is! num) {
|
||||
return true; // opaque API key or JWT without exp
|
||||
}
|
||||
final nowSeconds = DateTime.now().millisecondsSinceEpoch / 1000;
|
||||
return exp - expirationThreshold > nowSeconds;
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import 'client.dart';
|
||||
import 'models.dart';
|
||||
|
||||
/// The `/v1/cloud/*-machine(s)` surface — a worked example of the legacy
|
||||
/// `/v1/cloud/*` platform CRUD.
|
||||
///
|
||||
/// Mutations return the [CloudResponse] envelope; reads return the object
|
||||
/// directly. Every other `/v1/cloud/*` resource (nodes, pods, containers,
|
||||
/// providers, connections, …) follows the same shape and is reachable via
|
||||
/// [HanzoCloud.send].
|
||||
class MachinesService {
|
||||
MachinesService(this._client);
|
||||
|
||||
final HanzoCloud _client;
|
||||
|
||||
/// List machines (paginated).
|
||||
Future<List<Machine>> list({int pageSize = 100, int page = 1}) async {
|
||||
final res = await _client.send('/v1/cloud/get-machines',
|
||||
query: {'pageSize': pageSize, 'p': page});
|
||||
if (res is List) {
|
||||
return res
|
||||
.map((e) => Machine.fromJson((e as Map).cast<String, dynamic>()))
|
||||
.toList();
|
||||
}
|
||||
// Casdoor-style envelope: the page rides in `data`.
|
||||
final data = res is Map ? res['data'] : null;
|
||||
if (data is List) {
|
||||
return data
|
||||
.map((e) => Machine.fromJson((e as Map).cast<String, dynamic>()))
|
||||
.toList();
|
||||
}
|
||||
return const [];
|
||||
}
|
||||
|
||||
/// Get one machine by its `owner/name` id.
|
||||
Future<Machine> get(String id) async {
|
||||
final res = await _client.send('/v1/cloud/get-machine', query: {'id': id});
|
||||
return Machine.fromJson((res as Map).cast<String, dynamic>());
|
||||
}
|
||||
|
||||
/// Create a machine.
|
||||
Future<CloudResponse> add(Machine machine) async {
|
||||
final res = await _client.send('/v1/cloud/add-machine',
|
||||
method: 'POST', body: machine.toJson());
|
||||
return CloudResponse.fromJson((res as Map).cast<String, dynamic>());
|
||||
}
|
||||
|
||||
/// Update a machine by its `owner/name` id.
|
||||
Future<CloudResponse> update(String id, Machine machine) async {
|
||||
final res = await _client.send('/v1/cloud/update-machine',
|
||||
method: 'POST', query: {'id': id}, body: machine.toJson());
|
||||
return CloudResponse.fromJson((res as Map).cast<String, dynamic>());
|
||||
}
|
||||
|
||||
/// Delete a machine.
|
||||
Future<CloudResponse> delete(Machine machine) async {
|
||||
final res = await _client.send('/v1/cloud/delete-machine',
|
||||
method: 'POST', body: machine.toJson());
|
||||
return CloudResponse.fromJson((res as Map).cast<String, dynamic>());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,508 @@
|
||||
/// Typed domain models for the Hanzo Cloud API.
|
||||
///
|
||||
/// Each class mirrors a schema in the OpenAPI spec (`agents.*`,
|
||||
/// `object.Machine`, `controllers.Response`). Fields absent from a response
|
||||
/// take their zero value; `fromJson` never throws on a well-formed map.
|
||||
library;
|
||||
|
||||
String _s(dynamic v) => v?.toString() ?? '';
|
||||
int _i(dynamic v) => v is int
|
||||
? v
|
||||
: v is num
|
||||
? v.toInt()
|
||||
: v is String
|
||||
? int.tryParse(v) ?? 0
|
||||
: 0;
|
||||
bool _b(dynamic v) => v == true;
|
||||
List<String> _strList(dynamic v) =>
|
||||
v is List ? v.map((e) => e.toString()).toList() : const [];
|
||||
Map<String, dynamic> _map(dynamic v) =>
|
||||
v is Map ? v.map((k, value) => MapEntry(k.toString(), value)) : const {};
|
||||
|
||||
/// A per-org autonomous agent — a model, instructions, and tool names.
|
||||
class Agent {
|
||||
Agent({
|
||||
this.id = '',
|
||||
this.name = '',
|
||||
this.model = '',
|
||||
this.description = '',
|
||||
this.tools = const [],
|
||||
this.status = '',
|
||||
this.executionMode = '',
|
||||
this.schedule = '',
|
||||
this.computeRef = '',
|
||||
this.serviceAccountId = '',
|
||||
this.runs = 0,
|
||||
this.createdAt = '',
|
||||
this.updatedAt = '',
|
||||
});
|
||||
|
||||
factory Agent.fromJson(Map<String, dynamic> json) => Agent(
|
||||
id: _s(json['id']),
|
||||
name: _s(json['name']),
|
||||
model: _s(json['model']),
|
||||
description: _s(json['description']),
|
||||
tools: _strList(json['tools']),
|
||||
status: _s(json['status']),
|
||||
executionMode: _s(json['executionMode']),
|
||||
schedule: _s(json['schedule']),
|
||||
computeRef: _s(json['computeRef']),
|
||||
serviceAccountId: _s(json['serviceAccountId']),
|
||||
runs: _i(json['runs']),
|
||||
createdAt: _s(json['createdAt']),
|
||||
updatedAt: _s(json['updatedAt']),
|
||||
);
|
||||
|
||||
final String id;
|
||||
final String name;
|
||||
final String model;
|
||||
final String description;
|
||||
final List<String> tools;
|
||||
final String status;
|
||||
|
||||
/// `one-shot` or `long-running`.
|
||||
final String executionMode;
|
||||
|
||||
/// 5-field cron; set only when [executionMode] is `long-running`.
|
||||
final String schedule;
|
||||
final String computeRef;
|
||||
final String serviceAccountId;
|
||||
final int runs;
|
||||
final String createdAt;
|
||||
final String updatedAt;
|
||||
}
|
||||
|
||||
/// An [Agent] plus its instructions and recent runs.
|
||||
class AgentDetail extends Agent {
|
||||
AgentDetail({
|
||||
super.id,
|
||||
super.name,
|
||||
super.model,
|
||||
super.description,
|
||||
super.tools,
|
||||
super.status,
|
||||
super.executionMode,
|
||||
super.schedule,
|
||||
super.computeRef,
|
||||
super.serviceAccountId,
|
||||
super.runs,
|
||||
super.createdAt,
|
||||
super.updatedAt,
|
||||
this.instructions = '',
|
||||
this.recentRuns = const [],
|
||||
});
|
||||
|
||||
factory AgentDetail.fromJson(Map<String, dynamic> json) {
|
||||
final base = Agent.fromJson(json);
|
||||
return AgentDetail(
|
||||
id: base.id,
|
||||
name: base.name,
|
||||
model: base.model,
|
||||
description: base.description,
|
||||
tools: base.tools,
|
||||
status: base.status,
|
||||
executionMode: base.executionMode,
|
||||
schedule: base.schedule,
|
||||
computeRef: base.computeRef,
|
||||
serviceAccountId: base.serviceAccountId,
|
||||
runs: base.runs,
|
||||
createdAt: base.createdAt,
|
||||
updatedAt: base.updatedAt,
|
||||
instructions: _s(json['instructions']),
|
||||
recentRuns: (json['recentRuns'] as List? ?? const [])
|
||||
.map((e) => Run.fromJson(_map(e)))
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
|
||||
final String instructions;
|
||||
final List<Run> recentRuns;
|
||||
}
|
||||
|
||||
/// One execution of an agent.
|
||||
class Run {
|
||||
Run({
|
||||
this.id = '',
|
||||
this.status = '',
|
||||
this.model = '',
|
||||
this.input = '',
|
||||
this.output = '',
|
||||
this.error = '',
|
||||
this.durationMs = 0,
|
||||
this.createdAt = '',
|
||||
});
|
||||
|
||||
factory Run.fromJson(Map<String, dynamic> json) => Run(
|
||||
id: _s(json['id']),
|
||||
status: _s(json['status']),
|
||||
model: _s(json['model']),
|
||||
input: _s(json['input']),
|
||||
output: _s(json['output']),
|
||||
error: _s(json['error']),
|
||||
durationMs: _i(json['durationMs']),
|
||||
createdAt: _s(json['createdAt']),
|
||||
);
|
||||
|
||||
final String id;
|
||||
|
||||
/// `ok` or `error`.
|
||||
final String status;
|
||||
final String model;
|
||||
final String input;
|
||||
final String output;
|
||||
final String error;
|
||||
final int durationMs;
|
||||
final String createdAt;
|
||||
}
|
||||
|
||||
/// One entry in the org-wide agent activity feed.
|
||||
class Activity {
|
||||
Activity({
|
||||
this.id = '',
|
||||
this.kind = '',
|
||||
this.agent = '',
|
||||
this.message = '',
|
||||
this.at = '',
|
||||
});
|
||||
|
||||
factory Activity.fromJson(Map<String, dynamic> json) => Activity(
|
||||
id: _s(json['id']),
|
||||
kind: _s(json['kind']),
|
||||
agent: _s(json['agent']),
|
||||
message: _s(json['message']),
|
||||
at: _s(json['at']),
|
||||
);
|
||||
|
||||
final String id;
|
||||
|
||||
/// `invoked`, `failed`, `created`, or `updated`.
|
||||
final String kind;
|
||||
final String agent;
|
||||
final String message;
|
||||
final String at;
|
||||
}
|
||||
|
||||
/// A live agent-session — a node of the subagent tree.
|
||||
class Session {
|
||||
Session({
|
||||
this.id = '',
|
||||
this.agent = '',
|
||||
this.actor = '',
|
||||
this.status = '',
|
||||
this.parentSessionId = '',
|
||||
this.rootSessionId = '',
|
||||
this.title = '',
|
||||
this.taskWorkflowId = '',
|
||||
this.taskRunId = '',
|
||||
this.events = 0,
|
||||
this.children = 0,
|
||||
this.startedAt = '',
|
||||
this.endedAt = '',
|
||||
this.createdAt = '',
|
||||
this.updatedAt = '',
|
||||
});
|
||||
|
||||
factory Session.fromJson(Map<String, dynamic> json) => Session(
|
||||
id: _s(json['id']),
|
||||
agent: _s(json['agent']),
|
||||
actor: _s(json['actor']),
|
||||
status: _s(json['status']),
|
||||
parentSessionId: _s(json['parentSessionId']),
|
||||
rootSessionId: _s(json['rootSessionId']),
|
||||
title: _s(json['title']),
|
||||
taskWorkflowId: _s(json['taskWorkflowId']),
|
||||
taskRunId: _s(json['taskRunId']),
|
||||
events: _i(json['events']),
|
||||
children: _i(json['children']),
|
||||
startedAt: _s(json['startedAt']),
|
||||
endedAt: _s(json['endedAt']),
|
||||
createdAt: _s(json['createdAt']),
|
||||
updatedAt: _s(json['updatedAt']),
|
||||
);
|
||||
|
||||
final String id;
|
||||
final String agent;
|
||||
final String actor;
|
||||
|
||||
/// `running`, `paused`, `done`, or `error`.
|
||||
final String status;
|
||||
|
||||
/// Empty for a root (the outer agent).
|
||||
final String parentSessionId;
|
||||
|
||||
/// The tree key; equals [id] for a root.
|
||||
final String rootSessionId;
|
||||
final String title;
|
||||
final String taskWorkflowId;
|
||||
final String taskRunId;
|
||||
final int events;
|
||||
final int children;
|
||||
final String startedAt;
|
||||
final String endedAt;
|
||||
final String createdAt;
|
||||
final String updatedAt;
|
||||
}
|
||||
|
||||
/// A [Session] plus its direct children and recent events.
|
||||
class SessionDetail extends Session {
|
||||
SessionDetail({
|
||||
super.id,
|
||||
super.agent,
|
||||
super.actor,
|
||||
super.status,
|
||||
super.parentSessionId,
|
||||
super.rootSessionId,
|
||||
super.title,
|
||||
super.taskWorkflowId,
|
||||
super.taskRunId,
|
||||
super.events,
|
||||
super.children,
|
||||
super.startedAt,
|
||||
super.endedAt,
|
||||
super.createdAt,
|
||||
super.updatedAt,
|
||||
this.childSessions = const [],
|
||||
this.recentEvents = const [],
|
||||
});
|
||||
|
||||
factory SessionDetail.fromJson(Map<String, dynamic> json) {
|
||||
final base = Session.fromJson(json);
|
||||
return SessionDetail(
|
||||
id: base.id,
|
||||
agent: base.agent,
|
||||
actor: base.actor,
|
||||
status: base.status,
|
||||
parentSessionId: base.parentSessionId,
|
||||
rootSessionId: base.rootSessionId,
|
||||
title: base.title,
|
||||
taskWorkflowId: base.taskWorkflowId,
|
||||
taskRunId: base.taskRunId,
|
||||
events: base.events,
|
||||
children: base.children,
|
||||
startedAt: base.startedAt,
|
||||
endedAt: base.endedAt,
|
||||
createdAt: base.createdAt,
|
||||
updatedAt: base.updatedAt,
|
||||
childSessions: (json['childSessions'] as List? ?? const [])
|
||||
.map((e) => Session.fromJson(_map(e)))
|
||||
.toList(),
|
||||
recentEvents: (json['recentEvents'] as List? ?? const [])
|
||||
.map((e) => SessionEvent.fromJson(_map(e)))
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
|
||||
final List<Session> childSessions;
|
||||
final List<SessionEvent> recentEvents;
|
||||
}
|
||||
|
||||
/// One entry in a session's ordered event log.
|
||||
class SessionEvent {
|
||||
SessionEvent({
|
||||
this.id = '',
|
||||
this.sessionId = '',
|
||||
this.seq = 0,
|
||||
this.kind = '',
|
||||
this.actor = '',
|
||||
this.payload = const {},
|
||||
this.createdAt = '',
|
||||
});
|
||||
|
||||
factory SessionEvent.fromJson(Map<String, dynamic> json) => SessionEvent(
|
||||
id: _s(json['id']),
|
||||
sessionId: _s(json['sessionId']),
|
||||
seq: _i(json['seq']),
|
||||
kind: _s(json['kind']),
|
||||
actor: _s(json['actor']),
|
||||
payload: _map(json['payload']),
|
||||
createdAt: _s(json['createdAt']),
|
||||
);
|
||||
|
||||
final String id;
|
||||
final String sessionId;
|
||||
|
||||
/// Monotonic per session.
|
||||
final int seq;
|
||||
|
||||
/// `message`, `tool-call`, `spawn`, `log`, `status`, or `control`.
|
||||
final String kind;
|
||||
final String actor;
|
||||
final Map<String, dynamic> payload;
|
||||
final String createdAt;
|
||||
}
|
||||
|
||||
/// One node of the recursive subagent-flow graph.
|
||||
class TreeNode {
|
||||
TreeNode({Session? session, this.children = const []})
|
||||
: session = session ?? Session();
|
||||
|
||||
factory TreeNode.fromJson(Map<String, dynamic> json) => TreeNode(
|
||||
session: Session.fromJson(_map(json['session'])),
|
||||
children: (json['children'] as List? ?? const [])
|
||||
.map((e) => TreeNode.fromJson(_map(e)))
|
||||
.toList(),
|
||||
);
|
||||
|
||||
final Session session;
|
||||
final List<TreeNode> children;
|
||||
}
|
||||
|
||||
/// The result of a session control command (pause/resume/stop/message).
|
||||
class ControlResult {
|
||||
ControlResult({this.command = '', this.event, this.forwarded = false});
|
||||
|
||||
factory ControlResult.fromJson(Map<String, dynamic> json) => ControlResult(
|
||||
command: _s(json['command']),
|
||||
event: json['event'] is Map
|
||||
? SessionEvent.fromJson(_map(json['event']))
|
||||
: null,
|
||||
forwarded: _b(json['forwarded']),
|
||||
);
|
||||
|
||||
/// `pause`, `resume`, `stop`, or `message`.
|
||||
final String command;
|
||||
final SessionEvent? event;
|
||||
|
||||
/// True when the command reached the hanzoai/tasks engine.
|
||||
final bool forwarded;
|
||||
}
|
||||
|
||||
/// A provisioned compute machine.
|
||||
class Machine {
|
||||
Machine({
|
||||
this.id = '',
|
||||
this.name = '',
|
||||
this.owner = '',
|
||||
this.displayName = '',
|
||||
this.category = '',
|
||||
this.type = '',
|
||||
this.provider = '',
|
||||
this.region = '',
|
||||
this.zone = '',
|
||||
this.image = '',
|
||||
this.os = '',
|
||||
this.size = '',
|
||||
this.cpuSize = '',
|
||||
this.memSize = '',
|
||||
this.state = '',
|
||||
this.tag = '',
|
||||
this.publicIp = '',
|
||||
this.privateIp = '',
|
||||
this.remoteProtocol = '',
|
||||
this.remotePort = 0,
|
||||
this.remoteUsername = '',
|
||||
this.createdTime = '',
|
||||
this.updatedTime = '',
|
||||
this.expireTime = '',
|
||||
});
|
||||
|
||||
factory Machine.fromJson(Map<String, dynamic> json) => Machine(
|
||||
id: _s(json['id']),
|
||||
name: _s(json['name']),
|
||||
owner: _s(json['owner']),
|
||||
displayName: _s(json['displayName']),
|
||||
category: _s(json['category']),
|
||||
type: _s(json['type']),
|
||||
provider: _s(json['provider']),
|
||||
region: _s(json['region']),
|
||||
zone: _s(json['zone']),
|
||||
image: _s(json['image']),
|
||||
os: _s(json['os']),
|
||||
size: _s(json['size']),
|
||||
cpuSize: _s(json['cpuSize']),
|
||||
memSize: _s(json['memSize']),
|
||||
state: _s(json['state']),
|
||||
tag: _s(json['tag']),
|
||||
publicIp: _s(json['publicIp']),
|
||||
privateIp: _s(json['privateIp']),
|
||||
remoteProtocol: _s(json['remoteProtocol']),
|
||||
remotePort: _i(json['remotePort']),
|
||||
remoteUsername: _s(json['remoteUsername']),
|
||||
createdTime: _s(json['createdTime']),
|
||||
updatedTime: _s(json['updatedTime']),
|
||||
expireTime: _s(json['expireTime']),
|
||||
);
|
||||
|
||||
/// Serialize back to the wire shape (non-empty fields only).
|
||||
Map<String, dynamic> toJson() {
|
||||
final m = <String, dynamic>{
|
||||
'id': id,
|
||||
'name': name,
|
||||
'owner': owner,
|
||||
'displayName': displayName,
|
||||
'category': category,
|
||||
'type': type,
|
||||
'provider': provider,
|
||||
'region': region,
|
||||
'zone': zone,
|
||||
'image': image,
|
||||
'os': os,
|
||||
'size': size,
|
||||
'cpuSize': cpuSize,
|
||||
'memSize': memSize,
|
||||
'state': state,
|
||||
'tag': tag,
|
||||
'publicIp': publicIp,
|
||||
'privateIp': privateIp,
|
||||
'remoteProtocol': remoteProtocol,
|
||||
'remotePort': remotePort,
|
||||
'remoteUsername': remoteUsername,
|
||||
'createdTime': createdTime,
|
||||
'updatedTime': updatedTime,
|
||||
'expireTime': expireTime,
|
||||
};
|
||||
m.removeWhere((k, v) => v == '' || v == 0);
|
||||
return m;
|
||||
}
|
||||
|
||||
final String id;
|
||||
final String name;
|
||||
final String owner;
|
||||
final String displayName;
|
||||
final String category;
|
||||
final String type;
|
||||
final String provider;
|
||||
final String region;
|
||||
final String zone;
|
||||
final String image;
|
||||
final String os;
|
||||
final String size;
|
||||
final String cpuSize;
|
||||
final String memSize;
|
||||
final String state;
|
||||
final String tag;
|
||||
final String publicIp;
|
||||
final String privateIp;
|
||||
final String remoteProtocol;
|
||||
final int remotePort;
|
||||
final String remoteUsername;
|
||||
final String createdTime;
|
||||
final String updatedTime;
|
||||
final String expireTime;
|
||||
}
|
||||
|
||||
/// The `{ status, msg, data, data2 }` envelope returned by the legacy
|
||||
/// `/v1/cloud/*` (Casdoor-style) endpoints.
|
||||
class CloudResponse {
|
||||
CloudResponse({
|
||||
this.status = '',
|
||||
this.msg = '',
|
||||
this.data,
|
||||
this.data2,
|
||||
});
|
||||
|
||||
factory CloudResponse.fromJson(Map<String, dynamic> json) => CloudResponse(
|
||||
status: _s(json['status']),
|
||||
msg: _s(json['msg']),
|
||||
data: json['data'],
|
||||
data2: json['data2'],
|
||||
);
|
||||
|
||||
final String status;
|
||||
final String msg;
|
||||
final dynamic data;
|
||||
final dynamic data2;
|
||||
|
||||
/// Whether the server reported success.
|
||||
bool get ok => status == 'ok';
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import 'client.dart';
|
||||
import 'models.dart';
|
||||
|
||||
/// The `/v1/agents/sessions` surface.
|
||||
///
|
||||
/// A session is one live agent invocation. Sessions linked by `parentSessionId`
|
||||
/// form the subagent tree; `rootSessionId` is the shared key.
|
||||
class SessionsService {
|
||||
SessionsService(this._client);
|
||||
|
||||
final HanzoCloud _client;
|
||||
|
||||
/// Register a live session. Omit [parentSessionId] for a root.
|
||||
Future<Session> register({
|
||||
required String agent,
|
||||
String? actor,
|
||||
String? title,
|
||||
String? status,
|
||||
String? parentSessionId,
|
||||
String? taskWorkflowId,
|
||||
String? taskRunId,
|
||||
}) async {
|
||||
final res =
|
||||
await _client.send('/v1/agents/sessions', method: 'POST', body: {
|
||||
'agent': agent,
|
||||
if (actor != null) 'actor': actor,
|
||||
if (title != null) 'title': title,
|
||||
if (status != null) 'status': status,
|
||||
if (parentSessionId != null) 'parentSessionId': parentSessionId,
|
||||
if (taskWorkflowId != null) 'taskWorkflowId': taskWorkflowId,
|
||||
if (taskRunId != null) 'taskRunId': taskRunId,
|
||||
});
|
||||
return Session.fromJson((res as Map).cast<String, dynamic>());
|
||||
}
|
||||
|
||||
/// List the org's live sessions, newest first.
|
||||
Future<List<Session>> list({
|
||||
String? root,
|
||||
String? parent,
|
||||
String? status,
|
||||
int? limit,
|
||||
}) async {
|
||||
final res = await _client.send('/v1/agents/sessions', query: {
|
||||
if (root != null) 'root': root,
|
||||
if (parent != null) 'parent': parent,
|
||||
if (status != null) 'status': status,
|
||||
if (limit != null) 'limit': limit,
|
||||
});
|
||||
final sessions = (res is Map ? res['sessions'] : null) as List? ?? const [];
|
||||
return sessions
|
||||
.map((e) => Session.fromJson((e as Map).cast<String, dynamic>()))
|
||||
.toList();
|
||||
}
|
||||
|
||||
/// Session detail — the session, its direct children, and recent events.
|
||||
Future<SessionDetail> get(String id) async {
|
||||
final res =
|
||||
await _client.send('/v1/agents/sessions/${Uri.encodeComponent(id)}');
|
||||
return SessionDetail.fromJson((res as Map).cast<String, dynamic>());
|
||||
}
|
||||
|
||||
/// Update a session's status and/or title (terminal sessions are monotonic).
|
||||
Future<Session> patch(String id, {String? status, String? title}) async {
|
||||
final res = await _client.send(
|
||||
'/v1/agents/sessions/${Uri.encodeComponent(id)}',
|
||||
method: 'PATCH',
|
||||
body: {
|
||||
if (status != null) 'status': status,
|
||||
if (title != null) 'title': title,
|
||||
},
|
||||
);
|
||||
return Session.fromJson((res as Map).cast<String, dynamic>());
|
||||
}
|
||||
|
||||
/// The full subagent-flow graph rooted at this session's tree.
|
||||
Future<TreeNode> tree(String id) async {
|
||||
final res = await _client
|
||||
.send('/v1/agents/sessions/${Uri.encodeComponent(id)}/tree');
|
||||
return TreeNode.fromJson((res as Map).cast<String, dynamic>());
|
||||
}
|
||||
|
||||
/// Append one event to a session's ordered log.
|
||||
Future<SessionEvent> appendEvent(
|
||||
String id, {
|
||||
required String kind,
|
||||
String? actor,
|
||||
Map<String, dynamic>? payload,
|
||||
}) async {
|
||||
final res = await _client.send(
|
||||
'/v1/agents/sessions/${Uri.encodeComponent(id)}/events',
|
||||
method: 'POST',
|
||||
body: {
|
||||
'kind': kind,
|
||||
if (actor != null) 'actor': actor,
|
||||
if (payload != null) 'payload': payload,
|
||||
},
|
||||
);
|
||||
return SessionEvent.fromJson((res as Map).cast<String, dynamic>());
|
||||
}
|
||||
|
||||
/// Pause a live session.
|
||||
Future<ControlResult> pause(String id,
|
||||
{String? message, Map<String, dynamic>? payload}) =>
|
||||
_control(id, 'pause', message, payload);
|
||||
|
||||
/// Resume a paused session.
|
||||
Future<ControlResult> resume(String id,
|
||||
{String? message, Map<String, dynamic>? payload}) =>
|
||||
_control(id, 'resume', message, payload);
|
||||
|
||||
/// Stop a session.
|
||||
Future<ControlResult> stop(String id,
|
||||
{String? message, Map<String, dynamic>? payload}) =>
|
||||
_control(id, 'stop', message, payload);
|
||||
|
||||
/// Send a steering message to a live session (requires [message] or [payload]).
|
||||
Future<ControlResult> message(String id,
|
||||
{String? message, Map<String, dynamic>? payload}) =>
|
||||
_control(id, 'message', message, payload);
|
||||
|
||||
Future<ControlResult> _control(
|
||||
String id,
|
||||
String command,
|
||||
String? message,
|
||||
Map<String, dynamic>? payload,
|
||||
) async {
|
||||
final res = await _client.send(
|
||||
'/v1/agents/sessions/${Uri.encodeComponent(id)}/$command',
|
||||
method: 'POST',
|
||||
body: (message == null && payload == null)
|
||||
? null
|
||||
: {
|
||||
if (message != null) 'message': message,
|
||||
if (payload != null) 'payload': payload,
|
||||
},
|
||||
);
|
||||
return ControlResult.fromJson((res as Map).cast<String, dynamic>());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
name: hanzoai
|
||||
description: Dart client for the Hanzo Cloud platform API — agents, agent sessions, and machines with Hanzo IAM-native auth.
|
||||
version: 0.1.0
|
||||
repository: https://github.com/hanzo-dart/hanzoai
|
||||
homepage: https://hanzo.ai
|
||||
issue_tracker: https://github.com/hanzo-dart/hanzoai/issues
|
||||
|
||||
environment:
|
||||
sdk: '>=3.0.0 <4.0.0'
|
||||
|
||||
dependencies:
|
||||
http: ^1.3.0
|
||||
|
||||
dev_dependencies:
|
||||
lints: ^5.0.0
|
||||
test: ^1.24.0
|
||||
@@ -0,0 +1,281 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:hanzoai/hanzoai.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:http/testing.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
/// Builds an unsigned JWT with the given payload (for local exp checks).
|
||||
String jwt(Map<String, dynamic> payload) {
|
||||
String seg(Map<String, dynamic> m) =>
|
||||
base64Url.encode(utf8.encode(jsonEncode(m))).replaceAll('=', '');
|
||||
return '${seg({'alg': 'none'})}.${seg(payload)}.sig';
|
||||
}
|
||||
|
||||
HanzoCloud clientWith(
|
||||
Future<http.Response> Function(http.Request request) handler, {
|
||||
String? token,
|
||||
AuthStore? authStore,
|
||||
}) {
|
||||
return HanzoCloud(
|
||||
token: token,
|
||||
authStore: authStore,
|
||||
httpClientFactory: () => MockClient((r) => handler(r)),
|
||||
);
|
||||
}
|
||||
|
||||
void main() {
|
||||
group('jwt / token validity', () {
|
||||
test('decodes payload', () {
|
||||
expect(getTokenPayload(jwt({'id': 'u1', 'exp': 9999999999}))['id'], 'u1');
|
||||
});
|
||||
|
||||
test('opaque api key is valid, empty is not', () {
|
||||
expect(isTokenValid(''), isFalse);
|
||||
expect(isTokenValid('opaque-api-key'), isTrue);
|
||||
});
|
||||
|
||||
test('jwt exp respected', () {
|
||||
expect(isTokenValid(jwt({'exp': 9999999999})), isTrue);
|
||||
expect(isTokenValid(jwt({'exp': 1})), isFalse);
|
||||
});
|
||||
});
|
||||
|
||||
group('MemoryAuthStore', () {
|
||||
test('isValid tracks token', () {
|
||||
final store = MemoryAuthStore();
|
||||
expect(store.isValid, isFalse);
|
||||
store.save(jwt({'exp': 9999999999}));
|
||||
expect(store.isValid, isTrue);
|
||||
store.save(jwt({'exp': 1}));
|
||||
expect(store.isValid, isFalse);
|
||||
});
|
||||
|
||||
test('onChange fires and unsubscribes', () {
|
||||
final store = MemoryAuthStore();
|
||||
final seen = <String>[];
|
||||
final off = store.onChange(seen.add);
|
||||
store.save('a');
|
||||
off();
|
||||
store.save('b');
|
||||
expect(seen, ['a']);
|
||||
});
|
||||
});
|
||||
|
||||
group('construction', () {
|
||||
test('defaults to api.hanzo.ai and strips trailing slash', () {
|
||||
final c = clientWith((_) async => http.Response('{}', 200));
|
||||
expect(c.baseUrl, 'https://api.hanzo.ai');
|
||||
final c2 = HanzoCloud(baseUrl: 'https://example.test/');
|
||||
expect(c2.baseUrl, 'https://example.test');
|
||||
});
|
||||
|
||||
test('token seeds the auth store', () {
|
||||
final c = clientWith((_) async => http.Response('{}', 200), token: 'tok');
|
||||
expect(c.authStore.token, 'tok');
|
||||
});
|
||||
});
|
||||
|
||||
group('bearer auth', () {
|
||||
test('adds Authorization: Bearer <token>', () async {
|
||||
String? seenAuth;
|
||||
final c = clientWith((r) async {
|
||||
seenAuth = r.headers['Authorization'];
|
||||
return http.Response('{}', 200);
|
||||
}, token: jwt({'exp': 9999999999}));
|
||||
await c.health();
|
||||
expect(seenAuth, startsWith('Bearer '));
|
||||
expect(seenAuth, 'Bearer ${c.authStore.token}');
|
||||
});
|
||||
|
||||
test('no header after signOut', () async {
|
||||
String? seenAuth;
|
||||
final c = clientWith((r) async {
|
||||
seenAuth = r.headers['Authorization'];
|
||||
return http.Response('{}', 200);
|
||||
}, token: 'tok');
|
||||
c.signOut();
|
||||
await c.health();
|
||||
expect(seenAuth, isNull);
|
||||
});
|
||||
});
|
||||
|
||||
group('agents', () {
|
||||
test('list unwraps { agents }', () async {
|
||||
final c = clientWith((r) async {
|
||||
expect(r.url.path, '/v1/agents');
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
'agents': [
|
||||
{
|
||||
'id': 'agent_1',
|
||||
'name': 'a',
|
||||
'tools': ['search']
|
||||
}
|
||||
]
|
||||
}),
|
||||
200);
|
||||
});
|
||||
final agents = await c.agents.list();
|
||||
expect(agents.single.id, 'agent_1');
|
||||
expect(agents.single.tools, ['search']);
|
||||
});
|
||||
|
||||
test('create posts cleaned body', () async {
|
||||
final c = clientWith((r) async {
|
||||
expect(r.method, 'POST');
|
||||
final body = jsonDecode(r.body) as Map;
|
||||
expect(body['model'], 'zen-coder');
|
||||
expect(body.containsKey('description'), isFalse); // null stripped
|
||||
return http.Response(jsonEncode({'id': 'agent_2', 'name': 'w'}), 201);
|
||||
});
|
||||
final agent = await c.agents.create(name: 'w', model: 'zen-coder');
|
||||
expect(agent.id, 'agent_2');
|
||||
});
|
||||
|
||||
test('get encodes ref and parses detail', () async {
|
||||
final c = clientWith((r) async {
|
||||
expect(r.url.path, '/v1/agents/has%20space');
|
||||
return http.Response(
|
||||
jsonEncode({'id': 'agent_2', 'instructions': 'hi'}), 200);
|
||||
});
|
||||
final detail = await c.agents.get('has space');
|
||||
expect(detail.instructions, 'hi');
|
||||
});
|
||||
|
||||
test('run returns a Run', () async {
|
||||
final c = clientWith((r) async {
|
||||
expect(r.url.path, '/v1/agents/w/run');
|
||||
expect(jsonDecode(r.body)['input'], 'go');
|
||||
return http.Response(
|
||||
jsonEncode({'id': 'run_1', 'status': 'ok', 'output': 'done'}), 200);
|
||||
});
|
||||
final run = await c.agents.run('w', input: 'go');
|
||||
expect(run.status, 'ok');
|
||||
expect(run.output, 'done');
|
||||
});
|
||||
|
||||
test('runs applies limit and unwraps', () async {
|
||||
final c = clientWith((r) async {
|
||||
expect(r.url.queryParameters['limit'], '5');
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
'runs': [
|
||||
{'id': 'run_1'},
|
||||
{'id': 'run_2'}
|
||||
]
|
||||
}),
|
||||
200);
|
||||
});
|
||||
final runs = await c.agents.runs('w', limit: 5);
|
||||
expect(runs.length, 2);
|
||||
});
|
||||
|
||||
test('delete sends DELETE', () async {
|
||||
var called = false;
|
||||
final c = clientWith((r) async {
|
||||
called = r.method == 'DELETE';
|
||||
return http.Response('', 204);
|
||||
});
|
||||
await c.agents.delete('w');
|
||||
expect(called, isTrue);
|
||||
});
|
||||
});
|
||||
|
||||
group('sessions', () {
|
||||
test('register posts agent', () async {
|
||||
final c = clientWith((r) async {
|
||||
expect(r.url.path, '/v1/agents/sessions');
|
||||
expect(jsonDecode(r.body)['agent'], 'w');
|
||||
return http.Response(
|
||||
jsonEncode({'id': 'sess_1', 'status': 'running'}), 201);
|
||||
});
|
||||
final s = await c.sessions.register(agent: 'w');
|
||||
expect(s.id, 'sess_1');
|
||||
});
|
||||
|
||||
test('list applies filters', () async {
|
||||
late Uri seen;
|
||||
final c = clientWith((r) async {
|
||||
seen = r.url;
|
||||
return http.Response(jsonEncode({'sessions': <Object>[]}), 200);
|
||||
});
|
||||
await c.sessions.list(status: 'running', root: 'sess_1', limit: 50);
|
||||
expect(seen.queryParameters['status'], 'running');
|
||||
expect(seen.queryParameters['root'], 'sess_1');
|
||||
expect(seen.queryParameters['limit'], '50');
|
||||
});
|
||||
|
||||
test('pause hits control path and parses result', () async {
|
||||
final c = clientWith((r) async {
|
||||
expect(r.url.path, '/v1/agents/sessions/sess_1/pause');
|
||||
return http.Response(
|
||||
jsonEncode({'command': 'pause', 'forwarded': true}), 200);
|
||||
});
|
||||
final res = await c.sessions.pause('sess_1');
|
||||
expect(res.command, 'pause');
|
||||
expect(res.forwarded, isTrue);
|
||||
});
|
||||
|
||||
test('tree parses recursive nodes', () async {
|
||||
final c = clientWith((r) async {
|
||||
expect(r.url.path, '/v1/agents/sessions/sess_1/tree');
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
'session': {'id': 'sess_1'},
|
||||
'children': [
|
||||
{
|
||||
'session': {'id': 'sess_2'},
|
||||
'children': <Object>[]
|
||||
}
|
||||
]
|
||||
}),
|
||||
200);
|
||||
});
|
||||
final tree = await c.sessions.tree('sess_1');
|
||||
expect(tree.session.id, 'sess_1');
|
||||
expect(tree.children.single.session.id, 'sess_2');
|
||||
});
|
||||
});
|
||||
|
||||
group('machines', () {
|
||||
test('list sends pageSize + p and parses list', () async {
|
||||
late Uri seen;
|
||||
final c = clientWith((r) async {
|
||||
seen = r.url;
|
||||
return http.Response(
|
||||
jsonEncode([
|
||||
{'id': 'org/m1', 'name': 'm1'}
|
||||
]),
|
||||
200);
|
||||
});
|
||||
final machines = await c.machines.list(pageSize: 20, page: 2);
|
||||
expect(seen.queryParameters['pageSize'], '20');
|
||||
expect(seen.queryParameters['p'], '2');
|
||||
expect(machines.single.name, 'm1');
|
||||
});
|
||||
|
||||
test('add returns CloudResponse envelope', () async {
|
||||
final c = clientWith((r) async {
|
||||
expect(r.url.path, '/v1/cloud/add-machine');
|
||||
expect(jsonDecode(r.body)['name'], 'm2');
|
||||
return http.Response(jsonEncode({'status': 'ok', 'msg': ''}), 200);
|
||||
});
|
||||
final res = await c.machines.add(Machine(name: 'm2', region: 'sfo3'));
|
||||
expect(res.ok, isTrue);
|
||||
});
|
||||
});
|
||||
|
||||
group('errors', () {
|
||||
test('non-2xx throws HanzoException with status + message', () async {
|
||||
final c = clientWith((r) async =>
|
||||
http.Response(jsonEncode({'message': 'Insufficient credit'}), 402));
|
||||
expect(
|
||||
() => c.agents.run('w'),
|
||||
throwsA(isA<HanzoException>()
|
||||
.having((e) => e.statusCode, 'statusCode', 402)
|
||||
.having((e) => e.message, 'message', 'Insufficient credit')),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user