@ryuhq/sdk 0.0.5
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 +179 -0
- package/README.md +31 -0
- package/dist/agent.cjs +761 -0
- package/dist/agent.d.cts +3 -0
- package/dist/agent.d.ts +3 -0
- package/dist/agent.js +23 -0
- package/dist/chunk-GXHL5CO7.js +353 -0
- package/dist/chunk-KPKMMGVC.js +671 -0
- package/dist/chunk-ODFEUVPW.js +100 -0
- package/dist/cli.cjs +858 -0
- package/dist/cli.d.cts +1 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +454 -0
- package/dist/index-CEbS1SlS.d.cts +988 -0
- package/dist/index-DAxq7Y0R.d.ts +988 -0
- package/dist/index.cjs +1900 -0
- package/dist/index.d.cts +759 -0
- package/dist/index.d.ts +759 -0
- package/dist/index.js +771 -0
- package/dist/manifest.cjs +399 -0
- package/dist/manifest.d.cts +355 -0
- package/dist/manifest.d.ts +355 -0
- package/dist/manifest.js +38 -0
- package/package.json +56 -0
- package/src/agent/agent.ts +208 -0
- package/src/agent/index.ts +51 -0
- package/src/agent/loop.test.ts +261 -0
- package/src/agent/loop.ts +259 -0
- package/src/agent/model-call.ts +190 -0
- package/src/agent/query.ts +40 -0
- package/src/agent/tools.ts +295 -0
- package/src/builder.ts +473 -0
- package/src/cli/dev.test.ts +178 -0
- package/src/cli/dev.ts +425 -0
- package/src/cli.ts +390 -0
- package/src/contracts-lockstep.test.ts +77 -0
- package/src/generated/plugin-manifest.ts +1121 -0
- package/src/index.ts +141 -0
- package/src/manifest.test.ts +610 -0
- package/src/manifest.ts +589 -0
- package/src/mcp/bridge.test.ts +196 -0
- package/src/mcp/client.ts +253 -0
- package/src/mcp/fixture-server.ts +23 -0
- package/src/mcp/server.ts +351 -0
- package/src/model/client.test.ts +107 -0
- package/src/model/client.ts +179 -0
- package/src/model/gateway.ts +41 -0
- package/src/plugin/ryu-plugin.ts +191 -0
- package/src/runnable/agent.ts +338 -0
- package/src/runnable/app.ts +233 -0
- package/src/runnable/index.ts +61 -0
- package/src/runnable/primitives-hostapi.test.ts +73 -0
- package/src/runnable/primitives.test.ts +286 -0
- package/src/runnable/primitives.ts +610 -0
- package/src/runnable/runnable-types.ts +113 -0
- package/src/runnable/runnable.test.ts +397 -0
- package/src/runnable/skill.ts +60 -0
- package/src/runnable/tool.ts +260 -0
- package/src/runnable/turn-hook.test.ts +81 -0
- package/src/runnable/turn-hook.ts +191 -0
- package/src/runnable/workflow.ts +76 -0
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SDK MCP stdio server — exposes a set of SDK Runnables (and optional
|
|
3
|
+
* passthrough registrations from Ghost / Shadow) as MCP tools over stdio,
|
|
4
|
+
* so any MCP host can discover and call them.
|
|
5
|
+
*
|
|
6
|
+
* The server speaks the same JSON-RPC 2.0 / newline-delimited protocol as
|
|
7
|
+
* `apps/core/src/sidecar/mcp/client.rs` and as `client.ts` in this package.
|
|
8
|
+
*
|
|
9
|
+
* POLICY NOTE: this server does NOT implement tool-approval, permission grants,
|
|
10
|
+
* or any Gateway-level policy. Approval stays in the chat layer; policy stays
|
|
11
|
+
* in the Gateway (per issue #86). Callers must not route policy decisions
|
|
12
|
+
* through this server.
|
|
13
|
+
*
|
|
14
|
+
* ## Minimal Runnable contract
|
|
15
|
+
*
|
|
16
|
+
* The full `defineAgent / defineWorkflow / defineTool / defineSkill` authoring
|
|
17
|
+
* API is delivered by issue #205. This unit only needs the *consume side*:
|
|
18
|
+
* an executable object with a name and a `run()` method. Once #205 ships, its
|
|
19
|
+
* builders will produce objects that satisfy this same interface.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { createInterface } from "node:readline";
|
|
23
|
+
import type { McpStdioCommand, McpTool } from "./client.ts";
|
|
24
|
+
import { callTool, listTools, MCP_PROTOCOL_VERSION } from "./client.ts";
|
|
25
|
+
|
|
26
|
+
// ── Minimal Runnable contract ─────────────────────────────────────────────────
|
|
27
|
+
|
|
28
|
+
/** JSON Schema fragment — enough to describe a tool's input arguments. */
|
|
29
|
+
export interface JsonSchema {
|
|
30
|
+
description?: string;
|
|
31
|
+
properties?: Record<string, JsonSchema>;
|
|
32
|
+
required?: string[];
|
|
33
|
+
type?: string;
|
|
34
|
+
[key: string]: unknown;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The minimal executable Runnable interface consumed by this bridge.
|
|
39
|
+
*
|
|
40
|
+
* When #205 ships its `defineAgent / defineTool / ...` API, those builders must
|
|
41
|
+
* return objects that satisfy this interface so they plug straight in here
|
|
42
|
+
* without any adapter.
|
|
43
|
+
*/
|
|
44
|
+
export interface SdkRunnable {
|
|
45
|
+
/** Human-readable description shown to MCP hosts. */
|
|
46
|
+
description?: string;
|
|
47
|
+
/** JSON Schema for the tool's input arguments. */
|
|
48
|
+
inputSchema?: JsonSchema;
|
|
49
|
+
/** Stable, unique tool name (no spaces; used as the MCP tool name). */
|
|
50
|
+
name: string;
|
|
51
|
+
/**
|
|
52
|
+
* Execute the runnable with the given arguments and return a result.
|
|
53
|
+
* The result is JSON-encoded into an MCP `text` content block.
|
|
54
|
+
*/
|
|
55
|
+
run(args: unknown): Promise<unknown>;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// ── Passthrough (Ghost / Shadow) ──────────────────────────────────────────────
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* A passthrough registration forwards `tools/list` + `tools/call` to a remote
|
|
62
|
+
* MCP server (e.g. Ghost at its stdio command, or Shadow at :3030). The tools
|
|
63
|
+
* are re-advertised under their original names; calls are forwarded verbatim.
|
|
64
|
+
*
|
|
65
|
+
* This is the mechanism by which orphaned Ghost (29 computer-use tools) and
|
|
66
|
+
* Shadow (:3030 capture/search tools) can be advertised to any MCP host without
|
|
67
|
+
* embedding their implementation in the SDK.
|
|
68
|
+
*/
|
|
69
|
+
export interface PassthroughRegistration {
|
|
70
|
+
/** Command descriptor for the upstream MCP stdio server. */
|
|
71
|
+
command: McpStdioCommand;
|
|
72
|
+
/** Label used in error messages (e.g. "ghost", "shadow"). */
|
|
73
|
+
label: string;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ── JSON-RPC helpers ──────────────────────────────────────────────────────────
|
|
77
|
+
|
|
78
|
+
interface JsonRpcRequest {
|
|
79
|
+
id?: number | string | null;
|
|
80
|
+
jsonrpc: "2.0";
|
|
81
|
+
method: string;
|
|
82
|
+
params?: unknown;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
interface JsonRpcResponse {
|
|
86
|
+
error?: { code: number; message: string };
|
|
87
|
+
id: number | string | null;
|
|
88
|
+
jsonrpc: "2.0";
|
|
89
|
+
result?: unknown;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function respond(
|
|
93
|
+
id: number | string | null | undefined,
|
|
94
|
+
result: unknown
|
|
95
|
+
): JsonRpcResponse {
|
|
96
|
+
return { jsonrpc: "2.0", id: id ?? null, result };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function respondError(
|
|
100
|
+
id: number | string | null | undefined,
|
|
101
|
+
code: number,
|
|
102
|
+
message: string
|
|
103
|
+
): JsonRpcResponse {
|
|
104
|
+
return { jsonrpc: "2.0", id: id ?? null, error: { code, message } };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Wrap a `run()` result in the MCP `tools/call` content-block envelope. */
|
|
108
|
+
function wrapContent(value: unknown): {
|
|
109
|
+
content: { type: string; text: string }[];
|
|
110
|
+
} {
|
|
111
|
+
return {
|
|
112
|
+
content: [
|
|
113
|
+
{
|
|
114
|
+
type: "text",
|
|
115
|
+
text: typeof value === "string" ? value : JSON.stringify(value),
|
|
116
|
+
},
|
|
117
|
+
],
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Unwrap an MCP `tools/call` content-block envelope back to a plain value.
|
|
123
|
+
* If the text is valid JSON it is parsed; otherwise the raw string is returned.
|
|
124
|
+
*
|
|
125
|
+
* This is exported so tests can verify that `decode(wrapContent(x))` round-trips
|
|
126
|
+
* back to `x` and that `client.callTool()` output matches a direct `run()`.
|
|
127
|
+
*/
|
|
128
|
+
export function unwrapContent(raw: unknown): unknown {
|
|
129
|
+
const r = raw as { content?: { type: string; text: string }[] } | null;
|
|
130
|
+
const text = r?.content?.[0]?.text;
|
|
131
|
+
if (text === undefined) {
|
|
132
|
+
return raw;
|
|
133
|
+
}
|
|
134
|
+
try {
|
|
135
|
+
return JSON.parse(text);
|
|
136
|
+
} catch {
|
|
137
|
+
return text;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// ── McpServer ─────────────────────────────────────────────────────────────────
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* An MCP stdio server that exposes SDK Runnables and optional passthrough
|
|
145
|
+
* registrations as MCP tools.
|
|
146
|
+
*
|
|
147
|
+
* @example
|
|
148
|
+
* ```ts
|
|
149
|
+
* import { McpServer } from "@ryuhq/sdk/mcp/server"
|
|
150
|
+
*
|
|
151
|
+
* const server = new McpServer()
|
|
152
|
+
* .register({ name: "greet", run: (a) => Promise.resolve(`Hello!`) })
|
|
153
|
+
*
|
|
154
|
+
* await server.serve() // reads stdin, writes stdout until EOF
|
|
155
|
+
* ```
|
|
156
|
+
*/
|
|
157
|
+
export class McpServer {
|
|
158
|
+
private readonly runnables = new Map<string, SdkRunnable>();
|
|
159
|
+
private readonly passthroughs: PassthroughRegistration[] = [];
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Register an SDK Runnable as an MCP tool.
|
|
163
|
+
* Returns `this` for chaining.
|
|
164
|
+
*/
|
|
165
|
+
register(runnable: SdkRunnable): this {
|
|
166
|
+
this.runnables.set(runnable.name, runnable);
|
|
167
|
+
return this;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Register a passthrough to an external MCP stdio server (e.g. Ghost or
|
|
172
|
+
* Shadow). Tools from that server are fetched lazily and re-advertised.
|
|
173
|
+
* Returns `this` for chaining.
|
|
174
|
+
*/
|
|
175
|
+
passthrough(registration: PassthroughRegistration): this {
|
|
176
|
+
this.passthroughs.push(registration);
|
|
177
|
+
return this;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Fetch all tools: local Runnables + passthrough tools. */
|
|
181
|
+
private async allTools(): Promise<McpTool[]> {
|
|
182
|
+
const local: McpTool[] = [...this.runnables.values()].map((r) => ({
|
|
183
|
+
name: r.name,
|
|
184
|
+
description: r.description,
|
|
185
|
+
inputSchema: r.inputSchema,
|
|
186
|
+
}));
|
|
187
|
+
|
|
188
|
+
const remote: McpTool[] = [];
|
|
189
|
+
for (const pt of this.passthroughs) {
|
|
190
|
+
try {
|
|
191
|
+
const tools = await listTools(pt.command);
|
|
192
|
+
remote.push(...tools);
|
|
193
|
+
} catch (err) {
|
|
194
|
+
// Degrade gracefully: log but don't crash the server.
|
|
195
|
+
process.stderr.write(
|
|
196
|
+
`[mcp-server] passthrough '${pt.label}' list_tools failed: ${err}\n`
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
return [...local, ...remote];
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Handle a `tools/call` request. */
|
|
205
|
+
private async handleCallTool(name: string, args: unknown): Promise<unknown> {
|
|
206
|
+
const local = this.runnables.get(name);
|
|
207
|
+
if (local) {
|
|
208
|
+
const result = await local.run(args);
|
|
209
|
+
return wrapContent(result);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Not a local Runnable — try passthrough servers in order.
|
|
213
|
+
for (const pt of this.passthroughs) {
|
|
214
|
+
try {
|
|
215
|
+
const tools = await listTools(pt.command);
|
|
216
|
+
if (tools.some((t) => t.name === name)) {
|
|
217
|
+
return await callTool(pt.command, name, args);
|
|
218
|
+
}
|
|
219
|
+
} catch {
|
|
220
|
+
// continue to next passthrough
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
throw new Error(`Unknown tool: ${name}`);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Handle a single parsed JSON-RPC request line. Returns the response to write
|
|
229
|
+
* (or null for notifications that require no response).
|
|
230
|
+
*/
|
|
231
|
+
private async handleRequest(
|
|
232
|
+
req: JsonRpcRequest,
|
|
233
|
+
initialized: { value: boolean },
|
|
234
|
+
write: (obj: unknown) => void
|
|
235
|
+
): Promise<void> {
|
|
236
|
+
const { id, method, params } = req;
|
|
237
|
+
|
|
238
|
+
if (method === "initialize") {
|
|
239
|
+
initialized.value = true;
|
|
240
|
+
write(
|
|
241
|
+
respond(id, {
|
|
242
|
+
protocolVersion: MCP_PROTOCOL_VERSION,
|
|
243
|
+
capabilities: { tools: {} },
|
|
244
|
+
serverInfo: { name: "ryu-sdk-server", version: "0.0.1" },
|
|
245
|
+
})
|
|
246
|
+
);
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
if (method === "notifications/initialized") {
|
|
251
|
+
// Notification — no response required.
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
if (!initialized.value) {
|
|
256
|
+
write(respondError(id, -32_002, "Server not initialized"));
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
if (method === "tools/list") {
|
|
261
|
+
try {
|
|
262
|
+
const tools = await this.allTools();
|
|
263
|
+
write(respond(id, { tools }));
|
|
264
|
+
} catch (err) {
|
|
265
|
+
write(respondError(id, -32_603, String(err)));
|
|
266
|
+
}
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
if (method === "tools/call") {
|
|
271
|
+
await this.handleToolsCall(id, params, write);
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
write(respondError(id, -32_601, `Method not found: ${method}`));
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** Handle a `tools/call` JSON-RPC request. */
|
|
279
|
+
private async handleToolsCall(
|
|
280
|
+
id: number | string | null | undefined,
|
|
281
|
+
params: unknown,
|
|
282
|
+
write: (obj: unknown) => void
|
|
283
|
+
): Promise<void> {
|
|
284
|
+
const p = params as { name?: string; arguments?: unknown } | undefined;
|
|
285
|
+
const toolName = p?.name;
|
|
286
|
+
const toolArgs = p?.arguments ?? {};
|
|
287
|
+
if (typeof toolName !== "string") {
|
|
288
|
+
write(respondError(id, -32_602, "tools/call requires 'name'"));
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
try {
|
|
292
|
+
const result = await this.handleCallTool(toolName, toolArgs);
|
|
293
|
+
write(respond(id, result));
|
|
294
|
+
} catch (err) {
|
|
295
|
+
write(
|
|
296
|
+
respond(id, {
|
|
297
|
+
content: [{ type: "text", text: String(err) }],
|
|
298
|
+
isError: true,
|
|
299
|
+
})
|
|
300
|
+
);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Start reading JSON-RPC requests from the given readable stream and writing
|
|
306
|
+
* responses to the given writable stream.
|
|
307
|
+
*
|
|
308
|
+
* Defaults to `process.stdin` / `process.stdout`. Passing explicit streams
|
|
309
|
+
* lets tests inject a pair of in-process pipes.
|
|
310
|
+
*
|
|
311
|
+
* Resolves when the input stream ends (EOF).
|
|
312
|
+
*/
|
|
313
|
+
serve(
|
|
314
|
+
input: NodeJS.ReadableStream = process.stdin,
|
|
315
|
+
output: NodeJS.WritableStream = process.stdout
|
|
316
|
+
): Promise<void> {
|
|
317
|
+
const write = (obj: unknown) => {
|
|
318
|
+
output.write(`${JSON.stringify(obj)}\n`);
|
|
319
|
+
};
|
|
320
|
+
|
|
321
|
+
const initialized = { value: false };
|
|
322
|
+
|
|
323
|
+
return new Promise<void>((resolve) => {
|
|
324
|
+
const rl = createInterface({
|
|
325
|
+
input,
|
|
326
|
+
crlfDelay: Number.POSITIVE_INFINITY,
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
rl.on("line", (rawLine) => {
|
|
330
|
+
const line = rawLine.trim();
|
|
331
|
+
if (!line) {
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
let req: JsonRpcRequest;
|
|
336
|
+
try {
|
|
337
|
+
req = JSON.parse(line) as JsonRpcRequest;
|
|
338
|
+
} catch {
|
|
339
|
+
write(respondError(null, -32_700, "Parse error"));
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
this.handleRequest(req, initialized, write).catch((err) => {
|
|
344
|
+
write(respondError(req.id, -32_603, String(err)));
|
|
345
|
+
});
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
rl.on("close", resolve);
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for the gateway-mandatory model client (TS layer).
|
|
3
|
+
*
|
|
4
|
+
* The transport is now the Rust core (`@ryuhq/sdk-native` → `crates/ryu-sdk`), so
|
|
5
|
+
* the wire-shape and SSE-parsing assertions live in the Rust crate's tests
|
|
6
|
+
* (`cargo test -p ryu-sdk`) — they cannot be exercised here by mocking JS
|
|
7
|
+
* `fetch`, because the native client uses reqwest, not `fetch`. These tests
|
|
8
|
+
* cover what the TS layer is responsible for: egress enforcement (delegated to
|
|
9
|
+
* Rust), the public client/factory shape, and base-URL/model resolution.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { describe, expect, it } from "bun:test";
|
|
13
|
+
import { defineModel } from "./client.ts";
|
|
14
|
+
import { assertAllowedEgressUrl } from "./gateway.ts";
|
|
15
|
+
|
|
16
|
+
// Top-level regex constant (avoids lint/performance/useTopLevelRegex)
|
|
17
|
+
const RE_EGRESS_BLOCKED = /egress is not allowed/i;
|
|
18
|
+
|
|
19
|
+
const MOCK_GATEWAY = "http://127.0.0.1:7981";
|
|
20
|
+
|
|
21
|
+
// ── Egress enforcement (delegated to the Rust core) ───────────────────────────
|
|
22
|
+
|
|
23
|
+
describe("egress enforcement", () => {
|
|
24
|
+
it("rejects api.openai.com as a base URL", () => {
|
|
25
|
+
expect(() =>
|
|
26
|
+
defineModel("gpt-4o", { baseUrl: "https://api.openai.com/v1" })
|
|
27
|
+
).toThrow(RE_EGRESS_BLOCKED);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("rejects api.anthropic.com as a base URL", () => {
|
|
31
|
+
expect(() =>
|
|
32
|
+
defineModel("claude-3-5-sonnet", {
|
|
33
|
+
baseUrl: "https://api.anthropic.com/v1",
|
|
34
|
+
})
|
|
35
|
+
).toThrow(RE_EGRESS_BLOCKED);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("rejects generativelanguage.googleapis.com", () => {
|
|
39
|
+
expect(() =>
|
|
40
|
+
defineModel("gemini-2.5-flash", {
|
|
41
|
+
baseUrl: "https://generativelanguage.googleapis.com/v1beta",
|
|
42
|
+
})
|
|
43
|
+
).toThrow(RE_EGRESS_BLOCKED);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("rejects openrouter.ai", () => {
|
|
47
|
+
expect(() =>
|
|
48
|
+
defineModel("gpt-4o", { baseUrl: "https://openrouter.ai/api/v1" })
|
|
49
|
+
).toThrow(RE_EGRESS_BLOCKED);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("allows a loopback gateway URL", () => {
|
|
53
|
+
expect(() =>
|
|
54
|
+
defineModel("gpt-4o", { baseUrl: "http://127.0.0.1:7981" })
|
|
55
|
+
).not.toThrow();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("allows localhost gateway URL", () => {
|
|
59
|
+
expect(() =>
|
|
60
|
+
defineModel("gpt-4o", { baseUrl: "http://localhost:7981" })
|
|
61
|
+
).not.toThrow();
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("assertAllowedEgressUrl throws on direct provider URL", () => {
|
|
65
|
+
expect(() => assertAllowedEgressUrl("https://api.openai.com/v1")).toThrow(
|
|
66
|
+
RE_EGRESS_BLOCKED
|
|
67
|
+
);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("assertAllowedEgressUrl passes for loopback", () => {
|
|
71
|
+
expect(() => assertAllowedEgressUrl("http://127.0.0.1:7981")).not.toThrow();
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
// ── Client / factory shape ────────────────────────────────────────────────────
|
|
76
|
+
|
|
77
|
+
describe("ModelClient shape", () => {
|
|
78
|
+
it("exposes chat() and stream() backed by the native core", () => {
|
|
79
|
+
const client = defineModel("gpt-4o", { baseUrl: MOCK_GATEWAY });
|
|
80
|
+
expect(typeof client.chat).toBe("function");
|
|
81
|
+
expect(typeof client.stream).toBe("function");
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("reports the configured model id", () => {
|
|
85
|
+
const client = defineModel("claude-3-5-sonnet", { baseUrl: MOCK_GATEWAY });
|
|
86
|
+
expect(client.model).toBe("claude-3-5-sonnet");
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
// ── defineModel factory ───────────────────────────────────────────────────────
|
|
91
|
+
|
|
92
|
+
describe("defineModel", () => {
|
|
93
|
+
it("returns a ModelClient with the given model id", () => {
|
|
94
|
+
const client = defineModel("claude-3-5-sonnet", { baseUrl: MOCK_GATEWAY });
|
|
95
|
+
expect(client.model).toBe("claude-3-5-sonnet");
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("uses the default gateway URL when no baseUrl is given", () => {
|
|
99
|
+
const prev = process.env.RYU_GATEWAY_URL;
|
|
100
|
+
process.env.RYU_GATEWAY_URL = "";
|
|
101
|
+
|
|
102
|
+
const client = defineModel("gpt-4o");
|
|
103
|
+
expect(client.baseUrl).toBe("http://127.0.0.1:7981");
|
|
104
|
+
|
|
105
|
+
process.env.RYU_GATEWAY_URL = prev ?? "";
|
|
106
|
+
});
|
|
107
|
+
});
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Gateway-mandatory model client for the Ryu SDK.
|
|
3
|
+
*
|
|
4
|
+
* Rust-cored: this is a thin TypeScript wrapper over the `@ryuhq/sdk-native`
|
|
5
|
+
* addon's `ModelClient`, which is the `crates/ryu-sdk` Rust core. Every model
|
|
6
|
+
* call is routed by the Rust core to the Ryu gateway's OpenAI-compatible
|
|
7
|
+
* `/v1/chat/completions` endpoint; direct-provider base URLs are rejected at
|
|
8
|
+
* construction. No provider SDK or base URL is ever imported here.
|
|
9
|
+
*
|
|
10
|
+
* Usage:
|
|
11
|
+
*
|
|
12
|
+
* const model = defineModel("gpt-4o");
|
|
13
|
+
* const reply = await model.chat([{ role: "user", content: "hello" }]);
|
|
14
|
+
* for await (const delta of model.stream([{ role: "user", content: "hi" }])) {
|
|
15
|
+
* process.stdout.write(delta.content);
|
|
16
|
+
* }
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import * as native from "@ryuhq/sdk-native";
|
|
20
|
+
|
|
21
|
+
// ── Wire types (OpenAI-compat subset) ─────────────────────────────────────────
|
|
22
|
+
|
|
23
|
+
/** A single message in a chat conversation. */
|
|
24
|
+
export interface ChatMessage {
|
|
25
|
+
/** The message text. */
|
|
26
|
+
content: string;
|
|
27
|
+
/** The speaker: "system", "user", or "assistant". */
|
|
28
|
+
role: "system" | "user" | "assistant";
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** A streaming chat completion delta. */
|
|
32
|
+
export interface ChatDelta {
|
|
33
|
+
/** Incremental text fragment from the model. */
|
|
34
|
+
content: string | null;
|
|
35
|
+
/** Non-null on the final chunk when `finish_reason` is set. */
|
|
36
|
+
finishReason: string | null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Non-streaming chat completion result. */
|
|
40
|
+
export interface ChatResult {
|
|
41
|
+
/** The full assistant reply text. */
|
|
42
|
+
content: string;
|
|
43
|
+
/** The gateway/model-reported finish reason. */
|
|
44
|
+
finishReason: string | null;
|
|
45
|
+
/** Usage stats as reported by the gateway (optional — gateway may omit). */
|
|
46
|
+
usage?: {
|
|
47
|
+
promptTokens: number;
|
|
48
|
+
completionTokens: number;
|
|
49
|
+
totalTokens: number;
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Options accepted by `defineModel`. */
|
|
54
|
+
export interface ModelClientOptions {
|
|
55
|
+
/**
|
|
56
|
+
* Gateway base URL (no trailing `/v1`). Defaults to `RYU_GATEWAY_URL` then
|
|
57
|
+
* the Rust core's default. Direct provider URLs are rejected at construction.
|
|
58
|
+
*/
|
|
59
|
+
baseUrl?: string;
|
|
60
|
+
/**
|
|
61
|
+
* Bearer token forwarded to the gateway. Defaults to `RYU_GATEWAY_TOKEN`.
|
|
62
|
+
* This is the gateway token, never a provider API key.
|
|
63
|
+
*/
|
|
64
|
+
token?: string;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// ── ModelClient ───────────────────────────────────────────────────────────────
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* A gateway-mandatory model client backed by the Rust core.
|
|
71
|
+
*
|
|
72
|
+
* All calls go through the native `ModelClient`; if the configured base URL is a
|
|
73
|
+
* direct provider, construction throws.
|
|
74
|
+
*/
|
|
75
|
+
export class ModelClient {
|
|
76
|
+
readonly model: string;
|
|
77
|
+
readonly baseUrl: string;
|
|
78
|
+
private readonly native: native.ModelClient;
|
|
79
|
+
|
|
80
|
+
constructor(model: string, options: ModelClientOptions = {}) {
|
|
81
|
+
// Constructing the native client validates egress and resolves the base
|
|
82
|
+
// URL/token in the Rust core.
|
|
83
|
+
this.native = new native.ModelClient(
|
|
84
|
+
model,
|
|
85
|
+
options.baseUrl ?? null,
|
|
86
|
+
options.token ?? null
|
|
87
|
+
);
|
|
88
|
+
this.model = model;
|
|
89
|
+
this.baseUrl = options.baseUrl ?? native.resolveGatewayUrl();
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Send a non-streaming chat completion request to the gateway. */
|
|
93
|
+
async chat(messages: ChatMessage[]): Promise<ChatResult> {
|
|
94
|
+
const res = await this.native.chat(messages);
|
|
95
|
+
const usage =
|
|
96
|
+
res.promptTokens === undefined &&
|
|
97
|
+
res.completionTokens === undefined &&
|
|
98
|
+
res.totalTokens === undefined
|
|
99
|
+
? undefined
|
|
100
|
+
: {
|
|
101
|
+
promptTokens: res.promptTokens ?? 0,
|
|
102
|
+
completionTokens: res.completionTokens ?? 0,
|
|
103
|
+
totalTokens: res.totalTokens ?? 0,
|
|
104
|
+
};
|
|
105
|
+
return {
|
|
106
|
+
content: res.content,
|
|
107
|
+
finishReason: res.finishReason ?? null,
|
|
108
|
+
usage,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Send a streaming chat completion request, yielding deltas as they arrive. */
|
|
113
|
+
async *stream(messages: ChatMessage[]): AsyncGenerator<ChatDelta> {
|
|
114
|
+
// Bridge the native push-callback into a pull async-generator via a small
|
|
115
|
+
// queue. The native side calls back with `null` to signal clean end and
|
|
116
|
+
// with an Error on failure.
|
|
117
|
+
const queue: ChatDelta[] = [];
|
|
118
|
+
let done = false;
|
|
119
|
+
let failure: Error | null = null;
|
|
120
|
+
let wake: (() => void) | null = null;
|
|
121
|
+
const notify = () => {
|
|
122
|
+
if (wake) {
|
|
123
|
+
const w = wake;
|
|
124
|
+
wake = null;
|
|
125
|
+
w();
|
|
126
|
+
}
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
this.native.stream(messages, (err, delta) => {
|
|
130
|
+
if (err) {
|
|
131
|
+
failure = err;
|
|
132
|
+
done = true;
|
|
133
|
+
} else if (delta === null || delta === undefined) {
|
|
134
|
+
done = true;
|
|
135
|
+
} else {
|
|
136
|
+
queue.push({
|
|
137
|
+
content: delta.content ?? null,
|
|
138
|
+
finishReason: delta.finishReason ?? null,
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
notify();
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
while (true) {
|
|
145
|
+
while (queue.length > 0) {
|
|
146
|
+
const next = queue.shift();
|
|
147
|
+
if (next) {
|
|
148
|
+
yield next;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
if (failure) {
|
|
152
|
+
throw failure;
|
|
153
|
+
}
|
|
154
|
+
if (done) {
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
await new Promise<void>((resolve) => {
|
|
158
|
+
wake = resolve;
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// ── Factory ─────────────────────────────────────────────────────────────────
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Create a gateway-mandatory model client for `modelId`.
|
|
168
|
+
*
|
|
169
|
+
* const model = defineModel("gpt-4o");
|
|
170
|
+
* const model = defineModel("claude-3-5-sonnet", { baseUrl: "http://my-gateway:7981" });
|
|
171
|
+
*
|
|
172
|
+
* A direct provider URL throws immediately (egress enforcement in the Rust core).
|
|
173
|
+
*/
|
|
174
|
+
export function defineModel(
|
|
175
|
+
modelId: string,
|
|
176
|
+
options: ModelClientOptions = {}
|
|
177
|
+
): ModelClient {
|
|
178
|
+
return new ModelClient(modelId, options);
|
|
179
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Gateway configuration + egress enforcement for the Ryu SDK model client.
|
|
3
|
+
*
|
|
4
|
+
* Rust-cored: every function here delegates to the `@ryuhq/sdk-native` addon, so
|
|
5
|
+
* the gateway URL/token resolution and the direct-provider egress blocklist are
|
|
6
|
+
* the exact same implementation (`crates/ryu-sdk/src/gateway.rs`) used by the
|
|
7
|
+
* Go/Python bindings and Core itself — one source of truth, no drift.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import * as native from "@ryuhq/sdk-native";
|
|
11
|
+
|
|
12
|
+
/** Default base URL for the local Ryu gateway — matches Core's DEFAULT_GATEWAY_URL. */
|
|
13
|
+
export const DEFAULT_GATEWAY_URL = "http://127.0.0.1:7981";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Resolve the effective gateway base URL.
|
|
17
|
+
*
|
|
18
|
+
* Resolution order (in the Rust core):
|
|
19
|
+
* 1. `RYU_GATEWAY_URL` env var (when non-empty).
|
|
20
|
+
* 2. `DEFAULT_GATEWAY_URL`.
|
|
21
|
+
*/
|
|
22
|
+
export function resolveGatewayUrl(): string {
|
|
23
|
+
return native.resolveGatewayUrl();
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Resolve the optional gateway bearer token (`RYU_GATEWAY_TOKEN`), or
|
|
28
|
+
* `undefined` when unset/empty.
|
|
29
|
+
*/
|
|
30
|
+
export function resolveGatewayToken(): string | undefined {
|
|
31
|
+
return native.resolveGatewayToken() ?? undefined;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Validate that `baseUrl` is an allowed egress target. Throws a descriptive
|
|
36
|
+
* `Error` (from the Rust core) when the URL matches a known direct-provider
|
|
37
|
+
* pattern, enforcing the BYOK-at-the-gateway rule.
|
|
38
|
+
*/
|
|
39
|
+
export function assertAllowedEgressUrl(baseUrl: string): void {
|
|
40
|
+
native.assertAllowedEgress(baseUrl);
|
|
41
|
+
}
|