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
11 changes: 7 additions & 4 deletions docs/seed-reconciliation.md
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,10 @@ A genuine redeploy is the only honest repair.
`apps/hub/src/env-credential-plant.ts` delegates to
`plantEnvProviderCredentials` (`packages/onboarding`): keyed by the
provider's stable credential name, a provider already carrying an
active credential is skipped outright — a rotated or hand-renamed key
is never touched. Removing an env var never deletes the planted
credential: credentials are operator data once planted, not seeds to
garbage-collect.
active credential is not probed and its key is not overwritten — a
rotated or hand-renamed key is never touched. `seedCatalog` still
runs against that existing credential (`existingCredentialId`, no
`apiKey`) so a hub restart backfills newly curated models additively:
missing rows are planted, existing ones 409-skip, nothing is deleted.
Removing an env var never deletes the planted credential: credentials
are operator data once planted, not seeds to garbage-collect.
13 changes: 11 additions & 2 deletions packages/hub-client/src/catalog-seed-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,9 @@ export type CatalogProviderSpec = {

export type CatalogProviderSeed = {
readonly provider: CatalogProviderSpec;
/** 2-4 sensible defaults: enough to make the model picker useful,
* never the provider's entire model list. */
/** Curated defaults for the model picker — enough to be useful, never the
* provider's entire list. Anthropic ships six; other providers typically
* stay in a small 2–5 band. */
readonly models: readonly CatalogModelSpec[];
};

Expand All @@ -55,6 +56,14 @@ export const CATALOG_SEEDS: Readonly<
},
models: [
{ canonicalName: "claude-sonnet-5", displayName: "Claude Sonnet 5" },
{ canonicalName: "claude-opus-5", displayName: "Claude Opus 5" },
{ canonicalName: "claude-opus-4-8", displayName: "Claude Opus 4.8" },
{
canonicalName: "claude-haiku-4-5-20251001",
displayName: "Claude Haiku 4.5",
},
{ canonicalName: "claude-fable-5", displayName: "Claude Fable 5" },
{ canonicalName: "claude-sonnet-4-6", displayName: "Claude Sonnet 4.6" },
],
},
openai: {
Expand Down
32 changes: 28 additions & 4 deletions packages/hub-client/test/catalog-seed-data.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
// CATALOG_SEEDS is pure data consumed by seedCatalog (seed.ts) to give
// every supported credential provider a browsable, launchable model
// catalog. These tests guard the shape every entry must hold — one seed
// per SupportedCredentialProvider, 2-4 curated models, and a provider
// spec whose adapter plugin matches credential-test.ts's own mapping —
// so a newly added provider (like xAI) can't silently drift out of sync.
// per SupportedCredentialProvider, a small curated model set (Anthropic
// has six; others typically 2–5), and a provider spec whose adapter
// plugin matches credential-test.ts's own mapping — so a newly added
// provider (like xAI) can't silently drift out of sync.
import { describe, expect, test } from "bun:test";

import {
Expand All @@ -24,11 +25,14 @@ describe("CATALOG_SEEDS", () => {
expect(seededProviders).toEqual(supportedProviders);
});

test("every seed lists between 2 and 4 curated models", () => {
test("every non-exception seed stays inside the generic curated-size bound", () => {
for (const [provider, seed] of Object.entries(CATALOG_SEEDS) as [
SupportedCredentialProvider,
(typeof CATALOG_SEEDS)[SupportedCredentialProvider],
][]) {
// Anthropic is the curated six-model set (Sonnet 5 first); openai and
// google-genai keep their smaller curated lists. Everyone else stays
// inside the generic 2–5 bound.
if (provider === "anthropic" || provider === "openai") continue;
if (provider === "google-genai") continue;
expect(seed.models.length).toBeGreaterThanOrEqual(2);
Expand All @@ -43,6 +47,26 @@ describe("CATALOG_SEEDS", () => {
}
});

test("anthropic seeds Claude models behind the anthropic adapter", () => {
const seed = CATALOG_SEEDS.anthropic;
expect(seed.provider).toEqual({
name: "anthropic",
plugin: "anthropic",
baseURL: "https://api.anthropic.com",
});
expect(seed.models.map((m) => m.canonicalName)).toEqual([
"claude-sonnet-5",
"claude-opus-5",
"claude-opus-4-8",
"claude-haiku-4-5-20251001",
"claude-fable-5",
"claude-sonnet-4-6",
]);
for (const model of seed.models) {
expect(model.displayName.length).toBeGreaterThan(0);
}
});

test("xai seeds Grok models behind the openai-compatible adapter", () => {
const seed = CATALOG_SEEDS.xai;
expect(seed.provider).toEqual({
Expand Down
61 changes: 50 additions & 11 deletions packages/hub-client/test/seed.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1461,6 +1461,8 @@ describe("seedCatalog", () => {

test("fresh run creates the full provider-to-offering chain", async () => {
const { lines, log } = collector();
const modelPosts: string[] = [];
const offeringPosts: { modelId: string; providerId: string }[] = [];
const handler: FakeHandler = (method, path, body) => {
if (method === "POST" && path === `/api/tenants/${TENANT_ID}/providers`)
return { status: 201, data: providerRow("prv_1", "anthropic") };
Expand All @@ -1472,11 +1474,14 @@ describe("seedCatalog", () => {
if (
method === "POST" &&
path === `/api/tenants/${TENANT_ID}/catalog/models`
)
) {
const canonicalName = (body as { canonicalName: string }).canonicalName;
modelPosts.push(canonicalName);
return {
status: 201,
data: catalogModelRow("mdl_1", "claude-sonnet-5"),
data: catalogModelRow(`mdl_${modelPosts.length}`, canonicalName),
};
}
if (
method === "POST" &&
path === `/api/tenants/${TENANT_ID}/catalog/providers`
Expand All @@ -1489,25 +1494,30 @@ describe("seedCatalog", () => {
method === "POST" &&
path === `/api/tenants/${TENANT_ID}/catalog/offerings`
) {
// Anthropic Direct's claude-sonnet-5 is a probed deployment in the
// pinned catalog, so the offering is created carrying what that
// probe observed rather than an empty capability list.
// Every Anthropic Direct model in the curated six is an
// exact-deployment probe in the pinned catalog, so each offering
// carries what that probe observed rather than an empty list.
const offeringBody = body as {
modelId: string;
providerId: string;
priority: number;
capabilities: string[];
};
expect(offeringBody.modelId).toBe("mdl_1");
expect(offeringBody.providerId).toBe("cpv_1");
expect(offeringBody.priority).toBe(0);
expect(offeringBody.capabilities.length).toBeGreaterThan(0);
expect(offeringBody.capabilities).toContain("plain-text");
expect(offeringBody.capabilities).toContain(
"function-calling-multi-turn",
);
offeringPosts.push(offeringBody);
return {
status: 201,
data: catalogOfferingRow("off_1", "mdl_1", "cpv_1"),
data: catalogOfferingRow(
`off_${offeringPosts.length}`,
offeringBody.modelId,
offeringBody.providerId,
),
};
}
return undefined;
Expand All @@ -1521,13 +1531,32 @@ describe("seedCatalog", () => {
log,
});

expect(modelPosts).toEqual([
"claude-sonnet-5",
"claude-opus-5",
"claude-opus-4-8",
"claude-haiku-4-5-20251001",
"claude-fable-5",
"claude-sonnet-4-6",
]);
expect(offeringPosts.map((o) => o.modelId)).toEqual([
"mdl_1",
"mdl_2",
"mdl_3",
"mdl_4",
"mdl_5",
"mdl_6",
]);

const output = lines.join("\n");
expect(output).toContain("created provider anthropic");
expect(output).toContain("created credential anthropic-default");
expect(output).toContain("created catalog model claude-sonnet-5");
expect(output).toContain("created catalog provider anthropic");
expect(output).toContain("created catalog offering");
expect(output).toContain("catalog ready: anthropic/claude-sonnet-5");
expect(output).toContain(
"catalog ready: anthropic/claude-sonnet-5, claude-opus-5, claude-opus-4-8, claude-haiku-4-5-20251001, claude-fable-5, claude-sonnet-4-6",
);
});

test("an Ollama offering's quirks carry that model's real context-window ceiling, not the built-in 4096 default", async () => {
Expand Down Expand Up @@ -1982,6 +2011,14 @@ describe("seedCatalog", () => {
let modelPosts = 0;
let catalogProviderPosts = 0;
let offeringPosts = 0;
const anthropicModels = [
"claude-sonnet-5",
"claude-opus-5",
"claude-opus-4-8",
"claude-haiku-4-5-20251001",
"claude-fable-5",
"claude-sonnet-4-6",
];
const handler: FakeHandler = (method, path) => {
if (method === "POST" && path === `/api/tenants/${TENANT_ID}/providers`) {
providerPosts += 1;
Expand Down Expand Up @@ -2024,7 +2061,9 @@ describe("seedCatalog", () => {
return {
status: 200,
data: {
data: [catalogModelRow("mdl_1", "claude-sonnet-5")],
data: anthropicModels.map((name, index) =>
catalogModelRow(`mdl_${index + 1}`, name),
),
nextCursor: null,
},
};
Expand Down Expand Up @@ -2066,9 +2105,9 @@ describe("seedCatalog", () => {

expect(providerPosts).toBe(1);
expect(credentialPosts).toBe(1);
expect(modelPosts).toBe(1);
expect(modelPosts).toBe(6);
expect(catalogProviderPosts).toBe(1);
expect(offeringPosts).toBe(1);
expect(offeringPosts).toBe(6);

const output = lines.join("\n");
expect(output).toContain("provider anthropic already exists (skipped)");
Expand Down
97 changes: 57 additions & 40 deletions packages/onboarding/src/plant-env-credentials.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,9 @@
// `@workbench/hub-client` functions `completeCredentialSetup` calls),
// reused here exactly as onboarding's own guided step uses them. This
// module's only job is the env-map-to-provider translation, the
// idempotency check that skips a provider already carrying a working
// credential (never overwriting a rotated or renamed key), and folding
// idempotency check that skips overwriting a provider already carrying
// a working credential (never rotating a renamed key), backfills that
// provider's curated catalog additively on every hub boot, and folding
// a single provider's failure into a log line instead of an exception —
// one bad or rate-limited key must never stop every other provider from
// planting, and must never stop the hub itself from starting.
Expand Down Expand Up @@ -159,7 +160,7 @@ async function findActiveCredential(
cookies: string[],
tenantId: string,
provider: SupportedCredentialProvider,
): Promise<boolean> {
): Promise<{ id: string } | undefined> {
const name = inferenceCredentialName(provider);
let cursor: string | undefined;
do {
Expand All @@ -173,21 +174,26 @@ async function findActiveCredential(
listed.data,
"credentials response",
);
if (page.data.some((c) => c.name === name && c.status === "active"))
return true;
const match = page.data.find(
(c) => c.name === name && c.status === "active",
);
if (match !== undefined) return { id: match.id };
cursor = page.nextCursor ?? undefined;
} while (cursor !== undefined);
return false;
return undefined;
}

/**
* Plants a credential (and its curated catalog) for every provider
* present in `envProviderKeys`, at the given tenant, idempotently:
*
* - A provider already carrying an active credential of that name is
* skipped outright — no live probe, no `seedCatalog` call — so an
* already-rotated or hand-renamed key is never touched, and a hub
* restart never re-probes a provider that already works.
* not probed and its key is not overwritten — an already-rotated or
* hand-renamed key stays put. `seedCatalog` still runs against that
* existing credential (`existingCredentialId`, no `apiKey`) so a
* hub restart backfills newly curated models additively. `seedCatalog`
* is ensure-then-create: missing rows are planted, existing ones
* 409-skip, nothing is deleted.
* - A provider with no existing credential is proven with a real,
* free call (`testProviderCredential`) before anything is persisted;
* a failed probe is reported and skipped, never thrown, so one bad
Expand All @@ -201,17 +207,39 @@ async function findActiveCredential(
* proven key, and this is reported honestly as `"blocked"` — never
* logged as planted.
*
* Calling this twice with the same env plants nothing the second time:
* every provider it planted on the first call now has an active
* credential, so the second call's per-provider check short-circuits
* to "skipped" before any probe or plant runs.
* Calling this twice with the same env plants the credential once: the
* second call's per-provider check skips probe and key write, then
* backfills the curated catalog against the already-active row.
*/
export async function plantEnvProviderCredentials(
args: PlantEnvProviderCredentialsArgs,
): Promise<PlantEnvProviderCredentialsOutcome[]> {
const testCredential = args.testCredential ?? testProviderCredential;
const runSeedCatalog = args.seedCatalogFn ?? seedCatalog;
const outcomes: PlantEnvProviderCredentialsOutcome[] = [];
const suppressedLog = () => {
// seedCatalog's own step-by-step log is suppressed here: this
// module reports exactly one summary line per provider below,
// never the per-row created/skipped detail seedCatalog logs for
// its other callers (`workbench seed`, the guided step).
};

function catalogSeedArgs(
provider: SupportedCredentialProvider,
extra:
{ readonly apiKey: string } | { readonly existingCredentialId: string },
): SeedCatalogArgs {
const baseURL = args.envProviderBaseUrls?.[provider];
return {
api: args.api,
cookies: args.cookies,
tenantId: args.tenantId,
provider,
log: suppressedLog,
...extra,
...(baseURL !== undefined ? { baseURLOverride: baseURL } : {}),
};
}

for (const [provider, apiKey] of Object.entries(args.envProviderKeys) as [
SupportedCredentialProvider,
Expand All @@ -225,8 +253,22 @@ export async function plantEnvProviderCredentials(
);
if (alreadyActive) {
const name = inferenceCredentialName(provider);
try {
await runSeedCatalog(
catalogSeedArgs(provider, {
existingCredentialId: alreadyActive.id,
}),
);
} catch (cause) {
const message = cause instanceof Error ? cause.message : String(cause);
args.log(
`env credential plant: ${provider} failed to backfill catalog: ${message}`,
);
outcomes.push({ provider, status: "failed", message });
continue;
}
args.log(
`env credential plant: ${provider} already has an active credential named ${name} (skipped) — the env key was not planted; rotate the existing ${name} credential in Plugins if you meant to replace it`,
`env credential plant: ${provider} already has an active credential named ${name} (skipped) — the env key was not planted; rotate the existing ${name} credential in Plugins if you meant to replace it. Curated catalog models were backfilled additively.`,
);
outcomes.push({ provider, status: "skipped" });
continue;
Expand All @@ -245,33 +287,8 @@ export async function plantEnvProviderCredentials(
continue;
}

const suppressedLog = () => {
// seedCatalog's own step-by-step log is suppressed here: this
// module reports exactly one summary line per provider below,
// never the per-row created/skipped detail seedCatalog logs for
// its other callers (`workbench seed`, the guided step).
};
const seedCatalogArgs: SeedCatalogArgs =
baseURL !== undefined
? {
api: args.api,
cookies: args.cookies,
tenantId: args.tenantId,
provider,
apiKey,
baseURLOverride: baseURL,
log: suppressedLog,
}
: {
api: args.api,
cookies: args.cookies,
tenantId: args.tenantId,
provider,
apiKey,
log: suppressedLog,
};
try {
await runSeedCatalog(seedCatalogArgs);
await runSeedCatalog(catalogSeedArgs(provider, { apiKey }));
} catch (cause) {
const message = cause instanceof Error ? cause.message : String(cause);
args.log(`env credential plant: ${provider} failed to plant: ${message}`);
Expand Down
Loading
Loading