diff --git a/package.json b/package.json index d906cb6..ea849b2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ode", - "version": "0.1.20", + "version": "0.1.21", "description": "Coding anywhere with your coding agents connected", "module": "packages/core/index.ts", "type": "module", diff --git a/packages/ims/discord/settings.ts b/packages/ims/discord/settings.ts index e7750af..3be4e92 100644 --- a/packages/ims/discord/settings.ts +++ b/packages/ims/discord/settings.ts @@ -19,6 +19,10 @@ import { SETTINGS_LAUNCHER_ITEMS, type SettingsLauncherAction, } from "@/ims/shared/settings-domain"; +import { + refreshSettingsProviderData, + type SettingsProviderData, +} from "@/ims/shared/settings-provider-data"; import { AGENT_PROVIDERS, isAgentProviderId, @@ -31,7 +35,6 @@ import { getChannelModel, getChannelBaseBranch, resolveChannelCwd, - isAgentEnabled, setChannelAgentProvider, setChannelModel, setChannelWorkingDirectory, @@ -147,8 +150,16 @@ function parseProvider(value: string): AgentProviderId | null { return isAgentProviderId(normalized) ? normalized : null; } -function getProviderModels(provider: AgentProviderId): string[] { - return getProviderModelList(provider, getProviderModelListsFromConfig()); +function getProviderModels(provider: AgentProviderId, providerData?: SettingsProviderData): string[] { + if (!providerData) { + return getProviderModelList(provider, getProviderModelListsFromConfig()); + } + + return getProviderModelList(provider, { + opencode: providerData.opencodeModels, + codex: providerData.codexModels, + kilo: providerData.kiloModels, + }); } function draftKey(userId: string, channelId: string): string { @@ -170,13 +181,15 @@ function getDraftOrInitial(userId: string, channelId: string): { provider: Agent function buildChannelSettingsPickerPayload(params: { channelId: string; userId: string; + providerData?: SettingsProviderData; }): { content: string; components: Array | ActionRowBuilder>; } { - const { channelId, userId } = params; + const { channelId, userId, providerData } = params; const draft = getDraftOrInitial(userId, channelId); - const providerOptions = AGENT_PROVIDERS.map((provider) => ({ + const enabledProviders = providerData?.enabledProviders ?? AGENT_PROVIDERS; + const providerOptions = enabledProviders.map((provider) => ({ label: provider, value: provider, default: provider === draft.provider, @@ -191,7 +204,7 @@ function buildChannelSettingsPickerPayload(params: { new ActionRowBuilder().addComponents(providerSelect), ]; - const models = getProviderModels(draft.provider); + const models = getProviderModels(draft.provider, providerData); if (providerSupportsModelSelection(draft.provider)) { const selectedModel = findMatchingModel(models, draft.model) ?? ""; const modelOptions = models.length > 0 @@ -418,25 +431,26 @@ function stringSelectLabel(params: { return label; } -function buildChannelSettingsModal(channelId: string): ModalBuilder { +function buildChannelSettingsModal(channelId: string, providerData?: SettingsProviderData): ModalBuilder { const provider = getChannelAgentProvider(channelId); const model = getChannelModel(channelId) || ""; - const providerModels = getProviderModels(provider); + const providerModels = getProviderModels(provider, providerData); const selectedModel = findMatchingModel(providerModels, model) ?? providerModels[0] ?? ""; const baseBranch = getChannelBaseBranch(channelId) || "main"; const workingDirectory = resolveChannelCwd(channelId).workingDirectory || ""; const systemMessage = getChannelSystemMessage(channelId) || ""; + const enabledProviders = providerData?.enabledProviders ?? AGENT_PROVIDERS; const modalLabels: LabelBuilder[] = [ stringSelectLabel({ id: "agent_provider", label: "Agent provider", - options: AGENT_PROVIDERS.map((item) => ({ + options: enabledProviders.map((item) => ({ label: item, value: item, default: item === provider, })), - placeholder: AGENT_PROVIDERS.join(", "), + placeholder: enabledProviders.join(", "), }), ]; @@ -595,7 +609,8 @@ async function handleLauncherButtonInteraction(interaction: any): Promise if (modalKind === DISCORD_MODAL_CHANNEL) { const providerValue = (getModalSelectValue(interaction, "agent_provider") || getModalValue(interaction, "agent_provider")).trim(); const parsedProvider = parseProvider(providerValue); - if (!parsedProvider || !isAgentEnabled(parsedProvider)) { + const providerData = parsedProvider ? await refreshSettingsProviderData(parsedProvider) : null; + if (!parsedProvider || !providerData?.enabledProviders.includes(parsedProvider)) { await interaction.reply({ - content: `Invalid provider. Use one of: ${AGENT_PROVIDERS.join(", ")}`, + content: `Invalid provider. Use one of: ${(providerData?.enabledProviders ?? AGENT_PROVIDERS).join(", ")}`, flags: MessageFlags.Ephemeral, }); return true; @@ -633,14 +649,6 @@ async function handleModalSubmitInteraction(interaction: any): Promise const modelInputRaw = (getModalSelectValue(interaction, "model") || getModalValue(interaction, "model")).trim(); const modelInput = modelInputRaw === PROVIDER_DEFAULT_MODEL_VALUE ? "" : modelInputRaw; - const providerModels = getProviderModels(parsedProvider); - if (providerModels.length > 0 && modelInput && !findMatchingModel(providerModels, modelInput)) { - await interaction.reply({ - content: "Model is not available for the selected provider.", - flags: MessageFlags.Ephemeral, - }); - return true; - } const workingDirectory = getModalValue(interaction, "working_directory").trim(); const baseBranch = getModalValue(interaction, "base_branch").trim() || "main"; @@ -650,7 +658,11 @@ async function handleModalSubmitInteraction(interaction: any): Promise setChannelModel(channelId, resolveStoredModelForProvider({ provider: parsedProvider, selectedModel: modelInput, - lists: getProviderModelListsFromConfig(), + lists: { + opencode: providerData.opencodeModels, + codex: providerData.codexModels, + kilo: providerData.kiloModels, + }, })); setChannelWorkingDirectory(channelId, workingDirectory.length > 0 ? workingDirectory : null); setChannelBaseBranch(channelId, baseBranch); @@ -838,17 +850,18 @@ async function handleChannelSettingsComponentInteraction(interaction: any): Prom if (action === "provider") { const selected = interaction.values?.[0] as string | undefined; const parsed = selected ? parseProvider(selected) : null; - if (!parsed || !isAgentEnabled(parsed)) { + const providerData = parsed ? await refreshSettingsProviderData(parsed) : null; + if (!parsed || !providerData?.enabledProviders.includes(parsed)) { await interaction.reply({ content: "Selected provider is invalid or disabled.", flags: MessageFlags.Ephemeral }); return true; } - const models = getProviderModels(parsed); + const models = getProviderModels(parsed, providerData); const nextDraft = { provider: parsed, model: models.length > 0 ? (findMatchingModel(models, draft.model) ?? models[0]!) : "", }; channelSettingsDrafts.set(key, nextDraft); - await interaction.update(buildChannelSettingsPickerPayload({ channelId, userId })); + await interaction.update(buildChannelSettingsPickerPayload({ channelId, userId, providerData })); return true; } @@ -860,22 +873,23 @@ async function handleChannelSettingsComponentInteraction(interaction: any): Prom model: selected === PROVIDER_DEFAULT_MODEL_VALUE ? "" : selected, }); } - await interaction.update(buildChannelSettingsPickerPayload({ channelId, userId })); + const providerData = await refreshSettingsProviderData(draft.provider); + await interaction.update(buildChannelSettingsPickerPayload({ channelId, userId, providerData })); return true; } if (action === "save") { - const models = getProviderModels(draft.provider); - if (models.length > 0 && draft.model && !findMatchingModel(models, draft.model)) { - await interaction.reply({ content: "Selected model is no longer available.", flags: MessageFlags.Ephemeral }); - return true; - } + const providerData = await refreshSettingsProviderData(draft.provider); setChannelAgentProvider(channelId, draft.provider); setChannelModel(channelId, resolveStoredModelForProvider({ provider: draft.provider, selectedModel: draft.model, - lists: getProviderModelListsFromConfig(), + lists: { + opencode: providerData.opencodeModels, + codex: providerData.codexModels, + kilo: providerData.kiloModels, + }, })); channelSettingsDrafts.delete(key); await interaction.reply({ content: "Channel provider/model updated.", flags: MessageFlags.Ephemeral }); @@ -883,7 +897,8 @@ async function handleChannelSettingsComponentInteraction(interaction: any): Prom } if (action === "edit") { - await interaction.showModal(buildChannelSettingsModal(channelId)); + const providerData = await refreshSettingsProviderData(draft.provider); + await interaction.showModal(buildChannelSettingsModal(channelId, providerData)); return true; } diff --git a/packages/ims/lark/client.ts b/packages/ims/lark/client.ts index 8dec6ca..2d0222c 100644 --- a/packages/ims/lark/client.ts +++ b/packages/ims/lark/client.ts @@ -2,6 +2,7 @@ import { createAgentAdapter } from "@/agents/adapter"; import type { OpenCodeMessageContext } from "@/agents/types"; import * as Lark from "@larksuiteoapi/node-sdk"; import { + getChannelAgentProvider, getChannelSystemMessage, getGitHubInfoForUser, getLarkAppCredentials, @@ -39,6 +40,7 @@ import { resolveLarkSettingsCardAction, sendLarkSettingsCard, } from "./settings"; +import { refreshSettingsProviderData } from "@/ims/shared/settings-provider-data"; import { createProcessorManager } from "@/ims/shared/processor-manager"; import { extractFormValues, @@ -958,31 +960,37 @@ async function processLarkCardAction(payload: unknown): Promise { return; } + const detailAction = ( + action === "set_general_settings" + || action === "set_general_status_format" + || action === "set_general_status_frequency" + || action === "set_general_git_strategy" + || action === "set_general_auto_update" + || action === "set_channel_settings" + || action === "set_github_info" + || action === "clear_github_info" + ) + ? ( + action === "set_channel_settings" + ? "open_settings_modal" + : action === "set_github_info" || action === "clear_github_info" + ? "open_github_token_modal" + : "open_general_settings_modal" + ) + : action; + + const providerData = detailAction === "open_settings_modal" + ? await refreshSettingsProviderData(getChannelAgentProvider(channelId)) + : undefined; + const card = action === "open_settings_launcher" ? null : buildLarkSettingsDetailCard({ - action: ( - action === "set_general_settings" - || - action === "set_general_status_format" - || action === "set_general_status_frequency" - || action === "set_general_git_strategy" - || action === "set_general_auto_update" - || action === "set_channel_settings" - || action === "set_github_info" - || action === "clear_github_info" - ) - ? ( - action === "set_channel_settings" - ? "open_settings_modal" - : action === "set_github_info" || action === "clear_github_info" - ? "open_github_token_modal" - : "open_general_settings_modal" - ) - : action, + action: detailAction, channelId, threadId, userId: userId || "", + providerData, notice: ( action === "set_general_settings" || diff --git a/packages/ims/lark/settings.ts b/packages/ims/lark/settings.ts index 4ae4968..0adb9d7 100644 --- a/packages/ims/lark/settings.ts +++ b/packages/ims/lark/settings.ts @@ -18,6 +18,7 @@ import { getProviderModelListsFromConfig, SETTINGS_LAUNCHER_ITEMS, } from "@/ims/shared/settings-domain"; +import type { SettingsProviderData } from "@/ims/shared/settings-provider-data"; export type LarkSettingsCardAction = | "open_settings_launcher" @@ -155,8 +156,9 @@ export function buildLarkSettingsDetailCard(params: { threadId: string; userId: string; notice?: string; + providerData?: SettingsProviderData; }): Record { - const { action, channelId, threadId, userId, notice } = params; + const { action, channelId, threadId, userId, notice, providerData } = params; if (action === "open_general_settings_modal") { const general = getUserGeneralSettings(); @@ -290,11 +292,17 @@ export function buildLarkSettingsDetailCard(params: { const cwd = resolveChannelCwd(channelId).workingDirectory || "(not set)"; const baseBranch = getChannelBaseBranch(channelId); const systemMessage = getChannelSystemMessage(channelId) || "(none)"; - const providerOptions = getEnabledProvidersWithFallback().map((item) => ({ + const providerOptions = (providerData?.enabledProviders ?? getEnabledProvidersWithFallback()).map((item) => ({ text: { tag: "plain_text", content: item }, value: item, })); - const modelLists = getProviderModelListsFromConfig(); + const modelLists = providerData + ? { + opencode: providerData.opencodeModels, + codex: providerData.codexModels, + kilo: providerData.kiloModels, + } + : getProviderModelListsFromConfig(); const providerModels = provider === "codex" ? modelLists.codex : provider === "kilo" diff --git a/packages/ims/shared/settings-provider-data.ts b/packages/ims/shared/settings-provider-data.ts new file mode 100644 index 0000000..d6ee316 --- /dev/null +++ b/packages/ims/shared/settings-provider-data.ts @@ -0,0 +1,68 @@ +import { startServer as startCodexServer } from "@/agents/codex"; +import { + getCodexModels, + getEnabledAgentProviders, + getKiloModels, + getOpenCodeModels, + invalidateOdeConfigCache, +} from "@/config"; +import { runAgentCheck, type AgentCheckResult } from "@/core/web/agent-check"; +import { AGENT_PROVIDERS, type AgentProviderId } from "@/shared/agent-provider"; + +export type SettingsProviderData = { + enabledProviders: AgentProviderId[]; + opencodeModels: string[]; + codexModels: string[]; + kiloModels: string[]; +}; + +function getSelectableProvidersFromConfig(): AgentProviderId[] { + const enabled = getEnabledAgentProviders().filter( + (provider): provider is AgentProviderId => AGENT_PROVIDERS.includes(provider as AgentProviderId) + ); + if (enabled.length > 0) return enabled; + return Array.from(AGENT_PROVIDERS); +} + +function getSelectableProvidersFromAgentCheck( + result: AgentCheckResult, + selectedProvider?: AgentProviderId +): AgentProviderId[] { + const enabled = AGENT_PROVIDERS.filter((provider) => { + if (provider === "claudecode") return result.claudecode; + return result[provider]; + }); + + if (selectedProvider && !enabled.includes(selectedProvider)) { + enabled.unshift(selectedProvider); + } + + if (enabled.length > 0) return enabled; + return getSelectableProvidersFromConfig(); +} + +export async function refreshSettingsProviderData(selectedProvider?: AgentProviderId): Promise { + invalidateOdeConfigCache(); + + let agentCheckResult: AgentCheckResult | null = null; + try { + agentCheckResult = await runAgentCheck(); + } catch { + // Fall back to the config currently loaded from disk. + } + + try { + await startCodexServer(); + } catch { + // Fall back to models currently stored in local config. + } + + return { + enabledProviders: agentCheckResult + ? getSelectableProvidersFromAgentCheck(agentCheckResult, selectedProvider) + : getSelectableProvidersFromConfig(), + opencodeModels: agentCheckResult ? agentCheckResult.opencodeModels : getOpenCodeModels(), + codexModels: getCodexModels(), + kiloModels: agentCheckResult ? agentCheckResult.kiloModels : getKiloModels(), + }; +} diff --git a/packages/ims/slack/commands.ts b/packages/ims/slack/commands.ts index 249c6f4..bc3fadb 100644 --- a/packages/ims/slack/commands.ts +++ b/packages/ims/slack/commands.ts @@ -49,8 +49,7 @@ import { type StatusMessageFormat, type GitStrategy, } from "@/config"; -import { startServer as startOpenCodeServer } from "@/agents/opencode"; -import { startServer as startCodexServer } from "@/agents/codex"; +import { refreshSettingsProviderData } from "@/ims/shared/settings-provider-data"; const SETTINGS_LAUNCH_ACTION = "open_settings_modal"; const SETTINGS_MODAL_ID = "settings_modal"; @@ -513,25 +512,14 @@ export function setupInteractiveHandlers(): void { const triggerId = getActionTriggerId(actionBody); if (!channelId || !triggerId) return; - try { - await startOpenCodeServer(); - } catch { - // Fall back to models currently stored in local config. - } - try { - await startCodexServer(); - } catch { - // Fall back to models currently stored in local config. - } - - const enabledProviders = getSelectableProviders(); + const refreshedData = await refreshSettingsProviderData(toSelectableProvider(getChannelAgentProvider(channelId))); const view = buildSettingsModal({ channelId, - enabledProviders, - opencodeModels: getOpenCodeModels(), - codexModels: getCodexModels(), - kiloModels: getKiloModels(), + enabledProviders: refreshedData.enabledProviders, + opencodeModels: refreshedData.opencodeModels, + codexModels: refreshedData.codexModels, + kiloModels: refreshedData.kiloModels, selectedProvider: toSelectableProvider(getChannelAgentProvider(channelId)), selectedModel: getChannelModel(channelId), workingDirectory: resolveChannelCwd(channelId).workingDirectory, @@ -606,19 +594,7 @@ export function setupInteractiveHandlers(): void { if (!channelId) return; const selectedOption = getActionSelectedOptionValue(actionBody); const selectedProvider = parseAgentProvider(selectedOption); - if (selectedProvider === "opencode") { - try { - await startOpenCodeServer(); - } catch { - // Fall back to models currently stored in local config. - } - } else if (selectedProvider === "codex") { - try { - await startCodexServer(); - } catch { - // Fall back to models currently stored in local config. - } - } + const refreshedData = await refreshSettingsProviderData(selectedProvider); const selectedModel = view.state?.values?.[MODEL_BLOCK]?.[MODEL_ACTION]?.selected_option?.value || getChannelModel(channelId) || undefined; @@ -632,10 +608,10 @@ export function setupInteractiveHandlers(): void { const updatedView = buildSettingsModal({ channelId, - enabledProviders: getSelectableProviders(), - opencodeModels: getOpenCodeModels(), - codexModels: getCodexModels(), - kiloModels: getKiloModels(), + enabledProviders: refreshedData.enabledProviders, + opencodeModels: refreshedData.opencodeModels, + codexModels: refreshedData.codexModels, + kiloModels: refreshedData.kiloModels, selectedProvider, selectedModel, workingDirectory,