diff --git a/apps/hub/package.json b/apps/hub/package.json index 7c81f411a..f6ef5a999 100644 --- a/apps/hub/package.json +++ b/apps/hub/package.json @@ -20,6 +20,7 @@ "@corbits/artifacts": "github:corbitsdev/corbits-artifacts#dc435ba749b4378f19e5de772159c2a57e932938", "@corbits/artifacts-hub": "workspace:*", "@corbits/bench": "workspace:*", + "@corbits/catalog-tools": "workspace:*", "@corbits/chat": "workspace:*", "@corbits/commands": "workspace:*", "@corbits/error-sink": "workspace:*", diff --git a/apps/hub/src/index.ts b/apps/hub/src/index.ts index 508761be2..6e141593f 100644 --- a/apps/hub/src/index.ts +++ b/apps/hub/src/index.ts @@ -165,6 +165,7 @@ import { createDrizzleSidecarPlacementStore, createSidecarPlacementRoutes, } from "@corbits/sidecar-placement"; +import { createWorkflowCatalogAdminRoutes } from "@corbits/catalog-tools/routes"; import { createWorkflowAccessRoutes } from "@corbits/access-tools/routes"; import { generateId } from "@intx/hub-common"; @@ -2866,6 +2867,27 @@ export async function createHub(config: HubConfig) { getPolicy: (tenantId) => benchModelPolicy.store.getPolicy(tenantId), }), ); + // Myra's own catalog-administration surface + // (`@corbits/catalog-tools`' `create_offering`/`set_offering_priority`/ + // `disable_offering`): the workflow-run-authenticated counterpart to + // `@intx/hub-api`'s tenant-session `/api/tenants/:tenantId/catalog/ + // {models,providers,offerings}` mount, which only accepts a browser + // session and so 401s a workflow child. Reuses the SAME grant store and + // condition registry every other extension's own requireGrant check + // runs against, and the SAME sidecarRouter/credentialCipher the + // tenant-session mount pushes source updates through on every offering + // write, so a write through either surface propagates identically. + app.route( + "/api/workflow-catalog-admin", + createWorkflowCatalogAdminRoutes({ + db, + authenticator: createWorkflowRunAuthenticator({ db }), + grantStore: chatGrantStore, + conditionRegistry: chatConditionRegistry, + sidecarRouter, + credentialCipher, + }), + ); // Myra's own principal/grant surface (`@corbits/access-tools`' // `list_principals`/`list_grants`/`grant_access`/`revoke_access`): the // workflow-run-authenticated counterpart to the tenant-session diff --git a/bun.lock b/bun.lock index f5614e3d9..16a9ce7b0 100644 --- a/bun.lock +++ b/bun.lock @@ -41,6 +41,7 @@ "@corbits/artifacts": "github:corbitsdev/corbits-artifacts#dc435ba749b4378f19e5de772159c2a57e932938", "@corbits/artifacts-hub": "workspace:*", "@corbits/bench": "workspace:*", + "@corbits/catalog-tools": "workspace:*", "@corbits/chat": "workspace:*", "@corbits/commands": "workspace:*", "@corbits/connections": "workspace:*", @@ -429,11 +430,18 @@ }, "packages/catalog-tools": { "name": "@corbits/catalog-tools", - "version": "0.0.2", + "version": "0.0.4", "dependencies": { + "@corbits/error-sink": "workspace:*", "@intx/agent": "workspace:*", + "@intx/db": "workspace:*", + "@intx/hub-api": "workspace:*", + "@intx/hub-common": "0.3.0", + "@intx/hub-sessions": "workspace:*", "@intx/types": "workspace:*", "arktype": "catalog:", + "drizzle-orm": "catalog:", + "hono": "catalog:", }, "devDependencies": { "@types/bun": "catalog:", @@ -3601,8 +3609,18 @@ "@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + "@corbits/artifacts-hub/@corbits/artifacts": ["@corbits/artifacts@github:corbitsdev/corbits-artifacts#dc435ba", { "dependencies": { "@hono/standard-validator": "^0.2.3" }, "peerDependencies": { "@intx/hub-api": "^0.2.2", "@intx/types": "^0.2.2", "arktype": "^2.1.29", "drizzle-orm": "^0.45.2", "hono": "^4.12.32", "hono-openapi": "^1.2.0", "postgres": "^3.4.9" } }, "corbitsdev-corbits-artifacts-dc435ba", "sha512-VksIyrrJY9nRke0QyjgqGdW3gLD73Bi3+CzpJWG+l8vRJKdUxVi7ms2Mp95NmJXr4TpVX6i1z4odufX/QBxnSw=="], + + "@corbits/chat-ui/@corbits/react-ui": ["@corbits/react-ui@github:corbitsdev/react-ui#3b12281", { "dependencies": { "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "tailwind-merge": "^3.3.1" }, "peerDependencies": { "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-tooltip": "^1.2.8", "@tanstack/react-query": "^5.90.2", "lucide-react": "^0.545.0 || ^1.0.0", "react": "^18.2.0 || ^19.0.0", "react-dom": "^18.2.0 || ^19.0.0", "sonner": "^2.0.7" }, "optionalPeers": ["@tanstack/react-query"] }, "corbitsdev-react-ui-3b12281", "sha512-Abvm/DO0Gqg0ITHGT9355ZxyKRPMVJLSSQSjpd3a8qt4JPrSMOLIOS4sX8ZMNNaArIbnY9F+VKrOWkUJUyO4Nw=="], + + "@corbits/context-menu/@corbits/react-ui": ["@corbits/react-ui@github:corbitsdev/react-ui#3b12281", { "dependencies": { "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "tailwind-merge": "^3.3.1" }, "peerDependencies": { "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-tooltip": "^1.2.8", "@tanstack/react-query": "^5.90.2", "lucide-react": "^0.545.0 || ^1.0.0", "react": "^18.2.0 || ^19.0.0", "react-dom": "^18.2.0 || ^19.0.0", "sonner": "^2.0.7" }, "optionalPeers": ["@tanstack/react-query"] }, "corbitsdev-react-ui-3b12281", "sha512-Abvm/DO0Gqg0ITHGT9355ZxyKRPMVJLSSQSjpd3a8qt4JPrSMOLIOS4sX8ZMNNaArIbnY9F+VKrOWkUJUyO4Nw=="], + "@corbits/mailbox/hono-openapi": ["hono-openapi@1.3.1", "", { "peerDependencies": { "@hono/standard-validator": "^0.2.0", "@standard-community/standard-json": "^0.3.5", "@standard-community/standard-openapi": "^0.2.9", "@types/json-schema": "^7.0.15", "hono": "^4.11.2", "openapi-types": "^12.1.3" }, "optionalPeers": ["@hono/standard-validator", "hono"] }, "sha512-NLVeVkhKZ3drmQNEIPac8HX8Y54uf1hJAgIM/7MfDsaeVVmB+QILWQxx5x3R3NvRHgedcbEbOCGY2uR7WQYyMw=="], + "@corbits/plugins-ui/@corbits/react-ui": ["@corbits/react-ui@github:corbitsdev/react-ui#3b12281", { "dependencies": { "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "tailwind-merge": "^3.3.1" }, "peerDependencies": { "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-tooltip": "^1.2.8", "@tanstack/react-query": "^5.90.2", "lucide-react": "^0.545.0 || ^1.0.0", "react": "^18.2.0 || ^19.0.0", "react-dom": "^18.2.0 || ^19.0.0", "sonner": "^2.0.7" }, "optionalPeers": ["@tanstack/react-query"] }, "corbitsdev-react-ui-3b12281", "sha512-Abvm/DO0Gqg0ITHGT9355ZxyKRPMVJLSSQSjpd3a8qt4JPrSMOLIOS4sX8ZMNNaArIbnY9F+VKrOWkUJUyO4Nw=="], + + "@corbits/settings-ui/@corbits/react-ui": ["@corbits/react-ui@github:corbitsdev/react-ui#3b12281", { "dependencies": { "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "tailwind-merge": "^3.3.1" }, "peerDependencies": { "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-tooltip": "^1.2.8", "@tanstack/react-query": "^5.90.2", "lucide-react": "^0.545.0 || ^1.0.0", "react": "^18.2.0 || ^19.0.0", "react-dom": "^18.2.0 || ^19.0.0", "sonner": "^2.0.7" }, "optionalPeers": ["@tanstack/react-query"] }, "corbitsdev-react-ui-3b12281", "sha512-Abvm/DO0Gqg0ITHGT9355ZxyKRPMVJLSSQSjpd3a8qt4JPrSMOLIOS4sX8ZMNNaArIbnY9F+VKrOWkUJUyO4Nw=="], + "@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="], "@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], @@ -3625,6 +3643,10 @@ "@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.8", "", {}, "sha512-YYNsSlXBjMk92SKnkwvB5LOVSa6OznlFUGcsvrFgNJbJCd0M1XKeFVRc8ZByeCqz32FivYNHJVooLmdqrmvp/Q=="], + "@workbench/hub/@corbits/artifacts": ["@corbits/artifacts@github:corbitsdev/corbits-artifacts#dc435ba", { "dependencies": { "@hono/standard-validator": "^0.2.3" }, "peerDependencies": { "@intx/hub-api": "^0.2.2", "@intx/types": "^0.2.2", "arktype": "^2.1.29", "drizzle-orm": "^0.45.2", "hono": "^4.12.32", "hono-openapi": "^1.2.0", "postgres": "^3.4.9" } }, "corbitsdev-corbits-artifacts-dc435ba", "sha512-VksIyrrJY9nRke0QyjgqGdW3gLD73Bi3+CzpJWG+l8vRJKdUxVi7ms2Mp95NmJXr4TpVX6i1z4odufX/QBxnSw=="], + + "@workbench/web/@corbits/react-ui": ["@corbits/react-ui@github:corbitsdev/react-ui#3b12281", { "dependencies": { "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "tailwind-merge": "^3.3.1" }, "peerDependencies": { "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-tooltip": "^1.2.8", "@tanstack/react-query": "^5.90.2", "lucide-react": "^0.545.0 || ^1.0.0", "react": "^18.2.0 || ^19.0.0", "react-dom": "^18.2.0 || ^19.0.0", "sonner": "^2.0.7" }, "optionalPeers": ["@tanstack/react-query"] }, "corbitsdev-react-ui-3b12281", "sha512-Abvm/DO0Gqg0ITHGT9355ZxyKRPMVJLSSQSjpd3a8qt4JPrSMOLIOS4sX8ZMNNaArIbnY9F+VKrOWkUJUyO4Nw=="], + "ajv-formats/ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], "better-call/@better-auth/utils": ["@better-auth/utils@0.5.0", "", { "dependencies": { "@noble/hashes": "^2.0.1" } }, "sha512-BL8W4EfIZFwlu0r54m3v1ztjDhu6dDe/amLTm0xybmbZaNgYUqhD3SjpAsnq0q8YD6/ki4iwIgxJNLP/N3TxiA=="], diff --git a/packages/catalog-tools/README.md b/packages/catalog-tools/README.md index 312e27675..da252d310 100644 --- a/packages/catalog-tools/README.md +++ b/packages/catalog-tools/README.md @@ -20,3 +20,32 @@ Everything comes from `@corbits/inference-catalog`'s run-authenticated surface at `/api/workflow-inference-catalog`, reached with the sidecar bearer token and run address like every other workflow-run tool bundle. All three tools read only, so none is gated behind approval. + +## Managing offerings + +Three more tools let an agent write to the tenant's own inference catalog, +through this package's own workflow-run-authenticated mirror of the +tenant-admin catalog routes (`./src/routes.ts`'s +`createWorkflowCatalogAdminRoutes`, mounted in `apps/hub` at +`/api/workflow-catalog-admin`) rather than the read-only concept surface +above. The tenant-admin routes themselves +(`vendor/intx/hub-api/src/routes/{models,model-providers, +model-offerings}.ts`) only accept a browser session, which a workflow run +never has — only its sidecar bearer token and run address, the same +credential the read-only surface above already uses. + +- **`create_offering`** — pairs a model (by canonical name) with a model + provider (by name), both already present in this workbench's own catalog, + at a given priority with a set of advertised capabilities. Gated behind + `approval: "ask"`: it changes what this bench can resolve to at runtime. +- **`set_offering_priority`** — reorders an offering this workbench already + owns directly, changing where it falls in source-resolution fallback + order. Ungated: it only reorders an offering already live. +- **`disable_offering`** — restricts an offering this workbench already owns + directly, taking it out of source resolution without deleting its pricing + history. Gated behind `approval: "ask"`: running instances resolved + through it fail over to the next eligible source. + +`create_offering` never invents a model or provider id — it resolves the +given canonical name and provider name against this tenant's own catalog +listing first, and refuses rather than guessing when neither is found. diff --git a/packages/catalog-tools/package.json b/packages/catalog-tools/package.json index 842ccfef6..ca01d6968 100644 --- a/packages/catalog-tools/package.json +++ b/packages/catalog-tools/package.json @@ -2,20 +2,28 @@ "name": "@corbits/catalog-tools", "private": true, "description": "The list_model_concepts, pick_models and estimate_run_cost tools as an @intx/agent bundle: an agent asks for a model by what the work needs and gets back an ordered, priced chain of what this bench can actually reach — never a model named by hand.", - "version": "0.0.2", + "version": "0.0.4", "license": "LGPL-2.1-or-later", "type": "module", "exports": { - ".": "./src/index.ts" + ".": "./src/index.ts", + "./routes": "./src/routes.ts" }, "scripts": { "typecheck": "tsc --noEmit", "test": "bun test" }, "dependencies": { + "@corbits/error-sink": "workspace:*", "@intx/agent": "workspace:*", + "@intx/db": "workspace:*", + "@intx/hub-api": "workspace:*", + "@intx/hub-common": "0.3.0", + "@intx/hub-sessions": "workspace:*", "@intx/types": "workspace:*", - "arktype": "catalog:" + "arktype": "catalog:", + "drizzle-orm": "catalog:", + "hono": "catalog:" }, "devDependencies": { "@types/bun": "catalog:", diff --git a/packages/catalog-tools/src/client.ts b/packages/catalog-tools/src/client.ts index 487eb8737..9628112ff 100644 --- a/packages/catalog-tools/src/client.ts +++ b/packages/catalog-tools/src/client.ts @@ -6,7 +6,15 @@ // Authenticated the same way: sidecar bearer token plus run address, never a // human browser session. import { type } from "arktype"; -import { Capability } from "@intx/types"; +import { + Capability, + CreateModelOffering, + ModelOfferingResponse, + ModelProviderResponse, + ModelResponse, + UpdateModelOffering, + paginatedSchema, +} from "@intx/types"; export interface CatalogToolClientConfig { /** The hub's plain HTTP origin — the same value connections-tools' @@ -175,3 +183,193 @@ export async function fetchEstimate( request, ); } + +// --- Catalog administration ------------------------------------------- +// +// `list_model_concepts`/`pick_models`/`estimate_run_cost` above read the +// run-authenticated `/api/workflow-inference-catalog` surface. The tools +// below write through `@corbits/catalog-tools`' own workflow-run- +// authenticated mirror of the tenant-admin catalog surface Interchange's +// own `apps/hub-web` settings UI uses via a browser session +// (`vendor/intx/hub-api/src/routes/{models,model-providers, +// model-offerings}.ts`, mounted at `/api/tenants/:id/catalog/*`): see +// `./routes.ts`'s `createWorkflowCatalogAdminRoutes`, mounted in +// `apps/hub` at `/api/workflow-catalog-admin`. Same sidecar bearer token +// and run address as every other call in this file; the hub resolves +// that credential to the run's own tenant/principal, so the tenant never +// rides in the request the way it does in the tenant-admin routes' URL +// path. + +export interface CatalogAdminClientConfig { + /** Same hub origin as {@link CatalogToolClientConfig.hubCatalogUrl}. */ + readonly hubCatalogUrl: string; + readonly sidecarToken: string; + readonly address: string; + /** Override for tests; defaults to the global `fetch`. */ + readonly fetchImpl?: typeof fetch; +} + +/** Thrown when no model this tenant owns directly matches the requested + * canonical name. */ +export class UnknownCanonicalNameError extends Error {} + +/** Thrown when no model-provider this tenant owns directly matches the + * requested name. */ +export class UnknownProviderNameError extends Error {} + +const ModelsPage = paginatedSchema(ModelResponse); +const ModelProvidersPage = paginatedSchema(ModelProviderResponse); + +function adminAuthHeaders( + config: CatalogAdminClientConfig, +): Record { + return { + authorization: `Bearer ${config.sidecarToken}`, + "x-workflow-run-address": config.address, + "content-type": "application/json", + }; +} + +async function adminCall( + config: CatalogAdminClientConfig, + path: string, + what: string, + schema: (body: unknown) => T | type.errors, + init?: RequestInit, +): Promise { + const doFetch = config.fetchImpl ?? fetch; + const response = await doFetch( + `${config.hubCatalogUrl}/api/workflow-catalog-admin${path}`, + { ...init, headers: { ...adminAuthHeaders(config), ...init?.headers } }, + ); + if (!response.ok) { + const detail: unknown = await response.json().catch(() => undefined); + const message = + errorMessageFrom(detail) ?? `${response.status} ${response.statusText}`; + throw new Error(`${what} failed: ${message}`); + } + const parsed = schema(await response.json()); + if (parsed instanceof type.errors) { + throw new Error( + `${what} came back in an unexpected shape: ${parsed.summary}`, + ); + } + return parsed; +} + +/** Finds the id of a model this tenant owns directly by its canonical + * name, paging through the tenant's own model list. Throws + * {@link UnknownCanonicalNameError} rather than inventing an id when + * nothing matches. */ +export async function findModelIdByCanonicalName( + config: CatalogAdminClientConfig, + canonicalName: string, +): Promise { + let cursor: string | undefined; + do { + const qs = cursor + ? `?limit=100&cursor=${encodeURIComponent(cursor)}` + : "?limit=100"; + const page = await adminCall( + config, + `/models${qs}`, + "Listing this workbench's own models", + (raw) => ModelsPage(raw), + ); + const found = page.data.find((row) => row.canonicalName === canonicalName); + if (found !== undefined) return found.id; + cursor = page.nextCursor ?? undefined; + } while (cursor !== undefined); + throw new UnknownCanonicalNameError( + `No model named "${canonicalName}" in this workbench's own catalog.`, + ); +} + +/** Finds the id of a model-provider this tenant owns directly by its + * name, paging through the tenant's own provider list. Throws + * {@link UnknownProviderNameError} rather than inventing an id when + * nothing matches. */ +export async function findModelProviderIdByName( + config: CatalogAdminClientConfig, + providerName: string, +): Promise { + let cursor: string | undefined; + do { + const qs = cursor + ? `?limit=100&cursor=${encodeURIComponent(cursor)}` + : "?limit=100"; + const page = await adminCall( + config, + `/providers${qs}`, + "Listing this workbench's own model providers", + (raw) => ModelProvidersPage(raw), + ); + const found = page.data.find((row) => row.name === providerName); + if (found !== undefined) return found.id; + cursor = page.nextCursor ?? undefined; + } while (cursor !== undefined); + throw new UnknownProviderNameError( + `No model provider named "${providerName}" in this workbench's own catalog.`, + ); +} + +export type CreateOfferingRequest = { + readonly modelId: string; + readonly providerId: string; + readonly priority?: number; + readonly capabilities?: readonly Capability[]; +}; + +/** Creates a tenant-owned offering pairing a tenant-owned model with a + * tenant-owned provider. */ +export async function createOffering( + config: CatalogAdminClientConfig, + request: CreateOfferingRequest, +): Promise { + return await adminCall( + config, + "/offerings", + "Creating the offering", + (raw) => ModelOfferingResponse(raw), + { + method: "POST", + body: JSON.stringify(CreateModelOffering.assert(request)), + }, + ); +} + +/** Reorders an offering this tenant already owns directly. */ +export async function setOfferingPriority( + config: CatalogAdminClientConfig, + offeringId: string, + priority: number, +): Promise { + return await adminCall( + config, + `/offerings/${offeringId}`, + "Setting the offering's priority", + (raw) => ModelOfferingResponse(raw), + { + method: "PATCH", + body: JSON.stringify(UpdateModelOffering.assert({ priority })), + }, + ); +} + +/** Restricts an offering this tenant already owns directly, taking it out + * of resolution without deleting its pricing history. */ +export async function disableOffering( + config: CatalogAdminClientConfig, + offeringId: string, +): Promise { + return await adminCall( + config, + `/offerings/${offeringId}`, + "Disabling the offering", + (raw) => ModelOfferingResponse(raw), + { + method: "PATCH", + body: JSON.stringify(UpdateModelOffering.assert({ disabled: true })), + }, + ); +} diff --git a/packages/catalog-tools/src/index.ts b/packages/catalog-tools/src/index.ts index 2359443de..29bbe3e7b 100644 --- a/packages/catalog-tools/src/index.ts +++ b/packages/catalog-tools/src/index.ts @@ -2,6 +2,9 @@ export { fetchChain, fetchEstimate, listConcepts, + UnknownCanonicalNameError, + UnknownProviderNameError, + type CatalogAdminClientConfig, type CatalogToolClientConfig, type ChainEntry, type ConceptSummary, @@ -9,10 +12,21 @@ export { type ModelChainResult, } from "./client"; export { + catalogOfferingTools, catalogTools, describeChain, + CREATE_OFFERING_TOOL, + DISABLE_OFFERING_TOOL, ESTIMATE_RUN_COST_TOOL, LIST_MODEL_CONCEPTS_TOOL, PICK_MODELS_TOOL, + SET_OFFERING_PRIORITY_TOOL, type WorkflowCatalogEnv, + type WorkflowCatalogOfferingEnv, } from "./tool"; +// The workflow-run-authenticated hub-side routes these tools call +// (`create_offering`/`set_offering_priority`/`disable_offering`) live at +// `./routes`, a separate export subpath, not re-exported here: they pull +// in `hono`/`drizzle-orm`/`@intx/db`, server-only dependencies this +// package's main entry — consumed by a running agent's tool-loading path +// — must never carry. diff --git a/packages/catalog-tools/src/routes.test.ts b/packages/catalog-tools/src/routes.test.ts new file mode 100644 index 000000000..74154d10d --- /dev/null +++ b/packages/catalog-tools/src/routes.test.ts @@ -0,0 +1,164 @@ +import { expect, test } from "bun:test"; + +import type { ConditionRegistry, GrantStore } from "@intx/types/authz"; + +import { + createWorkflowCatalogAdminRoutes, + type WorkflowRunAuthenticator, +} from "./routes"; + +const ACTIVE_TENANT = { + id: "tenant_1", + name: "Acme", + slug: "acme", + domain: "acme.example.com", + parentId: null, + config: null, + createdAt: new Date(), + updatedAt: new Date(), +}; + +const ACTIVE_PRINCIPAL = { + id: "prin_1", + tenantId: "tenant_1", + kind: "workflow" as const, + refId: "run_1", + status: "active" as const, + createdAt: new Date(), + updatedAt: new Date(), +}; + +function fakeDb(overrides?: { + tenant?: typeof ACTIVE_TENANT | undefined; + principal?: + (Omit & { status: string }) | undefined; +}) { + return { + query: { + tenant: { + findFirst: async () => + overrides && "tenant" in overrides ? overrides.tenant : ACTIVE_TENANT, + }, + principal: { + findFirst: async () => + overrides && "principal" in overrides + ? overrides.principal + : ACTIVE_PRINCIPAL, + }, + model: { findMany: async () => [] }, + }, + } as unknown as import("@intx/db").DB["db"]; +} + +function allowAllGrantStore(): GrantStore { + return { + collectGrants: async () => [ + { + id: "grant_1", + resource: "model:*", + action: "read", + effect: "allow", + origin: "system", + conditions: null, + expiresAt: null, + roleId: null, + principalId: null, + }, + ], + collectGrantsInChain: async () => [], + }; +} + +function denyAllGrantStore(): GrantStore { + return { + collectGrants: async () => [], + collectGrantsInChain: async () => [], + }; +} + +function resolvingAuthenticator(): WorkflowRunAuthenticator { + return { + resolve: async (token, address) => + token === "sc-token" && address === "run_1@workflow" + ? { tenantId: "tenant_1", principalId: "prin_1" } + : null, + }; +} + +function requestModels( + app: ReturnType, +) { + return app.request("/models", { + headers: { + authorization: "Bearer sc-token", + "x-workflow-run-address": "run_1@workflow", + }, + }); +} + +test("rejects a request with no sidecar bearer token", async () => { + const app = createWorkflowCatalogAdminRoutes({ + db: fakeDb(), + authenticator: resolvingAuthenticator(), + grantStore: allowAllGrantStore(), + conditionRegistry: {} as ConditionRegistry, + sidecarRouter: {} as never, + credentialCipher: {} as never, + }); + const response = await app.request("/models"); + expect(response.status).toBe(401); +}); + +test("rejects a request whose run address the authenticator does not resolve", async () => { + const app = createWorkflowCatalogAdminRoutes({ + db: fakeDb(), + authenticator: { resolve: async () => null }, + grantStore: allowAllGrantStore(), + conditionRegistry: {} as ConditionRegistry, + sidecarRouter: {} as never, + credentialCipher: {} as never, + }); + const response = await requestModels(app); + expect(response.status).toBe(401); +}); + +test("rejects a resolved run whose principal row is not active", async () => { + const app = createWorkflowCatalogAdminRoutes({ + db: fakeDb({ principal: { ...ACTIVE_PRINCIPAL, status: "suspended" } }), + authenticator: resolvingAuthenticator(), + grantStore: allowAllGrantStore(), + conditionRegistry: {} as ConditionRegistry, + sidecarRouter: {} as never, + credentialCipher: {} as never, + }); + const response = await requestModels(app); + expect(response.status).toBe(401); +}); + +test("an authenticated principal with no model:read grant gets a real 403, not a bundle-local approximation", async () => { + const app = createWorkflowCatalogAdminRoutes({ + db: fakeDb(), + authenticator: resolvingAuthenticator(), + grantStore: denyAllGrantStore(), + conditionRegistry: {} as ConditionRegistry, + sidecarRouter: {} as never, + credentialCipher: {} as never, + }); + const response = await requestModels(app); + expect(response.status).toBe(403); +}); + +test("an authenticated, granted principal lists this tenant's own models", async () => { + const app = createWorkflowCatalogAdminRoutes({ + db: fakeDb(), + authenticator: resolvingAuthenticator(), + grantStore: allowAllGrantStore(), + conditionRegistry: {} as ConditionRegistry, + sidecarRouter: {} as never, + credentialCipher: {} as never, + }); + const response = await requestModels(app); + expect(response.status).toBe(200); + const body = (await response.json()) as { data: unknown[]; nextCursor: null }; + expect(body).toEqual({ data: [], nextCursor: null }); +}); diff --git a/packages/catalog-tools/src/routes.ts b/packages/catalog-tools/src/routes.ts new file mode 100644 index 000000000..36d585463 --- /dev/null +++ b/packages/catalog-tools/src/routes.ts @@ -0,0 +1,456 @@ +// Workflow-run-authenticated catalog-admin surface: the execution half of +// `@corbits/catalog-tools`' `create_offering`, `set_offering_priority`, and +// `disable_offering` tools. A workflow child has no browser session, only +// its sidecar bearer token and its own run address, so it cannot use +// `@intx/hub-api`'s tenant-session `/api/tenants/:id/catalog/{models, +// providers,offerings}` routes directly — the same reasoning +// `@corbits/access-tools`'s own `/api/workflow-access` mirror gives for its +// principal/grant surface. +// +// Mounted OUTSIDE the tenant prefix for that reason, at +// `/api/workflow-catalog-admin`. Every write is scoped to the authenticated +// run's own tenant alone — identity never rides in a request body. +// +// This is a thin wrapper, not a reimplementation: every route below reads +// or writes the SAME `model`/`modelProvider`/`modelOffering` rows +// `@intx/hub-api`'s own tenant-session catalog routes do +// (`vendor/intx/hub-api/src/routes/model-offerings.ts` and friends), and +// authorization runs through the SAME `createRequireGrant` against the +// same grant store and condition registry, resolved for the caller's real +// tenant/principal rows exactly like `@corbits/access-tools`' own +// `/api/workflow-access` mount resolves them. Those route factories aren't +// exported from `@intx/hub-api`'s public surface (only mounted internally +// by its own `createApp`), so this reimplements only their thin handler +// bodies against the shared schema and shared response shapes from +// `@intx/types` — never the grant-store authorization itself. Every +// offering write also pushes the same `pushSourceUpdatesSubtree` sidecar +// refresh the tenant-session routes push, so a running instance resolves +// the change the same way either surface made it. +import { and, desc, eq, lt, or, sql } from "drizzle-orm"; +import { Hono } from "hono"; +import { type } from "arktype"; + +import type { DB } from "@intx/db"; +import { model, modelProvider, modelOffering } from "@intx/db/schema"; +import { parseModelOfferingRow } from "@intx/db"; +import { generateId } from "@intx/hub-common"; +import { + createRequireGrant, + idResource, + type RequireGrant, + type TenantEnv, +} from "@intx/hub-api"; +import type { ConditionRegistry, GrantStore } from "@intx/types/authz"; +import { + CreateModelOffering, + UpdateModelOffering, + base64urlDecode, + base64urlEncode, + type CredentialCipher, +} from "@intx/types"; +import { makeErrorEnvelope } from "@corbits/error-sink"; +import { + pushSourceUpdatesSubtree, + type SidecarRouter, +} from "@intx/hub-sessions"; + +export type WorkflowCatalogAdminRunScope = { + readonly tenantId: string; + readonly principalId: string; +}; + +export type WorkflowRunAuthenticator = { + resolve( + token: string, + runAddress: string, + ): Promise; +}; + +export type CreateWorkflowCatalogAdminRoutesDeps = { + db: DB["db"]; + authenticator: WorkflowRunAuthenticator; + grantStore: GrantStore; + conditionRegistry: ConditionRegistry; + sidecarRouter: SidecarRouter; + credentialCipher: CredentialCipher; +}; + +function formatModel(row: typeof model.$inferSelect) { + return { + id: row.id, + tenantId: row.tenantId, + canonicalName: row.canonicalName, + displayName: row.displayName, + description: row.description, + disabled: row.disabled, + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + }; +} + +function formatModelProvider(row: typeof modelProvider.$inferSelect) { + return { + id: row.id, + tenantId: row.tenantId, + name: row.name, + plugin: row.plugin, + baseURL: row.baseURL, + credentialId: row.credentialId, + walletId: row.walletId, + disabled: row.disabled, + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + }; +} + +function formatModelOffering(row: typeof modelOffering.$inferSelect) { + const parsed = parseModelOfferingRow(row); + return { + id: parsed.id, + tenantId: parsed.tenantId, + modelId: parsed.modelId, + providerId: parsed.providerId, + priority: parsed.priority, + deploymentTags: parsed.deploymentTags, + capabilities: parsed.capabilities, + quirks: parsed.quirks, + disabled: parsed.disabled, + createdAt: parsed.createdAt.toISOString(), + updatedAt: parsed.updatedAt.toISOString(), + }; +} + +// --- Minimal keyset pagination, mirroring `@intx/hub-api`'s own +// `parsePageParams`/`cursorCondition`/`pageOrder`/`paginatedResponse` +// (`vendor/intx/hub-api/src/pagination.ts`) — not exported from its +// public surface, so reimplemented here at the same, small, generic +// shape rather than duplicating any domain logic. ----------------------- + +const DEFAULT_LIMIT = 50; +const MAX_LIMIT = 100; + +const CursorData = type({ + t: /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/, + id: "string", +}); +type CursorData = typeof CursorData.infer; + +function encodeCursor(createdAt: Date, id: string): string { + const data: CursorData = { t: createdAt.toISOString(), id }; + return base64urlEncode(new TextEncoder().encode(JSON.stringify(data))); +} + +function decodeCursor(cursor: string): CursorData | null { + try { + const json = new TextDecoder().decode(base64urlDecode(cursor)); + const parsed: unknown = JSON.parse(json); + const data = CursorData(parsed); + return data instanceof type.errors ? null : data; + } catch { + // report-error-ignore: a malformed opaque cursor is a client passing + // back garbage, not an operational incident — treated the same as + // the arktype mismatch just above, both fall through to `null` and + // the caller starts the listing over from the first page. + return null; + } +} + +function parsePageParams(query: { + cursor: string | undefined; + limit: string | undefined; +}): { limit: number; cursor: CursorData | null } { + const raw = query.limit ? Number(query.limit) : DEFAULT_LIMIT; + const limit = Number.isNaN(raw) + ? DEFAULT_LIMIT + : Math.min(Math.max(1, raw), MAX_LIMIT); + const cursor = query.cursor ? decodeCursor(query.cursor) : null; + return { limit, cursor }; +} + +function paginatedResponse( + items: T[], + rows: { createdAt: Date; id: string }[], + limit: number, +): { data: T[]; nextCursor: string | null } { + const hasMore = items.length === limit; + const lastRow = hasMore ? rows[rows.length - 1] : undefined; + return { + data: items, + nextCursor: lastRow ? encodeCursor(lastRow.createdAt, lastRow.id) : null, + }; +} + +export function createWorkflowCatalogAdminRoutes( + deps: CreateWorkflowCatalogAdminRoutesDeps, +): Hono { + const app = new Hono(); + const requireGrant: RequireGrant = createRequireGrant({ + grantStore: deps.grantStore, + conditionRegistry: deps.conditionRegistry, + }); + + app.use("*", async (c, next) => { + const authHeader = c.req.header("authorization") ?? ""; + const token = authHeader.startsWith("Bearer ") + ? authHeader.slice("Bearer ".length) + : ""; + const address = c.req.header("x-workflow-run-address") ?? ""; + const scope = await deps.authenticator.resolve(token, address); + if (scope === null) { + return c.json( + makeErrorEnvelope({ + code: "unauthorized", + userMessage: + "Missing or unrecognized sidecar bearer token / run address", + }), + 401, + ); + } + + const [tenantRow, principalRow] = await Promise.all([ + deps.db.query.tenant.findFirst({ + where: (row, { eq: eqCol }) => eqCol(row.id, scope.tenantId), + }), + deps.db.query.principal.findFirst({ + where: (row, { and: andCols, eq: eqCol }) => + andCols( + eqCol(row.id, scope.principalId), + eqCol(row.tenantId, scope.tenantId), + ), + }), + ]); + if ( + tenantRow === undefined || + principalRow === undefined || + principalRow.status !== "active" + ) { + return c.json( + makeErrorEnvelope({ + code: "unauthorized", + userMessage: + "Missing or unrecognized sidecar bearer token / run address", + }), + 401, + ); + } + c.set("tenant", tenantRow); + c.set("principal", principalRow); + await next(); + }); + + app.get("/models", requireGrant("model:*", "read"), async (c) => { + const tenantCtx = c.get("tenant"); + const { limit, cursor } = parsePageParams({ + cursor: c.req.query("cursor"), + limit: c.req.query("limit"), + }); + const conditions = [eq(model.tenantId, tenantCtx.id)]; + if (cursor) { + conditions.push( + // Both branches always produce valid SQL, so or() never actually + // returns undefined here (mirroring + // `vendor/intx/hub-api/src/pagination.ts`'s `cursorCondition`). + or( + lt(model.createdAt, new Date(cursor.t)), + and(eq(model.createdAt, new Date(cursor.t)), lt(model.id, cursor.id)), + ) ?? sql`false`, + ); + } + const rows = await deps.db.query.model.findMany({ + where: and(...conditions), + orderBy: [desc(model.createdAt), desc(model.id)], + limit, + }); + return c.json(paginatedResponse(rows.map(formatModel), rows, limit)); + }); + + app.get("/providers", requireGrant("model-provider:*", "read"), async (c) => { + const tenantCtx = c.get("tenant"); + const { limit, cursor } = parsePageParams({ + cursor: c.req.query("cursor"), + limit: c.req.query("limit"), + }); + const conditions = [eq(modelProvider.tenantId, tenantCtx.id)]; + if (cursor) { + conditions.push( + // Both branches always produce valid SQL, so or() never actually + // returns undefined here (mirroring + // `vendor/intx/hub-api/src/pagination.ts`'s `cursorCondition`). + or( + lt(modelProvider.createdAt, new Date(cursor.t)), + and( + eq(modelProvider.createdAt, new Date(cursor.t)), + lt(modelProvider.id, cursor.id), + ), + ) ?? sql`false`, + ); + } + const rows = await deps.db.query.modelProvider.findMany({ + where: and(...conditions), + orderBy: [desc(modelProvider.createdAt), desc(modelProvider.id)], + limit, + }); + return c.json( + paginatedResponse(rows.map(formatModelProvider), rows, limit), + ); + }); + + app.post( + "/offerings", + requireGrant("model-offering:*", "create"), + async (c) => { + const tenantCtx = c.get("tenant"); + const body = CreateModelOffering( + await c.req.json().catch(() => undefined), + ); + if (body instanceof type.errors) { + return c.json( + makeErrorEnvelope({ + code: "bad_request", + userMessage: `invalid offering request: ${body.summary}`, + }), + 400, + ); + } + + const modelRow = await deps.db.query.model.findFirst({ + where: and( + eq(model.id, body.modelId), + eq(model.tenantId, tenantCtx.id), + ), + }); + if (modelRow === undefined) { + return c.json( + makeErrorEnvelope({ + code: "not_found", + userMessage: "Model not found in this tenant", + }), + 404, + ); + } + + const providerRow = await deps.db.query.modelProvider.findFirst({ + where: and( + eq(modelProvider.id, body.providerId), + eq(modelProvider.tenantId, tenantCtx.id), + ), + }); + if (providerRow === undefined) { + return c.json( + makeErrorEnvelope({ + code: "not_found", + userMessage: "Provider not found in this tenant", + }), + 404, + ); + } + + const existing = await deps.db.query.modelOffering.findFirst({ + where: and( + eq(modelOffering.tenantId, tenantCtx.id), + eq(modelOffering.modelId, body.modelId), + eq(modelOffering.providerId, body.providerId), + ), + }); + if (existing !== undefined) { + return c.json( + makeErrorEnvelope({ + code: "conflict", + userMessage: + "An offering for this model and provider already exists", + }), + 409, + ); + } + + const now = new Date(); + const [row] = await deps.db + .insert(modelOffering) + .values({ + id: generateId("modelOffering"), + tenantId: tenantCtx.id, + modelId: body.modelId, + providerId: body.providerId, + priority: body.priority ?? 0, + deploymentTags: body.deploymentTags ?? [], + capabilities: body.capabilities ?? [], + quirks: body.quirks ?? null, + createdAt: now, + updatedAt: now, + }) + .returning(); + if (row === undefined) { + throw new Error("Insert into model_offering returned no row"); + } + + void pushSourceUpdatesSubtree( + deps.db, + deps.sidecarRouter, + tenantCtx.id, + deps.credentialCipher, + ); + return c.json(formatModelOffering(row), 201); + }, + ); + + app.patch( + "/offerings/:offeringId", + requireGrant(idResource("model-offering", "offeringId"), "manage"), + async (c) => { + const tenantCtx = c.get("tenant"); + const offeringId = c.req.param("offeringId"); + const body = UpdateModelOffering( + await c.req.json().catch(() => undefined), + ); + if (body instanceof type.errors) { + return c.json( + makeErrorEnvelope({ + code: "bad_request", + userMessage: `invalid offering update: ${body.summary}`, + }), + 400, + ); + } + + const updates: Record = { updatedAt: new Date() }; + if (body.priority !== undefined) updates["priority"] = body.priority; + if (body.deploymentTags !== undefined) + updates["deploymentTags"] = body.deploymentTags; + if (body.capabilities !== undefined) + updates["capabilities"] = body.capabilities; + if (body.quirks !== undefined) updates["quirks"] = body.quirks; + if (body.disabled !== undefined) updates["disabled"] = body.disabled; + + const [updated] = await deps.db + .update(modelOffering) + .set(updates) + .where( + and( + eq(modelOffering.id, offeringId), + eq(modelOffering.tenantId, tenantCtx.id), + ), + ) + .returning(); + + if (updated === undefined) { + return c.json( + makeErrorEnvelope({ + code: "not_found", + userMessage: `No offering "${offeringId}" in this tenant`, + }), + 404, + ); + } + + void pushSourceUpdatesSubtree( + deps.db, + deps.sidecarRouter, + tenantCtx.id, + deps.credentialCipher, + ); + return c.json(formatModelOffering(updated)); + }, + ); + + return app; +} diff --git a/packages/catalog-tools/src/tool.test.ts b/packages/catalog-tools/src/tool.test.ts index d8b13488a..809650f47 100644 --- a/packages/catalog-tools/src/tool.test.ts +++ b/packages/catalog-tools/src/tool.test.ts @@ -2,11 +2,16 @@ import { describe, expect, test } from "bun:test"; import type { ToolCall } from "@intx/types/runtime"; import { + catalogOfferingTools, catalogTools, + CREATE_OFFERING_TOOL, + DISABLE_OFFERING_TOOL, ESTIMATE_RUN_COST_TOOL, LIST_MODEL_CONCEPTS_TOOL, PICK_MODELS_TOOL, + SET_OFFERING_PRIORITY_TOOL, type WorkflowCatalogEnv, + type WorkflowCatalogOfferingEnv, } from "./tool"; const CHAIN_BODY = { @@ -234,3 +239,218 @@ describe("list_model_concepts", () => { expect(content).toContain("nothing here can do it yet"); }); }); + +function offeringEnv(): WorkflowCatalogOfferingEnv { + return { + hubCatalogUrl: "https://hub.example.com", + sidecarToken: "sc-token", + address: "run_1@workflow", + } as unknown as WorkflowCatalogOfferingEnv; +} + +const OWN_MODELS_PAGE = { + data: [ + { + id: "model_1", + tenantId: "tenant_1", + canonicalName: "thrifty", + displayName: null, + description: null, + disabled: false, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + }, + ], + nextCursor: null, +}; + +const OWN_PROVIDERS_PAGE = { + data: [ + { + id: "provider_1", + tenantId: "tenant_1", + name: "globex", + plugin: "openai-compatible", + baseURL: "https://globex.example.com", + credentialId: "cred_1", + walletId: null, + disabled: false, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + }, + ], + nextCursor: null, +}; + +const OFFERING_ROW = { + id: "offering_1", + tenantId: "tenant_1", + modelId: "model_1", + providerId: "provider_1", + priority: 0, + deploymentTags: [], + capabilities: ["plain-text"], + quirks: null, + disabled: false, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", +}; + +/** Routes a stub fetch by which tenant-admin path it hits, rather than + * one fixed body — `create_offering` makes several calls in sequence + * (list models, list providers, then create). */ +function routedFetch(handlers: Record Response>): typeof fetch { + return (async (input: string) => { + const url = new URL(input); + for (const [suffix, handler] of Object.entries(handlers)) { + if (url.pathname.endsWith(suffix)) return handler(); + } + throw new Error(`unstubbed request: ${url.pathname}`); + }) as unknown as typeof fetch; +} + +function ok(body: unknown, status = 200): () => Response { + return () => new Response(JSON.stringify(body), { status }); +} + +function forbidden(): Response { + return new Response( + JSON.stringify({ + error: { + code: "forbidden", + userMessage: "You do not have permission to perform this action", + refId: "ref_test", + }, + }), + { status: 403 }, + ); +} + +describe("the offering bundle's shape", () => { + test("gates create_offering and disable_offering behind approval, not set_offering_priority", () => { + expect(catalogOfferingTools.definitions).toEqual([ + { name: CREATE_OFFERING_TOOL, approval: "ask" }, + { name: SET_OFFERING_PRIORITY_TOOL }, + { name: DISABLE_OFFERING_TOOL, approval: "ask" }, + ]); + }); + + test("requires only the sanctioned workflow-run env keys", () => { + expect(catalogOfferingTools.requires).toEqual([ + "hubCatalogUrl", + "sidecarToken", + "address", + ]); + }); +}); + +describe("create_offering", () => { + test("resolves both names to ids, then creates the offering", async () => { + globalThis.fetch = routedFetch({ + "/models": ok(OWN_MODELS_PAGE), + "/providers": ok(OWN_PROVIDERS_PAGE), + "/offerings": ok(OFFERING_ROW, 201), + }); + const result = await catalogOfferingTools(offeringEnv()).run( + callFor(CREATE_OFFERING_TOOL, { + modelCanonicalName: "thrifty", + providerName: "globex", + priority: 0, + capabilities: ["plain-text"], + }), + new AbortController().signal, + ); + expect(result.isError).toBe(false); + expect(String(result.content)).toContain("offering_1"); + }); + + test("an unknown canonical name is refused, never a guessed id", async () => { + globalThis.fetch = routedFetch({ + "/models": ok({ data: [], nextCursor: null }), + "/providers": ok(OWN_PROVIDERS_PAGE), + }); + const result = await catalogOfferingTools(offeringEnv()).run( + callFor(CREATE_OFFERING_TOOL, { + modelCanonicalName: "does-not-exist", + providerName: "globex", + }), + new AbortController().signal, + ); + expect(result.isError).toBe(true); + expect(String(result.content)).toContain('No model named "does-not-exist"'); + }); + + test("a 403 from the hub surfaces as a refusal, not a thrown crash", async () => { + globalThis.fetch = routedFetch({ + "/models": () => forbidden(), + "/providers": ok(OWN_PROVIDERS_PAGE), + }); + const result = await catalogOfferingTools(offeringEnv()).run( + callFor(CREATE_OFFERING_TOOL, { + modelCanonicalName: "thrifty", + providerName: "globex", + }), + new AbortController().signal, + ); + expect(result.isError).toBe(true); + expect(String(result.content)).toContain( + "You do not have permission to perform this action", + ); + }); +}); + +describe("set_offering_priority", () => { + test("reorders an offering this workbench already owns", async () => { + globalThis.fetch = routedFetch({ + "/offerings/offering_1": ok({ ...OFFERING_ROW, priority: 5 }), + }); + const result = await catalogOfferingTools(offeringEnv()).run( + callFor(SET_OFFERING_PRIORITY_TOOL, { + offeringId: "offering_1", + priority: 5, + }), + new AbortController().signal, + ); + expect(result.isError).toBe(false); + expect(String(result.content)).toContain("priority 5"); + }); + + test("a 403 from the hub surfaces as a refusal", async () => { + globalThis.fetch = routedFetch({ + "/offerings/offering_1": () => forbidden(), + }); + const result = await catalogOfferingTools(offeringEnv()).run( + callFor(SET_OFFERING_PRIORITY_TOOL, { + offeringId: "offering_1", + priority: 5, + }), + new AbortController().signal, + ); + expect(result.isError).toBe(true); + }); +}); + +describe("disable_offering", () => { + test("restricts an offering this workbench already owns", async () => { + globalThis.fetch = routedFetch({ + "/offerings/offering_1": ok({ ...OFFERING_ROW, disabled: true }), + }); + const result = await catalogOfferingTools(offeringEnv()).run( + callFor(DISABLE_OFFERING_TOOL, { offeringId: "offering_1" }), + new AbortController().signal, + ); + expect(result.isError).toBe(false); + expect(String(result.content)).toContain("now disabled"); + }); + + test("a 403 from the hub surfaces as a refusal", async () => { + globalThis.fetch = routedFetch({ + "/offerings/offering_1": () => forbidden(), + }); + const result = await catalogOfferingTools(offeringEnv()).run( + callFor(DISABLE_OFFERING_TOOL, { offeringId: "offering_1" }), + new AbortController().signal, + ); + expect(result.isError).toBe(true); + }); +}); diff --git a/packages/catalog-tools/src/tool.ts b/packages/catalog-tools/src/tool.ts index 814522765..4226f227e 100644 --- a/packages/catalog-tools/src/tool.ts +++ b/packages/catalog-tools/src/tool.ts @@ -20,10 +20,20 @@ import type { ToolCall, ToolResult } from "@intx/types/runtime"; import { WIRE_CAPABILITIES, type Capability } from "@intx/types"; import { type } from "arktype"; +import { reportError } from "@corbits/error-sink"; + import { + createOffering, + disableOffering, fetchChain, fetchEstimate, + findModelIdByCanonicalName, + findModelProviderIdByName, listConcepts, + setOfferingPriority, + UnknownCanonicalNameError, + UnknownProviderNameError, + type CatalogAdminClientConfig, type CatalogToolClientConfig, type ChainEntry, type ModelChainResult, @@ -32,6 +42,9 @@ import { export const LIST_MODEL_CONCEPTS_TOOL = "list_model_concepts"; export const PICK_MODELS_TOOL = "pick_models"; export const ESTIMATE_RUN_COST_TOOL = "estimate_run_cost"; +export const CREATE_OFFERING_TOOL = "create_offering"; +export const SET_OFFERING_PRIORITY_TOOL = "set_offering_priority"; +export const DISABLE_OFFERING_TOOL = "disable_offering"; /** Env this bundle needs beyond `BaseEnv`: the run's hub-reach credential, * mirroring `@corbits/connections-tools`' `WorkflowConnectionEnv`. */ @@ -97,6 +110,46 @@ function clientConfig(env: WorkflowCatalogEnv): CatalogToolClientConfig { }; } +/** Env `create_offering`/`set_offering_priority`/`disable_offering` need + * beyond `BaseEnv`: the same hub-reach credential as + * {@link WorkflowCatalogEnv}. The hub resolves the run's own tenant from + * that same credential, so no tenant id rides in the env or the + * request. */ +export interface WorkflowCatalogOfferingEnv extends BaseEnv { + readonly hubCatalogUrl: string; + readonly sidecarToken: string; + readonly address: string; +} + +function adminClientConfig( + env: WorkflowCatalogOfferingEnv, +): CatalogAdminClientConfig { + return { + hubCatalogUrl: env.hubCatalogUrl, + sidecarToken: env.sidecarToken, + address: env.address, + }; +} + +const CreateOfferingInput = type({ + modelCanonicalName: "string > 0", + providerName: "string > 0", + "priority?": "number", + "capabilities?": type.enumerated(...WIRE_CAPABILITIES).array(), +}); +type CreateOfferingInput = typeof CreateOfferingInput.infer; + +const SetOfferingPriorityInput = type({ + offeringId: "string > 0", + priority: "number", +}); +type SetOfferingPriorityInput = typeof SetOfferingPriorityInput.infer; + +const DisableOfferingInput = type({ + offeringId: "string > 0", +}); +type DisableOfferingInput = typeof DisableOfferingInput.infer; + /** The need, as exactly one of the two forms the hub accepts. The input * schema has already rejected both-at-once and neither. */ function needOf(input: { concept?: string; capabilities?: Capability[] }): { @@ -217,6 +270,90 @@ async function runEstimateRunCost( } } +async function runCreateOffering( + env: WorkflowCatalogOfferingEnv, + call: ToolCall, + parsed: CreateOfferingInput, +): Promise { + try { + const config = adminClientConfig(env); + const [modelId, providerId] = await Promise.all([ + findModelIdByCanonicalName(config, parsed.modelCanonicalName), + findModelProviderIdByName(config, parsed.providerName), + ]); + const offering = await createOffering(config, { + modelId, + providerId, + ...(parsed.priority !== undefined ? { priority: parsed.priority } : {}), + ...(parsed.capabilities !== undefined + ? { capabilities: parsed.capabilities } + : {}), + }); + return { + callId: call.id, + isError: false, + content: + `Created offering ${offering.id}: ${parsed.modelCanonicalName} via ` + + `${parsed.providerName}, priority ${String(offering.priority)}.`, + }; + } catch (err) { + if ( + err instanceof UnknownCanonicalNameError || + err instanceof UnknownProviderNameError + ) { + return errorResult(call.id, err); + } + reportError(err, { operation: "create_offering", agentId: env.address }); + return errorResult(call.id, err); + } +} + +async function runSetOfferingPriority( + env: WorkflowCatalogOfferingEnv, + call: ToolCall, + parsed: SetOfferingPriorityInput, +): Promise { + try { + const offering = await setOfferingPriority( + adminClientConfig(env), + parsed.offeringId, + parsed.priority, + ); + return { + callId: call.id, + isError: false, + content: `Offering ${offering.id} is now priority ${String(offering.priority)}.`, + }; + } catch (err) { + reportError(err, { + operation: "set_offering_priority", + agentId: env.address, + }); + return errorResult(call.id, err); + } +} + +async function runDisableOffering( + env: WorkflowCatalogOfferingEnv, + call: ToolCall, + parsed: DisableOfferingInput, +): Promise { + try { + const offering = await disableOffering( + adminClientConfig(env), + parsed.offeringId, + ); + return { + callId: call.id, + isError: false, + content: `Offering ${offering.id} is now disabled.`, + }; + } catch (err) { + reportError(err, { operation: "disable_offering", agentId: env.address }); + return errorResult(call.id, err); + } +} + /** * The `@corbits/catalog-tools` bundle factory: three read-only tools, no * approval gate, three env keys. @@ -330,3 +467,160 @@ export const catalogTools = defineTool({ }, }), }); + +/** + * The `@corbits/catalog-tools` offering-management bundle: three write + * tools over the tenant-admin catalog routes, `create_offering` and + * `disable_offering` gated behind approval — both change what this + * bench can resolve to at runtime. `set_offering_priority` only reorders + * an offering already live, so it stays ungated, matching how + * `pick_models`' own `order` param is ungated. + */ +export const catalogOfferingTools = defineTool({ + id: "@corbits/catalog-tools/off", + requires: ["hubCatalogUrl", "sidecarToken", "address"], + definitions: [ + { name: CREATE_OFFERING_TOOL, approval: "ask" }, + { name: SET_OFFERING_PRIORITY_TOOL }, + { name: DISABLE_OFFERING_TOOL, approval: "ask" }, + ], + factory: (env) => ({ + definitions: [ + { + name: CREATE_OFFERING_TOOL, + description: + "add a tenant-owned offering pairing a model (by canonical " + + "name) with a model provider (by name), both already known to " + + "this workbench's own catalog. A human must approve before " + + "the offering becomes live.", + inputSchema: { + type: "object", + properties: { + modelCanonicalName: { + type: "string", + description: + "The exact canonical name of a model already in this " + + "workbench's own catalog — never invented.", + }, + providerName: { + type: "string", + description: + "The exact name of a model provider already in this " + + "workbench's own catalog — never invented.", + }, + priority: { + type: "number", + description: + "Ordering hint for source resolution; lower values are " + + "preferred first. Defaults to 0.", + }, + capabilities: { + type: "array", + items: { type: "string", enum: [...WIRE_CAPABILITIES] }, + description: + "The capabilities this offering advertises for the model.", + }, + }, + required: ["modelCanonicalName", "providerName"], + }, + }, + { + name: SET_OFFERING_PRIORITY_TOOL, + description: + "reorder an offering this workbench already owns directly, " + + "changing where it falls in source-resolution fallback order. " + + "Lower values are preferred first.", + inputSchema: { + type: "object", + properties: { + offeringId: { + type: "string", + description: + "The offering's id, from create_offering or " + + "the workbench's own catalog listing.", + }, + priority: { + type: "number", + description: + "The offering's new priority; lower values are preferred " + + "first.", + }, + }, + required: ["offeringId", "priority"], + }, + }, + { + name: DISABLE_OFFERING_TOOL, + description: + "restrict an offering this workbench already owns directly, " + + "taking it out of source resolution without deleting its " + + "pricing history. A human must approve before it stops " + + "resolving. Running instances resolved through it fail over " + + "to the next eligible source.", + inputSchema: { + type: "object", + properties: { + offeringId: { + type: "string", + description: "The offering's id to disable.", + }, + }, + required: ["offeringId"], + }, + }, + ], + run: (call: ToolCall, _signal: AbortSignal) => { + switch (call.name) { + case CREATE_OFFERING_TOOL: { + const parsed = CreateOfferingInput(call.arguments); + if (parsed instanceof type.errors) { + return Promise.resolve( + errorResult( + call.id, + new Error( + `create_offering received invalid input: ${parsed.summary}`, + ), + ), + ); + } + return runCreateOffering(env, call, parsed); + } + case SET_OFFERING_PRIORITY_TOOL: { + const parsed = SetOfferingPriorityInput(call.arguments); + if (parsed instanceof type.errors) { + return Promise.resolve( + errorResult( + call.id, + new Error( + `set_offering_priority received invalid input: ${parsed.summary}`, + ), + ), + ); + } + return runSetOfferingPriority(env, call, parsed); + } + case DISABLE_OFFERING_TOOL: { + const parsed = DisableOfferingInput(call.arguments); + if (parsed instanceof type.errors) { + return Promise.resolve( + errorResult( + call.id, + new Error( + `disable_offering received invalid input: ${parsed.summary}`, + ), + ), + ); + } + return runDisableOffering(env, call, parsed); + } + default: + return Promise.resolve( + errorResult( + call.id, + new Error(`@corbits/catalog-tools: unknown tool "${call.name}"`), + ), + ); + } + }, + }), +}); diff --git a/packages/catalog-tools/tsconfig.json b/packages/catalog-tools/tsconfig.json index 081530363..b9efa0c3b 100644 --- a/packages/catalog-tools/tsconfig.json +++ b/packages/catalog-tools/tsconfig.json @@ -16,8 +16,20 @@ { "path": "../../vendor/intx/agent/tsconfig.src.json" }, + { + "path": "../../vendor/intx/db/tsconfig.src.json" + }, + { + "path": "../../vendor/intx/hub-api/tsconfig.src.json" + }, + { + "path": "../../vendor/intx/hub-sessions/tsconfig.src.json" + }, { "path": "../../vendor/intx/types/tsconfig.src.json" + }, + { + "path": "../error-sink/tsconfig.src.json" } ] } diff --git a/packages/catalog-tools/tsconfig.src.json b/packages/catalog-tools/tsconfig.src.json index 3959f6247..2311cd544 100644 --- a/packages/catalog-tools/tsconfig.src.json +++ b/packages/catalog-tools/tsconfig.src.json @@ -13,8 +13,20 @@ { "path": "../../vendor/intx/agent/tsconfig.src.json" }, + { + "path": "../../vendor/intx/db/tsconfig.src.json" + }, + { + "path": "../../vendor/intx/hub-api/tsconfig.src.json" + }, + { + "path": "../../vendor/intx/hub-sessions/tsconfig.src.json" + }, { "path": "../../vendor/intx/types/tsconfig.src.json" + }, + { + "path": "../error-sink/tsconfig.src.json" } ] } diff --git a/packages/seeding/src/seed.ts b/packages/seeding/src/seed.ts index 74be9c52f..a0533ff75 100644 --- a/packages/seeding/src/seed.ts +++ b/packages/seeding/src/seed.ts @@ -666,16 +666,29 @@ export const SEED_GRANTS: readonly { resource: string; action: string }[] = [ { resource: "asset:*", action: "write" }, ]; +// Grants planted ONLY on Myra's own run principal (see +// `plantAssistantRunPrincipalGrants`), not the tenant's shared principal +// `SEED_GRANTS` above reconciles — these let Myra mint/revoke grants and +// administer the catalog for her own specialist agents without handing +// every seeded principal in the tenant that same reach. +// // `@corbits/access-tools`' workflow-run-authenticated routes // (`list_principals`, `list_grants`, `grant_access`, `revoke_access`): // read on both, plus create/manage on grants so Myra can mint and revoke -// the scoped grants she stands up for her own specialist agents. Planted -// ONLY on Myra's own run principal (see `plantAssistantAccessToolsGrants`) -// — every other `SEED_GRANTS` entry above lands on the tenant's shared -// principal, and `principal:*`/`grant:*` there would let that shared -// principal read and mint grants for every principal in the tenant, not -// just its own specialist agents. -const ASSISTANT_ACCESS_TOOLS_GRANTS: readonly { +// the scoped grants she stands up for her own specialist agents. +// `principal:*`/`grant:*` on the shared principal would let it read and +// mint grants for every principal in the tenant, not just its own +// specialist agents. +// +// CL-7468: `@corbits/catalog-tools`' `create_offering`/ +// `set_offering_priority`/`disable_offering` resolve a canonical model +// name and a provider name to ids (read) before writing the offering +// itself (create/manage) through the tenant-admin catalog routes +// (`vendor/intx/hub-api/src/routes/{models,model-providers, +// model-offerings}.ts`). Scoped to Myra's run principal for the same +// reason as the access-tools grants above: a tenant-wide grant would let +// every seeded principal administer the catalog, not just Myra. +const ASSISTANT_RUN_PRINCIPAL_GRANTS: readonly { resource: string; action: string; }[] = [ @@ -683,6 +696,11 @@ const ASSISTANT_ACCESS_TOOLS_GRANTS: readonly { { resource: "grant:*", action: "read" }, { resource: "grant:*", action: "create" }, { resource: "grant:*", action: "manage" }, + { resource: "model:*", action: "read" }, + { resource: "model-provider:*", action: "read" }, + { resource: "model-offering:*", action: "read" }, + { resource: "model-offering:*", action: "create" }, + { resource: "model-offering:*", action: "manage" }, ]; // The grants table has no unique constraint and the create route is a @@ -771,9 +789,10 @@ export async function reconcileSeedGrants( } /** - * Plants the `@corbits/access-tools` grants on Myra's OWN run principal, - * not the tenant's shared principal (CL-7467). `deriveRunPrincipalId` is - * the same deterministic `(tenantId, runId)` derivation + * Plants `ASSISTANT_RUN_PRINCIPAL_GRANTS` (the `@corbits/access-tools` + * and `@corbits/catalog-tools` grants) on Myra's OWN run principal, not + * the tenant's shared principal (CL-7467, CL-7468). `deriveRunPrincipalId` + * is the same deterministic `(tenantId, runId)` derivation * `@intx/hub-api`'s `workflow-run-trigger` uses to mint a deployment's * run principal on its first materialized trigger — since a deployment's * top-level run id never changes across a relaunch, this stays stable @@ -784,7 +803,7 @@ export async function reconcileSeedGrants( * the connect flow's `confirmDeployments: false` path defers this until * the tenant's next `workbench seed` run finds a confirmed deployment. */ -async function plantAssistantAccessToolsGrants( +async function plantAssistantRunPrincipalGrants( api: ApiCall, cookies: string[], args: { tenantId: string; deploymentId: string }, @@ -794,7 +813,7 @@ async function plantAssistantAccessToolsGrants( args.tenantId, args.deploymentId, ); - for (const grant of ASSISTANT_ACCESS_TOOLS_GRANTS) { + for (const grant of ASSISTANT_RUN_PRINCIPAL_GRANTS) { await plantGrant( api, cookies, @@ -1387,7 +1406,7 @@ export async function seedTenant(args: SeedTenantArgs): Promise { log, ); if (workflow.assetName === SETUP_AGENT_ASSET_NAME) { - await plantAssistantAccessToolsGrants( + await plantAssistantRunPrincipalGrants( api, cookies, { tenantId: tenant.tenantId, deploymentId }, diff --git a/packages/tool-registry-publish/src/pack.test.ts b/packages/tool-registry-publish/src/pack.test.ts index f0808342f..9328ecf19 100644 --- a/packages/tool-registry-publish/src/pack.test.ts +++ b/packages/tool-registry-publish/src/pack.test.ts @@ -71,6 +71,20 @@ describe("packToolPackageTarball", () => { "id" in (value as object), ); expect(factories.length).toBeGreaterThan(0); + + if (tarball.name === "@corbits/catalog-tools") { + // Both bundles this package exports (the read-only + // `catalogTools` and CL-7468's write-capable + // `catalogOfferingTools`) must resolve by id from the packed + // tarball the sidecar actually loads, not just from the + // workspace source tree — a bundle dropped from `./index.ts`'s + // re-exports would still leave `factories.length` above 0. + const ids = factories.map( + (factory) => (factory as { id: string }).id, + ); + expect(ids).toContain("@corbits/catalog-tools/catalog"); + expect(ids).toContain("@corbits/catalog-tools/off"); + } } finally { await rm(extractDir, { recursive: true, force: true }); } diff --git a/workflows/assistant/src/index.ts b/workflows/assistant/src/index.ts index 088f5eb34..bca576018 100644 --- a/workflows/assistant/src/index.ts +++ b/workflows/assistant/src/index.ts @@ -47,7 +47,7 @@ export const ASSISTANT_TOOL_PACKAGE_PINS: readonly ToolPackagePin[] = [ { name: "@corbits/capability-tools", version: "0.0.5" }, { name: "@corbits/agent-directory-tools", version: "0.0.6" }, { name: "@corbits/connections-tools", version: "0.0.8" }, - { name: "@corbits/catalog-tools", version: "0.0.2" }, + { name: "@corbits/catalog-tools", version: "0.0.4" }, { name: "@corbits/skills-tools", version: "0.0.6" }, { name: "@corbits/mcp-tools", version: "0.0.11" }, { name: "@corbits/interaction-tools", version: "0.0.7" },