Skip to content
Closed
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
6 changes: 5 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,11 @@ HUB_STATIC_DIR=../web/dist
# against themselves; running either (or both, or a hub restart) any
# number of times plants the credential once.
# ANTHROPIC_API_KEY=
# With ANTHROPIC_API_KEY set, this chooses the curated Anthropic model that
# boot-time workflows and Settings prefer. Setting it without the key is a
# boot-time error. Seed configuration is authoritative: restarting/reseeding
# restores this model to the first offering after a manual priority reorder.
# ANTHROPIC_MODEL=claude-sonnet-5

# Every other curated provider's key, read the same way and auto-planted
# the same way at hub start — set any subset of these, or none. See
Expand Down Expand Up @@ -350,4 +355,3 @@ ALLOW_UNVERIFIED_EMAILS=1
# the same manifest. Example replacing the default with a custom adapter
# for the "anthropic" provider key:
# SIDECAR_ADAPTER_MANIFEST=[{"provider":"anthropic","specifier":"@acme/custom-anthropic-adapter","export":"createCustomAdapter"}]

4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,10 @@ re-seeds idempotently. `ANTHROPIC_API_KEY` is the one optional line
worth setting before boot — with it, seeding plants a real credential
and the catalog is actually launchable; without it, everything above
still runs, but inference errors until you set it and restart the hub.
Alongside that key, `ANTHROPIC_MODEL` optionally selects which curated
Anthropic model the seeded workflows use and Settings shows first; it defaults to
`claude-sonnet-5`. The boot seed is authoritative, so a restart restores
that configured model after a manual catalog reorder.

Leaving `ANTHROPIC_API_KEY` unset doesn't just apply to the administrator
account: anyone who signs up gets a personal bench with no default routines
Expand Down
38 changes: 34 additions & 4 deletions apps/hub/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,15 @@
// Anything else the hub learns is data in the database, never
// configuration.
//
// ANTHROPIC_API_KEY is the one model-related variable a freshly
// ANTHROPIC_API_KEY is the one credential variable a freshly
// self-served personal bench needs: when set, the hub carries a seed
// model credential (anthropic/claude-sonnet-5) it hands to
// model credential it hands to
// `@workbench/onboarding` so that bench gets the default workflow set
// deployed at first login. Left unset, that deployment step is skipped
// — the bench is still provisioned, only the default workflow
// deployment is skipped, and the skip is logged.
// ANTHROPIC_MODEL optionally selects which curated Anthropic model that
// seed uses; unset keeps claude-sonnet-5 as the product default.
//
// ANTHROPIC_API_KEY and every other curated provider's conventional key
// (`@workbench/onboarding`'s `PROVIDER_ENV_VARS` — OPENAI_API_KEY,
Expand Down Expand Up @@ -45,6 +47,7 @@ import {
envProviderKeysFrom,
} from "@workbench/onboarding";
import type { SupportedCredentialProvider } from "@corbits/connections/credential-test";
import { CATALOG_SEEDS } from "@corbits/seeding";

const HTTP_URL = /^https?:\/\/.+$/;

