@checkstack/ai-backend 0.1.0

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.
Files changed (106) hide show
  1. package/CHANGELOG.md +97 -0
  2. package/drizzle/0000_productive_jackpot.sql +26 -0
  3. package/drizzle/0001_puzzling_purple_man.sql +26 -0
  4. package/drizzle/0002_sparkling_paper_doll.sql +15 -0
  5. package/drizzle/0003_married_senator_kelly.sql +1 -0
  6. package/drizzle/0004_crazy_miek.sql +2 -0
  7. package/drizzle/0005_tearful_randall_flagg.sql +1 -0
  8. package/drizzle/meta/0000_snapshot.json +232 -0
  9. package/drizzle/meta/0001_snapshot.json +434 -0
  10. package/drizzle/meta/0002_snapshot.json +551 -0
  11. package/drizzle/meta/0003_snapshot.json +557 -0
  12. package/drizzle/meta/0004_snapshot.json +573 -0
  13. package/drizzle/meta/0005_snapshot.json +574 -0
  14. package/drizzle/meta/_journal.json +48 -0
  15. package/drizzle.config.ts +7 -0
  16. package/package.json +42 -0
  17. package/src/agent-runner.test.ts +262 -0
  18. package/src/agent-runner.ts +262 -0
  19. package/src/chat/agent-loop.test.ts +119 -0
  20. package/src/chat/agent-loop.ts +73 -0
  21. package/src/chat/auto-apply.test.ts +237 -0
  22. package/src/chat/chat-handler.ts +111 -0
  23. package/src/chat/chat-service.streamturn.test.ts +417 -0
  24. package/src/chat/chat-service.test.ts +250 -0
  25. package/src/chat/chat-service.ts +923 -0
  26. package/src/chat/classifier-service.ts +64 -0
  27. package/src/chat/classifier.logic.test.ts +92 -0
  28. package/src/chat/classifier.logic.ts +71 -0
  29. package/src/chat/conversation-store.it.test.ts +203 -0
  30. package/src/chat/conversation-store.test.ts +248 -0
  31. package/src/chat/conversation-store.ts +237 -0
  32. package/src/chat/decision.logic.test.ts +45 -0
  33. package/src/chat/decision.logic.ts +54 -0
  34. package/src/chat/llm-provider.test.ts +63 -0
  35. package/src/chat/llm-provider.ts +67 -0
  36. package/src/chat/model-error.logic.test.ts +60 -0
  37. package/src/chat/model-error.logic.ts +65 -0
  38. package/src/chat/normalize-messages.logic.test.ts +101 -0
  39. package/src/chat/normalize-messages.logic.ts +65 -0
  40. package/src/chat/permission-mode.logic.test.ts +70 -0
  41. package/src/chat/permission-mode.logic.ts +45 -0
  42. package/src/chat/read-invoker.ts +72 -0
  43. package/src/chat/replay.test.ts +174 -0
  44. package/src/chat/scrub-content.test.ts +183 -0
  45. package/src/chat/scrub-content.ts +154 -0
  46. package/src/chat/sdk-tools.test.ts +168 -0
  47. package/src/chat/sdk-tools.ts +181 -0
  48. package/src/chat/title-service.test.ts +146 -0
  49. package/src/chat/title-service.ts +111 -0
  50. package/src/chat/title.logic.test.ts +98 -0
  51. package/src/chat/title.logic.ts +102 -0
  52. package/src/extension-points.ts +41 -0
  53. package/src/generated/docs-index.ts +3020 -0
  54. package/src/hardening/handler-authz.test.ts +282 -0
  55. package/src/hardening/no-secret-leak.test.ts +303 -0
  56. package/src/hooks.ts +33 -0
  57. package/src/index.ts +542 -0
  58. package/src/mcp/connection-registry.test.ts +25 -0
  59. package/src/mcp/connection-registry.ts +54 -0
  60. package/src/mcp/mcp-conformance.it.test.ts +128 -0
  61. package/src/mcp/server.test.ts +285 -0
  62. package/src/mcp/server.ts +300 -0
  63. package/src/mcp/tool-invoker.ts +65 -0
  64. package/src/openai-provider.test.ts +64 -0
  65. package/src/openai-provider.ts +146 -0
  66. package/src/projection.test.ts +97 -0
  67. package/src/projection.ts +132 -0
  68. package/src/propose-apply/args-hash.test.ts +26 -0
  69. package/src/propose-apply/args-hash.ts +30 -0
  70. package/src/propose-apply/service.test.ts +423 -0
  71. package/src/propose-apply/service.ts +419 -0
  72. package/src/propose-apply/store.test.ts +136 -0
  73. package/src/propose-apply/store.ts +224 -0
  74. package/src/propose-apply/token.test.ts +52 -0
  75. package/src/propose-apply/token.ts +71 -0
  76. package/src/rate-limit/spend-ledger.it.test.ts +224 -0
  77. package/src/rate-limit/spend-ledger.test.ts +176 -0
  78. package/src/rate-limit/spend-ledger.ts +162 -0
  79. package/src/rate-limit/tool-budget.it.test.ts +173 -0
  80. package/src/rate-limit/tool-budget.test.ts +58 -0
  81. package/src/rate-limit/tool-budget.ts +107 -0
  82. package/src/registry-wiring.test.ts +131 -0
  83. package/src/registry-wiring.ts +68 -0
  84. package/src/resolver.test.ts +156 -0
  85. package/src/resolver.ts +78 -0
  86. package/src/router.test.ts +78 -0
  87. package/src/router.ts +345 -0
  88. package/src/schema.ts +284 -0
  89. package/src/serializer.test.ts +88 -0
  90. package/src/serializer.ts +42 -0
  91. package/src/tool-registry.ts +58 -0
  92. package/src/tools/composite-tools.ts +24 -0
  93. package/src/tools/docs-tools.test.ts +150 -0
  94. package/src/tools/docs-tools.ts +115 -0
  95. package/src/tools/probe-url.test.ts +51 -0
  96. package/src/tools/probe-url.ts +146 -0
  97. package/src/tools/rank-docs.test.ts +153 -0
  98. package/src/tools/rank-docs.ts +209 -0
  99. package/src/tools/script-context-extract.test.ts +93 -0
  100. package/src/tools/script-context-extract.ts +283 -0
  101. package/src/tools/ssrf-guard.test.ts +69 -0
  102. package/src/tools/ssrf-guard.ts +108 -0
  103. package/src/tools/tool-set.e2e.test.ts +64 -0
  104. package/src/user-rpc-client.test.ts +45 -0
  105. package/src/user-rpc-client.ts +60 -0
  106. package/tsconfig.json +26 -0
