Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 47 additions & 1 deletion packages/chat/src/inference-preferences.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ function offering(
providerName: string;
credentialId: string | null;
capabilities: readonly Capability[];
origin: ResolvedOffering["origin"];
}> = {},
): ResolvedOffering {
const {
Expand All @@ -26,6 +27,7 @@ function offering(
providerName = "anthropic",
credentialId = "cred_1",
capabilities = ["plain-text"],
origin = { tenantId: "tnt_bench", direct: true },
} = overrides;
return {
offering: {
Expand All @@ -38,7 +40,7 @@ function offering(
name: providerName,
credentialId,
} as ResolvedOffering["provider"],
origin: { tenantId: "tnt_bench", direct: true },
origin,
};
}

Expand Down Expand Up @@ -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" }]);
});
});
14 changes: 12 additions & 2 deletions packages/chat/src/inference-preferences.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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) =>
Expand Down
40 changes: 36 additions & 4 deletions packages/onboarding/src/complete-credential.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@

import {
ModelInfo,
ModelResponse,
PrincipalSummary,
TenantResponse,
paginatedSchema,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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),
Expand Down
99 changes: 99 additions & 0 deletions packages/onboarding/test/complete-credential.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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}`);
};

Expand Down
Loading