From 66f7074783687d3fbb599fc298d5463a39446609 Mon Sep 17 00:00:00 2001 From: credence-the-bot Date: Mon, 11 May 2026 00:38:41 +0000 Subject: [PATCH 1/3] feat(inference): always-seed managed profile triplet (Phase 1.2 PR-D) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Managed profile seeding becomes idempotent and provider-agnostic on the caller side: - The 3 managed profiles (balanced / quality-optimized / cost-optimized) seed on fresh installs only. Existing on-disk entries are never overwritten, so user edits via the Providers UI and platform-overlay fragments both stay authoritative across reboots. - Every managed profile carries provider_connection: "anthropic-managed". Dispatch resolves auth from that connection (seeded as platform-auth by seedCanonicalConnections); users not logged in fail soft at call time rather than at boot. - Custom-* profiles no longer auto-materialize on non-Anthropic defaults. The Providers UI is responsible for creating user-defined connection + profile pairs on demand. - llm.default.provider rewrite + llm.default.model sync are gone. Routing is owned by the active profile's provider_connection now; the legacy default block is read-only fallback for backfill of pre-1.2 configs. Test churn drops the four scenarios that exercised the removed code paths (non-Anthropic seeding, openai→anthropic re-hatch, unknown-provider rewrite, default.model sync). --- .../__tests__/config-loader-backfill.test.ts | 175 --------------- .../src/config/seed-inference-profiles.ts | 199 +++--------------- 2 files changed, 35 insertions(+), 339 deletions(-) diff --git a/assistant/src/__tests__/config-loader-backfill.test.ts b/assistant/src/__tests__/config-loader-backfill.test.ts index f1be83fb388..cdb1b93657e 100644 --- a/assistant/src/__tests__/config-loader-backfill.test.ts +++ b/assistant/src/__tests__/config-loader-backfill.test.ts @@ -442,119 +442,6 @@ describe("loadConfig startup behavior", () => { expect(raw.llm.profiles.balanced.model).toBe("claude-sonnet-4-6"); }); - test("non-Anthropic hatch overlay seeds custom-* profiles and activates custom-balanced", () => { - const overlayPath = join(WORKSPACE_DIR, "hatch-overlay.json"); - writeFileSync( - overlayPath, - JSON.stringify( - { - llm: { - default: { - provider: "openai", - }, - }, - }, - null, - 2, - ) + "\n", - ); - process.env.VELLUM_DEFAULT_WORKSPACE_CONFIG_PATH = overlayPath; - - mergeDefaultConfigAndSeedInferenceProfiles(); - const config = loadConfig(); - const mainAgentConfig = resolveCallSiteConfig("mainAgent", config.llm); - - expect(config.llm.activeProfile).toBe("custom-balanced"); - expect(config.llm.profiles["custom-balanced"]?.provider).toBe("openai"); - expect(config.llm.profiles["custom-balanced"]?.model).toBe("gpt-5.4-mini"); - expect(config.llm.profiles["custom-quality-optimized"]?.provider).toBe( - "openai", - ); - expect(config.llm.profiles["custom-cost-optimized"]?.provider).toBe( - "openai", - ); - expect(config.llm.profiles.balanced?.provider).toBe("anthropic"); - expect(config.llm.profiles["quality-optimized"]?.provider).toBe( - "anthropic", - ); - expect(config.llm.profiles["cost-optimized"]?.provider).toBe("anthropic"); - expect(config.llm.default.provider).toBe("openai"); - expect(config.llm.default.model).toBe("gpt-5.4-mini"); - expect(mainAgentConfig.provider).toBe("openai"); - expect(mainAgentConfig.model).toBe("gpt-5.4-mini"); - - const raw = JSON.parse(readFileSync(CONFIG_PATH, "utf-8")); - expect(raw.llm.activeProfile).toBe("custom-balanced"); - expect(raw.llm.default.model).toBe( - raw.llm.profiles["custom-balanced"].model, - ); - }); - - test("re-hatch from openai to anthropic resets stale custom-balanced active profile", () => { - // Pre-seed an OpenAI-style workspace: custom-balanced is active, default is - // openai. Simulates a workspace that previously hatched against OpenAI. - writeConfig({ - llm: { - default: { provider: "openai", model: "gpt-5.4-mini" }, - profiles: { - "custom-balanced": { - source: "managed", - provider: "openai", - model: "gpt-5.4-mini", - }, - }, - activeProfile: "custom-balanced", - }, - }); - - const overlayPath = join(WORKSPACE_DIR, "rehatch-anthropic.json"); - writeFileSync( - overlayPath, - JSON.stringify({ llm: { default: { provider: "anthropic" } } }, null, 2) + - "\n", - ); - process.env.VELLUM_DEFAULT_WORKSPACE_CONFIG_PATH = overlayPath; - - mergeDefaultConfigAndSeedInferenceProfiles(); - - const raw = JSON.parse(readFileSync(CONFIG_PATH, "utf-8")); - expect(raw.llm.activeProfile).toBe("balanced"); - expect(raw.llm.default.provider).toBe("anthropic"); - expect(raw.llm.default.model).toBe("claude-sonnet-4-6"); - }); - - test("unknown overlay provider falls back to Anthropic seeding", () => { - const overlayPath = join(WORKSPACE_DIR, "hatch-overlay.json"); - writeFileSync( - overlayPath, - JSON.stringify( - { - llm: { - default: { - provider: "unknownprov", - }, - }, - }, - null, - 2, - ) + "\n", - ); - process.env.VELLUM_DEFAULT_WORKSPACE_CONFIG_PATH = overlayPath; - - mergeDefaultConfigAndSeedInferenceProfiles(); - - const raw = JSON.parse(readFileSync(CONFIG_PATH, "utf-8")); - expect(raw.llm.activeProfile).toBe("balanced"); - expect(raw.llm.profiles.balanced.provider).toBe("anthropic"); - expect(raw.llm.profiles.balanced.model).toBe("claude-sonnet-4-6"); - expect(raw.llm.profiles["custom-balanced"]).toBeUndefined(); - expect(raw.llm.profiles["custom-quality-optimized"]).toBeUndefined(); - expect(raw.llm.profiles["custom-cost-optimized"]).toBeUndefined(); - // The unrecognized provider should be rewritten on disk so subsequent - // loads don't trip Zod's enum validation warning. - expect(raw.llm.default.provider).toBe("anthropic"); - }); - test("preserves user-supplied non-catalog model on every restart (ollama custom model)", () => { // Models the ollama case: catalog lists only `llama3.2` but the user has // pulled `codellama`. The seeder must NOT silently overwrite their pick. @@ -572,68 +459,6 @@ describe("loadConfig startup behavior", () => { expect(raw.llm.default.model).toBe("codellama"); }); - test("syncs llm.default.model to active profile when missing or inconsistent, respects explicit user values", () => { - // 1. Missing on disk → write to active profile's model. - const overlayMissing = join(WORKSPACE_DIR, "overlay-missing.json"); - writeFileSync( - overlayMissing, - JSON.stringify({ llm: { default: { provider: "openai" } } }, null, 2) + - "\n", - ); - process.env.VELLUM_DEFAULT_WORKSPACE_CONFIG_PATH = overlayMissing; - mergeDefaultConfigAndSeedInferenceProfiles(); - let raw = JSON.parse(readFileSync(CONFIG_PATH, "utf-8")); - expect(raw.llm.default.model).toBe( - raw.llm.profiles["custom-balanced"].model, - ); - - // 2. Inconsistent (previous default model belongs to a different provider) - // is overwritten on the next seed run. - rmSync(CONFIG_PATH); - writeConfig({ - llm: { - default: { provider: "openai", model: "claude-opus-4-7" }, - }, - }); - process.env.VELLUM_DEFAULT_WORKSPACE_CONFIG_PATH = overlayMissing; - mergeDefaultConfigAndSeedInferenceProfiles(); - raw = JSON.parse(readFileSync(CONFIG_PATH, "utf-8")); - expect(raw.llm.default.model).toBe( - raw.llm.profiles["custom-balanced"].model, - ); - - // 3. Platform overlay supplies an explicit, internally-coherent - // profile/active/default — the user's explicit choice is preserved. - rmSync(CONFIG_PATH); - const explicit = join(WORKSPACE_DIR, "overlay-explicit.json"); - writeFileSync( - explicit, - JSON.stringify( - { - llm: { - default: { provider: "openai", model: "gpt-5.4" }, - profiles: { - balanced: { - source: "managed", - provider: "openai", - model: "gpt-5.4", - label: "Platform Balanced", - }, - }, - activeProfile: "balanced", - }, - }, - null, - 2, - ) + "\n", - ); - process.env.VELLUM_DEFAULT_WORKSPACE_CONFIG_PATH = explicit; - mergeDefaultConfigAndSeedInferenceProfiles(); - raw = JSON.parse(readFileSync(CONFIG_PATH, "utf-8")); - expect(raw.llm.activeProfile).toBe("balanced"); - expect(raw.llm.default.model).toBe("gpt-5.4"); - }); - test("platform-provided profile fragments are not polluted by managed seeds", () => { writeConfig({ llm: { diff --git a/assistant/src/config/seed-inference-profiles.ts b/assistant/src/config/seed-inference-profiles.ts index 785fed3e608..e5d363e07c2 100644 --- a/assistant/src/config/seed-inference-profiles.ts +++ b/assistant/src/config/seed-inference-profiles.ts @@ -1,4 +1,3 @@ -import { PROVIDER_CATALOG } from "../providers/model-catalog.js"; import { resolveModelIntent } from "../providers/model-intents.js"; import type { ModelIntent } from "../providers/types.js"; import { loadRawConfig, saveRawConfig } from "./loader.js"; @@ -7,6 +6,18 @@ import { type ProfileEntry, } from "./schemas/llm.js"; +/** + * Provider connection backing every managed profile. Seeded by + * `seedCanonicalConnections` in `providers/inference/connections.ts`; auth is + * `{ type: "platform" }` so dispatch resolves credentials from the logged-in + * Vellum account at call time. Users who want to use OpenAI / Gemini / a local + * model create their own connection + profile through the Providers UI; the + * daemon never auto-materializes a custom profile here. + */ +const MANAGED_CONNECTION_NAME = "anthropic-managed"; +const MANAGED_PROFILE_PROVIDER: NonNullable = + "anthropic"; + /** * Template for a daemon-managed inference profile. The profile's model is * resolved at seed time from `PROVIDER_MODEL_INTENTS` so the catalog stays the @@ -53,55 +64,10 @@ const ANTHROPIC_PROFILE_TEMPLATES: Record = { }, }; -/** - * Custom-provider profile templates. Materialized at seed time when the - * resolved default provider is non-Anthropic, using `resolveModelIntent` to - * pick the model. - */ -const CUSTOM_PROFILE_TEMPLATES: Record = { - "custom-balanced": { - intent: "balanced", - source: "user", - label: "Balanced (Custom Provider)", - description: "Good balance of quality, cost, and speed", - maxTokens: 16000, - effort: "high", - thinking: { enabled: true, streamThinking: true }, - contextWindow: { maxInputTokens: DEFAULT_CONTEXT_WINDOW_MAX_INPUT_TOKENS }, - }, - "custom-quality-optimized": { - intent: "quality-optimized", - source: "user", - label: "Quality (Custom Provider)", - description: "Best results with the most capable model", - maxTokens: 32000, - effort: "max", - thinking: { enabled: true, streamThinking: true }, - contextWindow: { maxInputTokens: DEFAULT_CONTEXT_WINDOW_MAX_INPUT_TOKENS }, - }, - "custom-cost-optimized": { - intent: "latency-optimized", - source: "user", - label: "Speed (Custom Provider)", - description: "Fastest responses at lower cost", - maxTokens: 8192, - effort: "low", - thinking: { enabled: false, streamThinking: false }, - contextWindow: { maxInputTokens: DEFAULT_CONTEXT_WINDOW_MAX_INPUT_TOKENS }, - }, -}; - export const MANAGED_PROFILE_NAMES = new Set( Object.keys(ANTHROPIC_PROFILE_TEMPLATES), ); -const SEEDED_PROFILE_NAMES = new Set([ - ...Object.keys(ANTHROPIC_PROFILE_TEMPLATES), - ...Object.keys(CUSTOM_PROFILE_TEMPLATES), -]); - -const KNOWN_PROVIDERS = new Set(PROVIDER_CATALOG.map((entry) => entry.id)); - export type SeedInferenceProfilesOptions = { /** * Managed profile names supplied by the platform/default overlay for this @@ -144,132 +110,39 @@ export function seedInferenceProfiles( } const profiles = llm.profiles as Record>; - const requestedProvider = - readString(readObject(llm.default)?.provider) ?? "anthropic"; - const isKnownProvider = KNOWN_PROVIDERS.has(requestedProvider); - const resolvedProvider: NonNullable = - isKnownProvider - ? (requestedProvider as NonNullable) - : "anthropic"; - const isAnthropicDefault = resolvedProvider === "anthropic"; - - // Persist the resolved provider when the overlay supplied an unrecognized - // value, so the on-disk config doesn't keep emitting Zod warnings for - // `unknownprov` on every load. - if (!isKnownProvider) { - const defaultBlock = (readObject(llm.default) ?? {}) as Record< - string, - unknown - >; - defaultBlock.provider = resolvedProvider; - llm.default = defaultBlock; - } - + // Seed the 3 managed profiles at their canonical names on fresh installs. + // Each points at the `anthropic-managed` provider connection (seeded by + // `seedCanonicalConnections`). We never overwrite an existing on-disk + // entry: platform overlays (covered by `preserveProfileNames`) and user + // edits via the Providers UI both stay authoritative across boots. The + // daemon's job here is only to ensure the canonical names exist for a + // fresh hatch. for (const [name, template] of Object.entries(ANTHROPIC_PROFILE_TEMPLATES)) { if (preservedProfileNames.has(name)) continue; - // Preserve a previously overlay-supplied profile whose provider matches - // the resolved default — that's the platform-managed case where the - // overlay file has already been consumed and archived. Only valid for - // non-Anthropic resolutions; when the default is Anthropic the daemon - // owns these names and re-seeds with Anthropic data so a stale openai - // `balanced` doesn't keep routing through the wrong provider after a - // re-hatch back to Anthropic. - const existing = readObject(profiles[name]); - const existingProvider = readString(existing?.provider); - if ( - existing !== null && - !isAnthropicDefault && - existingProvider === resolvedProvider - ) { - continue; - } - profiles[name] = materializeProfile(template, "anthropic"); - } - - if (!isAnthropicDefault) { - for (const [name, template] of Object.entries(CUSTOM_PROFILE_TEMPLATES)) { - if (preservedProfileNames.has(name)) continue; - if (readObject(profiles[name]) !== null) continue; - profiles[name] = materializeProfile(template, resolvedProvider); - } + if (readObject(profiles[name]) !== null) continue; + profiles[name] = materializeProfile(template); } const requestedActiveProfile = readString(llm.activeProfile); - const requestedActiveEntry = - requestedActiveProfile !== undefined - ? readObject(profiles[requestedActiveProfile]) - : null; - const requestedActiveExists = requestedActiveEntry !== null; + const requestedActiveExists = + requestedActiveProfile !== undefined && + readObject(profiles[requestedActiveProfile]) !== null; const shouldPreserveActiveProfile = options.preserveActiveProfile === true && requestedActiveExists; - // Decide whether the existing active profile is still appropriate. A managed - // profile whose provider no longer matches the resolved default goes stale - // (e.g. re-hatching anthropic→openai leaves `balanced` pointing at anthropic; - // re-hatching openai→anthropic leaves `custom-balanced` pointing at openai). - // Either direction should land the user on the new default's `balanced` - // counterpart rather than routing the main agent to a stale provider. - // User-created profiles are left alone — those are the user's choice. - let keepActiveProfile = shouldPreserveActiveProfile; - if (!keepActiveProfile && requestedActiveExists) { - const isSeededName = SEEDED_PROFILE_NAMES.has(requestedActiveProfile!); - const activeProvider = readString(requestedActiveEntry?.provider); - const managedActiveProviderMismatch = - isSeededName && activeProvider !== resolvedProvider; - keepActiveProfile = !managedActiveProviderMismatch; - } - - let activeProfileName: string; - if (keepActiveProfile) { - activeProfileName = requestedActiveProfile!; - } else { - activeProfileName = isAnthropicDefault ? "balanced" : "custom-balanced"; - llm.activeProfile = activeProfileName; - } - - // Sync `llm.default.model` to the active profile's model so the providers - // registry sees a coherent provider/model pair. Only writes when the on-disk - // default model is missing or unambiguously belongs to a *different* - // provider's catalog (e.g. `claude-opus-4-7` paired with `provider: openai`). - // A user-supplied model not listed in any provider's catalog is preserved — - // ollama and openrouter expose far more models than `PROVIDER_CATALOG` lists, - // and silently overwriting `codellama`/`phi3`/etc. on every restart would - // break those users' configs. Skipped when the overlay owns the active - // profile (platform mode). - if (!shouldPreserveActiveProfile) { - const activeEntry = readObject(profiles[activeProfileName]); - const activeModel = readString(activeEntry?.model); - if (activeModel !== undefined) { - const defaultBlock = (readObject(llm.default) ?? {}) as Record< - string, - unknown - >; - const currentModel = readString(defaultBlock.model); - const modelBelongsToOtherProvider = - currentModel !== undefined && - PROVIDER_CATALOG.some( - (p) => - p.id !== resolvedProvider && - p.models.some((m) => m.id === currentModel), - ); - const shouldOverwriteDefaultModel = - currentModel === undefined || modelBelongsToOtherProvider; - if (shouldOverwriteDefaultModel) { - defaultBlock.model = activeModel; - llm.default = defaultBlock; - } - } + // Active profile resolution: an existing valid choice wins. Otherwise fall + // back to the managed "balanced" default. User profiles created through + // the Providers UI keep their own activeProfile selection — we only + // reassign when the requested profile doesn't exist. + if (!shouldPreserveActiveProfile && !requestedActiveExists) { + llm.activeProfile = "balanced"; } const profileOrder = Array.isArray(llm.profileOrder) ? (llm.profileOrder as string[]) : []; const orderSet = new Set(profileOrder); - const seededOrder = [ - ...Object.keys(ANTHROPIC_PROFILE_TEMPLATES), - ...(isAnthropicDefault ? [] : Object.keys(CUSTOM_PROFILE_TEMPLATES)), - ]; - for (const name of seededOrder) { + for (const name of Object.keys(ANTHROPIC_PROFILE_TEMPLATES)) { if (!orderSet.has(name)) { profileOrder.push(name); orderSet.add(name); @@ -291,15 +164,13 @@ export function seedInferenceProfiles( saveRawConfig(config); } -function materializeProfile( - template: ManagedProfileTemplate, - provider: NonNullable, -): ProfileEntry { +function materializeProfile(template: ManagedProfileTemplate): ProfileEntry { const { intent, ...rest } = template; return { ...rest, - provider, - model: resolveModelIntent(provider, intent), + provider: MANAGED_PROFILE_PROVIDER, + provider_connection: MANAGED_CONNECTION_NAME, + model: resolveModelIntent(MANAGED_PROFILE_PROVIDER, intent), }; } From 351c55b80b15ca9a585f376b110d15c91a699277 Mon Sep 17 00:00:00 2001 From: credence-the-bot Date: Mon, 11 May 2026 00:53:26 +0000 Subject: [PATCH 2/3] fix(inference): rewrite stale seed docstring + reset stale active profile on re-hatch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses PR-D cycle-1 review feedback: - Docstring on seedInferenceProfiles described the deleted custom-* seeding and the resolveModelIntent branch. Replaced with current-state copy: managed-only seed, fresh-install-only writes, provider_connection wiring, and the new active-profile resolution rules. - The active profile resolution preserved any existing on-disk choice as long as the named profile existed, which let a legacy OpenAI→Anthropic re-hatch (overlay updates llm.default.provider only, no activeProfile) keep a stale custom-balanced active. Now: when options.preserveActiveProfile is false and the on-disk active profile's provider no longer matches llm.default.provider, reset to 'balanced'. - Restored a regression test covering the OpenAI→Anthropic re-hatch reset under the new rules (and asserting the legacy custom-balanced profile is preserved on disk for user recovery via the Providers UI). --- .../__tests__/config-loader-backfill.test.ts | 40 +++++++++++ .../src/config/seed-inference-profiles.ts | 70 +++++++++++++------ 2 files changed, 87 insertions(+), 23 deletions(-) diff --git a/assistant/src/__tests__/config-loader-backfill.test.ts b/assistant/src/__tests__/config-loader-backfill.test.ts index cdb1b93657e..2a31112ef7f 100644 --- a/assistant/src/__tests__/config-loader-backfill.test.ts +++ b/assistant/src/__tests__/config-loader-backfill.test.ts @@ -442,6 +442,46 @@ describe("loadConfig startup behavior", () => { expect(raw.llm.profiles.balanced.model).toBe("claude-sonnet-4-6"); }); + test("re-hatch from openai to anthropic resets stale active profile to balanced", () => { + // Pre-seed an OpenAI-style workspace: user-defined custom-balanced profile + // is active, default is openai. Simulates a workspace that hatched against + // OpenAI under the pre-1.2 model. + writeConfig({ + llm: { + default: { provider: "openai", model: "gpt-5.4-mini" }, + profiles: { + "custom-balanced": { + source: "user", + provider: "openai", + model: "gpt-5.4-mini", + }, + }, + activeProfile: "custom-balanced", + }, + }); + + const overlayPath = join(WORKSPACE_DIR, "rehatch-anthropic.json"); + writeFileSync( + overlayPath, + JSON.stringify({ llm: { default: { provider: "anthropic" } } }, null, 2) + + "\n", + ); + process.env.VELLUM_DEFAULT_WORKSPACE_CONFIG_PATH = overlayPath; + + mergeDefaultConfigAndSeedInferenceProfiles(); + + const raw = JSON.parse(readFileSync(CONFIG_PATH, "utf-8")); + expect(raw.llm.activeProfile).toBe("balanced"); + expect(raw.llm.profiles.balanced.provider).toBe("anthropic"); + expect(raw.llm.profiles.balanced.provider_connection).toBe( + "anthropic-managed", + ); + // The legacy custom-balanced profile is preserved on disk — the user can + // still switch back to it via the Providers UI — but it's no longer the + // routed active. + expect(raw.llm.profiles["custom-balanced"].provider).toBe("openai"); + }); + test("preserves user-supplied non-catalog model on every restart (ollama custom model)", () => { // Models the ollama case: catalog lists only `llama3.2` but the user has // pulled `codellama`. The seeder must NOT silently overwrite their pick. diff --git a/assistant/src/config/seed-inference-profiles.ts b/assistant/src/config/seed-inference-profiles.ts index e5d363e07c2..754fb293a31 100644 --- a/assistant/src/config/seed-inference-profiles.ts +++ b/assistant/src/config/seed-inference-profiles.ts @@ -82,17 +82,27 @@ export type SeedInferenceProfilesOptions = { * Seed managed inference profiles into the workspace config. * * Called on every daemon startup after workspace migrations and default-config - * overlay merge, but before the first `loadConfig()`. The 3 Anthropic-managed - * profiles (`balanced`, `quality-optimized`, `cost-optimized`) are always - * written so users can target Anthropic via their own key. When the resolved - * default provider is non-Anthropic, the 3 `custom-*` profiles are also - * materialized using `resolveModelIntent` against that provider, and - * `custom-balanced` becomes the active profile for fresh hatches. + * overlay merge, but before the first `loadConfig()`. Ensures the 3 managed + * profiles (`balanced`, `quality-optimized`, `cost-optimized`) exist at their + * canonical names. Existing on-disk entries are never overwritten — platform + * overlays (covered by `preserveProfileNames`) and user edits via the + * Providers UI both stay authoritative across boots. * - * Default-config overlays can provide their own profile fragments and active - * profile. Lifecycle passes those explicit fields in `options`, letting local - * hatches still receive managed defaults while platform-owned profile choices - * remain authoritative. + * Each materialized managed profile carries `provider_connection: + * "anthropic-managed"`. Dispatch resolves auth from that connection (seeded + * as `{ type: "platform" }` by `seedCanonicalConnections`). Users who want + * a different provider create a user-defined connection + profile pair via + * the Providers UI; this function never materializes user profiles. + * + * Active profile resolution: + * - `options.preserveActiveProfile === true` (overlay supplied an + * explicit `activeProfile`): keep the on-disk choice when it points + * at an existing profile. + * - Otherwise: fall back to `"balanced"` when the on-disk + * `activeProfile` is missing, points at a deleted profile, or points + * at a profile whose `provider` no longer matches the current + * `llm.default.provider` (e.g. a legacy overlay that re-hatches from + * OpenAI to Anthropic by updating only `default.provider`). */ export function seedInferenceProfiles( options: SeedInferenceProfilesOptions = {}, @@ -112,11 +122,8 @@ export function seedInferenceProfiles( // Seed the 3 managed profiles at their canonical names on fresh installs. // Each points at the `anthropic-managed` provider connection (seeded by - // `seedCanonicalConnections`). We never overwrite an existing on-disk - // entry: platform overlays (covered by `preserveProfileNames`) and user - // edits via the Providers UI both stay authoritative across boots. The - // daemon's job here is only to ensure the canonical names exist for a - // fresh hatch. + // `seedCanonicalConnections`). Skip overlays (`preservedProfileNames`) + // and any name already on disk — those stay authoritative. for (const [name, template] of Object.entries(ANTHROPIC_PROFILE_TEMPLATES)) { if (preservedProfileNames.has(name)) continue; if (readObject(profiles[name]) !== null) continue; @@ -124,17 +131,34 @@ export function seedInferenceProfiles( } const requestedActiveProfile = readString(llm.activeProfile); - const requestedActiveExists = - requestedActiveProfile !== undefined && - readObject(profiles[requestedActiveProfile]) !== null; + const requestedActiveEntry = + requestedActiveProfile !== undefined + ? readObject(profiles[requestedActiveProfile]) + : null; + const requestedActiveExists = requestedActiveEntry !== null; const shouldPreserveActiveProfile = options.preserveActiveProfile === true && requestedActiveExists; - // Active profile resolution: an existing valid choice wins. Otherwise fall - // back to the managed "balanced" default. User profiles created through - // the Providers UI keep their own activeProfile selection — we only - // reassign when the requested profile doesn't exist. - if (!shouldPreserveActiveProfile && !requestedActiveExists) { + // When the overlay didn't claim ownership of `activeProfile`, detect a + // stale choice from a legacy re-hatch: the active profile's `provider` + // no longer matches `llm.default.provider`. The clearest case is an + // OpenAI→Anthropic re-hatch that updates only `default.provider` and + // leaves the previous `custom-balanced` active. Reset to `balanced` so + // the user lands on the managed default for the new provider. + let activeProviderMismatch = false; + if (!shouldPreserveActiveProfile && requestedActiveExists) { + const activeProvider = readString(requestedActiveEntry?.provider); + const defaultProvider = readString(readObject(llm.default)?.provider); + activeProviderMismatch = + activeProvider !== undefined && + defaultProvider !== undefined && + activeProvider !== defaultProvider; + } + + if ( + !shouldPreserveActiveProfile && + (!requestedActiveExists || activeProviderMismatch) + ) { llm.activeProfile = "balanced"; } From db1289cc8f41b48d586726fa1467ff1e8e3d5f35 Mon Sep 17 00:00:00 2001 From: Noa Flaherty Date: Sun, 10 May 2026 22:20:33 -0400 Subject: [PATCH 3/3] feat(inference): off-platform hatch seeds user profiles + connection; managed profiles upserted every boot - Managed Anthropic profiles (balanced, quality-optimized, cost-optimized) are overwritten on every off-platform boot so Vellum can push model/config updates. On-platform, insert-if-not-exists (platform overlays are authoritative). - Off-platform hatches now create a personal provider connection (e.g. openai-personal backed by CES credential) and 3 user profiles (custom-balanced, custom-quality-optimized, custom-cost-optimized) pointing to it. activeProfile defaults to custom-balanced. - seedCanonicalConnections changed from insert-if-not-exists to upsert so managed connections can also receive updates in new releases. - DefaultWorkspaceConfigMergeResult gains hadOverlay flag so seedInferenceProfiles distinguishes hatch from regular boot. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../__tests__/config-loader-backfill.test.ts | 176 +++++++++++++- assistant/src/config/loader.ts | 3 + .../src/config/seed-inference-profiles.ts | 220 ++++++++++++------ assistant/src/daemon/lifecycle.ts | 11 +- assistant/src/providers/inference/backfill.ts | 2 +- .../src/providers/inference/connections.ts | 29 +-- 6 files changed, 344 insertions(+), 97 deletions(-) diff --git a/assistant/src/__tests__/config-loader-backfill.test.ts b/assistant/src/__tests__/config-loader-backfill.test.ts index 2a31112ef7f..ddada9c305c 100644 --- a/assistant/src/__tests__/config-loader-backfill.test.ts +++ b/assistant/src/__tests__/config-loader-backfill.test.ts @@ -88,6 +88,7 @@ function mergeDefaultConfigAndSeedInferenceProfiles(): void { seedInferenceProfiles({ preserveProfileNames: defaultConfigMerge.providedLlmProfileNames, preserveActiveProfile: defaultConfigMerge.providedLlmActiveProfile, + isHatch: defaultConfigMerge.hadOverlay, }); } @@ -309,12 +310,14 @@ describe("loadConfig startup behavior", () => { ensureTestDir(); _setStorePath(join(WORKSPACE_DIR, "keys.enc")); delete process.env.VELLUM_DEFAULT_WORKSPACE_CONFIG_PATH; + delete process.env.IS_PLATFORM; invalidateConfigCache(); }); afterEach(() => { _setStorePath(null); delete process.env.VELLUM_DEFAULT_WORKSPACE_CONFIG_PATH; + delete process.env.IS_PLATFORM; invalidateConfigCache(); }); @@ -406,7 +409,7 @@ describe("loadConfig startup behavior", () => { expect(raw.dataDir).toBeUndefined(); }); - test("hatch default overlay does not suppress first-load inference profiles", () => { + test("off-platform hatch seeds both managed and user anthropic profiles", () => { const overlayPath = join(WORKSPACE_DIR, "hatch-overlay.json"); writeFileSync( overlayPath, @@ -430,19 +433,61 @@ describe("loadConfig startup behavior", () => { expect(config.llm.default.provider).toBe("anthropic"); expect(config.llm.default.model).toBe("claude-opus-4-7"); - expect(config.llm.activeProfile).toBe("balanced"); + // Off-platform: user profiles are active, backed by the user's API key. + expect(config.llm.activeProfile).toBe("custom-balanced"); + expect(config.llm.profiles["custom-balanced"]?.provider).toBe("anthropic"); + expect(config.llm.profiles["custom-balanced"]?.provider_connection).toBe( + "anthropic-personal", + ); + // Managed profiles exist as well. expect(config.llm.profiles.balanced?.model).toBe("claude-sonnet-4-6"); + expect(config.llm.profiles.balanced?.provider_connection).toBe( + "anthropic-managed", + ); const raw = JSON.parse(readFileSync(CONFIG_PATH, "utf-8")); expect(raw.llm.default).toEqual({ provider: "anthropic", model: "claude-opus-4-7", }); - expect(raw.llm.activeProfile).toBe("balanced"); + expect(raw.llm.activeProfile).toBe("custom-balanced"); expect(raw.llm.profiles.balanced.model).toBe("claude-sonnet-4-6"); }); - test("re-hatch from openai to anthropic resets stale active profile to balanced", () => { + test("on-platform hatch seeds only managed profiles", () => { + process.env.IS_PLATFORM = "true"; + + const overlayPath = join(WORKSPACE_DIR, "hatch-overlay.json"); + writeFileSync( + overlayPath, + JSON.stringify( + { + llm: { + default: { + provider: "anthropic", + model: "claude-opus-4-7", + }, + }, + }, + null, + 2, + ) + "\n", + ); + process.env.VELLUM_DEFAULT_WORKSPACE_CONFIG_PATH = overlayPath; + + mergeDefaultConfigAndSeedInferenceProfiles(); + const config = loadConfig(); + + expect(config.llm.activeProfile).toBe("balanced"); + expect(config.llm.profiles.balanced?.model).toBe("claude-sonnet-4-6"); + expect(config.llm.profiles.balanced?.provider_connection).toBe( + "anthropic-managed", + ); + // No user profiles created on platform. + expect(config.llm.profiles["custom-balanced"]).toBeUndefined(); + }); + + test("re-hatch from openai to anthropic creates user anthropic profiles off-platform", () => { // Pre-seed an OpenAI-style workspace: user-defined custom-balanced profile // is active, default is openai. Simulates a workspace that hatched against // OpenAI under the pre-1.2 model. @@ -471,14 +516,55 @@ describe("loadConfig startup behavior", () => { mergeDefaultConfigAndSeedInferenceProfiles(); const raw = JSON.parse(readFileSync(CONFIG_PATH, "utf-8")); + // Off-platform re-hatch: user profiles are overwritten for the new + // provider and custom-balanced becomes active. + expect(raw.llm.activeProfile).toBe("custom-balanced"); + expect(raw.llm.profiles["custom-balanced"].provider).toBe("anthropic"); + expect(raw.llm.profiles["custom-balanced"].provider_connection).toBe( + "anthropic-personal", + ); + // Managed profiles are also seeded for anthropic-managed. + expect(raw.llm.profiles.balanced.provider).toBe("anthropic"); + expect(raw.llm.profiles.balanced.provider_connection).toBe( + "anthropic-managed", + ); + }); + + test("on-platform re-hatch resets active profile to balanced", () => { + process.env.IS_PLATFORM = "true"; + + writeConfig({ + llm: { + default: { provider: "openai", model: "gpt-5.4-mini" }, + profiles: { + "custom-balanced": { + source: "user", + provider: "openai", + model: "gpt-5.4-mini", + }, + }, + activeProfile: "custom-balanced", + }, + }); + + const overlayPath = join(WORKSPACE_DIR, "rehatch-anthropic.json"); + writeFileSync( + overlayPath, + JSON.stringify({ llm: { default: { provider: "anthropic" } } }, null, 2) + + "\n", + ); + process.env.VELLUM_DEFAULT_WORKSPACE_CONFIG_PATH = overlayPath; + + mergeDefaultConfigAndSeedInferenceProfiles(); + + const raw = JSON.parse(readFileSync(CONFIG_PATH, "utf-8")); + // On-platform: no user profiles created, active resets to managed balanced. expect(raw.llm.activeProfile).toBe("balanced"); expect(raw.llm.profiles.balanced.provider).toBe("anthropic"); expect(raw.llm.profiles.balanced.provider_connection).toBe( "anthropic-managed", ); - // The legacy custom-balanced profile is preserved on disk — the user can - // still switch back to it via the Providers UI — but it's no longer the - // routed active. + // The old custom-balanced is preserved on disk but no longer active. expect(raw.llm.profiles["custom-balanced"].provider).toBe("openai"); }); @@ -499,7 +585,79 @@ describe("loadConfig startup behavior", () => { expect(raw.llm.default.model).toBe("codellama"); }); + test("off-platform hatch with openai seeds user profiles and managed anthropic profiles", () => { + const overlayPath = join(WORKSPACE_DIR, "hatch-overlay.json"); + writeFileSync( + overlayPath, + JSON.stringify( + { llm: { default: { provider: "openai" } } }, + null, + 2, + ) + "\n", + ); + process.env.VELLUM_DEFAULT_WORKSPACE_CONFIG_PATH = overlayPath; + + mergeDefaultConfigAndSeedInferenceProfiles(); + const raw = JSON.parse(readFileSync(CONFIG_PATH, "utf-8")); + + // User profiles for the hatch provider (openai). + expect(raw.llm.activeProfile).toBe("custom-balanced"); + expect(raw.llm.profiles["custom-balanced"].provider).toBe("openai"); + expect(raw.llm.profiles["custom-balanced"].model).toBe("gpt-5.4-mini"); + expect(raw.llm.profiles["custom-balanced"].provider_connection).toBe( + "openai-personal", + ); + expect(raw.llm.profiles["custom-balanced"].source).toBe("user"); + expect(raw.llm.profiles["custom-quality-optimized"].provider).toBe( + "openai", + ); + expect(raw.llm.profiles["custom-quality-optimized"].model).toBe("gpt-5.4"); + expect(raw.llm.profiles["custom-cost-optimized"].provider).toBe("openai"); + expect(raw.llm.profiles["custom-cost-optimized"].model).toBe( + "gpt-5.4-nano", + ); + + // Managed anthropic profiles are also seeded. + expect(raw.llm.profiles.balanced.provider).toBe("anthropic"); + expect(raw.llm.profiles.balanced.provider_connection).toBe( + "anthropic-managed", + ); + expect(raw.llm.profiles.balanced.source).toBe("managed"); + expect(raw.llm.profiles["quality-optimized"].provider).toBe("anthropic"); + expect(raw.llm.profiles["cost-optimized"].provider).toBe("anthropic"); + }); + + test("off-platform managed profiles are overwritten on every boot", () => { + // Simulate a previous boot that left managed profiles on disk. + writeConfig({ + llm: { + profiles: { + balanced: { + source: "managed", + provider: "anthropic", + model: "old-model-from-previous-release", + provider_connection: "anthropic-managed", + }, + }, + activeProfile: "balanced", + }, + }); + + // Non-hatch boot (no overlay). Managed profiles should be overwritten + // with the latest templates. + mergeDefaultConfigAndSeedInferenceProfiles(); + const raw = JSON.parse(readFileSync(CONFIG_PATH, "utf-8")); + + expect(raw.llm.profiles.balanced.model).toBe("claude-sonnet-4-6"); + expect(raw.llm.profiles.balanced.provider_connection).toBe( + "anthropic-managed", + ); + expect(raw.llm.activeProfile).toBe("balanced"); + }); + test("platform-provided profile fragments are not polluted by managed seeds", () => { + process.env.IS_PLATFORM = "true"; + writeConfig({ llm: { default: { @@ -616,7 +774,9 @@ describe("loadConfig startup behavior", () => { provider: "anthropic", model: "claude-opus-4-7", }); - expect(raw.llm.activeProfile).toBe("balanced"); + // Off-platform hatch: user profiles are active. + expect(raw.llm.activeProfile).toBe("custom-balanced"); + expect(raw.llm.profiles["custom-balanced"].provider).toBe("anthropic"); expect(raw.llm.profiles.balanced.model).toBe("claude-sonnet-4-6"); }); diff --git a/assistant/src/config/loader.ts b/assistant/src/config/loader.ts index 6f562ff6791..4626ea370a6 100644 --- a/assistant/src/config/loader.ts +++ b/assistant/src/config/loader.ts @@ -519,12 +519,14 @@ export function deepMergeOverwrite( } export type DefaultWorkspaceConfigMergeResult = { + hadOverlay: boolean; providedLlmProfileNames: Set; providedLlmActiveProfile: boolean; }; function emptyDefaultWorkspaceConfigMergeResult(): DefaultWorkspaceConfigMergeResult { return { + hadOverlay: false, providedLlmProfileNames: new Set(), providedLlmActiveProfile: false, }; @@ -570,6 +572,7 @@ export function mergeDefaultWorkspaceConfig(): DefaultWorkspaceConfigMergeResult ); const providedProfiles = readPlainObject(llmDefaults?.profiles); const mergeResult: DefaultWorkspaceConfigMergeResult = { + hadOverlay: true, providedLlmProfileNames: new Set( providedProfiles ? Object.keys(providedProfiles) : [], ), diff --git a/assistant/src/config/seed-inference-profiles.ts b/assistant/src/config/seed-inference-profiles.ts index 754fb293a31..e6e409be66c 100644 --- a/assistant/src/config/seed-inference-profiles.ts +++ b/assistant/src/config/seed-inference-profiles.ts @@ -1,19 +1,20 @@ +import type { DrizzleDb } from "../memory/db-connection.js"; +import { + createConnection, + getConnection, +} from "../providers/inference/connections.js"; import { resolveModelIntent } from "../providers/model-intents.js"; import type { ModelIntent } from "../providers/types.js"; +import { credentialKey } from "../security/credential-key.js"; +import { getLogger } from "../util/logger.js"; import { loadRawConfig, saveRawConfig } from "./loader.js"; import { DEFAULT_CONTEXT_WINDOW_MAX_INPUT_TOKENS, type ProfileEntry, } from "./schemas/llm.js"; -/** - * Provider connection backing every managed profile. Seeded by - * `seedCanonicalConnections` in `providers/inference/connections.ts`; auth is - * `{ type: "platform" }` so dispatch resolves credentials from the logged-in - * Vellum account at call time. Users who want to use OpenAI / Gemini / a local - * model create their own connection + profile through the Providers UI; the - * daemon never auto-materializes a custom profile here. - */ +const log = getLogger("seed-inference-profiles"); + const MANAGED_CONNECTION_NAME = "anthropic-managed"; const MANAGED_PROFILE_PROVIDER: NonNullable = "anthropic"; @@ -23,15 +24,19 @@ const MANAGED_PROFILE_PROVIDER: NonNullable = * resolved at seed time from `PROVIDER_MODEL_INTENTS` so the catalog stays the * single source of truth for "which model does this intent map to?". */ -type ManagedProfileTemplate = Omit & { +type ManagedProfileTemplate = Omit< + ProfileEntry, + "provider" | "model" | "provider_connection" +> & { intent: ModelIntent; }; /** - * Anthropic-managed profiles. Always seeded so users can target Anthropic via - * their own key, even when the resolved default provider is something else. + * Managed Anthropic profiles. Overwritten on every daemon boot so Vellum can + * push model/config updates to customers in new releases. Platform overlays + * (`preserveProfileNames`) take precedence when present. */ -const ANTHROPIC_PROFILE_TEMPLATES: Record = { +const MANAGED_PROFILE_TEMPLATES: Record = { balanced: { intent: "balanced", source: "managed", @@ -64,45 +69,76 @@ const ANTHROPIC_PROFILE_TEMPLATES: Record = { }, }; +/** + * User profile templates. Materialized at hatch time for off-platform + * installations. Each points at the user's personal provider connection + * (backed by their API key in CES). + */ +const USER_PROFILE_TEMPLATES: Record = { + "custom-balanced": { + intent: "balanced", + source: "user", + label: "Balanced", + description: "Good balance of quality, cost, and speed", + maxTokens: 16000, + effort: "high", + thinking: { enabled: true, streamThinking: true }, + contextWindow: { maxInputTokens: DEFAULT_CONTEXT_WINDOW_MAX_INPUT_TOKENS }, + }, + "custom-quality-optimized": { + intent: "quality-optimized", + source: "user", + label: "Quality", + description: "Best results with the most capable model", + maxTokens: 32000, + effort: "max", + thinking: { enabled: true, streamThinking: true }, + contextWindow: { maxInputTokens: DEFAULT_CONTEXT_WINDOW_MAX_INPUT_TOKENS }, + }, + "custom-cost-optimized": { + intent: "latency-optimized", + source: "user", + label: "Speed", + description: "Fastest responses at lower cost", + maxTokens: 8192, + effort: "low", + thinking: { enabled: false, streamThinking: false }, + contextWindow: { maxInputTokens: DEFAULT_CONTEXT_WINDOW_MAX_INPUT_TOKENS }, + }, +}; + export const MANAGED_PROFILE_NAMES = new Set( - Object.keys(ANTHROPIC_PROFILE_TEMPLATES), + Object.keys(MANAGED_PROFILE_TEMPLATES), ); export type SeedInferenceProfilesOptions = { /** - * Managed profile names supplied by the platform/default overlay for this - * startup. Those entries are already on disk by the time seeding runs and - * should remain authoritative for this boot. + * Profile names supplied by the platform/default overlay for this startup. + * Those entries are already on disk and should remain authoritative. */ preserveProfileNames?: Iterable; preserveActiveProfile?: boolean; + /** True when a hatch overlay was consumed this startup. */ + isHatch?: boolean; + /** DB handle for creating user provider connections at hatch time. */ + db?: DrizzleDb; }; /** - * Seed managed inference profiles into the workspace config. + * Seed inference profiles into the workspace config. * - * Called on every daemon startup after workspace migrations and default-config - * overlay merge, but before the first `loadConfig()`. Ensures the 3 managed - * profiles (`balanced`, `quality-optimized`, `cost-optimized`) exist at their - * canonical names. Existing on-disk entries are never overwritten — platform - * overlays (covered by `preserveProfileNames`) and user edits via the - * Providers UI both stay authoritative across boots. + * Runs on every daemon startup. Two responsibilities: * - * Each materialized managed profile carries `provider_connection: - * "anthropic-managed"`. Dispatch resolves auth from that connection (seeded - * as `{ type: "platform" }` by `seedCanonicalConnections`). Users who want - * a different provider create a user-defined connection + profile pair via - * the Providers UI; this function never materializes user profiles. + * 1. **Managed profiles** (`balanced`, `quality-optimized`, `cost-optimized`): + * overwritten on every boot so Vellum can push model/config updates to + * customers. Each carries `provider_connection: "anthropic-managed"`. + * Platform overlays (`preserveProfileNames`) take precedence. * - * Active profile resolution: - * - `options.preserveActiveProfile === true` (overlay supplied an - * explicit `activeProfile`): keep the on-disk choice when it points - * at an existing profile. - * - Otherwise: fall back to `"balanced"` when the on-disk - * `activeProfile` is missing, points at a deleted profile, or points - * at a profile whose `provider` no longer matches the current - * `llm.default.provider` (e.g. a legacy overlay that re-hatches from - * OpenAI to Anthropic by updating only `default.provider`). + * 2. **User profiles** (`custom-balanced`, `custom-quality-optimized`, + * `custom-cost-optimized`): materialized once at hatch time for + * off-platform installations. Each points at a personal provider + * connection backed by the user's API key in CES. Subsequent boots + * leave these untouched — the user owns them. */ export function seedInferenceProfiles( options: SeedInferenceProfilesOptions = {}, @@ -120,16 +156,60 @@ export function seedInferenceProfiles( } const profiles = llm.profiles as Record>; - // Seed the 3 managed profiles at their canonical names on fresh installs. - // Each points at the `anthropic-managed` provider connection (seeded by - // `seedCanonicalConnections`). Skip overlays (`preservedProfileNames`) - // and any name already on disk — those stay authoritative. - for (const [name, template] of Object.entries(ANTHROPIC_PROFILE_TEMPLATES)) { + const isPlatform = + process.env.IS_PLATFORM === "true" || process.env.IS_PLATFORM === "1"; + + // 1. Managed profiles. Off-platform: overwrite on every boot so Vellum can + // push model/config updates in new releases. On-platform: insert only if + // absent — the platform controls profiles through overlays. + for (const [name, template] of Object.entries(MANAGED_PROFILE_TEMPLATES)) { if (preservedProfileNames.has(name)) continue; - if (readObject(profiles[name]) !== null) continue; - profiles[name] = materializeProfile(template); + if (isPlatform && readObject(profiles[name]) !== null) continue; + profiles[name] = materializeProfile( + template, + MANAGED_PROFILE_PROVIDER, + MANAGED_CONNECTION_NAME, + ); } + // 2. User profiles — only at hatch time for off-platform installations. + let userConnectionName: string | undefined; + if (options.isHatch && !isPlatform) { + const hatchProvider = readString(readObject(llm.default)?.provider); + if (hatchProvider && hatchProvider !== "ollama") { + userConnectionName = `${hatchProvider}-personal`; + + if (options.db) { + if (!getConnection(options.db, userConnectionName)) { + const credName = credentialKey(hatchProvider, "api_key"); + const result = createConnection(options.db, { + name: userConnectionName, + provider: hatchProvider, + auth: { type: "api_key", credential: credName }, + }); + if (!result.ok) { + log.warn( + { provider: hatchProvider, error: result.error }, + "Failed to create personal connection during hatch seeding", + ); + } + } + } + + const provider = + hatchProvider as NonNullable; + for (const [name, template] of Object.entries(USER_PROFILE_TEMPLATES)) { + if (preservedProfileNames.has(name)) continue; + profiles[name] = materializeProfile( + template, + provider, + userConnectionName, + ); + } + } + } + + // Active profile resolution. const requestedActiveProfile = readString(llm.activeProfile); const requestedActiveEntry = requestedActiveProfile !== undefined @@ -139,41 +219,37 @@ export function seedInferenceProfiles( const shouldPreserveActiveProfile = options.preserveActiveProfile === true && requestedActiveExists; - // When the overlay didn't claim ownership of `activeProfile`, detect a - // stale choice from a legacy re-hatch: the active profile's `provider` - // no longer matches `llm.default.provider`. The clearest case is an - // OpenAI→Anthropic re-hatch that updates only `default.provider` and - // leaves the previous `custom-balanced` active. Reset to `balanced` so - // the user lands on the managed default for the new provider. - let activeProviderMismatch = false; - if (!shouldPreserveActiveProfile && requestedActiveExists) { - const activeProvider = readString(requestedActiveEntry?.provider); - const defaultProvider = readString(readObject(llm.default)?.provider); - activeProviderMismatch = - activeProvider !== undefined && - defaultProvider !== undefined && - activeProvider !== defaultProvider; - } - - if ( - !shouldPreserveActiveProfile && - (!requestedActiveExists || activeProviderMismatch) - ) { - llm.activeProfile = "balanced"; + if (!shouldPreserveActiveProfile) { + if (options.isHatch) { + // Hatch = fresh setup. Pick the right default based on platform mode. + llm.activeProfile = userConnectionName ? "custom-balanced" : "balanced"; + } else if (!requestedActiveExists) { + llm.activeProfile = "balanced"; + } } + // Profile ordering — ensure all seeded profiles appear in the order array. const profileOrder = Array.isArray(llm.profileOrder) ? (llm.profileOrder as string[]) : []; const orderSet = new Set(profileOrder); - for (const name of Object.keys(ANTHROPIC_PROFILE_TEMPLATES)) { + for (const name of Object.keys(MANAGED_PROFILE_TEMPLATES)) { if (!orderSet.has(name)) { profileOrder.push(name); orderSet.add(name); } } + if (userConnectionName) { + for (const name of Object.keys(USER_PROFILE_TEMPLATES)) { + if (!orderSet.has(name)) { + profileOrder.push(name); + orderSet.add(name); + } + } + } llm.profileOrder = profileOrder; + // Tag any remaining profiles without a source as user-created. for (const [name, profile] of Object.entries(profiles)) { if (MANAGED_PROFILE_NAMES.has(name)) continue; if ( @@ -188,13 +264,17 @@ export function seedInferenceProfiles( saveRawConfig(config); } -function materializeProfile(template: ManagedProfileTemplate): ProfileEntry { +function materializeProfile( + template: ManagedProfileTemplate, + provider: NonNullable, + connectionName: string, +): ProfileEntry { const { intent, ...rest } = template; return { ...rest, - provider: MANAGED_PROFILE_PROVIDER, - provider_connection: MANAGED_CONNECTION_NAME, - model: resolveModelIntent(MANAGED_PROFILE_PROVIDER, intent), + provider, + provider_connection: connectionName, + model: resolveModelIntent(provider, intent), }; } diff --git a/assistant/src/daemon/lifecycle.ts b/assistant/src/daemon/lifecycle.ts index cd989181a3b..1a43d4dea78 100644 --- a/assistant/src/daemon/lifecycle.ts +++ b/assistant/src/daemon/lifecycle.ts @@ -514,15 +514,16 @@ export async function runDaemon(): Promise { // seeder and persisted alongside schema defaults. const defaultConfigMerge = mergeDefaultWorkspaceConfig(); - // Seed managed inference profiles into the workspace config. Runs after - // workspace migrations and default-config merge, but before loadConfig() so - // fresh hatches have profiles on disk before the first config load. Any - // profile fields explicitly supplied by the default overlay stay - // authoritative for this startup. + // Seed inference profiles into the workspace config. Managed Anthropic + // profiles are overwritten on every boot so Vellum can push updates. + // Off-platform hatches additionally create user profiles + a personal + // provider connection for the hatch provider. try { seedInferenceProfiles({ preserveProfileNames: defaultConfigMerge.providedLlmProfileNames, preserveActiveProfile: defaultConfigMerge.providedLlmActiveProfile, + isHatch: defaultConfigMerge.hadOverlay, + db: dbReady ? getDb() : undefined, }); log.info("Inference profile seeding complete"); } catch (err) { diff --git a/assistant/src/providers/inference/backfill.ts b/assistant/src/providers/inference/backfill.ts index a9fca9e317f..773394f07b8 100644 --- a/assistant/src/providers/inference/backfill.ts +++ b/assistant/src/providers/inference/backfill.ts @@ -38,7 +38,7 @@ const MANAGED_PROVIDERS = new Set(["anthropic", "openai", "gemini"]); * - self-heal manual config.json edits that drop the connection field * * Steps: - * 1. Seed canonical connections (INSERT … ON CONFLICT DO NOTHING). + * 1. Upsert canonical connections. * 2. Walk `llm.default`, `llm.profiles.*`, `llm.callSites.*` in config.json. * 3. For each entry without `provider_connection`, derive one from the * entry's `provider` field + the global inference mode and write it back. diff --git a/assistant/src/providers/inference/connections.ts b/assistant/src/providers/inference/connections.ts index 8b8a86359b7..14dc080ad9f 100644 --- a/assistant/src/providers/inference/connections.ts +++ b/assistant/src/providers/inference/connections.ts @@ -195,7 +195,7 @@ export function deleteConnection( } // --------------------------------------------------------------------------- -// Seed canonical connections (idempotent, used at boot time) +// Seed canonical connections (upsert, used at boot time) // --------------------------------------------------------------------------- const CANONICAL_CONNECTIONS: Array<{ name: string; provider: string; auth: Auth }> = [ @@ -206,26 +206,29 @@ const CANONICAL_CONNECTIONS: Array<{ name: string; provider: string; auth: Auth ]; /** - * Ensure the four canonical connections exist. Already-existing rows are left - * untouched. Safe to call on every boot. + * Upsert the four canonical connections on every boot. Existing rows are + * updated to the latest provider/auth values so Vellum can push connection + * changes to customers in new releases. */ export function seedCanonicalConnections(db: DrizzleDb): void { const now = Date.now(); for (const { name, provider, auth } of CANONICAL_CONNECTIONS) { - const exists = db - .select({ name: providerConnections.name }) - .from(providerConnections) - .where(eq(providerConnections.name, name)) - .get(); - - if (!exists) { - db.insert(providerConnections).values({ + db.insert(providerConnections) + .values({ name, provider, auth: JSON.stringify(auth), createdAt: now, updatedAt: now, - }).run(); - } + }) + .onConflictDoUpdate({ + target: providerConnections.name, + set: { + provider, + auth: JSON.stringify(auth), + updatedAt: now, + }, + }) + .run(); } }