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
73 changes: 72 additions & 1 deletion apps/hub/src/config/models.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import { expect, test } from "bun:test";
import { MODEL_PROVIDERS, resolveModelChoice } from "./models.ts";
import {
MODEL_PROVIDERS,
frozenModelChoice,
invalidModelChoice,
resolveModelChoice,
} from "./models.ts";

test("a project runs on the only connected provider's profile, a chosen one, or none", () => {
expect(
Expand Down Expand Up @@ -32,4 +37,70 @@ test("a project runs on the only connected provider's profile, a chosen one, or
).toEqual({
refused: "model_profile_required",
});
expect(
resolveModelChoice(MODEL_PROVIDERS, { profile: "openai_default" }, [
"anthropic",
]),
).toMatchObject({
choice: { profile: "anthropic_default" },
});
expect(
resolveModelChoice(MODEL_PROVIDERS, { profile: "xai_default" }, [
"anthropic",
"openai",
]),
).toEqual({
refused: "model_profile_required",
});
expect(
frozenModelChoice(MODEL_PROVIDERS, { profile: "openai_default" }, [
"anthropic",
]),
).toBeNull();
});

test("invalidModelChoice refuses activating a profile whose provider is not connected", () => {
const custom = {
id: "custom_mix",
label: "Mix",
tiers: {
low: { provider: "anthropic", model: "claude-haiku-5-5", effort: "low" },
medium: {
provider: "openai",
model: "gpt-6.1-terra",
effort: "medium",
},
high: {
provider: "anthropic",
model: "claude-opus-5-5",
effort: "medium",
},
max: { provider: "anthropic", model: "claude-opus-5-5", effort: "max" },
},
} as const;

expect(
invalidModelChoice(MODEL_PROVIDERS, { profile: "anthropic_default" }, [
"anthropic",
]),
).toBeUndefined();
expect(
invalidModelChoice(MODEL_PROVIDERS, { profile: "openai_default" }, [
"anthropic",
]),
).toBe("openai_default:low:unconnected:openai");
expect(
invalidModelChoice(
MODEL_PROVIDERS,
{ profile: "custom_mix", custom: [custom] },
["anthropic"],
),
).toBe("custom_mix:medium:unconnected:openai");
expect(
invalidModelChoice(
MODEL_PROVIDERS,
{ profile: "anthropic_default", custom: [custom] },
["anthropic"],
),
).toBeUndefined();
});
23 changes: 15 additions & 8 deletions apps/hub/src/config/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -386,13 +386,16 @@ export const PROFILES: readonly Profile[] = [
},
),
];
/** Why a binding cannot run: an unlisted provider or model, or a model without limits. */
/** Why a binding cannot run: an unlisted or unconnected provider, an unlisted model, or no limits. */
function unrunnable(
directory: ModelDirectory,
binding: RoleBinding,
connected?: readonly string[],
): string | undefined {
const provider = modelProvider(directory, binding.provider);
if (!provider) return `provider:${binding.provider}`;
if (connected && !connected.includes(binding.provider))
return `unconnected:${binding.provider}`;
if (!provider.models.some((model) => model.id === binding.model))
return `model:${binding.provider}:${binding.model}`;
if (!bindingLimits(directory, binding.provider, binding.model))
Expand All @@ -406,21 +409,23 @@ for (const profile of PROFILES)
if (reason) throw new Error(`profile_unrunnable:${profile.id}:${reason}`);
}

