@manta-eu/horns-server 0.1.4 → 0.2.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.
@@ -0,0 +1,34 @@
1
+ // A whole Horns worker, Studio included, from the three things a build has to hand it.
2
+ //
3
+ // `worker.ts` cannot reach the Studio itself: the conversation object imports `cloudflare:workers`, which
4
+ // that module must stay clear of so Node can load it. This entry may, so it does the wiring a host would
5
+ // otherwise repeat — registering what the Studio reads, and mounting its routes.
6
+ import { Hono } from "hono";
7
+ import { createTypecheck } from "./typecheck.js";
8
+ import { createHornsWorker } from "./worker.js";
9
+ import { provideStudioDeps, studioAgentRoutes } from "./studio-agent-routes.js";
10
+ export { HornStudioAgent } from "./studio-agent-class.js";
11
+ export function createHornsStudioWorker(options) {
12
+ const { shapes, componentApi, typecheckFs, routes, ...rest } = options;
13
+ const typecheck = typecheckFs === undefined ? undefined : createTypecheck(typecheckFs);
14
+ const parts = {
15
+ shapes,
16
+ ...(componentApi ? { componentApi } : {}),
17
+ ...(typecheck ? { typecheck } : {}),
18
+ };
19
+ // Registered rather than passed: the conversation object is built by the runtime, not by this call.
20
+ provideStudioDeps(parts);
21
+ return createHornsWorker({
22
+ ...rest,
23
+ ...parts,
24
+ routes: mount(routes),
25
+ });
26
+ }
27
+ // Hono's `route` mutates what it is called on, so a host's own app is composed into a fresh one rather than
28
+ // having the Studio's routes added to it. The host's come first.
29
+ function mount(routes) {
30
+ const app = new Hono();
31
+ if (routes !== undefined)
32
+ app.route("/", routes);
33
+ return app.route("/", studioAgentRoutes());
34
+ }
package/dist/worker.d.ts CHANGED
@@ -2,13 +2,18 @@ import { Hono } from "hono";
2
2
  import type { Env } from "./env.ts";
3
3
  import { type StudioRoutesDeps } from "./studio-routes.ts";
4
4
  export type { Env };
5
- export type HornsWorkerOptions = Pick<StudioRoutesDeps<Env>, "shapes" | "componentApi" | "typecheck"> & Partial<Pick<StudioRoutesDeps<Env>, "criticModel">>;
5
+ export type HornsWorkerOptions = Pick<StudioRoutesDeps<Env>, "shapes" | "componentApi" | "typecheck"> & Partial<Pick<StudioRoutesDeps<Env>, "criticModel">> & {
6
+ /** A host's own routes, mounted before the assets catch-all. */
7
+ routes?: Hono<{
8
+ Bindings: Env;
9
+ }>;
10
+ };
6
11
  /**
7
12
  * The Horns worker as a Hono app. `shapes` is the host's: the catalog describes a realm's data model, so
8
13
  * Horns takes it rather than shipping one.
9
14
  */
