@soba-so/sdk 0.0.7 → 0.0.8
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.
- package/README.md +28 -1
- package/dist/client.d.ts +64 -5
- package/dist/http.d.ts +11 -2
- package/dist/identity.d.ts +49 -0
- package/dist/identity.mjs +1 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.mjs +4 -4
- package/dist/protocol.d.ts +3 -1
- package/dist/resources.d.ts +18 -0
- package/dist/version.d.ts +1 -1
- package/dist/webhooks.d.ts +56 -0
- package/package.json +5 -1
package/README.md
CHANGED
|
@@ -4,11 +4,13 @@
|
|
|
4
4
|
|
|
5
5
|
```js
|
|
6
6
|
import { Soba } from "@soba-so/sdk"
|
|
7
|
+
import { sobaUserId } from "@soba-so/sdk/identity"
|
|
7
8
|
|
|
8
9
|
const soba = new Soba({ apiKey: process.env.SOBA_KEY })
|
|
9
10
|
|
|
10
11
|
const run = await soba.run({
|
|
11
|
-
|
|
12
|
+
// Stable across database resets — see "Identifying users" below.
|
|
13
|
+
user: sobaUserId(session.user.username),
|
|
12
14
|
model: "auto",
|
|
13
15
|
prompt: "Summarise everything I saved this week",
|
|
14
16
|
tools: [listNotes, readNote], // your code, executed in your app
|
|
@@ -20,6 +22,31 @@ for await (const event of run) {
|
|
|
20
22
|
}
|
|
21
23
|
```
|
|
22
24
|
|
|
25
|
+
## Identifying users
|
|
26
|
+
|
|
27
|
+
`user` is load-bearing: it is how a run is attributed to a person, therefore how
|
|
28
|
+
it is metered, therefore how Soba knows whose machine it may reach. Soba keys an
|
|
29
|
+
end user on `(app, user, environment)` and nothing else.
|
|
30
|
+
|
|
31
|
+
Resolve it **on the server** from the session — a `user` accepted from the
|
|
32
|
+
browser lets anyone run on anyone else's machine — and send the **same** id for
|
|
33
|
+
the run and for the connect session.
|
|
34
|
+
|
|
35
|
+
And pick one that outlives your database. `session.user.id` is usually a random
|
|
36
|
+
key minted at sign-up; it is stable only as long as that row is. Reset or move
|
|
37
|
+
the database and the same person arrives as somebody new, leaving the machine
|
|
38
|
+
they paired attached to an id you will never send again. If your own ids are not
|
|
39
|
+
stable, derive one:
|
|
40
|
+
|
|
41
|
+
```js
|
|
42
|
+
import { sobaUserId } from "@soba-so/sdk/identity"
|
|
43
|
+
|
|
44
|
+
const user = sobaUserId(session.user.username)
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
A server-only subpath, so reaching for it in client code fails at your bundler
|
|
48
|
+
rather than in production. Full reasoning: https://soba.so/docs/identity
|
|
49
|
+
|
|
23
50
|
## Why this exists alongside `chat/completions`
|
|
24
51
|
|
|
25
52
|
`POST /v1/chat/completions` is stateless and **the caller owns the loop**: you
|
package/dist/client.d.ts
CHANGED
|
@@ -39,6 +39,17 @@ export interface RunParams {
|
|
|
39
39
|
* The end user this run belongs to. Load-bearing, and the SDK refuses without
|
|
40
40
|
* it: it is how a run is attributed to a person, therefore how it is metered,
|
|
41
41
|
* and therefore how Soba knows whose machine it may reach.
|
|
42
|
+
*
|
|
43
|
+
* Soba keys an end user on `(app, user, environment)` and nothing else, so
|
|
44
|
+
* the string has to be stable for as long as the person is. A primary key
|
|
45
|
+
* minted at sign-up is not: reset or move your database and the same person
|
|
46
|
+
* arrives as somebody new, stranding the machine they paired on an id you
|
|
47
|
+
* will never send again. If your own ids are not stable, derive one —
|
|
48
|
+
* `sobaUserId` from `@soba-so/sdk/identity` — and send that. Resolve it on
|
|
49
|
+
* the server from the session; a `user` accepted from the browser lets
|
|
50
|
+
* anyone run on anyone else's machine.
|
|
51
|
+
*
|
|
52
|
+
* https://soba.so/docs/identity
|
|
42
53
|
*/
|
|
43
54
|
user: string;
|
|
44
55
|
/** A single user turn. Shorthand for `messages: [{role:"user", content}]`. */
|
|
@@ -63,12 +74,37 @@ export interface RunParams {
|
|
|
63
74
|
metadata?: Record<string, string>;
|
|
64
75
|
signal?: AbortSignal;
|
|
65
76
|
}
|
|
77
|
+
/**
|
|
78
|
+
* A DETACHED run: answered with its id at once, its events and tool calls
|
|
79
|
+
* delivered to `webhook` instead of streamed back. For a run that outlives the
|
|
80
|
+
* request that starts it, like a session somebody works in for an hour. Served
|
|
81
|
+
* only by one of the user's own machines.
|
|
82
|
+
*
|
|
83
|
+
* The app's webhook route verifies each delivery with `verifyWebhook` and
|
|
84
|
+
* answers it with `handleRunWebhook`, passing the same tools.
|
|
85
|
+
*/
|
|
86
|
+
export interface DetachedRunParams extends Omit<RunParams, "approve" | "signal"> {
|
|
87
|
+
webhook: {
|
|
88
|
+
url: string;
|
|
89
|
+
secret: string;
|
|
90
|
+
};
|
|
91
|
+
/** `terminal`: open it as the machine owner's own interactive session, in a
|
|
92
|
+
* window they can watch and type into. The machine must allow it. */
|
|
93
|
+
surface?: "headless" | "terminal";
|
|
94
|
+
/** What the terminal window is called. */
|
|
95
|
+
title?: string;
|
|
96
|
+
/** Work in a git worktree on this branch, inside `policy.cwd`. */
|
|
97
|
+
worktree?: {
|
|
98
|
+
branch: string;
|
|
99
|
+
};
|
|
100
|
+
}
|
|
66
101
|
/**
|
|
67
102
|
* The Soba client.
|
|
68
103
|
*
|
|
69
104
|
* ```js
|
|
70
105
|
* const soba = new Soba({ apiKey: process.env.SOBA_KEY })
|
|
71
|
-
* const
|
|
106
|
+
* const user = sobaUserId(session.user.username) // see @soba-so/sdk/identity
|
|
107
|
+
* const run = await soba.run({ user, prompt: "…" })
|
|
72
108
|
* for await (const event of run) …
|
|
73
109
|
* ```
|
|
74
110
|
*
|
|
@@ -83,16 +119,27 @@ export declare class Soba {
|
|
|
83
119
|
readonly users: {
|
|
84
120
|
/** What this user has: plan, allowance, usage this period, what they connected. */
|
|
85
121
|
get(id: string): Promise<SobaUser>;
|
|
86
|
-
/**
|
|
87
|
-
|
|
88
|
-
*
|
|
122
|
+
/**
|
|
123
|
+
* Each connected machine, its runtimes, cost classes and live state, in the
|
|
124
|
+
* SAME shape `<ComputeStatus />` renders, so a server can pass it to
|
|
125
|
+
* `<SobaProvider initialMachines>`.
|
|
126
|
+
*
|
|
127
|
+
* NOT IN A ROOT LAYOUT, AND NOT ON EVERY RENDER. The browser provider reads
|
|
128
|
+
* this itself when it mounts, so a server read only saves a loading frame —
|
|
129
|
+
* and in a layout it is a round trip to Soba on every full load, refresh
|
|
130
|
+
* and revalidating action of every page, which with the default timeout
|
|
131
|
+
* and retries can hold a page for a minute and a half if Soba is slow. To
|
|
132
|
+
* seed one page that shows compute, read it inside `<Suspense>` with a
|
|
133
|
+
* client built for it: `new Soba({ maxRetries: 0, connectTimeoutMs: 1500 })`.
|
|
134
|
+
*/
|
|
89
135
|
compute(id: string): Promise<MachineListItem[]>;
|
|
90
136
|
};
|
|
91
137
|
readonly usage: {
|
|
92
138
|
list(query?: UsageQuery): Promise<UsagePage>;
|
|
93
139
|
};
|
|
94
140
|
/** The app's name and connect key — the frame a compute panel renders
|
|
95
|
-
* around before it has anything to show.
|
|
141
|
+
* around before it has anything to show. The browser provider reads it on
|
|
142
|
+
* mount; the same caveat as `users.compute` applies to reading it here. */
|
|
96
143
|
readonly connect: () => Promise<ConnectContextResponse>;
|
|
97
144
|
readonly plans: {
|
|
98
145
|
/**
|
|
@@ -103,11 +150,23 @@ export declare class Soba {
|
|
|
103
150
|
*
|
|
104
151
|
* `user` is optional and is the same external id a run is attributed to.
|
|
105
152
|
* Pass it to learn which plan that person is on.
|
|
153
|
+
*
|
|
154
|
+
* The same caveat as `users.compute`: not in a root layout or on every
|
|
155
|
+
* render. The provider loads plans itself, and only once something on the
|
|
156
|
+
* page reads them.
|
|
106
157
|
*/
|
|
107
158
|
list(options?: {
|
|
108
159
|
user?: string;
|
|
109
160
|
}): Promise<PlanListResponse>;
|
|
110
161
|
};
|
|
162
|
+
/** Detached runs. See `DetachedRunParams`. */
|
|
163
|
+
readonly runs: {
|
|
164
|
+
start(params: DetachedRunParams): Promise<{
|
|
165
|
+
id: string;
|
|
166
|
+
}>;
|
|
167
|
+
/** Stop a run. For a terminal run, the window closes. */
|
|
168
|
+
cancel(id: string): Promise<void>;
|
|
169
|
+
};
|
|
111
170
|
constructor(options?: SobaOptions);
|
|
112
171
|
get baseURL(): string;
|
|
113
172
|
/**
|
package/dist/http.d.ts
CHANGED
|
@@ -16,8 +16,17 @@ export interface RequestOptions {
|
|
|
16
16
|
body?: unknown;
|
|
17
17
|
accept?: string;
|
|
18
18
|
signal?: AbortSignal;
|
|
19
|
-
/**
|
|
20
|
-
|
|
19
|
+
/**
|
|
20
|
+
* Default true. Set false for anything that must happen at most once.
|
|
21
|
+
*
|
|
22
|
+
* `"unsent"` for a request that must happen at most once but is worth
|
|
23
|
+
* another try when it PROVABLY never arrived: the connection was refused or
|
|
24
|
+
* the name did not resolve, or the server answered 429 — a refusal made
|
|
25
|
+
* before any work. Not a timeout, not a dropped socket, not a 5xx: each of
|
|
26
|
+
* those can follow a run the gateway already started, and a retry there is
|
|
27
|
+
* a second run, billed as one. `POST /runs` uses this.
|
|
28
|
+
*/
|
|
29
|
+
retry?: boolean | "unsent";
|
|
21
30
|
}
|
|
22
31
|
export declare function httpRequest(cfg: HttpConfig, opts: RequestOptions): Promise<Response>;
|
|
23
32
|
export declare function requestJson<T>(cfg: HttpConfig, opts: RequestOptions): Promise<T>;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A STABLE `user` FOR SOBA, DERIVED FROM SOMETHING THAT OUTLIVES YOUR DATABASE.
|
|
3
|
+
*
|
|
4
|
+
* Every Soba call takes a `user`, and Soba keys an end user on
|
|
5
|
+
* `(app, user, environment)` and nothing else. It is opaque to us on purpose —
|
|
6
|
+
* whatever string you send names a person, and a string we have never seen
|
|
7
|
+
* names a person we have never met. That is the right behaviour and it is also
|
|
8
|
+
* the trap, because the most obvious thing to send is the worst thing to send.
|
|
9
|
+
*
|
|
10
|
+
* `session.user.id` is usually a random key minted at sign-up and stored in a
|
|
11
|
+
* row. It is stable exactly as long as that row is. Reset the database, migrate
|
|
12
|
+
* it, or stand up a second instance of your app, and the same person signs in,
|
|
13
|
+
* is issued a fresh key, and arrives here as somebody new. The laptop they
|
|
14
|
+
* paired last week stays attached to the id they no longer have, so your Users
|
|
15
|
+
* page shows them twice: once holding the machine, once on metered compute with
|
|
16
|
+
* no runs. Soba cannot detect this. `email` travels with a session but it is a
|
|
17
|
+
* LABEL on that page, not an identifier — two ids carrying one address are two
|
|
18
|
+
* people, drawn on two rows under one name.
|
|
19
|
+
*
|
|
20
|
+
* So: if your own ids are stable, send them and ignore this function. If they
|
|
21
|
+
* are not, derive one from something that is.
|
|
22
|
+
*
|
|
23
|
+
* import { sobaUserId } from "@soba-so/sdk/identity"
|
|
24
|
+
*
|
|
25
|
+
* const user = sobaUserId(session.user.username)
|
|
26
|
+
* await soba.run({ user, prompt })
|
|
27
|
+
*
|
|
28
|
+
* USE THE SAME CALL FOR THE CONNECT SESSION. The person who pairs a laptop and
|
|
29
|
+
* the person a run is attributed to have to be one row, so every Soba boundary
|
|
30
|
+
* in your app goes through here. There is no second formula.
|
|
31
|
+
*
|
|
32
|
+
* SERVER ONLY, WHICH IS WHY THIS IS A SUBPATH. Resolving the id in the browser
|
|
33
|
+
* means accepting it from the browser, and then anyone can claim to be anyone
|
|
34
|
+
* and run on their machine. The import lives at `@soba-so/sdk/identity` rather
|
|
35
|
+
* than on the main entry so that reaching for it in client code fails at the
|
|
36
|
+
* bundler instead of in production — and so the main bundle stays free of node
|
|
37
|
+
* builtins for the runtimes that have none.
|
|
38
|
+
*/
|
|
39
|
+
export declare function sobaUserId(stableKey: string): string;
|
|
40
|
+
/**
|
|
41
|
+
* The normalisation, exported so a caller can tell whether two inputs are the
|
|
42
|
+
* same person before deciding what to store.
|
|
43
|
+
*
|
|
44
|
+
* Case and surrounding whitespace only. Anything cleverer — stripping dots,
|
|
45
|
+
* folding plus-addressing, unicode normalisation — would be a guess about what
|
|
46
|
+
* your app considers one account, and a wrong guess merges two people rather
|
|
47
|
+
* than splitting one.
|
|
48
|
+
*/
|
|
49
|
+
export declare function normalizeKey(stableKey: string): string;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{createHash as t}from"node:crypto";function i(r){return`usr_${t("sha256").update(e(r)).digest("hex").slice(0,12)}`}function e(r){return r.trim().toLowerCase()}export{e as normalizeKey,i as sobaUserId};
|
package/dist/index.d.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
export { DEFAULT_BASE_URL, type RunParams, Soba, type SobaOptions } from "./client.js";
|
|
1
|
+
export { DEFAULT_BASE_URL, type DetachedRunParams, type RunParams, Soba, type SobaOptions, } from "./client.js";
|
|
2
2
|
export { SobaError, type SobaErrorCode } from "./errors.js";
|
|
3
3
|
export { type AgentEvent, type AgentEventType, type CostClass, isCostClass, isMetered, isTerminal, METERED_CLASSES, type Message, type PermissionMode, type RouteRequest, type RunPolicy, type RunUsage, TOOL_SLUGS, type ToolSchema, type ToolSlug, } from "./protocol.js";
|
|
4
4
|
export type { Allowance, ConnectContextResponse, ConnectedMachine, CurrentPlanItem, MachineListItem, MachineListState, MachineRuntime, PlanListItem, PlanListResponse, PlanSummary, RuntimeListItem, SobaUser, UsagePage, UsageQuery, UsageRow, } from "./resources.js";
|
|
5
5
|
export { type ApprovalDecision, type ApprovalRequest, type Approver, Run, } from "./run.js";
|
|
6
6
|
export { type ToolContext, type ToolDefinition, tool } from "./tools.js";
|
|
7
7
|
export { VERSION } from "./version.js";
|
|
8
|
+
export { handleRunWebhook, type RunWebhookEvent, type ToolCallAnswer, verifyWebhook, } from "./webhooks.js";
|
package/dist/index.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
var
|
|
2
|
-
`);if(l===-1)break;let
|
|
3
|
-
`);if(
|
|
4
|
-
`);if(
|
|
1
|
+
var i=class extends Error{code;type;status;retryable;retryAfterMs;runId;constructor(e){super(e.message,e.cause===void 0?void 0:{cause:e.cause}),this.name="SobaError",this.code=e.code,this.type=e.type??e.code,this.status=e.status,this.retryable=e.retryable??F.has(e.code),this.retryAfterMs=e.retryAfterMs,this.runId=e.runId}},F=new Set(["rate_limited","server_error","connection","timeout","stream_truncated"]),W={400:"invalid_request",401:"unauthorized",402:"payment_required",403:"forbidden",404:"not_found",409:"no_compute",422:"user_required",429:"rate_limited"};function B(t){let e=W[t];return e||(t>=500?"server_error":t>=400?"invalid_request":"unknown")}async function P(t){let e=t.status,r="";try{r=await t.text()}catch{}let n=G(r);return new i({code:n?.code??B(e),type:n?.type,message:n?.message??H(e,t.statusText,r),status:e,retryAfterMs:J(t.headers.get("retry-after")),runId:t.headers.get("soba-run-id")??void 0})}function G(t){if(!t)return null;let e;try{e=JSON.parse(t)}catch{return null}if(!e||typeof e!="object")return null;let r=e.error;if(!r||typeof r!="object")return null;let n=r;return{message:typeof n.message=="string"&&n.message?n.message:void 0,type:typeof n.type=="string"&&n.type?n.type:void 0,code:K.has(n.code)?n.code:void 0}}var K=new Set(["invalid_request","user_required","unauthorized","payment_required","forbidden","not_found","no_compute","rate_limited","server_error","connection","timeout","stream_truncated","run_failed","canceled","unknown"]);function H(t,e,r){let n=r.trim().slice(0,200),o=n?`: ${n}`:"";return`Soba returned ${t}${e?` ${e}`:""}${o}`}function J(t){if(!t)return;let e=Number(t);if(Number.isFinite(e)&&e>=0)return Math.round(e*1e3);let r=Date.parse(t);if(!Number.isNaN(r))return Math.max(0,r-Date.now())}function h(t){if(t instanceof i)return t;let e=t;return e?.name==="AbortError"||e?.name==="TimeoutError"?new i({code:"canceled",message:e.message||"The request was aborted.",retryable:!1,cause:t}):new i({code:"connection",message:e?.message?`Could not reach Soba: ${e.message}`:"Could not reach Soba.",cause:t})}var R="0.0.8";var z=new Set(["ECONNREFUSED","ENOTFOUND","EAI_AGAIN"]);function Y(t){if(t.code==="rate_limited")return!0;if(t.code!=="connection")return!1;let e=t.cause;for(let r=0;e&&r<4;r++){let n=e.code;if(typeof n=="string"&&z.has(n))return!0;e=e.cause}return!1}var V=8e3,Q=3e4;async function y(t,e){let r=ee(t.baseURL,e.path,e.query),n=e.retry===!1?1:Math.max(0,t.maxRetries)+1,o,s=a=>a.retryable&&(e.retry!=="unsent"||Y(a));for(let a=0;a<n;a++){a>0&&await ne(te(a,o),e.signal);let u=new AbortController,g=x(e.signal,u),f=setTimeout(()=>{u.abort(new i({code:"timeout",message:`Soba did not answer within ${t.connectTimeoutMs}ms.`}))},t.connectTimeoutMs),l;try{l=await t.fetch(r,{method:e.method,headers:X(t,e),body:e.body===void 0?void 0:JSON.stringify(e.body),signal:u.signal})}catch(p){if(g(),o=h(u.signal.aborted?u.signal.reason??p:p),!s(o)||a===n-1)throw o;continue}finally{clearTimeout(f)}if(l.ok)return l;if(g(),o=await P(l),!s(o)||a===n-1)throw o}throw o??new i({code:"unknown",message:"The request failed with no error."})}async function E(t,e){let r=await y(t,{accept:"application/json",...e}),n=await r.text();if(n)try{return JSON.parse(n)}catch(o){throw new i({code:"server_error",message:`Soba returned a body that is not JSON: ${n.slice(0,200)}`,status:r.status,cause:o})}}function X(t,e){let r={authorization:`Bearer ${t.apiKey}`,accept:e.accept??"application/json","user-agent":Z(),...t.headers};return e.body!==void 0&&(r["content-type"]="application/json"),r}function Z(){let t=typeof process>"u"?"":` node/${process.versions?.node??"?"}`;return`soba-sdk/${R}${t}`}function ee(t,e,r){let n=t.replace(/\/+$/,""),o=new URL(`${n}${e.startsWith("/")?e:`/${e}`}`);for(let[s,a]of Object.entries(r??{}))a!==void 0&&o.searchParams.set(s,String(a));return o.toString()}function te(t,e){if(e?.retryAfterMs!==void 0)return Math.min(e.retryAfterMs,Q);let r=Math.min(V,500*2**(t-1));return Math.round(r*(.5+Math.random()/2))}function ne(t,e){return new Promise((r,n)=>{if(e?.aborted)return n(h(e.reason));let o=setTimeout(()=>{e?.removeEventListener("abort",s),r()},t);function s(){clearTimeout(o),n(h(e?.reason))}e?.addEventListener("abort",s,{once:!0})})}function x(t,e){if(!t)return()=>{};if(t.aborted)return e.abort(t.reason),()=>{};let r=()=>e.abort(t.reason);return t.addEventListener("abort",r,{once:!0}),()=>t.removeEventListener("abort",r)}var q=["open-weights","frontier"];function re(t){return q.includes(t)}function oe(t){return t==="user-hardware"||t==="user-subscription"||t==="user-api-key"||t==="open-weights"||t==="frontier"}function k(t){return t.type==="done"||t.type==="error"}var se=["search","fetch","read","write","edit","bash","files","agent","todo","think","sources","tool"];var L=new Set(["delta","thinking","status","done","error"]);function A(t){let e;try{e=JSON.parse(t)}catch{return null}if(!e||typeof e!="object"||Array.isArray(e))return null;let r=e,n=c(r.runId);if(typeof r.type=="string"&&L.has(r.type)){let o=U(r);return o?{kind:"event",runId:n,event:o}:null}switch(r.type){case"event":{if(!r.event||typeof r.event!="object")return null;let o=U(r.event);return o?{kind:"event",runId:n,event:o}:null}case"usage":{let o=D(r.usage);return o?{kind:"usage",runId:n,usage:o}:null}case"tool_call":{let o=c(r.callId),s=c(r.name);return!o||!s?null:{kind:"tool_call",runId:n,callId:o,name:s,input:r.input}}case"approval_request":{let o=c(r.approvalId),s=c(r.tool);return!o||!s?null:{kind:"approval",runId:n,approvalId:o,tool:s,input:r.input,cwd:c(r.cwd)}}default:return null}}function U(t){let e=typeof t.type=="string"&&L.has(t.type)?t.type:null;if(!e)return null;let r={type:e},n=c(t.text);n!==void 0&&(r.text=n);let o=c(t.message);o!==void 0&&(r.message=o);let s=c(t.tool);s!==void 0&&(r.tool=s);let a=c(t.tier);a!==void 0&&(r.tier=a);let u=D(t.usage);return u&&(r.usage=u),r}function D(t){if(!t||typeof t!="object")return null;let e=t,r={model:c(e.model)??"unknown",inputTokens:b(e.inputTokens),outputTokens:b(e.outputTokens),cacheReadTokens:b(e.cacheReadTokens),cacheWriteTokens:b(e.cacheWriteTokens),webSearches:b(e.webSearches)},n=c(e.costClass);n!==void 0&&(r.costClass=n);let o=c(e.provider);return o!==void 0&&(r.provider=o),typeof e.costMicros=="number"&&Number.isFinite(e.costMicros)&&(r.costMicros=e.costMicros),r}function c(t){return typeof t=="string"&&t.length>0?t:void 0}function b(t){return typeof t=="number"&&Number.isFinite(t)?t:0}async function*$(t){let e=t.body;if(!e)throw new i({code:"connection",message:"The run response arrived with no body to stream.",status:t.status});let r=t.headers.get("content-type")??"",n=r.includes("ndjson")||r.includes("jsonlines"),o=e.getReader(),s=new TextDecoder,a="",u=[];try{for(;;){let f=await o.read();if(f.done)break;for(a+=s.decode(f.value,{stream:!0});;){let l=a.indexOf(`
|
|
2
|
+
`);if(l===-1)break;let p=N(a.slice(0,l));if(a=a.slice(l+1),n){let d=p.trim()?A(p):null;d&&(yield d);continue}if(p===""){let d=u.join(`
|
|
3
|
+
`);if(u=[],d==="[DONE]")return;let m=d?A(d):null;m&&(yield m);continue}O(p,u)}}!n&&a&&O(N(a),u);let g=n?a.trim():u.join(`
|
|
4
|
+
`);if(g&&g!=="[DONE]"){let f=A(g);f&&(yield f)}}finally{o.cancel().catch(()=>{})}}function O(t,e){if(t.startsWith(":"))return;let r=t.indexOf(":");if(r===-1||t.slice(0,r)!=="data")return;let n=t.slice(r+1);e.push(n.startsWith(" ")?n.slice(1):n)}function N(t){return t.endsWith("\r")?t.slice(0,-1):t}function ie(t){return t}function w(t){let e=new Map;for(let r of t??[]){if(!r?.name)throw new i({code:"invalid_request",message:"Every tool needs a name."});if(typeof r.execute!="function")throw new i({code:"invalid_request",message:`Tool "${r.name}" has no execute function. A tool Soba cannot call is not a tool.`});if(e.has(r.name))throw new i({code:"invalid_request",message:`Two tools are named "${r.name}". Tool names must be unique within a run.`});e.set(r.name,r)}return e}function _(t){return[...t.values()].map(e=>({name:e.name,description:e.description??"",inputSchema:e.inputSchema??{type:"object"}}))}function v(t){if(t===void 0)return null;if(typeof t=="string")return t;try{return JSON.parse(JSON.stringify(t))}catch{return String(t)}}function S(t){return t instanceof Error?t.message||t.name:typeof t=="string"?t:String(t)}var T=class{#e;#n;#r;#a=!1;#t;#o=new AbortController;#s=new AbortController;#i=new Set;#u;constructor(e){this.#t=e,this.#e=e.response.headers.get("soba-run-id")??"",this.#u=x(e.signal,this.#o)}get runId(){return this.#e}get tier(){return this.#n}get usage(){return this.#r}[Symbol.asyncIterator](){return this.#d()}async text(){let e="";for await(let r of this)if(r.type==="delta"&&r.text&&(e+=r.text),r.type==="error")throw new i({code:"run_failed",message:r.message??"The run ended with an error.",runId:this.#e,retryable:!1});return e}async cancel(){if(this.#o.abort(new i({code:"canceled",message:"The run was cancelled."})),this.#s.abort(new i({code:"canceled",message:"The run was cancelled."})),!!this.#e)try{await y(this.#t.config,{method:"POST",path:`/runs/${encodeURIComponent(this.#e)}/cancel`,retry:!1})}catch(e){this.#t.onWarning("Could not tell Soba the run was cancelled.",e)}}async*#d(){if(this.#a)throw new i({code:"invalid_request",message:"This run has already been consumed. A run's events can only be read once.",runId:this.#e});this.#a=!0;let e=!1;try{for await(let r of $(this.#t.response)){if(r.runId&&!this.#e&&(this.#e=r.runId),r.kind==="event"){let n=r.event;if(n.tier&&(this.#n=n.tier),n.usage&&(this.#r=n.usage),e=k(n),yield n,e)return;continue}if(r.kind==="usage"){this.#r=r.usage;continue}this.#l(this.#p(r))}throw new i({code:"stream_truncated",message:"The run stream ended without a `done` or `error` event.",runId:this.#e})}finally{e||this.#s.abort(new i({code:"canceled",message:"The run ended before this tool finished."})),await Promise.allSettled([...this.#i]),this.#o.abort(),this.#u()}}#l(e){this.#i.add(e),e.finally(()=>this.#i.delete(e)).catch(()=>{})}async#p(e){if(e.kind==="tool_call")return this.#f(e);if(e.kind==="approval")return this.#g(e)}async#f(e){let r=this.#t.tools.get(e.name),n,o=!1;if(!r)n=`No tool named "${e.name}" is registered by this app.`,o=!0;else{let s={runId:this.#e,user:this.#t.user,callId:e.callId,signal:this.#s.signal};try{n=await r.execute(e.input,s)}catch(a){n=S(a),o=!0}}await this.#c(`/runs/${encodeURIComponent(this.#e)}/tool_result`,{callId:e.callId,result:v(n),isError:o})}async#g(e){let r="deny",n;if(!this.#t.approve)n="This app did not supply an approver, so escalations are denied.";else try{let o={runId:this.#e,approvalId:e.approvalId,tool:e.tool,input:e.input};e.cwd&&(o.cwd=e.cwd),r=await this.#t.approve(o)==="allow"?"allow":"deny"}catch(o){n=S(o)}await this.#c(`/runs/${encodeURIComponent(this.#e)}/approval`,{approvalId:e.approvalId,decision:r,...n?{reason:n}:{}})}async#c(e,r){if(!this.#e){this.#t.onWarning("Soba sent a frame before the run had an id, so it could not be answered.",r);return}try{await(await y(this.#t.config,{method:"POST",path:e,body:r})).text()}catch(n){this.#t.onWarning(`Could not deliver an answer to ${e}.`,h(n))}}};var j="https://api.soba.so/v1",ae=3e4,ue=2,I=class{#e;#n;users;usage;connect;plans;runs;constructor(e={}){let r=e.apiKey??C("SOBA_KEY")??C("SOBA_API_KEY");if(!r)throw new i({code:"unauthorized",message:"No Soba API key. Pass `apiKey`, or set SOBA_KEY. Keys are shaped sk_soba_<id>.<secret> and come from your dashboard."});if(ce()&&!e.dangerouslyAllowBrowser)throw new i({code:"invalid_request",message:"A Soba app key is a server credential and this looks like a browser. Call Soba from your server, or pass dangerouslyAllowBrowser if you are certain."});if(this.#n=e.onWarning??(()=>{}),this.#e={apiKey:r,baseURL:e.baseURL??C("SOBA_BASE_URL")??j,headers:e.headers??{},fetch:e.fetch??globalThis.fetch,connectTimeoutMs:e.connectTimeoutMs??ae,maxRetries:e.maxRetries??ue},typeof this.#e.fetch!="function")throw new i({code:"invalid_request",message:"No global fetch. Run on Node 22 or newer, or pass your own `fetch`."});this.users={get:n=>this.request("GET",`/users/${encodeURIComponent(n)}`),compute:async n=>(await this.request("GET",`/users/${encodeURIComponent(n)}/compute`)).machines},this.usage={list:n=>this.request("GET","/usage",{query:{...n}})},this.connect=()=>this.request("GET","/connect"),this.runs={start:async n=>{if(!n.user)throw new i({code:"user_required",status:422,message:"`user` is required."});let o=w(n.tools),s={user:n.user,model:n.model??"auto",detached:!0,webhook:n.webhook};return n.prompt&&(s.prompt=n.prompt),n.messages?.length&&(s.messages=n.messages),n.system&&(s.system=n.system),n.runtime&&(s.runtime=n.runtime),o.size&&(s.tools=_(o)),n.route&&(s.route=n.route),n.policy&&(s.policy=n.policy),n.metadata&&(s.metadata=n.metadata),n.surface&&(s.surface=n.surface),n.title&&(s.title=n.title),n.worktree&&(s.worktree=n.worktree),{id:(await E(this.#e,{method:"POST",path:"/runs",body:s,retry:"unsent"})).id}},cancel:async n=>{await this.request("POST",`/runs/${encodeURIComponent(n)}/cancel`,{body:{}})}},this.plans={list:n=>this.request("GET","/plans",{...n?.user?{query:{user:n.user}}:{}})}}get baseURL(){return this.#e.baseURL}async run(e){if(!e.user)throw new i({code:"user_required",status:422,message:"`user` is required. A run with no user cannot be attributed or metered, so it cannot be defaulted."});if(!e.prompt&&!e.messages?.length)throw new i({code:"invalid_request",status:400,message:"A run needs a `prompt` or a non-empty `messages`."});let r=w(e.tools),n={user:e.user,model:e.model??"auto"};e.prompt&&(n.prompt=e.prompt),e.messages?.length&&(n.messages=e.messages),e.system&&(n.system=e.system),e.runtime&&(n.runtime=e.runtime),r.size&&(n.tools=_(r)),e.route&&(n.route=e.route),e.policy&&(n.policy=e.policy),e.metadata&&(n.metadata=e.metadata);let o=await y(this.#e,{method:"POST",path:"/runs",body:n,accept:"text/event-stream",signal:e.signal,retry:"unsent"});return new T({config:this.#e,response:o,user:e.user,tools:r,approve:e.approve,onWarning:this.#n,signal:e.signal})}async request(e,r,n={}){return E(this.#e,{method:e,path:r,...n})}};function C(t){if(typeof process>"u")return;let e=process.env?.[t];return e||void 0}function ce(){let t=globalThis;return typeof t.window=="object"&&typeof t.window?.document=="object"}var de=300;function le(t){return[...new Uint8Array(t)].map(e=>e.toString(16).padStart(2,"0")).join("")}function pe(t,e){if(t.length!==e.length)return!1;let r=0;for(let n=0;n<t.length;n++)r|=t.charCodeAt(n)^e.charCodeAt(n);return r===0}async function fe(t,e,r,n={}){let o=Object.fromEntries((e??"").split(",").map(m=>{let M=m.indexOf("=");return[m.slice(0,M).trim(),m.slice(M+1).trim()]})),s=Number(o.t),a=o.v1??"",u=m=>new i({code:"unauthorized",status:401,message:m});if(!Number.isFinite(s)||!a)throw u("Missing or malformed Soba-Signature.");let g=Math.floor((n.now??Date.now())/1e3);if(Math.abs(g-s)>(n.toleranceSeconds??de))throw u("This delivery is too old to accept.");let f=await globalThis.crypto.subtle.importKey("raw",new TextEncoder().encode(r),{name:"HMAC",hash:"SHA-256"},!1,["sign"]),l=le(await globalThis.crypto.subtle.sign("HMAC",f,new TextEncoder().encode(`${s}.${t}`)));if(!pe(l,a))throw u("The Soba-Signature does not match.");let p;try{p=JSON.parse(t)}catch{throw new i({code:"invalid_request",message:"The delivery is not JSON."})}let d=p;if(d.type!=="run.event"&&d.type!=="run.tool_call"||typeof d.run_id!="string")throw new i({code:"invalid_request",message:"Not a Soba run delivery."});return d}async function ge(t,e){if(t.type!=="run.tool_call")return null;let{call_id:r,name:n,input:o}=t.data,s=w(e.tools).get(n);if(!s)return{result:`Unknown tool: ${n}`,is_error:!0};try{let a=await s.execute(o??{},{runId:t.run_id,user:e.user??"",callId:r,signal:AbortSignal.timeout(25e3)});return{result:v(a)}}catch(a){return{result:S(a),is_error:!0}}}export{j as DEFAULT_BASE_URL,q as METERED_CLASSES,T as Run,I as Soba,i as SobaError,se as TOOL_SLUGS,R as VERSION,ge as handleRunWebhook,oe as isCostClass,re as isMetered,k as isTerminal,ie as tool,fe as verifyWebhook};
|
package/dist/protocol.d.ts
CHANGED
|
@@ -3,10 +3,12 @@
|
|
|
3
3
|
*
|
|
4
4
|
* user-hardware the user's own silicon. Free to everyone
|
|
5
5
|
* user-subscription a plan the user already pays for. Free to the broker
|
|
6
|
+
* user-api-key the user's own provider key. Free to the broker, billed
|
|
7
|
+
* to the user by their provider
|
|
6
8
|
* open-weights an open model on GPU bought wholesale. METERED
|
|
7
9
|
* frontier a frontier provider API. METERED
|
|
8
10
|
*/
|
|
9
|
-
export type CostClass = "user-hardware" | "user-subscription" | "open-weights" | "frontier";
|
|
11
|
+
export type CostClass = "user-hardware" | "user-subscription" | "user-api-key" | "open-weights" | "frontier";
|
|
10
12
|
/** The classes somebody has to be billed for. The user-* classes are already paid. */
|
|
11
13
|
export declare const METERED_CLASSES: readonly ["open-weights", "frontier"];
|
|
12
14
|
export declare function isMetered(c: CostClass): boolean;
|
package/dist/resources.d.ts
CHANGED
|
@@ -106,6 +106,9 @@ export interface PlanListItem {
|
|
|
106
106
|
interval: string;
|
|
107
107
|
unit: string;
|
|
108
108
|
included_units: number | null;
|
|
109
|
+
/** What happens past the allowance: `stop` or `meter`. The difference
|
|
110
|
+
* between a wall and a bill, and not guessable from the price. */
|
|
111
|
+
overage: string;
|
|
109
112
|
/** Dollars per unit past the allowance. */
|
|
110
113
|
overage_per_unit: number;
|
|
111
114
|
trial_days: number;
|
|
@@ -113,6 +116,17 @@ export interface PlanListItem {
|
|
|
113
116
|
route_allow: string[] | null;
|
|
114
117
|
/** Whether it can be taken without a payment rail. */
|
|
115
118
|
selectable: boolean;
|
|
119
|
+
/**
|
|
120
|
+
* What connecting their own compute earns: `none` | `unlimited` | `included`
|
|
121
|
+
* | `credit`. Here for the same reason every other field is: these objects
|
|
122
|
+
* are handed straight to `<SobaProvider initialPlans>` and rendered, so the
|
|
123
|
+
* shape has to be the component's, not a report's.
|
|
124
|
+
*/
|
|
125
|
+
connected_reward: string;
|
|
126
|
+
/** The larger allowance an `included` reward grants. Null otherwise. */
|
|
127
|
+
connected_included_units: number | null;
|
|
128
|
+
/** Dollars back per interval on a `credit` reward. Zero otherwise. */
|
|
129
|
+
connected_credit: number;
|
|
116
130
|
}
|
|
117
131
|
export interface CurrentPlanItem {
|
|
118
132
|
plan_id: string;
|
|
@@ -162,6 +176,10 @@ export type MachineListState = "online" | "busy" | "offline";
|
|
|
162
176
|
export interface MachineListItem {
|
|
163
177
|
id: string;
|
|
164
178
|
label: string;
|
|
179
|
+
/** The reported hostname, which `label` already falls back to. Carried so a
|
|
180
|
+
* renderer can tell a name somebody chose from a DNS label and tidy only
|
|
181
|
+
* the second — see `machineName` in connect-ui. */
|
|
182
|
+
name?: string | null;
|
|
165
183
|
platform: string | null;
|
|
166
184
|
arch: string | null;
|
|
167
185
|
version: string | null;
|
package/dist/version.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const VERSION = "0.0.
|
|
1
|
+
export declare const VERSION = "0.0.8";
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { AgentEvent } from "./protocol.js";
|
|
2
|
+
import { type ToolDefinition } from "./tools.js";
|
|
3
|
+
/** A delivery, after its signature has been checked. */
|
|
4
|
+
export type RunWebhookEvent = {
|
|
5
|
+
type: "run.event";
|
|
6
|
+
run_id: string;
|
|
7
|
+
created: number;
|
|
8
|
+
metadata?: Record<string, string>;
|
|
9
|
+
data: {
|
|
10
|
+
event: AgentEvent & {
|
|
11
|
+
type: string;
|
|
12
|
+
turn?: {
|
|
13
|
+
text: string;
|
|
14
|
+
};
|
|
15
|
+
};
|
|
16
|
+
};
|
|
17
|
+
} | {
|
|
18
|
+
type: "run.tool_call";
|
|
19
|
+
run_id: string;
|
|
20
|
+
created: number;
|
|
21
|
+
metadata?: Record<string, string>;
|
|
22
|
+
data: {
|
|
23
|
+
call_id: string;
|
|
24
|
+
name: string;
|
|
25
|
+
input: Record<string, unknown>;
|
|
26
|
+
};
|
|
27
|
+
};
|
|
28
|
+
/** What a tool call is answered with. */
|
|
29
|
+
export interface ToolCallAnswer {
|
|
30
|
+
result: unknown;
|
|
31
|
+
is_error?: boolean;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Check a delivery's `Soba-Signature` against the secret the run was started
|
|
35
|
+
* with, and parse it. Throws on a bad signature, a stale timestamp (replays
|
|
36
|
+
* older than five minutes), or a body that is not a delivery.
|
|
37
|
+
*
|
|
38
|
+
* Pass the body exactly as it arrived: re-serialising parsed JSON changes the
|
|
39
|
+
* bytes and the signature with them.
|
|
40
|
+
*/
|
|
41
|
+
export declare function verifyWebhook(rawBody: string, header: string | null | undefined, secret: string, options?: {
|
|
42
|
+
toleranceSeconds?: number;
|
|
43
|
+
now?: number;
|
|
44
|
+
}): Promise<RunWebhookEvent>;
|
|
45
|
+
/**
|
|
46
|
+
* Answer a verified delivery. A tool call runs the named tool from `tools` and
|
|
47
|
+
* resolves to its result; anything else resolves to `null`.
|
|
48
|
+
*
|
|
49
|
+
* A tool that throws is answered as an error the agent can read and recover
|
|
50
|
+
* from, never as a failed request: a 500 here would be retried, and the tool
|
|
51
|
+
* would run again.
|
|
52
|
+
*/
|
|
53
|
+
export declare function handleRunWebhook(event: RunWebhookEvent, options: {
|
|
54
|
+
tools: readonly ToolDefinition[];
|
|
55
|
+
user?: string;
|
|
56
|
+
}): Promise<ToolCallAnswer | null>;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@soba-so/sdk",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.8",
|
|
4
4
|
"description": "One request that comes back finished, executed on compute the end user owns.",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"homepage": "https://soba.so/docs/runs",
|
|
@@ -11,6 +11,10 @@
|
|
|
11
11
|
".": {
|
|
12
12
|
"types": "./dist/index.d.ts",
|
|
13
13
|
"default": "./dist/index.mjs"
|
|
14
|
+
},
|
|
15
|
+
"./identity": {
|
|
16
|
+
"types": "./dist/identity.d.ts",
|
|
17
|
+
"default": "./dist/identity.mjs"
|
|
14
18
|
}
|
|
15
19
|
},
|
|
16
20
|
"files": [
|