diff --git a/apps/hub/src/config.ts b/apps/hub/src/config.ts index d9fdad334a..1d3afcf793 100644 --- a/apps/hub/src/config.ts +++ b/apps/hub/src/config.ts @@ -272,7 +272,6 @@ function parsePositiveMsEnv( const SEED_MODEL_PROVIDER = "anthropic"; const SEED_MODEL = "claude-sonnet-5"; -const SEED_MODEL_BASE_URL = "https://api.anthropic.com"; // Matches boot-time seeding's own defaults (`system-seed.ts`) exactly, // so a zero-.env-edit local checkout that seeds its admin account @@ -282,10 +281,14 @@ const DEFAULT_PLANT_ADMIN_EMAIL = "alice@example.com"; const DEFAULT_PLANT_ADMIN_PASSWORD = "password123"; const DEFAULT_PLANT_ORG_SLUG = "workbench"; +/** The root tenant's boot-time seed model: a provider/model pair + * `seedTenant` names in every deployed definition, plus the real (or + * placeholder) key `seedCatalog` plants a launchable credential with. No + * `baseURL` — a workflow deploy resolves inference from the tenant's + * catalog offerings, never a bare source tuple (CL-7461). */ export type ModelSource = { readonly provider: string; readonly model: string; - readonly baseURL: string; readonly apiKey: string; }; @@ -624,7 +627,6 @@ function seedModelFrom(parsed: ParsedHubEnv): ModelSource | undefined { return { provider: SEED_MODEL_PROVIDER, model: SEED_MODEL, - baseURL: SEED_MODEL_BASE_URL, apiKey, }; } diff --git a/apps/hub/src/index.ts b/apps/hub/src/index.ts index 39d52b853d..687bcde64e 100644 --- a/apps/hub/src/index.ts +++ b/apps/hub/src/index.ts @@ -150,6 +150,7 @@ import { applyInferenceCatalogMigrations, createBenchModelPolicyRoutes, createPostgresBenchModelPolicyStore, + createResolvedOfferingsRoutes, createWorkflowCatalogRoutes, } from "@corbits/inference-catalog"; import { generateId } from "@intx/hub-common"; @@ -1877,6 +1878,21 @@ export async function createHub(config: HubConfig) { }), }), ); + // Resolved catalog offerings: the same ancestor-inheriting view + // `listVisibleOfferings` gives `workflowDeployer` above, exposed over + // HTTP so an out-of-process deployer (`workbench seed`) can deploy + // against exactly what the hub itself would deploy against, not just + // the offerings a tenant owns directly. + app.route( + `${TENANT_PREFIX}/catalog/resolved-offerings`, + createResolvedOfferingsRoutes({ + listOfferings: (tenantId) => listVisibleOfferings(db, tenantId), + requireGrant: createRequireGrant({ + grantStore: chatGrantStore, + conditionRegistry: chatConditionRegistry, + }), + }), + ); // Bench purpose/type: benches are Interchange tenants, so this is a // package-owned side-table keyed by tenant id, migrated at hub start // like insights and preferences. diff --git a/apps/hub/src/system-seed.ts b/apps/hub/src/system-seed.ts index 7f9291fc54..1637f2e89d 100644 --- a/apps/hub/src/system-seed.ts +++ b/apps/hub/src/system-seed.ts @@ -26,7 +26,6 @@ import { publishCorbitsToolsRegistry, DEFAULT_WORKFLOWS, PLACEHOLDER_CATALOG_API_KEY, - type ModelSource, } from "@corbits/seeding"; import { createHubAPI, signIn, type ApiCall } from "@corbits/hub-api-client"; import { findPersonalTenant } from "@workbench/onboarding"; @@ -43,14 +42,28 @@ const DEFAULT_POLL_INTERVAL_MS = 2_000; // and the catalog is still browsable, just not launchable. const SEED_MODEL_PROVIDER = "anthropic"; const SEED_MODEL = "claude-sonnet-5"; -const SEED_MODEL_BASE_URL = "https://api.anthropic.com"; -function resolvedModel(seedModel: ModelSource | undefined): ModelSource { +/** + * The root tenant's seed model: a provider/model pair for + * `seedTenant`'s deployed definitions, plus the real (or placeholder) + * API key `seedCatalog` needs to plant a launchable credential. Distinct + * from `@corbits/seeding`'s `ModelSource`, which carries no key — + * `seedTenant` never needs one; the deploy itself resolves inference + * from the tenant's catalog offerings (CL-7461). + */ +export type SeedModelConfig = { + readonly provider: string; + readonly model: string; + readonly apiKey: string; +}; + +function resolvedModel( + seedModel: SeedModelConfig | undefined, +): SeedModelConfig { return ( seedModel ?? { provider: SEED_MODEL_PROVIDER, model: SEED_MODEL, - baseURL: SEED_MODEL_BASE_URL, apiKey: PLACEHOLDER_CATALOG_API_KEY, } ); @@ -60,7 +73,7 @@ export type SystemSeedDeps = { baseUrl: string; orgSlug: string; admin: { email: string; password: string }; - seedModel?: ModelSource; + seedModel?: SeedModelConfig; deadlineMs?: number; pollIntervalMs?: number; }; @@ -116,7 +129,7 @@ export async function runSystemSeed(deps: SystemSeedDeps): Promise { principalId: tenant.principalId, domain: tenant.tenantDomain, }, - model, + model: { provider: model.provider, model: model.model }, pushWorkflow, log: (line) => log.info`${line}`, workflows: DEFAULT_WORKFLOWS, diff --git a/apps/hub/test/config.test.ts b/apps/hub/test/config.test.ts index 9d34351174..b4d41908e4 100644 --- a/apps/hub/test/config.test.ts +++ b/apps/hub/test/config.test.ts @@ -277,7 +277,6 @@ describe("readHubConfig", () => { expect(config.seedModel).toEqual({ provider: "anthropic", model: "claude-sonnet-5", - baseURL: "https://api.anthropic.com", apiKey: "sk-ant-test", }); }); diff --git a/packages/inference-catalog/src/index.ts b/packages/inference-catalog/src/index.ts index b7e90138a8..01af47a3e3 100644 --- a/packages/inference-catalog/src/index.ts +++ b/packages/inference-catalog/src/index.ts @@ -63,6 +63,10 @@ export { createBenchModelPolicyRoutes, type CreateBenchModelPolicyRoutesDeps, } from "./routes"; +export { + createResolvedOfferingsRoutes, + type CreateResolvedOfferingsRoutesDeps, +} from "./resolved-offerings-routes"; export { createWorkflowCatalogRoutes, estimateUsd, diff --git a/packages/inference-catalog/src/resolved-offerings-routes.ts b/packages/inference-catalog/src/resolved-offerings-routes.ts new file mode 100644 index 0000000000..ec1cfae2f7 --- /dev/null +++ b/packages/inference-catalog/src/resolved-offerings-routes.ts @@ -0,0 +1,44 @@ +// The tenant-owned `GET .../catalog/offerings` route only ever lists rows a +// tenant planted directly on itself; it has no way to show offerings a +// tenant inherits from an ancestor tenant. `listVisibleOfferings` +// (`@intx/db`) already computes that resolved, inheritance-aware view for +// the hub's own workflow deployer — this route exposes the same view over +// HTTP so an out-of-process caller (`workbench seed`, in particular) can +// deploy against exactly what the hub itself would deploy against. +// +// Takes a `listOfferings` port rather than a `db` handle, same as +// `workflow-catalog-routes.ts`, so the route is testable with literals. +import { Hono } from "hono"; +import type { ResolvedOffering } from "@intx/db"; +import type { RequireGrant, TenantEnv } from "@intx/hub-api"; + +export type CreateResolvedOfferingsRoutesDeps = { + readonly listOfferings: ( + tenantId: string, + ) => Promise; + readonly requireGrant: RequireGrant; +}; + +export function createResolvedOfferingsRoutes( + deps: CreateResolvedOfferingsRoutesDeps, +): Hono { + const app = new Hono(); + + app.get("/", deps.requireGrant("model-offering:*", "read"), async (c) => { + const tenant = c.get("tenant"); + const resolved = [...(await deps.listOfferings(tenant.id))].sort( + (a, b) => a.offering.priority - b.offering.priority, + ); + return c.json({ + offerings: resolved.map((entry) => ({ + id: entry.offering.id, + priority: entry.offering.priority, + modelId: entry.offering.modelId, + providerId: entry.offering.providerId, + origin: entry.origin, + })), + }); + }); + + return app; +} diff --git a/packages/inference-catalog/test/resolved-offerings-routes.test.ts b/packages/inference-catalog/test/resolved-offerings-routes.test.ts new file mode 100644 index 0000000000..897e053130 --- /dev/null +++ b/packages/inference-catalog/test/resolved-offerings-routes.test.ts @@ -0,0 +1,99 @@ +// The resolved-offerings route exists so an out-of-process caller +// (`workbench seed`, CL-7461) can deploy against exactly what the hub's +// own workflow deployer would deploy against — including offerings a +// tenant only inherits from an ancestor, which the tenant-owned +// `GET .../catalog/offerings` route never lists. +import { Hono } from "hono"; +import { describe, expect, test } from "bun:test"; + +import type { TenantEnv } from "@intx/hub-api"; +import type { ResolvedOffering } from "@intx/db"; + +import { createResolvedOfferingsRoutes } from "../src/resolved-offerings-routes"; +import { offering } from "./fixtures"; + +const TENANT = { + id: "bench-1", + name: "Bench One", + slug: "bench-one", + domain: "bench-one.example", + parentId: null, + config: null, + createdAt: new Date("2026-01-01T00:00:00.000Z"), + updatedAt: new Date("2026-01-01T00:00:00.000Z"), +}; + +function app( + listOfferings: (tenantId: string) => Promise, + allow = true, +) { + const host = new Hono(); + host.use("*", async (c, next) => { + c.set("tenant", TENANT); + await next(); + }); + host.route( + "/", + createResolvedOfferingsRoutes({ + listOfferings, + requireGrant: () => async (c, next) => { + if (!allow) { + return c.json({ error: { code: "forbidden", message: "no" } }, 403); + } + return next(); + }, + }), + ); + return host; +} + +describe("GET resolved-offerings", () => { + test("a denied grant is rejected before the offerings are read", async () => { + const response = await app(async () => { + throw new Error("must not be called when the grant is denied"); + }, false).request("/"); + expect(response.status).toBe(403); + }); + + test("lists both directly-owned and ancestor-inherited offerings, sorted by priority", async () => { + const owned = offering({ + id: "owned", + canonicalName: "thrifty", + providerName: "globex", + capabilities: ["plain-text"], + priority: 2, + }); + const inherited = offering({ + id: "inherited", + canonicalName: "lavish", + providerName: "acme", + capabilities: ["plain-text"], + priority: 1, + inherited: true, + }); + + const response = await app(async (tenantId) => { + expect(tenantId).toBe(TENANT.id); + return [owned, inherited]; + }).request("/"); + + expect(response.status).toBe(200); + const body = (await response.json()) as { + offerings: { + id: string; + priority: number; + modelId: string; + providerId: string; + origin: { tenantId: string; direct: boolean }; + }[]; + }; + expect(body.offerings.map((o) => o.id)).toEqual(["inherited", "owned"]); + const inheritedEntry = body.offerings.find((o) => o.id === "inherited"); + expect(inheritedEntry?.origin).toEqual({ + tenantId: "parent-bench", + direct: false, + }); + const ownedEntry = body.offerings.find((o) => o.id === "owned"); + expect(ownedEntry?.origin).toEqual({ tenantId: TENANT.id, direct: true }); + }); +}); diff --git a/packages/longevity-sim/src/stack.ts b/packages/longevity-sim/src/stack.ts index a7dc917761..59c4ff72aa 100644 --- a/packages/longevity-sim/src/stack.ts +++ b/packages/longevity-sim/src/stack.ts @@ -437,7 +437,9 @@ export async function bootLongevityStack( // credential row still requires some string, so each target's own // `apiKey` (expected to be the same placeholder) is threaded through // unchanged rather than this package inventing its own convention. - async function seedCatalogChain(target: InferenceTarget): Promise { + async function seedCatalogChain( + target: InferenceTarget, + ): Promise<{ offeringId: string }> { const model = await api( hub.baseUrl, "POST", @@ -509,10 +511,15 @@ export async function bootLongevityStack( owner.cookies, ); expectStatus(`create catalog offering ${target.model}`, offering, 201); + return { + offeringId: stringField(offering.data, "id", "create catalog offering"), + }; } + const offeringIdByTargetLabel = new Map(); for (const target of options.realTargets) { - await seedCatalogChain(target); + const { offeringId } = await seedCatalogChain(target); + offeringIdByTargetLabel.set(target.label, offeringId); } for (const skill of options.skills ?? []) { @@ -668,6 +675,13 @@ export async function bootLongevityStack( workflowJson, }); + const offeringId = offeringIdByTargetLabel.get(target.label); + if (offeringId === undefined) { + throw new Error( + `agent ${spec.key}: no catalog offering seeded for realTarget "${target.label}"`, + ); + } + const deployDeadline = Date.now() + 90_000; for (;;) { if (sidecar.exited()) { @@ -682,11 +696,8 @@ export async function bootLongevityStack( workflowDeployBody({ assetId, commitSha: pushed.commitSha, - sourceId: `src-agent-${spec.handle}`, - provider: target.provider, - baseURL: target.baseURL, - apiKey: target.apiKey, - model: target.model, + sourceOfferingIds: [offeringId], + defaultSourceOfferingId: offeringId, }), owner.cookies, ); @@ -847,6 +858,12 @@ export async function bootLongevityStack( if (routineTarget === undefined) { throw new Error("unreachable: realTargets checked non-empty above"); } + const routineOfferingId = offeringIdByTargetLabel.get(routineTarget.label); + if (routineOfferingId === undefined) { + throw new Error( + `no catalog offering seeded for realTarget "${routineTarget.label}"`, + ); + } const assetName = "longevity-heartbeat"; const heartbeatAssetCreated = await api( hub.baseUrl, @@ -912,11 +929,8 @@ export async function bootLongevityStack( workflowDeployBody({ assetId: heartbeatAssetId, commitSha: heartbeatPushed.commitSha, - sourceId: "src-longevity-heartbeat", - provider: "openai-compatible", - baseURL: routineTarget.baseURL, - apiKey: routineTarget.apiKey, - model: routineTarget.model, + sourceOfferingIds: [routineOfferingId], + defaultSourceOfferingId: routineOfferingId, }), owner.cookies, ); diff --git a/packages/onboarding/src/complete-credential.ts b/packages/onboarding/src/complete-credential.ts index 5af80e3f77..c292bc897f 100644 --- a/packages/onboarding/src/complete-credential.ts +++ b/packages/onboarding/src/complete-credential.ts @@ -60,10 +60,7 @@ import { TenantResponse, paginatedSchema, } from "@intx/types"; -import { - ollamaOpenAICompatBaseURL, - type SupportedCredentialProvider, -} from "@corbits/connections/credential-test"; +import { type SupportedCredentialProvider } from "@corbits/connections/credential-test"; import { preferCompletionCapable } from "@corbits/connections/model-capability"; import { CATALOG_SEEDS, @@ -280,13 +277,8 @@ async function resolveOllamaModelSource( api: ApiCall, cookies: string[], tenantId: string, - apiKey: string, - baseURLOverride: string | undefined, ): Promise { const catalogSeed = CATALOG_SEEDS.ollama; - const baseURL = ollamaOpenAICompatBaseURL( - baseURLOverride ?? catalogSeed.provider.baseURL, - ); const response = await api( "GET", @@ -368,39 +360,27 @@ async function resolveOllamaModelSource( return { provider: winner.plugin, model: winner.canonicalName, - baseURL, - apiKey, }; } -/** The `ModelSource` `ensureSeeded` deploys every default workflow - * against. Every provider but `ollama` has a fixed, always-available - * curated model list (`CATALOG_SEEDS`), so its curated default is a safe - * pin. `ollama` is the one provider whose actual model list is - * per-instance and can diverge from that curated name entirely - * (CL-6366) — its resolution defers to `resolveOllamaModelSource`, which - * reads back what `seedCatalog` actually found on the instance rather - * than repeating the static pin. `baseURLOverride` is the - * configurable-base-URL seam every provider but `ollama` ignores (a - * fixed origin); for `ollama` it is the root the person actually pointed - * their instance at, normalized to the OpenAI-compatible `/v1` form this - * deploys against. */ +/** The `ModelSource` `ensureSeeded` names in every deployed definition's + * inference preferences — never what the deploy itself resolves + * inference against (the tenant's catalog offerings, CL-7461). Every + * provider but `ollama` has a fixed, always-available curated model list + * (`CATALOG_SEEDS`), so its curated default is a safe pin. `ollama` is + * the one provider whose actual model list is per-instance and can + * diverge from that curated name entirely (CL-6366) — its resolution + * defers to `resolveOllamaModelSource`, which reads back what + * `seedCatalog` actually found on the instance rather than repeating the + * static pin. */ export async function modelSourceFor( api: ApiCall, cookies: string[], tenantId: string, provider: SupportedCredentialProvider, - apiKey: string, - baseURLOverride?: string, ): Promise { if (provider === "ollama") { - return resolveOllamaModelSource( - api, - cookies, - tenantId, - apiKey, - baseURLOverride, - ); + return resolveOllamaModelSource(api, cookies, tenantId); } const catalogSeed = CATALOG_SEEDS[provider]; @@ -413,8 +393,6 @@ export async function modelSourceFor( return { provider: catalogSeed.provider.plugin, model: defaultModel.canonicalName, - baseURL: catalogSeed.provider.baseURL, - apiKey, }; } @@ -513,8 +491,6 @@ export async function ensureSeeded( args.cookies, args.tenant.tenantId, args.provider, - args.apiKey, - args.baseURLOverride, ), pushWorkflow: args.pushWorkflow, log: args.log, diff --git a/packages/onboarding/test/complete-credential.test.ts b/packages/onboarding/test/complete-credential.test.ts index c9ac97c2db..16e81d0dfd 100644 --- a/packages/onboarding/test/complete-credential.test.ts +++ b/packages/onboarding/test/complete-credential.test.ts @@ -63,6 +63,29 @@ function seedHandshake(method: string, path: string) { return { ...handshake, cookies: [] }; } +// `ensureDeployment` resolves a real (non-noop-pinned) workflow's deploy +// source from the tenant's own catalog offerings (CL-7461). A test that +// never seeds its own catalog (it stubs `seedCatalogFn` instead) still +// needs one listable offering for the "assistant" workflow's own deploy +// to succeed — this fixed single row is that. +function fixedOfferingsResponse() { + return { + status: 200, + data: { + offerings: [ + { + id: "off_1", + priority: 0, + modelId: "mdl_1", + providerId: "mpr_1", + origin: { tenantId: TENANT_ID, direct: true }, + }, + ], + }, + cookies: [], + }; +} + function principalsResponse() { return { status: 200, @@ -149,24 +172,15 @@ function ownedCatalogModelsResponse(canonicalNames: string[]) { } describe("modelSourceFor", () => { - test("every other provider ignores a baseURLOverride and never calls the hub", async () => { + test("every other provider resolves to its curated default and never calls the hub", async () => { const api: ApiCall = (async () => { throw new Error("must not be called for a fixed curated provider"); }) as ApiCall; expect( - await modelSourceFor( - api, - ["session=abc"], - TENANT_ID, - "anthropic", - "sk-ant", - "https://ignored", - ), + await modelSourceFor(api, ["session=abc"], TENANT_ID, "anthropic"), ).toEqual({ provider: "anthropic", model: "claude-sonnet-5", - baseURL: "https://api.anthropic.com", - apiKey: "sk-ant", }); }); @@ -188,12 +202,10 @@ describe("modelSourceFor", () => { }; expect( - await modelSourceFor(api, ["session=abc"], TENANT_ID, "ollama", "ollama"), + await modelSourceFor(api, ["session=abc"], TENANT_ID, "ollama"), ).toEqual({ provider: "openai-compatible", model: "llama3.2", - baseURL: "http://localhost:11434/v1", - apiKey: "ollama", }); }); @@ -224,7 +236,6 @@ describe("modelSourceFor", () => { ["session=abc"], TENANT_ID, "ollama", - "ollama", ); expect(result.model).toBe("llama3.2"); }); @@ -259,7 +270,6 @@ describe("modelSourceFor", () => { ["session=abc"], TENANT_ID, "ollama", - "ollama", ); expect(result.model).toBe("gpt-oss:20b"); }); @@ -297,7 +307,6 @@ describe("modelSourceFor", () => { ["session=abc"], TENANT_ID, "ollama", - "ollama", ); expect(result.model).toBe("llama3.2"); }); @@ -334,41 +343,9 @@ describe("modelSourceFor", () => { ["session=abc"], TENANT_ID, "ollama", - "ollama", ); expect(result.model).toBe("gpt-oss:20b"); }); - - test("ollama's baseURLOverride is normalized to the /v1 form", async () => { - const api: ApiCall = async (method, path) => { - if (method === "GET" && path === `/api/tenants/${TENANT_ID}/models`) { - return resolvedCatalogResponse([ - { - canonicalName: "qwen3.8:27b", - providerName: "ollama", - capabilities: ["plain-text"], - }, - ]); - } - throw new Error(`unexpected call: ${method} ${path}`); - }; - - expect( - await modelSourceFor( - api, - ["session=abc"], - TENANT_ID, - "ollama", - "ollama", - "https://home-mac.example.ts.net", - ), - ).toEqual({ - provider: "openai-compatible", - model: "qwen3.8:27b", - baseURL: "https://home-mac.example.ts.net/v1", - apiKey: "ollama", - }); - }); }); describe("completeCredentialSetup", () => { @@ -751,12 +728,11 @@ describe("completeCredentialSetup", () => { } if ( method === "GET" && - (path === `/api/tenants/${TENANT_ID}/catalog/offerings` || - path.startsWith(`/api/tenants/${TENANT_ID}/catalog/offerings?`)) + path === `/api/tenants/${TENANT_ID}/catalog/resolved-offerings` ) { return { status: 200, - data: { data: [], nextCursor: null }, + data: { offerings: [] }, cookies: [], }; } @@ -808,6 +784,12 @@ describe("completeCredentialSetup", () => { if (method === "GET" && path === `/api/tenants/${TENANT_ID}`) { return tenantResponse(); } + if ( + method === "GET" && + path === `/api/tenants/${TENANT_ID}/catalog/resolved-offerings` + ) { + return fixedOfferingsResponse(); + } if ( method === "GET" && path.startsWith(`/api/tenants/${TENANT_ID}/grants?`) @@ -1387,8 +1369,7 @@ describe("completeCredentialSetup", () => { } if ( method === "GET" && - (path === `/api/tenants/${TENANT_ID}/catalog/offerings` || - path.startsWith(`/api/tenants/${TENANT_ID}/catalog/offerings?`)) + path === `/api/tenants/${TENANT_ID}/catalog/offerings` ) { return { status: 200, @@ -1411,6 +1392,24 @@ describe("completeCredentialSetup", () => { cookies: [], }; } + if ( + method === "GET" && + path === `/api/tenants/${TENANT_ID}/catalog/resolved-offerings` + ) { + return { + status: 200, + data: { + offerings: catalogOfferings.map((o) => ({ + id: o.id, + modelId: o.modelId, + providerId: o.providerId, + priority: o.priority, + origin: { tenantId: TENANT_ID, direct: true }, + })), + }, + cookies: [], + }; + } throw new Error(`unexpected call: ${method} ${path}`); }; @@ -1791,6 +1790,12 @@ describe("ensureSeeded (the slow half)", () => { let deploymentCreatePosts = 0; const api: ApiCall = async (method, path, body) => { + if ( + method === "GET" && + path === `/api/tenants/${TENANT_ID}/catalog/resolved-offerings` + ) { + return fixedOfferingsResponse(); + } if ( method === "GET" && path.startsWith(`/api/tenants/${TENANT_ID}/grants?`) diff --git a/packages/onboarding/test/provision.test.ts b/packages/onboarding/test/provision.test.ts index af1594cbc6..aff387d489 100644 --- a/packages/onboarding/test/provision.test.ts +++ b/packages/onboarding/test/provision.test.ts @@ -87,6 +87,37 @@ function collector() { return { lines, log: (line: string) => lines.push(line) }; } +// `ensureDeployment` resolves a real (non-noop-pinned) workflow's deploy +// source from the tenant's own catalog offerings (CL-7461); every test +// that seeds a real default workflow needs at least one listable, since +// none of these tests seed a catalog of its own. +function catalogOfferingsResponse( + method: string, + path: string, + tenantId: string, +): { status: number; data: unknown; cookies: string[] } | undefined { + if ( + method !== "GET" || + path !== `/api/tenants/${tenantId}/catalog/resolved-offerings` + ) + return undefined; + return { + status: 200, + data: { + offerings: [ + { + id: "off_1", + priority: 0, + modelId: "mdl_1", + providerId: "mpr_1", + origin: { tenantId, direct: true }, + }, + ], + }, + cookies: [], + }; +} + function firstLoginSeedHub(args: { expectedParentId?: string }) { let principalsCalls = 0; const startedRuns: string[] = []; @@ -206,6 +237,26 @@ function firstLoginSeedHub(args: { expectedParentId?: string }) { ) { return { status: 200, data: [], cookies: [] }; } + if ( + method === "GET" && + path === `/api/tenants/${TENANT_ID}/catalog/resolved-offerings` + ) { + return { + status: 200, + data: { + offerings: [ + { + id: "off_1", + priority: 0, + modelId: "mdl_1", + providerId: "mpr_1", + origin: { tenantId: TENANT_ID, direct: true }, + }, + ], + }, + cookies: [], + }; + } if ( method === "GET" && path.startsWith(`/api/tenants/${TENANT_ID}/workflows/definitions`) @@ -638,6 +689,26 @@ describe("provisionPersonalTenantIfNeeded", () => { "missing", ); if (registry !== undefined) return registry; + if ( + method === "GET" && + path === `/api/tenants/${TENANT_ID}/catalog/resolved-offerings` + ) { + return { + status: 200, + data: { + offerings: [ + { + id: "off_1", + priority: 0, + modelId: "mdl_1", + providerId: "mpr_1", + origin: { tenantId: TENANT_ID, direct: true }, + }, + ], + }, + cookies: [], + }; + } if (method === "GET" && path === "/api/me/principals") { return membership(); } @@ -890,6 +961,8 @@ describe("provisionPersonalTenantIfNeeded", () => { SEEDED_MEMORY_TARBALL, ]); if (registry !== undefined) return registry; + const offerings = catalogOfferingsResponse(method, path, TENANT_ID); + if (offerings !== undefined) return offerings; if (method === "GET" && path === "/api/me/principals") { return { status: 200, @@ -1019,6 +1092,8 @@ describe("provisionPersonalTenantIfNeeded", () => { SEEDED_MEMORY_TARBALL, ]); if (registry !== undefined) return registry; + const offerings = catalogOfferingsResponse(method, path, TENANT_ID); + if (offerings !== undefined) return offerings; if (method === "GET" && path === "/api/me/principals") { return { status: 200, @@ -1133,6 +1208,8 @@ describe("provisionPersonalTenantIfNeeded", () => { "missing", ); if (registry !== undefined) return registry; + const offerings = catalogOfferingsResponse(method, path, TENANT_ID); + if (offerings !== undefined) return offerings; if (method === "GET" && path === "/api/me/principals") { return { status: 200, @@ -1243,6 +1320,8 @@ describe("provisionPersonalTenantIfNeeded", () => { "missing", ); if (registry !== undefined) return registry; + const offerings = catalogOfferingsResponse(method, path, TENANT_ID); + if (offerings !== undefined) return offerings; if (method === "GET" && path === "/api/me/principals") { return { status: 200, @@ -1485,6 +1564,8 @@ describe("provisionPersonalTenantIfNeeded", () => { "missing", ); if (registry !== undefined) return registry; + const offerings = catalogOfferingsResponse(method, path, TENANT_ID); + if (offerings !== undefined) return offerings; if (method === "GET" && path === "/api/me/principals") { return { status: 200, @@ -1832,6 +1913,8 @@ describe("provisionPersonalTenantIfNeeded", () => { [], ); if (registry !== undefined) return registry; + const offerings = catalogOfferingsResponse(method, path, TENANT_ID); + if (offerings !== undefined) return offerings; if (method === "GET" && path === "/api/me/principals") { return { status: 200, diff --git a/packages/seeding/src/index.ts b/packages/seeding/src/index.ts index 78506cd4fb..e67e47a58d 100644 --- a/packages/seeding/src/index.ts +++ b/packages/seeding/src/index.ts @@ -26,6 +26,7 @@ export { inferenceCredentialName, PLACEHOLDER_CATALOG_API_KEY, ensureCredential, + ensureNoopCatalogOffering, ensureProvider, reconcileSeedGrants, seedCatalog, diff --git a/packages/seeding/src/seed.ts b/packages/seeding/src/seed.ts index 2b34e98c3d..21ca427949 100644 --- a/packages/seeding/src/seed.ts +++ b/packages/seeding/src/seed.ts @@ -141,11 +141,6 @@ const DILIGENCE_BRIEF_TURN_TIMEOUT_MS = 2 * 60 * 1000; const RUN_START_TIMEOUT_MS = 30_000; const RUN_POLL_INTERVAL_MS = 1000; -// The deploy source the per-step agents launch against. The id is the -// routing key `defaultSource` must name; with exactly one source there -// is exactly one honest value for it. -const SEED_SOURCE_ID = "default"; - // The provider/model pair `noop-inference` (packages/chat/src/noop-inference.ts) // answers for any request, regardless of what is actually sent — the // route ignores its body and `x-api-key` entirely. Naming a distinct @@ -154,24 +149,31 @@ const SEED_SOURCE_ID = "default"; // UI and logs. const NOOP_PROVIDER = "anthropic"; const NOOP_MODEL = "noop"; +// The catalog provider/credential name a noop-pinned deployment's own +// offering is planted under — distinct from any real curated provider +// name (`CATALOG_SEEDS`'s keys), so `ensureNoopCatalogOffering` never +// collides with a tenant's real catalog and is trivially recognizable +// in the hub's own catalog UI. +const NOOP_CATALOG_PROVIDER_NAME = "noop"; +// Priority is meaningless for this offering — it is never included in +// a real workflow's `sourceOfferingIds` (`ensureNoopCatalogOffering`'s +// id is threaded through explicitly, never discovered by sorting), so +// any fixed value is honest here. +const NOOP_OFFERING_PRIORITY = 0; /** - * A `ModelSource` pointed at the hub's own `noop-inference` endpoint + * The provider/model pair naming the hub's own `noop-inference` endpoint * instead of a real provider — the same substitution * `packages/chat/src/platform-adapter.ts`'s `noopSourcesOverride` makes - * for workbench-host launches, reused here so a workflow deployed with - * this source resolves every turn instantly against a constant, - * locally served reply and never reaches a real model. `hubUrl` is the - * same base URL `seedTenant` already receives, so no new configuration - * is required to use it. + * for workbench-host launches, reused here so a workflow's rendered + * definition names this pair in its inference preferences. The actual + * deploy source is a catalog offering pinned at this same endpoint + * (`ensureNoopCatalogOffering`), not this pair directly — a workflow + * deployment resolves inference from catalog offering ids, never a bare + * provider/model/baseURL/apiKey tuple (CL-7461). */ -export function NOOP_MODEL_SOURCE(hubUrl: string): ModelSource { - return { - provider: NOOP_PROVIDER, - model: NOOP_MODEL, - baseURL: `${hubUrl}/api/chat/noop-inference`, - apiKey: "noop", - }; +export function NOOP_MODEL_SOURCE(): ModelSource { + return { provider: NOOP_PROVIDER, model: NOOP_MODEL }; } const GitTokenMintResponse = type({ id: "string", secret: "string" }); @@ -191,11 +193,18 @@ const WorkflowRunTriggerResponse = type({ messageId: "string", }); +/** + * A provider/model pair a deployed workflow's rendered definition names + * in its inference preferences (`DefaultWorkflow.buildJson`'s second + * argument). What a deployment actually resolves inference against is + * the tenant's catalog offerings, resolved separately by `ensureDeployment` + * (or, for a noop-pinned workflow, `ensureNoopCatalogOffering`) — this + * type carries no `baseURL`/`apiKey` because `seedTenant` never needs + * either (CL-7461). + */ export type ModelSource = { readonly provider: string; readonly model: string; - readonly baseURL: string; - readonly apiKey: string; }; /** @@ -240,9 +249,9 @@ export type DefaultWorkflow = { * Renders the definition's JSON given the tenant's mail domain and the * ordered provider/model preferences to deploy against. Takes the bare * preference list — never a full `ModelSource` — so this same - * function serves both `seedTenant`'s HTTP-deploy path (which also - * needs a `ModelSource`'s `baseURL`/`apiKey` for the deployment's - * `sources`, resolved separately) and a native in-process deploy path + * function serves both `seedTenant`'s HTTP-deploy path (whose actual + * deploy source is a set of catalog offering ids, resolved separately + * by `ensureDeployment`) and a native in-process deploy path * (`apps/hub/src/templates/block-workflows.ts`) that only ever has the * tenant's real, possibly multi-entry inference preferences on hand. */ @@ -251,15 +260,16 @@ export type DefaultWorkflow = { inferencePreferences: readonly InferencePreference[], ) => string; /** - * Overrides the deploy's inference source for this workflow only, - * given the hub's own base URL. Present on the catalog-test workflow - * `heartbeat`, which must stay free to run continuously: it names - * `NOOP_MODEL_SOURCE` instead of the tenant's real catalog model. - * Absent on every conversational workflow and on the seeded - * workbench-digest automation, which deploy against the tenant's real - * model. + * Overrides the deploy's inference source for this workflow only. + * Present on the catalog-test workflow `heartbeat`, which must stay + * free to run continuously: it names `NOOP_MODEL_SOURCE` instead of + * the tenant's real catalog model, and `seedTenant` deploys it against + * a dedicated noop catalog offering (`ensureNoopCatalogOffering`) + * rather than the tenant's own resolved offerings. Absent on every + * conversational workflow and on the seeded workbench-digest + * automation, which deploy against the tenant's real model. */ - modelSource?: (hubUrl: string) => ModelSource; + modelSource?: () => ModelSource; /** * When true, PUT the authored definition to `stopped` after deploy so * a native ScheduleTrigger does not fire every tenant at the next @@ -901,7 +911,8 @@ async function ensureDeployment( assetId: string; assetName: string; commitSha: string; - model: ModelSource; + sourceOfferingIds: readonly string[]; + defaultSourceOfferingId: string; }, log: (line: string) => void, ): Promise { @@ -950,16 +961,8 @@ async function ensureDeployment( package: { format: "source", commitSha: args.commitSha }, }, entry: WORKFLOW_SOURCE_ENTRY, - sources: [ - { - id: SEED_SOURCE_ID, - provider: args.model.provider, - baseURL: args.model.baseURL, - apiKey: args.model.apiKey, - model: args.model.model, - }, - ], - defaultSource: SEED_SOURCE_ID, + sourceOfferingIds: args.sourceOfferingIds, + defaultSourceOfferingId: args.defaultSourceOfferingId, }, cookies, ); @@ -984,6 +987,163 @@ async function ensureDeployment( return deployment.id; } +const ResolvedOfferingResponse = type({ + id: "string", + priority: "number", + modelId: "string", + providerId: "string", + origin: { tenantId: "string", direct: "boolean" }, +}); + +const ResolvedOfferingsResponse = type({ + offerings: ResolvedOfferingResponse.array(), +}); + +/** + * Lists the offerings visible to a tenant, including any inherited from + * an ancestor tenant — the same resolved view `listVisibleOfferings` + * (`@intx/db`) gives the hub's own workflow deployer + * (`apps/hub/src/index.ts`'s `workflowDeployer`), exposed at + * `GET .../catalog/resolved-offerings`. Seeding a tenant whose catalog is + * inherited rather than directly owned (e.g. a sub-tenant under a + * parent that already carries a real provider key) needs this resolved + * list, not the tenant-owned-only `GET .../catalog/offerings`. + */ +async function listResolvedCatalogOfferings( + api: ApiCall, + cookies: string[], + tenantId: string, +): Promise<(typeof ResolvedOfferingResponse.infer)[]> { + const listed = await api( + "GET", + `/api/tenants/${tenantId}/catalog/resolved-offerings`, + undefined, + cookies, + ); + return parseAs( + ResolvedOfferingsResponse, + listed.data, + "resolved catalog offerings response", + ).offerings; +} + +/** + * Resolves the catalog offering ids a REAL (non-noop-pinned) workflow + * deploys against: every offering visible to the tenant (owned or + * inherited from an ancestor), ordered by priority ascending, with the + * lowest-priority offering as the default — + * the SAME rule `apps/hub/src/index.ts`'s `workflowDeployer.deploy` and + * `packages/chat/src/platform-adapter.ts`'s `catalogOfferings` apply + * in-process via `listVisibleOfferings`. `excludeOfferingId` drops the + * noop offering (`ensureNoopCatalogOffering`) from this list when one + * has already been planted on the same tenant this run — it must never + * win a real workflow's default just because it happens to sort first. + * + * Throws when nothing is left to deploy against: "seeded but not + * launchable" (`seedCatalog` with no credential) is a valid catalog + * state, but a workflow deploy that names zero sources is not a + * lesser success, it is the failure this function exists to catch + * before the hub's own `sourceOfferingIds` validation would. + */ +async function resolveRealSourceOfferingIds( + api: ApiCall, + cookies: string[], + tenantId: string, + excludeOfferingId: string | undefined, +): Promise<{ + sourceOfferingIds: readonly string[]; + defaultSourceOfferingId: string; +}> { + const offerings = (await listResolvedCatalogOfferings(api, cookies, tenantId)) + .filter((offering) => offering.id !== excludeOfferingId) + .sort((a, b) => a.priority - b.priority); + const defaultSourceOfferingId = offerings[0]?.id; + if (defaultSourceOfferingId === undefined) { + throw new HubApiError( + "this tenant has no catalog offerings to deploy against — nothing is launchable yet", + "seed the tenant's catalog with a real provider key (or a placeholder credential for a keyless dev/CI run), then re-run: workbench seed", + ); + } + return { + sourceOfferingIds: offerings.map((offering) => offering.id), + defaultSourceOfferingId, + }; +} + +/** + * Plants (or finds) the dedicated catalog offering a noop-pinned + * workflow (`DefaultWorkflow.modelSource`, e.g. `heartbeat`) deploys + * against: a catalog model named `NOOP_MODEL`, a provider pointed at the + * hub's own `noop-inference` endpoint, and a placeholder credential — + * the same `ensureCatalogModel` / `ensureProvider` / `ensureCredential` / + * `ensureCatalogProvider` / `ensureCatalogOffering` sequence + * `seedCatalog` runs for a real provider, run here for this one + * synthetic one. Idempotent, same as every other seed step: a re-run + * finds the existing rows by name and reuses them. + */ +export async function ensureNoopCatalogOffering( + api: ApiCall, + cookies: string[], + tenantId: string, + hubUrl: string, + log: (line: string) => void, +): Promise { + const baseURL = `${hubUrl}/api/chat/noop-inference`; + const modelId = await ensureCatalogModel( + api, + cookies, + { tenantId, canonicalName: NOOP_MODEL }, + log, + ); + const providerId = await ensureProvider( + api, + cookies, + { + tenantId, + name: NOOP_CATALOG_PROVIDER_NAME, + plugin: NOOP_PROVIDER, + apiBaseUrl: baseURL, + }, + log, + ); + const credentialId = await ensureCredential( + api, + cookies, + { + tenantId, + providerId, + name: inferenceCredentialName(NOOP_CATALOG_PROVIDER_NAME), + secret: PLACEHOLDER_CATALOG_API_KEY, + type: "api_key", + }, + log, + ); + const catalogProviderId = await ensureCatalogProvider( + api, + cookies, + { + tenantId, + name: NOOP_CATALOG_PROVIDER_NAME, + plugin: NOOP_PROVIDER, + baseURL, + credentialId, + }, + log, + ); + return ensureCatalogOffering( + api, + cookies, + { + tenantId, + modelId, + providerId: catalogProviderId, + priority: NOOP_OFFERING_PRIORITY, + capabilities: [], + }, + log, + ); +} + /** * After deploy, PUT a pristine authored definition to `stopped` so a native * ScheduleTrigger does not fire every tenant. A member-restored row @@ -1160,6 +1320,14 @@ export type SeedTenantArgs = { cookies: string[]; hubUrl: string; tenant: SeedTenant; + /** + * The provider/model pair a real (non-noop-pinned) workflow's rendered + * definition names in its inference preferences. The deploy itself + * resolves inference from the tenant's own catalog offerings + * (`resolveRealSourceOfferingIds`), not from this pair — a tenant with + * no catalog offerings still fails clearly at deploy time even if this + * pair happens to name a real model. + */ model: ModelSource; pushWorkflow: WorkflowPusher; log: (line: string) => void; @@ -1228,9 +1396,41 @@ export async function seedTenant(args: SeedTenantArgs): Promise { await plantDefaultSkills(api, cookies, tenant.tenantId, log); + // Resolved lazily and cached across the loop below: most seed runs + // deploy several workflows against the same tenant catalog state, so + // this fetches the tenant's real offerings (or plants the noop one) + // at most once each, however many workflows ask for them. + let noopOfferingId: string | undefined; + let realOfferings: + | { sourceOfferingIds: readonly string[]; defaultSourceOfferingId: string } + | undefined; + let confirmed = 0; for (const workflow of workflows) { - const workflowModel = workflow.modelSource?.(hubUrl) ?? model; + const workflowModel = workflow.modelSource?.() ?? model; + + let sourceOfferingIds: readonly string[]; + let defaultSourceOfferingId: string; + if (workflow.modelSource !== undefined) { + noopOfferingId ??= await ensureNoopCatalogOffering( + api, + cookies, + tenant.tenantId, + hubUrl, + log, + ); + sourceOfferingIds = [noopOfferingId]; + defaultSourceOfferingId = noopOfferingId; + } else { + realOfferings ??= await resolveRealSourceOfferingIds( + api, + cookies, + tenant.tenantId, + noopOfferingId, + ); + ({ sourceOfferingIds, defaultSourceOfferingId } = realOfferings); + } + const assetId = await ensureWorkflowAsset( api, cookies, @@ -1265,7 +1465,8 @@ export async function seedTenant(args: SeedTenantArgs): Promise { assetId, assetName: workflow.assetName, commitSha: pushed.commitSha, - model: workflowModel, + sourceOfferingIds, + defaultSourceOfferingId, }, log, ); @@ -1666,7 +1867,7 @@ async function ensureCatalogOffering( quirks?: Record; }, log: (line: string) => void, -): Promise { +): Promise { const body: Record = { modelId: args.modelId, providerId: args.providerId, @@ -1681,9 +1882,13 @@ async function ensureCatalogOffering( cookies, ); if (created.status === 201) { - parseAs(ModelOfferingResponse, created.data, "catalog offering response"); + const offering = parseAs( + ModelOfferingResponse, + created.data, + "catalog offering response", + ); log("created catalog offering"); - return; + return offering.id; } if (created.status === 409) { let cursor: string | null = null; @@ -1715,7 +1920,7 @@ async function ensureCatalogOffering( } if (existing.priority === args.priority) { log("catalog offering already exists (skipped)"); - return; + return existing.id; } const updated = await api( @@ -1730,9 +1935,13 @@ async function ensureCatalogOffering( "check the hub logs for the underlying failure, then re-run: workbench seed", ); } - parseAs(ModelOfferingResponse, updated.data, "catalog offering response"); + const offering = parseAs( + ModelOfferingResponse, + updated.data, + "catalog offering response", + ); log("updated catalog offering priority"); - return; + return offering.id; } throw new HubApiError( `the hub rejected creation of the catalog offering with status ${created.status}: ${JSON.stringify(created.data)}`, diff --git a/packages/seeding/test/routine-launchability.test.ts b/packages/seeding/test/routine-launchability.test.ts index 4f63bbe0c9..a3764be60e 100644 --- a/packages/seeding/test/routine-launchability.test.ts +++ b/packages/seeding/test/routine-launchability.test.ts @@ -27,8 +27,6 @@ import { const FAKE_MODEL: ModelSource = { provider: "ollama", model: "qwen-test", - baseURL: "http://localhost:11434", - apiKey: "test-key", }; type SerializedStepDefinition = { diff --git a/packages/seeding/test/seed.test.ts b/packages/seeding/test/seed.test.ts index b6291fc941..d1bea7bcdb 100644 --- a/packages/seeding/test/seed.test.ts +++ b/packages/seeding/test/seed.test.ts @@ -38,10 +38,57 @@ installDisposableHubDataDir(); const MODEL = { provider: "anthropic", model: "claude-sonnet-4-5", - baseURL: "https://api.anthropic.com", - apiKey: "sk-test", }; +// The tenant's one real catalog offering — every `seedTenant` test that +// deploys a real (non-noop-pinned) workflow needs at least one of these +// listable, since `ensureDeployment` now resolves its deploy source from +// the tenant's catalog rather than a bare `ModelSource`. +const OFFERING_ID = "off_1"; + +function offeringsPage(ids: readonly string[] = [OFFERING_ID]): { + status: number; + data: unknown; +} { + return { + status: 200, + data: { + data: ids.map((id, index) => ({ + id, + tenantId: TENANT_ID, + modelId: `mdl_${id}`, + providerId: `mpr_${id}`, + priority: index, + deploymentTags: [], + capabilities: [], + quirks: null, + disabled: false, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + })), + nextCursor: null, + }, + }; +} + +function resolvedOfferingsPage(ids: readonly string[] = [OFFERING_ID]): { + status: number; + data: unknown; +} { + return { + status: 200, + data: { + offerings: ids.map((id, index) => ({ + id, + modelId: `mdl_${id}`, + providerId: `mpr_${id}`, + priority: index, + origin: { tenantId: TENANT_ID, direct: true }, + })), + }, + }; +} + const instantSleep = async (_ms: number) => {}; const recordingPusher = () => { @@ -98,6 +145,45 @@ function baseRoutes(method: string, path: string) { path === `/api/tenants/${TENANT_ID}/workflows/deployments` ) return { status: 200, data: [] }; + if ( + method === "GET" && + path === `/api/tenants/${TENANT_ID}/catalog/offerings` + ) + return offeringsPage(); + if ( + method === "GET" && + path === `/api/tenants/${TENANT_ID}/catalog/resolved-offerings` + ) + return resolvedOfferingsPage(); + // The noop catalog chain `ensureNoopCatalogOffering` plants for a + // modelSource-pinned workflow (heartbeat) — every seedTenant test that + // deploys one needs this fresh chain to succeed, so it lives in the + // shared fallback rather than every such test repeating it. + if (method === "POST" && path === `/api/tenants/${TENANT_ID}/catalog/models`) + return { status: 201, data: catalogModelRow("mdl_noop", "noop") }; + if (method === "POST" && path === `/api/tenants/${TENANT_ID}/providers`) + return { status: 201, data: providerRow("prv_noop", "noop") }; + if (method === "POST" && path === `/api/tenants/${TENANT_ID}/credentials`) + return { + status: 201, + data: credentialRow("cre_noop", "prv_noop", "noop-default"), + }; + if ( + method === "POST" && + path === `/api/tenants/${TENANT_ID}/catalog/providers` + ) + return { + status: 201, + data: catalogProviderRow("cpv_noop", "noop", "cre_noop", "anthropic"), + }; + if ( + method === "POST" && + path === `/api/tenants/${TENANT_ID}/catalog/offerings` + ) + return { + status: 201, + data: catalogOfferingRow("off_noop", "mdl_noop", "cpv_noop"), + }; const handshake = pristineScheduledDefinitionHandshake(method, path); if (handshake) return handshake; return undefined; @@ -860,11 +946,9 @@ describe("seedTenant", () => { expect(assistant?.displayName).toBe("Myra"); }); - test("NOOP_MODEL_SOURCE resolves to the hub's own noop-inference endpoint", () => { - const resolved = NOOP_MODEL_SOURCE("http://localhost:3000"); - expect(resolved.baseURL).toBe( - "http://localhost:3000/api/chat/noop-inference", - ); + test("NOOP_MODEL_SOURCE names the noop provider/model pair", () => { + const resolved = NOOP_MODEL_SOURCE(); + expect(resolved.provider).toBe("anthropic"); expect(resolved.model).toBe("noop"); }); @@ -934,8 +1018,8 @@ describe("seedTenant", () => { (w) => w.assetName === "heartbeat", ); if (!heartbeat) throw new Error("expected the heartbeat workflow"); - const resolved = heartbeat.modelSource?.("http://localhost:3000"); - expect(resolved).toEqual(NOOP_MODEL_SOURCE("http://localhost:3000")); + const resolved = heartbeat.modelSource?.(); + expect(resolved).toEqual(NOOP_MODEL_SOURCE()); }); test("fresh run pushes, deploys, and confirms the heartbeat workflow against the noop source", async () => { @@ -1012,12 +1096,17 @@ describe("seedTenant", () => { expect(definition.triggers[0]?.to).toBe(`heartbeat@${TENANT_DOMAIN}`); expect(definition.stepOrder).toEqual(["heartbeat"]); - // The deploy's own source, not the tenant's real MODEL, is what - // proves the noop pin took effect: it must name the noop provider - // fixture, not the ordinary anthropic/claude-sonnet-4-5 model this - // test file's `args()` helper hands every other workflow. - const deployedBody = deployedSources as { sources: { model: string }[] }; - expect(deployedBody.sources[0]?.model).toBe("noop"); + // The deploy's own source offering, not the tenant's real catalog + // offering, is what proves the noop pin took effect: it must name + // the dedicated noop offering `ensureNoopCatalogOffering` plants, + // never the tenant's own `OFFERING_ID` this test file's `baseRoutes` + // fixture hands every real workflow. + const deployedBody = deployedSources as { + sourceOfferingIds: string[]; + defaultSourceOfferingId: string; + }; + expect(deployedBody.sourceOfferingIds).toEqual(["off_noop"]); + expect(deployedBody.defaultSourceOfferingId).toBe("off_noop"); const output = lines.join("\n"); expect(output).toContain("deployed workflow heartbeat as dep_3"); @@ -1135,9 +1224,14 @@ describe("seedTenant", () => { ]); expect(definition.stepOrder).toEqual(["workbench-digest"]); - // Defaults deploy against the tenant's real model (not noop). - const deployedBody = deployedSources as { sources: { model: string }[] }; - expect(deployedBody.sources[0]?.model).not.toBe("noop"); + // Defaults deploy against the tenant's real catalog offering, never + // the noop one. + const deployedBody = deployedSources as { + sourceOfferingIds: string[]; + defaultSourceOfferingId: string; + }; + expect(deployedBody.sourceOfferingIds).toEqual([OFFERING_ID]); + expect(deployedBody.defaultSourceOfferingId).toBe(OFFERING_ID); const output = lines.join("\n"); expect(output).toContain("deployed workflow workbench-digest as dep_4"); diff --git a/scripts/e2e/chat.test.ts b/scripts/e2e/chat.test.ts index deea7c8268..da9b255c89 100644 --- a/scripts/e2e/chat.test.ts +++ b/scripts/e2e/chat.test.ts @@ -266,6 +266,47 @@ describe.skipIf(databaseUrl === undefined)("chat e2e", () => { log: () => undefined, }); + // The echo deploy resolves against the catalog offering `seedCatalog` + // just planted for the same provider/model this workflow declares, + // rather than seeding a second inference chain. + const echoModelsListed = await api( + "GET", + `/api/tenants/${tenantId}/catalog/models`, + undefined, + user1.cookies, + ); + expectStatus("list catalog models", echoModelsListed, 200); + const echoModelRows = ( + echoModelsListed.data as { data: { id: string; canonicalName: string }[] } + ).data; + const echoModelId = echoModelRows.find( + (row) => row.canonicalName === "claude-sonnet-5", + )?.id; + if (echoModelId === undefined) { + throw new Error( + `no catalog model named "claude-sonnet-5": ${JSON.stringify(echoModelsListed.data)}`, + ); + } + + const echoOfferingsListed = await api( + "GET", + `/api/tenants/${tenantId}/catalog/offerings`, + undefined, + user1.cookies, + ); + expectStatus("list catalog offerings", echoOfferingsListed, 200); + const echoOfferingRows = ( + echoOfferingsListed.data as { data: { id: string; modelId: string }[] } + ).data; + const echoOfferingId = echoOfferingRows.find( + (row) => row.modelId === echoModelId, + )?.id; + if (echoOfferingId === undefined) { + throw new Error( + `no catalog offering for model "${echoModelId}": ${JSON.stringify(echoOfferingsListed.data)}`, + ); + } + // Seed the echo workflow as a deployed, invitable definition: the // same asset-publish → git-token → smart-HTTP push → native deploy // path `scripts/e2e/walking-skeleton.test.ts` proves end to end. @@ -334,11 +375,8 @@ describe.skipIf(databaseUrl === undefined)("chat e2e", () => { workflowDeployBody({ assetId: echoAssetId, commitSha: echoPushed.commitSha, - sourceId: "src-echo-e2e", - provider: "anthropic", - baseURL: "https://inference.invalid", - apiKey: "e2e-placeholder", - model: "claude-sonnet-5", + sourceOfferingIds: [echoOfferingId], + defaultSourceOfferingId: echoOfferingId, }), user1.cookies, ); diff --git a/scripts/e2e/cl-6324-launch-proof.ts b/scripts/e2e/cl-6324-launch-proof.ts index 6743150908..80766c99db 100644 --- a/scripts/e2e/cl-6324-launch-proof.ts +++ b/scripts/e2e/cl-6324-launch-proof.ts @@ -369,62 +369,77 @@ async function main(): Promise { // on purpose, so it narrows the bench's own catalog to that model // through the catalog API rather than leaving the turn's model to a // coin flip the proof is not about. - await hop("narrow the bench catalog to the pinned model", async () => { - await plantGrant("model-offering:*", "read"); - await plantGrant("model-offering:*", "manage"); - await plantGrant("model:*", "read"); + const pinnedOfferingId = await hop( + "narrow the bench catalog to the pinned model", + async () => { + await plantGrant("model-offering:*", "read"); + await plantGrant("model-offering:*", "manage"); + await plantGrant("model:*", "read"); - const models = await api( - hub.baseUrl, - "GET", - `/api/tenants/${tenant.tenantId}/catalog/models?limit=200`, - undefined, - user.cookies, - ); - expectStatus("list the bench catalog models", models, 200); - const modelRows = arrayField(models.data, "data", "catalog models") as { - id: string; - canonicalName: string; - }[]; - const pinned = modelRows.find( - (row) => row.canonicalName === proofModelSource.model, - ); - if (pinned === undefined) { - throw new Error( - `the bench catalog carries no model named ${proofModelSource.model}; ` + - `it has ${JSON.stringify(modelRows.map((m) => m.canonicalName))}`, + const models = await api( + hub.baseUrl, + "GET", + `/api/tenants/${tenant.tenantId}/catalog/models?limit=200`, + undefined, + user.cookies, ); - } + expectStatus("list the bench catalog models", models, 200); + const modelRows = arrayField(models.data, "data", "catalog models") as { + id: string; + canonicalName: string; + }[]; + const pinned = modelRows.find( + (row) => row.canonicalName === proofModelSource.model, + ); + if (pinned === undefined) { + throw new Error( + `the bench catalog carries no model named ${proofModelSource.model}; ` + + `it has ${JSON.stringify(modelRows.map((m) => m.canonicalName))}`, + ); + } - const offerings = await api( - hub.baseUrl, - "GET", - `/api/tenants/${tenant.tenantId}/catalog/offerings?limit=200`, - undefined, - user.cookies, - ); - expectStatus("list the bench model offerings", offerings, 200); - const offeringRows = arrayField( - offerings.data, - "data", - "model offerings", - ) as { id: string; modelId: string; disabled: boolean }[]; - for (const offering of offeringRows) { - if (offering.modelId === pinned.id || offering.disabled) continue; - const patched = await api( + const offerings = await api( hub.baseUrl, - "PATCH", - `/api/tenants/${tenant.tenantId}/catalog/offerings/${offering.id}`, - { disabled: true }, + "GET", + `/api/tenants/${tenant.tenantId}/catalog/offerings?limit=200`, + undefined, user.cookies, ); - expectStatus(`disable offering ${offering.id}`, patched, 200); - } - console.log( - ` TRANSCRIPT — bench catalog narrowed to ${proofModelSource.model} ` + - `(${String(offeringRows.length - 1)} other offerings disabled)`, - ); - }); + expectStatus("list the bench model offerings", offerings, 200); + const offeringRows = arrayField( + offerings.data, + "data", + "model offerings", + ) as { id: string; modelId: string; disabled: boolean }[]; + let pinnedOffering: { id: string; modelId: string } | undefined; + for (const offering of offeringRows) { + if (offering.modelId === pinned.id) { + pinnedOffering = offering; + continue; + } + if (offering.disabled) continue; + const patched = await api( + hub.baseUrl, + "PATCH", + `/api/tenants/${tenant.tenantId}/catalog/offerings/${offering.id}`, + { disabled: true }, + user.cookies, + ); + expectStatus(`disable offering ${offering.id}`, patched, 200); + } + if (pinnedOffering === undefined) { + throw new Error( + `no catalog offering resolves the pinned model ${proofModelSource.model} ` + + `(id ${pinned.id})`, + ); + } + console.log( + ` TRANSCRIPT — bench catalog narrowed to ${proofModelSource.model} ` + + `(${String(offeringRows.length - 1)} other offerings disabled)`, + ); + return pinnedOffering.id; + }, + ); const assistantDefinitionId = await hop( "PROOF 1 — 'assistant' is invitable tenant-wide", @@ -694,7 +709,6 @@ async function main(): Promise { // own durable event log, which is the artefact the milestone's // `RunStarted` assertion was always about. const SECTION_ASSET_NAME = "cl6324-section"; - const SECTION_SOURCE_ID = "cl6324-section-source"; const SECTION_TURN_TIMEOUT_MS = 180_000; const sectionAddress = `${SECTION_ASSET_NAME}@${tenant.tenantDomain}`; @@ -780,16 +794,8 @@ async function main(): Promise { package: { format: "source", commitSha: pushed.commitSha }, }, entry: WORKFLOW_SOURCE_ENTRY, - sources: [ - { - id: SECTION_SOURCE_ID, - provider: model.provider, - baseURL: model.baseURL, - apiKey: model.apiKey, - model: model.model, - }, - ], - defaultSource: SECTION_SOURCE_ID, + sourceOfferingIds: [pinnedOfferingId], + defaultSourceOfferingId: pinnedOfferingId, }, user.cookies, ); diff --git a/scripts/e2e/greeting-delivery.test.ts b/scripts/e2e/greeting-delivery.test.ts index 0ac6e26bb2..fd67ec86dd 100644 --- a/scripts/e2e/greeting-delivery.test.ts +++ b/scripts/e2e/greeting-delivery.test.ts @@ -284,23 +284,12 @@ describe.skipIf(databaseUrl === undefined)( principalId: tenant.principalId, domain: tenant.tenantDomain, }, - model: - USE_OLLAMA && OLLAMA_BASE_URL !== undefined - ? await modelSourceFor( - hubApi, - user.cookies, - tenant.tenantId, - CONNECT_PROVIDER, - CONNECT_API_KEY, - OLLAMA_BASE_URL, - ) - : await modelSourceFor( - hubApi, - user.cookies, - tenant.tenantId, - CONNECT_PROVIDER, - CONNECT_API_KEY, - ), + model: await modelSourceFor( + hubApi, + user.cookies, + tenant.tenantId, + CONNECT_PROVIDER, + ), pushWorkflow, log: () => undefined, workflows: DEFAULT_WORKFLOWS, diff --git a/scripts/e2e/harness.test.ts b/scripts/e2e/harness.test.ts index 5b4d148005..37f4f93e4e 100644 --- a/scripts/e2e/harness.test.ts +++ b/scripts/e2e/harness.test.ts @@ -11,6 +11,7 @@ import { createCleanupHarness, parseEnvFileDatabaseUrl, runCleanups, + workflowDeployBody, type SpawnedApp, } from "./harness.ts"; @@ -67,6 +68,29 @@ describe("createCleanupHarness", () => { }); }); +describe("workflowDeployBody", () => { + test("carries catalog offering ids, never a raw provider/apiKey source", () => { + const body = workflowDeployBody({ + assetId: "asset-1", + commitSha: "deadbeef", + sourceOfferingIds: ["offering-1", "offering-2"], + defaultSourceOfferingId: "offering-1", + }); + expect(body).toEqual({ + source: { + kind: "asset", + assetId: "asset-1", + package: { format: "source", commitSha: "deadbeef" }, + }, + entry: (body as { entry: string }).entry, + sourceOfferingIds: ["offering-1", "offering-2"], + defaultSourceOfferingId: "offering-1", + }); + expect(body).not.toHaveProperty("sources"); + expect(body).not.toHaveProperty("defaultSource"); + }); +}); + describe("parseEnvFileDatabaseUrl", () => { test("reads DATABASE_URL and ignores comments and blanks", () => { expect( diff --git a/scripts/e2e/harness.ts b/scripts/e2e/harness.ts index 099a7a2109..39aa8393c2 100644 --- a/scripts/e2e/harness.ts +++ b/scripts/e2e/harness.ts @@ -653,16 +653,18 @@ export async function pushWorkflowSource(options: { /** * The deploy body a code-sourced asset deployment takes: the pushed * commit is the definition's pin, and the entry names the - * `interchange.workflow` module the sidecar evaluates. + * `interchange.workflow` module the sidecar evaluates. The native route + * resolves inference against the tenant's own catalog, so the caller + * supplies the ordered catalog offering ids to deploy against rather + * than a raw provider/baseURL/apiKey triple — see + * `@corbits/seeding`'s `ensureNoopCatalogOffering` for the zero-cost way + * to obtain one. */ export function workflowDeployBody(options: { assetId: string; commitSha: string; - sourceId: string; - provider: string; - baseURL: string; - apiKey: string; - model: string; + sourceOfferingIds: string[]; + defaultSourceOfferingId: string; }): Record { return { source: { @@ -671,15 +673,7 @@ export function workflowDeployBody(options: { package: { format: "source", commitSha: options.commitSha }, }, entry: WORKFLOW_SOURCE_ENTRY, - sources: [ - { - id: options.sourceId, - provider: options.provider, - baseURL: options.baseURL, - apiKey: options.apiKey, - model: options.model, - }, - ], - defaultSource: options.sourceId, + sourceOfferingIds: options.sourceOfferingIds, + defaultSourceOfferingId: options.defaultSourceOfferingId, }; } diff --git a/scripts/e2e/heartbeat.test.ts b/scripts/e2e/heartbeat.test.ts index c19a6b6a2c..668ed3b024 100644 --- a/scripts/e2e/heartbeat.test.ts +++ b/scripts/e2e/heartbeat.test.ts @@ -19,6 +19,7 @@ import { buildHeartbeatWorkflow, serializeHeartbeatWorkflow, } from "../../workflows/heartbeat/src/index.ts"; +import { ensureNoopCatalogOffering } from "../../packages/seeding/src/index.ts"; import { api, createCleanupHarness, @@ -203,16 +204,23 @@ describe.skipIf(databaseUrl === undefined)("heartbeat workflow", () => { // whole point of this suite: a run started against this source // actually completes an inference call, at zero cost, because // noop-inference answers it locally without reaching a real model. + const offeringId = await hop("noop catalog seeding", () => + ensureNoopCatalogOffering( + (method, path, body, cookies) => + api(hub.baseUrl, method, path, body, cookies), + user.cookies, + tenantId, + hub.baseUrl, + () => {}, + ), + ); + const deploymentId = await hop("workflow deploy", async () => { - const sourceId = "src-heartbeat-e2e"; const body = workflowDeployBody({ assetId, commitSha, - sourceId: sourceId, - provider: "anthropic", - baseURL: `${hub.baseUrl}/api/chat/noop-inference`, - apiKey: "noop", - model: "noop", + sourceOfferingIds: [offeringId], + defaultSourceOfferingId: offeringId, }); const deadline = Date.now() + 60_000; let res: ApiResult; diff --git a/scripts/e2e/local-rip.test.ts b/scripts/e2e/local-rip.test.ts index 2b4f4ec9c5..601147cc6e 100644 --- a/scripts/e2e/local-rip.test.ts +++ b/scripts/e2e/local-rip.test.ts @@ -361,7 +361,6 @@ describe.skipIf(databaseUrl === undefined)( user.cookies, tenant.tenantId, "anthropic", - STUB_API_KEY, ), pushWorkflow, log: () => undefined, diff --git a/scripts/e2e/play.ts b/scripts/e2e/play.ts index f3d34b94d3..16fa053911 100644 --- a/scripts/e2e/play.ts +++ b/scripts/e2e/play.ts @@ -299,23 +299,12 @@ async function main(): Promise { principalId: tenant.principalId, domain: tenant.tenantDomain, }, - model: - USE_OLLAMA && OLLAMA_BASE_URL !== undefined - ? await modelSourceFor( - hubApi, - user.cookies, - tenant.tenantId, - CONNECT_PROVIDER, - CONNECT_API_KEY, - OLLAMA_BASE_URL, - ) - : await modelSourceFor( - hubApi, - user.cookies, - tenant.tenantId, - CONNECT_PROVIDER, - CONNECT_API_KEY, - ), + model: await modelSourceFor( + hubApi, + user.cookies, + tenant.tenantId, + CONNECT_PROVIDER, + ), pushWorkflow, log: () => undefined, workflows: DEFAULT_WORKFLOWS, diff --git a/scripts/e2e/smoke-webhook.test.ts b/scripts/e2e/smoke-webhook.test.ts index 1d248defaf..66a258c601 100644 --- a/scripts/e2e/smoke-webhook.test.ts +++ b/scripts/e2e/smoke-webhook.test.ts @@ -28,9 +28,9 @@ import { buildHeartbeatWorkflow, serializeHeartbeatWorkflow, } from "../../workflows/heartbeat/src/index.ts"; +import { ensureNoopCatalogOffering } from "../../packages/seeding/src/index.ts"; import { api, - assertNeverRealProvider, connectE2eDb, createCleanupHarness, e2eDatabaseUrl, @@ -138,77 +138,17 @@ describe.skipIf(databaseUrl === undefined)("smoke: webhook trigger", () => { // The zero-cost catalog chain: an anthropic-plugin provider whose // base URL is the hub's own noop-inference endpoint, never a real - // model. Mirrors `workbench-digest.test.ts`'s noop catalog seeding. - const noopBaseUrl = `${hub.baseUrl}/api/chat/noop-inference`; - assertNeverRealProvider(noopBaseUrl, "noop catalog provider baseURL"); - await hop("noop catalog seeding", async () => { - const model = await api( - hub.baseUrl, - "POST", - `/api/tenants/${tenantId}/catalog/models`, - { canonicalName: "noop" }, - cookies, - ); - expectStatus("create catalog model", model, 201); - const modelId = stringField(model.data, "id", "create catalog model"); - - const provider = await api( - hub.baseUrl, - "POST", - `/api/tenants/${tenantId}/providers`, - { name: "anthropic", plugin: "anthropic" }, - cookies, - ); - expectStatus("create provider", provider, 201); - const providerId = stringField(provider.data, "id", "create provider"); - - const credential = await api( - hub.baseUrl, - "POST", - `/api/tenants/${tenantId}/credentials`, - { - providerId, - name: "anthropic-default", - type: "api_key", - secret: "noop", - }, - cookies, - ); - expectStatus("create credential", credential, 201); - const credentialId = stringField( - credential.data, - "id", - "create credential", - ); - - const catalogProvider = await api( - hub.baseUrl, - "POST", - `/api/tenants/${tenantId}/catalog/providers`, - { - name: "anthropic", - plugin: "anthropic", - baseURL: noopBaseUrl, - credentialId, - }, + // model. + const offeringId = await hop("noop catalog seeding", () => + ensureNoopCatalogOffering( + (method, path, body, cookies2) => + api(hub.baseUrl, method, path, body, cookies2), cookies, - ); - expectStatus("create catalog provider", catalogProvider, 201); - const catalogProviderId = stringField( - catalogProvider.data, - "id", - "create catalog provider", - ); - - const offering = await api( + tenantId, hub.baseUrl, - "POST", - `/api/tenants/${tenantId}/catalog/offerings`, - { modelId, providerId: catalogProviderId }, - cookies, - ); - expectStatus("create catalog offering", offering, 201); - }); + () => {}, + ), + ); const assetName = "heartbeat"; const { assetId, commitSha } = await hop( @@ -256,16 +196,11 @@ describe.skipIf(databaseUrl === undefined)("smoke: webhook trigger", () => { ); const definitionId = await hop("workflow deploy", async () => { - const sourceId = "src-smoke-webhook-e2e"; - assertNeverRealProvider(noopBaseUrl, "workflow deploy source baseURL"); const body = workflowDeployBody({ assetId, commitSha, - sourceId: sourceId, - provider: "anthropic", - baseURL: noopBaseUrl, - apiKey: "noop", - model: "noop", + sourceOfferingIds: [offeringId], + defaultSourceOfferingId: offeringId, }); const deadline = Date.now() + 60_000; for (;;) { diff --git a/scripts/e2e/walking-skeleton.test.ts b/scripts/e2e/walking-skeleton.test.ts index 833612dd67..4713eba043 100644 --- a/scripts/e2e/walking-skeleton.test.ts +++ b/scripts/e2e/walking-skeleton.test.ts @@ -28,6 +28,7 @@ import { } from "../../workflows/echo/src/index.ts"; import { api, + assertNeverRealProvider, createCleanupHarness, e2eDatabaseUrl, expectStatus, @@ -61,6 +62,93 @@ function stringField(data: unknown, field: string, what: string): string { ); } +/** + * Plants the same catalog-model/provider/credential/offering chain as + * `@corbits/seeding`'s `ensureNoopCatalogOffering`, but pointed at an + * unreachable placeholder host rather than the hub's own + * `noop-inference` endpoint — deliberately, since this suite never + * calls inference and asserts only that the deploy is + * address-reachable (see the module comment above). + */ +async function seedPlaceholderCatalogOffering(options: { + call: ( + method: string, + path: string, + body?: unknown, + cookies?: string[], + ) => Promise; + tenantId: string; + cookies: string[]; + placeholderBaseUrl: string; +}): Promise<{ offeringId: string }> { + assertNeverRealProvider( + options.placeholderBaseUrl, + "placeholder catalog provider baseURL", + ); + const { call, tenantId, cookies } = options; + + const model = await call( + "POST", + `/api/tenants/${tenantId}/catalog/models`, + { canonicalName: "noop" }, + cookies, + ); + expectStatus("create catalog model", model, 201); + const modelId = stringField(model.data, "id", "create catalog model"); + + const provider = await call( + "POST", + `/api/tenants/${tenantId}/providers`, + { name: "anthropic", plugin: "anthropic" }, + cookies, + ); + expectStatus("create provider", provider, 201); + const providerId = stringField(provider.data, "id", "create provider"); + + const credential = await call( + "POST", + `/api/tenants/${tenantId}/credentials`, + { + providerId, + name: "anthropic-default", + type: "api_key", + secret: "noop", + }, + cookies, + ); + expectStatus("create credential", credential, 201); + const credentialId = stringField(credential.data, "id", "create credential"); + + const catalogProvider = await call( + "POST", + `/api/tenants/${tenantId}/catalog/providers`, + { + name: "anthropic", + plugin: "anthropic", + baseURL: options.placeholderBaseUrl, + credentialId, + }, + cookies, + ); + expectStatus("create catalog provider", catalogProvider, 201); + const catalogProviderId = stringField( + catalogProvider.data, + "id", + "create catalog provider", + ); + + const offering = await call( + "POST", + `/api/tenants/${tenantId}/catalog/offerings`, + { modelId, providerId: catalogProviderId }, + cookies, + ); + expectStatus("create catalog offering", offering, 201); + return { + offeringId: stringField(offering.data, "id", "create catalog offering"), + }; +} + const { tempDir, track } = createCleanupHarness(); describe.skipIf(databaseUrl === undefined)("walking skeleton", () => { @@ -199,20 +287,30 @@ describe.skipIf(databaseUrl === undefined)("walking skeleton", () => { }, ); + // Hop: noop catalog seeding. The native deploy resolves inference + // against a real catalog offering; this suite's chosen offering + // still names an unreachable placeholder host, since deployment + // never calls inference and full run-completion is not asserted. + const { offeringId } = await hop("noop catalog seeding", () => + seedPlaceholderCatalogOffering({ + call: (method, path, body, cookies) => + api(hub.baseUrl, method, path, body, cookies), + tenantId, + cookies: user.cookies, + placeholderBaseUrl: "https://inference.invalid", + }), + ); + // Hop: workflow deploy via the native deploy API. Retries while // the hub still answers 502 (the sidecar's dial-in may not have // completed yet); any other failure is final. The inference // source is a placeholder — deployment does not call inference. const deploymentId = await hop("workflow deploy", async () => { - const sourceId = "src-echo-e2e"; const body = workflowDeployBody({ assetId, commitSha, - sourceId: sourceId, - provider: "anthropic", - baseURL: "https://inference.invalid", - apiKey: "e2e-placeholder", - model: "claude-sonnet-5", + sourceOfferingIds: [offeringId], + defaultSourceOfferingId: offeringId, }); const deadline = Date.now() + 60_000; let res: ApiResult; diff --git a/scripts/e2e/workbench-digest.test.ts b/scripts/e2e/workbench-digest.test.ts index ecf2dc56eb..b560c98a29 100644 --- a/scripts/e2e/workbench-digest.test.ts +++ b/scripts/e2e/workbench-digest.test.ts @@ -19,6 +19,7 @@ import { buildWorkbenchDigestWorkflow, serializeWorkbenchDigestWorkflow, } from "../../workflows/workbench-digest/src/index.ts"; +import { ensureNoopCatalogOffering } from "../../packages/seeding/src/index.ts"; import { api, createCleanupHarness, @@ -207,16 +208,23 @@ describe.skipIf(databaseUrl === undefined)("workbench-digest workflow", () => { // whole point of this suite: a run started against this source // actually completes an inference call, at zero cost, because // noop-inference answers it locally without reaching a real model. + const offeringId = await hop("noop catalog seeding", () => + ensureNoopCatalogOffering( + (method, path, body, cookies) => + api(hub.baseUrl, method, path, body, cookies), + user.cookies, + tenantId, + hub.baseUrl, + () => {}, + ), + ); + const deploymentId = await hop("workflow deploy", async () => { - const sourceId = "src-workbench-digest-e2e"; const body = workflowDeployBody({ assetId, commitSha, - sourceId: sourceId, - provider: "anthropic", - baseURL: `${hub.baseUrl}/api/chat/noop-inference`, - apiKey: "noop", - model: "noop", + sourceOfferingIds: [offeringId], + defaultSourceOfferingId: offeringId, }); const deadline = Date.now() + 60_000; let res: ApiResult;