Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 });
});
});
38 changes: 26 additions & 12 deletions packages/longevity-sim/src/stack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
async function seedCatalogChain(
target: InferenceTarget,
): Promise<{ offeringId: string }> {
const model = await api(
hub.baseUrl,
"POST",
Expand Down Expand Up @@ -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<string, string>();
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 ?? []) {
Expand Down Expand Up @@ -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()) {
Expand All @@ -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,
);
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
);
Expand Down
Loading
Loading