From 21607c24f4a7d83e1c6eaf2fb881d511250be654 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sat, 29 Aug 2026 18:49:41 -0700 Subject: [PATCH 1/2] Add tests for preferring tenant-owned Ollama models Connecting a local Ollama instance currently launches Myra on the inherited curated name even when that tag is not on the instance. These tests pin the two resolution paths that must prefer a tenant-owned completion offering over an inherited catalog seed. --- .../chat/src/inference-preferences.test.ts | 48 ++++++++- .../test/complete-credential.test.ts | 99 +++++++++++++++++++ 2 files changed, 146 insertions(+), 1 deletion(-) diff --git a/packages/chat/src/inference-preferences.test.ts b/packages/chat/src/inference-preferences.test.ts index d3b6026de..f27443386 100644 --- a/packages/chat/src/inference-preferences.test.ts +++ b/packages/chat/src/inference-preferences.test.ts @@ -17,6 +17,7 @@ function offering( providerName: string; credentialId: string | null; capabilities: readonly Capability[]; + origin: ResolvedOffering["origin"]; }> = {}, ): ResolvedOffering { const { @@ -26,6 +27,7 @@ function offering( providerName = "anthropic", credentialId = "cred_1", capabilities = ["plain-text"], + origin = { tenantId: "tnt_bench", direct: true }, } = overrides; return { offering: { @@ -38,7 +40,7 @@ function offering( name: providerName, credentialId, } as ResolvedOffering["provider"], - origin: { tenantId: "tnt_bench", direct: true }, + origin, }; } @@ -164,4 +166,48 @@ describe("selectDefaultInferencePreferences", () => { { provider: "opencode-zen", model: "claude-sonnet-5" }, ]); }); + + test("CL-7185: a tenant-owned completion offering wins over an inherited curated name at the same priority", () => { + const result = selectDefaultInferencePreferences([ + offering({ + offeringId: "off_inherited", + canonicalName: "gpt-oss:20b", + providerName: "ollama", + priority: 0, + capabilities: ["plain-text"], + origin: { tenantId: "tnt_parent", direct: false }, + }), + offering({ + offeringId: "off_direct", + canonicalName: "llama3.2", + providerName: "ollama", + priority: 0, + capabilities: ["plain-text"], + origin: { tenantId: "tnt_bench", direct: true }, + }), + ]); + expect(result).toEqual([{ provider: "ollama", model: "llama3.2" }]); + }); + + test("CL-7185: inherit-only offerings still pick among inherited when none are origin.direct", () => { + const result = selectDefaultInferencePreferences([ + offering({ + offeringId: "off_inherited_curated", + canonicalName: "gpt-oss:20b", + providerName: "ollama", + priority: 0, + capabilities: ["plain-text"], + origin: { tenantId: "tnt_parent", direct: false }, + }), + offering({ + offeringId: "off_inherited_live", + canonicalName: "llama3.2", + providerName: "ollama", + priority: 0, + capabilities: ["plain-text"], + origin: { tenantId: "tnt_parent", direct: false }, + }), + ]); + expect(result).toEqual([{ provider: "ollama", model: "gpt-oss:20b" }]); + }); }); diff --git a/packages/onboarding/test/complete-credential.test.ts b/packages/onboarding/test/complete-credential.test.ts index c008bbb7f..c754be3fe 100644 --- a/packages/onboarding/test/complete-credential.test.ts +++ b/packages/onboarding/test/complete-credential.test.ts @@ -122,6 +122,24 @@ function resolvedCatalogResponse( }; } +function ownedCatalogModelsResponse(canonicalNames: string[]) { + return { + status: 200, + data: { + data: canonicalNames.map((canonicalName, index) => ({ + id: `own_mdl_${index}`, + tenantId: TENANT_ID, + canonicalName, + disabled: false, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + })), + nextCursor: null, + }, + cookies: [], + }; +} + describe("modelSourceFor", () => { test("every other provider ignores a baseURLOverride and never calls the hub", async () => { const api: ApiCall = (async () => { @@ -219,6 +237,87 @@ describe("modelSourceFor", () => { }, ]); } + if ( + method === "GET" && + path === `/api/tenants/${TENANT_ID}/catalog/models` + ) { + return ownedCatalogModelsResponse(["llama3.2", "gpt-oss:20b"]); + } + throw new Error(`unexpected call: ${method} ${path}`); + }; + + const result = await modelSourceFor( + api, + ["session=abc"], + TENANT_ID, + "ollama", + "ollama", + ); + expect(result.model).toBe("gpt-oss:20b"); + }); + + // CL-7185: discovery includes the inherited curated name, but this + // tenant's own catalog only lists the model the instance actually + // pulled. Prefer the owned name, never the inherited pin. + test("ollama prefers a tenant-owned model over an inherited curated name the instance does not own", async () => { + const api: ApiCall = async (method, path) => { + if (method === "GET" && path === `/api/tenants/${TENANT_ID}/models`) { + return resolvedCatalogResponse([ + { + canonicalName: "gpt-oss:20b", + providerName: "ollama", + capabilities: ["plain-text"], + }, + { + canonicalName: "llama3.2", + providerName: "ollama", + capabilities: ["plain-text"], + }, + ]); + } + if ( + method === "GET" && + path === `/api/tenants/${TENANT_ID}/catalog/models` + ) { + return ownedCatalogModelsResponse(["llama3.2"]); + } + throw new Error(`unexpected call: ${method} ${path}`); + }; + + const result = await modelSourceFor( + api, + ["session=abc"], + TENANT_ID, + "ollama", + "ollama", + ); + expect(result.model).toBe("llama3.2"); + }); + + // CL-7185: an empty owned list means inherit-only — keep discovery's + // curated preference rather than failing open to "no candidates". + test("ollama keeps the discovery pick when the tenant owns no catalog models", async () => { + const api: ApiCall = async (method, path) => { + if (method === "GET" && path === `/api/tenants/${TENANT_ID}/models`) { + return resolvedCatalogResponse([ + { + canonicalName: "gpt-oss:20b", + providerName: "ollama", + capabilities: ["plain-text"], + }, + { + canonicalName: "llama3.2", + providerName: "ollama", + capabilities: ["plain-text"], + }, + ]); + } + if ( + method === "GET" && + path === `/api/tenants/${TENANT_ID}/catalog/models` + ) { + return ownedCatalogModelsResponse([]); + } throw new Error(`unexpected call: ${method} ${path}`); }; From f5850d9ba13f4d5dda7c1b020f47ba6af324f437 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sat, 29 Aug 2026 18:51:05 -0700 Subject: [PATCH 2/2] Prefer tenant-owned Ollama models over inherited catalog seeds Discovery includes operator CATALOG_SEEDS rebound onto a child credential, so the curated name resolved even when /api/tags never listed it. Restrict Ollama default selection to tenant-owned catalog rows when any exist, and pick inference defaults from origin.direct offerings when any credentialed completion-capable offering is direct. --- packages/chat/src/inference-preferences.ts | 14 ++++++- .../onboarding/src/complete-credential.ts | 40 +++++++++++++++++-- 2 files changed, 48 insertions(+), 6 deletions(-) diff --git a/packages/chat/src/inference-preferences.ts b/packages/chat/src/inference-preferences.ts index 8c2baabfd..9f42c0279 100644 --- a/packages/chat/src/inference-preferences.ts +++ b/packages/chat/src/inference-preferences.ts @@ -69,6 +69,13 @@ export async function listConnectedProviders( * whose only reachable offering really is an embedding model gets no * default rather than one that fails every turn. * + * When any credentialed completion-capable offering is tenant-owned + * (`origin.direct`), the default is picked from that direct set only + * (CL-7185). Inherited operator catalog seeds otherwise win the name + * sort (`gpt-oss:20b` before `llama3.2`) and launch against a model + * the child's instance never pulled. Inherit-only catalogs stay on + * inherited offerings. + * * Kept DB-free so the tie/fallback rules stay covered by a plain unit * test; `listDefaultInferencePreferences` is the thin `@intx/db`-backed * wrapper around it. @@ -79,11 +86,14 @@ export function selectDefaultInferencePreferences( const credentialed = offerings.filter( (entry) => entry.provider.credentialId !== null, ); - const sorted = preferCompletionCapable( + const completionCapable = preferCompletionCapable( credentialed, (entry) => entry.offering.capabilities, (entry) => entry.model.canonicalName, - ) + ); + const direct = completionCapable.filter((entry) => entry.origin.direct); + const pool = direct.length > 0 ? direct : completionCapable; + const sorted = pool .slice() .sort( (left, right) => diff --git a/packages/onboarding/src/complete-credential.ts b/packages/onboarding/src/complete-credential.ts index 350ff396b..f8e971d7b 100644 --- a/packages/onboarding/src/complete-credential.ts +++ b/packages/onboarding/src/complete-credential.ts @@ -55,6 +55,7 @@ import { ModelInfo, + ModelResponse, PrincipalSummary, TenantResponse, paginatedSchema, @@ -262,6 +263,14 @@ type CatalogOfferingCandidate = { * broken toward the name this repo already knows serves tool calls and * thinking — never as a value this can return when the instance doesn't * actually offer it. + * + * Discovery (`GET /models`) includes inherited operator catalog seeds, so + * the curated name can "resolve" without living on this tenant's instance + * (CL-7185). When that name appears in discovery, this also reads + * tenant-owned `/catalog/models`. A non-empty owned list restricts + * candidates to those names before the curated preference / priority sort; + * an empty owned list keeps discovery (inherit-only). The extra fetch is + * skipped when discovery does not contain the curated name. */ async function resolveOllamaModelSource( api: ApiCall, @@ -310,15 +319,38 @@ async function resolveOllamaModelSource( (candidate) => candidate.canonicalName, ); const curatedName = catalogSeed.models[0]?.canonicalName; + let pool = completionCapable; + if ( + curatedName !== undefined && + completionCapable.some( + (candidate) => candidate.canonicalName === curatedName, + ) + ) { + const ownedResponse = await api( + "GET", + `/api/tenants/${tenantId}/catalog/models`, + undefined, + cookies, + ); + const owned = parseAs( + paginatedSchema(ModelResponse), + ownedResponse.data, + "tenant-owned catalog models response", + ).data; + if (owned.length > 0) { + const ownedNames = new Set(owned.map((model) => model.canonicalName)); + pool = completionCapable.filter((candidate) => + ownedNames.has(candidate.canonicalName), + ); + } + } const preferred = curatedName !== undefined - ? completionCapable.find( - (candidate) => candidate.canonicalName === curatedName, - ) + ? pool.find((candidate) => candidate.canonicalName === curatedName) : undefined; const winner = preferred ?? - [...completionCapable].sort( + [...pool].sort( (left, right) => left.priority - right.priority || left.canonicalName.localeCompare(right.canonicalName),