Skip to content
Draft
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
4 changes: 2 additions & 2 deletions scripts/source-file-size-baseline.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,12 +40,12 @@
"src/tools/impl/enter-worktree.ts": 1260,
"src/tools/manager.ts": 3080,
"src/tools/tool-execution-context.test.ts": 1138,
"src/types/protocol_v2.ts": 2958,
"src/types/protocol_v2.ts": 2954,
"src/websocket/listen-client-concurrency.test.ts": 2686,
"src/websocket/listen-client-protocol.test.ts": 5827,
"src/websocket/listener/commands/memory.ts": 1114,
"src/websocket/listener/file-commands.ts": 1053,
"src/websocket/listener/lifecycle.ts": 1051,
"src/websocket/listener/protocol-inbound.ts": 2302,
"src/websocket/listener/protocol-inbound.ts": 2299,
"src/websocket/listener/protocol-outbound.ts": 1085
}
32 changes: 26 additions & 6 deletions src/agent/available-models.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import { getBackend } from "@/backend";
import { refreshByokProviders } from "@/backend/api/providers";
import type {
ModelReasoningCapabilities,
ModelReasoningEffort,
} from "@/types/model-reasoning";
import { isOpenAICompatibleProxyEndpoint } from "@/utils/openai-endpoint";
import type { ModelReasoningEffort } from "./model";

const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes

Expand All @@ -14,12 +17,10 @@ export type AvailableModel = {
providerCategory?: string;
modelEndpoint?: string;
openAICompatibleProxy?: boolean;
reasoningCapabilities?: ReasoningCapabilities;
};

export type ReasoningCapabilities = {
supported_efforts?: ModelReasoningEffort[] | null;
mandatory?: boolean;
};
export type ReasoningCapabilities = ModelReasoningCapabilities;

