Skip to content
Open
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
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,23 @@ const x = await agent.search.x({ query: "deva ai", max_results: 10 });
const tweets = await agent.search.xUserTweets({ username: "deva_ai", limit: 5 });
```

### Resources (`/v1/agents/resources/catalog`, `/run`, `/estimate`)

Discover the authenticated catalog, inspect a resource's JSON Schema, estimate cost, then run supported resources through the generic rail.

```ts
const catalog = await agent.resources.list();
const webSearch = await agent.resources.inspect("web_search");

if (webSearch.supports_generic_run) {
const params = { query: "latest Deva agent resources", count: 5 };
const estimate = await agent.resources.estimate(webSearch.id, params);
const run = await agent.resources.run(webSearch.id, params);

console.log(estimate.estimated_karma_cost, run.karma_charged, run.result);
}
```

### Files (`/v1/agents/files/*`)

```ts
Expand Down
2 changes: 2 additions & 0 deletions src/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export class DevaAgent {
public readonly models: DevaClient["models"];
public readonly discover: DevaClient["discover"];
public readonly profile: DevaClient["profile"];
public readonly resources: DevaClient["resources"];
public readonly wallet: DevaClient["wallet"];
public readonly embeddings: DevaClient["embeddings"];
public readonly transcription: DevaClient["transcription"];
Expand All @@ -46,6 +47,7 @@ export class DevaAgent {
this.models = this.client.models;
this.discover = this.client.discover;
this.profile = this.client.profile;
this.resources = this.client.resources;
this.wallet = this.client.wallet;
this.embeddings = this.client.embeddings;
this.transcription = this.client.transcription;
Expand Down
3 changes: 3 additions & 0 deletions src/deva-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { KvResource } from "./resources/kv.js";
import { MessagingResource } from "./resources/messaging.js";
import { ModelsResource } from "./resources/models.js";
import { ProfileResource } from "./resources/profile.js";
import { ResourcesResource } from "./resources/resources.js";
import { SearchResource } from "./resources/search.js";
import { SocialResource } from "./resources/social.js";
import { TranscriptionResource } from "./resources/transcription.js";
Expand All @@ -31,6 +32,7 @@ export class DevaClient {
public readonly models: ModelsResource;
public readonly discover: DiscoverResource;
public readonly profile: ProfileResource;
public readonly resources: ResourcesResource;
public readonly wallet: WalletResource;
public readonly embeddings: EmbeddingsResource;
public readonly transcription: TranscriptionResource;
Expand All @@ -50,6 +52,7 @@ export class DevaClient {
this.models = new ModelsResource(this.http);
this.discover = new DiscoverResource(this.http);
this.profile = new ProfileResource(this.http);
this.resources = new ResourcesResource(this.http);
this.wallet = new WalletResource(this.http);
this.embeddings = new EmbeddingsResource(this.http);
this.transcription = new TranscriptionResource(this.http);
Expand Down
10 changes: 8 additions & 2 deletions src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,8 +101,14 @@ export function normalizeErrorEnvelope(status: number, body: unknown): Normalize

if (body && typeof body === "object") {
const root = body as Record<string, unknown>;
const err = (root.error ?? root) as Record<string, unknown>;
code = typeof err.code === "string" ? err.code : undefined;
const detail = root.detail;
const err =
root.error && typeof root.error === "object"
? (root.error as Record<string, unknown>)
: detail && typeof detail === "object"
? (detail as Record<string, unknown>)
: root;
code = typeof err.code === "string" ? err.code : typeof err.error === "string" ? err.error : undefined;
message = typeof err.message === "string" ? err.message : undefined;
type = typeof err.type === "string" ? err.type : undefined;
details = err.details;
Expand Down
1 change: 1 addition & 0 deletions src/resources/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export * from "./kv.js";
export * from "./messaging.js";
export * from "./models.js";
export * from "./profile.js";
export * from "./resources.js";
export * from "./search.js";
export * from "./social.js";
export * from "./transcription.js";
Expand Down
53 changes: 53 additions & 0 deletions src/resources/resources.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { DevaHttpClient } from "../client.js";
import type {
ResourceCatalogResponse,
ResourceEstimateResponse,
ResourceInfo,
ResourceRunResponse
} from "../types.js";

export type ResourceRunParams = Record<string, unknown>;
export type ResourceEstimateParams = Record<string, unknown>;

/** Generic discover, inspect, estimate, and run APIs for agent resources. */
export class ResourcesResource {
constructor(private readonly client: DevaHttpClient) {}

/** Lists the authenticated resource catalog via `GET /v1/agents/resources/catalog`. */
async list(): Promise<ResourceInfo[]> {
const response = await this.client.request<ResourceCatalogResponse>({
method: "GET",
path: "/v1/agents/resources/catalog"
});
return response.resources;
}

/** Inspects a single resource via `GET /v1/agents/resources/catalog/{resource_id}`. */
inspect(resourceId: string): Promise<ResourceInfo> {
return this.client.request<ResourceInfo>({
method: "GET",
path: `/v1/agents/resources/catalog/${encodeURIComponent(resourceId)}`
});
}

/** Estimates the karma cost for a planned resource call via `POST /v1/agents/resources/estimate`. */
estimate(resourceId: string, params: ResourceEstimateParams = {}): Promise<ResourceEstimateResponse> {
return this.client.request<ResourceEstimateResponse>({
method: "POST",
path: "/v1/agents/resources/estimate",
body: {
resource_id: resourceId,
params
}
});
}

/** Runs a generic-supported resource via `POST /v1/agents/resources/{resource_id}/run`. */
run<T = Record<string, unknown>>(resourceId: string, params: ResourceRunParams): Promise<ResourceRunResponse<T>> {
return this.client.request<ResourceRunResponse<T>>({
method: "POST",
path: `/v1/agents/resources/${encodeURIComponent(resourceId)}/run`,
body: params
});
}
}
61 changes: 58 additions & 3 deletions src/types.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,69 @@
export type HttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE";