10
- export declare function createHornsApp({ shapes, componentApi, typecheck, criticModel, }: HornsWorkerOptions): Hono<{
15
+ export declare function createHornsApp({ shapes, componentApi, typecheck, routes, criticModel, }: HornsWorkerOptions): Hono<{
11
16
  Bindings: Env;
12
17
  }, import("hono/types").BlankSchema, "/">;
13
18
  /** The same app behind Sentry, ready to be a Worker's default export. */
14
- export declare function createHornsWorker(options: HornsWorkerOptions): ExportedHandler<Env, QueueHandlerMessage, CfHostMetadata>;
19
+ export declare function createHornsWorker(options: HornsWorkerOptions): ExportedHandler<Env, unknown, unknown, unknown>;
package/dist/worker.js CHANGED
@@ -8,7 +8,7 @@ import { hornRoutes } from "./horn-routes.js";
8
8
  import { actionPlanRoutes } from "./action-plan-routes.js";
9
9
  import { studioRoutes } from "./studio-routes.js";
10
10
  import { contentSecurityPolicy } from "./csp.js";
11
- import { criticModelFromEnv } from "./llm.js";
11
+ import { criticModel as criticModelFromEnv } from "./studio-model.js";
12
12
  import { requestSession } from "./dev-session.js";
13
13
  import { sessionCookiePrefixOf } from "./session.js";
14
14
  import { asJsonRequest, followedLink, noPageToOpen, signInFirst } from "./entity-links.js";
@@ -18,7 +18,7 @@ import { sentryOptionsForDeploy } from "./sentry.js";
18
18
  * The Horns worker as a Hono app. `shapes` is the host's: the catalog describes a realm's data model, so
19
19
  * Horns takes it rather than shipping one.
20
20
  */
21
- export function createHornsApp({ shapes, componentApi, typecheck, criticModel = criticModelFromEnv, }) {
21
+ export function createHornsApp({ shapes, componentApi, typecheck, routes, criticModel = (env) => criticModelFromEnv(env), }) {
22
22
  const app = new Hono();
23
23
  // On the document only: a JSON answer nobody renders has nothing to protect, and the header is not small.
24
24
  app.use("*", async (c, next) => {
@@ -180,6 +180,10 @@ export function createHornsApp({ shapes, componentApi, typecheck, criticModel =
180
180
  ...(componentApi ? { componentApi } : {}),
181
181
  ...(typecheck ? { typecheck } : {}),
182
182
  }));
183
+ // A host's own routes, mounted before the assets catch-all. The Studio conversation arrives this way:
184
+ // importing it pulls in `cloudflare:workers`, which this module must not do.
185
+ if (routes)
186
+ app.route("/", routes);
183
187
  app.all("*", (c) => c.env.ASSETS.fetch(c.req.raw));
184
188
  app.onError((err, c) => {
185
189
  captureException(err, { tags: { path: c.req.routePath, method: c.req.method } });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@manta-eu/horns-server",
3
- "version": "0.1.4",
3
+ "version": "0.2.1",
4
4
  "description": "The Manta Horns worker as a library: Hono routes a Cloudflare host mounts on its own app.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -26,13 +26,29 @@
26
26
  "types": "./dist/shared.d.ts",
27
27
  "default": "./dist/shared.js"
28
28
  },
29
- "./llm": {
30
- "types": "./dist/llm.d.ts",
31
- "default": "./dist/llm.js"
32
- },
33
29
  "./typecheck": {
34
30
  "types": "./dist/typecheck.d.ts",
35
31
  "default": "./dist/typecheck.js"
32
+ },
33
+ "./studio-model": {
34
+ "types": "./dist/studio-model.d.ts",
35
+ "default": "./dist/studio-model.js"
36
+ },
37
+ "./studio-agent": {
38
+ "types": "./dist/studio-agent-routes.d.ts",
39
+ "default": "./dist/studio-agent-routes.js"
40
+ },
41
+ "./studio-agent-class": {
42
+ "types": "./dist/studio-agent-class.d.ts",
43
+ "default": "./dist/studio-agent-class.js"
44
+ },
45
+ "./session": {
46
+ "types": "./dist/session.d.ts",
47
+ "default": "./dist/session.js"
48
+ },
49
+ "./studio-worker": {
50
+ "types": "./dist/studio-worker.d.ts",
51
+ "default": "./dist/studio-worker.js"
36
52
  }
37
53
  },
38
54
  "publishConfig": {
@@ -40,20 +56,26 @@
40
56
  "access": "public"
41
57
  },
42
58
  "dependencies": {
59
+ "@ai-sdk/google-vertex": "^5.0.101",
60
+ "@ai-sdk/openai-compatible": "^3.0.62",
61
+ "@cloudflare/think": "0.19.0",
43
62
  "@manta-eu/client": "0.1.5",
44
63
  "@manta-eu/lib": "0.1.5",
45
64
  "@sentry/cloudflare": "^10.70.0",
46
65
  "@sentry/core": "^10.70.0",
66
+ "agents": "^0.24.0",
67
+ "ai": "^7.0.98",
47
68
  "hono": "^4.9.8",
48
69
  "ldkit": "2.9.0",
49
70
  "sparqljs": "^3.7.4",
50
71
  "typescript": "^5.9.0",
51
72
  "zod": "^4.5.4",
52
- "@manta-eu/horns": "0.1.4",
53
- "@manta-eu/horns-kit": "0.1.4"
73
+ "@manta-eu/horns": "0.2.1",
74
+ "@manta-eu/horns-kit": "0.2.1"
54
75
  },
55
76
  "devDependencies": {
56
77
  "@cloudflare/vite-plugin": "1.47.0",
78
+ "@cloudflare/workers-types": "^5.20261001.1",
57
79
  "@types/sparqljs": "^3.1.12",
58
80
  "rimraf": "^6.1.3",
59
81
  "typescript": "^5.9.0",
package/dist/llm.d.ts DELETED
@@ -1,45 +0,0 @@
1
- import { type StudioModel } from "@manta-eu/horns/studio";
2
- export type StudioModelEnv = {
3
- HORNS_LLM_BASE_URL?: string;
4
- HORNS_LLM_MODEL?: string;
5
- /**
6
- * `vllm` (the default) for the self-hosted Qwen, `openai` for anything else behind the gateway's
7
- * OpenAI-compatible route — Anthropic, OpenAI. It decides whether `chat_template_kwargs` is sent and
8
- * whether we carry a key of our own, because on any route but vLLM's the gateway holds the provider key.
9
- */
10
- HORNS_LLM_DIALECT?: string;
11
- HORNS_LLM_MAX_TOKENS?: string;
12
- VLLM_PRIMARY_AUTH_TOKEN?: string;
13
- CLOUDFLARE_AI_GATEWAY_TOKEN?: string;
14
- CLOUDFLARE_ENV?: string;
15
- /** The critic, on its own gateway route: a model that can look at a picture of what the Studio drew. */
16
- HORNS_CRITIC_BASE_URL?: string;
17
- HORNS_CRITIC_MODEL?: string;
18
- HORNS_CRITIC_DIALECT?: string;
19
- };
20
- export declare const aiGatewayHost = "gateway.ai.cloudflare.com";
21
- export declare const defaultMaxTokens = 6000;
22
- export type StudioModelResult = {
23
- ok: true;
24
- model: StudioModel;
25
- monitored: boolean;
26
- } | {
27
- ok: false;
28
- reason: string;
29
- };
30
- export declare function studioModelFromEnv(env: StudioModelEnv, context: {
31
- feature: string;
32
- datastoreId?: string;
33
- }, fetcher?: typeof fetch): StudioModelResult;
34
- /**
35
- * The critic's model, which is not the Studio's: it reads a picture of what the Studio drew, so it has to be a
36
- * model with image input. It goes through the same gateway for the same reason — every call is logged — but it
37
- * carries no key of ours. The gateway holds the provider key and injects it; sending one of ours would
38
- * override the stored key with something the provider never issued.
39
- *
40
- * Unconfigured is not an error: the caller falls back to the Studio's own model, which reads the card as text.
41
- */
42
- export declare function criticModelFromEnv(env: StudioModelEnv, context: {
43
- feature: string;
44
- datastoreId?: string;
45
- }, fetcher?: typeof fetch): StudioModelResult;
package/dist/llm.js DELETED
@@ -1,124 +0,0 @@
1
- import { isPresent } from "@manta-eu/lib/present";
2
- import { createOpenAICompatibleModel } from "@manta-eu/horns/studio";
3
- /** Anything but an explicit `openai` is vLLM, so a deployment that says nothing keeps the Qwen it had. */
4
- function dialectOf(configured) {
5
- return configured?.trim().toLowerCase() === "openai" ? "openai" : "vllm";
6
- }
7
- export const aiGatewayHost = "gateway.ai.cloudflare.com";
8
- // A round that runs past the gateway's ~100 s limit comes back as a timeout instead of an answer. Studio
9
- // rounds need around 3000 tokens, so this bounds a runaway thinking chain without cutting a real answer.
10
- export const defaultMaxTokens = 6000;
11
- const loopbackHosts = new Set(["localhost", "127.0.0.1", "[::1]"]);
12
- function maxTokensOf(configured) {
13
- const parsed = Number(configured);
14
- return Number.isInteger(parsed) && parsed > 0 ? parsed : defaultMaxTokens;
15
- }
16
- // Every Qwen call goes through agents' AI Gateway route (`custom-vllm-manta`), so it is logged and metered
17
- // beside agents' own traffic and tagged as Horns'. The one exception is a loopback LLM stub in tests.
18
- export function studioModelFromEnv(env, context, fetcher) {
19
- const baseURL = env.HORNS_LLM_BASE_URL;
20
- const model = env.HORNS_LLM_MODEL;
21
- if (!isPresent(baseURL) || !isPresent(model)) {
22
- return { ok: false, reason: "HORNS_LLM_BASE_URL and HORNS_LLM_MODEL must both be set." };
23
- }
24
- if (!URL.canParse(baseURL))
25
- return { ok: false, reason: "HORNS_LLM_BASE_URL is not a URL." };
26
- const { hostname, protocol } = new URL(baseURL);
27
- const dialect = dialectOf(env.HORNS_LLM_DIALECT);
28
- const withFetch = fetcher ? { fetch: fetcher } : {};
29
- if (loopbackHosts.has(hostname)) {
30
- return {
31
- ok: true,
32
- monitored: false,
33
- model: createOpenAICompatibleModel({ baseURL, model, dialect, ...withFetch }),
34
- };
35
- }
36
- if (protocol !== "https:" || hostname !== aiGatewayHost) {
37
- return {
38
- ok: false,
39
- reason: `HORNS_LLM_BASE_URL must be the AI Gateway (${aiGatewayHost}) so model calls are logged, not ${hostname}.`,
40
- };
41
- }
42
- if (!isPresent(env.CLOUDFLARE_AI_GATEWAY_TOKEN)) {
43
- return { ok: false, reason: "CLOUDFLARE_AI_GATEWAY_TOKEN must be set to call the AI Gateway." };
44
- }
45
- // Only vLLM's route takes a key of ours. On any other the gateway holds the provider key, and sending one
46
- // would override the stored key with something the provider never issued.
47
- if (dialect === "vllm" && !isPresent(env.VLLM_PRIMARY_AUTH_TOKEN)) {
48
- return { ok: false, reason: "VLLM_PRIMARY_AUTH_TOKEN must be set for the gateway's vLLM route." };
49
- }
50
- return {
51
- ok: true,
52
- monitored: true,
53
- model: createOpenAICompatibleModel({
54
- baseURL,
55
- model,
56
- dialect,
57
- ...(dialect === "vllm" && isPresent(env.VLLM_PRIMARY_AUTH_TOKEN) ? { apiKey: env.VLLM_PRIMARY_AUTH_TOKEN } : {}),
58
- gatewayToken: env.CLOUDFLARE_AI_GATEWAY_TOKEN,
59
- maxTokens: maxTokensOf(env.HORNS_LLM_MAX_TOKENS),
60
- // No user identifiers: the gateway log is shared with agents.
61
- metadata: {
62
- app: "manta-horns",
63
- environment: env.CLOUDFLARE_ENV ?? "local",
64
- feature: context.feature,
65
- ...(isPresent(context.datastoreId) ? { datastore: context.datastoreId } : {}),
66
- },
67
- ...withFetch,
68
- }),
69
- };
70
- }
71
- /**
72
- * The critic's model, which is not the Studio's: it reads a picture of what the Studio drew, so it has to be a
73
- * model with image input. It goes through the same gateway for the same reason — every call is logged — but it
74
- * carries no key of ours. The gateway holds the provider key and injects it; sending one of ours would
75
- * override the stored key with something the provider never issued.
76
- *
77
- * Unconfigured is not an error: the caller falls back to the Studio's own model, which reads the card as text.
78
- */
79
- export function criticModelFromEnv(env, context, fetcher) {
80
- const baseURL = env.HORNS_CRITIC_BASE_URL;
81
- const model = env.HORNS_CRITIC_MODEL;
82
- if (!isPresent(baseURL) || !isPresent(model)) {
83
- return { ok: false, reason: "HORNS_CRITIC_BASE_URL and HORNS_CRITIC_MODEL must both be set." };
84
- }
85
- if (!URL.canParse(baseURL))
86
- return { ok: false, reason: "HORNS_CRITIC_BASE_URL is not a URL." };
87
- const { hostname, protocol } = new URL(baseURL);
88
- const dialect = dialectOf(env.HORNS_CRITIC_DIALECT);
89
- const withFetch = fetcher ? { fetch: fetcher } : {};
90
- if (loopbackHosts.has(hostname)) {
91
- return {
92
- ok: true,
93
- monitored: false,
94
- model: createOpenAICompatibleModel({ baseURL, model, dialect, ...withFetch }),
95
- };
96
- }
97
- if (protocol !== "https:" || hostname !== aiGatewayHost) {
98
- return {
99
- ok: false,
100
- reason: `HORNS_CRITIC_BASE_URL must be the AI Gateway (${aiGatewayHost}) so critic calls are logged, not ${hostname}.`,
101
- };
102
- }
103
- if (!isPresent(env.CLOUDFLARE_AI_GATEWAY_TOKEN)) {
104
- return { ok: false, reason: "CLOUDFLARE_AI_GATEWAY_TOKEN must be set to call the AI Gateway." };
105
- }
106
- return {
107
- ok: true,
108
- monitored: true,
109
- model: createOpenAICompatibleModel({
110
- baseURL,
111
- model,
112
- dialect,
113
- gatewayToken: env.CLOUDFLARE_AI_GATEWAY_TOKEN,
114
- maxTokens: maxTokensOf(env.HORNS_LLM_MAX_TOKENS),
115
- metadata: {
116
- app: "manta-horns",
117
- environment: env.CLOUDFLARE_ENV ?? "local",
118
- feature: context.feature,
119
- ...(isPresent(context.datastoreId) ? { datastore: context.datastoreId } : {}),
120
- },
121
- ...withFetch,
122
- }),
123
- };
124
- }