From d4180e06aa57bdc669652eb7eb6525885512416f Mon Sep 17 00:00:00 2001
From: Jesse Andrews
Date: Tue, 17 Jun 2025 13:02:26 -0700
Subject: [PATCH 1/8] start of port
---
deno.lock | 22 +++
packages/api/index.ts | 14 ++
packages/jumble/deno.json | 1 +
packages/llm/deno.json | 1 +
packages/llm/src/client.ts | 33 ++++-
packages/llm/src/types.ts | 16 ++
packages/runner/src/builder/built-in.ts | 16 ++
packages/runner/src/builder/factory.ts | 2 +
packages/runner/src/builder/types.ts | 2 +
packages/runner/src/builtins/index.ts | 3 +-
packages/runner/src/builtins/llm.ts | 138 +++++++++++++++++-
packages/static/assets/types/commontools.d.ts | 10 ++
packages/toolshed/deno.json | 1 +
.../toolshed/routes/ai/llm/generateObject.ts | 73 +++++++++
.../toolshed/routes/ai/llm/llm.handlers.ts | 32 ++++
packages/toolshed/routes/ai/llm/llm.index.ts | 3 +-
packages/toolshed/routes/ai/llm/llm.routes.ts | 70 +++++++++
recipes/test-generate-object.tsx | 128 ++++++++++++++++
18 files changed, 561 insertions(+), 4 deletions(-)
create mode 100644 packages/toolshed/routes/ai/llm/generateObject.ts
create mode 100644 recipes/test-generate-object.tsx
diff --git a/deno.lock b/deno.lock
index a1f383d64..17414d1c8 100644
--- a/deno.lock
+++ b/deno.lock
@@ -105,6 +105,7 @@
"npm:@web/test-runner@*": "0.20.2",
"npm:ai@^4.3.10": "4.3.16_react@18.3.1_zod@3.25.49",
"npm:ai@^4.3.9": "4.3.16_react@18.3.1_zod@3.25.49",
+ "npm:ajv@^8.17.1": "8.17.1",
"npm:cmdk@^1.0.4": "1.1.1_react@18.3.1_react-dom@18.3.1__react@18.3.1_@types+react@18.3.23_@types+react-dom@18.3.7__@types+react@18.3.23",
"npm:csstype@^3.1.3": "3.1.3",
"npm:emoji-picker-react@^4.12.0": "4.12.2_react@18.3.1",
@@ -3043,6 +3044,15 @@
"react"
]
},
+ "ajv@8.17.1": {
+ "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
+ "dependencies": [
+ "fast-deep-equal",
+ "fast-uri",
+ "json-schema-traverse",
+ "require-from-string"
+ ]
+ },
"ansi-escapes@4.3.2": {
"integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==",
"dependencies": [
@@ -3843,6 +3853,9 @@
"fast-copy@3.0.2": {
"integrity": "sha512-dl0O9Vhju8IrcLndv2eU4ldt1ftXMqqfgN4H1cpmGV7P6jeB9FwpN9a2c8DPGE1Ys88rNUJVYDHq73CGAGOPfQ=="
},
+ "fast-deep-equal@3.1.3": {
+ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="
+ },
"fast-fifo@1.3.2": {
"integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ=="
},
@@ -3862,6 +3875,9 @@
"fast-safe-stringify@2.1.1": {
"integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA=="
},
+ "fast-uri@3.0.6": {
+ "integrity": "sha512-Atfo14OibSv5wAp4VWNsFYE1AchQRTv9cBGWET4pZWHzYshFSS9NQI6I57rdKn9croWVMbYFbLhJ+yJvmZIIHw=="
+ },
"fastq@1.19.1": {
"integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==",
"dependencies": [
@@ -4434,6 +4450,9 @@
"bignumber.js"
]
},
+ "json-schema-traverse@1.0.0": {
+ "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="
+ },
"json-schema@0.4.0": {
"integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA=="
},
@@ -6844,6 +6863,7 @@
"npm:@uiw/react-json-view@2.0.0-alpha.30",
"npm:@use-gesture/react@^10.3.1",
"npm:@vitejs/plugin-react@^4.3.4",
+ "npm:ajv@^8.17.1",
"npm:cmdk@^1.0.4",
"npm:csstype@^3.1.3",
"npm:emoji-picker-react@^4.12.0",
@@ -6863,6 +6883,7 @@
},
"packages/llm": {
"dependencies": [
+ "npm:ajv@^8.17.1",
"npm:json5@^2.2.3"
]
},
@@ -6903,6 +6924,7 @@
"npm:@sentry/deno@^9.3.0",
"npm:@vercel/otel@^1.10.1",
"npm:ai@^4.3.10",
+ "npm:ajv@^8.17.1",
"npm:gcp-metadata@6.1.0",
"npm:hono-pino@0.7",
"npm:jsonschema@^1.5.0",
diff --git a/packages/api/index.ts b/packages/api/index.ts
index f91de411b..57ee585ae 100644
--- a/packages/api/index.ts
+++ b/packages/api/index.ts
@@ -229,6 +229,15 @@ export interface BuiltInLLMState {
error: unknown;
}
+export interface BuiltInGenerateObjectParams {
+ prompt?: string;
+ schema?: JSONSchema;
+ system?: string;
+ cache?: boolean;
+ maxTokens?: number;
+ metadata?: Record;
+}
+
export interface BuiltInCompileAndRunParams {
files: Record;
main: string;
@@ -340,6 +349,10 @@ export type LLMFunction = (
params: Opaque,
) => OpaqueRef>;
+export type GenerateObjectFunction = (
+ params: Opaque,
+) => OpaqueRef>;
+
export type FetchDataFunction = (
params: Opaque<{
url: string;
@@ -404,6 +417,7 @@ export declare const render: RenderFunction;
export declare const str: StrFunction;
export declare const ifElse: IfElseFunction;
export declare const llm: LLMFunction;
+export declare const generateObject: GenerateObjectFunction;
export declare const fetchData: FetchDataFunction;
export declare const streamData: StreamDataFunction;
export declare const compileAndRun: CompileAndRunFunction;
diff --git a/packages/jumble/deno.json b/packages/jumble/deno.json
index 02c8dcc32..5fa561ebb 100644
--- a/packages/jumble/deno.json
+++ b/packages/jumble/deno.json
@@ -39,6 +39,7 @@
"@codemirror/lang-json": "npm:@codemirror/lang-json@^6.0.1",
"@use-gesture/react": "npm:@use-gesture/react@^10.3.1",
"@vitejs/plugin-react": "npm:@vitejs/plugin-react@^4.3.4",
+ "ajv": "npm:ajv@^8.17.1",
"cmdk": "npm:cmdk@^1.0.4",
"csstype": "npm:csstype@^3.1.3",
"emoji-picker-react": "npm:emoji-picker-react@^4.12.0",
diff --git a/packages/llm/deno.json b/packages/llm/deno.json
index f6c928101..8f0e65bd1 100644
--- a/packages/llm/deno.json
+++ b/packages/llm/deno.json
@@ -8,6 +8,7 @@
"./types": "./src/types.ts"
},
"imports": {
+ "ajv": "npm:ajv@^8.17.1",
"json5": "npm:json5@^2.2.3"
}
}
diff --git a/packages/llm/src/client.ts b/packages/llm/src/client.ts
index 5e73b9bce..6c5a1016d 100644
--- a/packages/llm/src/client.ts
+++ b/packages/llm/src/client.ts
@@ -1,4 +1,11 @@
-import { LLMContent, LLMMessage, LLMRequest, LLMResponse } from "./types.ts";
+import {
+ LLMContent,
+ LLMMessage,
+ LLMRequest,
+ LLMResponse,
+ LLMGenerateObjectRequest,
+ LLMGenerateObjectResponse
+} from "./types.ts";
type PartialCallback = (text: string) => void;
@@ -14,6 +21,30 @@ export const setLLMUrl = (toolshedUrl: string) => {
};
export class LLMClient {
+ async generateObject(
+ request: LLMGenerateObjectRequest,
+ ): Promise {
+ const response = await fetch(llmApiUrl + "/generateObject", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(request),
+ });
+
+ if (!response.ok) {
+ const errorText = await response.text();
+ throw new Error(
+ `HTTP error! status: ${response.status}, body: ${errorText}`,
+ );
+ }
+
+ if (!response.body) {
+ throw new Error("No response body");
+ }
+
+ const data = await response.json();
+ return data;
+ }
+
/**
* Sends a request to the LLM service.
*
diff --git a/packages/llm/src/types.ts b/packages/llm/src/types.ts
index 5ee47d02c..2a62e68e5 100644
--- a/packages/llm/src/types.ts
+++ b/packages/llm/src/types.ts
@@ -6,6 +6,7 @@ export const DEFAULT_MODEL_NAME: ModelName =
// NOTE(ja): This should be an array of models, the first model will be tried, if it
// fails, the second model will be tried, etc.
export const DEFAULT_IFRAME_MODELS: ModelName = "openai:gpt-4.1-nano";
+export const DEFAULT_GENERATE_OBJECT_MODELS: ModelName = "openai:gpt-4.1-nano";
export type LLMResponse = {
content: string;
@@ -37,6 +38,21 @@ export interface LLMRequest {
metadata?: LLMRequestMetadata;
}
+export interface LLMGenerateObjectRequest {
+ schema: Record;
+ prompt: string;
+ model?: ModelName;
+ system?: string;
+ cache?: boolean;
+ maxTokens?: number;
+ metadata?: LLMRequestMetadata;
+}
+
+export interface LLMGenerateObjectResponse {
+ object: Record;
+ id?: string;
+}
+
function isArrayOf(
callback: (data: any) => boolean,
input: any,
diff --git a/packages/runner/src/builder/built-in.ts b/packages/runner/src/builder/built-in.ts
index 1bddf816a..aa9eb09ba 100644
--- a/packages/runner/src/builder/built-in.ts
+++ b/packages/runner/src/builder/built-in.ts
@@ -48,6 +48,15 @@ export interface BuiltInLLMState {
error: unknown;
}
+export interface BuiltInGenerateObjectParams {
+ prompt?: string;
+ schema?: JSONSchema;
+ system?: string;
+ cache?: boolean;
+ maxTokens?: number;
+ metadata?: Record;
+}
+
export const llm = createNodeFactory({
type: "ref",
implementation: "llm",
@@ -55,6 +64,13 @@ export const llm = createNodeFactory({
params: Opaque,
) => OpaqueRef>;
+export const generateObject = createNodeFactory({
+ type: "ref",
+ implementation: "generateObject",
+}) as (
+ params: Opaque,
+) => OpaqueRef>;
+
export const fetchData = createNodeFactory({
type: "ref",
implementation: "fetchData",
diff --git a/packages/runner/src/builder/factory.ts b/packages/runner/src/builder/factory.ts
index 56fa861a7..e4be664bc 100644
--- a/packages/runner/src/builder/factory.ts
+++ b/packages/runner/src/builder/factory.ts
@@ -25,6 +25,7 @@ import { byRef, compute, derive, handler, lift, render } from "./module.ts";
import {
compileAndRun,
fetchData,
+ generateObject,
ifElse,
llm,
navigateTo,
@@ -91,6 +92,7 @@ export const createBuilder = (
str,
ifElse,
llm,
+ generateObject,
fetchData,
streamData,
compileAndRun,
diff --git a/packages/runner/src/builder/types.ts b/packages/runner/src/builder/types.ts
index e1e29b87d..46215ee90 100644
--- a/packages/runner/src/builder/types.ts
+++ b/packages/runner/src/builder/types.ts
@@ -9,6 +9,7 @@ import type {
CreateCellFunction,
DeriveFunction,
FetchDataFunction,
+ GenerateObjectFunction,
GetRecipeEnvironmentFunction,
HandlerFunction,
IfElseFunction,
@@ -270,6 +271,7 @@ export interface BuilderFunctionsAndConstants {
str: StrFunction;
ifElse: IfElseFunction;
llm: LLMFunction;
+ generateObject: GenerateObjectFunction;
fetchData: FetchDataFunction;
streamData: StreamDataFunction;
compileAndRun: CompileAndRunFunction;
diff --git a/packages/runner/src/builtins/index.ts b/packages/runner/src/builtins/index.ts
index 9133bddcd..b746aa17b 100644
--- a/packages/runner/src/builtins/index.ts
+++ b/packages/runner/src/builtins/index.ts
@@ -2,7 +2,7 @@ import { raw } from "../module.ts";
import { map } from "./map.ts";
import { fetchData } from "./fetch-data.ts";
import { streamData } from "./stream-data.ts";
-import { llm } from "./llm.ts";
+import { llm, generateObject } from "./llm.ts";
import { ifElse } from "./if-else.ts";
import type { IRuntime } from "../runtime.ts";
import { compileAndRun } from "./compile-and-run.ts";
@@ -19,4 +19,5 @@ export function registerBuiltins(runtime: IRuntime) {
moduleRegistry.addModuleByRef("llm", raw(llm));
moduleRegistry.addModuleByRef("ifElse", raw(ifElse));
moduleRegistry.addModuleByRef("compileAndRun", raw(compileAndRun));
+ moduleRegistry.addModuleByRef("generateObject", raw(generateObject));
}
diff --git a/packages/runner/src/builtins/llm.ts b/packages/runner/src/builtins/llm.ts
index 6413cf253..4ed9380d7 100644
--- a/packages/runner/src/builtins/llm.ts
+++ b/packages/runner/src/builtins/llm.ts
@@ -1,5 +1,11 @@
import { type DocImpl } from "../doc.ts";
-import { DEFAULT_MODEL_NAME, LLMClient, LLMRequest } from "@commontools/llm";
+import {
+ DEFAULT_MODEL_NAME,
+ DEFAULT_GENERATE_OBJECT_MODELS,
+ LLMClient,
+ LLMRequest,
+ LLMGenerateObjectRequest,
+} from "@commontools/llm";
import { type Action } from "../scheduler.ts";
import type { IRuntime } from "../runtime.ts";
import { refer } from "merkle-reference";
@@ -7,6 +13,7 @@ import { type ReactivityLog } from "../scheduler.ts";
import {
BuiltInLLMParams,
BuiltInLLMState,
+ BuiltInGenerateObjectParams,
} from "@commontools/api";
const client = new LLMClient();
@@ -151,3 +158,132 @@ export function llm(
});
};
}
+
+/**
+ * Generate structured data via an LLM using JSON mode.
+ *
+ * Returns the complete result as `result` and the incremental result as
+ * `partial`. `pending` is true while a request is pending.
+ *
+ * @param prompt - The prompt to send to the LLM.
+ * @param schema - JSON Schema to validate the response against.
+ * @param system - Optional system message.
+ * @param maxTokens - Maximum number of tokens to generate.
+ * @param model - Model to use (defaults to DEFAULT_GENERATE_OBJECT_MODELS).
+ * @param cache - Whether to cache the response (defaults to true).
+ * @param metadata - Additional metadata to pass to the LLM.
+ *
+ * @returns { pending: boolean, result?: object, partial?: string } - As individual
+ * docs, representing `pending` state, final `result` and incrementally
+ * updating `partial` result.
+ */
+export function generateObject(
+ inputsCell: DocImpl,
+ sendResult: (result: any) => void,
+ _addCancel: (cancel: () => void) => void,
+ cause: any,
+ parentDoc: DocImpl,
+ runtime: IRuntime,
+): Action {
+ const pending = runtime.documentMap.getDoc(
+ false,
+ { generateObject: { pending: cause } },
+ parentDoc.space,
+ );
+ const result = runtime.documentMap.getDoc | undefined>(
+ undefined,
+ {
+ generateObject: { result: cause },
+ },
+ parentDoc.space,
+ );
+ const partial = runtime.documentMap.getDoc(
+ undefined,
+ {
+ generateObject: { partial: cause },
+ },
+ parentDoc.space,
+ );
+ const requestHash = runtime.documentMap.getDoc(
+ undefined,
+ {
+ generateObject: { requestHash: cause },
+ },
+ parentDoc.space,
+ );
+
+ sendResult({ pending, result, partial, requestHash });
+
+ let currentRun = 0;
+ let previousCallHash: string | undefined = undefined;
+
+ return (log: ReactivityLog) => {
+ const thisRun = ++currentRun;
+
+ const { prompt, maxTokens, model, schema, system, cache, metadata } =
+ inputsCell.getAsQueryResult([], log) ?? {};
+
+ if (!prompt || !schema) {
+ pending.setAtPath([], false, log);
+ return;
+ }
+
+ const readyMetadata = metadata ? JSON.parse(JSON.stringify(metadata)) : {};
+
+ const generateObjectParams: LLMGenerateObjectRequest = {
+ prompt,
+ maxTokens: maxTokens ?? 8192,
+ schema: JSON.parse(JSON.stringify(schema)),
+ model: model ?? DEFAULT_GENERATE_OBJECT_MODELS,
+ metadata: {
+ ...readyMetadata,
+ context: "charm",
+ },
+ cache: cache ?? true,
+ };
+
+ if (system) {
+ generateObjectParams.system = system;
+ }
+
+ const hash = refer(generateObjectParams).toString();
+
+ // Return if the same request is being made again, either concurrently (same
+ // as previousCallHash) or when rehydrated from storage (same as the
+ // contents of the requestHash doc).
+ if (hash === previousCallHash || hash === requestHash.get()) return;
+ previousCallHash = hash;
+
+ result.setAtPath([], undefined, log);
+ partial.setAtPath([], undefined, log);
+ pending.setAtPath([], true, log);
+
+ const resultPromise = client.generateObject(generateObjectParams);
+
+ resultPromise
+ .then(async (response) => {
+ if (thisRun !== currentRun) return;
+
+ await runtime.idle();
+
+ pending.setAtPath([], false, log);
+ result.setAtPath([], response.object, log);
+ requestHash.setAtPath([], hash, log);
+ })
+ .catch(async (error) => {
+ if (thisRun !== currentRun) return;
+
+ console.error("Error generating object", error);
+
+ await runtime.idle();
+
+ pending.setAtPath([], false, log);
+ result.setAtPath([], undefined, log);
+ partial.setAtPath([], undefined, log);
+
+ // TODO(seefeld): Not writing now, so we retry the request after failure.
+ // Replace this with more fine-grained retry logic.
+ // requestHash.setAtPath([], hash, log);
+ });
+ };
+}
diff --git a/packages/static/assets/types/commontools.d.ts b/packages/static/assets/types/commontools.d.ts
index b487030d4..c75ea33d1 100644
--- a/packages/static/assets/types/commontools.d.ts
+++ b/packages/static/assets/types/commontools.d.ts
@@ -131,6 +131,14 @@ export interface BuiltInLLMState {
partial?: string;
error: unknown;
}
+export interface BuiltInGenerateObjectParams {
+ prompt?: string;
+ schema?: JSONSchema;
+ system?: string;
+ cache?: boolean;
+ maxTokens?: number;
+ metadata?: Record;
+}
export interface BuiltInCompileAndRunParams {
files: Record;
main: string;
@@ -166,6 +174,7 @@ export type RenderFunction = (fn: () => T) => OpaqueRef;
export type StrFunction = (strings: TemplateStringsArray, ...values: any[]) => OpaqueRef;
export type IfElseFunction = (condition: Opaque, ifTrue: Opaque, ifFalse: Opaque) => OpaqueRef;
export type LLMFunction = (params: Opaque) => OpaqueRef>;
+export type GenerateObjectFunction = (params: Opaque) => OpaqueRef>;
export type FetchDataFunction = (params: Opaque<{
url: string;
mode?: "json" | "text";
@@ -208,6 +217,7 @@ export declare const render: RenderFunction;
export declare const str: StrFunction;
export declare const ifElse: IfElseFunction;
export declare const llm: LLMFunction;
+export declare const generateObject: GenerateObjectFunction;
export declare const fetchData: FetchDataFunction;
export declare const streamData: StreamDataFunction;
export declare const compileAndRun: CompileAndRunFunction;
diff --git a/packages/toolshed/deno.json b/packages/toolshed/deno.json
index a9c8bc14d..089102bfe 100644
--- a/packages/toolshed/deno.json
+++ b/packages/toolshed/deno.json
@@ -13,6 +13,7 @@
"@ai-sdk/google-vertex": "npm:@ai-sdk/google-vertex@^2.2.17",
"@ai-sdk/xai": "npm:@ai-sdk/xai@^1.2.15",
"@std/cli": "jsr:@std/cli@^1.0.12",
+ "ajv": "npm:ajv@^8.17.1",
"gcp-metadata": "npm:gcp-metadata@6.1.0",
"@ai-sdk/groq": "npm:@ai-sdk/groq@^1.2.8",
"@ai-sdk/openai": "npm:@ai-sdk/openai@^1.3.20",
diff --git a/packages/toolshed/routes/ai/llm/generateObject.ts b/packages/toolshed/routes/ai/llm/generateObject.ts
new file mode 100644
index 000000000..2f51f0932
--- /dev/null
+++ b/packages/toolshed/routes/ai/llm/generateObject.ts
@@ -0,0 +1,73 @@
+import {
+ type LLMGenerateObjectRequest,
+ type LLMGenerateObjectResponse,
+} from "@commontools/llm/types";
+import { findModel } from "./models.ts";
+import { generateObject as generateObjectCore, jsonSchema } from "ai";
+import { Ajv } from "ajv";
+import { DEFAULT_GENERATE_OBJECT_MODELS } from "@commontools/llm";
+import { trace } from "@opentelemetry/api";
+
+export async function generateObject(
+ params: LLMGenerateObjectRequest,
+): Promise {
+ try {
+ const model = findModel(params.model ?? DEFAULT_GENERATE_OBJECT_MODELS);
+ const ajv = new Ajv({ allErrors: true, strict: false });
+ const validator = ajv.compile(params.schema);
+
+ const activeSpan = trace.getActiveSpan();
+ const spanId = activeSpan?.spanContext().spanId;
+
+ // Attach metadata directly to the root span
+ if (activeSpan) {
+ // Add the metadata from params if available
+ if (params.metadata) {
+ Object.entries(params.metadata).forEach(([key, value]) => {
+ // Only set attributes with valid values (not undefined)
+ if (value !== undefined) {
+ // Handle different types to ensure we only use valid AttributeValue types
+ if (
+ typeof value === "string" ||
+ typeof value === "number" ||
+ typeof value === "boolean"
+ ) {
+ activeSpan.setAttribute(`metadata.${key}`, value);
+ } else if (typeof value === "object") {
+ // Convert objects to JSON strings
+ activeSpan.setAttribute(`metadata.${key}`, JSON.stringify(value));
+ }
+ }
+ });
+ }
+ }
+
+ const { object } = await generateObjectCore({
+ model: model.model,
+ prompt: params.prompt,
+ mode: "json",
+ schema: jsonSchema(params.schema, {
+ validate: (value: unknown) => {
+ if (!validator(value)) {
+ return {
+ success: false,
+ error: new Error(JSON.stringify(validator.errors)),
+ };
+ }
+ return {
+ success: true,
+ value,
+ };
+ },
+ }),
+ });
+
+ return {
+ object: object as Record,
+ id: spanId,
+ };
+ } catch (error) {
+ console.error("Error generating object:", error);
+ throw new Error(`Failed to generate object: ${error}`);
+ }
+}
\ No newline at end of file
diff --git a/packages/toolshed/routes/ai/llm/llm.handlers.ts b/packages/toolshed/routes/ai/llm/llm.handlers.ts
index 04ca298c3..a03675545 100644
--- a/packages/toolshed/routes/ai/llm/llm.handlers.ts
+++ b/packages/toolshed/routes/ai/llm/llm.handlers.ts
@@ -4,11 +4,13 @@ import type {
FeedbackRoute,
GenerateTextRoute,
GetModelsRoute,
+ GenerateObjectRoute,
} from "./llm.routes.ts";
import { ALIAS_NAMES, ModelList, MODELS, TASK_MODELS } from "./models.ts";
import { hashKey, loadFromCache, saveToCache } from "./cache.ts";
import type { Context } from "@hono/hono";
import { generateText as generateTextCore } from "./generateText.ts";
+import { generateObject as generateObjectCore } from "./generateObject.ts";
import { findModel } from "./models.ts";
import env from "@/env.ts";
import { isLLMRequest, type LLMMessage } from "@commontools/llm/types";
@@ -240,3 +242,33 @@ export const submitFeedback: AppRouteHandler = async (c) => {
return c.json({ error: message }, HttpStatusCodes.BAD_REQUEST);
}
};
+
+/**
+ * Handler for POST /generateObject endpoint
+ * Generates structured JSON objects using specified LLM model
+ */
+export const generateObject: AppRouteHandler = async (c) => {
+ const payload = await c.req.json();
+
+ if (!payload.prompt || !payload.schema) {
+ return c.json({ error: "Missing required fields: prompt and schema" }, HttpStatusCodes.BAD_REQUEST);
+ }
+
+ if (!payload.metadata) {
+ payload.metadata = {};
+ }
+
+ const user = c.req.header("Tailscale-User-Login");
+ if (user) {
+ payload.metadata.user = user;
+ }
+
+ try {
+ const result = await generateObjectCore(payload);
+ return c.json(result);
+ } catch (error) {
+ console.error("Error in generateObject:", error);
+ const message = error instanceof Error ? error.message : "Unknown error";
+ return c.json({ error: message }, HttpStatusCodes.BAD_REQUEST);
+ }
+};
diff --git a/packages/toolshed/routes/ai/llm/llm.index.ts b/packages/toolshed/routes/ai/llm/llm.index.ts
index d1c55d067..05013356a 100644
--- a/packages/toolshed/routes/ai/llm/llm.index.ts
+++ b/packages/toolshed/routes/ai/llm/llm.index.ts
@@ -7,7 +7,8 @@ import { cors } from "@hono/hono/cors";
const router = createRouter()
.openapi(routes.getModels, handlers.getModels)
.openapi(routes.generateText, handlers.generateText)
- .openapi(routes.feedback, handlers.submitFeedback);
+ .openapi(routes.feedback, handlers.submitFeedback)
+ .openapi(routes.generateObject, handlers.generateObject);
router.use(
"/api/ai/llm/*",
diff --git a/packages/toolshed/routes/ai/llm/llm.routes.ts b/packages/toolshed/routes/ai/llm/llm.routes.ts
index 568a5e1d2..1bedf1beb 100644
--- a/packages/toolshed/routes/ai/llm/llm.routes.ts
+++ b/packages/toolshed/routes/ai/llm/llm.routes.ts
@@ -6,6 +6,7 @@ import { toZod } from "@commontools/utils/zod-utils";
import {
type LLMMessage,
type LLMRequest,
+ type LLMGenerateObjectRequest,
LLMTypedContent,
} from "@commontools/llm/types";
@@ -40,6 +41,17 @@ export const LLMRequestSchema = toZod().with({
cache: z.boolean().default(true).optional(),
});
+export const GenerateObjectRequestSchema = toZod()
+ .with({
+ prompt: z.string(),
+ schema: z.record(z.string(), z.any()),
+ system: z.string().optional(),
+ cache: z.boolean().default(true).optional(),
+ maxTokens: z.number().optional(),
+ model: z.string().optional(),
+ metadata: z.record(z.string(), z.any()).optional(),
+ });
+
export const ModelInfoSchema = z.object({
capabilities: z.object({
contextWindow: z.number(),
@@ -231,6 +243,64 @@ export const feedback = createRoute({
},
});
+export const generateObject = createRoute({
+ path: "/api/ai/llm/generateObject",
+ method: "post",
+ tags,
+ request: {
+ body: {
+ content: {
+ "application/json": {
+ schema: GenerateObjectRequestSchema.openapi({
+ example: {
+ prompt:
+ "What is the first thing that comes to mind when I say 'apple'?",
+ schema: {
+ type: "object",
+ properties: {
+ idea: { type: "string" },
+ reason: { type: "string" },
+ silliness: { type: "number" },
+ },
+ required: ["idea", "reason", "silliness"],
+ },
+ },
+ }),
+ },
+ },
+ },
+ },
+ responses: {
+ [HttpStatusCodes.OK]: jsonContent(
+ z.object({
+ object: z.any(),
+ id: z.string().optional(),
+ }).openapi({
+ example: {
+ object: {
+ idea: "apple",
+ reason: "It's a fruit",
+ silliness: 0.5,
+ },
+ id: "123",
+ },
+ }),
+ "Generated object",
+ ),
+ [HttpStatusCodes.BAD_REQUEST]: jsonContent(
+ z.object({
+ error: z.string(),
+ }).openapi({
+ example: {
+ error: "idea is missing",
+ },
+ }),
+ "Invalid request parameters",
+ ),
+ },
+});
+
export type GetModelsRoute = typeof getModels;
export type GenerateTextRoute = typeof generateText;
export type FeedbackRoute = typeof feedback;
+export type GenerateObjectRoute = typeof generateObject;
diff --git a/recipes/test-generate-object.tsx b/recipes/test-generate-object.tsx
new file mode 100644
index 000000000..98dc4fc81
--- /dev/null
+++ b/recipes/test-generate-object.tsx
@@ -0,0 +1,128 @@
+import {
+ derive,
+ generateObject,
+ h,
+ handler,
+ ifElse,
+ type JSONSchema,
+ lift,
+ NAME,
+ recipe,
+ schema,
+ str,
+ UI,
+} from "commontools";
+
+// Input schema with a number that can be incremented
+const inputSchema = schema({
+ type: "object",
+ properties: {
+ number: { type: "number", default: 0, asCell: true },
+ },
+ default: { number: 0 },
+});
+
+// Output schema for the generated object
+const outputSchema = {
+ type: "object",
+ properties: {
+ number: { type: "number" },
+ story: { type: "string" },
+ title: { type: "string" },
+ storyOrigin: { type: "string" },
+ seeAlso: { type: "array", items: { type: "number" } },
+ imagePrompt: { type: "string" },
+ },
+} as const satisfies JSONSchema;
+
+// Handler to increment the number
+const adder = handler({}, inputSchema, (_, state) => {
+ console.log("incrementing number");
+ state.number.set(state.number.get() + 1);
+});
+
+// Handler to set a specific number
+const setNumber = handler({
+ type: "object",
+ properties: {},
+}, {
+ type: "object",
+ properties: {
+ number: { type: "number", asCell: true },
+ n: { type: "number" },
+ },
+}, (_, state) => {
+ if (state.number && state.n) {
+ state.number.set(state.n);
+ }
+});
+
+// Generate the prompt for the LLM
+const generatePrompt = lift(({ number: number }) => {
+ return {
+ prompt:
+ `You are the parent of a young child who loves to learn about numbers. Luckily for your child, you are a historian of numbers and when the child says a number you make up an interesting story about it, including the history of the number. The child is currently at ${number}. Also return a recommendation for other numbers that might be interesting to the child to learn about next.`,
+ schema: outputSchema,
+ };
+});
+
+// Generate an image URL from the prompt
+const generateImageUrl = lift(({ imagePrompt }) => {
+ return `/api/ai/img?prompt=${encodeURIComponent(imagePrompt)}`;
+});
+
+export default recipe(inputSchema, outputSchema, (cell) => {
+ // Use generateObject to get structured data from the LLM
+ const { result: object, pending } = generateObject(generatePrompt({ number: cell.number }));
+
+ return {
+ [NAME]: str`Number Story: ${object?.title || "Loading..."}`,
+ [UI]: (
+
+
+ Current number: {cell.number} (click to increment)
+
+ {ifElse(
+ pending,
+
Generating story...
,
+
+ {ifElse(object?.title,
{object.title}
,
No title
)}
+ {ifElse(
+ object?.imagePrompt,
+
+
+
,
+
No image prompt
,
+ )}
+ {ifElse(object?.story,
{object.story}
,
No story yet
)}
+ {ifElse(
+ object?.storyOrigin,
+
+ {object.storyOrigin}
+
,
+
No story origin
,
+ )}
+ {ifElse(
+ object?.seeAlso,
+
+
See also these interesting numbers:
+
+ {object.seeAlso.map((n: number) => (
+ -
+
+ {n}
+
+
+ ))}
+
+
,
+
No related numbers
,
+ )}
+
+ )}
+
+ ),
+ number: cell.number,
+ ...object,
+ };
+});
\ No newline at end of file
From ca07883861915dd15c7f88dbafdbcdc874db7d4c Mon Sep 17 00:00:00 2001
From: Jesse Andrews
Date: Tue, 17 Jun 2025 14:14:14 -0700
Subject: [PATCH 2/8] cleanups
---
packages/llm/src/client.ts | 4 ---
.../toolshed/routes/ai/llm/generateObject.ts | 8 +++--
.../toolshed/routes/ai/llm/llm.handlers.ts | 29 +++++++++++++++++++
packages/toolshed/routes/ai/llm/models.ts | 5 ++--
recipes/test-generate-object.tsx | 24 ++++++++-------
5 files changed, 50 insertions(+), 20 deletions(-)
diff --git a/packages/llm/src/client.ts b/packages/llm/src/client.ts
index 6c5a1016d..86a2d5e27 100644
--- a/packages/llm/src/client.ts
+++ b/packages/llm/src/client.ts
@@ -37,10 +37,6 @@ export class LLMClient {
);
}
- if (!response.body) {
- throw new Error("No response body");
- }
-
const data = await response.json();
return data;
}
diff --git a/packages/toolshed/routes/ai/llm/generateObject.ts b/packages/toolshed/routes/ai/llm/generateObject.ts
index 2f51f0932..2797af548 100644
--- a/packages/toolshed/routes/ai/llm/generateObject.ts
+++ b/packages/toolshed/routes/ai/llm/generateObject.ts
@@ -12,7 +12,7 @@ export async function generateObject(
params: LLMGenerateObjectRequest,
): Promise {
try {
- const model = findModel(params.model ?? DEFAULT_GENERATE_OBJECT_MODELS);
+ const modelConfig = findModel(params.model ?? DEFAULT_GENERATE_OBJECT_MODELS);
const ajv = new Ajv({ allErrors: true, strict: false });
const validator = ajv.compile(params.schema);
@@ -43,7 +43,7 @@ export async function generateObject(
}
const { object } = await generateObjectCore({
- model: model.model,
+ model: modelConfig.model,
prompt: params.prompt,
mode: "json",
schema: jsonSchema(params.schema, {
@@ -60,6 +60,8 @@ export async function generateObject(
};
},
}),
+ maxTokens: params.maxTokens,
+ ...(params.system && { system: params.system }),
});
return {
@@ -68,6 +70,6 @@ export async function generateObject(
};
} catch (error) {
console.error("Error generating object:", error);
- throw new Error(`Failed to generate object: ${error}`);
+ throw error instanceof Error ? error : new Error(`Failed to generate object: ${error}`);
}
}
\ No newline at end of file
diff --git a/packages/toolshed/routes/ai/llm/llm.handlers.ts b/packages/toolshed/routes/ai/llm/llm.handlers.ts
index a03675545..306e8ee0e 100644
--- a/packages/toolshed/routes/ai/llm/llm.handlers.ts
+++ b/packages/toolshed/routes/ai/llm/llm.handlers.ts
@@ -263,8 +263,37 @@ export const generateObject: AppRouteHandler = async (c) =>
payload.metadata.user = user;
}
+ const cacheKey = await hashKey(
+ JSON.stringify(removeNonCacheableFields(payload)),
+ );
+
+ // Check cache if enabled
+ if (payload.cache !== false) {
+ const cachedResult = await loadFromCache(cacheKey);
+ if (cachedResult) {
+ return c.json({
+ object: cachedResult.object,
+ id: cachedResult.id,
+ });
+ }
+ }
+
try {
const result = await generateObjectCore(payload);
+
+ // Save to cache if enabled
+ if (payload.cache !== false) {
+ try {
+ await saveToCache(cacheKey, {
+ ...removeNonCacheableFields(payload),
+ object: result.object,
+ id: result.id,
+ });
+ } catch (e) {
+ console.error("Error saving generateObject response to cache:", e);
+ }
+ }
+
return c.json(result);
} catch (error) {
console.error("Error in generateObject:", error);
diff --git a/packages/toolshed/routes/ai/llm/models.ts b/packages/toolshed/routes/ai/llm/models.ts
index 317dbb006..e0d114e61 100644
--- a/packages/toolshed/routes/ai/llm/models.ts
+++ b/packages/toolshed/routes/ai/llm/models.ts
@@ -4,6 +4,7 @@ import { createGroq, groq } from "@ai-sdk/groq";
import { openai } from "@ai-sdk/openai";
import { createVertex, vertex } from "@ai-sdk/google-vertex";
import { createXai, xai } from "@ai-sdk/xai";
+import type { LanguageModelV1 } from "ai";
import env from "@/env.ts";
@@ -20,7 +21,7 @@ export type Capabilities = {
};
type ModelConfig = {
- model: string; // FIXME(ja): this type is wrong! it isn't a string
+ model: LanguageModelV1;
name: string;
capabilities: Capabilities;
aliases: string[];
@@ -77,7 +78,7 @@ const addModel = ({
: provider(modelName);
const config: ModelConfig = {
- model: model as unknown as string, // FIXME(ja): this type is wrong! it isn't a string
+ model,
name,
capabilities,
aliases,
diff --git a/recipes/test-generate-object.tsx b/recipes/test-generate-object.tsx
index 98dc4fc81..27bd50d83 100644
--- a/recipes/test-generate-object.tsx
+++ b/recipes/test-generate-object.tsx
@@ -37,15 +37,11 @@ const outputSchema = {
// Handler to increment the number
const adder = handler({}, inputSchema, (_, state) => {
- console.log("incrementing number");
state.number.set(state.number.get() + 1);
});
// Handler to set a specific number
-const setNumber = handler({
- type: "object",
- properties: {},
-}, {
+const setNumber = handler({}, {
type: "object",
properties: {
number: { type: "number", asCell: true },
@@ -73,13 +69,15 @@ const generateImageUrl = lift(({ imagePrompt }) => {
export default recipe(inputSchema, outputSchema, (cell) => {
// Use generateObject to get structured data from the LLM
- const { result: object, pending } = generateObject(generatePrompt({ number: cell.number }));
+ const { result: object, pending } = generateObject(
+ generatePrompt({ number: cell.number }),
+ );
return {
[NAME]: str`Number Story: ${object?.title || "Loading..."}`,
[UI]: (
-
+
Current number: {cell.number} (click to increment)
{ifElse(
@@ -90,7 +88,9 @@ export default recipe(inputSchema, outputSchema, (cell) => {
{ifElse(
object?.imagePrompt,
-
+
,
No image prompt
,
)}
@@ -109,7 +109,9 @@ export default recipe(inputSchema, outputSchema, (cell) => {
{object.seeAlso.map((n: number) => (
-
-
+
{n}
@@ -118,11 +120,11 @@ export default recipe(inputSchema, outputSchema, (cell) => {
,
No related numbers
,
)}
-
+ ,
)}
),
number: cell.number,
...object,
};
-});
\ No newline at end of file
+});
From 6a1367899a25dc9be5995beeceba2644fb761c67 Mon Sep 17 00:00:00 2001
From: Jesse Andrews
Date: Wed, 18 Jun 2025 13:51:12 -0700
Subject: [PATCH 3/8] add type information
---
packages/runner/src/builtins/llm.ts | 31 +++++++++-----
recipes/test-generate-object.tsx | 65 +++++++++++++----------------
2 files changed, 48 insertions(+), 48 deletions(-)
diff --git a/packages/runner/src/builtins/llm.ts b/packages/runner/src/builtins/llm.ts
index 4ed9380d7..5af1ba26f 100644
--- a/packages/runner/src/builtins/llm.ts
+++ b/packages/runner/src/builtins/llm.ts
@@ -1,19 +1,19 @@
import { type DocImpl } from "../doc.ts";
-import {
- DEFAULT_MODEL_NAME,
+import {
DEFAULT_GENERATE_OBJECT_MODELS,
- LLMClient,
- LLMRequest,
+ DEFAULT_MODEL_NAME,
+ LLMClient,
LLMGenerateObjectRequest,
+ LLMRequest,
} from "@commontools/llm";
import { type Action } from "../scheduler.ts";
import type { IRuntime } from "../runtime.ts";
import { refer } from "merkle-reference";
import { type ReactivityLog } from "../scheduler.ts";
import {
+ BuiltInGenerateObjectParams,
BuiltInLLMParams,
BuiltInLLMState,
- BuiltInGenerateObjectParams,
} from "@commontools/api";
const client = new LLMClient();
@@ -177,9 +177,14 @@ export function llm(
* docs, representing `pending` state, final `result` and incrementally
* updating `partial` result.
*/
-export function generateObject(
+export function generateObject>(
inputsCell: DocImpl,
- sendResult: (result: any) => void,
+ sendResult: (docs: {
+ pending: DocImpl;
+ result: DocImpl;
+ partial: DocImpl;
+ requestHash: DocImpl;
+ }) => void,
_addCancel: (cancel: () => void) => void,
cause: any,
parentDoc: DocImpl,
@@ -190,7 +195,7 @@ export function generateObject(
{ generateObject: { pending: cause } },
parentDoc.space,
);
- const result = runtime.documentMap.getDoc | undefined>(
+ const result = runtime.documentMap.getDoc(
undefined,
{
generateObject: { result: cause },
@@ -254,11 +259,15 @@ export function generateObject(
if (hash === previousCallHash || hash === requestHash.get()) return;
previousCallHash = hash;
- result.setAtPath([], undefined, log);
+ result.setAtPath([], {}, log); // FIXME(ja): setting result to undefined causes a storage conflict
partial.setAtPath([], undefined, log);
pending.setAtPath([], true, log);
- const resultPromise = client.generateObject(generateObjectParams);
+ const resultPromise = client.generateObject(
+ generateObjectParams,
+ ) as Promise<{
+ object: T;
+ }>;
resultPromise
.then(async (response) => {
@@ -278,7 +287,7 @@ export function generateObject(
await runtime.idle();
pending.setAtPath([], false, log);
- result.setAtPath([], undefined, log);
+ result.setAtPath([], {}, log); // FIXME(ja): setting result to undefined causes a storage conflict
partial.setAtPath([], undefined, log);
// TODO(seefeld): Not writing now, so we retry the request after failure.
diff --git a/recipes/test-generate-object.tsx b/recipes/test-generate-object.tsx
index 27bd50d83..41a40b563 100644
--- a/recipes/test-generate-object.tsx
+++ b/recipes/test-generate-object.tsx
@@ -8,6 +8,7 @@ import {
lift,
NAME,
recipe,
+ Schema,
schema,
str,
UI,
@@ -35,6 +36,8 @@ const outputSchema = {
},
} as const satisfies JSONSchema;
+type OutputSchema = Schema;
+
// Handler to increment the number
const adder = handler({}, inputSchema, (_, state) => {
state.number.set(state.number.get() + 1);
@@ -69,7 +72,7 @@ const generateImageUrl = lift(({ imagePrompt }) => {
export default recipe(inputSchema, outputSchema, (cell) => {
// Use generateObject to get structured data from the LLM
- const { result: object, pending } = generateObject(
+ const { result: object, pending } = generateObject(
generatePrompt({ number: cell.number }),
);
@@ -84,42 +87,30 @@ export default recipe(inputSchema, outputSchema, (cell) => {
pending,
Generating story...
,
- {ifElse(object?.title,
{object.title}
,
No title
)}
- {ifElse(
- object?.imagePrompt,
-
-
-
,
-
No image prompt
,
- )}
- {ifElse(object?.story,
{object.story}
,
No story yet
)}
- {ifElse(
- object?.storyOrigin,
-
- {object.storyOrigin}
-
,
-
No story origin
,
- )}
- {ifElse(
- object?.seeAlso,
-
-
See also these interesting numbers:
-
- {object.seeAlso.map((n: number) => (
- -
-
- {n}
-
-
- ))}
-
-
,
-
No related numbers
,
- )}
+
{object?.title}
+
+
+
+
{object?.story}
+
+ {object?.storyOrigin}
+
+
+
See also these interesting numbers:
+
+ {object?.seeAlso?.map((n: number) => (
+ -
+
+ {n}
+
+
+ ))}
+
+
,
)}
From bd8da0c82f44bebcb1d087544d81adf9cd0471ab Mon Sep 17 00:00:00 2001
From: Jesse Andrews
Date: Wed, 18 Jun 2025 14:32:05 -0700
Subject: [PATCH 4/8] deno fmt
---
packages/toolshed/routes/ai/llm/generateObject.ts | 10 +++++++---
packages/toolshed/routes/ai/llm/llm.handlers.ts | 15 ++++++++++-----
packages/toolshed/routes/ai/llm/llm.routes.ts | 2 +-
3 files changed, 18 insertions(+), 9 deletions(-)
diff --git a/packages/toolshed/routes/ai/llm/generateObject.ts b/packages/toolshed/routes/ai/llm/generateObject.ts
index 2797af548..29d12ba08 100644
--- a/packages/toolshed/routes/ai/llm/generateObject.ts
+++ b/packages/toolshed/routes/ai/llm/generateObject.ts
@@ -12,7 +12,9 @@ export async function generateObject(
params: LLMGenerateObjectRequest,
): Promise {
try {
- const modelConfig = findModel(params.model ?? DEFAULT_GENERATE_OBJECT_MODELS);
+ const modelConfig = findModel(
+ params.model ?? DEFAULT_GENERATE_OBJECT_MODELS,
+ );
const ajv = new Ajv({ allErrors: true, strict: false });
const validator = ajv.compile(params.schema);
@@ -70,6 +72,8 @@ export async function generateObject(
};
} catch (error) {
console.error("Error generating object:", error);
- throw error instanceof Error ? error : new Error(`Failed to generate object: ${error}`);
+ throw error instanceof Error
+ ? error
+ : new Error(`Failed to generate object: ${error}`);
}
-}
\ No newline at end of file
+}
diff --git a/packages/toolshed/routes/ai/llm/llm.handlers.ts b/packages/toolshed/routes/ai/llm/llm.handlers.ts
index 306e8ee0e..005cbf99b 100644
--- a/packages/toolshed/routes/ai/llm/llm.handlers.ts
+++ b/packages/toolshed/routes/ai/llm/llm.handlers.ts
@@ -2,9 +2,9 @@ import * as HttpStatusCodes from "stoker/http-status-codes";
import type { AppRouteHandler } from "@/lib/types.ts";
import type {
FeedbackRoute,
+ GenerateObjectRoute,
GenerateTextRoute,
GetModelsRoute,
- GenerateObjectRoute,
} from "./llm.routes.ts";
import { ALIAS_NAMES, ModelList, MODELS, TASK_MODELS } from "./models.ts";
import { hashKey, loadFromCache, saveToCache } from "./cache.ts";
@@ -247,11 +247,16 @@ export const submitFeedback: AppRouteHandler = async (c) => {
* Handler for POST /generateObject endpoint
* Generates structured JSON objects using specified LLM model
*/
-export const generateObject: AppRouteHandler = async (c) => {
+export const generateObject: AppRouteHandler = async (
+ c,
+) => {
const payload = await c.req.json();
if (!payload.prompt || !payload.schema) {
- return c.json({ error: "Missing required fields: prompt and schema" }, HttpStatusCodes.BAD_REQUEST);
+ return c.json(
+ { error: "Missing required fields: prompt and schema" },
+ HttpStatusCodes.BAD_REQUEST,
+ );
}
if (!payload.metadata) {
@@ -280,7 +285,7 @@ export const generateObject: AppRouteHandler = async (c) =>
try {
const result = await generateObjectCore(payload);
-
+
// Save to cache if enabled
if (payload.cache !== false) {
try {
@@ -293,7 +298,7 @@ export const generateObject: AppRouteHandler = async (c) =>
console.error("Error saving generateObject response to cache:", e);
}
}
-
+
return c.json(result);
} catch (error) {
console.error("Error in generateObject:", error);
diff --git a/packages/toolshed/routes/ai/llm/llm.routes.ts b/packages/toolshed/routes/ai/llm/llm.routes.ts
index 1bedf1beb..9190d8fb1 100644
--- a/packages/toolshed/routes/ai/llm/llm.routes.ts
+++ b/packages/toolshed/routes/ai/llm/llm.routes.ts
@@ -4,9 +4,9 @@ import { jsonContent } from "stoker/openapi/helpers";
import { z } from "zod";
import { toZod } from "@commontools/utils/zod-utils";
import {
+ type LLMGenerateObjectRequest,
type LLMMessage,
type LLMRequest,
- type LLMGenerateObjectRequest,
LLMTypedContent,
} from "@commontools/llm/types";
From 98465e8e546920eb7c87822e8b3e98a38b05f36d Mon Sep 17 00:00:00 2001
From: Jesse Andrews
Date: Wed, 18 Jun 2025 17:16:40 -0700
Subject: [PATCH 5/8] closer to type completion
---
packages/toolshed/routes/ai/llm/cache.ts | 1 +
packages/toolshed/routes/ai/llm/llm.handlers.ts | 6 ++----
2 files changed, 3 insertions(+), 4 deletions(-)
diff --git a/packages/toolshed/routes/ai/llm/cache.ts b/packages/toolshed/routes/ai/llm/cache.ts
index f392bf614..1571582dc 100644
--- a/packages/toolshed/routes/ai/llm/cache.ts
+++ b/packages/toolshed/routes/ai/llm/cache.ts
@@ -6,6 +6,7 @@ export const CACHE_DIR = `${env.CACHE_DIR}/llm-api-cache`;
interface CacheItem {
messages: Array<{ role: string; content: string }>;
+ object?: Record;
model?: string;
system?: string;
stopSequences?: string[];
diff --git a/packages/toolshed/routes/ai/llm/llm.handlers.ts b/packages/toolshed/routes/ai/llm/llm.handlers.ts
index 005cbf99b..b5398334a 100644
--- a/packages/toolshed/routes/ai/llm/llm.handlers.ts
+++ b/packages/toolshed/routes/ai/llm/llm.handlers.ts
@@ -278,8 +278,7 @@ export const generateObject: AppRouteHandler = async (
if (cachedResult) {
return c.json({
object: cachedResult.object,
- id: cachedResult.id,
- });
+ }, HttpStatusCodes.OK);
}
}
@@ -292,14 +291,13 @@ export const generateObject: AppRouteHandler = async (
await saveToCache(cacheKey, {
...removeNonCacheableFields(payload),
object: result.object,
- id: result.id,
});
} catch (e) {
console.error("Error saving generateObject response to cache:", e);
}
}
- return c.json(result);
+ return c.json(result, HttpStatusCodes.OK);
} catch (error) {
console.error("Error in generateObject:", error);
const message = error instanceof Error ? error.message : "Unknown error";
From 1b01687391be246e1a3d1c6329d7f5353e9453c5 Mon Sep 17 00:00:00 2001
From: Jesse Andrews
Date: Wed, 18 Jun 2025 17:21:06 -0700
Subject: [PATCH 6/8] claude says this...
---
packages/runner/src/builtins/index.ts | 9 ++++++++-
1 file changed, 8 insertions(+), 1 deletion(-)
diff --git a/packages/runner/src/builtins/index.ts b/packages/runner/src/builtins/index.ts
index b746aa17b..f818f5c0c 100644
--- a/packages/runner/src/builtins/index.ts
+++ b/packages/runner/src/builtins/index.ts
@@ -6,6 +6,8 @@ import { llm, generateObject } from "./llm.ts";
import { ifElse } from "./if-else.ts";
import type { IRuntime } from "../runtime.ts";
import { compileAndRun } from "./compile-and-run.ts";
+import type { DocImpl } from "../doc.ts";
+import type { BuiltInGenerateObjectParams } from "@commontools/api";
/**
* Register all built-in modules with a runtime's module registry
@@ -19,5 +21,10 @@ export function registerBuiltins(runtime: IRuntime) {
moduleRegistry.addModuleByRef("llm", raw(llm));
moduleRegistry.addModuleByRef("ifElse", raw(ifElse));
moduleRegistry.addModuleByRef("compileAndRun", raw(compileAndRun));
- moduleRegistry.addModuleByRef("generateObject", raw(generateObject));
+ moduleRegistry.addModuleByRef("generateObject", raw;
+ result: DocImpl | undefined>;
+ partial: DocImpl;
+ requestHash: DocImpl;
+ }>(generateObject));
}
From 7edf13997a35c89d5fe428c406f7f193e159a42d Mon Sep 17 00:00:00 2001
From: Jesse Andrews
Date: Wed, 18 Jun 2025 17:28:06 -0700
Subject: [PATCH 7/8] fix ajv
---
deno.lock | 2 --
packages/jumble/deno.json | 1 -
packages/llm/deno.json | 1 -
3 files changed, 4 deletions(-)
diff --git a/deno.lock b/deno.lock
index 17414d1c8..3ddcd9c66 100644
--- a/deno.lock
+++ b/deno.lock
@@ -6863,7 +6863,6 @@
"npm:@uiw/react-json-view@2.0.0-alpha.30",
"npm:@use-gesture/react@^10.3.1",
"npm:@vitejs/plugin-react@^4.3.4",
- "npm:ajv@^8.17.1",
"npm:cmdk@^1.0.4",
"npm:csstype@^3.1.3",
"npm:emoji-picker-react@^4.12.0",
@@ -6883,7 +6882,6 @@
},
"packages/llm": {
"dependencies": [
- "npm:ajv@^8.17.1",
"npm:json5@^2.2.3"
]
},
diff --git a/packages/jumble/deno.json b/packages/jumble/deno.json
index 5fa561ebb..02c8dcc32 100644
--- a/packages/jumble/deno.json
+++ b/packages/jumble/deno.json
@@ -39,7 +39,6 @@
"@codemirror/lang-json": "npm:@codemirror/lang-json@^6.0.1",
"@use-gesture/react": "npm:@use-gesture/react@^10.3.1",
"@vitejs/plugin-react": "npm:@vitejs/plugin-react@^4.3.4",
- "ajv": "npm:ajv@^8.17.1",
"cmdk": "npm:cmdk@^1.0.4",
"csstype": "npm:csstype@^3.1.3",
"emoji-picker-react": "npm:emoji-picker-react@^4.12.0",
diff --git a/packages/llm/deno.json b/packages/llm/deno.json
index 8f0e65bd1..f6c928101 100644
--- a/packages/llm/deno.json
+++ b/packages/llm/deno.json
@@ -8,7 +8,6 @@
"./types": "./src/types.ts"
},
"imports": {
- "ajv": "npm:ajv@^8.17.1",
"json5": "npm:json5@^2.2.3"
}
}
From 929c23d97932e5eb0b4626b38a2dc7a35340b0c1 Mon Sep 17 00:00:00 2001
From: Jesse Andrews
Date: Wed, 18 Jun 2025 21:22:31 -0700
Subject: [PATCH 8/8] add model to generateObject
---
packages/api/index.ts | 1 +
packages/runner/src/builder/built-in.ts | 1 +
packages/static/assets/types/commontools.d.ts | 1 +
3 files changed, 3 insertions(+)
diff --git a/packages/api/index.ts b/packages/api/index.ts
index 57ee585ae..ac8d82332 100644
--- a/packages/api/index.ts
+++ b/packages/api/index.ts
@@ -230,6 +230,7 @@ export interface BuiltInLLMState {
}
export interface BuiltInGenerateObjectParams {
+ model?: string;
prompt?: string;
schema?: JSONSchema;
system?: string;
diff --git a/packages/runner/src/builder/built-in.ts b/packages/runner/src/builder/built-in.ts
index aa9eb09ba..9802fff55 100644
--- a/packages/runner/src/builder/built-in.ts
+++ b/packages/runner/src/builder/built-in.ts
@@ -49,6 +49,7 @@ export interface BuiltInLLMState {
}
export interface BuiltInGenerateObjectParams {
+ model?: string;
prompt?: string;
schema?: JSONSchema;
system?: string;
diff --git a/packages/static/assets/types/commontools.d.ts b/packages/static/assets/types/commontools.d.ts
index c75ea33d1..83901c262 100644
--- a/packages/static/assets/types/commontools.d.ts
+++ b/packages/static/assets/types/commontools.d.ts
@@ -132,6 +132,7 @@ export interface BuiltInLLMState {
error: unknown;
}
export interface BuiltInGenerateObjectParams {
+ model?: string;
prompt?: string;
schema?: JSONSchema;
system?: string;