Skip to content
Open
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
40 changes: 40 additions & 0 deletions src/backend/local/local-model-config.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import type { Api, Model } from "@earendil-works/pi-ai";
import { getSupportedThinkingLevels } from "@earendil-works/pi-ai";
import type { ModelReasoningEffort } from "@/agent/model";
import {
DEFAULT_PI_PROVIDER,
isUnselectedLocalModelHandle,
Expand Down Expand Up @@ -48,6 +50,11 @@ interface LocalModelListEntry {
model_endpoint_type: string;
name: string;
provider_type: string;
/** Reasoning efforts this model supports, so the UI can offer a level picker. */
reasoning_capabilities?: {
supported_efforts?: ModelReasoningEffort[] | null;
mandatory?: boolean;
};
}

interface ListLocalModelsOptions {
Expand Down Expand Up @@ -247,6 +254,30 @@ export async function resolveAvailableLocalModelForTurn(input: {
};
}

// Translates a pi-ai Model's reasoning capabilities (reasoning flag +
// thinkingLevelMap) into Letta's reasoning_capabilities contract so the UI can
// offer a level picker for locally-hosted pi-ai models. pi-ai exposes the
// canonical supported list via getSupportedThinkingLevels, which honors
// `reasoning: false` and null thinkingLevelMap entries. The one naming
// mismatch: pi calls the reasoning-disabled level "off", Letta calls it "none"
// (the picker renders it as "Off", a 0-bar state distinct from the
// provider-default null option). A model that cannot disable reasoning
// reports `mandatory: true`.
function reasoningCapabilitiesForPiModel(
model: Model<Api> | undefined,
): LocalModelListEntry["reasoning_capabilities"] {
if (!model?.reasoning) return undefined;
const supportedLevels = getSupportedThinkingLevels(model);
const mandatory = !supportedLevels.includes("off");
const supportedEfforts = supportedLevels.map((level) =>
level === "off" ? "none" : (level as ModelReasoningEffort),
);
return {
supported_efforts: supportedEfforts,
...(mandatory ? { mandatory: true } : {}),
};
}

export async function listLocalModels(
storageDir?: string,
options: ListLocalModelsOptions = {},
Expand Down Expand Up @@ -277,6 +308,8 @@ export async function listLocalModels(
maxOutputTokens?: number;
modelEndpointType?: string;
name?: string;
/** Full pi-ai Model published by a runtime-managed provider. */
model?: Model<Api>;
} = {},
) => {
const handle =
Expand Down Expand Up @@ -312,6 +345,9 @@ export async function listLocalModels(
const providerType =
options.modelEndpointType ?? localProviderTypeForModelConfig(provider);
const name = options.name ?? catalogModel?.name ?? modelId;
const reasoningCapabilities = reasoningCapabilitiesForPiModel(
options.model ?? catalogModel,
);
models.push({
display_name: name,
handle,
Expand All @@ -321,6 +357,9 @@ export async function listLocalModels(
model_endpoint_type: providerType,
name,
provider_type: providerType,
...(reasoningCapabilities
? { reasoning_capabilities: reasoningCapabilities }
: {}),
});
};

Expand Down Expand Up @@ -398,6 +437,7 @@ export async function listLocalModels(
maxContextWindow: model.contextWindow,
maxOutputTokens: model.maxTokens,
name: model.name,
model,
});
}
}
Expand Down
124 changes: 124 additions & 0 deletions src/providers/local-pi-provider-catalog.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -431,6 +431,60 @@ describe("local pi provider catalog", () => {
}
});

test("local model listing surfaces reasoning capabilities for mod providers", async () => {
const storageDir = await mkdtemp(join(tmpdir(), "local-acme-reasoning-"));
try {
// Register the way a provider mod does: through registerPiProvider with
// an owner (the mod engine passes { id: owner.id, path: owner.path }).
registerPiProvider(
"acme",
{
baseUrl: "https://api.acme.dev/v1",
apiKey: "ACME_API_KEY",
api: "openai-completions",
listModels() {
return [
{
id: "acme-reasoner",
name: "Acme Reasoner",
reasoning: true,
thinkingLevelMap: {
minimal: null,
low: null,
medium: null,
high: "high",
max: "max",
},
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 128000,
maxTokens: 8192,
},
];
},
},
{ id: "mod-owner", path: "/tmp/mod-owner" },
);
await createOrUpdateLocalProvider({
storageDir,
providerType: "acme",
providerName: "acme",
apiKey: "acme-key",
});

const models = await listLocalModels(storageDir);
const reasoner = models.find(
(model) => model.handle === "acme/acme-reasoner",
);

expect(reasoner?.reasoning_capabilities).toEqual({
supported_efforts: ["none", "high", "max"],
});
} finally {
await rm(storageDir, { recursive: true, force: true });
}
});

test("local model listing passes mod OAuth api keys to listModels", async () => {
const storageDir = await mkdtemp(join(tmpdir(), "local-kilo-oauth-"));
try {
Expand Down Expand Up @@ -520,4 +574,74 @@ describe("local pi provider catalog", () => {
await rm(storageDir, { recursive: true, force: true });
}
});

test("local model listing surfaces pi-ai reasoning capabilities", async () => {
const storageDir = await mkdtemp(join(tmpdir(), "local-pi-provider-"));
try {
await createOrUpdateLocalProvider({
storageDir,
providerType: "deepseek",
providerName: "deepseek",
apiKey: "deepseek-key",
});

const flash = (await listLocalModels(storageDir)).find(
(model) => model.handle === "deepseek/deepseek-v4-flash",
);
// deepseek-v4-flash declares reasoning: true with thinkingLevelMap
// { high, max } and reasoning-able-off, so the UI should expose a level
// picker for it (null default + none/Off + high + max), never mandatory.
expect(flash?.reasoning_capabilities).toEqual({
supported_efforts: ["none", "high", "max"],
});
} finally {
await rm(storageDir, { recursive: true, force: true });
}
});

test("local model listing marks reasoning as mandatory when off is unsupported", async () => {
const storageDir = await mkdtemp(join(tmpdir(), "local-pi-provider-"));
try {
await createOrUpdateLocalProvider({
storageDir,
providerType: "opencode",
providerName: "opencode",
apiKey: "opencode-key",
});

const fable = (await listLocalModels(storageDir)).find(
(model) => model.handle === "opencode/claude-fable-5",
);
// claude-fable-5 is an adaptive-thinking model that cannot disable
// reasoning (no "off" in its supported levels), so the UI must not offer
// an Off option and must mark the capability mandatory.
expect(fable?.reasoning_capabilities).toEqual({
supported_efforts: ["minimal", "low", "medium", "high", "xhigh", "max"],
mandatory: true,
});
} finally {
await rm(storageDir, { recursive: true, force: true });
}
});

test("local model listing omits reasoning capabilities for non-reasoning models", async () => {
const storageDir = await mkdtemp(join(tmpdir(), "local-pi-provider-"));
try {
await createOrUpdateLocalProvider({
storageDir,
providerType: "groq",
providerName: "groq",
apiKey: "groq-key",
});

const llama = (await listLocalModels(storageDir)).find(
(model) => model.handle === "groq/llama-3.1-8b-instant",
);
// Non-reasoning models emit no reasoning_capabilities: the UI falls back
// to no picker / default effort for them.
expect(llama?.reasoning_capabilities).toBeUndefined();
} finally {
await rm(storageDir, { recursive: true, force: true });
}
});
});
Loading