/** Resource availability status returned by the catalog endpoint. */
export type ResourceStatus = "AVAILABLE" | "UNAVAILABLE" | "DEGRADED";

export type JsonPrimitive = string | number | boolean | null;
export type JsonValue = JsonPrimitive | JsonObject | JsonValue[];
export interface JsonObject {
[key: string]: JsonValue;
}

/** Resource category returned by the agent resources catalog. */
export type ResourceCategory = "ai" | "infrastructure" | "communication" | "utility" | "storage";

/** Resource availability status returned by the agent resources catalog. */
export type ResourceStatus = "available" | "degraded" | "unavailable" | "coming_soon" | "maintenance";

export type JsonSchema = JsonObject;

export interface ResourcePricing {
unit: string;
karma_cost: number;
details?: string | null;
[key: string]: unknown;
}

export interface ResourceInfo {
id: string;
name: string;
category: ResourceCategory;
description: string;
endpoint: string;
provider?: string | null;
method: string;
pricing: ResourcePricing;
status: ResourceStatus;
auth_required: boolean;
rate_limit?: string | null;
input_schema: JsonSchema;
output_description: string;
supports_generic_run: boolean;
run_endpoint?: string | null;
[key: string]: unknown;
}

export interface ResourceCatalogResponse {
success?: boolean;
resources: ResourceInfo[];
total?: number;
[key: string]: unknown;
}

export interface ResourceEstimateResponse {
success?: boolean;
resource_id: string;
estimated_karma_cost: number;
unit: string;
breakdown?: JsonObject | null;
[key: string]: unknown;
}

export interface ResourceRunResponse<T = Record<string, unknown>> {
success?: boolean;
resource_id: string;
karma_charged: number;
result: T;
[key: string]: unknown;
}