Expand Down Expand Up @@ -109,6 +112,9 @@ const HubEnv = type({
"ANTHROPIC_API_KEY?": type("string > 0").describe(
"your Anthropic API key; optional, enables the default workflow set for freshly self-served benches, and auto-plants a probed catalog credential on the operator bench at hub start",
),
"ANTHROPIC_MODEL?": type("string > 0").describe(
"the curated Anthropic model to prefer for seeded workflows and catalog resolution; optional, defaults to claude-sonnet-5",
),
"OPENAI_API_KEY?": type("string > 0").describe(
"your OpenAI API key; optional, auto-plants a probed catalog credential on the operator bench at hub start",
),
Expand Down Expand Up @@ -394,6 +400,11 @@ export type HubConfig = {
readonly envProviderBaseUrls: Partial<
Record<SupportedCredentialProvider, string>
>;
/** Provider model choices that must remain aligned between the fast
* env-key catalog plant and the sidecar-dependent system seed. */
readonly envProviderPreferredModels?: Partial<
Record<SupportedCredentialProvider, string>
>;
/** The identity the env-key auto-plant signs in as to find the
* operator bench — the same identity `workbench setup`/`workbench
* seed` use, defaulted the same way when unset. Always populated
Expand Down Expand Up @@ -588,10 +599,27 @@ function sidecarProvisionerConfigFor(

function seedModelFrom(parsed: ParsedHubEnv): ModelSource | undefined {
const apiKey = parsed.ANTHROPIC_API_KEY;
if (apiKey === undefined) return undefined;
if (apiKey === undefined) {
if (parsed.ANTHROPIC_MODEL !== undefined) {
throw new Error(
"invalid hub environment: ANTHROPIC_MODEL requires ANTHROPIC_API_KEY",
);
}
return undefined;
}
const model = parsed.ANTHROPIC_MODEL ?? SEED_MODEL;
if (
!CATALOG_SEEDS.anthropic.models.some(
(candidate) => candidate.canonicalName === model,
)
) {
throw new Error(
`invalid hub environment: ANTHROPIC_MODEL must name a curated Anthropic model; got ${JSON.stringify(model)}`,
);
}
return {
provider: SEED_MODEL_PROVIDER,
model: SEED_MODEL,
model,
baseURL: SEED_MODEL_BASE_URL,
apiKey,
};
Expand Down Expand Up @@ -676,6 +704,8 @@ export function readHubConfig(
},
envProviderKeys: envProviderKeysFrom(parsed),
envProviderBaseUrls: envProviderBaseUrlsFrom(parsed),
envProviderPreferredModels:
seedModel === undefined ? {} : { anthropic: seedModel.model },
envCredentialPlantAdmin: {
email: parsed.HUB_ADMIN_EMAIL ?? DEFAULT_PLANT_ADMIN_EMAIL,
password: parsed.HUB_ADMIN_PASSWORD ?? DEFAULT_PLANT_ADMIN_PASSWORD,
Expand Down
4 changes: 4 additions & 0 deletions apps/hub/src/env-credential-plant.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ export type EnvCredentialPlantDeps = {
baseUrl: string;
envProviderKeys: HubConfig["envProviderKeys"];
envProviderBaseUrls: HubConfig["envProviderBaseUrls"];
envProviderPreferredModels?: HubConfig["envProviderPreferredModels"];
admin: HubConfig["envCredentialPlantAdmin"];
/** The fully composed, guarded app's own request entry point. */
fetch: (request: Request) => Promise<Response>;
Expand Down Expand Up @@ -182,6 +183,9 @@ async function attemptPlant(
tenantId: resolved.tenantId,
envProviderKeys: deps.envProviderKeys,
envProviderBaseUrls: deps.envProviderBaseUrls,
...(deps.envProviderPreferredModels !== undefined
? { envProviderPreferredModels: deps.envProviderPreferredModels }
: {}),
log: (line) => log.info`${line}`,
});
return { status: "ran", outcomes, session: resolved.session };
Expand Down
1 change: 1 addition & 0 deletions apps/hub/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3616,6 +3616,7 @@ export async function createHub(config: HubConfig) {
baseUrl: config.baseUrl,
envProviderKeys: config.envProviderKeys,
envProviderBaseUrls: config.envProviderBaseUrls,
envProviderPreferredModels: config.envProviderPreferredModels,
admin: config.envCredentialPlantAdmin,
fetch: (request) => Promise.resolve(servingApp.fetch(request)),
});
Expand Down
1 change: 1 addition & 0 deletions apps/hub/src/system-seed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,7 @@ export async function runSystemSeed(deps: SystemSeedDeps): Promise<void> {
api,
cookies: session.cookies,
tenantId: tenant.tenantId,
preferredModel: model.model,
log: (line) => log.info`${line}`,
...(deps.seedModel !== undefined
? { apiKey: deps.seedModel.apiKey }
Expand Down
40 changes: 40 additions & 0 deletions apps/hub/test/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ describe("readHubConfig", () => {
sidecarProvisioners: [],
envProviderKeys: {},
envProviderBaseUrls: {},
envProviderPreferredModels: {},
envCredentialPlantAdmin: {
email: "alice@example.com",
password: "password123",
Expand Down Expand Up @@ -279,6 +280,45 @@ describe("readHubConfig", () => {
baseURL: "https://api.anthropic.com",
apiKey: "sk-ant-test",
});
expect(config.envProviderPreferredModels).toEqual({
anthropic: "claude-sonnet-5",
});
});

test("ANTHROPIC_MODEL selects the model used for Anthropic seeding", () => {
const config = readHubConfig({
...validEnv,
ANTHROPIC_API_KEY: "sk-ant-test",
ANTHROPIC_MODEL: "claude-opus-5",
});
expect(config.seedModel).toEqual({
provider: "anthropic",
model: "claude-opus-5",
baseURL: "https://api.anthropic.com",
apiKey: "sk-ant-test",
});
expect(config.envProviderPreferredModels).toEqual({
anthropic: "claude-opus-5",
});
});

test("ANTHROPIC_MODEL rejects a model outside the curated catalog", () => {
expect(() =>
readHubConfig({
...validEnv,
ANTHROPIC_API_KEY: "sk-ant-test",
ANTHROPIC_MODEL: "claude-not-real",
}),
).toThrow("ANTHROPIC_MODEL must name a curated Anthropic model");
});

test("ANTHROPIC_MODEL requires ANTHROPIC_API_KEY", () => {
expect(() =>
readHubConfig({
...validEnv,
ANTHROPIC_MODEL: "claude-opus-5",
}),
).toThrow("ANTHROPIC_MODEL requires ANTHROPIC_API_KEY");
});

test("huggingfaceOAuthClientId is absent by default", () => {
Expand Down
6 changes: 6 additions & 0 deletions docs/model-seeding.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,12 @@ API — never discovered at runtime:
open-weight relay — is seeded with an empty list, said out loud in the
seed log, because a guessed capability routes real work to a model that
cannot do it.
- **`ANTHROPIC_MODEL`** — alongside `ANTHROPIC_API_KEY`, optionally selects a model from the curated
Anthropic seed for boot-time workflow deployment. `seedCatalog` gives
that model the provider's first offering priority, so runtime resolution
and Settings agree. When unset, the first declared Anthropic model,
`claude-sonnet-5`, remains the default. Boot seeding is authoritative:
reseeding restores the configured order after a manual priority change.
- **`packages/connections/src/credential-test.ts` (`PROVIDER_TEST_CONFIG`)** —
a second, independent hardcoded table: each provider's free auth-gated
probe endpoint, used to prove a freshly-entered key works before it is
Expand Down
6 changes: 6 additions & 0 deletions packages/onboarding/src/plant-env-credentials.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,10 @@ export type PlantEnvProviderCredentialsArgs = {
* probe and seed run against its own fixed origin regardless of what
* (if anything) this map holds for it. */
envProviderBaseUrls?: Partial<Record<SupportedCredentialProvider, string>>;
/** provider -> model that the seed must make first in catalog resolution. */
envProviderPreferredModels?: Partial<
Record<SupportedCredentialProvider, string>
>;
/** One line per provider: name, outcome, and (on failure) a probe
* error summary — never the key. */
log: (line: string) => void;
Expand Down Expand Up @@ -290,6 +294,7 @@ export async function plantEnvProviderCredentials(
{ readonly apiKey: string } | { readonly existingCredentialId: string },
): SeedCatalogArgs {
const baseURL = args.envProviderBaseUrls?.[provider];
const preferredModel = args.envProviderPreferredModels?.[provider];
return {
api: args.api,
cookies: args.cookies,
Expand All @@ -298,6 +303,7 @@ export async function plantEnvProviderCredentials(
log: suppressedLog,
...extra,
...(baseURL !== undefined ? { baseURLOverride: baseURL } : {}),
...(preferredModel !== undefined ? { preferredModel } : {}),
};
}

Expand Down
62 changes: 57 additions & 5 deletions packages/onboarding/test/complete-credential.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -753,6 +753,17 @@ describe("completeCredentialSetup", () => {
cookies: [],
};
}
if (
method === "GET" &&
(path === `/api/tenants/${TENANT_ID}/catalog/offerings` ||
path.startsWith(`/api/tenants/${TENANT_ID}/catalog/offerings?`))
) {
return {
status: 200,
data: { data: [], nextCursor: null },
cookies: [],
};
}
throw new Error(`unexpected call: ${method} ${path}`);
};

Expand Down Expand Up @@ -1004,7 +1015,12 @@ describe("completeCredentialSetup", () => {
const deployments: { definitionAssetId: string; id: string }[] = [];
const catalogModels: Row[] = [];
const catalogProviders: Row[] = [];
const catalogOfferings: { modelId: string; providerId: string }[] = [];
const catalogOfferings: {
id: string;
modelId: string;
providerId: string;
priority: number;
}[] = [];
const providers: Row[] = [];
const credentials: Row[] = [];
let assetCreatePosts = 0;
Expand Down Expand Up @@ -1407,21 +1423,31 @@ describe("completeCredentialSetup", () => {
method === "POST" &&
path === `/api/tenants/${TENANT_ID}/catalog/offerings`
) {
const b = body as { modelId: string; providerId: string };
const b = body as {
modelId: string;
providerId: string;
priority: number;
};
const existing = catalogOfferings.find(
(o) => o.modelId === b.modelId && o.providerId === b.providerId,
);
if (existing) return { status: 409, data: {}, cookies: [] };
catalogOfferingCreatePosts += 1;
catalogOfferings.push({ modelId: b.modelId, providerId: b.providerId });
const id = `off_${catalogOfferings.length + 1}`;
catalogOfferings.push({
id,
modelId: b.modelId,
providerId: b.providerId,
priority: b.priority,
});
return {
status: 201,
data: {
id: `off_${catalogOfferings.length}`,
id,
tenantId: TENANT_ID,
modelId: b.modelId,
providerId: b.providerId,
priority: 0,
priority: b.priority,
deploymentTags: [],
capabilities: [],
quirks: null,
Expand All @@ -1432,6 +1458,32 @@ describe("completeCredentialSetup", () => {
cookies: [],
};
}
if (
method === "GET" &&
(path === `/api/tenants/${TENANT_ID}/catalog/offerings` ||
path.startsWith(`/api/tenants/${TENANT_ID}/catalog/offerings?`))
) {
return {
status: 200,
data: {
data: catalogOfferings.map((o) => ({
id: o.id,
tenantId: TENANT_ID,
modelId: o.modelId,
providerId: o.providerId,
priority: o.priority,
deploymentTags: [],
capabilities: [],
quirks: null,
disabled: false,
createdAt: TIMESTAMP,
updatedAt: TIMESTAMP,
})),
nextCursor: null,
},
cookies: [],
};
}
throw new Error(`unexpected call: ${method} ${path}`);
};

Expand Down
3 changes: 3 additions & 0 deletions packages/onboarding/test/huggingface-connect-routes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,9 @@ function mockHub() {
201,
),
);
hub.get("/api/tenants/ten_1/catalog/offerings", (c) =>
c.json({ data: [], nextCursor: null }),
);
return hub;
}

Expand Down
3 changes: 3 additions & 0 deletions packages/onboarding/test/openrouter-connect-routes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,9 @@ function mockHub() {
201,
),
);
hub.get("/api/tenants/ten_1/catalog/offerings", (c) =>
c.json({ data: [], nextCursor: null }),
);
return hub;
}

Expand Down
2 changes: 2 additions & 0 deletions packages/onboarding/test/plant-env-credentials.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -308,6 +308,7 @@ describe("plantEnvProviderCredentials", () => {
cookies: ["session=abc"],
tenantId: TENANT_ID,
envProviderKeys: { anthropic: "sk-ant-real" },
envProviderPreferredModels: { anthropic: "claude-opus-5" },
log,
testCredential: async (args) => {
expect(args.provider).toBe("anthropic");
Expand All @@ -328,6 +329,7 @@ describe("plantEnvProviderCredentials", () => {
expect(seedCatalogCalls[0]?.provider).toBe("anthropic");
expect(seedCatalogCalls[0]?.apiKey).toBe("sk-ant-real");
expect(seedCatalogCalls[0]?.tenantId).toBe(TENANT_ID);
expect(seedCatalogCalls[0]?.preferredModel).toBe("claude-opus-5");

expect(lines).toHaveLength(1);
expect(lines[0]).toContain("anthropic");
Expand Down
Loading
Loading