@runappsai/sdk 0.0.0-stage → 0.3.1
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/LICENSE +21 -0
- package/README.md +274 -2
- package/dist/activity-tracker.d.ts +113 -0
- package/dist/activity-tracker.d.ts.map +1 -0
- package/dist/activity-tracker.js +176 -0
- package/dist/activity-tracker.js.map +1 -0
- package/dist/audio.d.ts +129 -0
- package/dist/audio.d.ts.map +1 -0
- package/dist/audio.js +267 -0
- package/dist/audio.js.map +1 -0
- package/dist/browser-auth.d.ts +179 -0
- package/dist/browser-auth.d.ts.map +1 -0
- package/dist/browser-auth.js +1361 -0
- package/dist/browser-auth.js.map +1 -0
- package/dist/chat.d.ts +221 -0
- package/dist/chat.d.ts.map +1 -0
- package/dist/chat.js +155 -0
- package/dist/chat.js.map +1 -0
- package/dist/client.d.ts +155 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +157 -0
- package/dist/client.js.map +1 -0
- package/dist/computer.d.ts +57 -0
- package/dist/computer.d.ts.map +1 -0
- package/dist/computer.js +21 -0
- package/dist/computer.js.map +1 -0
- package/dist/embeddings.d.ts +64 -0
- package/dist/embeddings.d.ts.map +1 -0
- package/dist/embeddings.js +30 -0
- package/dist/embeddings.js.map +1 -0
- package/dist/errors.d.ts +24 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +29 -0
- package/dist/errors.js.map +1 -0
- package/dist/event-wrap.d.ts +59 -0
- package/dist/event-wrap.d.ts.map +1 -0
- package/dist/event-wrap.js +142 -0
- package/dist/event-wrap.js.map +1 -0
- package/dist/events.d.ts +119 -0
- package/dist/events.d.ts.map +1 -0
- package/dist/events.js +87 -0
- package/dist/events.js.map +1 -0
- package/dist/files.d.ts +218 -0
- package/dist/files.d.ts.map +1 -0
- package/dist/files.js +212 -0
- package/dist/files.js.map +1 -0
- package/dist/image.d.ts +185 -0
- package/dist/image.d.ts.map +1 -0
- package/dist/image.js +148 -0
- package/dist/image.js.map +1 -0
- package/dist/index.d.ts +30 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +26 -0
- package/dist/index.js.map +1 -0
- package/dist/media.d.ts +56 -0
- package/dist/media.d.ts.map +1 -0
- package/dist/media.js +133 -0
- package/dist/media.js.map +1 -0
- package/dist/model_options.d.ts +165 -0
- package/dist/model_options.d.ts.map +1 -0
- package/dist/model_options.js +59 -0
- package/dist/model_options.js.map +1 -0
- package/dist/models.d.ts +88 -0
- package/dist/models.d.ts.map +1 -0
- package/dist/models.js +55 -0
- package/dist/models.js.map +1 -0
- package/dist/sdk.umd.js +4 -0
- package/dist/sdk.umd.js.map +1 -0
- package/dist/transport.d.ts +120 -0
- package/dist/transport.d.ts.map +1 -0
- package/dist/transport.js +214 -0
- package/dist/transport.js.map +1 -0
- package/dist/types.d.ts +31 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/dist/validate.d.ts +16 -0
- package/dist/validate.d.ts.map +1 -0
- package/dist/validate.js +329 -0
- package/dist/validate.js.map +1 -0
- package/dist/video.d.ts +134 -0
- package/dist/video.d.ts.map +1 -0
- package/dist/video.js +60 -0
- package/dist/video.js.map +1 -0
- package/package.json +54 -4
- package/src/activity-tracker.ts +251 -0
- package/src/audio.ts +440 -0
- package/src/browser-auth.ts +1529 -0
- package/src/chat.ts +368 -0
- package/src/client.ts +250 -0
- package/src/computer.ts +80 -0
- package/src/embeddings.ts +97 -0
- package/src/errors.ts +29 -0
- package/src/event-wrap.ts +168 -0
- package/src/events.ts +172 -0
- package/src/files.ts +347 -0
- package/src/image.ts +339 -0
- package/src/index.ts +151 -0
- package/src/media.ts +147 -0
- package/src/model_options.ts +220 -0
- package/src/models.ts +121 -0
- package/src/transport.ts +292 -0
- package/src/types.ts +31 -0
- package/src/validate.ts +364 -0
- package/src/video.ts +215 -0
package/src/chat.ts
ADDED
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
import type { Transport } from "./transport.js";
|
|
2
|
+
import type { Usage } from "./types.js";
|
|
3
|
+
import type { SDKEvents } from "./events.js";
|
|
4
|
+
import { wrapEvents, wrapStream } from "./event-wrap.js";
|
|
5
|
+
import { APIError } from "./errors.js";
|
|
6
|
+
|
|
7
|
+
/* ------------------------------------------------------------------ */
|
|
8
|
+
/* Request types */
|
|
9
|
+
/* ------------------------------------------------------------------ */
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* A single message in a chat conversation. Content is a string for plain
|
|
13
|
+
* text, or an array of `ContentPart` for multi-modal messages.
|
|
14
|
+
*/
|
|
15
|
+
export interface ChatMessage {
|
|
16
|
+
role: "system" | "user" | "assistant" | "tool";
|
|
17
|
+
content: string | ContentPart[] | null;
|
|
18
|
+
name?: string;
|
|
19
|
+
tool_call_id?: string;
|
|
20
|
+
tool_calls?: ChatToolCall[];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** One block inside a multi-modal message content array.
|
|
24
|
+
*
|
|
25
|
+
* Multi-modal LLMs see content as a heterogeneous list — some mix
|
|
26
|
+
* text + images (gpt-4o, claude), some accept video frames or raw
|
|
27
|
+
* audio (Gemini 2.x). The shape mirrors OpenAI's chat completions
|
|
28
|
+
* schema and is forwarded verbatim to the gateway; unsupported
|
|
29
|
+
* variants are rejected server-side with a clear error rather than
|
|
30
|
+
* silently ignored. */
|
|
31
|
+
export type ContentPart =
|
|
32
|
+
| { type: "text"; text: string }
|
|
33
|
+
| { type: "image_url"; image_url: { url: string; detail?: "auto" | "low" | "high" } }
|
|
34
|
+
| { type: "video_url"; video_url: { url: string } }
|
|
35
|
+
| { type: "audio_url"; audio_url: { url: string } };
|
|
36
|
+
|
|
37
|
+
export interface ChatTool {
|
|
38
|
+
type: "function";
|
|
39
|
+
function: {
|
|
40
|
+
name: string;
|
|
41
|
+
description?: string;
|
|
42
|
+
parameters?: Record<string, unknown>;
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface StreamOptions {
|
|
47
|
+
include_usage: boolean;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Names of platform-executed tools the SDK can ask the gateway to run on
|
|
52
|
+
* its behalf during a chat completion. When you set `server_tools` on a
|
|
53
|
+
* `ChatCompletionParams`, the gateway resolves each name to its tool
|
|
54
|
+
* schema, sends both server tools and your own `tools` (if any) to the
|
|
55
|
+
* model, then handles any server-tool calls itself — looping with the
|
|
56
|
+
* model until it produces a final answer. Your code only sees the final
|
|
57
|
+
* response; you don't have to implement web search yourself.
|
|
58
|
+
*
|
|
59
|
+
* The whitelist is intentionally narrow. For image, audio, file, and
|
|
60
|
+
* vision generation the SDK already exposes dedicated endpoints
|
|
61
|
+
* (`client.image.*`, `client.audio.*`, etc.) that callers should hit
|
|
62
|
+
* directly instead of paying for an LLM-in-the-middle. Search-class
|
|
63
|
+
* tools live here because they need the model in the loop — "search →
|
|
64
|
+
* read result → fetch → answer" can't be expressed as a single call.
|
|
65
|
+
*/
|
|
66
|
+
export type ServerToolName = "web_search" | "web_fetch" | "twitter_search";
|
|
67
|
+
|
|
68
|
+
/** Typed constants for {@link ServerToolName} so you get autocomplete. */
|
|
69
|
+
export const ServerTools = {
|
|
70
|
+
WebSearch: "web_search",
|
|
71
|
+
WebFetch: "web_fetch",
|
|
72
|
+
TwitterSearch: "twitter_search",
|
|
73
|
+
} as const;
|
|
74
|
+
|
|
75
|
+
export interface ChatCompletionParams {
|
|
76
|
+
model: string;
|
|
77
|
+
messages: ChatMessage[];
|
|
78
|
+
temperature?: number;
|
|
79
|
+
top_p?: number;
|
|
80
|
+
max_tokens?: number;
|
|
81
|
+
stop?: string[];
|
|
82
|
+
frequency_penalty?: number;
|
|
83
|
+
presence_penalty?: number;
|
|
84
|
+
n?: number;
|
|
85
|
+
tools?: ChatTool[];
|
|
86
|
+
tool_choice?: unknown;
|
|
87
|
+
user?: string;
|
|
88
|
+
metadata?: Record<string, unknown>;
|
|
89
|
+
stream?: boolean;
|
|
90
|
+
stream_options?: StreamOptions;
|
|
91
|
+
/**
|
|
92
|
+
* Names of platform-executed tools — the gateway will run these for
|
|
93
|
+
* you and loop with the model until it produces a final answer. Mix
|
|
94
|
+
* freely with your own `tools`: the model can call either; the
|
|
95
|
+
* platform handles the server ones, your code handles the rest.
|
|
96
|
+
*/
|
|
97
|
+
server_tools?: ServerToolName[];
|
|
98
|
+
/**
|
|
99
|
+
* Maximum number of LLM round-trips the server-tool loop will make
|
|
100
|
+
* (default 5, hard cap 10). Only meaningful when `server_tools` is
|
|
101
|
+
* set. Tune up for chains like "search → fetch → answer", down for
|
|
102
|
+
* tighter cost guarantees.
|
|
103
|
+
*/
|
|
104
|
+
max_server_iterations?: number;
|
|
105
|
+
/**
|
|
106
|
+
* One of the app's private prompts, kept out of the browser: the
|
|
107
|
+
* gateway reads `bundle.json (or .runapps/prompts/)<id>.md` from the app's bundle, fills
|
|
108
|
+
* `{{var}}` placeholders from `vars`, and prepends it as the system
|
|
109
|
+
* message. `template` sends the text inline instead — for a creator's
|
|
110
|
+
* local dev server, before a bundle exists; the gateway accepts it
|
|
111
|
+
* only from the app's creator.
|
|
112
|
+
*/
|
|
113
|
+
prompt?: { id?: string; vars?: Record<string, unknown>; template?: string };
|
|
114
|
+
/** Pass-through for provider-specific knobs. */
|
|
115
|
+
[extra: string]: unknown;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/* ------------------------------------------------------------------ */
|
|
119
|
+
/* Response types */
|
|
120
|
+
/* ------------------------------------------------------------------ */
|
|
121
|
+
|
|
122
|
+
export interface ChatToolCall {
|
|
123
|
+
index?: number;
|
|
124
|
+
id?: string;
|
|
125
|
+
type?: string;
|
|
126
|
+
function: {
|
|
127
|
+
name: string;
|
|
128
|
+
arguments: string;
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export interface ChatChoiceMessage {
|
|
133
|
+
role: string;
|
|
134
|
+
content: string | null;
|
|
135
|
+
tool_calls?: ChatToolCall[];
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export interface ChatChoice {
|
|
139
|
+
index: number;
|
|
140
|
+
message: ChatChoiceMessage;
|
|
141
|
+
finish_reason: string;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export interface ChatCompletion {
|
|
145
|
+
id: string;
|
|
146
|
+
object: string;
|
|
147
|
+
created: number;
|
|
148
|
+
model: string;
|
|
149
|
+
choices: ChatChoice[];
|
|
150
|
+
usage: Usage;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export interface ChatChunkDelta {
|
|
154
|
+
role?: string;
|
|
155
|
+
content?: string;
|
|
156
|
+
tool_calls?: ChatToolCall[];
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export interface ChatChunkChoice {
|
|
160
|
+
index: number;
|
|
161
|
+
delta: ChatChunkDelta;
|
|
162
|
+
finish_reason?: string | null;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export interface ChatCompletionChunk {
|
|
166
|
+
id: string;
|
|
167
|
+
object: string;
|
|
168
|
+
created: number;
|
|
169
|
+
model: string;
|
|
170
|
+
choices: ChatChunkChoice[];
|
|
171
|
+
usage?: Usage;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/* ------------------------------------------------------------------ */
|
|
175
|
+
/* Convenience builders */
|
|
176
|
+
/* ------------------------------------------------------------------ */
|
|
177
|
+
|
|
178
|
+
export const userMessage = (content: string): ChatMessage =>
|
|
179
|
+
({ role: "user", content });
|
|
180
|
+
|
|
181
|
+
export const systemMessage = (content: string): ChatMessage =>
|
|
182
|
+
({ role: "system", content });
|
|
183
|
+
|
|
184
|
+
export const assistantMessage = (content: string): ChatMessage =>
|
|
185
|
+
({ role: "assistant", content });
|
|
186
|
+
|
|
187
|
+
export const toolResultMessage = (toolCallId: string, content: string): ChatMessage =>
|
|
188
|
+
({ role: "tool", tool_call_id: toolCallId, content });
|
|
189
|
+
|
|
190
|
+
export const userMessageParts = (...parts: ContentPart[]): ChatMessage =>
|
|
191
|
+
({ role: "user", content: parts });
|
|
192
|
+
|
|
193
|
+
export const textPart = (text: string): ContentPart => ({ type: "text", text });
|
|
194
|
+
|
|
195
|
+
export const imagePart = (
|
|
196
|
+
url: string,
|
|
197
|
+
detail?: "auto" | "low" | "high",
|
|
198
|
+
): ContentPart => ({
|
|
199
|
+
type: "image_url",
|
|
200
|
+
image_url: detail ? { url, detail } : { url },
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Video content part. Only supported by chat models whose
|
|
205
|
+
* input_modalities includes `"video"` (currently Gemini 3.x). Other
|
|
206
|
+
* models 400 at the gateway. URL may be http(s) or a
|
|
207
|
+
* `data:video/mp4;base64,...` inline upload.
|
|
208
|
+
*/
|
|
209
|
+
export const videoPart = (url: string): ContentPart => ({
|
|
210
|
+
type: "video_url",
|
|
211
|
+
video_url: { url },
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Audio content part. Only supported by chat models whose
|
|
216
|
+
* input_modalities includes `"audio"` (currently Gemini 3.x). Other
|
|
217
|
+
* models 400 at the gateway. URL may be http(s) or a
|
|
218
|
+
* `data:audio/wav;base64,...` inline upload.
|
|
219
|
+
*/
|
|
220
|
+
export const audioPart = (url: string): ContentPart => ({
|
|
221
|
+
type: "audio_url",
|
|
222
|
+
audio_url: { url },
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
/* ------------------------------------------------------------------ */
|
|
226
|
+
/* Service */
|
|
227
|
+
/* ------------------------------------------------------------------ */
|
|
228
|
+
|
|
229
|
+
export class ChatService {
|
|
230
|
+
constructor(
|
|
231
|
+
private readonly transport: Transport,
|
|
232
|
+
private readonly events: SDKEvents,
|
|
233
|
+
) {}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Create a chat completion (non-streaming).
|
|
237
|
+
*
|
|
238
|
+
* Streaming is forced off — pass `stream()` instead for incremental output.
|
|
239
|
+
*/
|
|
240
|
+
async create(
|
|
241
|
+
params: ChatCompletionParams,
|
|
242
|
+
init?: { signal?: AbortSignal },
|
|
243
|
+
): Promise<ChatCompletion> {
|
|
244
|
+
const body = { ...params, stream: false };
|
|
245
|
+
return wrapEvents(
|
|
246
|
+
this.events,
|
|
247
|
+
{ model: params.model, capability: "text" },
|
|
248
|
+
() =>
|
|
249
|
+
this.transport.postJSON<ChatCompletion>(
|
|
250
|
+
"/v1/chat/completions",
|
|
251
|
+
body,
|
|
252
|
+
init,
|
|
253
|
+
),
|
|
254
|
+
(r) => ({
|
|
255
|
+
totalTokens: r.usage?.completion_tokens,
|
|
256
|
+
costUSD: r.usage?.total_cost,
|
|
257
|
+
finishReason: r.choices?.[0]?.finish_reason,
|
|
258
|
+
}),
|
|
259
|
+
);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Create a streaming chat completion. Returns an async iterator over
|
|
264
|
+
* `ChatCompletionChunk`. The final chunk carries `usage` (token / cost
|
|
265
|
+
* totals) — `stream_options.include_usage` is set automatically.
|
|
266
|
+
*
|
|
267
|
+
* ```ts
|
|
268
|
+
* let cost = 0;
|
|
269
|
+
* for await (const chunk of client.chat.stream({ ... })) {
|
|
270
|
+
* for (const c of chunk.choices) process.stdout.write(c.delta.content ?? "");
|
|
271
|
+
* if (chunk.usage) cost = chunk.usage.total_cost;
|
|
272
|
+
* }
|
|
273
|
+
* ```
|
|
274
|
+
*
|
|
275
|
+
* Also fires `request:streamDelta` on `client.events` for each chunk
|
|
276
|
+
* that carried text — drives the badge's tokens/sec rate display.
|
|
277
|
+
*/
|
|
278
|
+
async *stream(
|
|
279
|
+
params: ChatCompletionParams,
|
|
280
|
+
init?: { signal?: AbortSignal },
|
|
281
|
+
): AsyncGenerator<ChatCompletionChunk, void, unknown> {
|
|
282
|
+
const body = {
|
|
283
|
+
...params,
|
|
284
|
+
stream: true,
|
|
285
|
+
stream_options: params.stream_options ?? { include_usage: true },
|
|
286
|
+
};
|
|
287
|
+
const sourceGen = async function* (this: ChatService) {
|
|
288
|
+
const resp = await this.transport.postJSONStream(
|
|
289
|
+
"/v1/chat/completions",
|
|
290
|
+
body,
|
|
291
|
+
init,
|
|
292
|
+
);
|
|
293
|
+
if (!resp.body) {
|
|
294
|
+
throw new Error("runapps: streaming response had no body");
|
|
295
|
+
}
|
|
296
|
+
yield* parseSSE<ChatCompletionChunk>(resp.body);
|
|
297
|
+
}.bind(this);
|
|
298
|
+
yield* wrapStream(
|
|
299
|
+
this.events,
|
|
300
|
+
{ model: params.model, capability: "text" },
|
|
301
|
+
sourceGen,
|
|
302
|
+
(chunk) => ({
|
|
303
|
+
deltaText: chunk.choices?.[0]?.delta?.content ?? "",
|
|
304
|
+
completionTokens: chunk.usage?.completion_tokens,
|
|
305
|
+
costUSD: chunk.usage?.total_cost,
|
|
306
|
+
finishReason: chunk.choices?.[0]?.finish_reason ?? undefined,
|
|
307
|
+
}),
|
|
308
|
+
);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/* ------------------------------------------------------------------ */
|
|
313
|
+
/* SSE parsing */
|
|
314
|
+
/* ------------------------------------------------------------------ */
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Decode an `text/event-stream` body into a stream of parsed JSON chunks.
|
|
318
|
+
* Stops at the `[DONE]` sentinel. Lines that don't start with `data: ` are
|
|
319
|
+
* ignored (keep-alive comments, etc.).
|
|
320
|
+
*/
|
|
321
|
+
async function* parseSSE<T>(
|
|
322
|
+
body: ReadableStream<Uint8Array>,
|
|
323
|
+
): AsyncGenerator<T, void, unknown> {
|
|
324
|
+
const decoder = new TextDecoder();
|
|
325
|
+
const reader = body.getReader();
|
|
326
|
+
let buffer = "";
|
|
327
|
+
try {
|
|
328
|
+
while (true) {
|
|
329
|
+
const { value, done } = await reader.read();
|
|
330
|
+
if (done) break;
|
|
331
|
+
buffer += decoder.decode(value, { stream: true });
|
|
332
|
+
|
|
333
|
+
let nl: number;
|
|
334
|
+
while ((nl = buffer.indexOf("\n")) !== -1) {
|
|
335
|
+
const line = buffer.slice(0, nl).replace(/\r$/, "");
|
|
336
|
+
buffer = buffer.slice(nl + 1);
|
|
337
|
+
if (!line.startsWith("data: ")) continue;
|
|
338
|
+
const data = line.slice(6).trim();
|
|
339
|
+
if (data === "[DONE]") return;
|
|
340
|
+
if (!data) continue;
|
|
341
|
+
let parsed: unknown;
|
|
342
|
+
try {
|
|
343
|
+
parsed = JSON.parse(data);
|
|
344
|
+
} catch (e) {
|
|
345
|
+
throw new Error(
|
|
346
|
+
`runapps: decode stream chunk: ${(e as Error).message}`,
|
|
347
|
+
);
|
|
348
|
+
}
|
|
349
|
+
// The gateway reports mid-stream failures as an SSE error event
|
|
350
|
+
// (`data: {"error":{...}}`) followed by [DONE]. Such an event has no
|
|
351
|
+
// "choices", so without this it would surface as an empty chunk and
|
|
352
|
+
// the stream would end looking merely empty.
|
|
353
|
+
const err = (parsed as { error?: { code?: number; type?: string; message?: string } })
|
|
354
|
+
.error;
|
|
355
|
+
if (err) {
|
|
356
|
+
throw new APIError(
|
|
357
|
+
err.code ?? 0,
|
|
358
|
+
err.type ?? "upstream_error",
|
|
359
|
+
err.message ?? data,
|
|
360
|
+
);
|
|
361
|
+
}
|
|
362
|
+
yield parsed as T;
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
} finally {
|
|
366
|
+
reader.releaseLock();
|
|
367
|
+
}
|
|
368
|
+
}
|
package/src/client.ts
ADDED
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
import { Transport } from "./transport.js";
|
|
2
|
+
import { ChatService } from "./chat.js";
|
|
3
|
+
import { ModelsService } from "./models.js";
|
|
4
|
+
import { ImageService } from "./image.js";
|
|
5
|
+
import { AudioService } from "./audio.js";
|
|
6
|
+
import { VideoService } from "./video.js";
|
|
7
|
+
import { ComputerService } from "./computer.js";
|
|
8
|
+
import { FilesService } from "./files.js";
|
|
9
|
+
import { EmbeddingsService } from "./embeddings.js";
|
|
10
|
+
import { BrowserAuth, type BrowserUser } from "./browser-auth.js";
|
|
11
|
+
import { SDKEvents } from "./events.js";
|
|
12
|
+
|
|
13
|
+
// The platform, not the bare gateway: personal rk_ keys are checked and
|
|
14
|
+
// billed by www.runapps.ai, which forwards to the gateway. api.runapps.ai
|
|
15
|
+
// only knows the gateway's own keys.
|
|
16
|
+
const DEFAULT_BASE_URL = "https://www.runapps.ai";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Auth strategy.
|
|
20
|
+
*
|
|
21
|
+
* - `"static"` (default): use `apiKey` or `apiKeyResolver` directly.
|
|
22
|
+
* - `"runapps"`: opt into the built-in browser auth flow that
|
|
23
|
+
* handshakes with `https://www.runapps.ai/api/sdk/grant`. No
|
|
24
|
+
* external auth.js script needed; the SDK consumes the URL
|
|
25
|
+
* fragment after the redirect-back, exposes `client.user` and
|
|
26
|
+
* `client.signIn()`, and renders an identity badge. Only useful
|
|
27
|
+
* in browsers — in Node this falls back to throwing the same
|
|
28
|
+
* "missing apiKey" error you'd get with no auth config.
|
|
29
|
+
*/
|
|
30
|
+
// "runapps" is the current name of the platform sign-in; "runjobs" is the
|
|
31
|
+
// same thing under the name from before the rename.
|
|
32
|
+
export type AuthProvider = "static" | "runapps" | "runjobs";
|
|
33
|
+
|
|
34
|
+
export interface ClientOptions {
|
|
35
|
+
/** Static gateway API key (typically prefixed `gw-` / `rj_` / `rrt_`).
|
|
36
|
+
* Required unless `apiKeyResolver` or `authProvider: "runapps"` is supplied. */
|
|
37
|
+
apiKey?: string;
|
|
38
|
+
/**
|
|
39
|
+
* Dynamic API key resolver. See `AuthProvider` for the typical
|
|
40
|
+
* runapps.ai browser-bundle wiring.
|
|
41
|
+
*
|
|
42
|
+
* Wins over `apiKey` when both are supplied.
|
|
43
|
+
*/
|
|
44
|
+
apiKeyResolver?: () => string | Promise<string>;
|
|
45
|
+
/**
|
|
46
|
+
* Auth strategy — defaults to `"static"`. Pass `"runapps"` to
|
|
47
|
+
* activate the built-in browser auth flow against runapps.ai;
|
|
48
|
+
* baseURL defaults to `https://www.runapps.ai` in that mode.
|
|
49
|
+
*/
|
|
50
|
+
authProvider?: AuthProvider;
|
|
51
|
+
/**
|
|
52
|
+
* Show the floating identity badge in `runjobs` auth mode.
|
|
53
|
+
* **Default `true`** — the badge is now a desktop-ball-style
|
|
54
|
+
* real-time activity indicator (LED dot + progress ring around
|
|
55
|
+
* the avatar + click-through popover with active calls / recent
|
|
56
|
+
* completions / session totals). Set `false` only when the
|
|
57
|
+
* bundle ships its own status UI and you don't want the platform
|
|
58
|
+
* badge in the corner.
|
|
59
|
+
*/
|
|
60
|
+
showIdentityBadge?: boolean;
|
|
61
|
+
/**
|
|
62
|
+
* Which corner the identity badge floats in. Defaults to
|
|
63
|
+
* `"bottom-right"` — the canonical tray-app position. Switch to
|
|
64
|
+
* `"bottom-left"` when the bundle's own UI occupies the right
|
|
65
|
+
* gutter (e.g. a chat dock); the top corners are useful for
|
|
66
|
+
* fullscreen / portrait kiosk layouts.
|
|
67
|
+
*
|
|
68
|
+
* The popover anchors itself to the same corner — bottom-left
|
|
69
|
+
* badge → popover opens up + right of the badge; top-* badges →
|
|
70
|
+
* popover opens down. Each corner only nudges the CSS; the badge
|
|
71
|
+
* + popover content is identical.
|
|
72
|
+
*/
|
|
73
|
+
badgePosition?: "bottom-right" | "bottom-left" | "top-right" | "top-left";
|
|
74
|
+
/**
|
|
75
|
+
* @deprecated Use `showIdentityBadge` instead. Kept for back-compat;
|
|
76
|
+
* acts as `showIdentityBadge: !hideIdentityBadge` when set.
|
|
77
|
+
*/
|
|
78
|
+
hideIdentityBadge?: boolean;
|
|
79
|
+
/**
|
|
80
|
+
* Pin the runapps.ai grant flow to a specific project (only used
|
|
81
|
+
* when `authProvider: "runapps"`). When set, `/api/sdk/grant`
|
|
82
|
+
* receives `project_id=<value>` and mints an `rrt_*` resource
|
|
83
|
+
* token bound to THAT project — required for `client.files.*`
|
|
84
|
+
* calls from any origin that isn't a registered (origin, app)
|
|
85
|
+
* pair (e.g. a bundle running on `localhost:5173` during dev).
|
|
86
|
+
*
|
|
87
|
+
* Production bundles served from a registered origin can omit
|
|
88
|
+
* this; the gateway derives the project from the (origin, app)
|
|
89
|
+
* pair.
|
|
90
|
+
*/
|
|
91
|
+
project?: string;
|
|
92
|
+
/** Override the default base URL, `https://www.runapps.ai` (the
|
|
93
|
+
* platform: personal keys and app tokens are both accepted there). */
|
|
94
|
+
baseURL?: string;
|
|
95
|
+
/** Optional fetch override (e.g. node-fetch with custom agent). */
|
|
96
|
+
fetch?: typeof fetch;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Top-level RunApps SDK client. Construct once, share across services.
|
|
101
|
+
*
|
|
102
|
+
* Browser bundle (resource projects on runapps.ai):
|
|
103
|
+
*
|
|
104
|
+
* ```html
|
|
105
|
+
* <script src="https://cdn.jsdelivr.net/npm/@runappsai/sdk/dist/sdk.umd.js"></script>
|
|
106
|
+
* <script>
|
|
107
|
+
* const client = new RunApps.Client({ authProvider: "runapps" });
|
|
108
|
+
* const res = await client.chat.create({
|
|
109
|
+
* model: "gpt-4o-mini",
|
|
110
|
+
* messages: [{ role: "user", content: "hello" }],
|
|
111
|
+
* });
|
|
112
|
+
* </script>
|
|
113
|
+
* ```
|
|
114
|
+
*
|
|
115
|
+
* Node / server-side:
|
|
116
|
+
*
|
|
117
|
+
* ```ts
|
|
118
|
+
* import { RunApps } from "@runappsai/sdk";
|
|
119
|
+
* const client = new RunApps({ apiKey: process.env.RUNJOBS_API_KEY! });
|
|
120
|
+
* ```
|
|
121
|
+
*/
|
|
122
|
+
export class RunApps {
|
|
123
|
+
readonly chat: ChatService;
|
|
124
|
+
readonly models: ModelsService;
|
|
125
|
+
readonly image: ImageService;
|
|
126
|
+
readonly audio: AudioService;
|
|
127
|
+
readonly video: VideoService;
|
|
128
|
+
readonly computer: ComputerService;
|
|
129
|
+
readonly files: FilesService;
|
|
130
|
+
readonly embeddings: EmbeddingsService;
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Browser auth helper, populated only when `authProvider: "runapps"`.
|
|
134
|
+
* Use `client.signIn()` and `client.user` for the common cases; the
|
|
135
|
+
* full instance is exposed for advanced flows (manual `onTokenChange`
|
|
136
|
+
* subscription, etc.).
|
|
137
|
+
*/
|
|
138
|
+
readonly auth: BrowserAuth | null = null;
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Runtime event bus for SDK-call telemetry. Every LLM-ish service
|
|
142
|
+
* (chat / embeddings / image / audio / video / computer) fires
|
|
143
|
+
* `request:start`, `request:streamDelta`, `request:end`, and
|
|
144
|
+
* `request:error` events here so UI overlays — like the bottom-right
|
|
145
|
+
* identity badge's activity ring — can render real-time state
|
|
146
|
+
* without business code threading anything through.
|
|
147
|
+
*
|
|
148
|
+
* Zero-cost when nobody subscribes (emit is a few property reads).
|
|
149
|
+
* Subscribe via `client.events.on("request:start", handler)` — the
|
|
150
|
+
* returned closure is the unsubscribe.
|
|
151
|
+
*/
|
|
152
|
+
readonly events: SDKEvents = new SDKEvents();
|
|
153
|
+
|
|
154
|
+
constructor(options: ClientOptions = {}) {
|
|
155
|
+
const provider = options.authProvider ?? "static";
|
|
156
|
+
let apiKeyResolver = options.apiKeyResolver;
|
|
157
|
+
let baseURL = options.baseURL;
|
|
158
|
+
let onUnauthorized: (() => void) | undefined;
|
|
159
|
+
|
|
160
|
+
if (provider === "runapps" || provider === "runjobs") {
|
|
161
|
+
// The grant page (/api/sdk/grant) and the gateway both live on
|
|
162
|
+
// www.runapps.ai. An explicit baseURL keeps control.
|
|
163
|
+
baseURL = baseURL ?? "https://www.runapps.ai";
|
|
164
|
+
// Badge default: SHOWN. The badge is now a real-time activity
|
|
165
|
+
// indicator (LED + ring + popover) — useful enough that we'd
|
|
166
|
+
// rather have the rare "I already have my own UI" bundle opt
|
|
167
|
+
// OUT with `showIdentityBadge: false` than have every bundle
|
|
168
|
+
// miss the live feedback by default. The legacy
|
|
169
|
+
// `hideIdentityBadge` flag still wins when set, for callers
|
|
170
|
+
// that explicitly suppressed the badge under the old default.
|
|
171
|
+
const showBadge =
|
|
172
|
+
options.showIdentityBadge !== undefined
|
|
173
|
+
? options.showIdentityBadge
|
|
174
|
+
: options.hideIdentityBadge !== undefined
|
|
175
|
+
? !options.hideIdentityBadge
|
|
176
|
+
: true;
|
|
177
|
+
const auth = new BrowserAuth({
|
|
178
|
+
origin: baseURL,
|
|
179
|
+
hideBadge: !showBadge,
|
|
180
|
+
// Pipe the shared event bus into BrowserAuth so the badge can
|
|
181
|
+
// render its real-time activity ring / LED / popover. Safe to
|
|
182
|
+
// pass even when the caller never subscribes — emit is a few
|
|
183
|
+
// property reads when nobody's listening.
|
|
184
|
+
events: this.events,
|
|
185
|
+
...(options.badgePosition !== undefined && { badgePosition: options.badgePosition }),
|
|
186
|
+
...(options.project !== undefined && { project: options.project }),
|
|
187
|
+
});
|
|
188
|
+
(this as { auth: BrowserAuth | null }).auth = auth;
|
|
189
|
+
// The browser auth resolver wins over any caller-supplied one
|
|
190
|
+
// because the auth flow owns token issuance + refresh from now on.
|
|
191
|
+
apiKeyResolver = auth.getToken;
|
|
192
|
+
// 401 from the gateway → token was revoked server-side (typically
|
|
193
|
+
// because the user unsubscribed in another tab). Drop the cached
|
|
194
|
+
// token so the transport's auto-retry hits a fresh signIn() flow.
|
|
195
|
+
onUnauthorized = () => auth.invalidate();
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (!options.apiKey && !apiKeyResolver) {
|
|
199
|
+
throw new Error(
|
|
200
|
+
"runapps: pass either `apiKey`, `apiKeyResolver`, or `authProvider: \"runapps\"`",
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
const transport = new Transport({
|
|
204
|
+
baseURL: baseURL ?? DEFAULT_BASE_URL,
|
|
205
|
+
...(options.apiKey !== undefined && { apiKey: options.apiKey }),
|
|
206
|
+
...(apiKeyResolver !== undefined && { apiKeyResolver }),
|
|
207
|
+
...(onUnauthorized !== undefined && { onUnauthorized }),
|
|
208
|
+
...(options.fetch ? { fetchImpl: options.fetch } : {}),
|
|
209
|
+
});
|
|
210
|
+
// Service constructors receive the shared event bus so each call
|
|
211
|
+
// path (chat/embeddings/image/audio/video/computer) can emit
|
|
212
|
+
// start/end/error around its network operations. Files and Models
|
|
213
|
+
// intentionally don't take it — they're infrastructure, not
|
|
214
|
+
// user-facing LLM calls, and surfacing them would clutter the
|
|
215
|
+
// badge with /v1/models polls.
|
|
216
|
+
this.chat = new ChatService(transport, this.events);
|
|
217
|
+
this.models = new ModelsService(transport);
|
|
218
|
+
this.image = new ImageService(transport, this.events);
|
|
219
|
+
this.audio = new AudioService(transport, this.events);
|
|
220
|
+
this.video = new VideoService(transport, this.events);
|
|
221
|
+
this.computer = new ComputerService(transport, this.events);
|
|
222
|
+
// `project` also scopes the file URLs (/v1/p/<project>/files/...),
|
|
223
|
+
// so two apps never issue byte-identical file requests. Omitted →
|
|
224
|
+
// legacy unscoped /v1/files/... paths.
|
|
225
|
+
this.files = new FilesService(transport, options.project);
|
|
226
|
+
this.embeddings = new EmbeddingsService(transport, this.events);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** Force a redirect to the runapps.ai grant page. No-op in static
|
|
230
|
+
* auth mode or in Node. */
|
|
231
|
+
signIn(): void {
|
|
232
|
+
this.auth?.signIn();
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Clear the cached token + identity from memory and localStorage.
|
|
237
|
+
* The next API call will trigger sign-in again (redirect to grant
|
|
238
|
+
* page). Wire this to a "Sign out" button in your settings UI.
|
|
239
|
+
*
|
|
240
|
+
* No-op in static auth mode or in Node.
|
|
241
|
+
*/
|
|
242
|
+
signOut(): void {
|
|
243
|
+
this.auth?.signOut();
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** Currently-authenticated user (runapps auth mode), or null. */
|
|
247
|
+
get user(): BrowserUser | null {
|
|
248
|
+
return this.auth?.user ?? null;
|
|
249
|
+
}
|
|
250
|
+
}
|
package/src/computer.ts
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import type { Transport } from "./transport.js";
|
|
2
|
+
import type { Usage } from "./types.js";
|
|
3
|
+
import type { SDKEvents } from "./events.js";
|
|
4
|
+
import { wrapEvents } from "./event-wrap.js";
|
|
5
|
+
|
|
6
|
+
/** Single-step computer-use request — a screenshot + history → next action(s). */
|
|
7
|
+
export interface ComputerStepParams {
|
|
8
|
+
/**
|
|
9
|
+
* Conversation history including screenshots and tool results. Each
|
|
10
|
+
* entry has `role` + `content` (string or array of blocks). Format is
|
|
11
|
+
* intentionally opaque to support both Anthropic and OpenAI computer-use
|
|
12
|
+
* protocols — the gateway adapts.
|
|
13
|
+
*/
|
|
14
|
+
messages: Record<string, unknown>[];
|
|
15
|
+
/** Screen resolution. Defaults to 1920×1080 if omitted. */
|
|
16
|
+
display_width?: number;
|
|
17
|
+
display_height?: number;
|
|
18
|
+
max_tokens?: number;
|
|
19
|
+
enable_zoom?: boolean;
|
|
20
|
+
/** OpenAI Responses-API state chaining. */
|
|
21
|
+
previous_response_id?: string;
|
|
22
|
+
/** Follow-up `computer_call_output` for OpenAI's Responses API. */
|
|
23
|
+
openai_input?: unknown;
|
|
24
|
+
user?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** One block of a computer-use response: text, tool_use (Anthropic), or computer_call (OpenAI). */
|
|
28
|
+
export interface ComputerContentBlock {
|
|
29
|
+
type: string;
|
|
30
|
+
text?: string;
|
|
31
|
+
id?: string;
|
|
32
|
+
name?: string;
|
|
33
|
+
/** Anthropic tool input (raw JSON). */
|
|
34
|
+
input?: unknown;
|
|
35
|
+
call_id?: string;
|
|
36
|
+
/** OpenAI computer-call action. */
|
|
37
|
+
action?: Record<string, unknown>;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface ComputerResponse {
|
|
41
|
+
content: ComputerContentBlock[];
|
|
42
|
+
stop_reason?: string;
|
|
43
|
+
usage: Usage;
|
|
44
|
+
response_id?: string;
|
|
45
|
+
/** "anthropic" | "openai" — which protocol the upstream returned. */
|
|
46
|
+
protocol?: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export class ComputerService {
|
|
50
|
+
constructor(
|
|
51
|
+
private readonly transport: Transport,
|
|
52
|
+
private readonly events: SDKEvents,
|
|
53
|
+
) {}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Execute one step of a computer-use agent loop. Given a screenshot
|
|
57
|
+
* and conversation history, returns the next action(s) the model
|
|
58
|
+
* wants the caller to execute.
|
|
59
|
+
*/
|
|
60
|
+
async step(
|
|
61
|
+
model: string,
|
|
62
|
+
params: ComputerStepParams,
|
|
63
|
+
init?: { signal?: AbortSignal },
|
|
64
|
+
): Promise<ComputerResponse> {
|
|
65
|
+
return wrapEvents(
|
|
66
|
+
this.events,
|
|
67
|
+
{ model, capability: "computer_use" },
|
|
68
|
+
() =>
|
|
69
|
+
this.transport.postJSON<ComputerResponse>(
|
|
70
|
+
"/v1/computer/step",
|
|
71
|
+
{ model, ...params },
|
|
72
|
+
init,
|
|
73
|
+
),
|
|
74
|
+
(r) => ({
|
|
75
|
+
totalTokens: r.usage?.completion_tokens,
|
|
76
|
+
costUSD: r.usage?.total_cost,
|
|
77
|
+
}),
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
}
|