@@ -0,0 +1,300 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { extractErrorMessage } from "@checkstack/common";
3
+ import {
4
+ opaqueBearerToken,
5
+ type AuthService,
6
+ type AuthUser,
7
+ } from "@checkstack/backend-api";
8
+ import { serializeTools } from "../serializer";
9
+ import { hashToolArgs } from "../propose-apply/args-hash";
10
+ import type { AiToolResolver } from "../resolver";
11
+ import type { RegisteredAiTool } from "../tool-registry";
12
+ import type { McpToolInvoker } from "./tool-invoker";
13
+ import type { McpConnectionRegistry } from "./connection-registry";
14
+
15
+ /**
16
+ * A read-only tool exposed over MCP, bound to the live oRPC procedure that
17
+ * actually runs it. The `tool` carries the model-facing schema + the access
18
+ * rules the resolver filters on; `pluginId` / `procedureKey` route the
19
+ * `tools/call` re-entry through the live router (handler authz preserved).
20
+ */
21
+ export interface McpExecutableTool {
22
+ tool: RegisteredAiTool;
23
+ pluginId: string;
24
+ procedureKey: string;
25
+ }
26
+
27
+ const JSONRPC_VERSION = "2.0";
28
+ const MCP_PROTOCOL_VERSION = "2025-06-18";
29
+ const MCP_SESSION_HEADER = "mcp-session-id";
30
+
31
+ /** JSON-RPC 2.0 + MCP error codes (named to avoid magic numbers). */
32
+ const RPC_PARSE_ERROR = -32_700;
33
+ const RPC_METHOD_NOT_FOUND = -32_601;
34
+ const RPC_INVALID_PARAMS = -32_602;
35
+ const RPC_UNAUTHORIZED = -32_001;
36
+ const RPC_FORBIDDEN = -32_003;
37
+ /** Custom application error for an exceeded per-principal tool budget (§14.5). */
38
+ const RPC_RATE_LIMITED = -32_010;
39
+
40
+ /** The subset of an AuthUser the budget keys on. */
41
+ interface BudgetPrincipal {
42
+ kind: "user" | "application";
43
+ id: string;
44
+ }
45
+
46
+ /**
47
+ * Per-principal tool-budget enforcer (§14.5). Resolves when the call is within
48
+ * budget; rejects with a {@link Error} when over budget. Optional so the
49
+ * handler stays testable without a Postgres connection.
50
+ */
51
+ export type McpBudgetEnforcer = (
52
+ principal: BudgetPrincipal,
53
+ ) => Promise<void>;
54
+
55
+ /** Records a directly-executed read tool call into the audit log (optional). */
56
+ export type McpRecordExecuted = (args: {
57
+ principal: BudgetPrincipal;
58
+ toolName: string;
59
+ argsHash: string;
60
+ }) => Promise<void>;
61
+
62
+ interface JsonRpcRequest {
63
+ jsonrpc: string;
64
+ id?: string | number | null;
65
+ method: string;
66
+ params?: Record<string, unknown>;
67
+ }
68
+
69
+ type JsonRpcId = string | number | null;
70
+
71
+ function rpcResult(id: JsonRpcId, result: unknown) {
72
+ return { jsonrpc: JSONRPC_VERSION, id, result };
73
+ }
74
+
75
+ function rpcError(id: JsonRpcId, code: number, message: string) {
76
+ return { jsonrpc: JSONRPC_VERSION, id, error: { code, message } };
77
+ }
78
+
79
+ function jsonResponse(body: unknown, init?: ResponseInit): Response {
80
+ return Response.json(body, {
81
+ status: init?.status ?? 200,
82
+ headers: init?.headers,
83
+ });
84
+ }
85
+
86
+ /**
87
+ * Build the Streamable-HTTP MCP request handler (decision §9 — Streamable HTTP,
88
+ * NOT the deprecated HTTP+SSE). Handles the read-only surface: `initialize`,
89
+ * `tools/list`, `tools/call`, and the `notifications/initialized` ack.
90
+ *
91
+ * Authentication: the bearer OAuth token is resolved to a NARROWED principal by
92
+ * the platform auth strategy (`auth.authenticate` → the Bearer-OAuth branch).
93
+ * Tool visibility is filtered by the resolver (the principal only sees tools it
94
+ * may call); `tools/call` re-enters the live router as that principal so the
95
+ * handler re-checks authz. The model never bypasses authorization.
96
+ */
97
+ export function createMcpRequestHandler({
98
+ tools,
99
+ resolver,
100
+ invoker,
101
+ auth,
102
+ connections,
103
+ enforceBudget,
104
+ recordExecuted,
105
+ }: {
106
+ tools: McpExecutableTool[];
107
+ resolver: AiToolResolver;
108
+ invoker: McpToolInvoker;
109
+ auth: AuthService;
110
+ connections: McpConnectionRegistry;
111
+ /** Per-principal tool-budget enforcer (§14.5). Refuses over-budget calls. */
112
+ enforceBudget?: McpBudgetEnforcer;
113
+ /** Audit-record a directly-executed read tool (matrix #13). */
114
+ recordExecuted?: McpRecordExecuted;
115
+ }): (req: Request) => Promise<Response> {
116
+ // Built lazily on first request: the `tools` array is populated in
117
+ // `afterPluginsReady` (once every plugin has exposed its read projections),
118
+ // which runs AFTER this handler is created in init. Requests only arrive after
119
+ // that phase, so the first request sees the complete set.
120
+ let byName: Map<string, McpExecutableTool> | undefined;
121
+
122
+ return async function handleMcpRequest(req: Request): Promise<Response> {
123
+ byName ??= new Map(tools.map((t) => [t.tool.name, t]));
124
+ if (req.method === "GET") {
125
+ // No server-initiated SSE stream for the read-only surface; clients use
126
+ // POST request/response. (A GET opens an SSE channel in full MCP; not
127
+ // needed for read-only tools.)
128
+ return new Response(null, { status: 405 });
129
+ }
130
+ if (req.method !== "POST") {
131
+ return new Response(null, { status: 405 });
132
+ }
133
+
134
+ const bearerToken = opaqueBearerToken(req);
135
+ const principal: AuthUser | undefined = await auth.authenticate(req);
136
+
137
+ let payload: JsonRpcRequest;
138
+ try {
139
+ payload = (await req.json()) as JsonRpcRequest;
140
+ } catch {
141
+ return jsonResponse(rpcError(null, RPC_PARSE_ERROR, "Parse error"), {
142
+ status: 400,
143
+ });
144
+ }
145
+
146
+ const id = payload.id ?? null;
147
+
148
+ switch (payload.method) {
149
+ case "initialize": {
150
+ // A fresh session id is minted and tracked pod-locally (bookkeeping).
151
+ const sessionId = randomUUID();
152
+ if (principal) {
153
+ connections.open({
154
+ sessionId,
155
+ principalId: "id" in principal ? principal.id : "unknown",
156
+ });
157
+ }
158
+ return jsonResponse(
159
+ rpcResult(id, {
160
+ protocolVersion: MCP_PROTOCOL_VERSION,
161
+ capabilities: { tools: { listChanged: false } },
162
+ serverInfo: { name: "checkstack", version: "1.0.0" },
163
+ }),
164
+ { headers: { [MCP_SESSION_HEADER]: sessionId } },
165
+ );
166
+ }
167
+
168
+ case "notifications/initialized": {
169
+ // Notification: no response body (id is absent).
170
+ return new Response(null, { status: 202 });
171
+ }
172
+
173
+ case "tools/list": {
174
+ if (!principal) {
175
+ return jsonResponse(rpcError(id, RPC_UNAUTHORIZED, "Unauthorized"), {
176
+ status: 401,
177
+ });
178
+ }
179
+ // Only read-effect tools are listed: the read-only MCP surface invokes
180
+ // tools via a bare `tools/call`, which the structural effect-gate
181
+ // refuses for mutate/destructive tools. Listing a tool that could only
182
+ // ever be refused would mislead the model, so they are filtered here
183
+ // (the propose/apply flow surfaces mutating tools separately).
184
+ const allowed = resolver
185
+ .resolveTools(principal)
186
+ .filter((tool) => tool.effect === "read");
187
+ const descriptors = serializeTools({ tools: allowed }).map((d) => ({
188
+ name: d.name,
189
+ description: d.description,
190
+ inputSchema: d.inputSchema,
191
+ }));
192
+ return jsonResponse(rpcResult(id, { tools: descriptors }));
193
+ }
194
+
195
+ case "tools/call": {
196
+ if (!principal || !bearerToken) {
197
+ return jsonResponse(rpcError(id, RPC_UNAUTHORIZED, "Unauthorized"), {
198
+ status: 401,
199
+ });
200
+ }
201
+ const name = payload.params?.name;
202
+ if (typeof name !== "string") {
203
+ return jsonResponse(
204
+ rpcError(id, RPC_INVALID_PARAMS, "Invalid params: missing tool name"),
205
+ );
206
+ }
207
+ const executable = byName.get(name);
208
+ if (!executable) {
209
+ return jsonResponse(
210
+ rpcError(id, RPC_INVALID_PARAMS, `Unknown tool: ${name}`),
211
+ );
212
+ }
213
+ // Resolver gate: the model can only call a tool the principal may see.
214
+ // (Handler-side authz is the authority and re-runs below; this gate
215
+ // refuses a misbehaving model server-side BEFORE re-entry.)
216
+ if (!resolver.isAllowed({ principal, tool: executable.tool })) {
217
+ return jsonResponse(rpcError(id, RPC_FORBIDDEN, `Forbidden: ${name}`), {
218
+ status: 403,
219
+ });
220
+ }
221
+ // STRUCTURAL effect-gate (decision §1.6 / §8): a bare `tools/call` may
222
+ // ONLY run a read-effect tool. Mutating / destructive tools MUST go
223
+ // through the two-step propose -> apply flow (the proposal token is the
224
+ // single-use consent gate), never a direct invocation. This makes the
225
+ // read-only-over-MCP guarantee a structural property of the handler,
226
+ // independent of which tools happen to be wired into `tools`.
227
+ if (executable.tool.effect !== "read") {
228
+ return jsonResponse(
229
+ rpcError(
230
+ id,
231
+ RPC_FORBIDDEN,
232
+ `Tool "${name}" has effect "${executable.tool.effect}" and cannot be invoked via tools/call; use the propose/apply flow.`,
233
+ ),
234
+ { status: 403 },
235
+ );
236
+ }
237
+ const args =
238
+ (payload.params?.arguments as Record<string, unknown>) ?? {};
239
+
240
+ // Per-principal tool budget (§14.5): enforced BEFORE the call so an
241
+ // over-budget caller is refused. Shared-Postgres counter — cross-pod
242
+ // correct. The principal kind is "user" | "application" (services never
243
+ // reach here: they have no access rules and the resolver gate refused).
244
+ const budgetPrincipal: BudgetPrincipal | undefined =
245
+ principal.type === "user" || principal.type === "application"
246
+ ? { kind: principal.type, id: principal.id }
247
+ : undefined;
248
+ if (enforceBudget && budgetPrincipal) {
249
+ try {
250
+ await enforceBudget(budgetPrincipal);
251
+ } catch (error) {
252
+ return jsonResponse(
253
+ rpcError(id, RPC_RATE_LIMITED, extractErrorMessage(error)),
254
+ { status: 429 },
255
+ );
256
+ }
257
+ }
258
+
259
+ try {
260
+ const result = await invoker.invoke({
261
+ pluginId: executable.pluginId,
262
+ procedureKey: executable.procedureKey,
263
+ input: args,
264
+ bearerToken,
265
+ });
266
+ // Audit-record the directly-executed read tool (matrix #13). The args
267
+ // hash is recorded, never the raw args. Best-effort: an audit failure
268
+ // must not fail the tool call.
269
+ if (recordExecuted && budgetPrincipal) {
270
+ await recordExecuted({
271
+ principal: budgetPrincipal,
272
+ toolName: name,
273
+ argsHash: hashToolArgs(args),
274
+ }).catch(() => {});
275
+ }
276
+ return jsonResponse(
277
+ rpcResult(id, {
278
+ content: [{ type: "text", text: JSON.stringify(result) }],
279
+ isError: false,
280
+ }),
281
+ );
282
+ } catch (error) {
283
+ // Surfaced as an MCP tool error (not a transport error), per spec.
284
+ return jsonResponse(
285
+ rpcResult(id, {
286
+ content: [{ type: "text", text: extractErrorMessage(error) }],
287
+ isError: true,
288
+ }),
289
+ );
290
+ }
291
+ }
292
+
293
+ default: {
294
+ return jsonResponse(
295
+ rpcError(id, RPC_METHOD_NOT_FOUND, `Method not found: ${payload.method}`),
296
+ );
297
+ }
298
+ }
299
+ };
300
+ }
@@ -0,0 +1,65 @@
1
+ import { createORPCClient } from "@orpc/client";
2
+ import { RPCLink } from "@orpc/client/fetch";
3
+
4
+ /**
5
+ * Invokes a projected read-only tool's SOURCE oRPC procedure by re-entering the
6
+ * live router as the resolved principal, so handler-side authorization runs
7
+ * exactly as it would for any other caller (decision 5 — the model never
8
+ * bypasses authz; it is an untrusted caller that happens to pick arguments).
9
+ *
10
+ * The re-entry forwards the caller's OWN opaque OAuth bearer token to the
11
+ * loopback RPC endpoint. The API route handler re-authenticates that token
12
+ * through the Bearer-OAuth branch (introspect + narrow to the live principal)
13
+ * and runs `autoAuthMiddleware`. So the tool call is authorized as the NARROWED
14
+ * principal, server-side, on every call. We deliberately do NOT use the
15
+ * service-credential `fetch` (that would call as a trusted service and skip the
16
+ * user's authz).
17
+ */
18
+ export interface McpToolInvoker {
19
+ invoke(args: {
20
+ /** The source procedure's owning plugin id (e.g. "incident"). */
21
+ pluginId: string;
22
+ /** The source procedure key (e.g. "listIncidents"). */
23
+ procedureKey: string;
24
+ /** Validated tool input. */
25
+ input: unknown;
26
+ /** The caller's opaque OAuth bearer token (forwarded verbatim). */
27
+ bearerToken: string;
28
+ }): Promise<unknown>;
29
+ }
30
+
31
+ /** A minimal typed view of the loopback RPC client (per-plugin, per-procedure). */
32
+ type LoopbackClient = Record<
33
+ string,
34
+ Record<string, (input: unknown) => Promise<unknown>>
35
+ >;
36
+
37
+ export function createMcpToolInvoker({
38
+ internalUrl,
39
+ }: {
40
+ /** Base URL of this backend for the loopback call (e.g. INTERNAL_URL). */
41
+ internalUrl: string;
42
+ }): McpToolInvoker {
43
+ return {
44
+ async invoke({ pluginId, procedureKey, input, bearerToken }) {
45
+ // A fresh link per call carries this caller's bearer token; the live
46
+ // router re-checks authz as the narrowed principal.
47
+ const link = new RPCLink({
48
+ url: `${internalUrl}/api`,
49
+ headers: { authorization: `Bearer ${bearerToken}` },
50
+ });
51
+ const client = createORPCClient(link) as LoopbackClient;
52
+ const pluginClient = client[pluginId];
53
+ if (!pluginClient) {
54
+ throw new Error(`No RPC client for plugin "${pluginId}".`);
55
+ }
56
+ const procedure = pluginClient[procedureKey];
57
+ if (typeof procedure !== "function") {
58
+ throw new TypeError(
59
+ `Procedure "${pluginId}.${procedureKey}" is not callable.`,
60
+ );
61
+ }
62
+ return procedure(input);
63
+ },
64
+ };
65
+ }
@@ -0,0 +1,64 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import { OpenAiCompatibleConnectionSchema } from "./openai-provider";
3
+
4
+ /** Minimal valid connection sans spendCap; spread + override `spendCap` per case. */
5
+ const baseConfig = {
6
+ baseUrl: "https://openrouter.ai/api/v1",
7
+ apiKey: "sk-test",
8
+ defaultModel: "deepseek-v4-flash",
9
+ };
10
+
11
+ describe("OpenAiCompatibleConnectionSchema spendCap", () => {
12
+ it("accepts a connection with NO spendCap (unlimited)", () => {
13
+ const parsed = OpenAiCompatibleConnectionSchema.parse({ ...baseConfig });
14
+ expect(parsed.spendCap).toBeUndefined();
15
+ });
16
+
17
+ it("treats an empty spendCap object as no cap", () => {
18
+ const parsed = OpenAiCompatibleConnectionSchema.parse({
19
+ ...baseConfig,
20
+ spendCap: {},
21
+ });
22
+ expect(parsed.spendCap).toBeUndefined();
23
+ });
24
+
25
+ it("treats blank-string spendCap fields as no cap", () => {
26
+ const parsed = OpenAiCompatibleConnectionSchema.parse({
27
+ ...baseConfig,
28
+ spendCap: { tokenBudget: "", windowMinutes: "" },
29
+ });
30
+ expect(parsed.spendCap).toBeUndefined();
31
+ });
32
+
33
+ it("treats an incomplete spendCap (one blank bound) as no cap", () => {
34
+ expect(
35
+ OpenAiCompatibleConnectionSchema.parse({
36
+ ...baseConfig,
37
+ spendCap: { tokenBudget: 1000 },
38
+ }).spendCap,
39
+ ).toBeUndefined();
40
+ expect(
41
+ OpenAiCompatibleConnectionSchema.parse({
42
+ ...baseConfig,
43
+ spendCap: { windowMinutes: 60 },
44
+ }).spendCap,
45
+ ).toBeUndefined();
46
+ });
47
+
48
+ it("keeps a fully-specified spendCap", () => {
49
+ const parsed = OpenAiCompatibleConnectionSchema.parse({
50
+ ...baseConfig,
51
+ spendCap: { tokenBudget: 1000, windowMinutes: 60 },
52
+ });
53
+ expect(parsed.spendCap).toEqual({ tokenBudget: 1000, windowMinutes: 60 });
54
+ });
55
+
56
+ it("still rejects a present cap with a non-positive bound", () => {
57
+ expect(() =>
58
+ OpenAiCompatibleConnectionSchema.parse({
59
+ ...baseConfig,
60
+ spendCap: { tokenBudget: 0, windowMinutes: 60 },
61
+ }),
62
+ ).toThrow();
63
+ });
64
+ });
@@ -0,0 +1,146 @@
1
+ import { z } from "zod";
2
+ import { Versioned, configString, configNumber } from "@checkstack/backend-api";
3
+ import { extractErrorMessage } from "@checkstack/common";
4
+ import type {
5
+ IntegrationProvider,
6
+ TestConnectionResult,
7
+ } from "@checkstack/integration-backend";
8
+ import {
9
+ OPENAI_COMPATIBLE_DEFAULT_BASE_URL,
10
+ OPENAI_COMPATIBLE_PROVIDER_LOCAL_ID,
11
+ type OpenAiCompatibleConnection,
12
+ } from "@checkstack/ai-common";
13
+
14
+ /**
15
+ * Connection schema for an OpenAI-compatible LLM provider (OpenAI, Azure,
16
+ * OpenRouter, Ollama, vLLM, LM Studio, ...). Model choice lives on the
17
+ * connection (decision §14.6): `defaultModel` is required, `availableModels`
18
+ * optionally constrains the Phase 4 chat model picker.
19
+ *
20
+ * `apiKey` is marked `x-secret`, so the integration platform stores it in the
21
+ * Secrets Vault and redacts it from every UI / DTO response — the credential
22
+ * never leaves the backend.
23
+ */
24
+ /**
25
+ * Normalize the raw `spendCap` input before validation. A spend cap is OPTIONAL
26
+ * (absent = unlimited), and the schema-driven connection form always renders the
27
+ * nested `tokenBudget` / `windowMinutes` fields, so leaving them blank submits a
28
+ * PRESENT-but-empty object (empty strings / `undefined`) rather than omitting
29
+ * `spendCap`. Treat any such empty/incomplete cap as "no cap" so an operator can
30
+ * save a connection without configuring a budget: if either bound is unset or
31
+ * blank, the whole cap collapses to `undefined` (unlimited). A fully-specified
32
+ * cap passes through untouched for the object schema to validate.
33
+ */
34
+ function normalizeSpendCap(value: unknown): unknown {
35
+ if (value === undefined || value === null || value === "") {
36
+ return undefined;
37
+ }
38
+ if (typeof value === "object" && !Array.isArray(value)) {
39
+ const cap = value as Record<string, unknown>;
40
+ const isBlank = (field: unknown): boolean =>
41
+ field === undefined || field === null || field === "";
42
+ if (isBlank(cap.tokenBudget) || isBlank(cap.windowMinutes)) {
43
+ return undefined;
44
+ }
45
+ }
46
+ return value;
47
+ }
48
+
49
+ export const OpenAiCompatibleConnectionSchema = z.object({
50
+ baseUrl: configString({})
51
+ .url()
52
+ .default(OPENAI_COMPATIBLE_DEFAULT_BASE_URL)
53
+ .describe("OpenAI-compatible base URL (e.g. https://api.openai.com/v1)"),
54
+ apiKey: configString({ "x-secret": true }).describe("API key"),
55
+ defaultModel: configString({})
56
+ .min(1)
57
+ .describe("Default model id (e.g. gpt-4o-mini)"),
58
+ availableModels: z
59
+ .array(z.string().min(1))
60
+ .optional()
61
+ .describe("Optional allowlist of selectable model ids"),
62
+ spendCap: z
63
+ .preprocess(
64
+ normalizeSpendCap,
65
+ z
66
+ .object({
67
+ tokenBudget: configNumber({})
68
+ .int()
69
+ .positive()
70
+ .describe(
71
+ "Max total tokens (input + output) per user per window before chat is refused",
72
+ ),
73
+ windowMinutes: configNumber({})
74
+ .int()
75
+ .positive()
76
+ .describe(
77
+ "Rolling window length in minutes the token budget resets over",
78
+ ),
79
+ })
80
+ .optional(),
81
+ )
82
+ .describe(
83
+ "Optional LLM spend cap (token-count). Leave both fields blank for no cap. Enforced server-side and counted across all pods.",
84
+ ),
85
+ }) satisfies z.ZodType<OpenAiCompatibleConnection>;
86
+
87
+ /**
88
+ * The OpenAI-compatible integration provider. Registering it through
89
+ * `integrationProviderExtensionPoint` makes it appear in the generic
90
+ * Connections settings UI (rendered from `connectionSchema` via `DynamicForm`),
91
+ * exactly like every other integration provider — no AI-specific Settings page
92
+ * is required for credential management.
93
+ */
94
+ export function createOpenAiCompatibleProvider(): IntegrationProvider<OpenAiCompatibleConnection> {
95
+ return {
96
+ id: OPENAI_COMPATIBLE_PROVIDER_LOCAL_ID,
97
+ displayName: "OpenAI-compatible",
98
+ description:
99
+ "LLM provider for the AI platform (OpenAI, Azure, OpenRouter, Ollama, vLLM, LM Studio).",
100
+ icon: "Sparkles",
101
+ connectionSchema: new Versioned({
102
+ version: 1,
103
+ schema: OpenAiCompatibleConnectionSchema,
104
+ }),
105
+ documentation: {
106
+ setupGuide: `
107
+ ## OpenAI-compatible provider
108
+
109
+ Configure credentials for any OpenAI-compatible chat-completions endpoint.
110
+
111
+ 1. Set the base URL (defaults to \`https://api.openai.com/v1\`). For other
112
+ providers use their base URL, e.g. \`https://openrouter.ai/api/v1\` or a
113
+ local \`http://localhost:11434/v1\` for Ollama.
114
+ 2. Paste an API key. It is stored in the Secrets Vault and never returned to
115
+ the browser.
116
+ 3. Set a default model id (for example \`gpt-4o-mini\`). Optionally add an
117
+ allowlist of selectable models.
118
+ `.trim(),
119
+ },
120
+ async testConnection(
121
+ config: OpenAiCompatibleConnection,
122
+ ): Promise<TestConnectionResult> {
123
+ try {
124
+ // Strip trailing slashes without a backtracking-prone regex
125
+ // (`/\/+$/` is O(n^2) on inputs like "////...x" - CodeQL js/polynomial-redos).
126
+ let base = config.baseUrl;
127
+ while (base.endsWith("/")) base = base.slice(0, -1);
128
+ const response = await fetch(`${base}/models`, {
129
+ headers: { authorization: `Bearer ${config.apiKey}` },
130
+ });
131
+ if (!response.ok) {
132
+ return {
133
+ success: false,
134
+ message: `Provider returned ${response.status} ${response.statusText}`,
135
+ };
136
+ }
137
+ return { success: true, message: "Connection successful" };
138
+ } catch (error) {
139
+ return {
140
+ success: false,
141
+ message: `Failed to reach provider: ${extractErrorMessage(error)}`,
142
+ };
143
+ }
144
+ },
145
+ };
146
+ }
@@ -0,0 +1,97 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { z } from "zod";
3
+ import { access, definePluginMetadata, proc } from "@checkstack/common";
4
+ import type { AnyContractProcedure } from "@orpc/contract";
5
+ import { buildProjectedTool } from "./projection";
6
+
7
+ const sourcePluginMetadata = definePluginMetadata({ pluginId: "incident" });
8
+ const incidentRead = access("incident", "read", "View incidents");
9
+
10
+ // A realistic contract procedure with access metadata + an input schema.
11
+ const listIncidents = proc({
12
+ operationType: "query",
13
+ userType: "authenticated",
14
+ access: [incidentRead],
15
+ }).input(z.object({ status: z.string().optional() })) as AnyContractProcedure;
16
+
17
+ describe("buildProjectedTool", () => {
18
+ test("requiredAccessRules equals the source procedure's qualified access rules", () => {
19
+ const t = buildProjectedTool({
20
+ procedure: listIncidents,
21
+ sourcePluginMetadata,
22
+ procedureKey: "listIncidents",
23
+ description: "List incidents.",
24
+ effect: "read",
25
+ execute: () => Promise.resolve({}),
26
+ });
27
+
28
+ // qualifyAccessRuleId: `${pluginId}.${rule.id}` where rule.id = `incident.read`.
29
+ expect(t.requiredAccessRules).toEqual(["incident.incident.read"]);
30
+ });
31
+
32
+ test("derives the tool name as <sourcePlugin>.<procedureKey> when no name override", () => {
33
+ const t = buildProjectedTool({
34
+ procedure: listIncidents,
35
+ sourcePluginMetadata,
36
+ procedureKey: "listIncidents",
37
+ description: "List incidents.",
38
+ effect: "read",
39
+ execute: () => Promise.resolve({}),
40
+ });
41
+ expect(t.name).toBe("incident.listIncidents");
42
+ });
43
+
44
+ test("honours a name override", () => {
45
+ const t = buildProjectedTool({
46
+ procedure: listIncidents,
47
+ sourcePluginMetadata,
48
+ procedureKey: "listIncidents",
49
+ name: "incident.list",
50
+ description: "List incidents.",
51
+ effect: "read",
52
+ execute: () => Promise.resolve({}),
53
+ });
54
+ expect(t.name).toBe("incident.list");
55
+ });
56
+
57
+ test("uses the source procedure's input schema", () => {
58
+ const t = buildProjectedTool({
59
+ procedure: listIncidents,
60
+ sourcePluginMetadata,
61
+ procedureKey: "listIncidents",
62
+ description: "List incidents.",
63
+ effect: "read",
64
+ execute: () => Promise.resolve({}),
65
+ });
66
+ // The projected input parses the same shape the procedure declared.
67
+ expect(t.input.safeParse({ status: "open" }).success).toBe(true);
68
+ expect(t.input.safeParse({ status: 123 }).success).toBe(false);
69
+ });
70
+
71
+ test("throws when effect is omitted (never inferred from operationType)", () => {
72
+ expect(() =>
73
+ buildProjectedTool({
74
+ procedure: listIncidents,
75
+ sourcePluginMetadata,
76
+ procedureKey: "listIncidents",
77
+ description: "List incidents.",
78
+ // Force the runtime guard: a JS caller could omit `effect`.
79
+ effect: undefined as unknown as "read",
80
+ execute: () => Promise.resolve({}),
81
+ }),
82
+ ).toThrow(/effect.*required/i);
83
+ });
84
+
85
+ test("throws when the value is not an oRPC contract procedure", () => {
86
+ expect(() =>
87
+ buildProjectedTool({
88
+ procedure: {} as AnyContractProcedure,
89
+ sourcePluginMetadata,
90
+ procedureKey: "bogus",
91
+ description: "Bogus.",
92
+ effect: "read",
93
+ execute: () => Promise.resolve({}),
94
+ }),
95
+ ).toThrow(/not an oRPC contract procedure/i);
96
+ });
97
+ });