From 295f6c0f76e0234a4608fc91d052bd0cf2b61597 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Thu, 17 Sep 2026 11:10:48 -0700 Subject: [PATCH 1/5] feat(inference-catalog)!: model chain resolves from stock tenant reads Deletes the bench_model_policy table and pg-store: bench model policy now lives under the tenant's own config blob at corbits.modelPolicy, read with the stock GET /api/tenants/:tenantId. Adds catalog.ts, a pure flattening of the stock GET /api/tenants/:tenantId/models discovery response into the offering/pricing rows resolveModelChain ranks, so chain resolution has no database and no Workbench-only route anywhere near it. --- packages/inference-catalog/package.json | 37 ++- packages/inference-catalog/src/catalog.ts | 80 +++++ packages/inference-catalog/src/index.ts | 35 +- packages/inference-catalog/src/migrations.ts | 51 --- packages/inference-catalog/src/pg-store.ts | 123 ------- packages/inference-catalog/src/policy.ts | 75 ++++- packages/inference-catalog/src/price.test.ts | 38 +-- packages/inference-catalog/src/price.ts | 48 ++- .../src/resolve-chain.test.ts | 41 --- .../inference-catalog/src/resolve-chain.ts | 38 +-- packages/inference-catalog/src/schema.ts | 37 --- packages/inference-catalog/src/store.test.ts | 57 ---- packages/inference-catalog/src/store.ts | 84 ----- .../src/workflow-catalog-routes.ts | 309 ------------------ packages/inference-catalog/test/fixtures.ts | 73 +---- .../inference-catalog/test/migrations.test.ts | 114 ------- .../test/workflow-catalog-routes.test.ts | 158 --------- packages/inference-catalog/tsconfig.json | 22 +- packages/inference-catalog/tsconfig.src.json | 22 +- packages/ollama-adapter/package.json | 22 +- packages/ollama-adapter/tsconfig.json | 10 +- packages/ollama-adapter/tsconfig.src.json | 10 +- 22 files changed, 264 insertions(+), 1220 deletions(-) create mode 100644 packages/inference-catalog/src/catalog.ts delete mode 100644 packages/inference-catalog/src/migrations.ts delete mode 100644 packages/inference-catalog/src/pg-store.ts delete mode 100644 packages/inference-catalog/src/schema.ts delete mode 100644 packages/inference-catalog/src/store.test.ts delete mode 100644 packages/inference-catalog/src/store.ts delete mode 100644 packages/inference-catalog/src/workflow-catalog-routes.ts delete mode 100644 packages/inference-catalog/test/migrations.test.ts delete mode 100644 packages/inference-catalog/test/workflow-catalog-routes.test.ts diff --git a/packages/inference-catalog/package.json b/packages/inference-catalog/package.json index a83a87b887..2c2ae5b15f 100644 --- a/packages/inference-catalog/package.json +++ b/packages/inference-catalog/package.json @@ -1,34 +1,43 @@ { "name": "@corbits/inference-catalog", - "private": true, "description": "Model concepts and chain resolution: agents ask for what the work needs (a concept or a capability set) and get an ordered, priced fallback chain of the models this bench can actually reach — never a single model, never a model named by hand.", - "version": "0.0.1", + "version": "0.1.0", "license": "LGPL-2.1-or-later", "type": "module", "exports": { ".": "./src/index.ts", + "./catalog": "./src/catalog.ts", "./concepts": "./src/concepts.ts", + "./policy": "./src/policy.ts", + "./price": "./src/price.ts", "./resolve-chain": "./src/resolve-chain.ts", "./offering-capabilities": "./src/offering-capabilities.ts", - "./ollama-context-defaults": "./src/ollama-context-defaults.ts", - "./migrations": "./src/migrations.ts" + "./ollama-context-defaults": "./src/ollama-context-defaults.ts" + }, + "files": [ + "src", + "LICENSE", + "README.md" + ], + "repository": { + "type": "git", + "url": "https://github.com/corbitsdev/workbench.git", + "directory": "packages/inference-catalog" + }, + "publishConfig": { + "access": "public" }, "scripts": { "typecheck": "tsc --noEmit", "test": "bun test" }, "dependencies": { - "@corbits/error-sink": "workspace:*", - "@corbits/migration-runner": "workspace:*", - "@corbits/ollama-adapter": "workspace:*", - "@intx/db": "workspace:*", - "@intx/hub-api": "workspace:*", + "@corbits/ollama-adapter": "0.0.2", "@intx/inference-catalog": "0.3.0", - "@intx/types": "workspace:*", - "arktype": "catalog:", - "drizzle-orm": "catalog:", - "hono": "catalog:", - "postgres": "catalog:" + "arktype": "catalog:" + }, + "peerDependencies": { + "@intx/types": "0.3.0" }, "devDependencies": { "@types/bun": "catalog:", diff --git a/packages/inference-catalog/src/catalog.ts b/packages/inference-catalog/src/catalog.ts new file mode 100644 index 0000000000..09d92f08fe --- /dev/null +++ b/packages/inference-catalog/src/catalog.ts @@ -0,0 +1,80 @@ +// The catalog shape chain resolution works on: the platform's own model +// discovery response (`GET /api/tenants/:tenantId/models`), parsed. +// +// That route already does the parts that belong to the platform — the +// ancestor walk, shadowing, disable suppression, and picking each +// offering's active price per currency — so this package restates none of +// them. It declares the wire shape once, as an arktype schema a caller +// parses an untrusted response with, and flattens it into the per-offering +// rows the resolver ranks. +// +// Nothing here reaches a database. The types are the platform's response, +// not its tables, which is what lets this package ship to a workflow child +// with no server dependency at all. +import { type } from "arktype"; + +import { Capability } from "./capabilities"; + +/** One active price row for an offering, prices per token as decimal + * strings — the column's own units, normalized to USD per million tokens + * in `price.ts` and nowhere else. */ +export const CatalogPricingRow = type({ + offeringId: "string", + currency: "string", + "inputTokenPrice?": "string | null", + "outputTokenPrice?": "string | null", +}); +export type CatalogPricingRow = typeof CatalogPricingRow.infer; + +export const DiscoveredModelOffering = type({ + offeringId: "string", + providerName: "string", + plugin: "string", + priority: "number", + capabilities: Capability.array(), + pricing: CatalogPricingRow.array(), +}); + +export const DiscoveredModel = type({ + id: "string", + canonicalName: "string", + "displayName?": "string | null", + offerings: DiscoveredModelOffering.array(), +}); +export type DiscoveredModel = typeof DiscoveredModel.infer; + +/** One model on one provider, the unit chain resolution ranks. */ +export type CatalogOffering = { + readonly offeringId: string; + readonly canonicalName: string; + readonly displayName: string | null; + readonly providerName: string; + readonly plugin: string; + readonly priority: number; + readonly capabilities: readonly Capability[]; +}; + +/** Flattens the discovery response into the resolver's two inputs: one row + * per (model, provider) pairing, and the price rows those offerings carry. */ +export function catalogOfferingsFrom(models: readonly DiscoveredModel[]): { + readonly offerings: readonly CatalogOffering[]; + readonly pricing: readonly CatalogPricingRow[]; +} { + const offerings: CatalogOffering[] = []; + const pricing: CatalogPricingRow[] = []; + for (const model of models) { + for (const entry of model.offerings) { + offerings.push({ + offeringId: entry.offeringId, + canonicalName: model.canonicalName, + displayName: model.displayName ?? null, + providerName: entry.providerName, + plugin: entry.plugin, + priority: entry.priority, + capabilities: entry.capabilities, + }); + pricing.push(...entry.pricing); + } + } + return { offerings, pricing }; +} diff --git a/packages/inference-catalog/src/index.ts b/packages/inference-catalog/src/index.ts index 2d5ecd7bdc..b6dc5cb218 100644 --- a/packages/inference-catalog/src/index.ts +++ b/packages/inference-catalog/src/index.ts @@ -1,4 +1,10 @@ export { Capability, WIRE_CAPABILITIES } from "./capabilities"; +export { + catalogOfferingsFrom, + CatalogPricingRow, + DiscoveredModel, + type CatalogOffering, +} from "./catalog"; export { CONCEPTS, CONCEPT_IDS, @@ -16,6 +22,7 @@ export { } from "./offering-capabilities"; export { DEFAULT_CURRENCY, + estimateUsd, groupPricingByOffering, perMTok, priceForOffering, @@ -23,10 +30,12 @@ export { type OfferingPrice, } from "./price"; export { - BenchModelPolicyPatch, EMPTY_POLICY, + MODEL_POLICY_CONFIG_PATH, matchesAny, + readModelPolicy, selectorMatches, + StoredModelPolicy, type BenchModelPolicy, type PolicyCeiling, type PolicySelector, @@ -42,27 +51,3 @@ export { type ModelChain, type ResolveChainInput, } from "./resolve-chain"; -export { - applyInferenceCatalogMigrations, - inferenceCatalogMigrations, - type ApplyInferenceCatalogMigrationsReport, - type InferenceCatalogMigration, -} from "./migrations"; -export { - benchModelPolicy, - inferenceCatalogSchema, - type BenchModelPolicyRow, -} from "./schema"; -export { - applyPolicyPatch, - createMemoryBenchModelPolicyStore, - type BenchModelPolicyStore, -} from "./store"; -export { createPostgresBenchModelPolicyStore } from "./pg-store"; -export { - createWorkflowCatalogRoutes, - estimateUsd, - type CreateWorkflowCatalogRoutesDeps, - type WorkflowCatalogEnv, - type WorkflowCatalogRunScope, -} from "./workflow-catalog-routes"; diff --git a/packages/inference-catalog/src/migrations.ts b/packages/inference-catalog/src/migrations.ts deleted file mode 100644 index b9db427b6d..0000000000 --- a/packages/inference-catalog/src/migrations.ts +++ /dev/null @@ -1,51 +0,0 @@ -// Package-owned migrations for @corbits/inference-catalog. Own ledger, own -// Postgres schema, literal SQL, transactional apply — the convention in -// docs/package-migrations.md. Mechanics (schema/ledger bootstrap, -// transactional apply, the advisory lock across concurrent hub replicas) -// live in @corbits/migration-runner — this file owns only the domain SQL. -import { - applyPackageMigrations, - type ApplyPackageMigrationsReport, - type PackageMigration, -} from "@corbits/migration-runner"; - -export type InferenceCatalogMigration = PackageMigration; - -const SCHEMA = "inference_catalog"; - -export const inferenceCatalogMigrations: readonly InferenceCatalogMigration[] = - [ - { - name: "0001_bench_model_policy", - sql: ` - CREATE TABLE IF NOT EXISTS "inference_catalog"."bench_model_policy" ( - "tenant_id" text PRIMARY KEY, - "allow" text[] NOT NULL DEFAULT '{}', - "deny" text[] NOT NULL DEFAULT '{}', - "max_input_usd_per_mtok" numeric, - "max_output_usd_per_mtok" numeric, - "ceiling_is_hard" boolean NOT NULL DEFAULT false, - "concept_ceilings" jsonb NOT NULL DEFAULT '{}', - "provider_preference" jsonb, - "updated_at" timestamptz NOT NULL DEFAULT now() - ); - `, - }, - ]; - -const LEDGER_TABLE = "inference_catalog_migrations"; - -export type ApplyInferenceCatalogMigrationsReport = - ApplyPackageMigrationsReport; - -export async function applyInferenceCatalogMigrations( - databaseUrl: string, -): Promise { - return applyPackageMigrations({ - databaseUrl, - schema: SCHEMA, - ledgerTable: LEDGER_TABLE, - migrations: inferenceCatalogMigrations, - packageLabel: "inference-catalog", - }); -} diff --git a/packages/inference-catalog/src/pg-store.ts b/packages/inference-catalog/src/pg-store.ts deleted file mode 100644 index 21cec5be35..0000000000 --- a/packages/inference-catalog/src/pg-store.ts +++ /dev/null @@ -1,123 +0,0 @@ -// Postgres-backed BenchModelPolicyStore against the package-owned -// bench_model_policy table. Opened after applyInferenceCatalogMigrations on -// the same URL; the caller owns connection lifetime. -// -// The patch is merged in application code rather than in SQL: a policy is a -// small set of named fields a human edits from a settings surface, not a -// forward-compatible bag, so a read-modify-write of the whole row is the -// honest shape. The read and the write run in one transaction so two -// concurrent edits cannot interleave. -import postgres from "postgres"; - -import { EMPTY_POLICY, type BenchModelPolicy } from "./policy"; -import { applyPolicyPatch, type BenchModelPolicyStore } from "./store"; - -type Sql = ReturnType; - -type PolicyRow = { - allow: string[]; - deny: string[]; - max_input_usd_per_mtok: string | null; - max_output_usd_per_mtok: string | null; - ceiling_is_hard: boolean; - concept_ceilings: unknown; - provider_preference: unknown; -}; - -// postgres.js does not reliably deserialize jsonb under every runtime this -// repo ships on — parse defensively rather than trusting the driver. -function parseJsonb(value: unknown, fallback: T): T { - if (value === null || value === undefined) return fallback; - if (typeof value === "string") return JSON.parse(value) as T; - return value as T; -} - -function toPolicy(row: PolicyRow): BenchModelPolicy { - return { - allow: row.allow, - deny: row.deny, - maxInputUsdPerMTok: - row.max_input_usd_per_mtok === null - ? null - : Number(row.max_input_usd_per_mtok), - maxOutputUsdPerMTok: - row.max_output_usd_per_mtok === null - ? null - : Number(row.max_output_usd_per_mtok), - ceilingIsHard: row.ceiling_is_hard, - conceptCeilings: parseJsonb(row.concept_ceilings, {}), - providerPreference: parseJsonb(row.provider_preference, null), - }; -} - -export function createPostgresBenchModelPolicyStore(databaseUrl: string): { - store: BenchModelPolicyStore; - close: () => Promise; -} { - const sql: Sql = postgres(databaseUrl, { max: 4, onnotice: () => undefined }); - - const store: BenchModelPolicyStore = { - async getPolicy(tenantId) { - const rows = await sql` - SELECT allow, deny, max_input_usd_per_mtok, max_output_usd_per_mtok, - ceiling_is_hard, concept_ceilings, provider_preference - FROM inference_catalog.bench_model_policy - WHERE tenant_id = ${tenantId} - LIMIT 1 - `; - const row = rows[0]; - return row === undefined ? EMPTY_POLICY : toPolicy(row); - }, - - async patchPolicy(tenantId, patch) { - return await sql.begin(async (tx) => { - const rows = await tx` - SELECT allow, deny, max_input_usd_per_mtok, max_output_usd_per_mtok, - ceiling_is_hard, concept_ceilings, provider_preference - FROM inference_catalog.bench_model_policy - WHERE tenant_id = ${tenantId} - FOR UPDATE - `; - const row = rows[0]; - const merged = applyPolicyPatch( - row === undefined ? EMPTY_POLICY : toPolicy(row), - patch, - ); - await tx` - INSERT INTO inference_catalog.bench_model_policy - (tenant_id, allow, deny, max_input_usd_per_mtok, - max_output_usd_per_mtok, ceiling_is_hard, concept_ceilings, - provider_preference, updated_at) - VALUES ( - ${tenantId}, - ${[...merged.allow]}, - ${[...merged.deny]}, - ${merged.maxInputUsdPerMTok}, - ${merged.maxOutputUsdPerMTok}, - ${merged.ceilingIsHard}, - ${tx.json(merged.conceptCeilings as never)}, - ${merged.providerPreference === null ? null : tx.json(merged.providerPreference as never)}, - now() - ) - ON CONFLICT (tenant_id) DO UPDATE SET - allow = EXCLUDED.allow, - deny = EXCLUDED.deny, - max_input_usd_per_mtok = EXCLUDED.max_input_usd_per_mtok, - max_output_usd_per_mtok = EXCLUDED.max_output_usd_per_mtok, - ceiling_is_hard = EXCLUDED.ceiling_is_hard, - concept_ceilings = EXCLUDED.concept_ceilings, - provider_preference = EXCLUDED.provider_preference, - updated_at = now() - `; - return merged; - }); - }, - }; - - return { - store, - close: async () => { - await sql.end({ timeout: 5 }); - }, - }; -} diff --git a/packages/inference-catalog/src/policy.ts b/packages/inference-catalog/src/policy.ts index e75b97713e..78cf303050 100644 --- a/packages/inference-catalog/src/policy.ts +++ b/packages/inference-catalog/src/policy.ts @@ -1,8 +1,16 @@ -// A bench's model policy: the one row this package stores. Everything else -// it answers is derived at read time from the platform's own catalog and -// price history. +// A bench's model policy: what this bench is willing to spend, and on +// which models. Everything else the package answers is derived at read time +// from the platform's own catalog and price history. // -// A bench with no row uses EMPTY_POLICY, which constrains nothing — that is +// The policy lives in the bench's own tenant `config` blob, under +// `corbits.modelPolicy` — read with the stock `GET /api/tenants/:tenantId`, +// written with the stock `PATCH`. A bench is a plain Interchange tenant and +// its config blob is the platform's own place for per-tenant settings, so +// there is no product table and no Workbench-only route in the path: a +// workflow child reads its bench's policy through the same stock route +// everything else reads a tenant through. +// +// A bench with no key uses EMPTY_POLICY, which constrains nothing — that is // what makes a freshly connected bench work with no configuration at all. import { type } from "arktype"; import { ProviderPreference } from "@intx/types"; @@ -44,9 +52,10 @@ const PolicyCeilingBody = type({ "maxOutputUsdPerMTok?": "number >= 0 | null", }); -/** The write boundary: every field optional, absent fields left untouched. - * `null` on a ceiling clears it. */ -export const BenchModelPolicyPatch = type({ +/** The stored shape, under `config.corbits.modelPolicy`. Every field is + * optional: a partially written key is read as the parts it does carry, + * with EMPTY_POLICY supplying the rest. */ +export const StoredModelPolicy = type({ "allow?": "string[]", "deny?": "string[]", "maxInputUsdPerMTok?": "number >= 0 | null", @@ -55,7 +64,57 @@ export const BenchModelPolicyPatch = type({ "conceptCeilings?": type.Record("string", PolicyCeilingBody), "providerPreference?": ProviderPreference.or("null"), }); -export type BenchModelPolicyPatch = typeof BenchModelPolicyPatch.infer; + +const CorbitsTenantConfig = type({ + "corbits?": { + "modelPolicy?": StoredModelPolicy, + "[string]": "unknown", + }, + "[string]": "unknown", +}); + +/** The key path a policy is stored and read under, as the stock PATCH body + * spells it. */ +export const MODEL_POLICY_CONFIG_PATH = ["corbits", "modelPolicy"] as const; + +/** + * This bench's model policy, out of its tenant `config` blob. + * + * Anything the schema rejects — a missing key, a config blob shaped some + * other way, a half-written policy — reads as the parts that do parse over + * EMPTY_POLICY. A bench is never left unable to pick a model because its + * settings blob is malformed; it is left unconstrained, which is the same + * place a brand-new bench starts. + */ +export function readModelPolicy(tenantConfig: unknown): BenchModelPolicy { + const parsed = CorbitsTenantConfig(tenantConfig); + if (parsed instanceof type.errors) return EMPTY_POLICY; + const stored = parsed.corbits?.modelPolicy; + if (stored === undefined) return EMPTY_POLICY; + return { + allow: stored.allow ?? EMPTY_POLICY.allow, + deny: stored.deny ?? EMPTY_POLICY.deny, + maxInputUsdPerMTok: stored.maxInputUsdPerMTok ?? null, + maxOutputUsdPerMTok: stored.maxOutputUsdPerMTok ?? null, + ceilingIsHard: stored.ceilingIsHard ?? EMPTY_POLICY.ceilingIsHard, + conceptCeilings: readConceptCeilings(stored.conceptCeilings), + providerPreference: stored.providerPreference ?? null, + }; +} + +function readConceptCeilings( + stored: Record | undefined, +): Readonly> { + if (stored === undefined) return EMPTY_POLICY.conceptCeilings; + const ceilings: Record = {}; + for (const [concept, ceiling] of Object.entries(stored)) { + ceilings[concept] = { + maxInputUsdPerMTok: ceiling.maxInputUsdPerMTok ?? null, + maxOutputUsdPerMTok: ceiling.maxOutputUsdPerMTok ?? null, + }; + } + return ceilings; +} /** True when `selector` names this offering, under any of the three forms. */ export function selectorMatches( diff --git a/packages/inference-catalog/src/price.test.ts b/packages/inference-catalog/src/price.test.ts index 05512e7859..ae23811ce0 100644 --- a/packages/inference-catalog/src/price.test.ts +++ b/packages/inference-catalog/src/price.test.ts @@ -8,8 +8,6 @@ import { referenceCostUsd, } from "./price"; -const ASOF = new Date("2026-06-01T00:00:00.000Z"); - describe("perMTok", () => { test("scales a per-token decimal string to USD per million tokens", () => { expect(perMTok("0.000003")).toBeCloseTo(3, 10); @@ -25,29 +23,9 @@ describe("perMTok", () => { }); describe("priceForOffering", () => { - test("takes the latest row at or before asOf", () => { + test("takes the row in the requested currency", () => { const price = priceForOffering( - [ - pricing({ - offeringId: "o1", - inputUsdPerMTok: 3, - outputUsdPerMTok: 15, - effectiveFrom: new Date("2026-01-01T00:00:00.000Z"), - }), - pricing({ - offeringId: "o1", - inputUsdPerMTok: 2, - outputUsdPerMTok: 10, - effectiveFrom: new Date("2026-05-01T00:00:00.000Z"), - }), - pricing({ - offeringId: "o1", - inputUsdPerMTok: 99, - outputUsdPerMTok: 99, - effectiveFrom: new Date("2026-09-01T00:00:00.000Z"), - }), - ], - ASOF, + [pricing({ offeringId: "o1", inputUsdPerMTok: 2, outputUsdPerMTok: 10 })], "USD", ); expect(price.inputUsdPerMTok).toBeCloseTo(2, 10); @@ -65,7 +43,6 @@ describe("priceForOffering", () => { currency: "EUR", }), ], - ASOF, "USD", ); expect(price).toEqual({ @@ -77,7 +54,7 @@ describe("priceForOffering", () => { }); test("no rows at all is unknown, never zero", () => { - const price = priceForOffering([], ASOF, "USD"); + const price = priceForOffering([], "USD"); expect(price.known).toBe(false); expect(price.inputUsdPerMTok).toBeNull(); }); @@ -91,7 +68,6 @@ describe("priceForOffering", () => { outputUsdPerMTok: null, }), ], - ASOF, "USD", ); expect(price.known).toBe(false); @@ -102,7 +78,6 @@ describe("referenceCostUsd", () => { test("weights the two axes by the concept's mix", () => { const price = priceForOffering( [pricing({ offeringId: "o1", inputUsdPerMTok: 3, outputUsdPerMTok: 15 })], - ASOF, "USD", ); expect( @@ -129,12 +104,7 @@ describe("groupPricingByOffering", () => { test("keeps every row for an offering together", () => { const grouped = groupPricingByOffering([ pricing({ offeringId: "o1", inputUsdPerMTok: 1, outputUsdPerMTok: 2 }), - pricing({ - offeringId: "o1", - inputUsdPerMTok: 1, - outputUsdPerMTok: 2, - effectiveFrom: new Date("2026-02-01T00:00:00.000Z"), - }), + pricing({ offeringId: "o1", inputUsdPerMTok: 1, outputUsdPerMTok: 2 }), pricing({ offeringId: "o2", inputUsdPerMTok: 1, outputUsdPerMTok: 2 }), ]); expect(grouped.get("o1")?.length).toBe(2); diff --git a/packages/inference-catalog/src/price.ts b/packages/inference-catalog/src/price.ts index 3fa2260556..9b1a35ca64 100644 --- a/packages/inference-catalog/src/price.ts +++ b/packages/inference-catalog/src/price.ts @@ -1,12 +1,16 @@ -// Price normalization. `model_pricing` stores per-token prices as decimal +// Price normalization. The catalog stores per-token prices as decimal // strings; every human number in this package — ceilings, estimates, the // copy an agent reads — is USD per million tokens. That conversion happens // here and nowhere else. // +// As-of selection is not restated here: the platform's model discovery +// route already returns the row in effect per currency, so this module only +// picks the currency and converts. +// // A missing row, a missing currency, or a null price component yields // `known: false` with null numbers. It never yields zero: a fabricated zero // would read as "free" and quietly win every cheapest-first sort. -import { resolveActivePrice, type ModelPricingRow } from "@intx/db"; +import type { CatalogPricingRow } from "./catalog"; export const DEFAULT_CURRENCY = "USD"; @@ -20,16 +24,16 @@ export type OfferingPrice = { const TOKENS_PER_MTOK = 1_000_000; /** Converts one per-token decimal string to USD per million tokens. */ -export function perMTok(perToken: string | null): number | null { - if (perToken === null) return null; +export function perMTok(perToken: string | null | undefined): number | null { + if (perToken === null || perToken === undefined) return null; const parsed = Number(perToken); return Number.isFinite(parsed) ? parsed * TOKENS_PER_MTOK : null; } export function groupPricingByOffering( - rows: readonly ModelPricingRow[], -): Map { - const byOffering = new Map(); + rows: readonly CatalogPricingRow[], +): Map { + const byOffering = new Map(); for (const row of rows) { const existing = byOffering.get(row.offeringId); if (existing === undefined) byOffering.set(row.offeringId, [row]); @@ -38,19 +42,12 @@ export function groupPricingByOffering( return byOffering; } -/** - * The price in effect for one offering at `asOf`, in USD per million tokens. - * As-of selection is the platform's own `resolveActivePrice`; this adds only - * the currency pick and the per-million normalization. - */ +/** The offering's price in `currency`, in USD per million tokens. */ export function priceForOffering( - rows: readonly ModelPricingRow[], - asOf: Date, + rows: readonly CatalogPricingRow[], currency: string, ): OfferingPrice { - const active = resolveActivePrice([...rows], asOf).find( - (row) => row.currency === currency, - ); + const active = rows.find((row) => row.currency === currency); if (active === undefined) { return { currency, @@ -84,3 +81,20 @@ export function referenceCostUsd( price.outputUsdPerMTok * mix.outputMTok ); } + +/** What a run of the given size would cost at this price, in USD. Null + * whenever either axis is unpriced: half an estimate is not an estimate, + * and a zero would read as free. */ +export function estimateUsd( + price: OfferingPrice, + expectedInputTokens: number, + expectedOutputTokens: number, +): number | null { + if (price.inputUsdPerMTok === null || price.outputUsdPerMTok === null) { + return null; + } + return ( + (price.inputUsdPerMTok * expectedInputTokens) / TOKENS_PER_MTOK + + (price.outputUsdPerMTok * expectedOutputTokens) / TOKENS_PER_MTOK + ); +} diff --git a/packages/inference-catalog/src/resolve-chain.test.ts b/packages/inference-catalog/src/resolve-chain.test.ts index 8d8e583006..e72a49e1e5 100644 --- a/packages/inference-catalog/src/resolve-chain.test.ts +++ b/packages/inference-catalog/src/resolve-chain.test.ts @@ -10,8 +10,6 @@ import { type ResolveChainInput, } from "./resolve-chain"; -const ASOF = new Date("2026-06-01T00:00:00.000Z"); - const CHEAP_LOOP_CAPABLE = ["plain-text", "structured-output"] as const; function input(overrides: Partial): ResolveChainInput { @@ -20,7 +18,6 @@ function input(overrides: Partial): ResolveChainInput { offerings: [], pricing: [], policy: EMPTY_POLICY, - asOf: ASOF, ...overrides, }; } @@ -89,26 +86,6 @@ describe("resolveModelChain: what qualifies", () => { { offeringId: "o1", reason: "missing-capabilities" }, ]); }); - - test("a provider with no credential is not something this bench can reach", () => { - const chain = resolveModelChain( - input({ - offerings: [ - offering({ - id: "o1", - canonicalName: "unreachable", - providerName: "acme", - capabilities: [...CHEAP_LOOP_CAPABLE], - connected: false, - }), - ], - }), - ); - expect(chain.entries).toEqual([]); - expect(chain.excluded).toEqual([ - { offeringId: "o1", reason: "provider-not-connected" }, - ]); - }); }); describe("resolveModelChain: policy selectors", () => { @@ -514,7 +491,6 @@ describe("resolveModelChain: the chain itself", () => { ], pricing: [], policy: EMPTY_POLICY, - asOf: ASOF, }); expect(chain.entries.map((entry) => entry.canonicalName)).toEqual([ "only-model", @@ -546,23 +522,6 @@ describe("resolveModelChain: the chain itself", () => { { offeringId: "o1", reason: "missing-capabilities" }, ]); }); - - test("provenance records whether the offering was set here or inherited", () => { - const chain = resolveModelChain( - input({ - offerings: [ - offering({ - id: "o1", - canonicalName: "inherited-model", - providerName: "acme", - capabilities: [...CHEAP_LOOP_CAPABLE], - inherited: true, - }), - ], - }), - ); - expect(chain.entries[0]?.provenance).toBe("inherited"); - }); }); describe("chainToModelRequirements", () => { diff --git a/packages/inference-catalog/src/resolve-chain.ts b/packages/inference-catalog/src/resolve-chain.ts index dd3f13fb9d..3106e3ffe9 100644 --- a/packages/inference-catalog/src/resolve-chain.ts +++ b/packages/inference-catalog/src/resolve-chain.ts @@ -1,8 +1,9 @@ // Chain resolution: what this bench can reach for a given kind of work, // cheapest first, with fallbacks. // -// Pure — every read is done by the caller and handed in, so the whole -// algorithm is exercised with plain literals and no database. It answers +// Pure — every read is done by the caller against the platform's own model +// discovery route and handed in, so the whole algorithm is exercised with +// plain literals and no database anywhere near it. It answers // with an ordered chain, never a single model: one model is not a fallback // plan, and the platform's own source resolution already consumes chains. // @@ -11,9 +12,10 @@ // turns into runnable sources — the capability predicate and the priority // tiebreakers here are deliberately the same ones it uses, so a chain this // package returns can never be rejected downstream by upstream's own rules. -import type { ResolvedOffering, ModelPricingRow } from "@intx/db"; import type { Capability, ProviderPreference } from "@intx/types"; +import type { CatalogOffering, CatalogPricingRow } from "./catalog"; + import { conceptById, CONCEPT_IDS, @@ -47,19 +49,15 @@ export type ChainOrder = "cheapest" | "catalog"; export type ResolveChainInput = { readonly need: ChainNeed; - readonly offerings: readonly ResolvedOffering[]; - readonly pricing: readonly ModelPricingRow[]; + readonly offerings: readonly CatalogOffering[]; + readonly pricing: readonly CatalogPricingRow[]; readonly policy: BenchModelPolicy; - /** Never read from the clock inside: discovery passes now, historical - * attribution passes the moment it is attributing. */ - readonly asOf: Date; readonly currency?: string | undefined; readonly limit?: number | undefined; readonly order?: ChainOrder | undefined; }; export type ExclusionReason = - | "provider-not-connected" | "missing-capabilities" | "policy-deny" | "outside-policy-allow" @@ -76,7 +74,6 @@ export type ChainEntry = { readonly price: OfferingPrice; readonly referenceCostUsd: number | null; readonly overCeiling: boolean; - readonly provenance: "set-here" | "inherited"; }; export type ModelChain = { @@ -223,16 +220,11 @@ export function resolveModelChain(input: ResolveChainInput): ModelChain { const unpriced: Candidate[] = []; for (const resolved of input.offerings) { - const offeringId = resolved.offering.id; - const canonicalName = resolved.model.canonicalName; - const providerName = resolved.provider.name; - - if (resolved.provider.credentialId === null) { - excluded.push({ offeringId, reason: "provider-not-connected" }); - continue; - } + const offeringId = resolved.offeringId; + const canonicalName = resolved.canonicalName; + const providerName = resolved.providerName; - const capabilities = resolved.offering.capabilities as Capability[]; + const capabilities = resolved.capabilities; if (!need.required.every((required) => capabilities.includes(required))) { excluded.push({ offeringId, reason: "missing-capabilities" }); continue; @@ -250,7 +242,6 @@ export function resolveModelChain(input: ResolveChainInput): ModelChain { const price = priceForOffering( pricingByOffering.get(offeringId) ?? [], - input.asOf, currency, ); const overBenchCeiling = overCeiling( @@ -273,16 +264,15 @@ export function resolveModelChain(input: ResolveChainInput): ModelChain { const entry: ChainEntry = { canonicalName, - displayName: resolved.model.displayName, + displayName: resolved.displayName, providerName, - plugin: resolved.provider.plugin, + plugin: resolved.plugin, offeringId, - priority: resolved.offering.priority, + priority: resolved.priority, capabilities, price, referenceCostUsd: referenceCostUsd(price, need.mix), overCeiling: isOverCeiling, - provenance: resolved.origin.direct ? "set-here" : "inherited", }; const candidate: Candidate = { entry, diff --git a/packages/inference-catalog/src/schema.ts b/packages/inference-catalog/src/schema.ts deleted file mode 100644 index 1a31085d4a..0000000000 --- a/packages/inference-catalog/src/schema.ts +++ /dev/null @@ -1,37 +0,0 @@ -// The one table this package owns: a bench's model policy. Everything else -// it answers — what a bench can reach, what it costs, which models fit a -// kind of work — is derived at read time from the platform's catalog and -// price history, so there is nothing else to store. -// -// Lives in its own `inference_catalog` Postgres schema, siloed from the -// platform's `public` schema — see docs/package-migrations.md. `tenantId` is -// a plain text identifier, not a foreign key. -import { - boolean, - jsonb, - numeric, - pgSchema, - text, - timestamp, -} from "drizzle-orm/pg-core"; - -export const inferenceCatalogSchema = pgSchema("inference_catalog"); - -export const benchModelPolicy = inferenceCatalogSchema.table( - "bench_model_policy", - { - tenantId: text("tenant_id").primaryKey(), - allow: text("allow").array().notNull().default([]), - deny: text("deny").array().notNull().default([]), - maxInputUsdPerMTok: numeric("max_input_usd_per_mtok"), - maxOutputUsdPerMTok: numeric("max_output_usd_per_mtok"), - ceilingIsHard: boolean("ceiling_is_hard").notNull().default(false), - conceptCeilings: jsonb("concept_ceilings").notNull().default({}), - providerPreference: jsonb("provider_preference"), - updatedAt: timestamp("updated_at", { withTimezone: true }) - .notNull() - .defaultNow(), - }, -); - -export type BenchModelPolicyRow = typeof benchModelPolicy.$inferSelect; diff --git a/packages/inference-catalog/src/store.test.ts b/packages/inference-catalog/src/store.test.ts deleted file mode 100644 index d08d0df230..0000000000 --- a/packages/inference-catalog/src/store.test.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import { EMPTY_POLICY } from "./policy"; -import { applyPolicyPatch, createMemoryBenchModelPolicyStore } from "./store"; - -describe("bench model policy store", () => { - test("a bench that has never set a policy is unconstrained", async () => { - const store = createMemoryBenchModelPolicyStore(); - expect(await store.getPolicy("bench-1")).toEqual(EMPTY_POLICY); - }); - - test("a patch touches only the fields it names", async () => { - const store = createMemoryBenchModelPolicyStore(); - await store.patchPolicy("bench-1", { deny: ["provider:pricey"] }); - const policy = await store.patchPolicy("bench-1", { ceilingIsHard: true }); - expect(policy.deny).toEqual(["provider:pricey"]); - expect(policy.ceilingIsHard).toBe(true); - expect(policy.allow).toEqual([]); - }); - - test("policies are per bench", async () => { - const store = createMemoryBenchModelPolicyStore(); - await store.patchPolicy("bench-1", { allow: ["alpha"] }); - expect((await store.getPolicy("bench-2")).allow).toEqual([]); - }); -}); - -describe("applyPolicyPatch", () => { - test("null clears a ceiling; absent leaves it alone", () => { - const withCeiling = applyPolicyPatch(EMPTY_POLICY, { - maxInputUsdPerMTok: 2, - maxOutputUsdPerMTok: 8, - }); - const cleared = applyPolicyPatch(withCeiling, { maxInputUsdPerMTok: null }); - expect(cleared.maxInputUsdPerMTok).toBeNull(); - expect(cleared.maxOutputUsdPerMTok).toBe(8); - }); - - test("a concept ceiling override fills the axes it omits with null", () => { - const patched = applyPolicyPatch(EMPTY_POLICY, { - conceptCeilings: { "cheap-loop": { maxInputUsdPerMTok: 1 } }, - }); - expect(patched.conceptCeilings["cheap-loop"]).toEqual({ - maxInputUsdPerMTok: 1, - maxOutputUsdPerMTok: null, - }); - }); - - test("a provider preference can be cleared back to none", () => { - const pinned = applyPolicyPatch(EMPTY_POLICY, { - providerPreference: { mode: "pin", order: ["acme"] }, - }); - expect( - applyPolicyPatch(pinned, { providerPreference: null }).providerPreference, - ).toBeNull(); - }); -}); diff --git a/packages/inference-catalog/src/store.ts b/packages/inference-catalog/src/store.ts deleted file mode 100644 index db630b17fa..0000000000 --- a/packages/inference-catalog/src/store.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { - EMPTY_POLICY, - type BenchModelPolicy, - type BenchModelPolicyPatch, -} from "./policy"; - -export type BenchModelPolicyStore = { - /** The bench's policy, or EMPTY_POLICY when it has never set one. */ - getPolicy(tenantId: string): Promise; - /** Applies only the fields present in `patch` and returns the result. */ - patchPolicy( - tenantId: string, - patch: BenchModelPolicyPatch, - ): Promise; -}; - -export function applyPolicyPatch( - current: BenchModelPolicy, - patch: BenchModelPolicyPatch, -): BenchModelPolicy { - return { - allow: patch.allow ?? current.allow, - deny: patch.deny ?? current.deny, - maxInputUsdPerMTok: - patch.maxInputUsdPerMTok === undefined - ? current.maxInputUsdPerMTok - : patch.maxInputUsdPerMTok, - maxOutputUsdPerMTok: - patch.maxOutputUsdPerMTok === undefined - ? current.maxOutputUsdPerMTok - : patch.maxOutputUsdPerMTok, - ceilingIsHard: patch.ceilingIsHard ?? current.ceilingIsHard, - conceptCeilings: - patch.conceptCeilings === undefined - ? current.conceptCeilings - : normalizeConceptCeilings(patch.conceptCeilings), - providerPreference: - patch.providerPreference === undefined - ? current.providerPreference - : patch.providerPreference, - }; -} - -function normalizeConceptCeilings( - ceilings: Record< - string, - { maxInputUsdPerMTok?: number | null; maxOutputUsdPerMTok?: number | null } - >, -): Record< - string, - { maxInputUsdPerMTok: number | null; maxOutputUsdPerMTok: number | null } -> { - const normalized: Record< - string, - { maxInputUsdPerMTok: number | null; maxOutputUsdPerMTok: number | null } - > = {}; - for (const [conceptId, ceiling] of Object.entries(ceilings)) { - normalized[conceptId] = { - maxInputUsdPerMTok: ceiling.maxInputUsdPerMTok ?? null, - maxOutputUsdPerMTok: ceiling.maxOutputUsdPerMTok ?? null, - }; - } - return normalized; -} - -/** In-memory store for unit tests and local smoke; production wiring uses - * the drizzle-backed implementation behind the same interface. */ -export function createMemoryBenchModelPolicyStore(): BenchModelPolicyStore { - const rows = new Map(); - - return { - async getPolicy(tenantId) { - return rows.get(tenantId) ?? EMPTY_POLICY; - }, - async patchPolicy(tenantId, patch) { - const merged = applyPolicyPatch( - rows.get(tenantId) ?? EMPTY_POLICY, - patch, - ); - rows.set(tenantId, merged); - return merged; - }, - }; -} diff --git a/packages/inference-catalog/src/workflow-catalog-routes.ts b/packages/inference-catalog/src/workflow-catalog-routes.ts deleted file mode 100644 index a38848422d..0000000000 --- a/packages/inference-catalog/src/workflow-catalog-routes.ts +++ /dev/null @@ -1,309 +0,0 @@ -// The execution half of this package: how a running agent asks what this -// bench can reach for a kind of work. Mounted outside the tenant prefix at -// `/api/workflow-inference-catalog`, authenticated by sidecar bearer token -// and run address like every other workflow-run surface — a workflow child -// has no browser session. The tenant every read is scoped to comes from the -// authenticated run alone; identity never rides in a request body. -// -// Read-only throughout, so no grant gate: there is no write to guard. -// -// Takes ports, not a `db` handle, so the package stays decoupled from the -// catalog schema and the whole surface is testable with literals. -import { Hono } from "hono"; -import { type } from "arktype"; -import type { ModelPricingRow, ResolvedOffering } from "@intx/db"; -import { Capability } from "@intx/types"; -import { makeErrorEnvelope } from "@corbits/error-sink"; - -import { CONCEPTS } from "./concepts"; -import { EMPTY_POLICY, type BenchModelPolicy } from "./policy"; -import { - resolveModelChain, - UnknownConceptError, - type ChainNeed, - type ModelChain, -} from "./resolve-chain"; - -export type WorkflowCatalogRunScope = { - readonly tenantId: string; - readonly principalId: string; - readonly runId: string; -}; - -export type WorkflowRunAuthenticator = { - resolve( - token: string, - runAddress: string, - ): Promise; -}; - -export type WorkflowCatalogEnv = { - Variables: { workflowCatalogScope: WorkflowCatalogRunScope }; -}; - -export type CreateWorkflowCatalogRoutesDeps = { - readonly authenticator: WorkflowRunAuthenticator; - readonly listOfferings: ( - tenantId: string, - ) => Promise; - readonly listPricing: ( - tenantId: string, - offeringIds: readonly string[], - ) => Promise; - readonly getPolicy: (tenantId: string) => Promise; - /** Overridable so a test pins the as-of instant rather than the clock. */ - readonly now?: () => Date; -}; - -const NeedBody = type({ - "concept?": "string > 0", - "capabilities?": Capability.array(), -}); - -const ChainBody = NeedBody.and({ - "order?": "'cheapest'|'catalog'", - "limit?": "1 <= number <= 10", -}); - -const EstimateBody = NeedBody.and({ - expectedInputTokens: "number >= 0", - expectedOutputTokens: "number >= 0", -}); - -type NeedFields = { concept?: string; capabilities?: Capability[] }; - -function needFrom(fields: NeedFields): ChainNeed | Error { - const hasConcept = fields.concept !== undefined; - const hasCapabilities = fields.capabilities !== undefined; - if (hasConcept === hasCapabilities) { - return new Error( - "ask for exactly one of `concept` (a kind of work) or `capabilities` (the wire features the work needs)", - ); - } - return fields.concept !== undefined - ? { concept: fields.concept } - : { capabilities: fields.capabilities ?? [] }; -} - -export function createWorkflowCatalogRoutes( - deps: CreateWorkflowCatalogRoutesDeps, -): Hono { - const app = new Hono(); - const now = deps.now ?? (() => new Date()); - - 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, - ); - } - c.set("workflowCatalogScope", scope); - await next(); - }); - - async function chainFor( - tenantId: string, - need: ChainNeed, - order: "cheapest" | "catalog" | undefined, - limit: number | undefined, - ): Promise { - const offerings = await deps.listOfferings(tenantId); - const pricing = await deps.listPricing( - tenantId, - offerings.map((resolved) => resolved.offering.id), - ); - const policy = await deps.getPolicy(tenantId); - return resolveModelChain({ - need, - offerings, - pricing, - policy, - asOf: now(), - order, - limit, - }); - } - - app.get("/concepts", async (c) => { - const scope = c.get("workflowCatalogScope"); - const data = []; - for (const concept of CONCEPTS) { - const chain = await chainFor( - scope.tenantId, - { concept: concept.id }, - "cheapest", - 10, - ); - const head = chain.entries[0]; - data.push({ - id: concept.id, - title: concept.title, - whenToUse: concept.whenToUse, - availableModels: chain.entries.length, - headProvider: head === undefined ? null : head.providerName, - }); - } - return c.json({ data }, 200); - }); - - app.post("/chain", async (c) => { - const scope = c.get("workflowCatalogScope"); - const raw = await c.req.json().catch(() => undefined); - const body = ChainBody(raw); - if (body instanceof type.errors) { - return c.json( - makeErrorEnvelope({ - code: "bad_request", - userMessage: `invalid body: ${body.summary}`, - }), - 400, - ); - } - const need = needFrom(body); - if (need instanceof Error) { - return c.json( - makeErrorEnvelope({ - code: "bad_request", - userMessage: need.message, - }), - 400, - ); - } - try { - const chain = await chainFor( - scope.tenantId, - need, - body.order, - body.limit, - ); - return c.json(chainResponse(chain), 200); - } catch (err) { - if (err instanceof UnknownConceptError) { - return c.json( - makeErrorEnvelope({ - code: "bad_request", - userMessage: err.message, - }), - 400, - ); - } - throw err; - } - }); - - app.post("/estimate", async (c) => { - const scope = c.get("workflowCatalogScope"); - const raw = await c.req.json().catch(() => undefined); - const body = EstimateBody(raw); - if (body instanceof type.errors) { - return c.json( - makeErrorEnvelope({ - code: "bad_request", - userMessage: `invalid body: ${body.summary}`, - }), - 400, - ); - } - const need = needFrom(body); - if (need instanceof Error) { - return c.json( - makeErrorEnvelope({ - code: "bad_request", - userMessage: need.message, - }), - 400, - ); - } - try { - const chain = await chainFor(scope.tenantId, need, "cheapest", 10); - return c.json( - { - concept: chain.concept, - estimates: chain.entries.map((entry) => ({ - canonicalName: entry.canonicalName, - providerName: entry.providerName, - known: entry.price.known, - estimatedUsd: estimateUsd( - entry.price.inputUsdPerMTok, - entry.price.outputUsdPerMTok, - body.expectedInputTokens, - body.expectedOutputTokens, - ), - })), - }, - 200, - ); - } catch (err) { - if (err instanceof UnknownConceptError) { - return c.json( - makeErrorEnvelope({ - code: "bad_request", - userMessage: err.message, - }), - 400, - ); - } - throw err; - } - }); - - return app; -} - -/** Null whenever either axis is unpriced: half an estimate is not an - * estimate, and a zero would read as free. */ -export function estimateUsd( - inputUsdPerMTok: number | null, - outputUsdPerMTok: number | null, - expectedInputTokens: number, - expectedOutputTokens: number, -): number | null { - if (inputUsdPerMTok === null || outputUsdPerMTok === null) return null; - return ( - (inputUsdPerMTok * expectedInputTokens) / 1_000_000 + - (outputUsdPerMTok * expectedOutputTokens) / 1_000_000 - ); -} - -function chainNote(chain: ModelChain): string | null { - if (chain.entries.length === 0) return "nothing on this bench can do that"; - if (chain.entries.every((entry) => !entry.price.known)) { - return "nothing on this bench is priced yet, so cheapest-first is ordered by catalog priority"; - } - if (chain.entries.some((entry) => entry.overCeiling)) { - return "some of these cost more than this bench's ceiling for that kind of work"; - } - return null; -} - -function chainResponse(chain: ModelChain) { - return { - concept: chain.concept, - requiredCapabilities: chain.requiredCapabilities, - entries: chain.entries.map((entry) => ({ - canonicalName: entry.canonicalName, - displayName: entry.displayName, - providerName: entry.providerName, - plugin: entry.plugin, - offeringId: entry.offeringId, - capabilities: entry.capabilities, - price: entry.price, - referenceCostUsd: entry.referenceCostUsd, - overCeiling: entry.overCeiling, - })), - note: chainNote(chain), - }; -} - -export { EMPTY_POLICY }; diff --git a/packages/inference-catalog/test/fixtures.ts b/packages/inference-catalog/test/fixtures.ts index c41c233d46..dda23d9552 100644 --- a/packages/inference-catalog/test/fixtures.ts +++ b/packages/inference-catalog/test/fixtures.ts @@ -1,64 +1,29 @@ // Plain catalog-row literals so the resolution suites exercise the real -// algorithm with no database anywhere near them. -import type { ModelPricingRow, ResolvedOffering } from "@intx/db"; +// algorithm with no database anywhere near them — the platform's model +// discovery response, already flattened, as `catalog.ts` produces it. import type { Capability } from "@intx/types"; -const EPOCH = new Date("2026-01-01T00:00:00.000Z"); +import type { CatalogOffering, CatalogPricingRow } from "../src/catalog"; export type OfferingFixture = { id: string; canonicalName: string; displayName?: string | null; providerName: string; - plugin?: "anthropic" | "openai" | "openai-compatible" | "google-genai"; + plugin?: string; capabilities: Capability[]; priority?: number; - connected?: boolean; - inherited?: boolean; }; -export function offering(fixture: OfferingFixture): ResolvedOffering { - const tenantId = "bench-1"; +export function offering(fixture: OfferingFixture): CatalogOffering { return { - offering: { - id: fixture.id, - tenantId, - modelId: `model-${fixture.canonicalName}`, - providerId: `provider-${fixture.providerName}`, - priority: fixture.priority ?? 0, - deploymentTags: [], - capabilities: fixture.capabilities, - quirks: null, - disabled: false, - createdAt: EPOCH, - updatedAt: EPOCH, - }, - model: { - id: `model-${fixture.canonicalName}`, - tenantId, - canonicalName: fixture.canonicalName, - displayName: fixture.displayName ?? fixture.canonicalName, - description: null, - disabled: false, - createdAt: EPOCH, - updatedAt: EPOCH, - }, - provider: { - id: `provider-${fixture.providerName}`, - tenantId, - name: fixture.providerName, - plugin: fixture.plugin ?? "openai-compatible", - baseURL: `https://${fixture.providerName}.example/v1`, - credentialId: (fixture.connected ?? true) ? "credential-1" : null, - walletId: null, - disabled: false, - createdAt: EPOCH, - updatedAt: EPOCH, - }, - origin: { - tenantId: fixture.inherited === true ? "parent-bench" : tenantId, - direct: fixture.inherited !== true, - }, + offeringId: fixture.id, + canonicalName: fixture.canonicalName, + displayName: fixture.displayName ?? null, + providerName: fixture.providerName, + plugin: fixture.plugin ?? "openai-compatible", + priority: fixture.priority ?? 0, + capabilities: fixture.capabilities, }; } @@ -68,27 +33,15 @@ export type PricingFixture = { inputUsdPerMTok: number | null; outputUsdPerMTok: number | null; currency?: string; - effectiveFrom?: Date; }; -export function pricing(fixture: PricingFixture): ModelPricingRow { +export function pricing(fixture: PricingFixture): CatalogPricingRow { const perToken = (perMTok: number | null): string | null => perMTok === null ? null : String(perMTok / 1_000_000); - const effectiveFrom = fixture.effectiveFrom ?? EPOCH; return { - id: `pricing-${fixture.offeringId}-${effectiveFrom.toISOString()}`, - tenantId: "bench-1", offeringId: fixture.offeringId, currency: fixture.currency ?? "USD", inputTokenPrice: perToken(fixture.inputUsdPerMTok), outputTokenPrice: perToken(fixture.outputUsdPerMTok), - cacheReadTokenPrice: null, - cacheWriteTokenPrice: null, - thinkingTokenPrice: null, - perRequestFee: null, - perImageFee: null, - perAudioFee: null, - effectiveFrom, - createdAt: effectiveFrom, }; } diff --git a/packages/inference-catalog/test/migrations.test.ts b/packages/inference-catalog/test/migrations.test.ts deleted file mode 100644 index dc091ff615..0000000000 --- a/packages/inference-catalog/test/migrations.test.ts +++ /dev/null @@ -1,114 +0,0 @@ -// DB-gated: skipped when no DATABASE_URL is reachable (a fresh checkout -// still runs the unit gates), mirroring @corbits/insights' migrations test. -// Runs against its own scratch database, never the developer's or the -// walking-skeleton suite's. -import { afterAll, beforeAll, expect, test } from "bun:test"; -import postgres from "postgres"; - -import { e2eDatabaseUrl } from "../../../scripts/e2e/database-url"; -import { applyInferenceCatalogMigrations } from "../src/migrations"; -import { createPostgresBenchModelPolicyStore } from "../src/pg-store"; -import { EMPTY_POLICY } from "../src/policy"; -import { dbGate } from "../../../scripts/e2e/db-gate"; - -function scratchUrlFor(e2eUrl: string): string { - const url = new URL(e2eUrl); - const database = url.pathname.replace(/^\//, ""); - url.pathname = `/${database}_inference_catalog_migrations_test`; - return url.toString(); -} - -const databaseUrl = e2eDatabaseUrl(); -const describeIfDb = dbGate(databaseUrl, import.meta.path); - -const migrationNames = ["0001_bench_model_policy"]; - -describeIfDb("applyInferenceCatalogMigrations", () => { - const scratchUrl = scratchUrlFor( - databaseUrl ?? "postgres://localhost:5432/unused", - ); - const scratchDatabase = new URL(scratchUrl).pathname.replace(/^\//, ""); - - async function withMaintenance( - run: (sql: ReturnType) => Promise, - ): Promise { - const maintenanceUrl = new URL(scratchUrl); - maintenanceUrl.pathname = "/postgres"; - const maintenance = postgres(maintenanceUrl.toString(), { - max: 1, - onnotice: () => undefined, - }); - try { - await run(maintenance); - } finally { - await maintenance.end(); - } - } - - beforeAll(async () => { - await withMaintenance(async (sql) => { - await sql.unsafe(`DROP DATABASE IF EXISTS "${scratchDatabase}"`); - await sql.unsafe(`CREATE DATABASE "${scratchDatabase}"`); - }); - }, 20000); - - afterAll(async () => { - await withMaintenance(async (sql) => { - await sql.unsafe(`DROP DATABASE IF EXISTS "${scratchDatabase}"`); - }); - }, 20000); - - test("creates the policy table in its own schema and is idempotent", async () => { - const first = await applyInferenceCatalogMigrations(scratchUrl); - expect(first.applied).toEqual(migrationNames); - - const second = await applyInferenceCatalogMigrations(scratchUrl); - expect(second.applied).toEqual([]); - expect(second.alreadyApplied).toEqual(migrationNames); - - const sql = postgres(scratchUrl, { max: 1, onnotice: () => undefined }); - try { - const tables = await sql.unsafe( - `SELECT table_name FROM information_schema.tables ` + - `WHERE table_schema = 'inference_catalog'`, - ); - expect(tables.map((row) => String(row["table_name"])).sort()).toEqual([ - "bench_model_policy", - "inference_catalog_migrations", - ]); - - const inPublic = await sql.unsafe( - `SELECT table_name FROM information_schema.tables ` + - `WHERE table_schema = 'public' AND table_name = 'bench_model_policy'`, - ); - expect(inPublic).toHaveLength(0); - } finally { - await sql.end(); - } - }, 20000); - - test("a bench with no row reads as unconstrained, and a patch round-trips", async () => { - await applyInferenceCatalogMigrations(scratchUrl); - const { store, close } = createPostgresBenchModelPolicyStore(scratchUrl); - try { - expect(await store.getPolicy("bench-never-set")).toEqual(EMPTY_POLICY); - - await store.patchPolicy("bench-1", { - deny: ["provider:pricey"], - maxInputUsdPerMTok: 2.5, - conceptCeilings: { "cheap-loop": { maxInputUsdPerMTok: 0.5 } }, - providerPreference: { mode: "prefer", order: ["anthropic"] }, - }); - const patched = await store.patchPolicy("bench-1", { - ceilingIsHard: true, - }); - - expect(patched.deny).toEqual(["provider:pricey"]); - expect(patched.maxInputUsdPerMTok).toBe(2.5); - expect(patched.ceilingIsHard).toBe(true); - expect(await store.getPolicy("bench-1")).toEqual(patched); - } finally { - await close(); - } - }, 20000); -}); diff --git a/packages/inference-catalog/test/workflow-catalog-routes.test.ts b/packages/inference-catalog/test/workflow-catalog-routes.test.ts deleted file mode 100644 index 31942bf1a7..0000000000 --- a/packages/inference-catalog/test/workflow-catalog-routes.test.ts +++ /dev/null @@ -1,158 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import { createWorkflowCatalogRoutes } from "../src/workflow-catalog-routes"; -import { EMPTY_POLICY, type BenchModelPolicy } from "../src/policy"; -import { offering, pricing } from "./fixtures"; - -const ASOF = new Date("2026-06-01T00:00:00.000Z"); -const TOKEN = "sidecar-token"; -const ADDRESS = "run-address"; - -const OFFERINGS = [ - offering({ - id: "cheap", - canonicalName: "thrifty", - displayName: "Thrifty", - providerName: "globex", - capabilities: ["plain-text", "structured-output"], - }), - offering({ - id: "dear", - canonicalName: "lavish", - displayName: "Lavish", - providerName: "acme", - capabilities: ["plain-text", "structured-output"], - }), -]; - -const PRICING = [ - pricing({ offeringId: "cheap", inputUsdPerMTok: 0.1, outputUsdPerMTok: 0.4 }), - pricing({ offeringId: "dear", inputUsdPerMTok: 4, outputUsdPerMTok: 20 }), -]; - -function routes(policy: BenchModelPolicy = EMPTY_POLICY) { - return createWorkflowCatalogRoutes({ - authenticator: { - resolve: async (token, address) => - token === TOKEN && address === ADDRESS - ? { tenantId: "bench-1", principalId: "p1", runId: "r1" } - : null, - }, - listOfferings: async () => OFFERINGS, - listPricing: async () => PRICING, - getPolicy: async () => policy, - now: () => ASOF, - }); -} - -function post(path: string, body: unknown): Request { - return new Request(`http://hub${path}`, { - method: "POST", - headers: { - authorization: `Bearer ${TOKEN}`, - "x-workflow-run-address": ADDRESS, - "content-type": "application/json", - }, - body: JSON.stringify(body), - }); -} - -describe("workflow inference-catalog routes", () => { - test("a request without a recognized run is refused", async () => { - const response = await routes().request("http://hub/concepts", { - headers: { authorization: "Bearer wrong" }, - }); - expect(response.status).toBe(401); - }); - - test("GET /concepts says how many models this bench has for each", async () => { - const response = await routes().request("http://hub/concepts", { - headers: { - authorization: `Bearer ${TOKEN}`, - "x-workflow-run-address": ADDRESS, - }, - }); - expect(response.status).toBe(200); - const body = (await response.json()) as { - data: { - id: string; - whenToUse: string; - availableModels: number; - headProvider: string | null; - }[]; - }; - const cheapLoop = body.data.find((entry) => entry.id === "cheap-loop"); - expect(cheapLoop?.availableModels).toBe(2); - expect(cheapLoop?.headProvider).toBe("globex"); - const imageMaker = body.data.find((entry) => entry.id === "image-maker"); - expect(imageMaker?.availableModels).toBe(0); - expect(imageMaker?.headProvider).toBeNull(); - }); - - test("POST /chain answers cheapest first, with the fallback behind it", async () => { - const response = await routes().request( - post("/chain", { concept: "cheap-loop" }), - ); - expect(response.status).toBe(200); - const body = (await response.json()) as { - concept: string; - entries: { canonicalName: string; overCeiling: boolean }[]; - note: string | null; - }; - expect(body.concept).toBe("cheap-loop"); - expect(body.entries.map((entry) => entry.canonicalName)).toEqual([ - "thrifty", - "lavish", - ]); - expect(body.entries[1]?.overCeiling).toBe(true); - expect(body.note).toBe( - "some of these cost more than this bench's ceiling for that kind of work", - ); - }); - - test("naming both a concept and capabilities is refused, and says so", async () => { - const response = await routes().request( - post("/chain", { concept: "cheap-loop", capabilities: ["plain-text"] }), - ); - expect(response.status).toBe(400); - const body = (await response.json()) as { error: { userMessage: string } }; - expect(body.error.userMessage).toContain("exactly one"); - }); - - test("naming neither is refused too", async () => { - const response = await routes().request(post("/chain", {})); - expect(response.status).toBe(400); - }); - - test("a capability outside the platform vocabulary is refused at the boundary", async () => { - const response = await routes().request( - post("/chain", { capabilities: ["telepathy"] }), - ); - expect(response.status).toBe(400); - }); - - test("an unknown concept comes back naming the real ones", async () => { - const response = await routes().request( - post("/chain", { concept: "vibes-based" }), - ); - expect(response.status).toBe(400); - const body = (await response.json()) as { error: { userMessage: string } }; - expect(body.error.userMessage).toContain("cheap-loop"); - }); - - test("POST /estimate prices the work before it is spent", async () => { - const response = await routes().request( - post("/estimate", { - concept: "cheap-loop", - expectedInputTokens: 1_000_000, - expectedOutputTokens: 100_000, - }), - ); - expect(response.status).toBe(200); - const body = (await response.json()) as { - estimates: { canonicalName: string; estimatedUsd: number | null }[]; - }; - expect(body.estimates[0]?.canonicalName).toBe("thrifty"); - expect(body.estimates[0]?.estimatedUsd).toBeCloseTo(0.14, 6); - }); -}); diff --git a/packages/inference-catalog/tsconfig.json b/packages/inference-catalog/tsconfig.json index 8ea901671b..a8c2b9966d 100644 --- a/packages/inference-catalog/tsconfig.json +++ b/packages/inference-catalog/tsconfig.json @@ -11,25 +11,5 @@ "rootDir": "../.." }, "include": ["src", "test"], - "exclude": [], - "references": [ - { - "path": "../../vendor/intx/db/tsconfig.src.json" - }, - { - "path": "../../vendor/intx/hub-api/tsconfig.src.json" - }, - { - "path": "../../vendor/intx/types/tsconfig.src.json" - }, - { - "path": "../error-sink/tsconfig.src.json" - }, - { - "path": "../migration-runner/tsconfig.src.json" - }, - { - "path": "../ollama-adapter/tsconfig.src.json" - } - ] + "exclude": [] } diff --git a/packages/inference-catalog/tsconfig.src.json b/packages/inference-catalog/tsconfig.src.json index 6a425e76a3..fc8724305f 100644 --- a/packages/inference-catalog/tsconfig.src.json +++ b/packages/inference-catalog/tsconfig.src.json @@ -8,25 +8,5 @@ "emitDeclarationOnly": true, "outDir": "dist", "tsBuildInfoFile": "dist/tsconfig.tsbuildinfo" - }, - "references": [ - { - "path": "../../vendor/intx/db/tsconfig.src.json" - }, - { - "path": "../../vendor/intx/hub-api/tsconfig.src.json" - }, - { - "path": "../../vendor/intx/types/tsconfig.src.json" - }, - { - "path": "../error-sink/tsconfig.src.json" - }, - { - "path": "../migration-runner/tsconfig.src.json" - }, - { - "path": "../ollama-adapter/tsconfig.src.json" - } - ] + } } diff --git a/packages/ollama-adapter/package.json b/packages/ollama-adapter/package.json index 572f16bc74..a9cb5f86c2 100644 --- a/packages/ollama-adapter/package.json +++ b/packages/ollama-adapter/package.json @@ -1,23 +1,37 @@ { "name": "@corbits/ollama-adapter", - "private": true, "description": "Custom Interchange inference adapter for the Ollama openai-compatible provider: operator-configured context-window (num_ctx), max-output-token, and reasoning-effort overrides, general or per model, wrapping the built-in OpenAI adapter's request/response handling rather than reimplementing it.", - "version": "0.0.1", + "version": "0.0.2", "license": "LGPL-2.1-or-later", "type": "module", "exports": { ".": "./src/index.ts", "./overrides": "./src/overrides.ts" }, + "files": [ + "src", + "LICENSE", + "README.md" + ], + "repository": { + "type": "git", + "url": "https://github.com/corbitsdev/workbench.git", + "directory": "packages/ollama-adapter" + }, + "publishConfig": { + "access": "public" + }, "scripts": { "typecheck": "tsc --noEmit", "test": "bun test" }, "dependencies": { - "@intx/inference": "workspace:*", - "@intx/types": "workspace:*", "arktype": "catalog:" }, + "peerDependencies": { + "@intx/inference": "0.3.0", + "@intx/types": "0.3.0" + }, "devDependencies": { "@types/bun": "catalog:", "typescript": "catalog:" diff --git a/packages/ollama-adapter/tsconfig.json b/packages/ollama-adapter/tsconfig.json index bee7b64f31..e125e90614 100644 --- a/packages/ollama-adapter/tsconfig.json +++ b/packages/ollama-adapter/tsconfig.json @@ -11,13 +11,5 @@ "rootDir": "../.." }, "include": ["src"], - "exclude": [], - "references": [ - { - "path": "../../vendor/intx/inference/tsconfig.src.json" - }, - { - "path": "../../vendor/intx/types/tsconfig.src.json" - } - ] + "exclude": [] } diff --git a/packages/ollama-adapter/tsconfig.src.json b/packages/ollama-adapter/tsconfig.src.json index 23572def72..fc8724305f 100644 --- a/packages/ollama-adapter/tsconfig.src.json +++ b/packages/ollama-adapter/tsconfig.src.json @@ -8,13 +8,5 @@ "emitDeclarationOnly": true, "outDir": "dist", "tsBuildInfoFile": "dist/tsconfig.tsbuildinfo" - }, - "references": [ - { - "path": "../../vendor/intx/inference/tsconfig.src.json" - }, - { - "path": "../../vendor/intx/types/tsconfig.src.json" - } - ] + } } From 9e53e45ab30b8f841e5c40438b41558b5ad12de3 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Thu, 17 Sep 2026 11:11:01 -0700 Subject: [PATCH 2/5] feat(catalog-tools)!: resolve chains against stock routes, in-process MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit list_model_concepts, pick_models and estimate_run_cost no longer call a Workbench-only hub route. They read the run's bench catalog and model policy directly off the stock GET /api/tenants/:tenantId/models and GET /api/tenants/:tenantId (client.ts), and resolve the chain themselves (chain.ts) with @corbits/inference-catalog's pure resolver — the hub does no chain computation for this bundle at all. --- packages/catalog-tools/package.json | 3 +- packages/catalog-tools/src/chain.ts | 67 +++++++++ packages/catalog-tools/src/client.test.ts | 99 ++++++++----- packages/catalog-tools/src/client.ts | 172 +++++++--------------- packages/catalog-tools/src/index.ts | 25 ++-- packages/catalog-tools/src/tool.test.ts | 156 ++++++++++---------- packages/catalog-tools/src/tool.ts | 88 +++++------ 7 files changed, 318 insertions(+), 292 deletions(-) create mode 100644 packages/catalog-tools/src/chain.ts diff --git a/packages/catalog-tools/package.json b/packages/catalog-tools/package.json index 2f99da5620..a98c61c0c3 100644 --- a/packages/catalog-tools/package.json +++ b/packages/catalog-tools/package.json @@ -1,7 +1,7 @@ { "name": "@corbits/catalog-tools", "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.6", + "version": "0.1.0", "license": "LGPL-2.1-or-later", "type": "module", "exports": { @@ -25,6 +25,7 @@ "test": "bun test" }, "dependencies": { + "@corbits/inference-catalog": "0.1.0", "arktype": "catalog:" }, "peerDependencies": { diff --git a/packages/catalog-tools/src/chain.ts b/packages/catalog-tools/src/chain.ts new file mode 100644 index 0000000000..f5a6d49458 --- /dev/null +++ b/packages/catalog-tools/src/chain.ts @@ -0,0 +1,67 @@ +// Chain resolution, in the workflow child. +// +// The two stock reads happen once per tool call and are then used for every +// question that call asks — `list_model_concepts` ranks every concept off +// one catalog read rather than one read per concept, which is what the +// deleted hub route did per request. +import { + catalogOfferingsFrom, + resolveModelChain, + type BenchModelPolicy, + type ChainNeed, + type ChainOrder, + type DiscoveredModel, + type ModelChain, +} from "@corbits/inference-catalog"; + +import { + fetchCatalog, + fetchModelPolicy, + type CatalogToolClientConfig, +} from "./client"; + +/** What this bench can reach and what it is willing to spend, read once. */ +export type BenchCatalog = { + readonly models: readonly DiscoveredModel[]; + readonly policy: BenchModelPolicy; +}; + +export async function readBenchCatalog( + config: CatalogToolClientConfig, +): Promise { + const [models, policy] = await Promise.all([ + fetchCatalog(config), + fetchModelPolicy(config), + ]); + return { models, policy }; +} + +export function chainFor( + bench: BenchCatalog, + need: ChainNeed, + order?: ChainOrder, + limit?: number, +): ModelChain { + const { offerings, pricing } = catalogOfferingsFrom(bench.models); + return resolveModelChain({ + need, + offerings, + pricing, + policy: bench.policy, + ...(order !== undefined ? { order } : {}), + ...(limit !== undefined ? { limit } : {}), + }); +} + +/** The one line that explains an otherwise puzzling chain: empty, entirely + * unpriced, or partly over this bench's ceiling. */ +export function chainNote(chain: ModelChain): string | null { + if (chain.entries.length === 0) return "nothing on this bench can do that"; + if (chain.entries.every((entry) => !entry.price.known)) { + return "nothing on this bench is priced yet, so cheapest-first is ordered by catalog priority"; + } + if (chain.entries.some((entry) => entry.overCeiling)) { + return "some of these cost more than this bench's ceiling for that kind of work"; + } + return null; +} diff --git a/packages/catalog-tools/src/client.test.ts b/packages/catalog-tools/src/client.test.ts index 9fe23814e7..3e7b9ea30c 100644 --- a/packages/catalog-tools/src/client.test.ts +++ b/packages/catalog-tools/src/client.test.ts @@ -1,38 +1,40 @@ import { describe, expect, test } from "bun:test"; import { - fetchChain, - listConcepts, + fetchCatalog, + fetchModelPolicy, type CatalogToolClientConfig, } from "./client"; -const CHAIN_BODY = { - concept: "cheap-loop", - requiredCapabilities: ["plain-text"], - entries: [ - { - canonicalName: "thrifty", - displayName: "Thrifty", - providerName: "globex", - plugin: "openai-compatible", - offeringId: "off_1", - capabilities: ["plain-text"], - price: { - currency: "USD", - known: true, - inputUsdPerMTok: 0.1, - outputUsdPerMTok: 0.4, +const MODELS_BODY = [ + { + id: "m1", + canonicalName: "thrifty", + displayName: "Thrifty", + offerings: [ + { + offeringId: "off_1", + providerName: "globex", + plugin: "openai-compatible", + priority: 0, + capabilities: ["plain-text"], + pricing: [ + { + offeringId: "off_1", + currency: "USD", + inputTokenPrice: "0.0000001", + outputTokenPrice: "0.0000004", + }, + ], }, - referenceCostUsd: 0.12, - overCeiling: false, - }, - ], - note: null, -}; + ], + }, +]; function config(fetchImpl: typeof fetch): CatalogToolClientConfig { return { hubCatalogUrl: "https://hub.example.com", + tenantId: "bench_1", sidecarToken: "sc-token", address: "run_1@workflow", fetchImpl, @@ -44,13 +46,13 @@ describe("catalog tool client", () => { let seen: Request | undefined; const fetchImpl = (async (input: string, init?: RequestInit) => { seen = new Request(input, init); - return new Response(JSON.stringify(CHAIN_BODY), { status: 200 }); + return new Response(JSON.stringify(MODELS_BODY), { status: 200 }); }) as unknown as typeof fetch; - await fetchChain(config(fetchImpl), { concept: "cheap-loop" }); + await fetchCatalog(config(fetchImpl)); expect(seen?.url).toBe( - "https://hub.example.com/api/workflow-inference-catalog/chain", + "https://hub.example.com/api/tenants/bench_1/models", ); expect(seen?.headers.get("authorization")).toBe("Bearer sc-token"); expect(seen?.headers.get("x-workflow-run-address")).toBe("run_1@workflow"); @@ -62,27 +64,27 @@ describe("catalog tool client", () => { JSON.stringify({ error: { code: "bad_request", - userMessage: '"jazz" is not a kind of work', + userMessage: "the bench is not reachable", refId: "ref_test", }, }), { status: 400 }, )) as unknown as typeof fetch; - await expect( - fetchChain(config(fetchImpl), { concept: "jazz" }), - ).rejects.toThrow("is not a kind of work"); + await expect(fetchCatalog(config(fetchImpl))).rejects.toThrow( + "the bench is not reachable", + ); }); test("a body in an unexpected shape is an error, not a half-parsed answer", async () => { const fetchImpl = (async () => - new Response(JSON.stringify({ entries: "lots" }), { + new Response(JSON.stringify({ not: "a model list" }), { status: 200, })) as unknown as typeof fetch; - await expect( - fetchChain(config(fetchImpl), { concept: "cheap-loop" }), - ).rejects.toThrow("unexpected shape"); + await expect(fetchCatalog(config(fetchImpl))).rejects.toThrow( + "unexpected shape", + ); }); test("an unreachable hub is an error, never an empty list", async () => { @@ -90,8 +92,33 @@ describe("catalog tool client", () => { throw new Error("connection refused"); }) as unknown as typeof fetch; - await expect(listConcepts(config(fetchImpl))).rejects.toThrow( + await expect(fetchCatalog(config(fetchImpl))).rejects.toThrow( "connection refused", ); }); + + test("reads the model policy out of the tenant's own config blob", async () => { + const fetchImpl = (async () => + new Response( + JSON.stringify({ + id: "bench_1", + config: { corbits: { modelPolicy: { deny: ["acme/alpha"] } } }, + }), + { status: 200 }, + )) as unknown as typeof fetch; + + const policy = await fetchModelPolicy(config(fetchImpl)); + expect(policy.deny).toEqual(["acme/alpha"]); + }); + + test("a bench with no config blob reads as the empty policy", async () => { + const fetchImpl = (async () => + new Response(JSON.stringify({ id: "bench_1" }), { + status: 200, + })) as unknown as typeof fetch; + + const policy = await fetchModelPolicy(config(fetchImpl)); + expect(policy.allow).toEqual([]); + expect(policy.deny).toEqual([]); + }); }); diff --git a/packages/catalog-tools/src/client.ts b/packages/catalog-tools/src/client.ts index a655bf9339..292094adcc 100644 --- a/packages/catalog-tools/src/client.ts +++ b/packages/catalog-tools/src/client.ts @@ -1,127 +1,76 @@ -// A minimal client for the workflow-run-authenticated inference-catalog -// surface a running agent calls to ask what this bench can reach for a kind -// of work — the execution half of `@corbits/inference-catalog`'s -// `createWorkflowCatalogRoutes`, mounted in `apps/hub` at -// `/api/workflow-inference-catalog` beside `/api/workflow-connections`. -// Authenticated the same way: sidecar bearer token plus run address, never a -// human browser session. +// What this bundle reads, and from where. +// +// Both reads are STOCK Interchange tenant routes, addressed with the +// workflow run's own bearer credential (sidecar token plus run address), +// which the hub resolves to this run's principal and tenant: +// +// GET /api/tenants/:tenantId/models — the bench's resolved model +// catalog. The ancestor walk, shadowing, disable suppression and +// each offering's active price per currency are all the platform's +// own work; this package restates none of it. +// GET /api/tenants/:tenantId — the bench itself, for the model +// policy in its `config` blob under `corbits.modelPolicy`. +// +// There is no `/api/workflow-inference-catalog` mirror any more, and no +// hub-side chain resolution: the chain is computed here, in the workflow +// child, from those two reads. import { type } from "arktype"; -import { Capability } from "@intx/types"; +import { DiscoveredModel } from "@corbits/inference-catalog/catalog"; +import { + readModelPolicy, + type BenchModelPolicy, +} from "@corbits/inference-catalog/policy"; export interface CatalogToolClientConfig { /** The hub's plain HTTP origin — the same value connections-tools' * `hubConnectionsUrl` and memory-tools' `hubMemoryUrl` reach the hub * through. */ readonly hubCatalogUrl: string; + /** The run's own tenant — the `:tenantId` segment of a stock route. */ + readonly tenantId: string; readonly sidecarToken: string; readonly address: string; /** Override for tests; defaults to the global `fetch`. */ readonly fetchImpl?: typeof fetch; } -const ConceptsResponse = type({ - data: type({ - id: "string", - title: "string", - whenToUse: "string", - availableModels: "number", - headProvider: "string | null", - }).array(), -}); -export type ConceptSummary = (typeof ConceptsResponse.infer)["data"][number]; +const DiscoveredModels = DiscoveredModel.array(); -const ChainResponse = type({ - concept: "string | null", - requiredCapabilities: Capability.array(), - entries: type({ - canonicalName: "string", - displayName: "string | null", - providerName: "string", - plugin: "string", - offeringId: "string", - capabilities: Capability.array(), - price: { - currency: "string", - known: "boolean", - inputUsdPerMTok: "number | null", - outputUsdPerMTok: "number | null", - }, - referenceCostUsd: "number | null", - overCeiling: "boolean", - }).array(), - note: "string | null", +const TenantResponse = type({ + id: "string", + "config?": type({ "[string]": "unknown" }).or("null"), }); -export type ModelChainResult = typeof ChainResponse.infer; -export type ChainEntry = ModelChainResult["entries"][number]; - -const EstimateResponse = type({ - concept: "string | null", - estimates: type({ - canonicalName: "string", - providerName: "string", - known: "boolean", - estimatedUsd: "number | null", - }).array(), -}); -export type EstimateResult = typeof EstimateResponse.infer; - -export type ChainRequest = { - readonly concept?: string | undefined; - readonly capabilities?: readonly Capability[] | undefined; - readonly order?: "cheapest" | "catalog" | undefined; - readonly limit?: number | undefined; -}; - -export type EstimateRequest = { - readonly concept?: string | undefined; - readonly capabilities?: readonly Capability[] | undefined; - readonly expectedInputTokens: number; - readonly expectedOutputTokens: number; -}; function authHeaders(config: CatalogToolClientConfig): Record { return { authorization: `Bearer ${config.sidecarToken}`, "x-workflow-run-address": config.address, - "content-type": "application/json", }; } -/** Pulls `error.userMessage` out of the canonical hub envelope - * (`{error: {code, userMessage, refId}}`), if `body` matches that shape. */ +/** Pulls `error.userMessage` or `error.message` out of the hub's error + * envelope, if `body` matches that shape. */ function errorMessageFrom(body: unknown): string | undefined { if (body === null || typeof body !== "object" || !("error" in body)) { return undefined; } const error = (body as { error: unknown }).error; - if ( - error === null || - typeof error !== "object" || - !("userMessage" in error) - ) { - return undefined; - } - const userMessage = (error as { userMessage: unknown }).userMessage; - return typeof userMessage === "string" ? userMessage : undefined; + if (error === null || typeof error !== "object") return undefined; + const fields = error as { userMessage?: unknown; message?: unknown }; + if (typeof fields.userMessage === "string") return fields.userMessage; + return typeof fields.message === "string" ? fields.message : undefined; } -async function call( +async function read( config: CatalogToolClientConfig, path: string, what: string, schema: (body: unknown) => T | type.errors, - body?: unknown, ): Promise { const doFetch = config.fetchImpl ?? fetch; const response = await doFetch( - `${config.hubCatalogUrl}/api/workflow-inference-catalog${path}`, - body === undefined - ? { headers: authHeaders(config) } - : { - method: "POST", - headers: authHeaders(config), - body: JSON.stringify(body), - }, + `${config.hubCatalogUrl}/api/tenants/${encodeURIComponent(config.tenantId)}${path}`, + { headers: authHeaders(config) }, ); if (!response.ok) { const detail: unknown = await response.json().catch(() => undefined); @@ -138,46 +87,25 @@ async function call( return parsed; } -/** Every kind of work this bench knows, with how many models it currently - * has for each. Throws on any transport, HTTP, or shape failure — never - * fabricates a result. */ -export async function listConcepts( - config: CatalogToolClientConfig, -): Promise { - const body = await call(config, "/concepts", "Listing kinds of work", (raw) => - ConceptsResponse(raw), - ); - return body.data; -} - -export async function fetchChain( +/** This bench's resolved model catalog. Throws on any transport, HTTP, or + * shape failure — never fabricates a catalog. */ +export async function fetchCatalog( config: CatalogToolClientConfig, - request: ChainRequest, -): Promise { - return await call( - config, - "/chain", - "Picking models", - (raw) => ChainResponse(raw), - request, +): Promise { + return await read(config, "/models", "Reading this bench's models", (raw) => + DiscoveredModels(raw), ); } -export async function fetchEstimate( +/** This bench's model policy, out of its own tenant config blob. */ +export async function fetchModelPolicy( config: CatalogToolClientConfig, - request: EstimateRequest, -): Promise { - return await call( +): Promise { + const tenant = await read( config, - "/estimate", - "Estimating run cost", - (raw) => EstimateResponse(raw), - request, + "", + "Reading this bench's settings", + (raw) => TenantResponse(raw), ); + return readModelPolicy(tenant.config ?? {}); } - -// --- Catalog administration ------------------------------------------- -// -// UI-only (CL-7588): catalog writes go through the tenant-admin catalog -// routes behind a browser session, never through a workflow child, so -// this client exposes no write surface — the reads above are the whole API. diff --git a/packages/catalog-tools/src/index.ts b/packages/catalog-tools/src/index.ts index 2c9ef42d44..0fba547f84 100644 --- a/packages/catalog-tools/src/index.ts +++ b/packages/catalog-tools/src/index.ts @@ -1,12 +1,13 @@ export { - fetchChain, - fetchEstimate, - listConcepts, + chainFor, + chainNote, + readBenchCatalog, + type BenchCatalog, +} from "./chain"; +export { + fetchCatalog, + fetchModelPolicy, type CatalogToolClientConfig, - type ChainEntry, - type ConceptSummary, - type EstimateResult, - type ModelChainResult, } from "./client"; export { catalogTools, @@ -16,7 +17,9 @@ export { PICK_MODELS_TOOL, type WorkflowCatalogEnv, } from "./tool"; -// The read-only tools call the workflow-run-authenticated catalog surface -// `@corbits/inference-catalog` mounts in `apps/hub` at -// `/api/workflow-inference-catalog`. Catalog administration is UI-only -// (CL-7588): this package exposes no write tools and no hub routes. +// The three read-only tools resolve the chain in the workflow child from +// two stock Interchange tenant reads — `/api/tenants/:tenantId/models` and +// `/api/tenants/:tenantId` — with the run's own bearer credential. There is +// no `/api/workflow-inference-catalog` mount any more. Catalog +// administration stays UI-only (CL-7588): this package exposes no write +// tools and no hub routes. diff --git a/packages/catalog-tools/src/tool.test.ts b/packages/catalog-tools/src/tool.test.ts index c2be0a8e14..a874e0ca62 100644 --- a/packages/catalog-tools/src/tool.test.ts +++ b/packages/catalog-tools/src/tool.test.ts @@ -9,52 +9,70 @@ import { type WorkflowCatalogEnv, } from "./tool"; -const CHAIN_BODY = { - concept: "cheap-loop", - requiredCapabilities: ["plain-text"], - entries: [ - { - canonicalName: "thrifty", - displayName: "Thrifty", - providerName: "globex", - plugin: "openai-compatible", - offeringId: "off_1", - capabilities: ["plain-text"], - price: { - currency: "USD", - known: true, - inputUsdPerMTok: 0.1, - outputUsdPerMTok: 0.4, +const CHEAP_LOOP_MODELS = [ + { + id: "m1", + canonicalName: "thrifty", + displayName: "Thrifty", + offerings: [ + { + offeringId: "off_1", + providerName: "globex", + plugin: "openai-compatible", + priority: 0, + capabilities: ["plain-text"], + pricing: [ + { + offeringId: "off_1", + currency: "USD", + inputTokenPrice: "0.0000001", + outputTokenPrice: "0.0000004", + }, + ], + }, + ], + }, + { + id: "m2", + canonicalName: "mystery", + displayName: null, + offerings: [ + { + offeringId: "off_2", + providerName: "initech", + plugin: "openai-compatible", + priority: 1, + capabilities: ["plain-text"], + pricing: [], }, - referenceCostUsd: 0.12, - overCeiling: false, - }, - { - canonicalName: "mystery", - displayName: null, - providerName: "initech", - plugin: "openai-compatible", - offeringId: "off_2", - capabilities: ["plain-text"], - price: { - currency: "USD", - known: false, - inputUsdPerMTok: null, - outputUsdPerMTok: null, + ], + }, +]; + +const IMAGE_MAKER_MODELS = [ + { + id: "m1", + canonicalName: "thrifty", + displayName: "Thrifty", + offerings: [ + { + offeringId: "off_1", + providerName: "globex", + plugin: "openai-compatible", + priority: 0, + capabilities: ["plain-text"], + pricing: [], }, - referenceCostUsd: null, - overCeiling: false, - }, - ], - note: null, -}; + ], + }, +]; -function env(fetchImpl?: typeof fetch): WorkflowCatalogEnv { +function env(): WorkflowCatalogEnv { return { hubCatalogUrl: "https://hub.example.com", + tenantId: "bench_1", sidecarToken: "sc-token", address: "run_1@workflow", - fetch: fetchImpl, } as unknown as WorkflowCatalogEnv; } @@ -62,11 +80,19 @@ function callFor(name: string, args: Record): ToolCall { return { id: "call_1", name, arguments: args }; } -function stubbing(body: unknown): void { - globalThis.fetch = (async () => - new Response(JSON.stringify(body), { - status: 200, - })) as unknown as typeof fetch; +/** Stubs both stock reads `readBenchCatalog` makes: the model list at + * `/models`, and the tenant itself (for its config's model policy). */ +function stubbing(models: unknown, config?: Record): void { + globalThis.fetch = (async (input: string) => { + const url = String(input); + if (url.endsWith("/models")) { + return new Response(JSON.stringify(models), { status: 200 }); + } + return new Response( + JSON.stringify({ id: "bench_1", config: config ?? null }), + { status: 200 }, + ); + }) as unknown as typeof fetch; } describe("pick_models", () => { @@ -99,7 +125,7 @@ describe("pick_models", () => { }); test("names every model in the chain, in order, and no model that is not", async () => { - stubbing(CHAIN_BODY); + stubbing(CHEAP_LOOP_MODELS); const result = await catalogTools(env()).run( callFor(PICK_MODELS_TOOL, { concept: "cheap-loop" }), new AbortController().signal, @@ -113,7 +139,7 @@ describe("pick_models", () => { }); test("an unpriced model is reported as unpriced, never as free", async () => { - stubbing(CHAIN_BODY); + stubbing(CHEAP_LOOP_MODELS); const result = await catalogTools(env()).run( callFor(PICK_MODELS_TOOL, { concept: "cheap-loop" }), new AbortController().signal, @@ -123,12 +149,7 @@ describe("pick_models", () => { }); test("an empty chain says nothing here can do it, without inventing a model", async () => { - stubbing({ - concept: "image-maker", - requiredCapabilities: ["image-output"], - entries: [], - note: "nothing on this bench can do that", - }); + stubbing(IMAGE_MAKER_MODELS); const result = await catalogTools(env()).run( callFor(PICK_MODELS_TOOL, { concept: "image-maker" }), new AbortController().signal, @@ -140,17 +161,7 @@ describe("pick_models", () => { describe("estimate_run_cost", () => { test("reports an honest no-estimate for an unpriced model", async () => { - stubbing({ - concept: "cheap-loop", - estimates: [ - { - canonicalName: "mystery", - providerName: "initech", - known: false, - estimatedUsd: null, - }, - ], - }); + stubbing(CHEAP_LOOP_MODELS); const result = await catalogTools(env()).run( callFor(ESTIMATE_RUN_COST_TOOL, { concept: "cheap-loop", @@ -173,24 +184,7 @@ describe("estimate_run_cost", () => { describe("list_model_concepts", () => { test("says how many models this bench has for each kind of work", async () => { - stubbing({ - data: [ - { - id: "cheap-loop", - title: "Cheap loop", - whenToUse: "A step that runs hundreds of times.", - availableModels: 2, - headProvider: "globex", - }, - { - id: "image-maker", - title: "Image maker", - whenToUse: "Produce a picture rather than words.", - availableModels: 0, - headProvider: null, - }, - ], - }); + stubbing(CHEAP_LOOP_MODELS); const result = await catalogTools(env()).run( callFor(LIST_MODEL_CONCEPTS_TOOL, {}), new AbortController().signal, diff --git a/packages/catalog-tools/src/tool.ts b/packages/catalog-tools/src/tool.ts index f5a0f01130..fa9772f67d 100644 --- a/packages/catalog-tools/src/tool.ts +++ b/packages/catalog-tools/src/tool.ts @@ -14,6 +14,10 @@ // // All three tools read only, so none declares an `approval` key — the same // call `@corbits/connections-tools` makes for `list_connections`. +// +// The chain is resolved here, in the workflow child, from two stock tenant +// reads (see `client.ts`). Nothing about a kind of work, a price, or a +// bench's policy is computed by a Workbench-specific hub route. import { defineTool } from "@intx/agent"; import type { BaseEnv } from "@intx/agent"; import type { ToolCall, ToolResult } from "@intx/types/runtime"; @@ -21,13 +25,15 @@ import { WIRE_CAPABILITIES, type Capability } from "@intx/types"; import { type } from "arktype"; import { - fetchChain, - fetchEstimate, - listConcepts, - type CatalogToolClientConfig, + CONCEPTS, + estimateUsd, type ChainEntry, - type ModelChainResult, -} from "./client"; + type ChainNeed, + type ModelChain, +} from "@corbits/inference-catalog"; + +import { chainFor, chainNote, readBenchCatalog } from "./chain"; +import type { CatalogToolClientConfig } from "./client"; export const LIST_MODEL_CONCEPTS_TOOL = "list_model_concepts"; export const PICK_MODELS_TOOL = "pick_models"; @@ -37,6 +43,8 @@ export const ESTIMATE_RUN_COST_TOOL = "estimate_run_cost"; * mirroring `@corbits/connections-tools`' `WorkflowConnectionEnv`. */ export interface WorkflowCatalogEnv extends BaseEnv { readonly hubCatalogUrl: string; + /** The run's own tenant — the `:tenantId` segment of a stock route. */ + readonly tenantId: string; readonly sidecarToken: string; readonly address: string; } @@ -92,17 +100,18 @@ function errorResult(callId: string, err: unknown): ToolResult { function clientConfig(env: WorkflowCatalogEnv): CatalogToolClientConfig { return { hubCatalogUrl: env.hubCatalogUrl, + tenantId: env.tenantId, sidecarToken: env.sidecarToken, address: env.address, }; } -/** 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[] }): { +/** The need, as exactly one of the two forms chain resolution accepts. The + * input schema has already rejected both-at-once and neither. */ +function needOf(input: { concept?: string; - capabilities?: readonly Capability[]; -} { + capabilities?: Capability[]; +}): ChainNeed { return input.concept !== undefined ? { concept: input.concept } : { capabilities: input.capabilities ?? [] }; @@ -121,11 +130,10 @@ function describeEntry(entry: ChainEntry, position: number): string { return `${position}. ${name} via ${entry.providerName} — ${price}${flag}`; } -export function describeChain(chain: ModelChainResult): string { +export function describeChain(chain: ModelChain): string { + const note = chainNote(chain); if (chain.entries.length === 0) { - return ( - chain.note ?? "Nothing on this bench can do that kind of work right now." - ); + return note ?? "Nothing on this bench can do that kind of work right now."; } const lines = chain.entries.map((entry, index) => describeEntry(entry, index + 1), @@ -134,7 +142,7 @@ export function describeChain(chain: ModelChainResult): string { chain.entries.length === 1 ? "One model here fits:" : "Use the first; the rest are fallbacks, in order:"; - return [head, ...lines, chain.note].filter(Boolean).join("\n"); + return [head, ...lines, note].filter(Boolean).join("\n"); } async function runListModelConcepts( @@ -142,12 +150,15 @@ async function runListModelConcepts( call: ToolCall, ): Promise { try { - const concepts = await listConcepts(clientConfig(env)); - const lines = concepts.map((concept) => { + const bench = await readBenchCatalog(clientConfig(env)); + const lines = CONCEPTS.map((concept) => { + const chain = chainFor(bench, { concept: concept.id }, "cheapest", 10); + const head = chain.entries[0]; + const available = chain.entries.length; const availability = - concept.availableModels === 0 + available === 0 ? "nothing here can do it yet" - : `${concept.availableModels} model${concept.availableModels === 1 ? "" : "s"} here, best via ${concept.headProvider ?? "unknown"}`; + : `${available} model${available === 1 ? "" : "s"} here, best via ${head?.providerName ?? "unknown"}`; return `${concept.id} — ${concept.whenToUse} (${availability})`; }); return { @@ -169,13 +180,8 @@ async function runPickModels( parsed: PickModelsInput, ): Promise { try { - const need = needOf(parsed); - const chain = await fetchChain(clientConfig(env), { - concept: need.concept, - capabilities: need.capabilities, - order: parsed.order, - limit: parsed.limit, - }); + const bench = await readBenchCatalog(clientConfig(env)); + const chain = chainFor(bench, needOf(parsed), parsed.order, parsed.limit); return { callId: call.id, isError: false, content: describeChain(chain) }; } catch (err) { return errorResult(call.id, err); @@ -188,25 +194,25 @@ async function runEstimateRunCost( parsed: EstimateRunCostInput, ): Promise { try { - const need = needOf(parsed); - const estimate = await fetchEstimate(clientConfig(env), { - concept: need.concept, - capabilities: need.capabilities, - expectedInputTokens: parsed.expectedInputTokens, - expectedOutputTokens: parsed.expectedOutputTokens, - }); - if (estimate.estimates.length === 0) { + const bench = await readBenchCatalog(clientConfig(env)); + const chain = chainFor(bench, needOf(parsed), "cheapest", 10); + if (chain.entries.length === 0) { return { callId: call.id, isError: false, content: "Nothing on this bench can do that kind of work right now.", }; } - const lines = estimate.estimates.map((row) => - row.estimatedUsd === null - ? `${row.canonicalName} via ${row.providerName} — no price on record, so no estimate` - : `${row.canonicalName} via ${row.providerName} — about ${usd(row.estimatedUsd)}`, - ); + const lines = chain.entries.map((entry) => { + const amount = estimateUsd( + entry.price, + parsed.expectedInputTokens, + parsed.expectedOutputTokens, + ); + return amount === null + ? `${entry.canonicalName} via ${entry.providerName} — no price on record, so no estimate` + : `${entry.canonicalName} via ${entry.providerName} — about ${usd(amount)}`; + }); return { callId: call.id, isError: false, @@ -223,7 +229,7 @@ async function runEstimateRunCost( */ export const catalogTools = defineTool({ id: "@corbits/catalog-tools/catalog", - requires: ["hubCatalogUrl", "sidecarToken", "address"], + requires: ["hubCatalogUrl", "tenantId", "sidecarToken", "address"], definitions: [ { name: LIST_MODEL_CONCEPTS_TOOL }, { name: PICK_MODELS_TOOL }, From c54841db46b6558cdd960052c89052bbd2bc6c93 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Thu, 17 Sep 2026 11:16:27 -0700 Subject: [PATCH 3/5] feat(hub)!: delete the workflow-inference-catalog mount Removes the dedicated /api/workflow-inference-catalog route, its migrations, and the bench_model_policy pg-store now that @corbits/catalog-tools resolves its own chains off stock tenant routes. Bumps the catalog-tools pin in the assistant workflow to match the package's new minor version. --- apps/hub/package.json | 1 - apps/hub/src/index.ts | 32 ------------------- .../workflow-substrate-factory/step-env.ts | 7 ++-- scripts/checks/no-product-tenancy.ts | 11 ------- scripts/db-setup.ts | 5 --- workflows/assistant/package.json | 2 +- workflows/assistant/src/prompt.ts | 2 +- 7 files changed, 6 insertions(+), 54 deletions(-) diff --git a/apps/hub/package.json b/apps/hub/package.json index e2dd429dfb..9cec8fa5df 100644 --- a/apps/hub/package.json +++ b/apps/hub/package.json @@ -36,7 +36,6 @@ "@corbits/granola-call-workflow": "workspace:*", "@corbits/hub-api-client": "workspace:*", "@corbits/inbox": "workspace:*", - "@corbits/inference-catalog": "workspace:*", "@corbits/insights": "workspace:*", "@corbits/last-30-days-research-workflow": "workspace:*", "@corbits/mailbox": "github:corbitsdev/corbits-mailbox#118a2cd3b0b2d83abcd57b4bed1698e95ee41b6d", diff --git a/apps/hub/src/index.ts b/apps/hub/src/index.ts index 9bfca585ca..8edda44ab9 100644 --- a/apps/hub/src/index.ts +++ b/apps/hub/src/index.ts @@ -20,7 +20,6 @@ import { } from "@intx/db"; import { asset as assetTable, - modelPricing, tenant as tenantTable, user as userTable, workflowDefinition, @@ -106,10 +105,6 @@ import { withTurnPartPersistGuard, } from "@corbits/insights"; import { - applyInferenceCatalogMigrations, - createPostgresBenchModelPolicyStore, - createWorkflowCatalogRoutes, -} from "@corbits/inference-catalog"; import { generateId } from "@intx/hub-common"; import { @@ -1706,13 +1701,6 @@ export async function createHub(config: HubConfig) { ), }), ); - // Bench model policy migrations still run: the store itself is read - // below (tenant-session bench-model-policy read), even though the - // dedicated read/write mount is gone. - await applyInferenceCatalogMigrations(config.databaseUrl); - const benchModelPolicy = createPostgresBenchModelPolicyStore( - config.databaseUrl, - ); { const mailboxApp = new Hono(); mountMailbox(mailboxApp, { @@ -2210,26 +2198,6 @@ export async function createHub(config: HubConfig) { listMcpServers: (tenantId) => listMcpServerConnections(db, tenantId), }), ); - // How a running agent asks what this bench can reach for a kind of work - // (`@corbits/catalog-tools`' `list_model_concepts` / `pick_models` / - // `estimate_run_cost`): the workflow-run-authenticated counterpart to - // the tenant-session bench-model-policy mount above. Read-only, and it - // takes ports rather than a db handle, so the package never learns the - // catalog schema. - app.route( - "/api/workflow-inference-catalog", - createWorkflowCatalogRoutes({ - authenticator: createWorkflowRunAuthenticator({ db }), - listOfferings: (tenantId) => listVisibleOfferings(db, tenantId), - listPricing: async (_tenantId, offeringIds) => - offeringIds.length === 0 - ? [] - : await db.query.modelPricing.findMany({ - where: inArray(modelPricing.offeringId, [...offeringIds]), - }), - getPolicy: (tenantId) => benchModelPolicy.store.getPolicy(tenantId), - }), - ); // Notify-to-reconnect for an OAuth-connected credential whose token // expired (Hugging Face today — see docs/onboarding-huggingface-connect.md) // is now `workflows/credential-expiry` (CL-8181), a schedule-triggered diff --git a/apps/sidecar/src/workflow-substrate-factory/step-env.ts b/apps/sidecar/src/workflow-substrate-factory/step-env.ts index a13387eef9..4b348f7a33 100644 --- a/apps/sidecar/src/workflow-substrate-factory/step-env.ts +++ b/apps/sidecar/src/workflow-substrate-factory/step-env.ts @@ -446,8 +446,9 @@ export function createSidecarStepBuildEnv( hubRoutinesUrl: deps.hubArtifactsUrl, hubConnectionsUrl: deps.hubArtifactsUrl, // And once more under the key `@corbits/catalog-tools` declares - // (`requires: ["hubCatalogUrl", "sidecarToken", "address"]`) for the - // inference catalog's own run-authenticated surface. + // (`requires: ["hubCatalogUrl", "tenantId", "sidecarToken", + // "address"]`) for the stock tenant model-discovery and tenant reads + // it resolves a model chain from. hubCatalogUrl: deps.hubArtifactsUrl, hubAgentDirectoryUrl: deps.hubArtifactsUrl, hubChatUrl: deps.hubArtifactsUrl, @@ -466,7 +467,7 @@ export function createSidecarStepBuildEnv( // The run's own tenant/principal, for the tool bundles that address // stock `/api/tenants/:tenantId/*` routes with the run bearer // (`@corbits/access-tools`, `@corbits/connections-tools`, - // `@corbits/workflow-authoring-tools`). + // `@corbits/workflow-authoring-tools`, `@corbits/catalog-tools`). tenantId: deps.tenantId, principalId: deps.principalId, }; diff --git a/scripts/checks/no-product-tenancy.ts b/scripts/checks/no-product-tenancy.ts index b2ae12d829..6b8fc3768b 100644 --- a/scripts/checks/no-product-tenancy.ts +++ b/scripts/checks/no-product-tenancy.ts @@ -103,17 +103,6 @@ const ALLOWLIST: readonly { maxOccurrences: 3, tables: ["usage_turn", "model_price", "turn_latency"], }, - { - // A bench's model policy: its allow/deny selectors, price ceilings - // and provider preference. The platform's catalog owns what a bench - // can reach; what a bench is willing to spend on it is a product - // decision with nowhere native to live. Everything else this package - // answers is derived at read time from model_offering and - // model_pricing — see docs/inference-concepts.md. - relPath: "packages/inference-catalog/src/schema.ts", - maxOccurrences: 1, - tables: ["bench_model_policy"], - }, { relPath: "packages/onboarding/src/schema.ts", maxOccurrences: 1, diff --git a/scripts/db-setup.ts b/scripts/db-setup.ts index ebbd535618..f8d217958b 100644 --- a/scripts/db-setup.ts +++ b/scripts/db-setup.ts @@ -38,7 +38,6 @@ import { import { applyInsightsMigrations } from "../packages/insights/src/migrations"; import { applyAgentDirectoryMigrations } from "../packages/agent-directory/src/migrations"; import { applyOnboardingMigrations } from "../packages/onboarding/src/migrations"; -import { applyInferenceCatalogMigrations } from "../packages/inference-catalog/src/migrations"; const repoRoot = path.resolve(import.meta.dir, ".."); const HUB_DIR = path.join(repoRoot, "apps", "hub"); @@ -65,10 +64,6 @@ const INSTALLED_PACKAGE_MIGRATIONS: readonly { { name: "@corbits/insights", apply: applyInsightsMigrations }, { name: "@corbits/agent-directory", apply: applyAgentDirectoryMigrations }, { name: "@workbench/onboarding", apply: applyOnboardingMigrations }, - { - name: "@corbits/inference-catalog", - apply: applyInferenceCatalogMigrations, - }, ]; /** diff --git a/workflows/assistant/package.json b/workflows/assistant/package.json index 084b3193f6..11d4504770 100644 --- a/workflows/assistant/package.json +++ b/workflows/assistant/package.json @@ -36,7 +36,7 @@ "test": "bun test" }, "dependencies": { - "@corbits/catalog-tools": "0.0.6" + "@corbits/catalog-tools": "0.1.0" }, "peerDependencies": { "@intx/agent": "0.3.0", diff --git a/workflows/assistant/src/prompt.ts b/workflows/assistant/src/prompt.ts index d4aeffe583..81ae926b1a 100644 --- a/workflows/assistant/src/prompt.ts +++ b/workflows/assistant/src/prompt.ts @@ -34,7 +34,7 @@ export const ASSISTANT_TOOL_PACKAGE_PINS: readonly ToolPackagePin[] = [ { name: "@corbits/capability-tools", version: "0.0.6" }, { name: "@corbits/agent-directory-tools", version: "0.0.6" }, { name: "@corbits/connections-tools", version: "0.0.11" }, - { name: "@corbits/catalog-tools", version: "0.0.6" }, + { name: "@corbits/catalog-tools", version: "0.1.0" }, { name: "@corbits/skills-tools", version: "0.0.7" }, { name: "@corbits/mcp-tools", version: "0.0.11" }, { name: "@corbits/interaction-tools", version: "0.0.9" }, From 0ee05f94bf8798f242ae1edaaac55dabd2747fb9 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Thu, 17 Sep 2026 11:17:31 -0700 Subject: [PATCH 4/5] chore: regenerate changeset ignore list for newly public packages --- .changeset/config.json | 2 -- 1 file changed, 2 deletions(-) diff --git a/.changeset/config.json b/.changeset/config.json index e3e94388cd..89ddb43a41 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -34,7 +34,6 @@ "@corbits/hub-api-client", "@corbits/icons", "@corbits/inbox", - "@corbits/inference-catalog", "@corbits/inference-settings", "@corbits/insights", "@corbits/jimmy-agent", @@ -43,7 +42,6 @@ "@corbits/migration-runner", "@corbits/mocks", "@corbits/notify", - "@corbits/ollama-adapter", "@corbits/plugins-ui", "@corbits/presence", "@corbits/process-provisioner", From 415a72dc92338e8bf0b5094a39f8d1101fe38e03 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Thu, 17 Sep 2026 11:24:54 -0700 Subject: [PATCH 5/5] chore: resolve rebase against main (evals mount already gone) --- apps/hub/src/index.ts | 1 - bun.lock | 27 ++++++++++++--------------- 2 files changed, 12 insertions(+), 16 deletions(-) diff --git a/apps/hub/src/index.ts b/apps/hub/src/index.ts index 8edda44ab9..b254351dbe 100644 --- a/apps/hub/src/index.ts +++ b/apps/hub/src/index.ts @@ -104,7 +104,6 @@ import { createUsageSink, withTurnPartPersistGuard, } from "@corbits/insights"; -import { import { generateId } from "@intx/hub-common"; import { diff --git a/bun.lock b/bun.lock index dd5a1caeda..327d5c2cb6 100644 --- a/bun.lock +++ b/bun.lock @@ -61,7 +61,6 @@ "@corbits/granola-call-workflow": "workspace:*", "@corbits/hub-api-client": "workspace:*", "@corbits/inbox": "workspace:*", - "@corbits/inference-catalog": "workspace:*", "@corbits/insights": "workspace:*", "@corbits/last-30-days-research-workflow": "workspace:*", "@corbits/mailbox": "github:corbitsdev/corbits-mailbox#118a2cd3b0b2d83abcd57b4bed1698e95ee41b6d", @@ -419,8 +418,9 @@ }, "packages/catalog-tools": { "name": "@corbits/catalog-tools", - "version": "0.0.6", + "version": "0.1.0", "dependencies": { + "@corbits/inference-catalog": "0.1.0", "arktype": "catalog:", }, "devDependencies": { @@ -821,24 +821,19 @@ }, "packages/inference-catalog": { "name": "@corbits/inference-catalog", - "version": "0.0.1", + "version": "0.1.0", "dependencies": { - "@corbits/error-sink": "workspace:*", - "@corbits/migration-runner": "workspace:*", - "@corbits/ollama-adapter": "workspace:*", - "@intx/db": "workspace:*", - "@intx/hub-api": "workspace:*", + "@corbits/ollama-adapter": "0.0.2", "@intx/inference-catalog": "0.3.0", - "@intx/types": "workspace:*", "arktype": "catalog:", - "drizzle-orm": "catalog:", - "hono": "catalog:", - "postgres": "catalog:", }, "devDependencies": { "@types/bun": "catalog:", "typescript": "catalog:", }, + "peerDependencies": { + "@intx/types": "0.3.0", + }, }, "packages/inference-settings": { "name": "@corbits/inference-settings", @@ -1039,16 +1034,18 @@ }, "packages/ollama-adapter": { "name": "@corbits/ollama-adapter", - "version": "0.0.1", + "version": "0.0.2", "dependencies": { - "@intx/inference": "workspace:*", - "@intx/types": "workspace:*", "arktype": "catalog:", }, "devDependencies": { "@types/bun": "catalog:", "typescript": "catalog:", }, + "peerDependencies": { + "@intx/inference": "0.3.0", + "@intx/types": "0.3.0", + }, }, "packages/onboarding": { "name": "@workbench/onboarding",