Skip to content
8 changes: 5 additions & 3 deletions apps/hub/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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;
};

Expand Down Expand Up @@ -624,7 +627,6 @@ function seedModelFrom(parsed: ParsedHubEnv): ModelSource | undefined {
return {
provider: SEED_MODEL_PROVIDER,
model: SEED_MODEL,
baseURL: SEED_MODEL_BASE_URL,
apiKey,
};
}
Expand Down
16 changes: 16 additions & 0 deletions apps/hub/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,7 @@ import {
applyInferenceCatalogMigrations,
createBenchModelPolicyRoutes,
createPostgresBenchModelPolicyStore,
createResolvedOfferingsRoutes,
createWorkflowCatalogRoutes,
} from "@corbits/inference-catalog";
import { generateId } from "@intx/hub-common";
Expand Down Expand Up @@ -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.
Expand Down
25 changes: 19 additions & 6 deletions apps/hub/src/system-seed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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,
}
);
Expand All @@ -60,7 +73,7 @@ export type SystemSeedDeps = {
baseUrl: string;
orgSlug: string;
admin: { email: string; password: string };
seedModel?: ModelSource;
seedModel?: SeedModelConfig;
deadlineMs?: number;
pollIntervalMs?: number;
};
Expand Down Expand Up @@ -116,7 +129,7 @@ export async function runSystemSeed(deps: SystemSeedDeps): Promise<void> {
principalId: tenant.principalId,
domain: tenant.tenantDomain,
},
model,
model: { provider: model.provider, model: model.model },
pushWorkflow,
log: (line) => log.info`${line}`,
workflows: DEFAULT_WORKFLOWS,
Expand Down
1 change: 0 additions & 1 deletion apps/hub/test/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
});
});
Expand Down
4 changes: 4 additions & 0 deletions packages/inference-catalog/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,10 @@ export {
createBenchModelPolicyRoutes,
type CreateBenchModelPolicyRoutesDeps,
} from "./routes";
export {
createResolvedOfferingsRoutes,
type CreateResolvedOfferingsRoutesDeps,
} from "./resolved-offerings-routes";
export {
createWorkflowCatalogRoutes,
estimateUsd,
Expand Down
44 changes: 44 additions & 0 deletions packages/inference-catalog/src/resolved-offerings-routes.ts
Original file line number Diff line number Diff line change
@@ -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 ResolvedOffering[]>;
readonly requireGrant: RequireGrant;
};

export function createResolvedOfferingsRoutes(
deps: CreateResolvedOfferingsRoutesDeps,
): Hono<TenantEnv> {
const app = new Hono<TenantEnv>();

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;
}
Original file line number Diff line number Diff line change
@@ -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<readonly ResolvedOffering[]>,
allow = true,
) {
const host = new Hono<TenantEnv>();
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 });
});
});
48 changes: 12 additions & 36 deletions packages/onboarding/src/complete-credential.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -280,13 +277,8 @@ async function resolveOllamaModelSource(
api: ApiCall,
cookies: string[],
tenantId: string,
apiKey: string,
baseURLOverride: string | undefined,
): Promise<ModelSource> {
const catalogSeed = CATALOG_SEEDS.ollama;
const baseURL = ollamaOpenAICompatBaseURL(
baseURLOverride ?? catalogSeed.provider.baseURL,
);

const response = await api(
"GET",
Expand Down Expand Up @@ -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<ModelSource> {
if (provider === "ollama") {
return resolveOllamaModelSource(
api,
cookies,
tenantId,
apiKey,
baseURLOverride,
);
return resolveOllamaModelSource(api, cookies, tenantId);
}

const catalogSeed = CATALOG_SEEDS[provider];
Expand All @@ -413,8 +393,6 @@ export async function modelSourceFor(
return {
provider: catalogSeed.provider.plugin,
model: defaultModel.canonicalName,
baseURL: catalogSeed.provider.baseURL,
apiKey,
};
}

Expand Down Expand Up @@ -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,
Expand Down
Loading
Loading