diff --git a/.env.example b/.env.example index 37c9185d4..9ed97e89a 100644 --- a/.env.example +++ b/.env.example @@ -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 @@ -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"}] - diff --git a/README.md b/README.md index 5addba643..73c727c9e 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/apps/hub/src/config.ts b/apps/hub/src/config.ts index 01e311885..498d910a9 100644 --- a/apps/hub/src/config.ts +++ b/apps/hub/src/config.ts @@ -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, @@ -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?:\/\/.+$/; @@ -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", ), @@ -394,6 +400,11 @@ export type HubConfig = { readonly envProviderBaseUrls: Partial< Record >; + /** Provider model choices that must remain aligned between the fast + * env-key catalog plant and the sidecar-dependent system seed. */ + readonly envProviderPreferredModels?: Partial< + Record + >; /** 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 @@ -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, }; @@ -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, diff --git a/apps/hub/src/env-credential-plant.ts b/apps/hub/src/env-credential-plant.ts index 31daf2e9e..ff664eb0f 100644 --- a/apps/hub/src/env-credential-plant.ts +++ b/apps/hub/src/env-credential-plant.ts @@ -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; @@ -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 }; diff --git a/apps/hub/src/index.ts b/apps/hub/src/index.ts index 8a467fd92..85b71272c 100644 --- a/apps/hub/src/index.ts +++ b/apps/hub/src/index.ts @@ -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)), }); diff --git a/apps/hub/src/system-seed.ts b/apps/hub/src/system-seed.ts index 92a0c8cf6..23e77a66c 100644 --- a/apps/hub/src/system-seed.ts +++ b/apps/hub/src/system-seed.ts @@ -128,6 +128,7 @@ export async function runSystemSeed(deps: SystemSeedDeps): Promise { api, cookies: session.cookies, tenantId: tenant.tenantId, + preferredModel: model.model, log: (line) => log.info`${line}`, ...(deps.seedModel !== undefined ? { apiKey: deps.seedModel.apiKey } diff --git a/apps/hub/test/config.test.ts b/apps/hub/test/config.test.ts index da1ee3640..b5d23251c 100644 --- a/apps/hub/test/config.test.ts +++ b/apps/hub/test/config.test.ts @@ -39,6 +39,7 @@ describe("readHubConfig", () => { sidecarProvisioners: [], envProviderKeys: {}, envProviderBaseUrls: {}, + envProviderPreferredModels: {}, envCredentialPlantAdmin: { email: "alice@example.com", password: "password123", @@ -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", () => { diff --git a/docs/model-seeding.md b/docs/model-seeding.md index 2c145bd00..05893bd0c 100644 --- a/docs/model-seeding.md +++ b/docs/model-seeding.md @@ -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 diff --git a/packages/onboarding/src/plant-env-credentials.ts b/packages/onboarding/src/plant-env-credentials.ts index dc8598cf1..0f223579b 100644 --- a/packages/onboarding/src/plant-env-credentials.ts +++ b/packages/onboarding/src/plant-env-credentials.ts @@ -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>; + /** provider -> model that the seed must make first in catalog resolution. */ + envProviderPreferredModels?: Partial< + Record + >; /** One line per provider: name, outcome, and (on failure) a probe * error summary — never the key. */ log: (line: string) => void; @@ -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, @@ -298,6 +303,7 @@ export async function plantEnvProviderCredentials( log: suppressedLog, ...extra, ...(baseURL !== undefined ? { baseURLOverride: baseURL } : {}), + ...(preferredModel !== undefined ? { preferredModel } : {}), }; } diff --git a/packages/onboarding/test/complete-credential.test.ts b/packages/onboarding/test/complete-credential.test.ts index 87349592c..02034f612 100644 --- a/packages/onboarding/test/complete-credential.test.ts +++ b/packages/onboarding/test/complete-credential.test.ts @@ -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}`); }; @@ -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; @@ -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, @@ -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}`); }; diff --git a/packages/onboarding/test/huggingface-connect-routes.test.ts b/packages/onboarding/test/huggingface-connect-routes.test.ts index 19905bee8..f78971cc5 100644 --- a/packages/onboarding/test/huggingface-connect-routes.test.ts +++ b/packages/onboarding/test/huggingface-connect-routes.test.ts @@ -156,6 +156,9 @@ function mockHub() { 201, ), ); + hub.get("/api/tenants/ten_1/catalog/offerings", (c) => + c.json({ data: [], nextCursor: null }), + ); return hub; } diff --git a/packages/onboarding/test/openrouter-connect-routes.test.ts b/packages/onboarding/test/openrouter-connect-routes.test.ts index a18304407..27454ab4c 100644 --- a/packages/onboarding/test/openrouter-connect-routes.test.ts +++ b/packages/onboarding/test/openrouter-connect-routes.test.ts @@ -154,6 +154,9 @@ function mockHub() { 201, ), ); + hub.get("/api/tenants/ten_1/catalog/offerings", (c) => + c.json({ data: [], nextCursor: null }), + ); return hub; } diff --git a/packages/onboarding/test/plant-env-credentials.test.ts b/packages/onboarding/test/plant-env-credentials.test.ts index 61eab8f1e..c4b5d5db7 100644 --- a/packages/onboarding/test/plant-env-credentials.test.ts +++ b/packages/onboarding/test/plant-env-credentials.test.ts @@ -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"); @@ -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"); diff --git a/packages/seeding/src/seed.ts b/packages/seeding/src/seed.ts index ac636d18d..3b19fdc85 100644 --- a/packages/seeding/src/seed.ts +++ b/packages/seeding/src/seed.ts @@ -1343,7 +1343,52 @@ async function ensureCatalogOffering( return; } if (created.status === 409) { - log("catalog offering already exists (skipped)"); + let cursor: string | null = null; + let existing: typeof ModelOfferingResponse.infer | undefined; + do { + const listed = await api( + "GET", + `/api/tenants/${args.tenantId}/catalog/offerings${cursor === null ? "" : `?cursor=${encodeURIComponent(cursor)}`}`, + undefined, + cookies, + ); + const page = parseAs( + paginatedSchema(ModelOfferingResponse), + listed.data, + "catalog offerings response", + ); + existing = page.data.find( + (offering) => + offering.modelId === args.modelId && + offering.providerId === args.providerId, + ); + cursor = page.nextCursor; + } while (existing === undefined && cursor !== null); + if (!existing) { + throw new HubApiError( + "catalog offering reported a conflict but is not listable on the bench", + "check the hub logs for the underlying failure, then re-run: workbench seed", + ); + } + if (existing.priority === args.priority) { + log("catalog offering already exists (skipped)"); + return; + } + + const updated = await api( + "PATCH", + `/api/tenants/${args.tenantId}/catalog/offerings/${existing.id}`, + { priority: args.priority }, + cookies, + ); + if (updated.status !== 200) { + throw new HubApiError( + `the hub rejected updating the catalog offering priority with status ${updated.status}: ${JSON.stringify(updated.data)}`, + "check the hub logs for the underlying failure, then re-run: workbench seed", + ); + } + parseAs(ModelOfferingResponse, updated.data, "catalog offering response"); + log("updated catalog offering priority"); return; } throw new HubApiError( @@ -1436,6 +1481,13 @@ export type SeedCatalogArgs = { * Ignored for every provider except `ollama`. */ baseURLOverride?: string; + /** + * The model this seed run makes first among the provider's offerings. + * Hub boot passes its configured seed model so runtime deployment and + * Settings resolve the same choice. Unset preserves the provider's + * declared first model as the default. + */ + preferredModel?: string; }; export type SeedCatalogResult = { @@ -1482,6 +1534,18 @@ export async function seedCatalog( ? await fetchOllamaModelCatalog(providerBaseURL) : undefined; const models = dynamicModels ?? seed.models; + const preferredModelIndex = + args.preferredModel === undefined + ? 0 + : models.findIndex( + (model) => model.canonicalName === args.preferredModel, + ); + if (preferredModelIndex < 0) { + throw new HubApiError( + `preferred model ${JSON.stringify(args.preferredModel)} is not present in the ${provider} seed catalog`, + "choose a model declared by this provider's catalog seed, then re-run the seed", + ); + } const seededModels: { id: string; @@ -1570,14 +1634,14 @@ export async function seedCatalog( }, log, ); - // Priority = the provider's CATALOG_SEEDS declaration index (anthropic - // first), so when several connected providers serve the same model the - // fallback order is deterministic instead of an all-zeroes tie broken - // by insertion accident. - const offeringPriority = Math.max( - 0, - Object.keys(CATALOG_SEEDS).indexOf(provider), - ); + // Flatten the curated provider/model declaration order into one priority + // sequence. Provider order still controls cross-provider fallback, while + // model order makes each provider's declared default the first choice. + let offeringPriorityOffset = 0; + for (const [seedProvider, providerSeed] of Object.entries(CATALOG_SEEDS)) { + if (seedProvider === provider) break; + offeringPriorityOffset += providerSeed.models.length; + } // What each deployment can do, resolved from the pinned catalog's probe // results. Until this, every seeded offering stored an empty capability // list, so no capability filter — this repo's concept resolution or the @@ -1589,7 +1653,7 @@ export async function seedCatalog( canonicalName: string; capabilities: readonly string[]; }[] = []; - for (const model of seededModels) { + for (const [modelIndex, model] of seededModels.entries()) { // Ollama's dynamic entries already carry their own live-probed // capabilities (`fetchOllamaModelCatalog`, CL-6366) — narrowed against // the real `Capability` enum here, the trust boundary, rather than @@ -1631,7 +1695,13 @@ export async function seedCatalog( tenantId, modelId: model.id, providerId: catalogProviderId, - priority: offeringPriority, + priority: + offeringPriorityOffset + + (modelIndex === preferredModelIndex + ? 0 + : modelIndex < preferredModelIndex + ? modelIndex + 1 + : modelIndex), capabilities, ...(quirks !== undefined ? { quirks } : {}), }, diff --git a/packages/seeding/test/seed.test.ts b/packages/seeding/test/seed.test.ts index 31a945f61..17e72df1c 100644 --- a/packages/seeding/test/seed.test.ts +++ b/packages/seeding/test/seed.test.ts @@ -1463,7 +1463,11 @@ 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 offeringPosts: { + modelId: string; + providerId: string; + priority: number; + }[] = []; const handler: FakeHandler = (method, path, body) => { if (method === "POST" && path === `/api/tenants/${TENANT_ID}/providers`) return { status: 201, data: providerRow("prv_1", "anthropic") }; @@ -1505,7 +1509,7 @@ describe("seedCatalog", () => { capabilities: string[]; }; expect(offeringBody.providerId).toBe("cpv_1"); - expect(offeringBody.priority).toBe(0); + expect(offeringBody.priority).toBe(offeringPosts.length); expect(offeringBody.capabilities.length).toBeGreaterThan(0); expect(offeringBody.capabilities).toContain("plain-text"); expect(offeringBody.capabilities).toContain( @@ -1548,7 +1552,6 @@ describe("seedCatalog", () => { "mdl_5", "mdl_6", ]); - const output = lines.join("\n"); expect(output).toContain("created provider anthropic"); expect(output).toContain("created credential anthropic-default"); @@ -1560,6 +1563,179 @@ describe("seedCatalog", () => { ); }); + test("fresh run gives the declared Anthropic default the lowest distinct priority", async () => { + const { log } = collector(); + const modelNamesById = new Map(); + const offeringPosts: { modelId: string; priority: number }[] = []; + const handler: FakeHandler = (method, path, body) => { + if (method === "POST" && path === `/api/tenants/${TENANT_ID}/providers`) + return { status: 201, data: providerRow("prv_1", "anthropic") }; + if (method === "POST" && path === `/api/tenants/${TENANT_ID}/credentials`) + return { + status: 201, + data: credentialRow("cre_1", "prv_1", "anthropic-default"), + }; + if ( + method === "POST" && + path === `/api/tenants/${TENANT_ID}/catalog/models` + ) { + const canonicalName = (body as { canonicalName: string }).canonicalName; + const modelId = `mdl_${modelNamesById.size + 1}`; + modelNamesById.set(modelId, canonicalName); + return { + status: 201, + data: catalogModelRow(modelId, canonicalName), + }; + } + if ( + method === "POST" && + path === `/api/tenants/${TENANT_ID}/catalog/providers` + ) + return { + status: 201, + data: catalogProviderRow("cpv_1", "anthropic", "cre_1"), + }; + if ( + method === "POST" && + path === `/api/tenants/${TENANT_ID}/catalog/offerings` + ) { + const offering = body as { modelId: string; priority: number }; + offeringPosts.push(offering); + return { + status: 201, + data: catalogOfferingRow( + `off_${offeringPosts.length}`, + offering.modelId, + "cpv_1", + ), + }; + } + return undefined; + }; + + await seedCatalog({ + api: fakeAPI(handler), + cookies: [], + tenantId: TENANT_ID, + apiKey: "sk-test", + log, + }); + + const priorities = offeringPosts.map((offering) => offering.priority); + const sonnet = offeringPosts.find( + (offering) => modelNamesById.get(offering.modelId) === "claude-sonnet-5", + ); + expect(new Set(priorities).size).toBe(offeringPosts.length); + expect(sonnet?.priority).toBe(Math.min(...priorities)); + }); + + test("re-run makes the configured model the preferred offering", async () => { + const { log } = collector(); + const patchedOfferings: { id: string; priority: number }[] = []; + const handler: FakeHandler = (method, path, body) => { + if (method === "POST" && path === `/api/tenants/${TENANT_ID}/providers`) + return { status: 201, data: providerRow("prv_1", "anthropic") }; + if (method === "POST" && path === `/api/tenants/${TENANT_ID}/credentials`) + return { + status: 201, + data: credentialRow("cre_1", "prv_1", "anthropic-default"), + }; + if ( + method === "POST" && + path === `/api/tenants/${TENANT_ID}/catalog/models` + ) { + const canonicalName = (body as { canonicalName: string }).canonicalName; + const modelId = + canonicalName === "claude-opus-5" ? "mdl_legacy" : "mdl_new"; + return { status: 201, data: catalogModelRow(modelId, canonicalName) }; + } + if ( + method === "POST" && + path === `/api/tenants/${TENANT_ID}/catalog/providers` + ) + return { + status: 201, + data: catalogProviderRow("cpv_1", "anthropic", "cre_1"), + }; + if ( + method === "POST" && + path === `/api/tenants/${TENANT_ID}/catalog/offerings` + ) { + const offering = body as { modelId: string }; + if (offering.modelId === "mdl_legacy") + return { status: 409, data: { error: "already exists" } }; + return { + status: 201, + data: catalogOfferingRow( + `off_${offering.modelId}`, + offering.modelId, + "cpv_1", + ), + }; + } + if ( + method === "GET" && + path === `/api/tenants/${TENANT_ID}/catalog/offerings` + ) + return { + status: 200, + data: { + data: [ + catalogOfferingRow( + "off_other_provider", + "mdl_legacy", + "cpv_other", + ), + ], + nextCursor: "second-page", + }, + }; + if ( + method === "GET" && + path === + `/api/tenants/${TENANT_ID}/catalog/offerings?cursor=second-page` + ) + return { + status: 200, + data: { + data: [ + { + ...catalogOfferingRow("off_legacy", "mdl_legacy", "cpv_1"), + priority: 5, + }, + ], + nextCursor: null, + }, + }; + if ( + method === "PATCH" && + path === `/api/tenants/${TENANT_ID}/catalog/offerings/off_legacy` + ) { + const patch = body as { priority: number }; + patchedOfferings.push({ id: "off_legacy", priority: patch.priority }); + return { + status: 200, + data: { + ...catalogOfferingRow("off_legacy", "mdl_legacy", "cpv_1"), + priority: patch.priority, + }, + }; + } + return undefined; + }; + + await seedCatalog({ + api: fakeAPI(handler), + cookies: [], + tenantId: TENANT_ID, + apiKey: "sk-test", + preferredModel: "claude-opus-5", + log, + }); + + expect(patchedOfferings).toEqual([{ id: "off_legacy", priority: 0 }]); + }); + test("an Ollama offering's quirks carry that model's real context-window ceiling, not the built-in 4096 default", async () => { const { log } = collector(); const offeringBodies: Record[] = []; @@ -1721,6 +1897,7 @@ describe("seedCatalog", () => { const { lines, log } = collector(); let patchCalls = 0; let postCredentialCalls = 0; + let offeringPosts = 0; let patchBody: unknown; const staleCredentialRow = () => ({ @@ -1793,11 +1970,17 @@ describe("seedCatalog", () => { method === "POST" && path === `/api/tenants/${TENANT_ID}/catalog/offerings` ) { - // Priority = the provider's CATALOG_SEEDS declaration index, so - // multi-provider fallback order is deterministic, never a tie. - expect((body as { priority: number }).priority).toBe( + const providersBeforeHuggingFace = Object.entries(CATALOG_SEEDS).slice( + 0, Object.keys(CATALOG_SEEDS).indexOf("huggingface"), ); + expect((body as { priority: number }).priority).toBe( + providersBeforeHuggingFace.reduce( + (offset, [, providerSeed]) => offset + providerSeed.models.length, + 0, + ) + offeringPosts, + ); + offeringPosts += 1; return { status: 201, data: catalogOfferingRow("off_1", "mdl_1", "cpv_1"), @@ -2198,6 +2381,24 @@ describe("seedCatalog", () => { offeringPosts += 1; return { status: 409, data: { error: "already exists" } }; } + if ( + method === "GET" && + path === `/api/tenants/${TENANT_ID}/catalog/offerings` + ) + return { + status: 200, + data: { + data: anthropicModels.map((_, index) => ({ + ...catalogOfferingRow( + `off_${index + 1}`, + `mdl_${index + 1}`, + "cpv_1", + ), + priority: index, + })), + nextCursor: null, + }, + }; return undefined; };