/** The first part of a choice the profiles cannot run: its profile or a made profile's tier. */
/** The first part of a choice the profiles cannot run: its profile or the chosen profile's tier. */
export function invalidModelChoice(
directory: ModelDirectory,
choice: ModelChoice,
connected?: readonly string[],
): string | undefined {
const custom = choice.custom ?? [];
const ids = [...PROFILES, ...custom].map((profile) => profile.id);
const profiles = [...PROFILES, ...custom];
const ids = profiles.map((profile) => profile.id);
if (new Set(ids).size !== ids.length) return "duplicate_profile";
if (choice.profile !== undefined && !ids.includes(choice.profile))
return choice.profile;
// A made profile nobody runs may name a provider since removed; only the chosen one must run.
const chosen = custom.find((profile) => profile.id === choice.profile);
const chosen = profiles.find((profile) => profile.id === choice.profile);
if (chosen) {
for (const [tier, binding] of Object.entries(chosen.tiers)) {
const reason = unrunnable(directory, binding);
const reason = unrunnable(directory, binding, connected);
if (reason) return `${chosen.id}:${tier}:${reason}`;
}
}
Expand All @@ -431,13 +436,14 @@ export function invalidModelChoice(
function chosenProfile(
directory: ModelDirectory,
choice: ModelChoice | null,
connected?: readonly string[],
): CustomProfile | undefined {
const profile =
choice?.custom?.find((entry) => entry.id === choice.profile) ??
PROFILES.find((entry) => entry.id === choice?.profile);
return profile &&
Object.values(profile.tiers).every(
(binding) => !unrunnable(directory, binding),
(binding) => !unrunnable(directory, binding, connected),
)
? profile
: undefined;
Expand Down Expand Up @@ -470,7 +476,7 @@ export function resolveModelChoice(
choice: ModelChoice | null,
connected: readonly string[],
): { choice: ModelChoice } | { refused: ModelChoiceRefusal } {
const chosen = chosenProfile(directory, choice);
const chosen = chosenProfile(directory, choice, connected);
if (chosen) return { choice: frozen(chosen) };
const providers = [...new Set(connected)];
const only =
Expand Down Expand Up @@ -518,8 +524,9 @@ function frozen(profile: CustomProfile): ModelChoice {
export function frozenModelChoice(
directory: ModelDirectory,
choice: ModelChoice | null,
connected?: readonly string[],
): ModelChoice | null {
const profile = chosenProfile(directory, choice);
const profile = chosenProfile(directory, choice, connected);
return profile ? frozen(profile) : null;
}

Expand Down
20 changes: 2 additions & 18 deletions apps/hub/src/projects/model-choice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,22 +52,6 @@ async function personalChoice(
.models.value;
}

// A choice runs only when every tier's provider is connected in this workspace, not merely offered.
function frozenOn(
directory: ModelDirectory,
choice: ModelChoice | null,
connected: readonly string[],
): ModelChoice | null {
const kept = frozenModelChoice(directory, choice);
const profile = kept?.custom?.find((entry) => entry.id === kept.profile);
return profile &&
Object.values(profile.tiers).every((binding) =>
connected.includes(binding.provider),
)
? kept
: null;
}

/**
* The choice a project's roles run on. A project keeps the profile it started with; one that has
* none this server can run on a connected provider takes the caller's choice or the only connected
Expand All @@ -81,11 +65,11 @@ export async function projectModelChoice(
): Promise<ModelChoice> {
const { models } = await loadSettings(db, actor.tenantId, projectId);
const connected = await connectedProviders(db, directory, actor.tenantId);
const kept = frozenOn(directory, models.value, connected);
const kept = frozenModelChoice(directory, models.value, connected);
if (kept) return kept;
const resolved = resolveModelChoice(
directory,
frozenOn(directory, await personalChoice(db, actor), connected),
await personalChoice(db, actor),
connected,
);
if ("refused" in resolved)
Expand Down
15 changes: 10 additions & 5 deletions apps/hub/src/settings/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
} from "./policy.ts";
import { invalidPreference } from "../config/preferences.ts";
import { invalidModelChoice } from "../config/models.ts";
import { connectedProviders } from "../projects/model-choice.ts";
import { modelDirectory } from "./custom-providers.ts";
import { digest } from "../lib/digest.ts";
import type { Actor } from "../lib/actor.ts";
Expand Down Expand Up @@ -90,11 +91,15 @@ export function createSettingsService(db: DB["db"]) {
preference: unknown,
});
const models = input.fields.models?.value;
const refused =
models &&
invalidModelChoice(await modelDirectory(db, actor.tenantId), models);
if (refused)
throw new ProjectError(400, "unknown_model_choice", { choice: refused });
if (models) {
const directory = await modelDirectory(db, actor.tenantId);
const connected = await connectedProviders(db, directory, actor.tenantId);
const refused = invalidModelChoice(directory, models, connected);
if (refused)
throw new ProjectError(400, "unknown_model_choice", {
choice: refused,
});
}
return input;
}
return {
Expand Down
127 changes: 122 additions & 5 deletions apps/web/src/pages/profile-select.test.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,15 @@
import { describe, expect, test } from "bun:test";
import { renderToStaticMarkup } from "react-dom/server";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { ModelCatalog, Tiers } from "@corbits-builder/schemas/models";
import type {
ModelCatalog,
ModelChoice,
Tiers,
} from "@corbits-builder/schemas/models";
import { wording } from "../config.ts";
import { keys } from "../queries/keys.ts";
import { NewProjectProfile } from "./profile-select.tsx";
import { Profiles, available, editorProviders } from "./profiles.tsx";

function tiers(provider: string): Tiers {
const binding = { provider, model: "m", effort: "medium" as const };
Expand All @@ -12,10 +18,60 @@ function tiers(provider: string): Tiers {

function catalog(): ModelCatalog {
return {
providers: [],
roles: [],
tiers: [],
efforts: [],
providers: [
{
id: "xai",
plugin: "xai",
kind: "login",
label: "xAI",
hint: "xAI",
baseURL: "https://x.ai",
models: [{ id: "m", label: "Grok 4.7" }],
connections: [],
},
{
id: "anthropic",
plugin: "anthropic",
kind: "api_key",
label: "Anthropic",
hint: "Anthropic",
baseURL: "https://api.anthropic.com",
models: [{ id: "m", label: "Claude" }],
connections: [
{
id: "c1",
name: "Anthropic",
status: "connected",
credentialSet: true,
},
],
},
{
id: "openai",
plugin: "openai-responses",
kind: "api_key",
label: "OpenAI",
hint: "OpenAI",
baseURL: "https://api.openai.com/v1",
models: [{ id: "m", label: "GPT" }],
connections: [
{
id: "c2",
name: "OpenAI",
status: "connected",
credentialSet: true,
},
],
},
],
roles: [{ id: "coordinator", label: "Coordinator", hint: "", tier: "low" }],
tiers: [
{ id: "low", label: "Low" },
{ id: "medium", label: "Medium" },
{ id: "high", label: "High" },
{ id: "max", label: "Max" },
],
efforts: [{ id: "medium", label: "Medium" }],
customApis: [],
profiles: [
{
Expand Down Expand Up @@ -56,6 +112,18 @@ function render(value: string | undefined, models = catalog()) {
);
}

function renderProfiles(choice: ModelChoice | null, models = catalog()) {
const client = new QueryClient({
defaultOptions: { queries: { retry: false, staleTime: Infinity } },
});
client.setQueryData(keys.modelCatalog, models);
return renderToStaticMarkup(
<QueryClientProvider client={client}>
<Profiles initialField={{ value: choice, version: 1 }} />
</QueryClientProvider>,
);
}

describe("NewProjectProfile", () => {
test("selects the first runnable profile when none is automatic", () => {
const initial = render(undefined);
Expand All @@ -82,3 +150,52 @@ describe("NewProjectProfile", () => {
expect(render(undefined, models)).toBe("");
});
});

describe("Profiles", () => {
test("offers Use only on profiles whose providers are connected", () => {
const html = renderProfiles({ profile: "anthropic_default" });
expect(html).toContain(wording.profiles.unavailable);
expect(html).toContain(wording.profiles.active);
// Only GPT is runnable and not active; Grok is unavailable so it has no Use button.
expect(html.split(`>${wording.profiles.use}<`).length - 1).toBe(1);
});

test("falls back when the saved profile's provider is no longer connected", () => {
const models = catalog();
models.automaticProfile = "anthropic_default";
models.unresolved = {
code: "model_profile_required",
message: "Choose a connected profile.",
};
const html = renderProfiles({ profile: "xai_default" }, models);
expect(html).toContain(wording.profiles.active);
expect(html).toContain(wording.profiles.detail("Claude"));
expect(html).not.toContain(wording.profiles.detail("Grok"));
});

test("shows the unresolved banner when the saved profile's provider is disconnected and none is automatic", () => {
const models = catalog();
models.unresolved = {
code: "model_profile_required",
message: "Choose a connected profile.",
};
const html = renderProfiles({ profile: "xai_default" }, models);
expect(html).not.toContain(wording.profiles.active);
expect(html).toContain("Choose a connected profile.");
});

test("lists only connected providers when building a custom profile", () => {
const models = catalog();
expect(editorProviders(models).map((entry) => entry.id)).toEqual([
"anthropic",
"openai",
]);
expect(editorProviders(models, "xai").map((entry) => entry.id)).toEqual([
"xai",
"anthropic",
"openai",
]);
expect(available(models, tiers("anthropic"))).toBe(true);
expect(available(models, tiers("xai"))).toBe(false);
});
});
Loading