export interface DevaClientOptions {
apiKey?: string;
apiBase?: string;
Expand Down
146 changes: 146 additions & 0 deletions test/resources.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { DevaClient, InvalidRequestError } from "../dist/esm/index.js";

interface CapturedRequest {
url: string;
method?: string;
body?: unknown;
authorization?: string | null;
}

function captureFetch(responseBody: unknown, status = 200): { fetch: typeof fetch; requests: CapturedRequest[] } {
const requests: CapturedRequest[] = [];
const fetchImpl = (async (input: RequestInfo | URL, init?: RequestInit) => {
const rawBody = typeof init?.body === "string" ? JSON.parse(init.body) : undefined;
requests.push({
url: String(input),
method: init?.method,
body: rawBody,
authorization: new Headers(init?.headers).get("authorization")
});

return new Response(JSON.stringify(responseBody), {
status,
headers: { "content-type": "application/json" }
});
}) as unknown as typeof fetch;

return { fetch: fetchImpl, requests };
}

const WEB_SEARCH_RESOURCE = {
id: "web_search",
name: "Web Search",
category: "utility",
description: "Search the web",
endpoint: "/v1/agents/resources/search",
provider: "openrouter",
method: "POST",
pricing: { unit: "per search", karma_cost: 8 },
status: "available",
auth_required: true,
rate_limit: null,
input_schema: {
type: "object",
properties: {
query: { type: "string" },
count: { type: "integer", minimum: 1, maximum: 20 }
},
required: ["query"]
},
output_description: "Search results, result count, normalized query, and karma_cost.",
supports_generic_run: true,
run_endpoint: "/v1/agents/resources/web_search/run"
};

test("resources.list GETs the catalog and returns ResourceInfo[]", async () => {
const r = captureFetch({ success: true, resources: [WEB_SEARCH_RESOURCE], total: 1 });
const client = new DevaClient({ apiBase: "http://localhost:8000", apiKey: "deva_test", fetch: r.fetch });

const resources = await client.resources.list();

assert.deepEqual(resources, [WEB_SEARCH_RESOURCE]);
assert.equal(r.requests[0].method, "GET");
assert.equal(r.requests[0].authorization, "Bearer deva_test");
assert.ok(r.requests[0].url.endsWith("/v1/agents/resources/catalog"), r.requests[0].url);
});

test("resources.inspect GETs /catalog/{resource_id}", async () => {
const r = captureFetch(WEB_SEARCH_RESOURCE);
const client = new DevaClient({ apiBase: "http://localhost:8000", apiKey: "deva_test", fetch: r.fetch });

const resource = await client.resources.inspect("web_search");

assert.equal(resource.id, "web_search");
assert.equal(resource.supports_generic_run, true);
assert.deepEqual(resource.input_schema.properties?.query, WEB_SEARCH_RESOURCE.input_schema.properties.query);
assert.equal(r.requests[0].method, "GET");
assert.ok(r.requests[0].url.endsWith("/v1/agents/resources/catalog/web_search"), r.requests[0].url);
});

test("resources.estimate POSTs the estimate envelope", async () => {
const r = captureFetch({
success: true,
resource_id: "web_search",
estimated_karma_cost: 8,
unit: "per search",
breakdown: { count: 5 }
});
const client = new DevaClient({ apiBase: "http://localhost:8000", apiKey: "deva_test", fetch: r.fetch });

const estimate = await client.resources.estimate("web_search", { query: "deva", count: 5 });

assert.equal(estimate.estimated_karma_cost, 8);
assert.equal(r.requests[0].method, "POST");
assert.deepEqual(r.requests[0].body, {
resource_id: "web_search",
params: { query: "deva", count: 5 }
});
assert.ok(r.requests[0].url.endsWith("/v1/agents/resources/estimate"), r.requests[0].url);
});

test("resources.run POSTs params to /{resource_id}/run", async () => {
const r = captureFetch({
success: true,
resource_id: "web_search",
karma_charged: 8,
result: {
results: [{ title: "Deva", url: "https://deva.me" }],
karma_cost: 8
}
});
const client = new DevaClient({ apiBase: "http://localhost:8000", apiKey: "deva_test", fetch: r.fetch });

const run = await client.resources.run("web_search", { query: "deva", count: 1 });

assert.equal(run.resource_id, "web_search");
assert.equal(run.karma_charged, 8);
assert.equal(run.result.karma_cost, 8);
assert.equal(r.requests[0].method, "POST");
assert.deepEqual(r.requests[0].body, { query: "deva", count: 1 });
assert.ok(r.requests[0].url.endsWith("/v1/agents/resources/web_search/run"), r.requests[0].url);
});

test("resources.run against an unsupported id throws InvalidRequestError with backend code", async () => {
const r = captureFetch(
{
detail: {
error: "RESOURCE_RUN_UNSUPPORTED",
message: "Generic run is not supported for this resource yet."
}
},
400
);
const client = new DevaClient({ apiBase: "http://localhost:8000", apiKey: "deva_test", fetch: r.fetch });

await assert.rejects(
() => client.resources.run("email", { to: ["user@example.com"], subject: "Hi", body_text: "Hello" }),
(error: unknown) =>
error instanceof InvalidRequestError &&
error.status === 400 &&
error.code === "RESOURCE_RUN_UNSUPPORTED" &&
error.message === "Generic run is not supported for this resource yet."
);
assert.ok(r.requests[0].url.endsWith("/v1/agents/resources/email/run"), r.requests[0].url);
});