type CacheEntry = {
handles: Set<string>;
Expand Down Expand Up @@ -112,7 +113,25 @@ export function getCachedOpenAICompatibleProxyHandles(): Set<string> | null {
}

export function getCachedAvailableModels(): AvailableModel[] | null {
return cache?.models.map((model) => ({ ...model })) ?? null;
return (
cache?.models.map((model) => ({
...model,
...(model.reasoningCapabilities
? {
reasoningCapabilities: {
...model.reasoningCapabilities,
...(Array.isArray(model.reasoningCapabilities.supported_efforts)
? {
supported_efforts: [
...model.reasoningCapabilities.supported_efforts,
],
}
: {}),
},
}
: {}),
})) ?? null
);
}

async function fetchFromNetwork(): Promise<CacheEntry> {
Expand Down Expand Up @@ -180,6 +199,7 @@ async function fetchFromNetwork(): Promise<CacheEntry> {
...(providerCategory ? { providerCategory } : {}),
...(modelEndpoint ? { modelEndpoint } : {}),
...(isOpenAICompatibleProxy ? { openAICompatibleProxy: true } : {}),
...(capabilities ? { reasoningCapabilities: capabilities } : {}),
};
if (!modelsByHandle.has(model.handle)) {
modelsByHandle.set(model.handle, availableModel);
Expand Down
64 changes: 44 additions & 20 deletions src/agent/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@
* Model resolution and handling utilities
*/
import { OPENAI_CODEX_PROVIDER_NAME } from "@/providers/openai-codex-constants";
import type {
ModelReasoningCapabilities,
ModelReasoningEffort,
ModelReasoningSelection,
} from "@/types/model-reasoning";
import { getDefaultModel, models, resolveModel } from "./model-catalog";
import {
CHATGPT_OAUTH_LLM_CONFIG_PROVIDER,
Expand All @@ -16,30 +21,18 @@ import {
// agent-presets package export); re-exported here so CLI code keeps a single
// import surface for model utilities.
export { getDefaultModel, models, resolveModel };

export type {
ModelReasoningEffort,
ModelReasoningSelection,
} from "@/types/model-reasoning";
export {
mapModelHandleToLlmConfigPatch,
normalizeKnownModelHandle,
normalizeModelHandleForRegistry,
resolveModelHandleFromLlmConfig,
} from "./model-handles";

export type ModelReasoningEffort =
| "none"
| "minimal"
| "low"
| "medium"
| "high"
| "xhigh"
| "max";

/** Null means use the upstream provider's default and omit reasoning_effort. */
export type ModelReasoningSelection = ModelReasoningEffort | null;

type ReasoningCapabilities = {
supported_efforts?: ModelReasoningEffort[] | null;
mandatory?: boolean;
};

const REASONING_EFFORT_ORDER: ModelReasoningEffort[] = [
"none",
"minimal",
Expand All @@ -65,6 +58,37 @@ export function isLocalModelHandle(modelHandle: string): boolean {
);
}

export function resolveReasoningTierLookupHandle(
modelHandle: string,
providerType?: string | null,
): string {
const normalizedHandle = normalizeModelHandleForRegistry(modelHandle);
if (normalizedHandle && normalizedHandle !== modelHandle) {
return normalizedHandle;
}
if (isLocalModelHandle(modelHandle)) {
return modelHandle;
}

const slashIndex = modelHandle.indexOf("/");
const modelName =
slashIndex >= 0 && slashIndex < modelHandle.length - 1
? modelHandle.slice(slashIndex + 1)
: null;
if (!providerType || !modelName) {
return normalizedHandle ?? modelHandle;
}

const registryProvider =
providerType === "chatgpt_oauth"
? OPENAI_CODEX_PROVIDER_NAME
: providerType;
const provider = modelHandle.slice(0, slashIndex);
return provider === registryProvider
? (normalizedHandle ?? modelHandle)
: `${registryProvider}/${modelName}`;
}

export function getLocalModelLabel(modelHandle: string): string {
const providerPrefix = LOCAL_MODEL_HANDLE_PREFIXES.find((prefix) =>
modelHandle.startsWith(prefix),
Expand Down Expand Up @@ -121,7 +145,7 @@ function displayRegistryHandleForServiceTier(
export function getReasoningTierOptionsForHandle(
modelHandle: string,
contextWindow?: number,
reasoningCapabilities?: ReasoningCapabilities | null,
reasoningCapabilities?: ModelReasoningCapabilities | null,
): Array<{
effort: ModelReasoningEffort;
modelId: string;
Expand Down Expand Up @@ -182,7 +206,7 @@ export function getReasoningTierOptionsForHandle(

export function getReasoningTierOptionsFromCapabilities(
modelHandle: string,
capabilities?: ReasoningCapabilities | null,
capabilities?: ModelReasoningCapabilities | null,
): Array<{
effort: ModelReasoningEffort;
modelId: string;
Expand Down Expand Up @@ -216,7 +240,7 @@ export function getByokOpenAIReasoningTierOptions(
options?: {
registryHandle?: string;
contextWindow?: number;
reasoningCapabilities?: ReasoningCapabilities | null;
reasoningCapabilities?: ModelReasoningCapabilities | null;
},
): Array<{
effort: ModelReasoningSelection;
Expand Down
6 changes: 5 additions & 1 deletion src/channels-public.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,11 @@ export {
LettaStreamCoreError,
LettaStreamNoAssistantMessageError,
} from "./channels/core-stream";
export type {
ChannelModelPickerData,
ChannelReasoningEffort,
ChannelReasoningSelection,
} from "./channels/model-picker-types";
export type {
ChannelMessageActionAdapter,
ChannelMessageActionContext,
Expand Down Expand Up @@ -40,7 +45,6 @@ export type {
ChannelAdapter,
ChannelChatType,
ChannelControlRequestEvent,
ChannelModelPickerData,
ChannelRoute,
ChannelThreadContext,
ChannelThreadContextEntry,
Expand Down
13 changes: 11 additions & 2 deletions src/channels/commands.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -753,6 +753,10 @@ describe("channel slash commands", () => {
],
availableHandles: ["openai/gpt-5", "anthropic/claude-sonnet-4-6"],
recentHandles: ["anthropic/claude-sonnet-4-6"],
reasoningOptions: [
{ effort: "low", modelId: "gpt-low" },
{ effort: "high", modelId: "gpt-high" },
],
});

expect(blocks).toBeDefined();
Expand All @@ -777,18 +781,23 @@ describe("channel slash commands", () => {
type: "section",
text: {
type: "mrkdwn",
text: "Choose a model for this routed conversation:",
text: "Choose a model or reasoning level for this routed conversation:",
},
});
expect(explanatoryBlock).not.toHaveProperty("accessory");
expect(actionsBlock?.type).toBe("actions");
expect(elements).toHaveLength(1);
expect(elements).toHaveLength(2);
expect(selectElement?.type).toBe("static_select");
expect(selectElement?.action_id).toBe("letta_channel_model_select");
expect(selectElement?.options?.map((option) => option.value)).toEqual([
"sonnet",
"gpt",
]);
expect(elements?.[1]?.action_id).toBe("letta_channel_reasoning_select");
expect(elements?.[1]?.options?.map((option) => option.value)).toEqual([
"low",
"high",
]);
expect(contextBlock?.type).toBe("context");
expect(JSON.stringify(blocks)).toContain("Claude Sonnet 4.6");
});
Expand Down
15 changes: 7 additions & 8 deletions src/channels/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,12 @@ import {
canRunChannelCommand,
} from "./access-control";
import { handleChannelFeedbackCommand } from "./feedback";
import type { ChannelModelPickerData } from "./model-picker-types";
import { channelModelCommandPrefix } from "./model-reasoning-command";
import { getChannelDisplayName } from "./plugin-registry";
import { buildDirectReplyOptions } from "./registry-presentation";
import type {
ChannelAdapter,
ChannelModelPickerData,
ChannelRoute,
InboundChannelMessage,
} from "./types";
Expand Down Expand Up @@ -281,6 +282,7 @@ const SLACK_MENTION_SLASH_COMMAND_EXAMPLES = [
"@agent /model",
"@agent /model list",
"@agent /model <handle-or-id>",
"@agent /model reasoning <level>",
"@agent /cancel",
"@agent /chat",
"@agent /feedback <message>",
Expand Down Expand Up @@ -335,6 +337,7 @@ export function buildChannelHelpMessage(channelId: string): string {
"@agent /model - show this thread's current model",
"@agent /model list - show available models",
"@agent /model <handle-or-id> - switch this thread's model",
"@agent /model reasoning <level> - change reasoning for the current model",
"@agent /status - show route and listener status",
"@agent /cancel - cancel the current turn",
"@agent /chat - show the web chat link",
Expand Down Expand Up @@ -619,12 +622,8 @@ export function getFallbackModelEntries(
return preferred.length > 0 ? preferred : Array.from(byHandle.values());
}

function modelCommandPrefix(channelId: string): "/model" | "@agent /model" {
return channelId === "slack" ? "@agent /model" : "/model";
}

export function buildChannelModelNotFoundText(channelId: string): string {
return `Model not found. Use ${modelCommandPrefix(channelId)} list to see available models.`;
return `Model not found. Use ${channelModelCommandPrefix(channelId)} list to see available models.`;
}

export function buildChannelCurrentModelMessage(
Expand All @@ -641,7 +640,7 @@ export function buildChannelCurrentModelMessage(
params.modelHandle && params.modelHandle !== params.modelLabel
? ` (${params.modelHandle})`
: "";
const switchCommand = modelCommandPrefix(channelId);
const switchCommand = channelModelCommandPrefix(channelId);
return [
`${displayName} current ${scope} model: ${params.modelLabel}${handleText}.`,
`Use ${switchCommand} list to see available models, or ${switchCommand} <handle-or-id> to switch.`,
Expand All @@ -654,7 +653,7 @@ function formatChannelModelEntry(
): string {
const selector = entry.id || entry.handle;
const handleText = entry.handle === entry.label ? "" : ` — ${entry.handle}`;
return `• ${entry.label}${handleText} (${modelCommandPrefix(channelId)} ${selector})`;
return `• ${entry.label}${handleText} (${channelModelCommandPrefix(channelId)} ${selector})`;
}

function appendModelEntrySection(
Expand Down
12 changes: 11 additions & 1 deletion src/channels/gateway-core.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -670,7 +670,11 @@ test("runtime registration exposes and updates model status without backend acce
startResponse: {
agent: {
id: "agent-1",
llm_config: { model: "anthropic/claude-sonnet-4-6" },
llm_config: {
model: "anthropic/claude-sonnet-4-6",
context_window: 950_000,
},
model_settings: { provider_type: "anthropic" },
} as RuntimeStartResponseMessage["agent"],
},
});
Expand All @@ -681,8 +685,14 @@ test("runtime registration exposes and updates model status without backend acce
expect(gateway.getModelStatus(TEST_RUNTIME)).toEqual({
modelHandle: "anthropic/claude-sonnet-4-6",
scope: "conversation",
contextWindow: 950_000,
providerType: "anthropic",
});

gateway.updateModelStatus(TEST_RUNTIME, "anthropic/claude-sonnet-4-6");
expect(gateway.getModelStatus(TEST_RUNTIME)?.contextWindow).toBe(950_000);
expect(gateway.getModelStatus(TEST_RUNTIME)?.providerType).toBe("anthropic");

gateway.updateModelStatus(TEST_RUNTIME, "openai/gpt-5");
expect(gateway.getModelStatus(TEST_RUNTIME)).toEqual({
modelHandle: "openai/gpt-5",
Expand Down
Loading