From d61902d42a1b7c4ec350ace4c7e280e58a9c7e2f Mon Sep 17 00:00:00 2001 From: SiYue-ZO <2835601846@qq.com> Date: Mon, 18 May 2026 13:01:39 +0800 Subject: [PATCH 01/20] feat(config): add ResetToDefaults and CLI `config reset` command Export MakeBackup for external use, add ResetToDefaults function that backs up current config, creates defaults, and preserves security credentials. Add `picoclaw config reset` CLI command with --force flag. --- cmd/picoclaw/internal/config/command.go | 57 +++++++++++++++++++++++++ cmd/picoclaw/main.go | 2 + pkg/config/config.go | 21 +++++++-- pkg/config/config_test.go | 26 +++++------ 4 files changed, 89 insertions(+), 17 deletions(-) create mode 100644 cmd/picoclaw/internal/config/command.go diff --git a/cmd/picoclaw/internal/config/command.go b/cmd/picoclaw/internal/config/command.go new file mode 100644 index 00000000..3329d491 --- /dev/null +++ b/cmd/picoclaw/internal/config/command.go @@ -0,0 +1,57 @@ +package config + +import ( + "fmt" + "strings" + + "github.com/spf13/cobra" + + "github.com/sipeed/picoclaw/cmd/picoclaw/internal" + "github.com/sipeed/picoclaw/pkg/config" +) + +func NewConfigCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "config", + Short: "Manage configuration", + } + + cmd.AddCommand(newResetCommand()) + return cmd +} + +func newResetCommand() *cobra.Command { + var force bool + + cmd := &cobra.Command{ + Use: "reset", + Short: "Reset configuration to factory defaults", + Args: cobra.NoArgs, + Example: ` picoclaw config reset + picoclaw config reset --force`, + RunE: func(_ *cobra.Command, _ []string) error { + if !force { + fmt.Print("Reset config to factory defaults? API keys will be preserved. (y/n): ") + var response string + fmt.Scanln(&response) + if strings.ToLower(strings.TrimSpace(response)) != "y" { + fmt.Println("Aborted.") + return nil + } + } + + configPath := internal.GetConfigPath() + if err := config.ResetToDefaults(configPath); err != nil { + return fmt.Errorf("reset failed: %w", err) + } + fmt.Println("Configuration has been reset to factory defaults.") + fmt.Println("A backup of the previous config was created in the same directory.") + return nil + }, + } + + cmd.Flags().BoolVarP(&force, "force", "f", false, + "Skip confirmation prompt") + + return cmd +} diff --git a/cmd/picoclaw/main.go b/cmd/picoclaw/main.go index abcf03a3..11d0a8bb 100644 --- a/cmd/picoclaw/main.go +++ b/cmd/picoclaw/main.go @@ -16,6 +16,7 @@ import ( "github.com/sipeed/picoclaw/cmd/picoclaw/internal" "github.com/sipeed/picoclaw/cmd/picoclaw/internal/agent" "github.com/sipeed/picoclaw/cmd/picoclaw/internal/auth" + configcmd "github.com/sipeed/picoclaw/cmd/picoclaw/internal/config" "github.com/sipeed/picoclaw/cmd/picoclaw/internal/cliui" "github.com/sipeed/picoclaw/cmd/picoclaw/internal/cron" "github.com/sipeed/picoclaw/cmd/picoclaw/internal/gateway" @@ -82,6 +83,7 @@ picoclaw --no-color status`, }) cmd.AddCommand( + configcmd.NewConfigCommand(), onboard.NewOnboardCommand(), agent.NewAgentCommand(), auth.NewAuthCommand(), diff --git a/pkg/config/config.go b/pkg/config/config.go index 8ed3da13..61d7cba5 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -1239,7 +1239,7 @@ func LoadConfig(path string) (*Config, error) { return nil, err } - err = makeBackup(path) + err = MakeBackup(path) if err != nil { return nil, err } @@ -1293,7 +1293,7 @@ func LoadConfig(path string) (*Config, error) { return nil, err } - err = makeBackup(path) + err = MakeBackup(path) if err != nil { return nil, err } @@ -1345,7 +1345,7 @@ func LoadConfig(path string) (*Config, error) { return nil, err } - err = makeBackup(path) + err = MakeBackup(path) if err != nil { return nil, err } @@ -1496,7 +1496,7 @@ func applySkillsRegistryEnvCompat(cfg *Config) { cfg.Tools.Skills.Registries.Set("github", githubCfg) } -func makeBackup(path string) error { +func MakeBackup(path string) error { if _, err := os.Stat(path); os.IsNotExist(err) { return nil } @@ -1624,6 +1624,19 @@ func (c *Config) SecurityCopyFrom(path string) error { return loadSecurityConfig(c, securityPath(path)) } +// ResetToDefaults backs up the current config, creates a default config, +// preserves security credentials from the existing config, and saves it. +func ResetToDefaults(configPath string) error { + if err := MakeBackup(configPath); err != nil { + return fmt.Errorf("backup before reset: %w", err) + } + cfg := DefaultConfig() + if err := cfg.SecurityCopyFrom(configPath); err != nil { + logger.WarnF("could not preserve security config", map[string]any{"error": err}) + } + return SaveConfig(configPath, cfg) +} + func expandMultiKeyModels(models []*ModelConfig) []*ModelConfig { var expanded []*ModelConfig diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index 96417612..c808293f 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -2602,7 +2602,7 @@ func TestFilterSensitiveData_AllTokenTypes(t *testing.T) { } // --------------------------------------------------------------------------- -// makeBackup tests +// MakeBackup tests // --------------------------------------------------------------------------- // TestMakeBackup_WithDateSuffix verifies backup files include a date suffix. @@ -2613,8 +2613,8 @@ func TestMakeBackup_WithDateSuffix(t *testing.T) { t.Fatalf("WriteFile: %v", err) } - if err := makeBackup(configPath); err != nil { - t.Fatalf("makeBackup: %v", err) + if err := MakeBackup(configPath); err != nil { + t.Fatalf("MakeBackup: %v", err) } entries, err := os.ReadDir(dir) @@ -2653,8 +2653,8 @@ func TestMakeBackup_AlsoBacksSecurityFile(t *testing.T) { os.WriteFile(configPath, []byte(`{"version":2}`), 0o600) os.WriteFile(secPath, []byte(`model_list:\n test:0:\n api_keys:\n - "sk-test"\n`), 0o600) - if err := makeBackup(configPath); err != nil { - t.Fatalf("makeBackup: %v", err) + if err := MakeBackup(configPath); err != nil { + t.Fatalf("MakeBackup: %v", err) } entries, err := os.ReadDir(dir) @@ -2680,14 +2680,14 @@ func TestMakeBackup_AlsoBacksSecurityFile(t *testing.T) { } } -// TestMakeBackup_NonexistentFileSkipsBackup verifies that makeBackup returns nil +// TestMakeBackup_NonexistentFileSkipsBackup verifies that MakeBackup returns nil // when the config file does not exist (no error, no panic). func TestMakeBackup_NonexistentFileSkipsBackup(t *testing.T) { dir := t.TempDir() configPath := filepath.Join(dir, "nonexistent.json") - if err := makeBackup(configPath); err != nil { - t.Fatalf("makeBackup on nonexistent file should return nil, got: %v", err) + if err := MakeBackup(configPath); err != nil { + t.Fatalf("MakeBackup on nonexistent file should return nil, got: %v", err) } } @@ -2698,8 +2698,8 @@ func TestMakeBackup_OnlyConfigNoSecurity(t *testing.T) { configPath := filepath.Join(dir, "config.json") os.WriteFile(configPath, []byte(`{"version":2}`), 0o600) - if err := makeBackup(configPath); err != nil { - t.Fatalf("makeBackup: %v", err) + if err := MakeBackup(configPath); err != nil { + t.Fatalf("MakeBackup: %v", err) } entries, _ := os.ReadDir(dir) @@ -2722,7 +2722,7 @@ func TestMakeBackup_OnlyConfigNoSecurity(t *testing.T) { } // TestMakeBackup_SameDateSuffix verifies that config and security backups -// share the same date suffix (they are created in the same makeBackup call). +// share the same date suffix (they are created in the same MakeBackup call). func TestMakeBackup_SameDateSuffix(t *testing.T) { dir := t.TempDir() configPath := filepath.Join(dir, "config.json") @@ -2731,8 +2731,8 @@ func TestMakeBackup_SameDateSuffix(t *testing.T) { os.WriteFile(configPath, []byte(`{"version":2}`), 0o600) os.WriteFile(secPath, []byte(`key: value`), 0o600) - if err := makeBackup(configPath); err != nil { - t.Fatalf("makeBackup: %v", err) + if err := MakeBackup(configPath); err != nil { + t.Fatalf("MakeBackup: %v", err) } entries, _ := os.ReadDir(dir) From f53222f6a4c0d1cf78fa053d5df5978c022bbb5a Mon Sep 17 00:00:00 2001 From: SiYue-ZO <2835601846@qq.com> Date: Mon, 18 May 2026 13:02:45 +0800 Subject: [PATCH 02/20] feat(api): add POST /api/config/reset endpoint Add handleResetConfig handler that calls ResetToDefaults, applies runtime log level, and restarts the gateway if running. --- web/backend/api/config.go | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/web/backend/api/config.go b/web/backend/api/config.go index afcd3f74..345fdbb7 100644 --- a/web/backend/api/config.go +++ b/web/backend/api/config.go @@ -18,6 +18,7 @@ func (h *Handler) registerConfigRoutes(mux *http.ServeMux) { mux.HandleFunc("GET /api/config", h.handleGetConfig) mux.HandleFunc("PUT /api/config", h.handleUpdateConfig) mux.HandleFunc("PATCH /api/config", h.handlePatchConfig) + mux.HandleFunc("POST /api/config/reset", h.handleResetConfig) mux.HandleFunc("POST /api/config/test-command-patterns", h.handleTestCommandPatterns) } @@ -211,6 +212,32 @@ func (h *Handler) handlePatchConfig(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) } +// handleResetConfig resets the configuration to factory defaults. +// API keys and security credentials are preserved. +// +// POST /api/config/reset +func (h *Handler) handleResetConfig(w http.ResponseWriter, r *http.Request) { + if err := config.ResetToDefaults(h.configPath); err != nil { + http.Error(w, fmt.Sprintf("Failed to reset config: %v", err), http.StatusInternalServerError) + return + } + + h.applyRuntimeLogLevel() + logger.Infof("configuration reset to factory defaults") + + // Restart gateway if running + status := h.gatewayStatusData() + gatewayStatus, _ := status["gateway_status"].(string) + if gatewayStatus == "running" { + if _, err := h.RestartGateway(); err != nil { + logger.ErrorF("failed to restart gateway after config reset", map[string]any{"error": err.Error()}) + } + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) +} + // handleTestCommandPatterns tests a command against whitelist and blacklist patterns. // // POST /api/config/test-command-patterns From 3f653161e33e6e1ef9d1a6b50f2fc91b527f123c Mon Sep 17 00:00:00 2001 From: SiYue-ZO <2835601846@qq.com> Date: Mon, 18 May 2026 13:53:34 +0800 Subject: [PATCH 03/20] feat(frontend): add factory reset button with confirmation dialog Add resetAppConfig API function, AlertDialog-confirmed factory reset button in config page, and i18n keys for en/zh/pt-br locales. --- cmd/picoclaw/main.go | 2 +- cmd/picoclaw/main_test.go | 1 + web/frontend/src/api/channels.ts | 6 ++ .../src/components/config/config-page.tsx | 88 +++++++++++++++++-- web/frontend/src/i18n/locales/en.json | 8 +- web/frontend/src/i18n/locales/pt-br.json | 8 +- web/frontend/src/i18n/locales/zh.json | 8 +- 7 files changed, 110 insertions(+), 11 deletions(-) diff --git a/cmd/picoclaw/main.go b/cmd/picoclaw/main.go index 11d0a8bb..1b5d0974 100644 --- a/cmd/picoclaw/main.go +++ b/cmd/picoclaw/main.go @@ -16,8 +16,8 @@ import ( "github.com/sipeed/picoclaw/cmd/picoclaw/internal" "github.com/sipeed/picoclaw/cmd/picoclaw/internal/agent" "github.com/sipeed/picoclaw/cmd/picoclaw/internal/auth" - configcmd "github.com/sipeed/picoclaw/cmd/picoclaw/internal/config" "github.com/sipeed/picoclaw/cmd/picoclaw/internal/cliui" + configcmd "github.com/sipeed/picoclaw/cmd/picoclaw/internal/config" "github.com/sipeed/picoclaw/cmd/picoclaw/internal/cron" "github.com/sipeed/picoclaw/cmd/picoclaw/internal/gateway" "github.com/sipeed/picoclaw/cmd/picoclaw/internal/mcp" diff --git a/cmd/picoclaw/main_test.go b/cmd/picoclaw/main_test.go index 037c7c2e..e4f9de5e 100644 --- a/cmd/picoclaw/main_test.go +++ b/cmd/picoclaw/main_test.go @@ -39,6 +39,7 @@ func TestNewPicoclawCommand(t *testing.T) { allowedCommands := []string{ "agent", "auth", + "config", "cron", "gateway", "mcp", diff --git a/web/frontend/src/api/channels.ts b/web/frontend/src/api/channels.ts index 42a3a060..36389ca8 100644 --- a/web/frontend/src/api/channels.ts +++ b/web/frontend/src/api/channels.ts @@ -77,6 +77,12 @@ export async function patchAppConfig( }) } +export async function resetAppConfig(): Promise { + return request("/api/config/reset", { + method: "POST", + }) +} + // WeChat QR login flow API export interface WeixinFlowResponse { diff --git a/web/frontend/src/components/config/config-page.tsx b/web/frontend/src/components/config/config-page.tsx index 8af5815f..4db059cd 100644 --- a/web/frontend/src/components/config/config-page.tsx +++ b/web/frontend/src/components/config/config-page.tsx @@ -5,7 +5,7 @@ import { useEffect, useState } from "react" import { useTranslation } from "react-i18next" import { toast } from "sonner" -import { patchAppConfig } from "@/api/channels" +import { patchAppConfig, resetAppConfig } from "@/api/channels" import { launcherFetch } from "@/api/http" import { postLauncherDashboardSetup } from "@/api/launcher-auth" import { @@ -41,6 +41,17 @@ import { } from "@/components/config/form-model" import { PageHeader } from "@/components/page-header" import { Badge } from "@/components/ui/badge" +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@/components/ui/alert-dialog" import { Button } from "@/components/ui/button" import { showSaveSuccessOrRestartToast } from "@/lib/restart-required" import { refreshGatewayState } from "@/store/gateway" @@ -72,15 +83,24 @@ export function ConfigPage() { const [autoStartEnabled, setAutoStartEnabled] = useState(false) const [autoStartBaseline, setAutoStartBaseline] = useState(false) const [saving, setSaving] = useState(false) + const [showFactoryResetDialog, setShowFactoryResetDialog] = useState(false) const { data, isLoading, error } = useQuery({ queryKey: ["config"], queryFn: async () => { - const res = await launcherFetch("/api/config") - if (!res.ok) { - throw new Error("Failed to load config") + const controller = new AbortController() + const timer = setTimeout(() => controller.abort(), 5000) + try { + const res = await launcherFetch("/api/config", { + signal: controller.signal, + }) + if (!res.ok) { + throw new Error("Failed to load config") + } + return res.json() + } finally { + clearTimeout(timer) } - return res.json() }, }) @@ -208,6 +228,27 @@ export function ConfigPage() { toast.info(t("pages.config.reset_success")) } + const handleFactoryReset = async () => { + try { + await resetAppConfig() + const fresh = await launcherFetch("/api/config").then((r) => r.json()) + const parsed = buildFormFromConfig(fresh) + setForm(parsed) + setBaseline(parsed) + await queryClient.invalidateQueries({ queryKey: ["config"] }) + await refreshGatewayState() + toast.success(t("pages.config.factory_reset_success")) + } catch (err) { + toast.error( + err instanceof Error + ? err.message + : t("pages.config.factory_reset_error"), + ) + } finally { + setShowFactoryResetDialog(false) + } + } + const handleSave = async () => { try { setSaving(true) @@ -625,8 +666,38 @@ export function ConfigPage() { } } + const factoryResetButton = ( + + + + + + + + {t("pages.config.factory_reset_confirm_title")} + + + {t("pages.config.factory_reset_confirm_desc")} + + + + {t("common.cancel")} + + {t("pages.config.factory_reset_confirm")} + + + + + ) + const actionButtons = (
+ {factoryResetButton} - )} + {form.provider && + FETCHABLE_PROVIDER_KEYS.has(form.provider) && ( + + )} {!form.provider && ( {t("models.field.selectProviderFirst")} @@ -671,6 +681,16 @@ export function AddModelSheet({ /> + + setForm((f) => ({ ...f, streamingEnabled: checked })) + } + ariaLabel={t("models.field.streamingEnabled")} + /> + o.id === provider)?.default_model_allowed ?? false + const allowed = + providerOptions?.find((o) => o.id === provider)?.default_model_allowed ?? + false if (!allowed) { setSetAsDefault(false) } @@ -252,7 +261,8 @@ export function EditModelSheet({ const providerDef = PROVIDER_MAP.get(form.provider) const commonModels = providerDef?.commonModels || [] const defaultModelAllowed = form.provider - ? (providerOptions?.find((o) => o.id === form.provider)?.default_model_allowed ?? false) + ? (providerOptions?.find((o) => o.id === form.provider) + ?.default_model_allowed ?? false) : false const handleSave = async () => { @@ -295,6 +305,10 @@ export function EditModelSheet({ try { const modelId = form.modelId.trim() const provider = form.provider.trim() + const streaming = + model.streaming?.enabled === true || form.streamingEnabled + ? { enabled: form.streamingEnabled } + : undefined await updateModel(model.index, { model_name: model.model_name, provider: provider, @@ -312,6 +326,7 @@ export function EditModelSheet({ : undefined, thinking_level: form.thinkingLevel || undefined, tool_schema_transform: form.toolSchemaTransform.trim() || undefined, + streaming, extra_body: extraBody, custom_headers: customHeaders, }) @@ -359,7 +374,10 @@ export function EditModelSheet({ -
+
)}
- {form.provider && FETCHABLE_PROVIDER_KEYS.has(form.provider) && ( - - )} + {form.provider && + FETCHABLE_PROVIDER_KEYS.has(form.provider) && ( + + )}
@@ -651,6 +670,16 @@ export function EditModelSheet({ placeholder="google" /> + + + setForm((f) => ({ ...f, streamingEnabled: checked })) + } + ariaLabel={t("models.field.streamingEnabled")} + /> {error && ( diff --git a/web/frontend/src/features/chat/protocol.ts b/web/frontend/src/features/chat/protocol.ts index 7b5c5e17..04cc924b 100644 --- a/web/frontend/src/features/chat/protocol.ts +++ b/web/frontend/src/features/chat/protocol.ts @@ -101,6 +101,7 @@ export function handlePicoMessage( parseAssistantMessageCreateState(payload) const attachments = parseAttachments(payload) const contextUsage = parseContextUsage(payload) + const isPlaceholder = payload.placeholder === true const timestamp = message.timestamp !== undefined && Number.isFinite(Number(message.timestamp)) @@ -120,7 +121,11 @@ export function handlePicoMessage( timestamp, }, ], - isTyping: false, + isTyping: + !isPlaceholder && + (kind === "normal" || message.type === "media.create") + ? false + : prev.isTyping, ...(contextUsage ? { contextUsage } : {}), })) break diff --git a/web/frontend/src/i18n/locales/en.json b/web/frontend/src/i18n/locales/en.json index 84e78aa8..ab60a814 100644 --- a/web/frontend/src/i18n/locales/en.json +++ b/web/frontend/src/i18n/locales/en.json @@ -313,6 +313,8 @@ "maxTokensFieldHint": "Override the request field name for max tokens, e.g. max_completion_tokens.", "toolSchemaTransform": "Tool Schema Transform", "toolSchemaTransformHint": "Optional compatibility transform for tool JSON schemas. Leave blank for native behavior. Supported values: simple.", + "streamingEnabled": "Streaming Output", + "streamingEnabledHint": "Allow this model entry to try provider streaming requests. The current channel streaming switch must also be enabled.", "extraBody": "Extra Body", "extraBodyHint": "Additional JSON fields to inject into the request body, e.g. {\"reasoning_split\": true}.", "customHeaders": "Custom Headers", @@ -464,6 +466,9 @@ "typingEnabled": "Typing Indicator", "placeholderEnabled": "Placeholder Message", "placeholderText": "Placeholder Text", + "streamingEnabled": "Streaming Output", + "streamingThrottleSeconds": "Update Interval (s)", + "streamingMinGrowthChars": "Minimum Growth Characters", "groupTriggerMentionOnly": "Group Mention Only", "groupTriggerPrefixes": "Group Trigger Prefixes", "groupTriggerPrefixesPlaceholder": "e.g. /, !, ?", @@ -502,6 +507,9 @@ "mentionOnly": "Only respond when the bot is explicitly mentioned in group chats.", "typingEnabled": "Display typing status while the assistant is generating a response.", "placeholderEnabled": "Enable temporary placeholder messages before the final reply is sent.", + "streamingEnabled": "Allow this channel to display provider streaming output. The current model entry streaming switch must also be enabled.", + "streamingThrottleSeconds": "Minimum interval between intermediate streaming updates. 0 means use the default. Final replies are not throttled.", + "streamingMinGrowthChars": "Minimum text growth before sending another intermediate streaming update. 0 means use the default. Final replies are not throttled.", "groupTriggerMentionOnly": "In group chats, respond only when the bot is mentioned.", "groupTriggerPrefixes": "Custom group-chat trigger prefixes. Add items one by one, or paste multiple values at once.", "randomReactionEmoji": "PicoClaw adds emoji reactions to user messages to confirm receipt. Example: \"THUMBSUP\", \"HEART\", \"SMILE\". Leave empty to use the default \"Pin\" emoji.", diff --git a/web/frontend/src/i18n/locales/pt-br.json b/web/frontend/src/i18n/locales/pt-br.json index 09ebc157..4af5218f 100644 --- a/web/frontend/src/i18n/locales/pt-br.json +++ b/web/frontend/src/i18n/locales/pt-br.json @@ -308,6 +308,10 @@ "thinkingLevelHint": "Orçamento de pensamento estendido: off, low, medium, high, xhigh, adaptive.", "maxTokensField": "Campo de Max Tokens", "maxTokensFieldHint": "Sobrescreve o nome do campo de max tokens na requisição, ex: max_completion_tokens.", + "toolSchemaTransform": "Transformação de Schema de Tool", + "toolSchemaTransformHint": "Transformação opcional de compatibilidade para schemas JSON de tools. Deixe em branco para comportamento nativo. Valores suportados: simple.", + "streamingEnabled": "Saída Streaming", + "streamingEnabledHint": "Permite que esta entrada de modelo tente requisições de provider streaming. O switch de streaming do canal atual também precisa estar habilitado.", "extraBody": "Body Extra", "extraBodyHint": "Campos JSON adicionais para injetar no body da requisição, ex: {\"reasoning_split\": true}.", "customHeaders": "Headers Customizados", @@ -386,6 +390,9 @@ "typingEnabled": "Indicador de Digitação", "placeholderEnabled": "Mensagem de Placeholder", "placeholderText": "Texto do Placeholder", + "streamingEnabled": "Saída Streaming", + "streamingThrottleSeconds": "Intervalo de Atualização (s)", + "streamingMinGrowthChars": "Caracteres Mínimos de Crescimento", "groupTriggerMentionOnly": "Apenas Menção em Grupo", "groupTriggerPrefixes": "Prefixos de Trigger em Grupo", "groupTriggerPrefixesPlaceholder": "ex: /, !, ?", @@ -424,6 +431,9 @@ "mentionOnly": "Responder apenas quando o bot for explicitamente mencionado em chats em grupo.", "typingEnabled": "Exibir status de digitação enquanto o assistente está gerando uma resposta.", "placeholderEnabled": "Habilitar mensagens de placeholder temporárias antes da resposta final ser enviada.", + "streamingEnabled": "Permite que este canal exiba output streaming do provider. O switch de streaming da entrada de modelo atual também precisa estar habilitado.", + "streamingThrottleSeconds": "Intervalo mínimo entre atualizações intermediárias de streaming. 0 significa usar o padrão. Respostas finais não são limitadas.", + "streamingMinGrowthChars": "Crescimento mínimo de texto antes de enviar outra atualização intermediária. 0 significa usar o padrão. Respostas finais não são limitadas.", "groupTriggerMentionOnly": "Em chats em grupo, responder apenas quando o bot for mencionado.", "groupTriggerPrefixes": "Prefixos customizados de trigger para chats em grupo. Adicione itens um a um ou cole vários valores de uma vez.", "randomReactionEmoji": "PicoClaw adiciona reações de emoji às mensagens dos usuários para confirmar recebimento. Exemplo: \"THUMBSUP\", \"HEART\", \"SMILE\". Deixe vazio para usar o emoji \"Pin\" padrão.", diff --git a/web/frontend/src/i18n/locales/zh.json b/web/frontend/src/i18n/locales/zh.json index b12fd583..431dc992 100644 --- a/web/frontend/src/i18n/locales/zh.json +++ b/web/frontend/src/i18n/locales/zh.json @@ -316,6 +316,8 @@ "maxTokensFieldHint": "覆盖请求中 max_tokens 的字段名,例如 max_completion_tokens。", "toolSchemaTransform": "工具 Schema 转换", "toolSchemaTransformHint": "可选的工具 JSON Schema 兼容性转换。留空表示保持原生行为。当前支持值:simple。", + "streamingEnabled": "流式输出", + "streamingEnabledHint": "允许此模型条目尝试 Provider 流式请求;还需要当前频道的流式开关同时开启。", "extraBody": "Extra Body", "extraBodyHint": "要注入到请求体中的额外 JSON 字段,例如 {\"reasoning_split\": true}。", "customHeaders": "Custom Headers", @@ -465,6 +467,9 @@ "typingEnabled": "输入中提示", "placeholderEnabled": "占位消息", "placeholderText": "占位文案", + "streamingEnabled": "流式输出", + "streamingThrottleSeconds": "更新间隔(秒)", + "streamingMinGrowthChars": "最小增长字符数", "groupTriggerMentionOnly": "群聊仅提及时响应", "groupTriggerPrefixes": "群聊触发前缀", "groupTriggerPrefixesPlaceholder": "例如 /, !, ?", @@ -503,6 +508,9 @@ "mentionOnly": "在群聊中仅当明确提及时才响应", "typingEnabled": "在生成回复时显示“正在输入”状态", "placeholderEnabled": "在最终回复发送前,先发送临时占位消息", + "streamingEnabled": "允许该频道展示 Provider 流式输出。还需要当前模型条目的流式开关同时开启。", + "streamingThrottleSeconds": "中间流式更新的最小间隔,0 表示使用默认值。最终回复不受限制。", + "streamingMinGrowthChars": "中间流式更新相比上次发送至少增长的字符数,0 表示使用默认值。最终回复不受限制。", "groupTriggerMentionOnly": "在群聊中仅当提及机器人时才响应", "groupTriggerPrefixes": "群聊触发前缀。可逐项添加,也支持一次粘贴多个值。", "randomReactionEmoji": "PicoClaw 会对用户消息添加表情回复以确认已收到。例如:\"THUMBSUP\", \"HEART\", \"SMILE\"。留空则使用默认的 \"Pin\" 表情。", From 548dc15acdd2f87f18ec445bb9bc0ff874863df0 Mon Sep 17 00:00:00 2001 From: LC Date: Wed, 20 May 2026 11:50:34 +0800 Subject: [PATCH 05/20] refactor(models): unify provider metadata around backend catalog (#2896) * feat(models): unify provider metadata around backend catalog - Move shared provider metadata and alias normalization into backend-owned provider catalog - Expose display, fetch, auth, and default model metadata through /api/models provider_options - Replace frontend static provider registry with catalog-driven selection, validation, grouping, and fallback rendering - Treat provider default api_base as placeholder and effective fetch/test base while keep submitted api_base separate from derived defaults - Add model page retry handling, touched locale updates, and provider metadata assertions in backend tests * fix(models): canonicalize backend provider aliases and common models * fix(models): restore deepseek common model recommendations --- pkg/audio/asr/asr.go | 14 +- pkg/providers/factory_provider.go | 91 +-- pkg/providers/factory_provider_test.go | 74 ++- pkg/providers/model_ref.go | 46 +- pkg/providers/model_ref_test.go | 10 +- pkg/providers/provider_catalog.go | 129 +--- pkg/providers/provider_metadata.go | 581 +++++++++++++++++ web/backend/api/model_catalog.go | 6 +- web/backend/api/model_status.go | 16 +- web/backend/api/models.go | 21 +- web/backend/api/models_test.go | 13 + web/backend/api/oauth.go | 2 +- web/frontend/src/api/models.ts | 8 + .../src/components/models/add-model-sheet.tsx | 230 ++++--- .../src/components/models/catalog-dialog.tsx | 19 +- .../components/models/edit-model-sheet.tsx | 236 ++++--- .../components/models/fetch-models-dialog.tsx | 22 +- .../models/model-provider-form-shared.ts | 21 + .../src/components/models/model-validation.ts | 16 +- .../src/components/models/models-page.tsx | 66 +- .../components/models/provider-combobox.tsx | 148 ++--- .../src/components/models/provider-icon.tsx | 20 +- .../src/components/models/provider-label.ts | 14 - .../components/models/provider-registry.ts | 606 ++++-------------- .../components/models/provider-section.tsx | 9 +- web/frontend/src/i18n/locales/en.json | 16 +- web/frontend/src/i18n/locales/pt-br.json | 88 ++- web/frontend/src/i18n/locales/zh.json | 3 + 28 files changed, 1441 insertions(+), 1084 deletions(-) create mode 100644 pkg/providers/provider_metadata.go create mode 100644 web/frontend/src/components/models/model-provider-form-shared.ts delete mode 100644 web/frontend/src/components/models/provider-label.ts diff --git a/pkg/audio/asr/asr.go b/pkg/audio/asr/asr.go index a7c93e57..94ede23e 100644 --- a/pkg/audio/asr/asr.go +++ b/pkg/audio/asr/asr.go @@ -29,12 +29,12 @@ func supportsAudioTranscription(modelCfg *config.ModelConfig) bool { protocol, _ := providers.ExtractProtocol(modelCfg) switch protocol { - case "openai", "azure", "azure-openai", + case "openai", "azure", "litellm", "openrouter", "groq", "zhipu", "gemini", "nvidia", "ollama", "moonshot", "shengsuanyun", "deepseek", "cerebras", - "vivgrid", "volcengine", "vllm", "qwen", "qwen-portal", "qwen-intl", "qwen-international", "dashscope-intl", - "qwen-us", "dashscope-us", "mistral", "avian", "minimax", "longcat", "modelscope", "novita", - "coding-plan", "alibaba-coding", "qwen-coding", "zai": + "vivgrid", "volcengine", "vllm", "qwen-portal", "qwen-intl", "qwen-us", + "mistral", "avian", "minimax", "longcat", "modelscope", "novita", + "alibaba-coding", "zai": // These protocols all go through the OpenAI-compatible or Azure provider path in // providers.CreateProviderFromConfig, so they are the only ones that can supply // the audio media payload shape expected by NewAudioModelTranscriber. @@ -53,9 +53,9 @@ func supportsWhisperTranscription(modelCfg *config.ModelConfig) bool { switch protocol { case "openai", "litellm", "openrouter", "groq", "zhipu", "gemini", "nvidia", "ollama", "moonshot", "shengsuanyun", "deepseek", "cerebras", - "vivgrid", "volcengine", "vllm", "qwen", "qwen-portal", "qwen-intl", "qwen-international", "dashscope-intl", - "qwen-us", "dashscope-us", "mistral", "avian", "minimax", "longcat", "modelscope", "novita", - "coding-plan", "alibaba-coding", "qwen-coding", "zai", "mimo": + "vivgrid", "volcengine", "vllm", "qwen-portal", "qwen-intl", "qwen-us", + "mistral", "avian", "minimax", "longcat", "modelscope", "novita", + "alibaba-coding", "zai", "mimo": return true default: return false diff --git a/pkg/providers/factory_provider.go b/pkg/providers/factory_provider.go index 034c8050..667c6da6 100644 --- a/pkg/providers/factory_provider.go +++ b/pkg/providers/factory_provider.go @@ -18,54 +18,6 @@ import ( "github.com/sipeed/picoclaw/pkg/providers/common" ) -type protocolMeta struct { - defaultAPIBase string - emptyAPIKeyAllowed bool -} - -var protocolMetaByName = map[string]protocolMeta{ - "openai": {defaultAPIBase: "https://api.openai.com/v1"}, - "venice": {defaultAPIBase: "https://api.venice.ai/api/v1"}, - "openrouter": {defaultAPIBase: "https://openrouter.ai/api/v1"}, - "litellm": {defaultAPIBase: "http://localhost:4000/v1"}, - "lmstudio": {defaultAPIBase: "http://localhost:1234/v1", emptyAPIKeyAllowed: true}, - "novita": {defaultAPIBase: "https://api.novita.ai/openai"}, - "groq": {defaultAPIBase: "https://api.groq.com/openai/v1"}, - "zhipu": {defaultAPIBase: "https://open.bigmodel.cn/api/paas/v4"}, - "gemini": {defaultAPIBase: "https://generativelanguage.googleapis.com/v1beta"}, - "nvidia": {defaultAPIBase: "https://integrate.api.nvidia.com/v1"}, - "ollama": {defaultAPIBase: "http://localhost:11434/v1", emptyAPIKeyAllowed: true}, - "moonshot": {defaultAPIBase: "https://api.moonshot.cn/v1"}, - "shengsuanyun": {defaultAPIBase: "https://router.shengsuanyun.com/api/v1"}, - "siliconflow": {defaultAPIBase: "https://api.siliconflow.cn/v1"}, - "deepseek": {defaultAPIBase: "https://api.deepseek.com/v1"}, - "cerebras": {defaultAPIBase: "https://api.cerebras.ai/v1"}, - "vivgrid": {defaultAPIBase: "https://api.vivgrid.com/v1"}, - "volcengine": {defaultAPIBase: "https://ark.cn-beijing.volces.com/api/v3"}, - "qwen": {defaultAPIBase: "https://dashscope.aliyuncs.com/compatible-mode/v1"}, - "qwen-portal": {defaultAPIBase: "https://dashscope.aliyuncs.com/compatible-mode/v1"}, - "qwen-intl": {defaultAPIBase: "https://dashscope-intl.aliyuncs.com/compatible-mode/v1"}, - "qwen-international": {defaultAPIBase: "https://dashscope-intl.aliyuncs.com/compatible-mode/v1"}, - "dashscope-intl": {defaultAPIBase: "https://dashscope-intl.aliyuncs.com/compatible-mode/v1"}, - "qwen-us": {defaultAPIBase: "https://dashscope-us.aliyuncs.com/compatible-mode/v1"}, - "dashscope-us": {defaultAPIBase: "https://dashscope-us.aliyuncs.com/compatible-mode/v1"}, - "coding-plan": {defaultAPIBase: "https://coding-intl.dashscope.aliyuncs.com/v1"}, - "alibaba-coding": {defaultAPIBase: "https://coding-intl.dashscope.aliyuncs.com/v1"}, - "qwen-coding": {defaultAPIBase: "https://coding-intl.dashscope.aliyuncs.com/v1"}, - "coding-plan-anthropic": {defaultAPIBase: "https://coding-intl.dashscope.aliyuncs.com/apps/anthropic"}, - "alibaba-coding-anthropic": {defaultAPIBase: "https://coding-intl.dashscope.aliyuncs.com/apps/anthropic"}, - "zai": {defaultAPIBase: "https://api.z.ai/api/coding/paas/v4"}, - "vllm": {defaultAPIBase: "http://localhost:8000/v1", emptyAPIKeyAllowed: true}, - "mistral": {defaultAPIBase: "https://api.mistral.ai/v1"}, - "avian": {defaultAPIBase: "https://api.avian.io/v1"}, - "minimax": {defaultAPIBase: "https://api.minimaxi.com/v1"}, - "longcat": {defaultAPIBase: "https://api.longcat.chat/openai"}, - "modelscope": {defaultAPIBase: "https://api-inference.modelscope.cn/v1"}, - "mimo": {defaultAPIBase: "https://api.xiaomimimo.com/v1"}, - "anthropic": {defaultAPIBase: "https://api.anthropic.com/v1"}, - "anthropic-messages": {defaultAPIBase: "https://api.anthropic.com/v1"}, -} - // createClaudeAuthProvider creates a Claude provider using OAuth credentials from auth store. func createClaudeAuthProvider() (LLMProvider, error) { cred, err := getCredential("anthropic") @@ -184,7 +136,7 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err provider.SetProviderName(protocol) return finalizeProviderFromConfig(provider, modelID, cfg) - case "azure", "azure-openai": + case "azure": // Azure OpenAI uses deployment-based URLs, api-key header auth, // and always sends max_completion_tokens. if cfg.APIKey() == "" { @@ -241,9 +193,8 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err case "litellm", "lmstudio", "openrouter", "groq", "zhipu", "nvidia", "venice", "ollama", "moonshot", "shengsuanyun", "siliconflow", "deepseek", "cerebras", - "vivgrid", "volcengine", "vllm", "qwen", "qwen-portal", "qwen-intl", "qwen-international", "dashscope-intl", - "qwen-us", "dashscope-us", "mistral", "avian", "longcat", "modelscope", "novita", - "coding-plan", "alibaba-coding", "qwen-coding", "zai", "mimo": + "vivgrid", "volcengine", "vllm", "qwen-portal", "qwen-intl", "qwen-us", "mistral", + "avian", "longcat", "modelscope", "novita", "alibaba-coding", "zai", "mimo": // All other OpenAI-compatible HTTP providers if cfg.APIKey() == "" && cfg.APIBase == "" && !isEmptyAPIKeyAllowed(protocol) { return nil, "", fmt.Errorf("api_key or api_base is required for HTTP-based protocol %q", protocol) @@ -355,7 +306,7 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err cfg.RequestTimeout, ), modelID, cfg) - case "coding-plan-anthropic", "alibaba-coding-anthropic": + case "alibaba-coding-anthropic": // Alibaba Coding Plan with Anthropic-compatible API apiBase := cfg.APIBase if apiBase == "" { @@ -374,21 +325,21 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err case "antigravity": return finalizeProviderFromConfig(NewAntigravityProvider(), modelID, cfg) - case "claude-cli", "claudecli": + case "claude-cli": workspace := cfg.Workspace if workspace == "" { workspace = "." } return finalizeProviderFromConfig(NewClaudeCliProvider(workspace), modelID, cfg) - case "codex-cli", "codexcli": + case "codex-cli": workspace := cfg.Workspace if workspace == "" { workspace = "." } return finalizeProviderFromConfig(NewCodexCliProvider(workspace), modelID, cfg) - case "github-copilot", "copilot": + case "github-copilot": apiBase := cfg.APIBase if apiBase == "" { apiBase = "localhost:4321" @@ -421,8 +372,8 @@ func finalizeProviderFromConfig( } func isEmptyAPIKeyAllowed(protocol string) bool { - meta, ok := protocolMetaForName(protocol) - return ok && meta.emptyAPIKeyAllowed + option, ok := modelProviderOptionForName(protocol) + return ok && option.EmptyAPIKeyAllowed } // IsEmptyAPIKeyAllowedForProtocol reports whether a protocol allows requests @@ -432,6 +383,16 @@ func IsEmptyAPIKeyAllowedForProtocol(protocol string) bool { return isEmptyAPIKeyAllowed(protocol) } +// IsHTTPAPIProtocol reports whether a provider uses an HTTP API base in the +// model configuration path. This excludes providers such as Bedrock, CLI +// bridges, and OAuth-only managed providers even if they do not require an +// explicit api_key field. +func IsHTTPAPIProtocol(protocol string) bool { + protocol = NormalizeProvider(protocol) + option, ok := modelProviderOptionsByName[protocol] + return ok && option.httpAPI +} + // DefaultAPIBaseForProtocol returns the configured default API base for a protocol. // It returns empty string if the protocol has no default base. func DefaultAPIBaseForProtocol(protocol string) string { @@ -441,19 +402,9 @@ func DefaultAPIBaseForProtocol(protocol string) string { // getDefaultAPIBase returns the default API base URL for a given protocol. func getDefaultAPIBase(protocol string) string { - meta, ok := protocolMetaForName(protocol) + option, ok := modelProviderOptionForName(protocol) if !ok { return "" } - return meta.defaultAPIBase -} - -func protocolMetaForName(protocol string) (protocolMeta, bool) { - if meta, ok := protocolMetaByName[protocol]; ok { - return meta, true - } - if meta, ok := attachedModelProviderMetaByName[protocol]; ok { - return meta.protocolMeta, true - } - return protocolMeta{}, false + return option.DefaultAPIBase } diff --git a/pkg/providers/factory_provider_test.go b/pkg/providers/factory_provider_test.go index 69903711..09dbf8b4 100644 --- a/pkg/providers/factory_provider_test.go +++ b/pkg/providers/factory_provider_test.go @@ -946,7 +946,7 @@ func TestCreateProviderFromConfig_CodingPlanAnthropic(t *testing.T) { if modelID != wantModelID { t.Errorf("modelID = %q, want %q", modelID, wantModelID) } - // coding-plan-anthropic uses Anthropic Messages provider + // alibaba-coding-anthropic uses Anthropic Messages provider // Verify it's the anthropic messages provider by checking interface var _ LLMProvider = provider }) @@ -998,6 +998,13 @@ func TestModelProviderOptions(t *testing.T) { if option, ok := seen["openai"]; ok && !option.CreateAllowed { t.Fatal("openai should be creatable") } + if option, ok := seen["openai"]; ok && !option.SupportsFetch { + t.Fatal("openai should support upstream model listing") + } else if option.DisplayName != "OpenAI" { + t.Fatalf("openai display_name = %q, want %q", option.DisplayName, "OpenAI") + } else if len(option.CommonModels) == 0 { + t.Fatal("openai common_models should not be empty") + } if option, ok := seen["lmstudio"]; !ok { t.Fatal("lmstudio option missing") } else if !option.EmptyAPIKeyAllowed { @@ -1052,6 +1059,71 @@ func TestModelProviderOptions(t *testing.T) { t.Fatal("github-copilot option missing") } else if option.DefaultAPIBase != "localhost:4321" { t.Fatalf("github-copilot default_api_base = %q, want %q", option.DefaultAPIBase, "localhost:4321") + } else if !option.Local { + t.Fatal("github-copilot should be marked local") + } + if option, ok := seen["qwen-portal"]; !ok { + t.Fatal("qwen-portal option missing") + } else if len(option.Aliases) == 0 || option.Aliases[0] != "qwen" { + t.Fatalf("qwen-portal aliases = %#v, want to include qwen", option.Aliases) + } + + for _, option := range options { + if len(option.CommonModels) > 6 { + t.Fatalf("provider %q exposes %d common_models, want at most 6", option.ID, len(option.CommonModels)) + } + if option.Local && len(option.CommonModels) > 0 { + t.Fatalf("local provider %q should not expose common_models", option.ID) + } + seenModels := make(map[string]struct{}, len(option.CommonModels)) + for _, model := range option.CommonModels { + if strings.TrimSpace(model) == "" { + t.Fatalf("provider %q includes an empty common_model entry", option.ID) + } + if _, exists := seenModels[model]; exists { + t.Fatalf("provider %q includes duplicate common_model %q", option.ID, model) + } + seenModels[model] = struct{}{} + } + } +} + +func TestBuildModelProviderAliasMap(t *testing.T) { + aliases := buildModelProviderAliasMap() + if len(aliases) == 0 { + t.Fatal("buildModelProviderAliasMap() returned empty map") + } + + seenAliases := make(map[string]string, len(aliases)) + for provider, option := range modelProviderOptionsByName { + got, ok := aliases[provider] + if !ok { + t.Fatalf("canonical provider %q missing from alias map", provider) + } + if got != provider { + t.Fatalf("canonical provider %q mapped to %q", provider, got) + } + if existing, ok := seenAliases[provider]; ok { + t.Fatalf("canonical provider key %q collides with provider %q", provider, existing) + } + seenAliases[provider] = provider + for _, alias := range option.Aliases { + normalized := strings.ToLower(strings.TrimSpace(alias)) + if normalized == "" { + t.Fatalf("provider %q includes empty alias", provider) + } + if existing, ok := seenAliases[normalized]; ok && existing != provider { + t.Fatalf("alias %q for provider %q collides with provider %q", alias, provider, existing) + } + seenAliases[normalized] = provider + got, ok := aliases[normalized] + if !ok { + t.Fatalf("alias %q for provider %q missing from alias map", alias, provider) + } + if got != provider { + t.Fatalf("alias %q normalized to %q, want %q", alias, got, provider) + } + } } } diff --git a/pkg/providers/model_ref.go b/pkg/providers/model_ref.go index 48e3fb4c..ac7cae78 100644 --- a/pkg/providers/model_ref.go +++ b/pkg/providers/model_ref.go @@ -29,46 +29,14 @@ func ParseModelRef(raw string, defaultProvider string) *ModelRef { // NormalizeProvider normalizes provider identifiers to canonical form. func NormalizeProvider(provider string) string { - p := strings.ToLower(strings.TrimSpace(provider)) - - switch p { - case "z.ai", "z-ai": - return "zai" - case "opencode-zen": - return "opencode" - case "qwen": - return "qwen-portal" - case "kimi-code": - return "kimi-coding" - case "gpt": - return "openai" - case "claude": - return "anthropic" - case "glm": - return "zhipu" - case "google": - return "gemini" - case "google-antigravity": - return "antigravity" - case "alibaba-coding", "qwen-coding": - return "coding-plan" - case "alibaba-coding-anthropic": - return "coding-plan-anthropic" - case "qwen-international", "dashscope-intl": - return "qwen-intl" - case "dashscope-us": - return "qwen-us" - case "azure-openai": - return "azure" - case "claudecli": - return "claude-cli" - case "codexcli": - return "codex-cli" - case "copilot": - return "github-copilot" + normalized := strings.ToLower(strings.TrimSpace(provider)) + if normalized == "" { + return "" } - - return p + if canonical, ok := normalizedModelProviderAliasesByName[normalized]; ok { + return canonical + } + return normalized } // ModelKey returns a canonical "provider/model" key for deduplication. diff --git a/pkg/providers/model_ref_test.go b/pkg/providers/model_ref_test.go index 9a164bf4..80ea8bb6 100644 --- a/pkg/providers/model_ref_test.go +++ b/pkg/providers/model_ref_test.go @@ -65,9 +65,7 @@ func TestNormalizeProvider(t *testing.T) { {"z.ai", "zai"}, {"z-ai", "zai"}, {"Z.AI", "zai"}, - {"opencode-zen", "opencode"}, {"qwen", "qwen-portal"}, - {"kimi-code", "kimi-coding"}, {"gpt", "openai"}, {"claude", "anthropic"}, {"glm", "zhipu"}, @@ -79,9 +77,11 @@ func TestNormalizeProvider(t *testing.T) { {"codexcli", "codex-cli"}, {"copilot", "github-copilot"}, // Alibaba Coding Plan aliases - {"alibaba-coding", "coding-plan"}, - {"qwen-coding", "coding-plan"}, - {"alibaba-coding-anthropic", "coding-plan-anthropic"}, + {"alibaba-coding", "alibaba-coding"}, + {"coding-plan", "alibaba-coding"}, + {"qwen-coding", "alibaba-coding"}, + {"alibaba-coding-anthropic", "alibaba-coding-anthropic"}, + {"coding-plan-anthropic", "alibaba-coding-anthropic"}, // Qwen international aliases {"qwen-international", "qwen-intl"}, {"dashscope-intl", "qwen-intl"}, diff --git a/pkg/providers/provider_catalog.go b/pkg/providers/provider_catalog.go index a9178cb8..b2056563 100644 --- a/pkg/providers/provider_catalog.go +++ b/pkg/providers/provider_catalog.go @@ -5,97 +5,10 @@ import ( "strings" ) -// ModelProviderOption describes a canonical provider entry exposed to the Web UI. -type ModelProviderOption struct { - ID string `json:"id"` - DefaultAPIBase string `json:"default_api_base"` - EmptyAPIKeyAllowed bool `json:"empty_api_key_allowed"` - CreateAllowed bool `json:"create_allowed"` - DefaultModelAllowed bool `json:"default_model_allowed"` - DefaultAuthMethod string `json:"default_auth_method,omitempty"` - AuthMethodLocked bool `json:"auth_method_locked,omitempty"` -} - -type attachedModelProviderMeta struct { - protocolMeta - createAllowed bool - defaultModelAllowed bool - defaultAuthMethod string - authMethodLocked bool -} - -// attachedModelProviderMetaByName augments protocolMetaByName for provider -// families that are implemented in CreateProviderFromConfig but intentionally -// kept out of the core HTTP metadata map because they have special auth/runtime -// semantics. -var attachedModelProviderMetaByName = map[string]attachedModelProviderMeta{ - "azure": {createAllowed: true, defaultModelAllowed: true}, - "anthropic": { - protocolMeta: protocolMeta{defaultAPIBase: "https://api.anthropic.com/v1"}, - createAllowed: true, - defaultModelAllowed: true, - }, - "anthropic-messages": { - protocolMeta: protocolMeta{defaultAPIBase: "https://api.anthropic.com/v1"}, - createAllowed: true, - defaultModelAllowed: true, - }, - "bedrock": {createAllowed: true, defaultModelAllowed: true}, - "antigravity": { - createAllowed: true, - defaultModelAllowed: true, - defaultAuthMethod: "oauth", - authMethodLocked: true, - }, - "claude-cli": {createAllowed: true, defaultModelAllowed: true}, - "codex-cli": {createAllowed: true, defaultModelAllowed: true}, - "github-copilot": { - protocolMeta: protocolMeta{defaultAPIBase: "localhost:4321"}, - createAllowed: true, - defaultModelAllowed: true, - }, - // ElevenLabs is intentionally exposed only as an ASR-capable provider. It - // belongs in the shared model catalog because ASR is configured via - // model_list, but it must not be selectable as the default chat model. - "elevenlabs": { - protocolMeta: protocolMeta{defaultAPIBase: "https://api.elevenlabs.io"}, - createAllowed: true, - defaultModelAllowed: false, - }, -} - // ModelProviderOptions returns the canonical provider catalog exposed to the Web UI. func ModelProviderOptions() []ModelProviderOption { - optionsByID := make(map[string]ModelProviderOption, len(protocolMetaByName)+len(attachedModelProviderMetaByName)) - for provider := range protocolMetaByName { - if NormalizeProvider(provider) != provider { - continue - } - optionsByID[provider] = ModelProviderOption{ - ID: provider, - DefaultAPIBase: DefaultAPIBaseForProtocol(provider), - EmptyAPIKeyAllowed: IsEmptyAPIKeyAllowedForProtocol(provider), - CreateAllowed: true, - DefaultModelAllowed: true, - } - } - for provider, meta := range attachedModelProviderMetaByName { - if NormalizeProvider(provider) != provider { - continue - } - optionsByID[provider] = ModelProviderOption{ - ID: provider, - DefaultAPIBase: meta.defaultAPIBase, - EmptyAPIKeyAllowed: meta.emptyAPIKeyAllowed, - CreateAllowed: meta.createAllowed, - DefaultModelAllowed: meta.defaultModelAllowed, - DefaultAuthMethod: meta.defaultAuthMethod, - AuthMethodLocked: meta.authMethodLocked, - } - } - - options := make([]ModelProviderOption, 0, len(optionsByID)) - for _, option := range optionsByID { + options := make([]ModelProviderOption, 0, len(modelProviderOptionsByName)) + for _, option := range modelProviderOptionsByName { options = append(options, option) } sort.Slice(options, func(i, j int) bool { @@ -107,44 +20,30 @@ func ModelProviderOptions() []ModelProviderOption { // IsSupportedModelProvider reports whether provider resolves to a provider ID // returned by ModelProviderOptions. func IsSupportedModelProvider(provider string) bool { - normalized := NormalizeProvider(provider) - if normalized == "" { - return false - } - if _, ok := protocolMetaByName[normalized]; ok { - return true - } - _, ok := attachedModelProviderMetaByName[normalized] + _, ok := modelProviderOptionForName(provider) return ok } +// IsModelProviderFetchable reports whether provider supports upstream /models +// listing through the launcher fetch endpoint. +func IsModelProviderFetchable(provider string) bool { + option, ok := modelProviderOptionForName(provider) + return ok && option.SupportsFetch +} + // IsCreatableModelProvider reports whether provider can be selected for a new // model entry from the Web UI. func IsCreatableModelProvider(provider string) bool { - normalized := NormalizeProvider(provider) - if normalized == "" { - return false - } - if _, ok := protocolMetaByName[normalized]; ok { - return true - } - meta, ok := attachedModelProviderMetaByName[normalized] - return ok && meta.createAllowed + option, ok := modelProviderOptionForName(provider) + return ok && option.CreateAllowed } // IsDefaultModelProvider reports whether provider can be used as the default // chat model. Some providers such as ASR-only entries are intentionally // exposed in model_list management but cannot drive the gateway default model. func IsDefaultModelProvider(provider string) bool { - normalized := NormalizeProvider(provider) - if normalized == "" { - return false - } - if _, ok := protocolMetaByName[normalized]; ok { - return true - } - meta, ok := attachedModelProviderMetaByName[normalized] - return ok && meta.defaultModelAllowed + option, ok := modelProviderOptionForName(provider) + return ok && option.DefaultModelAllowed } // SplitModelProviderAndID separates a legacy "provider/model" string into its diff --git a/pkg/providers/provider_metadata.go b/pkg/providers/provider_metadata.go new file mode 100644 index 00000000..52d27dad --- /dev/null +++ b/pkg/providers/provider_metadata.go @@ -0,0 +1,581 @@ +package providers + +import "strings" + +// ModelProviderOption describes a canonical provider entry exposed to the Web UI. +// It also serves as the backend-owned source of truth for shared provider metadata. +type ModelProviderOption struct { + ID string `json:"id"` + DisplayName string `json:"display_name,omitempty"` + IconSlug string `json:"icon_slug,omitempty"` + Domain string `json:"domain,omitempty"` + DefaultAPIBase string `json:"default_api_base"` + EmptyAPIKeyAllowed bool `json:"empty_api_key_allowed"` + CreateAllowed bool `json:"create_allowed"` + DefaultModelAllowed bool `json:"default_model_allowed"` + SupportsFetch bool `json:"supports_fetch,omitempty"` + DefaultAuthMethod string `json:"default_auth_method,omitempty"` + AuthMethodLocked bool `json:"auth_method_locked,omitempty"` + Local bool `json:"local,omitempty"` + Priority float64 `json:"priority,omitempty"` + CommonModels []string `json:"common_models,omitempty"` + Aliases []string `json:"aliases,omitempty"` + + httpAPI bool `json:"-"` +} + +var modelProviderOptionsByName = map[string]ModelProviderOption{ + "openai": { + ID: "openai", + DisplayName: "OpenAI", + IconSlug: "openai", + Domain: "openai.com", + DefaultAPIBase: "https://api.openai.com/v1", + CreateAllowed: true, + DefaultModelAllowed: true, + SupportsFetch: true, + Priority: 100, + CommonModels: []string{"gpt-5.4", "gpt-5.4-mini", "gpt-5.5"}, + Aliases: []string{"gpt"}, + httpAPI: true, + }, + "anthropic": { + ID: "anthropic", + DisplayName: "Anthropic", + IconSlug: "anthropic", + Domain: "anthropic.com", + DefaultAPIBase: "https://api.anthropic.com/v1", + CreateAllowed: true, + DefaultModelAllowed: true, + Priority: 95, + CommonModels: []string{"claude-opus-4-7", "claude-sonnet-4-6", "claude-haiku-4-5"}, + Aliases: []string{"claude"}, + httpAPI: true, + }, + "anthropic-messages": { + ID: "anthropic-messages", + DisplayName: "Anthropic Messages", + IconSlug: "anthropic", + Domain: "anthropic.com", + DefaultAPIBase: "https://api.anthropic.com/v1", + CreateAllowed: true, + DefaultModelAllowed: true, + Priority: 94, + CommonModels: []string{"claude-opus-4-7", "claude-sonnet-4-6", "claude-haiku-4-5"}, + httpAPI: true, + }, + "gemini": { + ID: "gemini", + DisplayName: "Google Gemini", + IconSlug: "googlegemini", + Domain: "gemini.google.com", + DefaultAPIBase: "https://generativelanguage.googleapis.com/v1beta", + CreateAllowed: true, + DefaultModelAllowed: true, + Priority: 90, + CommonModels: []string{"gemini-3.1-pro-preview", "gemini-3-flash-preview", "gemini-3.1-flash-lite"}, + Aliases: []string{"google"}, + httpAPI: true, + }, + "deepseek": { + ID: "deepseek", + DisplayName: "DeepSeek", + IconSlug: "deepseek", + Domain: "deepseek.com", + DefaultAPIBase: "https://api.deepseek.com/v1", + CreateAllowed: true, + DefaultModelAllowed: true, + SupportsFetch: true, + Priority: 85, + CommonModels: []string{"deepseek-v4-flash", "deepseek-v4-pro"}, + httpAPI: true, + }, + "openrouter": { + ID: "openrouter", + DisplayName: "OpenRouter", + IconSlug: "openrouter", + Domain: "openrouter.ai", + DefaultAPIBase: "https://openrouter.ai/api/v1", + CreateAllowed: true, + DefaultModelAllowed: true, + SupportsFetch: true, + Priority: 80, + CommonModels: []string{ + "openai/gpt-5.4", + "anthropic/claude-opus-4.7", + "google/gemini-3.1-pro-preview", + "qwen/qwen3-coder-next", + }, + httpAPI: true, + }, + "qwen-portal": { + ID: "qwen-portal", + DisplayName: "Qwen", + IconSlug: "alibabacloud", + Domain: "qwenlm.ai", + DefaultAPIBase: "https://dashscope.aliyuncs.com/compatible-mode/v1", + CreateAllowed: true, + DefaultModelAllowed: true, + SupportsFetch: true, + Priority: 75, + CommonModels: []string{"qwen3.6-max-preview", "qwen3.6-plus", "qwen3.6-flash", "qwen3-coder-next"}, + Aliases: []string{"qwen"}, + httpAPI: true, + }, + "qwen-intl": { + ID: "qwen-intl", + DisplayName: "Qwen International", + IconSlug: "alibabacloud", + Domain: "alibabacloud.com", + DefaultAPIBase: "https://dashscope-intl.aliyuncs.com/compatible-mode/v1", + CreateAllowed: true, + DefaultModelAllowed: true, + SupportsFetch: true, + Priority: 74, + CommonModels: []string{"qwen3.6-max-preview", "qwen3.6-plus", "qwen3.6-flash", "qwen3-coder-next"}, + Aliases: []string{"qwen-international", "dashscope-intl"}, + httpAPI: true, + }, + "moonshot": { + ID: "moonshot", + DisplayName: "Moonshot", + Domain: "moonshot.ai", + DefaultAPIBase: "https://api.moonshot.cn/v1", + CreateAllowed: true, + DefaultModelAllowed: true, + SupportsFetch: true, + Priority: 70, + CommonModels: []string{ + "kimi-k2.5", + "kimi-k2-thinking", + "kimi-k2-thinking-turbo", + "kimi-k2-turbo-preview", + }, + httpAPI: true, + }, + "volcengine": { + ID: "volcengine", + DisplayName: "Volcengine", + IconSlug: "bytedance", + Domain: "volcengine.com", + DefaultAPIBase: "https://ark.cn-beijing.volces.com/api/v3", + CreateAllowed: true, + DefaultModelAllowed: true, + SupportsFetch: true, + Priority: 69, + CommonModels: []string{ + "doubao-seed-1-6-251015", + "doubao-seed-1-6-flash-250828", + "doubao-seed-1-6-thinking", + }, + httpAPI: true, + }, + "zhipu": { + ID: "zhipu", + DisplayName: "Zhipu AI", + IconSlug: "zhipu", + Domain: "zhipuai.cn", + DefaultAPIBase: "https://open.bigmodel.cn/api/paas/v4", + CreateAllowed: true, + DefaultModelAllowed: true, + SupportsFetch: true, + Priority: 68, + CommonModels: []string{"glm-5", "glm-4.7", "glm-4.5-air", "glm-4-flash-250414"}, + Aliases: []string{"glm"}, + httpAPI: true, + }, + "groq": { + ID: "groq", + DisplayName: "Groq", + IconSlug: "groq", + Domain: "groq.com", + DefaultAPIBase: "https://api.groq.com/openai/v1", + CreateAllowed: true, + DefaultModelAllowed: true, + SupportsFetch: true, + Priority: 65, + CommonModels: []string{ + "openai/gpt-oss-120b", + "openai/gpt-oss-20b", + "llama-3.3-70b-versatile", + "qwen/qwen3-32b", + }, + httpAPI: true, + }, + "mistral": { + ID: "mistral", + DisplayName: "Mistral AI", + IconSlug: "mistralai", + Domain: "mistral.ai", + DefaultAPIBase: "https://api.mistral.ai/v1", + CreateAllowed: true, + DefaultModelAllowed: true, + SupportsFetch: true, + Priority: 64, + CommonModels: []string{ + "mistral-large-latest", + "mistral-medium-3-5", + "mistral-small-latest", + "devstral-latest", + }, + httpAPI: true, + }, + "nvidia": { + ID: "nvidia", + DisplayName: "NVIDIA", + IconSlug: "nvidia", + Domain: "nvidia.com", + DefaultAPIBase: "https://integrate.api.nvidia.com/v1", + CreateAllowed: true, + DefaultModelAllowed: true, + SupportsFetch: true, + Priority: 63, + CommonModels: []string{ + "openai/gpt-oss-120b", + "openai/gpt-oss-20b", + "qwen/qwen3-coder-480b-a35b-instruct", + "qwen/qwen3-next-80b-a3b-thinking", + }, + httpAPI: true, + }, + "cerebras": { + ID: "cerebras", + DisplayName: "Cerebras", + IconSlug: "cerebras", + Domain: "cerebras.ai", + DefaultAPIBase: "https://api.cerebras.ai/v1", + CreateAllowed: true, + DefaultModelAllowed: true, + SupportsFetch: true, + Priority: 62, + CommonModels: []string{"gpt-oss-120b", "zai-glm-4.7"}, + httpAPI: true, + }, + "azure": { + ID: "azure", + DisplayName: "Azure OpenAI", + IconSlug: "microsoftazure", + Domain: "azure.com", + CreateAllowed: true, + DefaultModelAllowed: true, + Priority: 61, + Aliases: []string{"azure-openai"}, + httpAPI: true, + }, + "bedrock": { + ID: "bedrock", + DisplayName: "AWS Bedrock", + IconSlug: "amazonwebservices", + Domain: "aws.amazon.com", + EmptyAPIKeyAllowed: true, + CreateAllowed: true, + DefaultModelAllowed: true, + Priority: 60, + }, + "github-copilot": { + ID: "github-copilot", + DisplayName: "GitHub Copilot", + IconSlug: "githubcopilot", + Domain: "github.com", + DefaultAPIBase: "localhost:4321", + EmptyAPIKeyAllowed: true, + CreateAllowed: true, + DefaultModelAllowed: true, + Local: true, + Priority: 55, + Aliases: []string{"copilot"}, + }, + "antigravity": { + ID: "antigravity", + DisplayName: "Google Code Assist", + Domain: "antigravity.google", + EmptyAPIKeyAllowed: true, + CreateAllowed: true, + DefaultModelAllowed: true, + DefaultAuthMethod: "oauth", + AuthMethodLocked: true, + Priority: 54, + Aliases: []string{"google-antigravity"}, + }, + "claude-cli": { + ID: "claude-cli", + DisplayName: "Claude CLI", + IconSlug: "anthropic", + Domain: "anthropic.com", + EmptyAPIKeyAllowed: true, + CreateAllowed: true, + DefaultModelAllowed: true, + Local: true, + Priority: 53, + Aliases: []string{"claudecli"}, + }, + "codex-cli": { + ID: "codex-cli", + DisplayName: "Codex CLI", + IconSlug: "openai", + Domain: "openai.com", + EmptyAPIKeyAllowed: true, + CreateAllowed: true, + DefaultModelAllowed: true, + Local: true, + Priority: 52, + Aliases: []string{"codexcli"}, + }, + "ollama": { + ID: "ollama", + DisplayName: "Ollama", + IconSlug: "ollama", + Domain: "ollama.com", + DefaultAPIBase: "http://localhost:11434/v1", + EmptyAPIKeyAllowed: true, + CreateAllowed: true, + DefaultModelAllowed: true, + SupportsFetch: true, + Local: true, + Priority: 50, + httpAPI: true, + }, + "vllm": { + ID: "vllm", + DisplayName: "VLLM", + Domain: "vllm.ai", + DefaultAPIBase: "http://localhost:8000/v1", + EmptyAPIKeyAllowed: true, + CreateAllowed: true, + DefaultModelAllowed: true, + SupportsFetch: true, + Local: true, + Priority: 49, + httpAPI: true, + }, + "lmstudio": { + ID: "lmstudio", + DisplayName: "LM Studio", + Domain: "lmstudio.ai", + DefaultAPIBase: "http://localhost:1234/v1", + EmptyAPIKeyAllowed: true, + CreateAllowed: true, + DefaultModelAllowed: true, + SupportsFetch: true, + Local: true, + Priority: 48, + httpAPI: true, + }, + "elevenlabs": { + ID: "elevenlabs", + DisplayName: "ElevenLabs ASR", + IconSlug: "elevenlabs", + Domain: "elevenlabs.io", + DefaultAPIBase: "https://api.elevenlabs.io", + CreateAllowed: true, + DefaultModelAllowed: false, + Priority: 47, + httpAPI: true, + }, + "venice": { + ID: "venice", + DisplayName: "Venice AI", + IconSlug: "venice", + Domain: "venice.ai", + DefaultAPIBase: "https://api.venice.ai/api/v1", + CreateAllowed: true, + DefaultModelAllowed: true, + SupportsFetch: true, + Priority: 45, + httpAPI: true, + }, + "shengsuanyun": { + ID: "shengsuanyun", + DisplayName: "ShengsuanYun", + Domain: "shengsuanyun.com", + DefaultAPIBase: "https://router.shengsuanyun.com/api/v1", + CreateAllowed: true, + DefaultModelAllowed: true, + SupportsFetch: true, + Priority: 44, + httpAPI: true, + }, + "siliconflow": { + ID: "siliconflow", + DisplayName: "SiliconFlow", + Domain: "siliconflow.cn", + DefaultAPIBase: "https://api.siliconflow.cn/v1", + CreateAllowed: true, + DefaultModelAllowed: true, + SupportsFetch: true, + Priority: 43.5, + httpAPI: true, + }, + "vivgrid": { + ID: "vivgrid", + DisplayName: "Vivgrid", + Domain: "vivgrid.com", + DefaultAPIBase: "https://api.vivgrid.com/v1", + CreateAllowed: true, + DefaultModelAllowed: true, + SupportsFetch: true, + Priority: 43, + httpAPI: true, + }, + "minimax": { + ID: "minimax", + DisplayName: "MiniMax", + Domain: "minimaxi.com", + DefaultAPIBase: "https://api.minimaxi.com/v1", + CreateAllowed: true, + DefaultModelAllowed: true, + SupportsFetch: true, + Priority: 42, + httpAPI: true, + }, + "longcat": { + ID: "longcat", + DisplayName: "LongCat", + Domain: "longcat.chat", + DefaultAPIBase: "https://api.longcat.chat/openai", + CreateAllowed: true, + DefaultModelAllowed: true, + SupportsFetch: true, + Priority: 41, + httpAPI: true, + }, + "modelscope": { + ID: "modelscope", + DisplayName: "ModelScope", + Domain: "modelscope.cn", + DefaultAPIBase: "https://api-inference.modelscope.cn/v1", + CreateAllowed: true, + DefaultModelAllowed: true, + SupportsFetch: true, + Priority: 40, + httpAPI: true, + }, + "mimo": { + ID: "mimo", + DisplayName: "Xiaomi MiMo", + IconSlug: "xiaomi", + Domain: "xiaomi.com", + DefaultAPIBase: "https://api.xiaomimimo.com/v1", + CreateAllowed: true, + DefaultModelAllowed: true, + SupportsFetch: true, + Priority: 39, + httpAPI: true, + }, + "avian": { + ID: "avian", + DisplayName: "Avian", + Domain: "avian.io", + DefaultAPIBase: "https://api.avian.io/v1", + CreateAllowed: true, + DefaultModelAllowed: true, + SupportsFetch: true, + Priority: 38, + httpAPI: true, + }, + "zai": { + ID: "zai", + DisplayName: "Z.ai", + Domain: "z.ai", + DefaultAPIBase: "https://api.z.ai/api/coding/paas/v4", + CreateAllowed: true, + DefaultModelAllowed: true, + SupportsFetch: true, + Priority: 37, + CommonModels: []string{"glm-5", "glm-4.7", "glm-4.5-air", "glm-4-flash-250414"}, + Aliases: []string{"z.ai", "z-ai"}, + httpAPI: true, + }, + "alibaba-coding": { + ID: "alibaba-coding", + DisplayName: "Alibaba Coding Plan", + IconSlug: "alibabacloud", + Domain: "alibabacloud.com", + DefaultAPIBase: "https://coding-intl.dashscope.aliyuncs.com/v1", + CreateAllowed: true, + DefaultModelAllowed: true, + Priority: 36.5, + CommonModels: []string{"qwen3.6-plus", "kimi-k2.5", "glm-5", "MiniMax-M2.5"}, + Aliases: []string{"coding-plan", "qwen-coding"}, + httpAPI: true, + }, + "alibaba-coding-anthropic": { + ID: "alibaba-coding-anthropic", + DisplayName: "Alibaba Coding Plan (Anthropic)", + IconSlug: "alibabacloud", + Domain: "alibabacloud.com", + DefaultAPIBase: "https://coding-intl.dashscope.aliyuncs.com/apps/anthropic", + CreateAllowed: true, + DefaultModelAllowed: true, + Priority: 36.25, + CommonModels: []string{"qwen3.6-plus", "kimi-k2.5", "glm-5", "MiniMax-M2.5"}, + Aliases: []string{"coding-plan-anthropic"}, + httpAPI: true, + }, + "novita": { + ID: "novita", + DisplayName: "Novita AI", + Domain: "novita.ai", + DefaultAPIBase: "https://api.novita.ai/openai", + CreateAllowed: true, + DefaultModelAllowed: true, + SupportsFetch: true, + Priority: 36, + httpAPI: true, + }, + "litellm": { + ID: "litellm", + DisplayName: "LiteLLM", + Domain: "litellm.ai", + DefaultAPIBase: "http://localhost:4000/v1", + CreateAllowed: true, + DefaultModelAllowed: true, + SupportsFetch: true, + Priority: 35, + httpAPI: true, + }, + "qwen-us": { + ID: "qwen-us", + DisplayName: "Qwen US", + IconSlug: "alibabacloud", + Domain: "alibabacloud.com", + DefaultAPIBase: "https://dashscope-us.aliyuncs.com/compatible-mode/v1", + CreateAllowed: true, + DefaultModelAllowed: true, + Priority: 34, + CommonModels: []string{"qwen3.6-max-preview", "qwen3.6-plus", "qwen3.6-flash", "qwen3-coder-next"}, + Aliases: []string{"dashscope-us"}, + httpAPI: true, + }, +} + +var normalizedModelProviderAliasesByName = buildModelProviderAliasMap() + +func buildModelProviderAliasMap() map[string]string { + totalAliases := 0 + for _, option := range modelProviderOptionsByName { + totalAliases += len(option.Aliases) + } + + aliases := make(map[string]string, len(modelProviderOptionsByName)+totalAliases) + for provider, option := range modelProviderOptionsByName { + aliases[provider] = provider + for _, alias := range option.Aliases { + normalized := strings.ToLower(strings.TrimSpace(alias)) + if normalized == "" { + continue + } + aliases[normalized] = provider + } + } + return aliases +} + +func modelProviderOptionForName(provider string) (ModelProviderOption, bool) { + normalized := NormalizeProvider(provider) + if normalized == "" { + return ModelProviderOption{}, false + } + option, ok := modelProviderOptionsByName[normalized] + return option, ok +} diff --git a/web/backend/api/model_catalog.go b/web/backend/api/model_catalog.go index ce50deaf..edce03cf 100644 --- a/web/backend/api/model_catalog.go +++ b/web/backend/api/model_catalog.go @@ -12,6 +12,7 @@ import ( "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/fileutil" + "github.com/sipeed/picoclaw/pkg/providers" ) // CatalogModel represents a single model entry in a saved catalog. @@ -42,7 +43,7 @@ func catalogFilePath() string { // generateCatalogKey creates a deterministic key for a provider+base+key combination. func generateCatalogKey(provider, apiBase, apiKey string) string { - provider = strings.ToLower(strings.TrimSpace(provider)) + provider = providers.NormalizeProvider(provider) apiBase = strings.TrimRight(strings.TrimSpace(apiBase), "/") hash := sha256.Sum256([]byte(apiKey)) return fmt.Sprintf("%s|%s|%x", provider, apiBase, hash[:6]) @@ -104,9 +105,10 @@ func SaveCatalog(provider, apiBase, apiKey string, models []CatalogModel) error return err } key := generateCatalogKey(provider, apiBase, apiKey) + provider = providers.NormalizeProvider(provider) store.Entries[key] = &CatalogEntry{ ID: key, - Provider: strings.ToLower(strings.TrimSpace(provider)), + Provider: provider, APIBase: strings.TrimRight(strings.TrimSpace(apiBase), "/"), APIKeyMask: maskAPIKeyValue(apiKey), Models: models, diff --git a/web/backend/api/model_status.go b/web/backend/api/model_status.go index 6cfda501..435664d7 100644 --- a/web/backend/api/model_status.go +++ b/web/backend/api/model_status.go @@ -126,7 +126,7 @@ func hasStoredOAuthCredential(m *config.ModelConfig) (bool, bool) { func providerUsesImplicitOAuth(protocol string) bool { switch protocol { - case "antigravity", "google-antigravity": + case "antigravity": return true default: return false @@ -168,11 +168,11 @@ func requiresRuntimeProbe(m *config.ModelConfig) bool { protocol := modelProtocol(m) switch protocol { - case "claude-cli", "claudecli", "codex-cli", "codexcli", "github-copilot", "copilot": + case "claude-cli", "codex-cli", "github-copilot": return true } - if providers.IsEmptyAPIKeyAllowedForProtocol(protocol) { + if providers.IsHTTPAPIProtocol(protocol) && providers.IsEmptyAPIKeyAllowedForProtocol(protocol) { apiBase := strings.TrimSpace(m.APIBase) return apiBase == "" || hasLocalAPIBase(apiBase) } @@ -220,11 +220,11 @@ func runLocalModelProbe(m *config.ModelConfig) bool { return probeOllamaModelFunc(apiBase, modelID) case "vllm", "lmstudio": return probeOpenAICompatibleModelFunc(apiBase, modelID, m.APIKey()) - case "github-copilot", "copilot": + case "github-copilot": return probeTCPServiceFunc(apiBase) - case "claude-cli", "claudecli": + case "claude-cli": return probeCommandAvailableFunc("claude") - case "codex-cli", "codexcli": + case "codex-cli": return probeCommandAvailableFunc("codex") default: if hasLocalAPIBase(apiBase) { @@ -442,7 +442,7 @@ func modelProbeAPIBase(m *config.ModelConfig) string { } switch protocol { - case "github-copilot", "copilot": + case "github-copilot": return "localhost:4321" default: return "" @@ -477,7 +477,7 @@ func oauthProviderForModel(m *config.ModelConfig) (string, bool) { return oauthProviderOpenAI, true case "anthropic": return oauthProviderAnthropic, true - case "antigravity", "google-antigravity": + case "antigravity": return oauthProviderGoogleAntigravity, true default: return "", false diff --git a/web/backend/api/models.go b/web/backend/api/models.go index 4fea86cd..5c1eb23a 100644 --- a/web/backend/api/models.go +++ b/web/backend/api/models.go @@ -18,19 +18,6 @@ import ( "github.com/sipeed/picoclaw/pkg/providers" ) -// fetchableProviders lists providers that support OpenAI-compatible /models listing. -var fetchableProviders = map[string]bool{ - "openai": true, "deepseek": true, "openrouter": true, - "qwen-portal": true, "qwen-intl": true, "moonshot": true, - "volcengine": true, "zhipu": true, "groq": true, - "mistral": true, "nvidia": true, "cerebras": true, - "venice": true, "shengsuanyun": true, "vivgrid": true, - "siliconflow": true, - "minimax": true, "longcat": true, "modelscope": true, - "mimo": true, "avian": true, "zai": true, "novita": true, - "litellm": true, "vllm": true, "lmstudio": true, "ollama": true, -} - // registerModelRoutes binds model list management endpoints to the ServeMux. func (h *Handler) registerModelRoutes(mux *http.ServeMux) { mux.HandleFunc("GET /api/models", h.handleListModels) @@ -667,7 +654,7 @@ func (h *Handler) handleFetchModels(w http.ResponseWriter, r *http.Request) { return } - if !fetchableProviders[strings.ToLower(req.Provider)] { + if !providers.IsModelProviderFetchable(req.Provider) { http.Error(w, fmt.Sprintf("provider %q does not support model listing", req.Provider), http.StatusBadRequest) return } @@ -1012,11 +999,11 @@ func probeModelConnectivity(m *config.ModelConfig) bool { return probeOllamaModel(apiBase, modelID) case "vllm", "lmstudio": return probeOpenAICompatibleModel(apiBase, modelID, m.APIKey()) - case "github-copilot", "copilot": + case "github-copilot": return probeTCPService(apiBase) - case "claude-cli", "claudecli": + case "claude-cli": return probeCommandAvailable("claude") - case "codex-cli", "codexcli": + case "codex-cli": return probeCommandAvailable("codex") default: // For remote providers (OpenAI, Anthropic, Gemini, DeepSeek, etc.), diff --git a/web/backend/api/models_test.go b/web/backend/api/models_test.go index 1ac5b626..94a3da3b 100644 --- a/web/backend/api/models_test.go +++ b/web/backend/api/models_test.go @@ -1900,6 +1900,12 @@ func TestHandleListModels_ReturnsProviderOptionsWithoutPersistingLegacyMigration t.Fatal("openai provider option missing") } else if option.DefaultAPIBase != "https://api.openai.com/v1" { t.Fatalf("openai default_api_base = %q, want %q", option.DefaultAPIBase, "https://api.openai.com/v1") + } else if !option.SupportsFetch { + t.Fatal("openai provider option should report supports_fetch") + } else if option.DisplayName != "OpenAI" { + t.Fatalf("openai display_name = %q, want %q", option.DisplayName, "OpenAI") + } else if len(option.CommonModels) == 0 { + t.Fatal("openai common_models should not be empty") } if option, ok := optionsByID["anthropic"]; !ok { t.Fatal("anthropic provider option missing") @@ -1913,6 +1919,8 @@ func TestHandleListModels_ReturnsProviderOptionsWithoutPersistingLegacyMigration t.Fatal("github-copilot provider option missing") } else if option.DefaultAPIBase != "localhost:4321" { t.Fatalf("github-copilot default_api_base = %q, want %q", option.DefaultAPIBase, "localhost:4321") + } else if !option.Local { + t.Fatal("github-copilot should be marked local") } if option, ok := optionsByID["elevenlabs"]; !ok { t.Fatal("elevenlabs provider option missing") @@ -1953,6 +1961,11 @@ func TestHandleListModels_ReturnsProviderOptionsWithoutPersistingLegacyMigration t.Fatal("antigravity auth method should be locked") } } + if option, ok := optionsByID["qwen-portal"]; !ok { + t.Fatal("qwen-portal provider option missing") + } else if len(option.Aliases) == 0 || option.Aliases[0] != "qwen" { + t.Fatalf("qwen-portal aliases = %#v, want to include qwen", option.Aliases) + } updated, err := config.LoadConfig(configPath) if err != nil { diff --git a/web/backend/api/oauth.go b/web/backend/api/oauth.go index 116e304b..7586a45b 100644 --- a/web/backend/api/oauth.go +++ b/web/backend/api/oauth.go @@ -767,7 +767,7 @@ func modelBelongsToProvider(provider string, modelCfg *config.ModelConfig) bool case oauthProviderAnthropic: return protocol == "anthropic" case oauthProviderGoogleAntigravity: - return protocol == "antigravity" || protocol == "google-antigravity" + return protocol == "antigravity" default: return false } diff --git a/web/frontend/src/api/models.ts b/web/frontend/src/api/models.ts index 4ed0e076..5f89cbfc 100644 --- a/web/frontend/src/api/models.ts +++ b/web/frontend/src/api/models.ts @@ -36,12 +36,20 @@ export interface ModelInfo { export interface ModelProviderOption { id: string + display_name?: string + icon_slug?: string + domain?: string default_api_base: string empty_api_key_allowed: boolean create_allowed: boolean default_model_allowed: boolean + supports_fetch?: boolean default_auth_method?: string auth_method_locked?: boolean + local?: boolean + priority?: number + common_models?: string[] + aliases?: string[] } interface ModelsListResponse { diff --git a/web/frontend/src/components/models/add-model-sheet.tsx b/web/frontend/src/components/models/add-model-sheet.tsx index 2059199b..453bbe25 100644 --- a/web/frontend/src/components/models/add-model-sheet.tsx +++ b/web/frontend/src/components/models/add-model-sheet.tsx @@ -36,10 +36,22 @@ import { showSaveSuccessOrRestartToast } from "@/lib/restart-required" import { refreshGatewayState } from "@/store/gateway" import { FetchModelsDialog } from "./fetch-models-dialog" +import { + getEffectiveAPIBase, + getSubmittedAPIBase, + normalizeApiBase, +} from "./model-provider-form-shared" import { type FieldValidation, validateModelField } from "./model-validation" import { ProviderCombobox } from "./provider-combobox" -import { getProviderKey } from "./provider-label" -import { FETCHABLE_PROVIDER_KEYS, PROVIDER_MAP } from "./provider-registry" +import { + getCanonicalProviderKey, + getProviderCatalogEntry, + getProviderCatalogMap, + getProviderDefaultAPIBase, + getProviderDefaultAuthMethod, + isProviderAuthMethodLocked, + providerSupportsFetch, +} from "./provider-registry" import { TestModelDialog } from "./test-model-dialog" interface AddForm { @@ -82,37 +94,6 @@ const EMPTY_ADD_FORM: AddForm = { customHeaders: "", } -function normalizeApiBase(value: string): string { - return value.trim().replace(/\/+$/, "") -} - -function getNextApiBaseForProviderChange( - currentApiBase: string, - currentProvider: string, - nextProvider: string, -): string { - const normalizedCurrentApiBase = normalizeApiBase(currentApiBase) - const currentDefaultApiBase = normalizeApiBase( - PROVIDER_MAP.get(currentProvider)?.defaultApiBase ?? "", - ) - const nextDefaultApiBase = - PROVIDER_MAP.get(nextProvider)?.defaultApiBase ?? "" - - if (!normalizedCurrentApiBase) { - return nextDefaultApiBase - } - - if ( - normalizedCurrentApiBase && - currentDefaultApiBase && - normalizedCurrentApiBase === currentDefaultApiBase - ) { - return nextDefaultApiBase - } - - return currentApiBase -} - interface AddModelSheetProps { open: boolean onClose: () => void @@ -144,6 +125,7 @@ export function AddModelSheet({ const [catalogModels, setCatalogModels] = useState([]) const debounceRef = useRef>(undefined) const scrollContainerRef = useRef(null) + const providerMap = getProviderCatalogMap(providerOptions) const apiKeyPlaceholder = maskedSecretPlaceholder( form.apiKey, @@ -166,8 +148,12 @@ export function AddModelSheet({ // Load catalog models when provider or apiBase changes useEffect(() => { - const providerKey = getProviderKey(form.provider || undefined) - const apiBase = form.apiBase.trim().replace(/\/+$/, "") + const providerKey = getCanonicalProviderKey(form.provider, providerOptions) + const apiBase = getEffectiveAPIBase( + form.provider, + form.apiBase, + providerOptions, + ) if (!form.provider.trim()) { setCatalogModels([]) return @@ -177,7 +163,7 @@ export function AddModelSheet({ .then((res) => { if (cancelled) return const matched = (res.entries || []).filter((e) => { - const ep = getProviderKey(e.provider || undefined) + const ep = getCanonicalProviderKey(e.provider, providerOptions) const eb = (e.api_base ?? "").trim().replace(/\/+$/, "") return ep === providerKey && eb === apiBase }) @@ -189,7 +175,7 @@ export function AddModelSheet({ return () => { cancelled = true } - }, [form.provider, form.apiBase]) + }, [form.provider, form.apiBase, providerOptions]) const validate = (): boolean => { const errors: Partial> = {} @@ -199,6 +185,9 @@ export function AddModelSheet({ } else if (existingModelNames.some((name) => name.trim() === modelName)) { errors.modelName = t("models.add.errorDuplicateModelName") } + if (!providerDef) { + errors.provider = t("models.field.providerInvalid") + } if (!form.model.trim()) errors.model = t("models.add.errorRequired") if (modelValidation?.level === "error") { errors.model = t( @@ -223,11 +212,15 @@ export function AddModelSheet({ (value: string, provider: string) => { if (debounceRef.current) clearTimeout(debounceRef.current) debounceRef.current = setTimeout(() => { - const result = validateModelField(value, provider || undefined) + const result = validateModelField( + value, + provider || undefined, + providerOptions, + ) setModelValidation(result) }, 300) }, - [], + [providerOptions], ) const handleModelChange = (e: React.ChangeEvent) => { @@ -241,14 +234,41 @@ export function AddModelSheet({ const handleProviderChange = (provider: string) => { setForm((f) => { + const previousOption = getProviderCatalogEntry( + f.provider, + providerOptions, + ) + const nextOption = getProviderCatalogEntry(provider, providerOptions) + const previousDefaultBase = normalizeApiBase( + getProviderDefaultAPIBase(f.provider, providerOptions), + ) + const nextDefaultBase = normalizeApiBase( + getProviderDefaultAPIBase(provider, providerOptions), + ) + const currentApiBase = normalizeApiBase(f.apiBase) + let authMethod = f.authMethod + let apiBase = f.apiBase + if (nextOption?.authMethodLocked) { + authMethod = nextOption.defaultAuthMethod ?? "" + } else if ( + previousOption?.authMethodLocked && + f.authMethod === (previousOption.defaultAuthMethod ?? "") + ) { + authMethod = "" + } + if ( + currentApiBase && + previousDefaultBase && + currentApiBase === previousDefaultBase && + currentApiBase !== nextDefaultBase + ) { + apiBase = "" + } return { ...f, - provider, - apiBase: getNextApiBaseForProviderChange( - f.apiBase, - f.provider, - provider, - ), + provider: getCanonicalProviderKey(provider, providerOptions), + apiBase, + authMethod, } }) // Re-validate model with new provider context @@ -257,11 +277,14 @@ export function AddModelSheet({ } // Clear setAsDefault if the new provider doesn't support being default const allowed = - providerOptions?.find((o) => o.id === provider)?.default_model_allowed ?? + getProviderCatalogEntry(provider, providerOptions)?.defaultModelAllowed ?? false if (!allowed) { setSetAsDefault(false) } + if (fieldErrors.provider) { + setFieldErrors((prev) => ({ ...prev, provider: undefined })) + } } const applyFix = () => { @@ -290,12 +313,38 @@ export function AddModelSheet({ } } - const providerDef = PROVIDER_MAP.get(form.provider) + const canonicalProvider = getCanonicalProviderKey( + form.provider, + providerOptions, + ) + const providerDef = canonicalProvider + ? providerMap.get(canonicalProvider) + : undefined const commonModels = providerDef?.commonModels || [] - const defaultModelAllowed = form.provider - ? (providerOptions?.find((o) => o.id === form.provider) - ?.default_model_allowed ?? false) - : false + const authMethodLocked = isProviderAuthMethodLocked( + form.provider, + providerOptions, + ) + const defaultAuthMethod = getProviderDefaultAuthMethod( + form.provider, + providerOptions, + ) + const effectiveAuthMethod = ( + authMethodLocked ? defaultAuthMethod : form.authMethod + ) + .trim() + .toLowerCase() + const isOAuth = effectiveAuthMethod === "oauth" + const defaultModelAllowed = providerDef?.defaultModelAllowed === true + const apiBasePlaceholder = + getProviderDefaultAPIBase(form.provider, providerOptions) || + "https://api.example.com/v1" + const effectiveApiBase = getEffectiveAPIBase( + form.provider, + form.apiBase, + providerOptions, + ) + const submittedApiBase = getSubmittedAPIBase(form.apiBase) const handleSave = async () => { if (!validate()) return @@ -331,16 +380,18 @@ export function AddModelSheet({ setServerError("") try { const modelName = form.modelName.trim() - const provider = form.provider.trim() + const provider = canonicalProvider const modelId = form.model.trim() await addModel({ model_name: modelName, provider: provider || undefined, model: modelId, - api_base: form.apiBase.trim() || undefined, + api_base: submittedApiBase, api_key: form.apiKey.trim() || undefined, proxy: form.proxy.trim() || undefined, - auth_method: form.authMethod.trim() || undefined, + auth_method: authMethodLocked + ? defaultAuthMethod || undefined + : form.authMethod.trim() || undefined, connect_mode: form.connectMode.trim() || undefined, workspace: form.workspace.trim() || undefined, rpm: form.rpm ? Number(form.rpm) : undefined, @@ -414,6 +465,8 @@ export function AddModelSheet({ )}
- {form.provider && - FETCHABLE_PROVIDER_KEYS.has(form.provider) && ( - - )} + {providerSupportsFetch(form.provider, providerOptions) && ( + + )} {!form.provider && ( {t("models.field.selectProviderFirst")} @@ -537,19 +589,25 @@ export function AddModelSheet({
- - setForm((f) => ({ ...f, apiKey: v }))} - placeholder={apiKeyPlaceholder} - /> - + {!isOAuth && ( + + setForm((f) => ({ ...f, apiKey: v }))} + placeholder={apiKeyPlaceholder} + /> + + )} - + @@ -591,12 +649,19 @@ export function AddModelSheet({ @@ -751,9 +816,10 @@ export function AddModelSheet({ open={fetchOpen} onClose={() => setFetchOpen(false)} onFill={handleFetchFill} - provider={form.provider} + provider={canonicalProvider} apiKey={form.apiKey} - apiBase={form.apiBase} + apiBase={effectiveApiBase} + backendOptions={providerOptions} /> setTestOpen(false)} inlineParams={{ - provider: form.provider, + provider: canonicalProvider, model: form.model, - apiBase: form.apiBase, + apiBase: effectiveApiBase, apiKey: form.apiKey, - authMethod: form.authMethod, + authMethod: effectiveAuthMethod, }} /> diff --git a/web/frontend/src/components/models/catalog-dialog.tsx b/web/frontend/src/components/models/catalog-dialog.tsx index 9fe7283c..dcca1f71 100644 --- a/web/frontend/src/components/models/catalog-dialog.tsx +++ b/web/frontend/src/components/models/catalog-dialog.tsx @@ -11,6 +11,7 @@ import { toast } from "sonner" import { type CatalogEntry, type CatalogModel, + type ModelProviderOption, addModel, deleteCatalog, getCatalogs, @@ -27,21 +28,26 @@ import { import { Input } from "@/components/ui/input" import { refreshGatewayState } from "@/store/gateway" -import { getProviderLabel } from "./provider-label" -import { PROVIDER_MAP } from "./provider-registry" +import { + getCanonicalProviderKey, + getProviderCatalogMap, +} from "./provider-registry" interface CatalogDialogProps { open: boolean onClose: () => void onModelAdded: () => void + providerOptions?: ModelProviderOption[] } export function CatalogDialog({ open, onClose, onModelAdded, + providerOptions, }: CatalogDialogProps) { const { t } = useTranslation() + const providerMap = getProviderCatalogMap(providerOptions) const [loading, setLoading] = useState(false) const [entries, setEntries] = useState([]) const [expandedId, setExpandedId] = useState(null) @@ -188,6 +194,11 @@ export function CatalogDialog({ const isExpanded = expandedId === entry.id const entrySelected = selected.get(entry.id) || new Set() const filteredModels = getFilteredModels(entry.models) + const providerKey = getCanonicalProviderKey( + entry.provider, + providerOptions, + ) + const providerDef = providerMap.get(providerKey) return (
- {getProviderLabel(entry.provider)} + {providerDef?.label || providerKey} {entry.api_key_mask} @@ -290,7 +301,7 @@ export function CatalogDialog({
{entrySelected.size > 0 && (
- {PROVIDER_MAP.get(entry.provider)?.requiresApiKey !== + {providerDef?.requiresApiKey !== false && (
{t("models.catalog.needApiKey")} diff --git a/web/frontend/src/components/models/edit-model-sheet.tsx b/web/frontend/src/components/models/edit-model-sheet.tsx index 7aec4d69..c01732be 100644 --- a/web/frontend/src/components/models/edit-model-sheet.tsx +++ b/web/frontend/src/components/models/edit-model-sheet.tsx @@ -37,13 +37,21 @@ import { showSaveSuccessOrRestartToast } from "@/lib/restart-required" import { refreshGatewayState } from "@/store/gateway" import { FetchModelsDialog } from "./fetch-models-dialog" +import { + getEffectiveAPIBase, + getSubmittedAPIBase, + normalizeApiBase, +} from "./model-provider-form-shared" import { type FieldValidation, validateModelField } from "./model-validation" import { ProviderCombobox } from "./provider-combobox" -import { getProviderKey } from "./provider-label" import { - FETCHABLE_PROVIDER_KEYS, - PROVIDER_API_BASES, - PROVIDER_MAP, + getCanonicalProviderKey, + getProviderCatalogEntry, + getProviderCatalogMap, + getProviderDefaultAPIBase, + getProviderDefaultAuthMethod, + isProviderAuthMethodLocked, + providerSupportsFetch, } from "./provider-registry" import { TestModelDialog } from "./test-model-dialog" @@ -74,39 +82,9 @@ interface EditModelSheetProps { providerOptions?: ModelProviderOption[] } -function normalizeApiBase(value: string): string { - return value.trim().replace(/\/+$/, "") -} - -function getNextApiBaseForProviderChange( - currentApiBase: string, - currentProvider: string, - nextProvider: string, -): string { - const normalizedCurrentApiBase = normalizeApiBase(currentApiBase) - const currentDefaultApiBase = normalizeApiBase( - PROVIDER_API_BASES[currentProvider] || "", - ) - const nextDefaultApiBase = PROVIDER_API_BASES[nextProvider] || "" - - if (!normalizedCurrentApiBase) { - return nextDefaultApiBase - } - - if ( - normalizedCurrentApiBase && - currentDefaultApiBase && - normalizedCurrentApiBase === currentDefaultApiBase - ) { - return nextDefaultApiBase - } - - return currentApiBase -} - function buildInitialEditForm(model: ModelInfo): EditForm { return { - provider: model.provider ?? "", + provider: getCanonicalProviderKey(model.provider), modelId: model.model, apiKey: "", apiBase: model.api_base ?? "", @@ -166,6 +144,7 @@ export function EditModelSheet({ const [catalogModels, setCatalogModels] = useState([]) const debounceRef = useRef>(undefined) const scrollContainerRef = useRef(null) + const providerMap = getProviderCatalogMap(providerOptions) const initialForm = model ? buildInitialEditForm(model) : null const isDirty = @@ -182,12 +161,19 @@ export function EditModelSheet({ setFetchedModels([]) setCatalogModels([]) // Load matching catalog models - const providerKey = getProviderKey(model.provider || undefined) - const apiBase = (model.api_base ?? "").trim().replace(/\/+$/, "") + const providerKey = getCanonicalProviderKey( + model.provider, + providerOptions, + ) + const apiBase = getEffectiveAPIBase( + model.provider ?? "", + model.api_base ?? "", + providerOptions, + ) getCatalogs() .then((res) => { const matched = (res.entries || []).filter((e) => { - const ep = getProviderKey(e.provider || undefined) + const ep = getCanonicalProviderKey(e.provider, providerOptions) const eb = (e.api_base ?? "").trim().replace(/\/+$/, "") return ep === providerKey && eb === apiBase }) @@ -197,22 +183,28 @@ export function EditModelSheet({ }) .catch(() => {}) } - }, [model]) + }, [model, providerOptions]) const setField = (key: keyof EditForm) => - (e: React.ChangeEvent) => + (e: React.ChangeEvent) => { + if (error) setError("") setForm((f) => ({ ...f, [key]: e.target.value })) + } const debouncedValidateModel = useCallback( (value: string, provider: string) => { if (debounceRef.current) clearTimeout(debounceRef.current) debounceRef.current = setTimeout(() => { - const result = validateModelField(value, provider || undefined) + const result = validateModelField( + value, + provider || undefined, + providerOptions, + ) setModelValidation(result) }, 300) }, - [], + [providerOptions], ) const handleModelChange = (e: React.ChangeEvent) => { @@ -222,16 +214,50 @@ export function EditModelSheet({ } const handleProviderChange = (provider: string) => { - setForm((f) => ({ - ...f, - provider, - apiBase: getNextApiBaseForProviderChange(f.apiBase, f.provider, provider), - })) + if (error) setError("") + setForm((f) => { + const previousOption = getProviderCatalogEntry( + f.provider, + providerOptions, + ) + const nextOption = getProviderCatalogEntry(provider, providerOptions) + const previousDefaultBase = normalizeApiBase( + getProviderDefaultAPIBase(f.provider, providerOptions), + ) + const nextDefaultBase = normalizeApiBase( + getProviderDefaultAPIBase(provider, providerOptions), + ) + const currentApiBase = normalizeApiBase(f.apiBase) + let authMethod = f.authMethod + let apiBase = f.apiBase + if (nextOption?.authMethodLocked) { + authMethod = nextOption.defaultAuthMethod ?? "" + } else if ( + previousOption?.authMethodLocked && + f.authMethod === (previousOption.defaultAuthMethod ?? "") + ) { + authMethod = "" + } + if ( + currentApiBase && + previousDefaultBase && + currentApiBase === previousDefaultBase && + currentApiBase !== nextDefaultBase + ) { + apiBase = "" + } + return { + ...f, + provider: getCanonicalProviderKey(provider, providerOptions), + apiBase, + authMethod, + } + }) if (form.modelId) { debouncedValidateModel(form.modelId, provider) } const allowed = - providerOptions?.find((o) => o.id === provider)?.default_model_allowed ?? + getProviderCatalogEntry(provider, providerOptions)?.defaultModelAllowed ?? false if (!allowed) { setSetAsDefault(false) @@ -258,15 +284,45 @@ export function EditModelSheet({ } } - const providerDef = PROVIDER_MAP.get(form.provider) + const canonicalProvider = getCanonicalProviderKey( + form.provider, + providerOptions, + ) + const providerDef = canonicalProvider + ? providerMap.get(canonicalProvider) + : undefined const commonModels = providerDef?.commonModels || [] - const defaultModelAllowed = form.provider - ? (providerOptions?.find((o) => o.id === form.provider) - ?.default_model_allowed ?? false) - : false + const authMethodLocked = isProviderAuthMethodLocked( + form.provider, + providerOptions, + ) + const defaultAuthMethod = getProviderDefaultAuthMethod( + form.provider, + providerOptions, + ) + const effectiveAuthMethod = ( + authMethodLocked ? defaultAuthMethod : form.authMethod + ) + .trim() + .toLowerCase() + const isOAuth = effectiveAuthMethod === "oauth" + const defaultModelAllowed = providerDef?.defaultModelAllowed === true + const apiBasePlaceholder = + getProviderDefaultAPIBase(form.provider, providerOptions) || + "https://api.example.com/v1" + const effectiveApiBase = getEffectiveAPIBase( + form.provider, + form.apiBase, + providerOptions, + ) + const submittedApiBase = getSubmittedAPIBase(form.apiBase) const handleSave = async () => { if (!model) return + if (!providerDef) { + setError(t("models.field.providerInvalid")) + return + } if (!form.modelId.trim()) { setError(t("models.add.errorRequired")) return @@ -304,7 +360,7 @@ export function EditModelSheet({ setError("") try { const modelId = form.modelId.trim() - const provider = form.provider.trim() + const provider = canonicalProvider const streaming = model.streaming?.enabled === true || form.streamingEnabled ? { enabled: form.streamingEnabled } @@ -313,18 +369,20 @@ export function EditModelSheet({ model_name: model.model_name, provider: provider, model: modelId, - api_base: form.apiBase || undefined, - api_key: form.apiKey || undefined, - proxy: form.proxy || undefined, - auth_method: form.authMethod || undefined, - connect_mode: form.connectMode || undefined, - workspace: form.workspace || undefined, + api_base: submittedApiBase, + api_key: form.apiKey.trim() || undefined, + proxy: form.proxy.trim() || undefined, + auth_method: authMethodLocked + ? defaultAuthMethod || undefined + : form.authMethod.trim() || undefined, + connect_mode: form.connectMode.trim() || undefined, + workspace: form.workspace.trim() || undefined, rpm: form.rpm ? Number(form.rpm) : undefined, - max_tokens_field: form.maxTokensField || undefined, + max_tokens_field: form.maxTokensField.trim() || undefined, request_timeout: form.requestTimeout ? Number(form.requestTimeout) : undefined, - thinking_level: form.thinkingLevel || undefined, + thinking_level: form.thinkingLevel.trim() || undefined, tool_schema_transform: form.toolSchemaTransform.trim() || undefined, streaming, extra_body: extraBody, @@ -349,7 +407,6 @@ export function EditModelSheet({ } } - const isOAuth = model?.auth_method === "oauth" const hasSavedAPIKey = Boolean(model?.api_key) const apiKeyPlaceholder = hasSavedAPIKey ? maskedSecretPlaceholder( @@ -382,6 +439,12 @@ export function EditModelSheet({ )}
- {form.provider && - FETCHABLE_PROVIDER_KEYS.has(form.provider) && ( - - )} + {providerSupportsFetch(form.provider, providerOptions) && ( + + )}
@@ -514,7 +576,7 @@ export function EditModelSheet({ @@ -557,12 +619,19 @@ export function EditModelSheet({ @@ -719,11 +788,11 @@ export function EditModelSheet({ open={testOpen} onClose={() => setTestOpen(false)} inlineParams={{ - provider: form.provider, + provider: canonicalProvider, model: form.modelId, - apiBase: form.apiBase, + apiBase: effectiveApiBase, apiKey: form.apiKey, - authMethod: form.authMethod, + authMethod: effectiveAuthMethod, modelIndex: model?.index, }} /> @@ -732,9 +801,10 @@ export function EditModelSheet({ open={fetchOpen} onClose={() => setFetchOpen(false)} onFill={handleFetchFill} - provider={form.provider} + provider={canonicalProvider} apiKey={form.apiKey} - apiBase={form.apiBase} + apiBase={effectiveApiBase} + backendOptions={providerOptions} /> ) diff --git a/web/frontend/src/components/models/fetch-models-dialog.tsx b/web/frontend/src/components/models/fetch-models-dialog.tsx index 09b602e6..8ea9e52a 100644 --- a/web/frontend/src/components/models/fetch-models-dialog.tsx +++ b/web/frontend/src/components/models/fetch-models-dialog.tsx @@ -2,7 +2,11 @@ import { IconDownload, IconLoader2 } from "@tabler/icons-react" import { useCallback, useEffect, useState } from "react" import { useTranslation } from "react-i18next" -import { type UpstreamModel, fetchUpstreamModels } from "@/api/models" +import { + type ModelProviderOption, + type UpstreamModel, + fetchUpstreamModels, +} from "@/api/models" import { Button } from "@/components/ui/button" import { Dialog, @@ -14,7 +18,10 @@ import { } from "@/components/ui/dialog" import { Input } from "@/components/ui/input" -import { PROVIDER_MAP } from "./provider-registry" +import { + getCanonicalProviderKey, + getProviderCatalogMap, +} from "./provider-registry" interface FetchModelsDialogProps { open: boolean @@ -23,6 +30,7 @@ interface FetchModelsDialogProps { provider: string apiKey: string apiBase: string + backendOptions?: ModelProviderOption[] } export function FetchModelsDialog({ @@ -32,6 +40,7 @@ export function FetchModelsDialog({ provider, apiKey, apiBase, + backendOptions, }: FetchModelsDialogProps) { const { t } = useTranslation() const [fetching, setFetching] = useState(false) @@ -40,7 +49,8 @@ export function FetchModelsDialog({ const [error, setError] = useState("") const [filter, setFilter] = useState("") - const providerDef = PROVIDER_MAP.get(provider) + const canonicalProvider = getCanonicalProviderKey(provider, backendOptions) + const providerDef = getProviderCatalogMap(backendOptions).get(canonicalProvider) const needsKey = providerDef?.requiresApiKey !== false const handleFetch = useCallback(async () => { @@ -50,7 +60,7 @@ export function FetchModelsDialog({ setSelected(new Set()) try { const res = await fetchUpstreamModels({ - provider, + provider: canonicalProvider, api_key: apiKey, api_base: apiBase, }) @@ -62,7 +72,7 @@ export function FetchModelsDialog({ } finally { setFetching(false) } - }, [provider, apiKey, apiBase, t]) + }, [canonicalProvider, apiKey, apiBase, t]) // Auto-fetch when dialog opens (skip if provider requires API key but none is set) useEffect(() => { @@ -122,7 +132,7 @@ export function FetchModelsDialog({ {t("models.fetch.description")} {provider && ( - {t("models.fetch.providerLabel")} {provider} + {t("models.fetch.providerLabel")} {canonicalProvider} {apiBase && ` | ${apiBase}`} )} diff --git a/web/frontend/src/components/models/model-provider-form-shared.ts b/web/frontend/src/components/models/model-provider-form-shared.ts new file mode 100644 index 00000000..103cbe3d --- /dev/null +++ b/web/frontend/src/components/models/model-provider-form-shared.ts @@ -0,0 +1,21 @@ +import type { ModelProviderOption } from "@/api/models" + +import { getProviderDefaultAPIBase } from "./provider-registry" + +export function normalizeApiBase(value: string): string { + return value.trim().replace(/\/+$/, "") +} + +export function getEffectiveAPIBase( + provider: string, + apiBase: string, + providerOptions?: ModelProviderOption[], +): string { + return normalizeApiBase( + apiBase || getProviderDefaultAPIBase(provider, providerOptions), + ) +} + +export function getSubmittedAPIBase(apiBase: string): string | undefined { + return normalizeApiBase(apiBase) || undefined +} diff --git a/web/frontend/src/components/models/model-validation.ts b/web/frontend/src/components/models/model-validation.ts index 10ac0d27..43d16bb1 100644 --- a/web/frontend/src/components/models/model-validation.ts +++ b/web/frontend/src/components/models/model-validation.ts @@ -5,10 +5,12 @@ * Messages use i18n keys with interpolation params — callers must * translate them via t(key, params). */ +import type { ModelProviderOption } from "@/api/models" + import { - KNOWN_PROVIDER_KEYS, - PROVIDER_ALIASES, findClosestProvider, + getCanonicalProviderKey, + getKnownProviderKeys, } from "./provider-registry" export type ValidationLevel = "error" | "warning" | "success" @@ -27,9 +29,11 @@ export interface FieldValidation { export function validateModelField( input: string, selectedProvider?: string, + backendOptions?: ModelProviderOption[], ): FieldValidation { const trimmed = input.trim() if (!trimmed) return { level: "success", messageKey: "" } + const knownProviderKeys = getKnownProviderKeys(backendOptions) // Hard errors if (/\s/.test(trimmed)) { @@ -78,10 +82,10 @@ export function validateModelField( return { level: "error", messageKey: "models.validation.emptyModel" } } - if (!KNOWN_PROVIDER_KEYS.has(provider)) { + if (!knownProviderKeys.has(provider)) { // Check aliases - const alias = PROVIDER_ALIASES[provider] - if (alias) { + const alias = getCanonicalProviderKey(provider, backendOptions) + if (alias && alias !== provider) { return { level: "warning", messageKey: "models.validation.shouldUse", @@ -90,7 +94,7 @@ export function validateModelField( } } // Typo check - const closest = findClosestProvider(provider) + const closest = findClosestProvider(provider, backendOptions) if (closest) { return { level: "warning", diff --git a/web/frontend/src/components/models/models-page.tsx b/web/frontend/src/components/models/models-page.tsx index 9c0c400d..ecad762b 100644 --- a/web/frontend/src/components/models/models-page.tsx +++ b/web/frontend/src/components/models/models-page.tsx @@ -23,13 +23,16 @@ import { AddModelSheet } from "./add-model-sheet" import { CatalogDialog } from "./catalog-dialog" import { DeleteModelDialog } from "./delete-model-dialog" import { EditModelSheet } from "./edit-model-sheet" -import { getProviderKey, getProviderLabel } from "./provider-label" -import { PROVIDER_PRIORITY } from "./provider-registry" +import { + getCanonicalProviderKey, + getProviderCatalogMap, +} from "./provider-registry" import { ProviderSection } from "./provider-section" +import type { ProviderCatalogEntry } from "./provider-registry" interface ProviderGroup { key: string - label: string + provider: Pick models: ModelInfo[] hasDefault: boolean availableCount: number @@ -51,8 +54,10 @@ export function ModelsPage() { const [settingDefaultIndex, setSettingDefaultIndex] = useState( null, ) + const providerMap = getProviderCatalogMap(providerOptions) const fetchModels = useCallback(async () => { + setLoading(true) try { const data = await getModels() const sorted = [...data.models].sort((a, b) => { @@ -97,12 +102,21 @@ export function ModelsPage() { } } - const grouped: Record = {} + const grouped: Record< + string, + { provider: Pick; models: ModelInfo[] } + > = {} for (const model of models) { - const providerKey = getProviderKey(model.provider) + const providerKey = getCanonicalProviderKey(model.provider, providerOptions) + const providerDef = providerKey ? providerMap.get(providerKey) : undefined if (!grouped[providerKey]) { grouped[providerKey] = { - label: getProviderLabel(model.provider), + provider: { + key: providerKey, + label: providerDef?.label || providerKey, + iconSlug: providerDef?.iconSlug, + domain: providerDef?.domain, + }, models: [], } } @@ -116,7 +130,7 @@ export function ModelsPage() { ).length return { key, - label: group.label, + provider: group.provider, models: group.models, hasDefault: group.models.some((model) => model.is_default), availableCount, @@ -130,13 +144,13 @@ export function ModelsPage() { return b.availableCount - a.availableCount } - const aPriority = PROVIDER_PRIORITY[a.key] ?? Number.MAX_SAFE_INTEGER - const bPriority = PROVIDER_PRIORITY[b.key] ?? Number.MAX_SAFE_INTEGER + const aPriority = -(providerMap.get(a.key)?.priority ?? 0) + const bPriority = -(providerMap.get(b.key)?.priority ?? 0) if (aPriority !== bPriority) { return aPriority - bPriority } - return a.label.localeCompare(b.label) + return a.provider.label.localeCompare(b.provider.label) }) const defaultModel = models.find((model) => model.is_default) @@ -149,11 +163,17 @@ export function ModelsPage() { size="sm" variant="outline" onClick={() => setCatalogOpen(true)} + disabled={providerOptions.length === 0} > {t("models.catalog.button")} - @@ -172,6 +192,11 @@ export function ModelsPage() {

{t("models.description")}

+ {!loading && providerOptions.length === 0 && ( +

+ {t("models.providerCatalogUnavailable")} +

+ )}
{loading && ( @@ -181,8 +206,19 @@ export function ModelsPage() { )} {fetchError && ( -
- {fetchError} +
+

{fetchError}

+
+ +
)} @@ -191,8 +227,7 @@ export function ModelsPage() { {providerGroups.map((providerGroup) => ( setCatalogOpen(false)} onModelAdded={fetchModels} + providerOptions={providerOptions} />
) diff --git a/web/frontend/src/components/models/provider-combobox.tsx b/web/frontend/src/components/models/provider-combobox.tsx index c023694c..d7f9c15d 100644 --- a/web/frontend/src/components/models/provider-combobox.tsx +++ b/web/frontend/src/components/models/provider-combobox.tsx @@ -11,7 +11,6 @@ import { CommandItem, CommandList, } from "@/components/ui/command" -import { Input } from "@/components/ui/input" import { Popover, PopoverContent, @@ -21,9 +20,9 @@ import { cn } from "@/lib/utils" import { ProviderIcon } from "./provider-icon" import { - type MergedProvider, - PROVIDERS, - mergeWithBackendOptions, + getCanonicalProviderKey, + type ProviderCatalogEntry, + getProviderCatalog, } from "./provider-registry" import type { ModelProviderOption } from "@/api/models" @@ -48,47 +47,23 @@ export function ProviderCombobox({ }: ProviderComboboxProps) { const { t } = useTranslation() const [open, setOpen] = useState(false) - const [customMode, setCustomMode] = useState(false) - const [customValue, setCustomValue] = useState("") const [containerEl, setContainerEl] = useState(null) useEffect(() => { setContainerEl(containerRef?.current ?? null) }, [containerRef]) - const allProviders: MergedProvider[] = backendOptions - ? mergeWithBackendOptions(backendOptions) - : [...PROVIDERS] - .sort((a, b) => b.priority - a.priority) - .map((p) => ({ - ...p, - createAllowed: true, - defaultModelAllowed: false, - })) + const canonicalValue = getCanonicalProviderKey(value, backendOptions) + const allProviders: ProviderCatalogEntry[] = getProviderCatalog(backendOptions) const visible = filterCreateAllowed - ? allProviders.filter((p) => p.createAllowed) + ? allProviders.filter((p) => p.createAllowed || p.key === canonicalValue) : allProviders const allKeys = new Set(allProviders.map((p) => p.key)) - const selected = allProviders.find((p) => p.key === value) - const isCustom = value && !allKeys.has(value) + const selected = allProviders.find((p) => p.key === canonicalValue) + const showUnknownValue = value && !allKeys.has(canonicalValue) const handleSelect = (currentValue: string) => { - if (currentValue === "__custom__") { - setCustomMode(true) - setCustomValue(isCustom ? value : "") - return - } - onChange(currentValue === value ? "" : currentValue) - setCustomMode(false) - setOpen(false) - } - - const handleCustomConfirm = () => { - const trimmed = customValue.trim() - if (trimmed) { - onChange(trimmed) - } - setCustomMode(false) + onChange(currentValue === canonicalValue ? "" : currentValue) setOpen(false) } @@ -97,7 +72,6 @@ export function ProviderCombobox({ open={open} onOpenChange={(isOpen: boolean) => { setOpen(isOpen) - if (!isOpen) setCustomMode(false) }} > @@ -110,12 +84,11 @@ export function ProviderCombobox({ {selected ? ( - {selected.labelZh || selected.label} + {selected.label} - ) : isCustom ? ( + ) : showUnknownValue ? ( {value} @@ -128,97 +101,52 @@ export function ProviderCombobox({ - {customMode ? ( -
- setCustomValue(e.target.value)} - placeholder={t("models.combobox.customPlaceholder")} - className="h-8 font-mono text-sm" - autoFocus - onKeyDown={(e) => { - if (e.key === "Enter") handleCustomConfirm() - if (e.key === "Escape") { - setCustomMode(false) - setOpen(false) - } - }} - /> -
- - -
-
- ) : ( - - - - {t("models.combobox.noProvider")} - - {visible.map((provider) => ( + + + + + {backendOptions && backendOptions.length > 0 + ? t("models.combobox.noProvider") + : t("models.combobox.noCatalog")} + + + {visible.map((provider) => { + const disabled = !provider.createAllowed && provider.key !== value + + return ( - {provider.labelZh || provider.label} + {provider.label} {provider.isLocal && ( - - {t("models.combobox.local")} - + + {t("models.combobox.local")} + )} - ))} - - - {t("models.combobox.custom")} - - {isCustom && ( - - )} - - - - - )} + ) + })} + + +
) diff --git a/web/frontend/src/components/models/provider-icon.tsx b/web/frontend/src/components/models/provider-icon.tsx index 6b7f7b6c..673b1da3 100644 --- a/web/frontend/src/components/models/provider-icon.tsx +++ b/web/frontend/src/components/models/provider-icon.tsx @@ -1,22 +1,18 @@ import { useMemo, useState } from "react" -import { PROVIDER_DOMAINS, PROVIDER_ICON_SLUGS } from "./provider-registry" +import type { ProviderCatalogEntry } from "./provider-registry" interface ProviderIconProps { - providerKey: string - providerLabel: string + provider: Pick } -export function ProviderIcon({ - providerKey, - providerLabel, -}: ProviderIconProps) { +export function ProviderIcon({ provider }: ProviderIconProps) { const [sourceIndex, setSourceIndex] = useState(0) const [loadFailed, setLoadFailed] = useState(false) - const initial = providerLabel.trim().charAt(0).toUpperCase() || "?" + const initial = provider.label.trim().charAt(0).toUpperCase() || "?" const iconUrls = useMemo(() => { - const slug = PROVIDER_ICON_SLUGS[providerKey] - const domain = PROVIDER_DOMAINS[providerKey] + const slug = provider.iconSlug + const domain = provider.domain const urls: string[] = [] if (slug) { urls.push(`https://cdn.simpleicons.org/${slug}`) @@ -25,7 +21,7 @@ export function ProviderIcon({ urls.push(`https://www.google.com/s2/favicons?domain=${domain}&sz=64`) } return urls - }, [providerKey]) + }, [provider.domain, provider.iconSlug]) const iconUrl = iconUrls[sourceIndex] @@ -41,7 +37,7 @@ export function ProviderIcon({ {`${providerLabel} [p.key, p])) +function buildAliasMap( + backendOptions?: ModelProviderOption[], +): Record { + const aliases: Record = {} + for (const option of backendOptions || []) { + const key = normalizeProvider(option.id) + if (!key) continue + aliases[key] = option.id + for (const alias of option.aliases || []) { + const normalized = normalizeProvider(alias) + if (normalized) { + aliases[normalized] = option.id + } + } + } + return aliases +} -export const PROVIDER_LABELS: Record = Object.fromEntries( - PROVIDERS.map((p) => [p.key, p.labelZh || p.label]), -) +export function getProviderAliasMap( + backendOptions?: ModelProviderOption[], +): Record { + return buildAliasMap(backendOptions) +} -export const PROVIDER_ALIASES: Record = Object.fromEntries( - PROVIDERS.flatMap((p) => (p.aliases || []).map((a) => [a, p.key])), -) +export function getCanonicalProviderKey( + provider?: string, + backendOptions?: ModelProviderOption[], +): string { + const normalized = normalizeProvider(provider) + if (!normalized) return "" + return getProviderAliasMap(backendOptions)[normalized] ?? normalized +} -export const KNOWN_PROVIDER_KEYS = new Set(PROVIDERS.map((p) => p.key)) +export function getKnownProviderKeys( + backendOptions?: ModelProviderOption[], +): Set { + return new Set(getProviderCatalog(backendOptions).map((p) => p.key)) +} -export const FETCHABLE_PROVIDER_KEYS = new Set( - PROVIDERS.filter((p) => p.supportsFetch).map((p) => p.key), -) +export function getProviderCatalog( + backendOptions?: ModelProviderOption[], +): ProviderCatalogEntry[] { + if (!backendOptions || backendOptions.length === 0) { + return [] + } -export const PROVIDER_ICON_SLUGS: Record = Object.fromEntries( - PROVIDERS.filter((p) => p.iconSlug).map((p) => [p.key, p.iconSlug!]), -) + return [...backendOptions] + .map(toCatalogEntry) + .sort((a, b) => b.priority - a.priority) +} -export const PROVIDER_DOMAINS: Record = Object.fromEntries( - PROVIDERS.filter((p) => p.domain).map((p) => [p.key, p.domain!]), -) +export function getProviderCatalogMap( + backendOptions?: ModelProviderOption[], +): Map { + return new Map(getProviderCatalog(backendOptions).map((p) => [p.key, p])) +} -export const PROVIDER_PRIORITY: Record = Object.fromEntries( - PROVIDERS.map((p) => [p.key, p.priority]), -) +export function getProviderCatalogEntry( + provider: string | undefined, + backendOptions?: ModelProviderOption[], +): ProviderCatalogEntry | undefined { + const key = getCanonicalProviderKey(provider, backendOptions) + if (!key) return undefined + return getProviderCatalogMap(backendOptions).get(key) +} -export const PROVIDER_API_BASES: Record = Object.fromEntries( - PROVIDERS.filter((p) => p.defaultApiBase).map((p) => [ - p.key, - p.defaultApiBase!, - ]), -) +export function getProviderDefaultAPIBase( + provider: string | undefined, + backendOptions?: ModelProviderOption[], +): string { + return getProviderCatalogEntry(provider, backendOptions)?.defaultApiBase ?? "" +} + +export function getProviderDefaultAuthMethod( + provider: string | undefined, + backendOptions?: ModelProviderOption[], +): string { + return getProviderCatalogEntry(provider, backendOptions)?.defaultAuthMethod ?? "" +} + +export function isProviderAuthMethodLocked( + provider: string | undefined, + backendOptions?: ModelProviderOption[], +): boolean { + return getProviderCatalogEntry(provider, backendOptions)?.authMethodLocked === true +} + +export function providerSupportsFetch( + provider: string | undefined, + backendOptions?: ModelProviderOption[], +): boolean { + const key = getCanonicalProviderKey(provider, backendOptions) + if (!key) return false + return getProviderCatalogMap(backendOptions).get(key)?.supportsFetch === true +} /** * Find the closest known provider key by edit distance. * Returns the key if distance <= 2, otherwise undefined. */ -export function findClosestProvider(input: string): string | undefined { +export function findClosestProvider( + input: string, + backendOptions?: ModelProviderOption[], +): string | undefined { const lower = input.toLowerCase() let best: string | undefined - let bestDist = 3 // only accept distance <= 2 + let bestDist = 3 - for (const key of KNOWN_PROVIDER_KEYS) { + for (const key of getKnownProviderKeys(backendOptions)) { const dist = editDistance(lower, key) if (dist < bestDist) { bestDist = dist best = key } } - // Also check aliases - for (const alias of Object.keys(PROVIDER_ALIASES)) { + + for (const alias of Object.keys(getProviderAliasMap(backendOptions))) { const dist = editDistance(lower, alias) if (dist < bestDist) { bestDist = dist - best = PROVIDER_ALIASES[alias] + best = getProviderAliasMap(backendOptions)[alias] } } return best @@ -477,55 +193,3 @@ function editDistance(a: string, b: string): number { } return dp[m][n] } - -// ── Backend options merge ──────────────────────────────────────────────────── - -export interface MergedProvider extends ProviderDefinition { - createAllowed: boolean - defaultModelAllowed: boolean - defaultAuthMethod?: string - authMethodLocked?: boolean -} - -/** - * Merge the frontend PROVIDERS registry with backend provider_options. - * Frontend provides presentation data (labels, icons, priority, etc.). - * Backend provides authoritative availability and policy fields. - */ -export function mergeWithBackendOptions( - backendOptions: ModelProviderOption[], -): MergedProvider[] { - const backendMap = new Map(backendOptions.map((o) => [o.id, o])) - const merged: MergedProvider[] = [] - - // Start with frontend providers, enriched with backend policy - for (const p of PROVIDERS) { - const backend = backendMap.get(p.key) - merged.push({ - ...p, - createAllowed: backend?.create_allowed ?? false, - defaultModelAllowed: backend?.default_model_allowed ?? false, - defaultAuthMethod: backend?.default_auth_method, - authMethodLocked: backend?.auth_method_locked, - }) - if (backend) backendMap.delete(p.key) - } - - // Add providers only known to the backend - for (const [key, backend] of backendMap) { - merged.push({ - key, - label: key, - requiresApiKey: !backend.empty_api_key_allowed, - isLocal: backend.empty_api_key_allowed, - priority: 0, - createAllowed: backend.create_allowed, - defaultModelAllowed: backend.default_model_allowed, - defaultAuthMethod: backend.default_auth_method, - authMethodLocked: backend.auth_method_locked, - defaultApiBase: backend.default_api_base || undefined, - }) - } - - return merged.sort((a, b) => b.priority - a.priority) -} diff --git a/web/frontend/src/components/models/provider-section.tsx b/web/frontend/src/components/models/provider-section.tsx index a1451926..495c13fb 100644 --- a/web/frontend/src/components/models/provider-section.tsx +++ b/web/frontend/src/components/models/provider-section.tsx @@ -5,10 +5,10 @@ import type { ModelInfo } from "@/api/models" import { ModelCard } from "./model-card" import { ProviderIcon } from "./provider-icon" +import type { ProviderCatalogEntry } from "./provider-registry" interface ProviderSectionProps { - provider: string - providerKey: string + provider: Pick models: ModelInfo[] onEdit: (model: ModelInfo) => void onSetDefault: (model: ModelInfo) => void @@ -18,7 +18,6 @@ interface ProviderSectionProps { export function ProviderSection({ provider, - providerKey, models, onEdit, onSetDefault, @@ -38,8 +37,8 @@ export function ProviderSection({
- - {provider} + + {provider.label}
diff --git a/web/frontend/src/i18n/locales/en.json b/web/frontend/src/i18n/locales/en.json index ab60a814..f341dcac 100644 --- a/web/frontend/src/i18n/locales/en.json +++ b/web/frontend/src/i18n/locales/en.json @@ -232,6 +232,8 @@ "unsavedPrompt": "This change has not been saved yet. Save to write it into the model configuration.", "restartHint": "Model configuration changes take effect after the gateway restarts.", "loadError": "Failed to load models", + "retry": "Retry", + "providerCatalogUnavailable": "The backend provider catalog is unavailable. New provider selections are disabled until the Models API loads successfully.", "noDefaultHintPrefix": "No default model set yet. Click", "noDefaultHintSuffix": "to set one.", "status": { @@ -251,7 +253,8 @@ "setting": "Setting as default...", "unavailable": "Cannot set unavailable model as default", "isDefault": "Already the default model", - "isVirtual": "Cannot set virtual model as default" + "isVirtual": "Cannot set virtual model as default", + "unsupportedProvider": "This provider cannot be used as the default chat model." }, "deleteDisabled": { "isDefault": "Cannot delete the default model" @@ -288,8 +291,9 @@ }, "field": { "provider": "Provider", - "providerPlaceholder": "e.g. openai", - "providerHint": "Optional. If specified, this value is used as the effective provider, and Model Identifier is interpreted as the canonical model ID.", + "providerPlaceholder": "Select a provider", + "providerHint": "Choose a provider from the backend catalog; Model Identifier will be interpreted as that provider's canonical model ID.", + "providerInvalid": "The current provider is invalid. Please choose a supported provider.", "selectProviderFirst": "Select a provider first", "apiBase": "API Base URL", "apiKey": "API Key", @@ -299,6 +303,7 @@ "proxyHint": "Optional. e.g. http://127.0.0.1:7890", "authMethod": "Auth Method", "authMethodHint": "Authentication method: oauth, token. Leave blank for API key auth.", + "authMethodManagedHint": "This provider's auth method is managed by the system.", "connectMode": "Connect Mode", "connectModeHint": "Connection mode for CLI-based providers: stdio or grpc.", "workspace": "Workspace Path", @@ -395,9 +400,8 @@ "selectProvider": "Select provider...", "searchProvider": "Search provider...", "noProvider": "No provider found.", - "local": "local", - "custom": "Custom provider...", - "customPlaceholder": "Enter provider name..." + "noCatalog": "Provider catalog unavailable.", + "local": "local" } }, "channels": { diff --git a/web/frontend/src/i18n/locales/pt-br.json b/web/frontend/src/i18n/locales/pt-br.json index 4af5218f..1cc9db73 100644 --- a/web/frontend/src/i18n/locales/pt-br.json +++ b/web/frontend/src/i18n/locales/pt-br.json @@ -142,10 +142,12 @@ }, "common": { "cancel": "Cancelar", + "close": "Fechar", "save": "Salvar", "saving": "Salvando...", "reset": "Redefinir", "confirm": "Confirmar", + "fix": "Corrigir", "saveChangesTitle": "Você tem alterações de configuração não salvas", "restartRequiredTitle": "Reinício do gateway necessário", "restartRequiredDesc": "A configuração mais recente de {{name}} foi salva. Reinicie o gateway para que tenha efeito." @@ -230,6 +232,8 @@ "unsavedPrompt": "Esta alteração ainda não foi salva. Salve para gravá-la na configuração do modelo.", "restartHint": "Alterações na configuração de modelos só têm efeito após o gateway reiniciar.", "loadError": "Falha ao carregar modelos", + "retry": "Tentar novamente", + "providerCatalogUnavailable": "O catálogo de providers do backend está indisponível. A seleção de novos providers fica desabilitada até a API de Modelos carregar com sucesso.", "noDefaultHintPrefix": "Nenhum modelo padrão definido ainda. Clique em", "noDefaultHintSuffix": "para definir um.", "status": { @@ -286,8 +290,10 @@ }, "field": { "provider": "Provider", - "providerPlaceholder": "ex: openai", - "providerHint": "Opcional. Se especificado, este valor é usado como o provider efetivo, e Identificador do Modelo é interpretado como o ID canônico do modelo.", + "providerPlaceholder": "Selecione um provider", + "providerHint": "Escolha um provider do catálogo do backend; o Identificador do Modelo será interpretado como o ID canônico desse provider.", + "providerInvalid": "O provider atual é inválido. Selecione um provider suportado.", + "selectProviderFirst": "Selecione um provider primeiro", "apiBase": "URL Base da API", "apiKey": "API Key", "apiKeyPlaceholder": "Digite sua API Key", @@ -296,6 +302,7 @@ "proxyHint": "Opcional. ex: http://127.0.0.1:7890", "authMethod": "Método de Autenticação", "authMethodHint": "Método de autenticação: oauth, token. Deixe em branco para autenticação por API Key.", + "authMethodManagedHint": "O método de autenticação deste provider é gerenciado pelo sistema.", "connectMode": "Modo de Conexão", "connectModeHint": "Modo de conexão para providers baseados em CLI: stdio ou grpc.", "workspace": "Caminho do Workspace", @@ -308,14 +315,15 @@ "thinkingLevelHint": "Orçamento de pensamento estendido: off, low, medium, high, xhigh, adaptive.", "maxTokensField": "Campo de Max Tokens", "maxTokensFieldHint": "Sobrescreve o nome do campo de max tokens na requisição, ex: max_completion_tokens.", - "toolSchemaTransform": "Transformação de Schema de Tool", - "toolSchemaTransformHint": "Transformação opcional de compatibilidade para schemas JSON de tools. Deixe em branco para comportamento nativo. Valores suportados: simple.", + "toolSchemaTransform": "Transformação de Schema de Ferramentas", + "toolSchemaTransformHint": "Transformação opcional de compatibilidade para schemas JSON de ferramentas. Deixe em branco para o comportamento nativo. Valor suportado: simple.", "streamingEnabled": "Saída Streaming", "streamingEnabledHint": "Permite que esta entrada de modelo tente requisições de provider streaming. O switch de streaming do canal atual também precisa estar habilitado.", "extraBody": "Body Extra", "extraBodyHint": "Campos JSON adicionais para injetar no body da requisição, ex: {\"reasoning_split\": true}.", "customHeaders": "Headers Customizados", - "customHeadersHint": "Headers HTTP adicionais para injetar em cada requisição, ex: {\"X-Source\": \"coding-plan\"}." + "customHeadersHint": "Headers HTTP adicionais para injetar em cada requisição, ex: {\"X-Source\": \"coding-plan\"}.", + "invalidJson": "Formato JSON inválido" }, "edit": { "title": "Configurar {{name}}", @@ -323,6 +331,76 @@ "oauthNote": "Este provider usa OAuth — não é necessária API Key.", "saveError": "Falha ao salvar", "saveSuccess": "Configuração do modelo salva." + }, + "fetch": { + "title": "Buscar Modelos Disponíveis", + "description": "Busque a lista de modelos do provider upstream.", + "providerLabel": "Provider:", + "needApiKey": "Digite primeiro uma API Key para buscar modelos.", + "fetching": "Buscando modelos...", + "retry": "Tentar novamente", + "filterPlaceholder": "Filtrar modelos...", + "found": "Encontrado {{count}} modelo", + "found_plural": "Encontrados {{count}} modelos", + "shown": "({{count}} exibidos)", + "selectAll": "Selecionar todos", + "deselectAll": "Desmarcar todos", + "fill": "Preencher {{count}} Modelo Selecionado", + "fill_plural": "Preencher {{count}} Modelos Selecionados", + "failed": "Falha ao buscar modelos" + }, + "catalog": { + "button": "Catálogos Salvos", + "title": "Catálogos de Modelos Salvos", + "description": "Listas de modelos buscadas anteriormente, armazenadas por API key. Selecione modelos para adicionar à sua configuração.", + "loading": "Carregando catálogos...", + "empty": "Ainda não há catálogos salvos. Busque modelos de um provider para salvar um catálogo.", + "filterPlaceholder": "Filtrar modelos...", + "models": "modelos", + "fetchedAt": "Obtido em", + "delete": "Excluir catálogo", + "refresh": "Atualizar do upstream", + "found": "Encontrado {{count}} modelo", + "found_plural": "Encontrados {{count}} modelos", + "selectAll": "Selecionar todos", + "deselectAll": "Desmarcar todos", + "addSelected": "Adicionar {{count}} Selecionados", + "addSuccess": "{{count}} modelo(s) adicionados à configuração.", + "needApiKey": "Esses modelos exigem uma API key. Será necessário configurar as credenciais após a importação." + }, + "test": { + "title": "Testar Conectividade do Modelo", + "description": "Verifique se o endpoint do modelo está acessível e configurado corretamente.", + "modelLabel": "Modelo:", + "identifierLabel": "Identificador:", + "endpointLabel": "Endpoint:", + "testConnection": "Testar Conexão", + "testing": "Testando conexão...", + "success": "Conexão bem-sucedida", + "responseTime": "Tempo de resposta: {{ms}}ms", + "failed": "Falha na conexão", + "status": "Status: {{status}}", + "testFailed": "Falha no teste", + "testAgain": "Testar novamente" + }, + "validation": { + "whitespace": "O identificador do modelo não pode conter espaços", + "leadingSlash": "Não deve começar com /", + "consecutiveSlash": "Não deve conter // consecutivos", + "useProvider": "Usará \"{{provider}}\" como provider", + "defaultToOpenAI": "Nenhum provider especificado, o padrão será OpenAI", + "emptyModel": "O nome do modelo não pode estar vazio", + "shouldUse": "\"{{provider}}\" deve usar \"{{alias}}\"", + "didYouMean": "Você quis dizer \"{{closest}}\"?", + "unknownProvider": "Provider desconhecido \"{{provider}}\"", + "parsed": "provider={{provider}}, model={{model}}" + }, + "combobox": { + "selectProvider": "Selecionar provider...", + "searchProvider": "Buscar provider...", + "noProvider": "Nenhum provider encontrado.", + "noCatalog": "Catálogo de providers indisponível.", + "local": "local" } }, "channels": { diff --git a/web/frontend/src/i18n/locales/zh.json b/web/frontend/src/i18n/locales/zh.json index 431dc992..b8d3c236 100644 --- a/web/frontend/src/i18n/locales/zh.json +++ b/web/frontend/src/i18n/locales/zh.json @@ -232,6 +232,8 @@ "unsavedPrompt": "当前修改尚未保存,保存后才会写入模型配置。", "restartHint": "模型配置保存后需要重启服务才能生效。", "loadError": "加载模型列表失败", + "retry": "重试", + "providerCatalogUnavailable": "后端 Provider catalog 暂不可用,待模型 API 成功加载后才能选择新的 Provider。", "noDefaultHintPrefix": "尚未设置默认模型,点击", "noDefaultHintSuffix": "设为默认。", "status": { @@ -396,6 +398,7 @@ "selectProvider": "选择服务商...", "searchProvider": "搜索服务商...", "noProvider": "未找到服务商。", + "noCatalog": "Provider catalog 暂不可用。", "local": "本地", "custom": "自定义服务商...", "customPlaceholder": "输入服务商名称..." From b7db0595441d1f725cd4ace994a9936e08557337 Mon Sep 17 00:00:00 2001 From: LC Date: Wed, 20 May 2026 13:42:21 +0800 Subject: [PATCH 06/20] feat(chat,seahorse): persist and display model_name across history (#2897) * feat(chat,seahorse): persist and display model_name across history * test(seahorse): fix lint regressions in repair coverage * fix(pico): preserve model_name in live updates * fix(pico): preserve model_name through live stream wrappers --- pkg/agent/agent.go | 6 + pkg/agent/agent_outbound.go | 43 +++- pkg/agent/agent_test.go | 2 +- pkg/agent/agent_utils.go | 52 ++++- pkg/agent/context_seahorse.go | 2 + pkg/agent/context_seahorse_test.go | 8 + pkg/agent/model_resolution.go | 18 +- pkg/agent/model_resolution_test.go | 49 +++++ pkg/agent/pipeline_execute.go | 12 +- pkg/agent/pipeline_finalize.go | 25 +-- pkg/agent/pipeline_llm.go | 14 +- pkg/agent/pipeline_setup.go | 6 + pkg/agent/pipeline_streaming.go | 17 +- pkg/agent/pipeline_streaming_test.go | 3 + pkg/agent/turn_coord_test.go | 180 +++++++++++++++ pkg/agent/turn_state.go | 2 + pkg/channels/interfaces.go | 11 + pkg/channels/manager.go | 65 +++++- pkg/channels/manager_test.go | 102 +++++++++ pkg/channels/pico/pico.go | 100 ++++++++- pkg/channels/pico/pico_test.go | 94 +++++++- pkg/channels/pico/protocol.go | 1 + pkg/memory/jsonl_test.go | 26 +++ pkg/providers/fallback.go | 17 +- pkg/providers/protocoltypes/types.go | 1 + pkg/seahorse/schema.go | 19 ++ pkg/seahorse/schema_test.go | 31 +++ pkg/seahorse/short_engine.go | 117 ++++++++-- pkg/seahorse/short_engine_test.go | 206 +++++++++++++++--- pkg/seahorse/store.go | 67 ++++-- pkg/seahorse/store_test.go | 14 ++ pkg/seahorse/types.go | 2 + pkg/session/jsonl_backend_test.go | 19 ++ web/backend/api/session.go | 22 +- web/backend/api/session_test.go | 19 +- web/frontend/src/api/sessions.ts | 1 + .../src/components/chat/assistant-message.tsx | 14 +- .../src/components/chat/chat-page.tsx | 1 + web/frontend/src/features/chat/history.ts | 3 +- web/frontend/src/features/chat/protocol.ts | 13 ++ web/frontend/src/store/chat.ts | 1 + 41 files changed, 1266 insertions(+), 139 deletions(-) diff --git a/pkg/agent/agent.go b/pkg/agent/agent.go index 49cd5e76..0392829f 100644 --- a/pkg/agent/agent.go +++ b/pkg/agent/agent.go @@ -600,6 +600,12 @@ func (al *AgentLoop) runAgentLoop( Content: result.finalContent, ContextUsage: computeContextUsage(agent, opts.Dispatch.SessionKey), } + if modelName := strings.TrimSpace(result.modelName); modelName != "" { + if msg.Context.Raw == nil { + msg.Context.Raw = make(map[string]string, 1) + } + msg.Context.Raw["model_name"] = modelName + } markFinalOutbound(&msg) al.bus.PublishOutbound(ctx, msg) } diff --git a/pkg/agent/agent_outbound.go b/pkg/agent/agent_outbound.go index cae6dcb4..f5de6fa4 100644 --- a/pkg/agent/agent_outbound.go +++ b/pkg/agent/agent_outbound.go @@ -102,7 +102,10 @@ func (al *AgentLoop) targetReasoningChannelID(channelName string) (chatID string return "" } -func (al *AgentLoop) publishPicoReasoning(ctx context.Context, reasoningContent, chatID, sessionKey string) { +func (al *AgentLoop) publishPicoReasoning( + ctx context.Context, + reasoningContent, chatID, sessionKey, modelName string, +) { if reasoningContent == "" || chatID == "" { return } @@ -114,13 +117,16 @@ func (al *AgentLoop) publishPicoReasoning(ctx context.Context, reasoningContent, pubCtx, pubCancel := context.WithTimeout(ctx, 5*time.Second) defer pubCancel() + raw := map[string]string{metadataKeyMessageKind: messageKindThought} + if trimmedModelName := strings.TrimSpace(modelName); trimmedModelName != "" { + raw["model_name"] = trimmedModelName + } + if err := al.bus.PublishOutbound(pubCtx, bus.OutboundMessage{ Context: bus.InboundContext{ Channel: "pico", ChatID: chatID, - Raw: map[string]string{ - metadataKeyMessageKind: messageKindThought, - }, + Raw: raw, }, SessionKey: sessionKey, Content: reasoningContent, @@ -143,6 +149,7 @@ func (al *AgentLoop) publishPicoReasoning(ctx context.Context, reasoningContent, func (al *AgentLoop) publishPicoToolCallInterim( ctx context.Context, ts *turnState, + modelName string, reasoningContent string, content string, toolCalls []providers.ToolCall, @@ -155,7 +162,14 @@ func (al *AgentLoop) publishPicoToolCallInterim( pubCtx, pubCancel := context.WithTimeout(ctx, 3*time.Second) err := al.bus.PublishOutbound( pubCtx, - outboundMessageForTurnWithKind(ts, reasoningContent, messageKindThought), + outboundMessageForTurnWithOptions( + ts, + reasoningContent, + outboundTurnMessageOptions{ + kind: messageKindThought, + modelName: modelName, + }, + ), ) pubCancel() if err != nil && !errors.Is(err, context.DeadlineExceeded) && @@ -182,7 +196,12 @@ func (al *AgentLoop) publishPicoToolCallInterim( if strings.TrimSpace(content) != "" && !duplicateToolCallContent { pubCtx, pubCancel := context.WithTimeout(ctx, 3*time.Second) - err := al.bus.PublishOutbound(pubCtx, outboundMessageForTurn(ts, content)) + err := al.bus.PublishOutbound( + pubCtx, + outboundMessageForTurnWithOptions(ts, content, outboundTurnMessageOptions{ + modelName: modelName, + }), + ) pubCancel() if err != nil && !errors.Is(err, context.DeadlineExceeded) && !errors.Is(err, context.Canceled) && @@ -209,11 +228,13 @@ func (al *AgentLoop) publishPicoToolCallInterim( return } - msg := outboundMessageForTurnWithKind(ts, "", messageKindToolCalls) - if msg.Context.Raw == nil { - msg.Context.Raw = map[string]string{} - } - msg.Context.Raw[metadataKeyToolCalls] = string(rawToolCalls) + msg := outboundMessageForTurnWithOptions(ts, "", outboundTurnMessageOptions{ + kind: messageKindToolCalls, + modelName: modelName, + raw: map[string]string{ + metadataKeyToolCalls: string(rawToolCalls), + }, + }) pubCtx, pubCancel := context.WithTimeout(ctx, 3*time.Second) err = al.bus.PublishOutbound(pubCtx, msg) diff --git a/pkg/agent/agent_test.go b/pkg/agent/agent_test.go index bfdb0d2a..aaf3d1a8 100644 --- a/pkg/agent/agent_test.go +++ b/pkg/agent/agent_test.go @@ -312,7 +312,7 @@ func TestPublishPicoReasoningIncludesSessionKey(t *testing.T) { defer cleanup() _ = provider - al.publishPicoReasoning(context.Background(), "reasoning", "pico-chat", "session-1") + al.publishPicoReasoning(context.Background(), "reasoning", "pico-chat", "session-1", "") select { case outbound := <-msgBus.OutboundChan(): diff --git a/pkg/agent/agent_utils.go b/pkg/agent/agent_utils.go index b155740a..432a9f24 100644 --- a/pkg/agent/agent_utils.go +++ b/pkg/agent/agent_utils.go @@ -96,15 +96,46 @@ func markFinalOutbound(msg *bus.OutboundMessage) { msg.Context.Raw[metadataKeyOutboundKind] = outboundKindFinal } -func outboundMessageForTurnWithKind(ts *turnState, content, kind string) bus.OutboundMessage { +type outboundTurnMessageOptions struct { + kind string + modelName string + raw map[string]string +} + +func outboundMessageForTurnWithOptions( + ts *turnState, + content string, + opts outboundTurnMessageOptions, +) bus.OutboundMessage { msg := outboundMessageForTurn(ts, content) - if strings.TrimSpace(kind) == "" { + trimmedKind := strings.TrimSpace(opts.kind) + trimmedModelName := strings.TrimSpace(opts.modelName) + rawCount := len(opts.raw) + if trimmedKind != "" { + rawCount++ + } + if trimmedModelName != "" { + rawCount++ + } + if rawCount == 0 { return msg } + if msg.Context.Raw == nil { - msg.Context.Raw = make(map[string]string, 1) + msg.Context.Raw = make(map[string]string, rawCount) + } + if trimmedKind != "" { + msg.Context.Raw[metadataKeyMessageKind] = trimmedKind + } + if trimmedModelName != "" { + msg.Context.Raw["model_name"] = trimmedModelName + } + for key, value := range opts.raw { + if strings.TrimSpace(key) == "" { + continue + } + msg.Context.Raw[key] = value } - msg.Context.Raw[metadataKeyMessageKind] = kind return msg } @@ -521,8 +552,9 @@ func hasMediaRefs(messages []providers.Message) bool { func sideQuestionModelName(agent *AgentInstance, usedLight bool) string { if usedLight && len(agent.LightCandidates) > 0 { - // Use the first light candidate's model - return agent.LightCandidates[0].Model + if name := resolvedCandidateModelName(agent.LightCandidates, ""); name != "" { + return name + } } return agent.Model } @@ -538,6 +570,14 @@ func modelNameFromIdentityKey(identityKey string) string { return identityKey } +func modelAliasFromCandidateIdentityKey(identityKey string) string { + const prefix = "model_name:" + if !strings.HasPrefix(identityKey, prefix) { + return "" + } + return strings.TrimSpace(strings.TrimPrefix(identityKey, prefix)) +} + func closeProviderIfStateful(provider providers.LLMProvider) { if stateful, ok := provider.(providers.StatefulProvider); ok { stateful.Close() diff --git a/pkg/agent/context_seahorse.go b/pkg/agent/context_seahorse.go index c6e5b30a..2a10d245 100644 --- a/pkg/agent/context_seahorse.go +++ b/pkg/agent/context_seahorse.go @@ -197,6 +197,7 @@ func providerToSeahorseMessage(msg protocoltypes.Message) seahorse.Message { result := seahorse.Message{ Role: msg.Role, Content: msg.Content, + ModelName: msg.ModelName, ReasoningContent: msg.ReasoningContent, TokenCount: tokenizer.EstimateMessageTokens(msg), } @@ -243,6 +244,7 @@ func seahorseToProviderMessages(result *seahorse.AssembleResult) []protocoltypes pm := protocoltypes.Message{ Role: msg.Role, Content: msg.Content, + ModelName: msg.ModelName, ReasoningContent: msg.ReasoningContent, } diff --git a/pkg/agent/context_seahorse_test.go b/pkg/agent/context_seahorse_test.go index e405ef94..2a9de326 100644 --- a/pkg/agent/context_seahorse_test.go +++ b/pkg/agent/context_seahorse_test.go @@ -174,6 +174,7 @@ func TestProviderToSeahorseMessageWithReasoning(t *testing.T) { msg := protocoltypes.Message{ Role: "assistant", Content: "response text", + ModelName: "gpt-5.4-mini", ReasoningContent: "I thought about this carefully", } @@ -181,6 +182,9 @@ func TestProviderToSeahorseMessageWithReasoning(t *testing.T) { if result.ReasoningContent != "I thought about this carefully" { t.Errorf("ReasoningContent = %q, want 'I thought about this carefully'", result.ReasoningContent) } + if result.ModelName != "gpt-5.4-mini" { + t.Errorf("ModelName = %q, want %q", result.ModelName, "gpt-5.4-mini") + } } func TestSeahorseToProviderMessagesWithReasoning(t *testing.T) { @@ -189,6 +193,7 @@ func TestSeahorseToProviderMessagesWithReasoning(t *testing.T) { { Role: "assistant", Content: "response", + ModelName: "gpt-5.4", ReasoningContent: "thinking process", }, }, @@ -201,6 +206,9 @@ func TestSeahorseToProviderMessagesWithReasoning(t *testing.T) { if messages[0].ReasoningContent != "thinking process" { t.Errorf("ReasoningContent = %q, want 'thinking process'", messages[0].ReasoningContent) } + if messages[0].ModelName != "gpt-5.4" { + t.Errorf("ModelName = %q, want %q", messages[0].ModelName, "gpt-5.4") + } } func TestSeahorseToProviderMessages(t *testing.T) { diff --git a/pkg/agent/model_resolution.go b/pkg/agent/model_resolution.go index 724278e6..487a3072 100644 --- a/pkg/agent/model_resolution.go +++ b/pkg/agent/model_resolution.go @@ -75,6 +75,7 @@ func candidateFromModelConfig( return providers.FallbackCandidate{ Provider: protocol, Model: modelID, + DisplayName: strings.TrimSpace(mc.ModelName), RPM: mc.RPM, IdentityKey: modelConfigIdentityKey(mc), }, true @@ -147,8 +148,9 @@ func resolveModelCandidate( } return providers.FallbackCandidate{ - Provider: ref.Provider, - Model: ref.Model, + Provider: ref.Provider, + Model: ref.Model, + DisplayName: raw, }, true } @@ -197,6 +199,18 @@ func resolvedCandidateProvider(candidates []providers.FallbackCandidate, fallbac return fallback } +func resolvedCandidateModelName(candidates []providers.FallbackCandidate, fallback string) string { + if len(candidates) > 0 { + if name := modelAliasFromCandidateIdentityKey(candidates[0].IdentityKey); strings.TrimSpace(name) != "" { + return name + } + if displayName := strings.TrimSpace(candidates[0].DisplayName); displayName != "" { + return displayName + } + } + return strings.TrimSpace(fallback) +} + func resolvedModelConfig(cfg *config.Config, modelName, workspace string) (*config.ModelConfig, error) { if cfg == nil { return nil, fmt.Errorf("config is nil") diff --git a/pkg/agent/model_resolution_test.go b/pkg/agent/model_resolution_test.go index 270242ea..ea23e5da 100644 --- a/pkg/agent/model_resolution_test.go +++ b/pkg/agent/model_resolution_test.go @@ -7,6 +7,55 @@ import ( "github.com/sipeed/picoclaw/pkg/providers" ) +func TestModelNameFromIdentityKey_LegacyProviderModel(t *testing.T) { + if got := modelNameFromIdentityKey("openai/gpt-5.4"); got != "gpt-5.4" { + t.Fatalf("modelNameFromIdentityKey() = %q, want %q", got, "gpt-5.4") + } +} + +func TestModelNameFromIdentityKey_PreservesNonLegacyIdentity(t *testing.T) { + if got := modelNameFromIdentityKey("model_name:primary"); got != "model_name:primary" { + t.Fatalf("modelNameFromIdentityKey() = %q, want %q", got, "model_name:primary") + } +} + +func TestModelAliasFromCandidateIdentityKey(t *testing.T) { + if got := modelAliasFromCandidateIdentityKey("model_name:primary"); got != "primary" { + t.Fatalf("modelAliasFromCandidateIdentityKey() = %q, want %q", got, "primary") + } + if got := modelAliasFromCandidateIdentityKey("openai/gpt-5.4"); got != "" { + t.Fatalf("modelAliasFromCandidateIdentityKey() = %q, want empty", got) + } +} + +func TestResolvedCandidateModelName_PrefersIdentityAlias(t *testing.T) { + got := resolvedCandidateModelName([]providers.FallbackCandidate{ + {Provider: "openai", Model: "gpt-5.4", IdentityKey: "model_name:primary"}, + }, "fallback-model") + if got != "primary" { + t.Fatalf("resolvedCandidateModelName() = %q, want %q", got, "primary") + } +} + +func TestResolvedCandidateModelName_DoesNotScanFallbackAliases(t *testing.T) { + got := resolvedCandidateModelName([]providers.FallbackCandidate{ + {Provider: "openai", Model: "gpt-5.4"}, + {Provider: "openai", Model: "gpt-5.4-mini", IdentityKey: "model_name:fallback"}, + }, "primary-model") + if got != "primary-model" { + t.Fatalf("resolvedCandidateModelName() = %q, want %q", got, "primary-model") + } +} + +func TestResolvedCandidateModelName_UsesCandidateDisplayName(t *testing.T) { + got := resolvedCandidateModelName([]providers.FallbackCandidate{ + {Provider: "openai", Model: "gpt-5.4", DisplayName: "gpt-5.4-display"}, + }, "fallback-model") + if got != "gpt-5.4-display" { + t.Fatalf("resolvedCandidateModelName() = %q, want %q", got, "gpt-5.4-display") + } +} + func TestResolveActiveModelConfig_PrefersCandidateIdentityKey(t *testing.T) { cfg := &config.Config{ ModelList: []*config.ModelConfig{ diff --git a/pkg/agent/pipeline_execute.go b/pkg/agent/pipeline_execute.go index 567e56d1..7281a0ea 100644 --- a/pkg/agent/pipeline_execute.go +++ b/pkg/agent/pipeline_execute.go @@ -180,7 +180,11 @@ toolLoop: toolFeedbackArgsPreview(toolArgs, toolFeedbackMaxLen), ) fbCtx, fbCancel := context.WithTimeout(turnCtx, 3*time.Second) - _ = al.bus.PublishOutbound(fbCtx, outboundMessageForTurnWithKind(ts, feedbackMsg, messageKindToolFeedback)) + _ = al.bus.PublishOutbound(fbCtx, outboundMessageForTurnWithOptions( + ts, + feedbackMsg, + outboundTurnMessageOptions{kind: messageKindToolFeedback}, + )) fbCancel() } @@ -467,7 +471,11 @@ toolLoop: toolFeedbackArgsPreview(toolArgs, toolFeedbackMaxLen), ) fbCtx, fbCancel := context.WithTimeout(turnCtx, 3*time.Second) - _ = al.bus.PublishOutbound(fbCtx, outboundMessageForTurnWithKind(ts, feedbackMsg, messageKindToolFeedback)) + _ = al.bus.PublishOutbound(fbCtx, outboundMessageForTurnWithOptions( + ts, + feedbackMsg, + outboundTurnMessageOptions{kind: messageKindToolFeedback}, + )) fbCancel() } diff --git a/pkg/agent/pipeline_finalize.go b/pkg/agent/pipeline_finalize.go index d15e312e..1b674d0a 100644 --- a/pkg/agent/pipeline_finalize.go +++ b/pkg/agent/pipeline_finalize.go @@ -33,6 +33,7 @@ func (p *Pipeline) Finalize( ts.setPhase(TurnPhaseCompleted) return turnResult{ finalContent: finalContent, + modelName: exec.llmModelName, status: turnStatus, followUps: append([]bus.InboundMessage(nil), ts.followUps...), }, nil @@ -44,6 +45,7 @@ func (p *Pipeline) Finalize( finalMsg := providers.Message{ Role: "assistant", Content: finalContent, + ModelName: exec.llmModelName, ReasoningContent: responseReasoningContent(exec.response), } ts.agent.Sessions.AddFullMessage(ts.sessionKey, finalMsg) @@ -80,24 +82,10 @@ func (p *Pipeline) Finalize( // so the final answer is still delivered outside normal SendResponse. if ((streamErr != nil && !isConfiguredStreamingVisibleError(streamErr)) || exec.streamingFallback) && !ts.opts.SendResponse && ts.opts.AllowInterimPicoPublish && finalContent != "" { - agentID, sessionKey, scope := outboundTurnMetadata( - ts.agent.ID, - ts.opts.Dispatch.SessionKey, - ts.opts.Dispatch.SessionScope, - ) - msg := bus.OutboundMessage{ - Context: outboundContextFromInbound( - ts.opts.Dispatch.InboundContext, - ts.opts.Dispatch.Channel(), - ts.opts.Dispatch.ChatID(), - ts.opts.Dispatch.ReplyToMessageID(), - ), - AgentID: agentID, - SessionKey: sessionKey, - Scope: scope, - Content: finalContent, - ContextUsage: contextUsage, - } + msg := outboundMessageForTurnWithOptions(ts, finalContent, outboundTurnMessageOptions{ + modelName: exec.llmModelName, + }) + msg.ContextUsage = contextUsage markFinalOutbound(&msg) _ = al.bus.PublishOutbound(turnCtx, msg) } @@ -112,6 +100,7 @@ func (p *Pipeline) Finalize( ts.setPhase(TurnPhaseCompleted) return turnResult{ finalContent: finalContent, + modelName: exec.llmModelName, status: turnStatus, followUps: append([]bus.InboundMessage(nil), ts.followUps...), }, nil diff --git a/pkg/agent/pipeline_llm.go b/pkg/agent/pipeline_llm.go index 2410b1b5..aaae765e 100644 --- a/pkg/agent/pipeline_llm.go +++ b/pkg/agent/pipeline_llm.go @@ -200,6 +200,16 @@ func (p *Pipeline) CallLLM( map[string]any{"agent_id": ts.agent.ID, "iteration": iteration}, ) } + for _, candidate := range exec.activeCandidates { + if candidate.StableKey() != fbResult.IdentityKey { + continue + } + exec.llmModelName = resolvedCandidateModelName( + []providers.FallbackCandidate{candidate}, + exec.llmModelName, + ) + break + } return fbResult.Response, nil } return exec.activeProvider.Chat(providerCtx, messagesForCall, toolDefsForCall, exec.llmModel, exec.llmOpts) @@ -477,7 +487,7 @@ func (p *Pipeline) CallLLM( // Publish pico thoughts before the turn context is canceled at return time. // The async variant can race with turn teardown and intermittently drop the // thought message in CI even though the LLM produced reasoning content. - al.publishPicoReasoning(turnCtx, reasoningContent, ts.chatID, ts.sessionKey) + al.publishPicoReasoning(turnCtx, reasoningContent, ts.chatID, ts.sessionKey, exec.llmModelName) } } else { go al.handleReasoning( @@ -564,6 +574,7 @@ func (p *Pipeline) CallLLM( assistantMsg := providers.Message{ Role: "assistant", Content: exec.response.Content, + ModelName: exec.llmModelName, ReasoningContent: reasoningContent, } for _, tc := range exec.normalizedToolCalls { @@ -607,6 +618,7 @@ func (p *Pipeline) CallLLM( al.publishPicoToolCallInterim( turnCtx, ts, + exec.llmModelName, reasoningContent, exec.response.Content, assistantMsg.ToolCalls, diff --git a/pkg/agent/pipeline_setup.go b/pkg/agent/pipeline_setup.go index ba6fa06f..1fdd8f6c 100644 --- a/pkg/agent/pipeline_setup.go +++ b/pkg/agent/pipeline_setup.go @@ -89,6 +89,11 @@ func (p *Pipeline) SetupTurn(ctx context.Context, ts *turnState) (*turnExecution if usedLight && ts.agent.LightProvider != nil { activeProvider = ts.agent.LightProvider } + activeModelName := strings.TrimSpace(ts.agent.Model) + if usedLight { + activeModelName = strings.TrimSpace(sideQuestionModelName(ts.agent, true)) + } + activeModelName = resolvedCandidateModelName(activeCandidates, activeModelName) exec := newTurnExecution( ts.agent, @@ -106,6 +111,7 @@ func (p *Pipeline) SetupTurn(ctx context.Context, ts *turnState) (*turnExecution activeModel, p.Cfg.Agents.Defaults.Provider, ) + exec.llmModelName = activeModelName exec.activeProvider = activeProvider exec.usedLight = usedLight diff --git a/pkg/agent/pipeline_streaming.go b/pkg/agent/pipeline_streaming.go index 5a9bbfd3..d0ef9052 100644 --- a/pkg/agent/pipeline_streaming.go +++ b/pkg/agent/pipeline_streaming.go @@ -50,9 +50,10 @@ func (p *Pipeline) tryConfiguredStreamingLLM( } publisher := &streamingChunkPublisher{ - streamer: streamer, - channel: ts.channel, - chatID: ts.chatID, + streamer: streamer, + channel: ts.channel, + chatID: ts.chatID, + modelName: exec.llmModelName, } logger.DebugCF("agent", "configured streaming enabled", map[string]any{ @@ -371,6 +372,7 @@ type streamingChunkPublisher struct { streamer bus.Streamer channel string chatID string + modelName string published bool reasoningPublished bool err error @@ -380,6 +382,9 @@ func (p *streamingChunkPublisher) Update(ctx context.Context, accumulated string if p == nil || p.streamer == nil || strings.TrimSpace(accumulated) == "" { return } + if setter, ok := p.streamer.(interface{ SetModelName(modelName string) }); ok { + setter.SetModelName(p.modelName) + } if err := p.streamer.Update(ctx, accumulated); err != nil { p.err = err logger.WarnCF("agent", "stream update failed", map[string]any{ @@ -396,6 +401,9 @@ func (p *streamingChunkPublisher) UpdateReasoning(ctx context.Context, accumulat if p == nil || p.streamer == nil || strings.TrimSpace(accumulated) == "" { return } + if setter, ok := p.streamer.(interface{ SetModelName(modelName string) }); ok { + setter.SetModelName(p.modelName) + } reasoningStreamer, ok := p.streamer.(bus.ReasoningStreamer) if !ok { return @@ -434,6 +442,9 @@ func (p *streamingChunkPublisher) Finalize(ctx context.Context, content string, if strings.TrimSpace(content) == "" && !p.published { return nil } + if setter, ok := p.streamer.(interface{ SetModelName(modelName string) }); ok { + setter.SetModelName(p.modelName) + } var err error if streamer, ok := p.streamer.(bus.ContextUsageStreamer); ok { err = streamer.FinalizeWithContext(ctx, content, contextUsage) diff --git a/pkg/agent/pipeline_streaming_test.go b/pkg/agent/pipeline_streaming_test.go index 98122f34..a18e4c6f 100644 --- a/pkg/agent/pipeline_streaming_test.go +++ b/pkg/agent/pipeline_streaming_test.go @@ -570,6 +570,9 @@ func TestConfiguredStreamingFinalFlushFailureBeforeVisibleOutputPublishesFallbac if outbound.Content != "stream response" { t.Fatalf("fallback outbound content = %q, want stream response", outbound.Content) } + if got := outbound.Context.Raw["model_name"]; got != "test-model" { + t.Fatalf("fallback outbound model_name = %q, want %q", got, "test-model") + } case <-time.After(time.Second): t.Fatal("expected fallback outbound after invisible final stream flush failure") } diff --git a/pkg/agent/turn_coord_test.go b/pkg/agent/turn_coord_test.go index c7cdd8a3..4a63e639 100644 --- a/pkg/agent/turn_coord_test.go +++ b/pkg/agent/turn_coord_test.go @@ -10,6 +10,7 @@ import ( "github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/providers" + "github.com/sipeed/picoclaw/pkg/routing" "github.com/sipeed/picoclaw/pkg/session" ) @@ -37,6 +38,39 @@ func (p *simpleConvProvider) GetDefaultModel() string { return "simple-model" } +type sequenceProvider struct { + responses []*providers.LLMResponse + errors []error + callCount int + mu sync.Mutex +} + +func (p *sequenceProvider) Chat( + ctx context.Context, + messages []providers.Message, + tools []providers.ToolDefinition, + model string, + opts map[string]any, +) (*providers.LLMResponse, error) { + p.mu.Lock() + defer p.mu.Unlock() + + idx := p.callCount + p.callCount++ + + if idx < len(p.errors) && p.errors[idx] != nil { + return nil, p.errors[idx] + } + if idx < len(p.responses) && p.responses[idx] != nil { + return p.responses[idx], nil + } + return &providers.LLMResponse{Content: "ok", FinishReason: "stop"}, nil +} + +func (p *sequenceProvider) GetDefaultModel() string { + return "sequence-model" +} + type nativeSearchCaptureProvider struct { lastOpts map[string]any } @@ -271,6 +305,152 @@ func TestPipeline_CallLLM_SimpleResponse(t *testing.T) { } } +func TestPipeline_SetupTurn_ModelNameDoesNotUseFallbackAliasBeforeFallback(t *testing.T) { + al, agent, cleanup := newTurnCoordTestLoop(t, &simpleConvProvider{}) + defer cleanup() + + agent.Model = "primary-model" + agent.Candidates = []providers.FallbackCandidate{ + {Provider: "openai", Model: "gpt-5.4"}, + {Provider: "anthropic", Model: "claude-sonnet", IdentityKey: "model_name:fallback-model"}, + } + + pipeline := NewPipeline(al) + ts := newTurnState(agent, makeTestProcessOpts("test-session"), turnEventScope{ + turnID: "turn-1", + context: newTurnContext(nil, nil, nil), + }) + + exec, err := pipeline.SetupTurn(context.Background(), ts) + if err != nil { + t.Fatalf("SetupTurn failed: %v", err) + } + if exec.llmModelName != "primary-model" { + t.Fatalf("exec.llmModelName = %q, want %q", exec.llmModelName, "primary-model") + } +} + +func TestPipeline_CallLLM_UsesSuccessfulFallbackIdentityAlias(t *testing.T) { + provider := &sequenceProvider{ + errors: []error{ + errors.New("status: 429 - rate limit exceeded"), + nil, + }, + responses: []*providers.LLMResponse{ + nil, + {Content: "fallback answer", FinishReason: "stop"}, + }, + } + al, agent, cleanup := newTurnCoordTestLoop(t, provider) + defer cleanup() + + agent.Model = "primary-model" + agent.Candidates = []providers.FallbackCandidate{ + {Provider: "openai", Model: "gpt-5.4", IdentityKey: "model_name:primary"}, + {Provider: "openai", Model: "gpt-5.4", IdentityKey: "model_name:secondary"}, + } + al.fallback = providers.NewFallbackChain(providers.NewCooldownTracker(), nil) + + pipeline := NewPipeline(al) + ts := newTurnState(agent, makeTestProcessOpts("test-session"), turnEventScope{ + turnID: "turn-1", + context: newTurnContext(nil, nil, nil), + }) + + exec, err := pipeline.SetupTurn(context.Background(), ts) + if err != nil { + t.Fatalf("SetupTurn failed: %v", err) + } + + ctrl, err := pipeline.CallLLM(context.Background(), context.Background(), ts, exec, 1) + if err != nil { + t.Fatalf("CallLLM failed: %v", err) + } + if ctrl != ControlBreak { + t.Fatalf("expected ControlBreak, got %v", ctrl) + } + if exec.llmModelName != "secondary" { + t.Fatalf("exec.llmModelName = %q, want %q", exec.llmModelName, "secondary") + } +} + +func TestPipeline_CallLLM_UsesSuccessfulFallbackDisplayNameWithoutAlias(t *testing.T) { + provider := &sequenceProvider{ + errors: []error{ + errors.New("status: 429 - rate limit exceeded"), + nil, + }, + responses: []*providers.LLMResponse{ + nil, + {Content: "fallback answer", FinishReason: "stop"}, + }, + } + al, agent, cleanup := newTurnCoordTestLoop(t, provider) + defer cleanup() + + agent.Model = "primary-model" + agent.Candidates = []providers.FallbackCandidate{ + {Provider: "openai", Model: "gpt-5.4", IdentityKey: "model_name:primary", DisplayName: "primary-model"}, + {Provider: "anthropic", Model: "claude-sonnet", DisplayName: "anthropic/claude-sonnet"}, + } + al.fallback = providers.NewFallbackChain(providers.NewCooldownTracker(), nil) + + pipeline := NewPipeline(al) + ts := newTurnState(agent, makeTestProcessOpts("test-session"), turnEventScope{ + turnID: "turn-1", + context: newTurnContext(nil, nil, nil), + }) + + exec, err := pipeline.SetupTurn(context.Background(), ts) + if err != nil { + t.Fatalf("SetupTurn failed: %v", err) + } + + ctrl, err := pipeline.CallLLM(context.Background(), context.Background(), ts, exec, 1) + if err != nil { + t.Fatalf("CallLLM failed: %v", err) + } + if ctrl != ControlBreak { + t.Fatalf("expected ControlBreak, got %v", ctrl) + } + if exec.llmModelName != "anthropic/claude-sonnet" { + t.Fatalf("exec.llmModelName = %q, want %q", exec.llmModelName, "anthropic/claude-sonnet") + } +} + +func TestPipeline_SetupTurn_UsesLightCandidateDisplayName(t *testing.T) { + al, agent, cleanup := newTurnCoordTestLoop(t, &simpleConvProvider{}) + defer cleanup() + + agent.Model = "primary-model" + agent.Candidates = []providers.FallbackCandidate{ + {Provider: "openai", Model: "gpt-5.4", IdentityKey: "model_name:primary", DisplayName: "primary-model"}, + } + agent.LightCandidates = []providers.FallbackCandidate{ + {Provider: "openai", Model: "gpt-5.4-mini", IdentityKey: "model_name:light-model", DisplayName: "light-model"}, + } + agent.Router = routing.New(routing.RouterConfig{LightModel: "light-model", Threshold: 1}) + + pipeline := NewPipeline(al) + opts := makeTestProcessOpts("test-session") + opts.UserMessage = "" + ts := newTurnState(agent, opts, turnEventScope{ + turnID: "turn-1", + context: newTurnContext(nil, nil, nil), + }) + + exec, err := pipeline.SetupTurn(context.Background(), ts) + if err != nil { + t.Fatalf("SetupTurn failed: %v", err) + } + if !exec.usedLight { + t.Fatal("expected light routing to be used") + } + if exec.llmModelName != "light-model" { + t.Fatalf("exec.llmModelName = %q, want %q", exec.llmModelName, "light-model") + } +} + func TestRunTurn_FinalizeSaveErrorEmitsErrorTurnEnd(t *testing.T) { al, agent, cleanup := newTurnCoordTestLoop(t, &simpleConvProvider{}) defer cleanup() diff --git a/pkg/agent/turn_state.go b/pkg/agent/turn_state.go index ad5d5972..ddd1eb89 100644 --- a/pkg/agent/turn_state.go +++ b/pkg/agent/turn_state.go @@ -84,6 +84,7 @@ const ( type turnResult struct { finalContent string + modelName string status TurnEndStatus followUps []bus.InboundMessage } @@ -140,6 +141,7 @@ type turnExecution struct { callMessages []providers.Message providerToolDefs []providers.ToolDefinition llmModel string + llmModelName string llmOpts map[string]any gracefulTerminal bool useNativeSearch bool diff --git a/pkg/channels/interfaces.go b/pkg/channels/interfaces.go index 0cfd435b..ad442195 100644 --- a/pkg/channels/interfaces.go +++ b/pkg/channels/interfaces.go @@ -20,6 +20,17 @@ type MessageEditor interface { EditMessage(ctx context.Context, chatID string, messageID string, content string) error } +// MessageEditorWithPayload extends MessageEditor for channels that can update +// structured message metadata in addition to plain text content. +type MessageEditorWithPayload interface { + EditMessageWithPayload( + ctx context.Context, + chatID string, + messageID string, + payload map[string]any, + ) error +} + // MessageDeleter — channels that can delete a message by ID. type MessageDeleter interface { DeleteMessage(ctx context.Context, chatID string, messageID string) error diff --git a/pkg/channels/manager.go b/pkg/channels/manager.go index 43423148..03f65e11 100644 --- a/pkg/channels/manager.go +++ b/pkg/channels/manager.go @@ -191,6 +191,19 @@ func outboundMessageBypassesPlaceholderEdit(msg bus.OutboundMessage) bool { return strings.EqualFold(kind, "thought") || strings.EqualFold(kind, "tool_calls") } +func outboundMessageEditPayload(msg bus.OutboundMessage, content string) map[string]any { + payload := map[string]any{ + "content": content, + } + if len(msg.Context.Raw) == 0 { + return payload + } + if modelName := strings.TrimSpace(msg.Context.Raw["model_name"]); modelName != "" { + payload["model_name"] = modelName + } + return payload +} + func outboundMediaChannel(msg bus.OutboundMediaMessage) string { return msg.Context.Channel } @@ -394,7 +407,16 @@ func (m *Manager) preSend(ctx context.Context, name string, msg bus.OutboundMess if deleter, ok := ch.(MessageDeleter); ok { deleter.DeleteMessage(ctx, chatID, entry.id) // best effort } else if editor, ok := ch.(MessageEditor); ok { - editor.EditMessage(ctx, chatID, entry.id, msg.Content) // fallback + if payloadEditor, ok := ch.(MessageEditorWithPayload); ok { + _ = payloadEditor.EditMessageWithPayload( + ctx, + chatID, + entry.id, + outboundMessageEditPayload(msg, msg.Content), + ) + } else { + editor.EditMessage(ctx, chatID, entry.id, msg.Content) // fallback + } } } } @@ -446,7 +468,18 @@ func (m *Manager) preSend(ctx context.Context, name string, msg bus.OutboundMess trackedContent = prepareToolFeedbackMessageContent(ch, msg.Content) content = InitialAnimatedToolFeedbackContent(trackedContent) } - if err := editor.EditMessage(ctx, chatID, entry.id, content); err == nil { + err := func() error { + if payloadEditor, ok := ch.(MessageEditorWithPayload); ok { + return payloadEditor.EditMessageWithPayload( + ctx, + chatID, + entry.id, + outboundMessageEditPayload(msg, content), + ) + } + return editor.EditMessage(ctx, chatID, entry.id, content) + }() + if err == nil { trackedChatID := trackedToolFeedbackMessageChatID(ch, chatID, &msg.Context) if tracker, ok := ch.(toolFeedbackMessageTracker); ok && isToolFeedback { tracker.RecordToolFeedbackMessage(trackedChatID, entry.id, trackedContent) @@ -643,6 +676,18 @@ func reasoningStreamerFrom(streamer bus.Streamer) bus.ReasoningStreamer { return nil } +type modelNameStreamer interface { + SetModelName(modelName string) +} + +func setStreamerModelName(streamer any, modelName string) { + setter, ok := streamer.(modelNameStreamer) + if !ok { + return + } + setter.SetModelName(modelName) +} + // splitMarkerStreamer turns accumulated streaming text containing // MessageSplitMarker into separate channel stream messages. type splitMarkerStreamer struct { @@ -654,6 +699,7 @@ type splitMarkerStreamer struct { finalized bool onFinalize func(context.Context, string) clearMarker func() + modelName string } func (s *splitMarkerStreamer) Update(ctx context.Context, content string) error { @@ -682,6 +728,7 @@ func (s *splitMarkerStreamer) UpdateReasoning(ctx context.Context, content strin if s.reasoning == nil { return nil } + setStreamerModelName(s.reasoning, s.modelName) return s.reasoning.UpdateReasoning(ctx, content) } @@ -691,9 +738,18 @@ func (s *splitMarkerStreamer) FinalizeReasoning(ctx context.Context, content str if s.reasoning == nil { return nil } + setStreamerModelName(s.reasoning, s.modelName) return s.reasoning.FinalizeReasoning(ctx, content) } +func (s *splitMarkerStreamer) SetModelName(modelName string) { + s.mu.Lock() + defer s.mu.Unlock() + s.modelName = strings.TrimSpace(modelName) + setStreamerModelName(s.current, s.modelName) + setStreamerModelName(s.reasoning, s.modelName) +} + func (s *splitMarkerStreamer) Cancel(ctx context.Context) { s.mu.Lock() defer s.mu.Unlock() @@ -772,6 +828,7 @@ func (s *splitMarkerStreamer) ensureCurrentLocked(ctx context.Context) error { return err } s.current = streamer + setStreamerModelName(s.current, s.modelName) return nil } @@ -856,6 +913,10 @@ func (s *finalizeHookStreamer) FinalizeReasoning(ctx context.Context, content st return nil } +func (s *finalizeHookStreamer) SetModelName(modelName string) { + setStreamerModelName(s.Streamer, strings.TrimSpace(modelName)) +} + func (s *finalizeHookStreamer) runFinalizeHook(ctx context.Context, content string) { if s.onFinalize != nil { s.onFinalize(ctx, content) diff --git a/pkg/channels/manager_test.go b/pkg/channels/manager_test.go index af3bc6c0..a7ceac67 100644 --- a/pkg/channels/manager_test.go +++ b/pkg/channels/manager_test.go @@ -142,11 +142,21 @@ func (m *mockReasoningStreamer) FinalizeReasoning(_ context.Context, content str return nil } +type modelTrackingReasoningStreamer struct { + mockReasoningStreamer + modelNames []string +} + +func (m *modelTrackingReasoningStreamer) SetModelName(modelName string) { + m.modelNames = append(m.modelNames, strings.TrimSpace(modelName)) +} + type recordingStreamSegment struct { updates []string finals []string finalUsage *bus.ContextUsage canceledCount int + modelNames []string } func (s *recordingStreamSegment) Update(_ context.Context, content string) error { @@ -168,6 +178,10 @@ func (s *recordingStreamSegment) Cancel(context.Context) { s.canceledCount++ } +func (s *recordingStreamSegment) SetModelName(modelName string) { + s.modelNames = append(s.modelNames, strings.TrimSpace(modelName)) +} + type mockStreamingChannel struct { mockMessageEditor streamer Streamer @@ -2068,6 +2082,42 @@ func TestGetStreamer_PreservesReasoningStreamer(t *testing.T) { } } +func TestGetStreamer_PreservesModelNameSetter(t *testing.T) { + m := newTestManager() + inner := &modelTrackingReasoningStreamer{} + ch := &mockStreamingChannel{ + streamer: inner, + } + m.channels["test"] = ch + + streamer, ok := m.GetStreamer(context.Background(), "test", "123", "") + if !ok { + t.Fatal("expected streamer to be available") + } + setter, ok := streamer.(interface{ SetModelName(modelName string) }) + if !ok { + t.Fatal("manager-wrapped streamer should preserve SetModelName") + } + setter.SetModelName("gpt-5.4") + if err := streamer.Update(context.Background(), "hello"); err != nil { + t.Fatalf("Update() error = %v", err) + } + reasoningStreamer, ok := streamer.(bus.ReasoningStreamer) + if !ok { + t.Fatal("manager-wrapped streamer should preserve ReasoningStreamer") + } + setter.SetModelName("gpt-5.4") + if err := reasoningStreamer.UpdateReasoning(context.Background(), "thinking"); err != nil { + t.Fatalf("UpdateReasoning() error = %v", err) + } + if len(inner.modelNames) != 2 { + t.Fatalf("model name calls = %v, want 2 forwarded calls", inner.modelNames) + } + if inner.modelNames[0] != "gpt-5.4" || inner.modelNames[1] != "gpt-5.4" { + t.Fatalf("model name calls = %v, want both forwarded as gpt-5.4", inner.modelNames) + } +} + func TestGetStreamer_SplitOnMarkerStreamsSeparateSegments(t *testing.T) { m := newTestManager() m.config = &config.Config{ @@ -2188,6 +2238,58 @@ func TestGetStreamer_SplitOnMarkerKeepsReasoningOnInitialStreamer(t *testing.T) } } +func TestGetStreamer_SplitOnMarkerPreservesModelNameSetter(t *testing.T) { + m := newTestManager() + m.config = &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + SplitOnMarker: true, + }, + }, + } + + initial := &modelTrackingReasoningStreamer{} + next := &recordingStreamSegment{} + callCount := 0 + ch := &mockStreamingChannel{ + beginStreamFn: func(context.Context, string) (Streamer, error) { + callCount++ + if callCount == 1 { + return initial, nil + } + return next, nil + }, + } + m.channels["test"] = ch + + streamer, ok := m.GetStreamer(context.Background(), "test", "123", "") + if !ok { + t.Fatal("expected streamer to be available") + } + setter, ok := streamer.(interface{ SetModelName(modelName string) }) + if !ok { + t.Fatal("split streamer should preserve SetModelName") + } + setter.SetModelName("gpt-5.4-mini") + if err := streamer.Update(context.Background(), "hello<|[SPLIT]|>world"); err != nil { + t.Fatalf("Update() error = %v", err) + } + reasoningStreamer, ok := streamer.(bus.ReasoningStreamer) + if !ok { + t.Fatal("split streamer should preserve ReasoningStreamer") + } + if err := reasoningStreamer.UpdateReasoning(context.Background(), "thinking"); err != nil { + t.Fatalf("UpdateReasoning() error = %v", err) + } + + if len(initial.modelNames) == 0 || initial.modelNames[0] != "gpt-5.4-mini" { + t.Fatalf("initial model names = %v, want forwarded gpt-5.4-mini", initial.modelNames) + } + if len(next.modelNames) == 0 || next.modelNames[0] != "gpt-5.4-mini" { + t.Fatalf("next model names = %v, want forwarded gpt-5.4-mini", next.modelNames) + } +} + func TestGetStreamer_FinalizeSeparateMessagesClearsTrackedToolFeedback(t *testing.T) { m := newTestManager() m.config = &config.Config{ diff --git a/pkg/channels/pico/pico.go b/pkg/channels/pico/pico.go index e15ffa6c..3c393898 100644 --- a/pkg/channels/pico/pico.go +++ b/pkg/channels/pico/pico.go @@ -325,6 +325,9 @@ func (c *PicoChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]stri PayloadKeyContent: content, "message_id": msgID, } + if modelName := strings.TrimSpace(msg.Context.Raw[PayloadKeyModelName]); modelName != "" { + payload[PayloadKeyModelName] = modelName + } switch { case isThought: payload[PayloadKeyKind] = MessageKindThought @@ -359,6 +362,15 @@ func (c *PicoChannel) EditMessage(ctx context.Context, chatID string, messageID return c.editMessage(ctx, chatID, messageID, content, nil) } +func (c *PicoChannel) EditMessageWithPayload( + ctx context.Context, + chatID string, + messageID string, + payload map[string]any, +) error { + return c.editMessagePayload(ctx, chatID, messageID, payload, nil) +} + // DeleteMessage implements channels.MessageDeleter. func (c *PicoChannel) DeleteMessage(ctx context.Context, chatID string, messageID string) error { outMsg := newMessage(TypeMessageDelete, map[string]any{ @@ -419,14 +431,23 @@ func (c *PicoChannel) finalizeTrackedToolFeedbackMessage( ctx context.Context, chatID string, content string, - editFn func(context.Context, string, string, string, *bus.ContextUsage) error, + editFn func(context.Context, string, string, map[string]any, *bus.ContextUsage) error, + payload map[string]any, contextUsage *bus.ContextUsage, ) ([]string, bool) { msgID, baseContent, ok := c.takeToolFeedbackMessage(chatID) if !ok || editFn == nil { return nil, false } - if err := editFn(ctx, chatID, msgID, content, contextUsage); err != nil { + if payload == nil { + payload = map[string]any{ + PayloadKeyContent: content, + } + } + if _, ok := payload[PayloadKeyContent]; !ok { + payload[PayloadKeyContent] = content + } + if err := editFn(ctx, chatID, msgID, payload, contextUsage); err != nil { c.RecordToolFeedbackMessage(chatID, msgID, baseContent) return nil, false } @@ -437,7 +458,20 @@ func (c *PicoChannel) FinalizeToolFeedbackMessage(ctx context.Context, msg bus.O if !outboundMessageFinalizesTrackedToolFeedback(msg) { return nil, false } - return c.finalizeTrackedToolFeedbackMessage(ctx, msg.ChatID, msg.Content, c.editMessage, msg.ContextUsage) + payload := map[string]any{ + PayloadKeyContent: msg.Content, + } + if modelName := strings.TrimSpace(msg.Context.Raw[PayloadKeyModelName]); modelName != "" { + payload[PayloadKeyModelName] = modelName + } + return c.finalizeTrackedToolFeedbackMessage( + ctx, + msg.ChatID, + msg.Content, + c.editMessagePayload, + payload, + msg.ContextUsage, + ) } // StartTyping implements channels.TypingCapable. @@ -496,6 +530,7 @@ func (c *PicoChannel) BeginStream(ctx context.Context, chatID string) (channels. type picoStreamer struct { channel *PicoChannel chatID string + modelName string messageID string reasoningID string throttleInterval time.Duration @@ -509,6 +544,15 @@ type picoStreamer struct { mu sync.Mutex } +func (s *picoStreamer) SetModelName(modelName string) { + if s == nil { + return + } + s.mu.Lock() + defer s.mu.Unlock() + s.modelName = strings.TrimSpace(modelName) +} + func (s *picoStreamer) Update(ctx context.Context, content string) error { s.mu.Lock() defer s.mu.Unlock() @@ -613,13 +657,23 @@ func (s *picoStreamer) sendLocked(ctx context.Context, content string, contextUs PayloadKeyContent: content, "message_id": s.messageID, } + if s.modelName != "" { + payload[PayloadKeyModelName] = s.modelName + } setContextUsagePayload(payload, contextUsage) outMsg := newMessage(TypeMessageCreate, payload) if err := s.channel.broadcastToSession(s.chatID, outMsg); err != nil { return err } } else if content != s.lastContent || contextUsage != nil { - if err := s.channel.editMessage(ctx, s.chatID, s.messageID, content, contextUsage); err != nil { + payload := map[string]any{ + PayloadKeyContent: content, + "message_id": s.messageID, + } + if s.modelName != "" { + payload[PayloadKeyModelName] = s.modelName + } + if err := s.channel.editMessagePayload(ctx, s.chatID, s.messageID, payload, contextUsage); err != nil { return err } } @@ -642,6 +696,9 @@ func (s *picoStreamer) sendReasoningLocked(ctx context.Context, content string) PayloadKeyKind: MessageKindThought, PayloadKeyThought: true, } + if s.modelName != "" { + payload[PayloadKeyModelName] = s.modelName + } outMsg := newMessage(TypeMessageCreate, payload) if err := s.channel.broadcastToSession(s.chatID, outMsg); err != nil { return err @@ -653,6 +710,9 @@ func (s *picoStreamer) sendReasoningLocked(ctx context.Context, content string) PayloadKeyKind: MessageKindThought, PayloadKeyThought: true, } + if s.modelName != "" { + payload[PayloadKeyModelName] = s.modelName + } outMsg := newMessage(TypeMessageUpdate, payload) if err := s.channel.broadcastToSession(s.chatID, outMsg); err != nil { return err @@ -744,6 +804,9 @@ func (c *PicoChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessag "attachments": attachments, "message_id": msgID, }) + if modelName := strings.TrimSpace(msg.Context.Raw[PayloadKeyModelName]); modelName != "" { + outMsg.Payload[PayloadKeyModelName] = modelName + } if err := c.broadcastToSession(msg.ChatID, outMsg); err != nil { return nil, err @@ -1358,11 +1421,30 @@ func (c *PicoChannel) editMessage( content string, contextUsage *bus.ContextUsage, ) error { - payload := map[string]any{ - "message_id": messageID, - "content": content, + return c.editMessagePayload(ctx, chatID, messageID, map[string]any{ + PayloadKeyContent: content, + }, contextUsage) +} + +func (c *PicoChannel) editMessagePayload( + ctx context.Context, + chatID string, + messageID string, + payload map[string]any, + contextUsage *bus.ContextUsage, +) error { + if payload == nil { + payload = map[string]any{} } - setContextUsagePayload(payload, contextUsage) - outMsg := newMessage(TypeMessageUpdate, payload) + normalized := make(map[string]any, len(payload)+1) + for key, value := range payload { + normalized[key] = value + } + if _, ok := normalized[PayloadKeyContent]; !ok { + normalized[PayloadKeyContent] = "" + } + normalized["message_id"] = messageID + setContextUsagePayload(normalized, contextUsage) + outMsg := newMessage(TypeMessageUpdate, normalized) return c.broadcastToSession(chatID, outMsg) } diff --git a/pkg/channels/pico/pico_test.go b/pkg/channels/pico/pico_test.go index fb095952..9e90fc53 100644 --- a/pkg/channels/pico/pico_test.go +++ b/pkg/channels/pico/pico_test.go @@ -46,12 +46,15 @@ func TestFinalizeTrackedToolFeedbackMessage_StopsTrackingBeforeEdit(t *testing.T context.Background(), "pico:chat-1", "final reply", - func(_ context.Context, chatID, messageID, content string, contextUsage *bus.ContextUsage) error { + func(_ context.Context, chatID, messageID string, payload map[string]any, contextUsage *bus.ContextUsage) error { if _, ok := ch.currentToolFeedbackMessage(chatID); ok { t.Fatal("expected tracked tool feedback to be stopped before edit") } - if chatID != "pico:chat-1" || messageID != "msg-1" || content != "final reply" { - t.Fatalf("unexpected edit args: %s %s %s", chatID, messageID, content) + if chatID != "pico:chat-1" || messageID != "msg-1" { + t.Fatalf("unexpected edit args: %s %s", chatID, messageID) + } + if got := payload[PayloadKeyContent]; got != "final reply" { + t.Fatalf("unexpected content payload: %#v", got) } if contextUsage != nil { t.Fatalf("unexpected context usage: %+v", contextUsage) @@ -59,6 +62,7 @@ func TestFinalizeTrackedToolFeedbackMessage_StopsTrackingBeforeEdit(t *testing.T return nil }, nil, + nil, ) if !handled { t.Fatal("expected finalizeTrackedToolFeedbackMessage to handle tracked message") @@ -115,7 +119,8 @@ func TestSend_ThoughtMessageDoesNotFinalizeTrackedToolFeedback(t *testing.T) { Channel: "pico", ChatID: "pico:sess-1", Raw: map[string]string{ - "message_kind": MessageKindThought, + "message_kind": MessageKindThought, + PayloadKeyModelName: "gpt-5.4-mini", }, }, }); err != nil { @@ -134,6 +139,9 @@ func TestSend_ThoughtMessageDoesNotFinalizeTrackedToolFeedback(t *testing.T) { if got := payload[PayloadKeyKind]; got != MessageKindThought { t.Fatalf("thought kind = %#v, want %q", got, MessageKindThought) } + if got := payload[PayloadKeyModelName]; got != "gpt-5.4-mini" { + t.Fatalf("thought model_name = %#v, want %q", got, "gpt-5.4-mini") + } if got := payload["message_id"]; got == "msg-progress" || got == nil || got == "" { t.Fatalf("thought message_id = %#v, want new non-progress id", got) } @@ -151,6 +159,9 @@ func TestSend_ThoughtMessageDoesNotFinalizeTrackedToolFeedback(t *testing.T) { Context: bus.InboundContext{ Channel: "pico", ChatID: "pico:sess-1", + Raw: map[string]string{ + PayloadKeyModelName: "gpt-5.4", + }, }, ContextUsage: &bus.ContextUsage{ UsedTokens: 321, @@ -174,6 +185,9 @@ func TestSend_ThoughtMessageDoesNotFinalizeTrackedToolFeedback(t *testing.T) { if got := payload[PayloadKeyContent]; got != "final reply" { t.Fatalf("final content = %#v, want %q", got, "final reply") } + if got := payload[PayloadKeyModelName]; got != "gpt-5.4" { + t.Fatalf("final model_name = %#v, want %q", got, "gpt-5.4") + } rawUsage, ok := payload["context_usage"].(map[string]any) if !ok { t.Fatalf("final context_usage = %#v, want map payload", payload["context_usage"]) @@ -193,6 +207,54 @@ func TestSend_ThoughtMessageDoesNotFinalizeTrackedToolFeedback(t *testing.T) { } } +func TestSend_ToolCallsMessageIncludesModelName(t *testing.T) { + ch := newTestPicoChannel(t) + + if err := ch.Start(context.Background()); err != nil { + t.Fatalf("Start() error = %v", err) + } + defer ch.Stop(context.Background()) + + clientConn, received, cleanup := newTestPicoWebSocket(t) + defer cleanup() + ch.addConnForTest(&picoConn{id: "conn-1", conn: clientConn, sessionID: "sess-1"}) + + if _, err := ch.Send(context.Background(), bus.OutboundMessage{ + ChatID: "pico:sess-1", + Content: "", + Context: bus.InboundContext{ + Channel: "pico", + ChatID: "pico:sess-1", + Raw: map[string]string{ + "message_kind": MessageKindToolCalls, + PayloadKeyModelName: "gpt-5.4", + PayloadKeyToolCalls: `[{"id":"call_1","type":"function","function":{"name":"read_file","arguments":"{\"path\":\"README.md\"}"}}]`, + }, + }, + }); err != nil { + t.Fatalf("Send(tool_calls) error = %v", err) + } + + select { + case msg := <-received: + if msg.Type != TypeMessageCreate { + t.Fatalf("tool_calls message type = %q, want %q", msg.Type, TypeMessageCreate) + } + payload := msg.Payload + if got := payload[PayloadKeyKind]; got != MessageKindToolCalls { + t.Fatalf("tool_calls kind = %#v, want %q", got, MessageKindToolCalls) + } + if got := payload[PayloadKeyModelName]; got != "gpt-5.4" { + t.Fatalf("tool_calls model_name = %#v, want %q", got, "gpt-5.4") + } + if _, ok := payload[PayloadKeyToolCalls].([]any); !ok { + t.Fatalf("tool_calls payload = %#v, want parsed array", payload[PayloadKeyToolCalls]) + } + case <-time.After(time.Second): + t.Fatal("expected tool_calls message to be delivered") + } +} + func TestSendPlaceholder_EmitsNormalMessageWithoutKind(t *testing.T) { ch := newTestPicoChannel(t) ch.bc.Placeholder.Enabled = true @@ -257,6 +319,9 @@ func TestBeginStream_CreatesAndUpdatesSameMessage(t *testing.T) { if err != nil { t.Fatalf("BeginStream() error = %v", err) } + if setter, ok := streamer.(interface{ SetModelName(modelName string) }); ok { + setter.SetModelName("gpt-5.4") + } if err := streamer.Update(context.Background(), "hello"); err != nil { t.Fatalf("Update(first) error = %v", err) } @@ -271,6 +336,9 @@ func TestBeginStream_CreatesAndUpdatesSameMessage(t *testing.T) { if got := first.Payload[PayloadKeyContent]; got != "hello" { t.Fatalf("first content = %#v, want hello", got) } + if got := first.Payload[PayloadKeyModelName]; got != "gpt-5.4" { + t.Fatalf("first model_name = %#v, want %q", got, "gpt-5.4") + } rawStreamer := streamer.(*picoStreamer) rawStreamer.mu.Lock() @@ -290,6 +358,9 @@ func TestBeginStream_CreatesAndUpdatesSameMessage(t *testing.T) { if got := second.Payload[PayloadKeyContent]; got != secondContent { t.Fatalf("second content = %#v, want %q", got, secondContent) } + if got := second.Payload[PayloadKeyModelName]; got != "gpt-5.4" { + t.Fatalf("second model_name = %#v, want %q", got, "gpt-5.4") + } } func TestBeginStream_DefaultStreamingShowsSmallIncrements(t *testing.T) { @@ -355,6 +426,9 @@ func TestBeginStream_StreamsReasoningAsThoughtUpdates(t *testing.T) { if !ok { t.Fatal("pico stream should support reasoning updates") } + if setter, ok := streamer.(interface{ SetModelName(modelName string) }); ok { + setter.SetModelName("gpt-5.4-mini") + } if err := reasoningStreamer.UpdateReasoning(context.Background(), "thinking"); err != nil { t.Fatalf("UpdateReasoning(first) error = %v", err) } @@ -372,6 +446,9 @@ func TestBeginStream_StreamsReasoningAsThoughtUpdates(t *testing.T) { if got := first.Payload[PayloadKeyContent]; got != "thinking" { t.Fatalf("first content = %#v, want thinking", got) } + if got := first.Payload[PayloadKeyModelName]; got != "gpt-5.4-mini" { + t.Fatalf("first model_name = %#v, want %q", got, "gpt-5.4-mini") + } if err := reasoningStreamer.UpdateReasoning(context.Background(), "thinking more"); err != nil { t.Fatalf("UpdateReasoning(second) error = %v", err) @@ -389,6 +466,9 @@ func TestBeginStream_StreamsReasoningAsThoughtUpdates(t *testing.T) { if got := second.Payload[PayloadKeyContent]; got != "thinking more" { t.Fatalf("second content = %#v, want thinking more", got) } + if got := second.Payload[PayloadKeyModelName]; got != "gpt-5.4-mini" { + t.Fatalf("second model_name = %#v, want %q", got, "gpt-5.4-mini") + } } func TestBeginStream_ThrottlesIntermediateUpdatesAndFinalFlushes(t *testing.T) { @@ -473,6 +553,9 @@ func TestBeginStream_FinalizeIncludesContextUsage(t *testing.T) { if err != nil { t.Fatalf("BeginStream() error = %v", err) } + if setter, ok := streamer.(interface{ SetModelName(modelName string) }); ok { + setter.SetModelName("gpt-5.4") + } if err := streamer.Update(context.Background(), "partial"); err != nil { t.Fatalf("Update() error = %v", err) } @@ -501,6 +584,9 @@ func TestBeginStream_FinalizeIncludesContextUsage(t *testing.T) { if got := final.Payload["message_id"]; got != msgID { t.Fatalf("final message_id = %#v, want %q", got, msgID) } + if got := final.Payload[PayloadKeyModelName]; got != "gpt-5.4" { + t.Fatalf("final model_name = %#v, want %q", got, "gpt-5.4") + } rawUsage, ok := final.Payload["context_usage"].(map[string]any) if !ok { t.Fatalf("final context_usage = %#v, want map", final.Payload["context_usage"]) diff --git a/pkg/channels/pico/protocol.go b/pkg/channels/pico/protocol.go index 320c44d1..0c809430 100644 --- a/pkg/channels/pico/protocol.go +++ b/pkg/channels/pico/protocol.go @@ -27,6 +27,7 @@ const ( PayloadKeyKind = "kind" PayloadKeyPlaceholder = "placeholder" PayloadKeyToolCalls = "tool_calls" + PayloadKeyModelName = "model_name" MessageKindThought = "thought" MessageKindToolCalls = "tool_calls" diff --git a/pkg/memory/jsonl_test.go b/pkg/memory/jsonl_test.go index 3a7b9813..c77a4393 100644 --- a/pkg/memory/jsonl_test.go +++ b/pkg/memory/jsonl_test.go @@ -130,6 +130,32 @@ func TestAddFullMessage_WithToolCalls(t *testing.T) { } } +func TestAddFullMessage_PreservesModelName(t *testing.T) { + store := newTestStore(t) + ctx := context.Background() + + msg := providers.Message{ + Role: "assistant", + Content: "done", + ModelName: "gpt-5.4-mini", + } + + if err := store.AddFullMessage(ctx, "model-name", msg); err != nil { + t.Fatalf("AddFullMessage: %v", err) + } + + history, err := store.GetHistory(ctx, "model-name") + if err != nil { + t.Fatalf("GetHistory: %v", err) + } + if len(history) != 1 { + t.Fatalf("expected 1, got %d", len(history)) + } + if history[0].ModelName != "gpt-5.4-mini" { + t.Fatalf("ModelName = %q, want %q", history[0].ModelName, "gpt-5.4-mini") + } +} + func TestAddFullMessage_ToolCallID(t *testing.T) { store := newTestStore(t) ctx := context.Background() diff --git a/pkg/providers/fallback.go b/pkg/providers/fallback.go index 36092105..ca0e652d 100644 --- a/pkg/providers/fallback.go +++ b/pkg/providers/fallback.go @@ -17,6 +17,7 @@ type FallbackChain struct { type FallbackCandidate struct { Provider string Model string + DisplayName string // optional configured alias/raw model label for persistence/UI RPM int // requests per minute; 0 means unrestricted IdentityKey string // optional stable config identity for cooldown/rate limiting } @@ -32,10 +33,11 @@ func (c FallbackCandidate) StableKey() string { // FallbackResult contains the successful response and metadata about all attempts. type FallbackResult struct { - Response *LLMResponse - Provider string - Model string - Attempts []FallbackAttempt + Response *LLMResponse + Provider string + Model string + IdentityKey string + Attempts []FallbackAttempt } // FallbackAttempt records one attempt in the fallback chain. @@ -85,8 +87,9 @@ func ResolveCandidatesWithLookup( } seen[key] = true candidates = append(candidates, FallbackCandidate{ - Provider: ref.Provider, - Model: ref.Model, + Provider: ref.Provider, + Model: ref.Model, + DisplayName: candidateRaw, }) } @@ -187,6 +190,7 @@ func (fc *FallbackChain) Execute( result.Response = resp result.Provider = candidate.Provider result.Model = candidate.Model + result.IdentityKey = candidate.StableKey() return result, nil } @@ -305,6 +309,7 @@ func (fc *FallbackChain) ExecuteImage( result.Response = resp result.Provider = candidate.Provider result.Model = candidate.Model + result.IdentityKey = candidate.StableKey() return result, nil } diff --git a/pkg/providers/protocoltypes/types.go b/pkg/providers/protocoltypes/types.go index 37a929a5..650bcb28 100644 --- a/pkg/providers/protocoltypes/types.go +++ b/pkg/providers/protocoltypes/types.go @@ -86,6 +86,7 @@ type Attachment struct { type Message struct { Role string `json:"role"` Content string `json:"content"` + ModelName string `json:"model_name,omitempty"` Media []string `json:"media,omitempty"` Attachments []Attachment `json:"attachments,omitempty"` ReasoningContent string `json:"reasoning_content,omitempty"` diff --git a/pkg/seahorse/schema.go b/pkg/seahorse/schema.go index 5b67fe9e..97e638c9 100644 --- a/pkg/seahorse/schema.go +++ b/pkg/seahorse/schema.go @@ -46,6 +46,7 @@ func runSchema(db *sql.DB) error { conversation_id INTEGER NOT NULL REFERENCES conversations(conversation_id), role TEXT NOT NULL, content TEXT NOT NULL DEFAULT '', + model_name TEXT NOT NULL DEFAULT '', reasoning_content TEXT NOT NULL DEFAULT '', token_count INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL DEFAULT (datetime('now')) @@ -162,6 +163,9 @@ func runSchema(db *sql.DB) error { if err := ensureMessagesReasoningContentColumn(db); err != nil { return err } + if err := ensureMessagesModelNameColumn(db); err != nil { + return err + } return nil } @@ -180,6 +184,21 @@ func ensureMessagesReasoningContentColumn(db *sql.DB) error { return nil } +func ensureMessagesModelNameColumn(db *sql.DB) error { + hasColumn, err := tableHasColumn(db, "messages", "model_name") + if err != nil { + return fmt.Errorf("check messages.model_name: %w", err) + } + if hasColumn { + return nil + } + + if _, err := db.Exec(`ALTER TABLE messages ADD COLUMN model_name TEXT NOT NULL DEFAULT ''`); err != nil { + return fmt.Errorf("add messages.model_name: %w", err) + } + return nil +} + func tableHasColumn(db *sql.DB, tableName, columnName string) (bool, error) { rows, err := db.Query(fmt.Sprintf(`PRAGMA table_info(%s)`, tableName)) if err != nil { diff --git a/pkg/seahorse/schema_test.go b/pkg/seahorse/schema_test.go index 943b742b..4618eeff 100644 --- a/pkg/seahorse/schema_test.go +++ b/pkg/seahorse/schema_test.go @@ -138,6 +138,37 @@ func TestRunSchemaAddsMessagesReasoningContentColumn(t *testing.T) { } } +func TestRunSchemaAddsMessagesModelNameColumn(t *testing.T) { + db := openTestDB(t) + + _, err := db.Exec(`CREATE TABLE messages ( + message_id INTEGER PRIMARY KEY AUTOINCREMENT, + conversation_id INTEGER NOT NULL, + role TEXT NOT NULL, + content TEXT NOT NULL DEFAULT '', + reasoning_content TEXT NOT NULL DEFAULT '', + token_count INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + )`) + if err != nil { + t.Fatalf("create legacy messages table: %v", err) + } + + err = runSchema(db) + if err != nil { + t.Fatalf("runSchema: %v", err) + } + + var count int + err = db.QueryRow(`SELECT count(*) FROM pragma_table_info('messages') WHERE name = 'model_name'`).Scan(&count) + if err != nil { + t.Fatalf("query pragma_table_info: %v", err) + } + if count != 1 { + t.Fatalf("model_name column count = %d, want 1", count) + } +} + func TestMigrationConversationUnique(t *testing.T) { db := openTestDB(t) if err := runSchema(db); err != nil { diff --git a/pkg/seahorse/short_engine.go b/pkg/seahorse/short_engine.go index 0a817561..d275da51 100644 --- a/pkg/seahorse/short_engine.go +++ b/pkg/seahorse/short_engine.go @@ -258,6 +258,7 @@ func (e *Engine) Ingest(ctx context.Context, sessionKey string, messages []Messa conv.ConversationID, msg.Role, msg.Parts, + msg.ModelName, msg.ReasoningContent, msg.TokenCount, ) @@ -267,6 +268,7 @@ func (e *Engine) Ingest(ctx context.Context, sessionKey string, messages []Messa conv.ConversationID, msg.Role, msg.Content, + msg.ModelName, msg.ReasoningContent, msg.TokenCount, ) @@ -431,6 +433,31 @@ func (e *Engine) Bootstrap(ctx context.Context, sessionKey string, messages []Me return fmt.Errorf("bootstrap: get messages: %w", err) } + // Migration repair path: old SeaHorse rows may be missing reasoning_content + // even though the canonical JSONL history already has it. Backfill those + // rows in place so we do not treat this as edited history and leave stale + // summaries/context behind after a partial raw-message rebuild. + repairedReasoning, err := e.repairBootstrapReasoningContent(ctx, dbMsgs, messages) + if err != nil { + return fmt.Errorf("bootstrap: repair reasoning_content: %w", err) + } + repairedModelName, err := e.repairBootstrapModelName(ctx, dbMsgs, messages) + if err != nil { + return fmt.Errorf("bootstrap: repair model_name: %w", err) + } + if (repairedReasoning || repairedModelName) && len(dbMsgs) == len(messages) { + matched := true + for i := range messages { + if !messageMatches(dbMsgs[i], messages[i]) { + matched = false + break + } + } + if matched { + return nil + } + } + // Fast path: DB has same count and exact match → no-op if len(dbMsgs) == len(messages) { matched := true @@ -445,16 +472,6 @@ func (e *Engine) Bootstrap(ctx context.Context, sessionKey string, messages []Me } } - // Migration repair path: old SeaHorse rows may be missing reasoning_content - // even though the canonical JSONL history already has it. Backfill those - // rows in place so we do not treat this as edited history and leave stale - // summaries/context behind after a partial raw-message rebuild. - if repaired, err := e.repairBootstrapReasoningContent(ctx, dbMsgs, messages); err != nil { - return fmt.Errorf("bootstrap: repair reasoning_content: %w", err) - } else if repaired && len(dbMsgs) == len(messages) { - return nil - } - // Find longest matching prefix from the start anchor := -1 compareLen := min(len(dbMsgs), len(messages)) @@ -465,14 +482,16 @@ func (e *Engine) Bootstrap(ctx context.Context, sessionKey string, messages []Me } else { // Mismatch detected - log details and rebuild logger.InfoCF("seahorse", "bootstrap: mismatch detected", map[string]any{ - "conv_id": conv.ConversationID, - "index": i, - "db_role": dbMsgs[i].Role, - "db_content": truncate(dbMsgs[i].Content, 50), - "db_parts": len(dbMsgs[i].Parts), - "msg_role": messages[i].Role, - "msg_content": truncate(messages[i].Content, 50), - "msg_parts": len(messages[i].Parts), + "conv_id": conv.ConversationID, + "index": i, + "db_role": dbMsgs[i].Role, + "db_content": truncate(dbMsgs[i].Content, 50), + "db_parts": len(dbMsgs[i].Parts), + "db_model_name": dbMsgs[i].ModelName, + "msg_role": messages[i].Role, + "msg_content": truncate(messages[i].Content, 50), + "msg_parts": len(messages[i].Parts), + "msg_model_name": messages[i].ModelName, }) break } @@ -559,7 +578,7 @@ func (e *Engine) repairBootstrapReasoningContent(ctx context.Context, dbMsgs, me } for i := range overlap { - if !messageMatchesIgnoringReasoning(dbMsgs[i], messages[i]) { + if !messageMatchesIgnoringReasoningAndModelName(dbMsgs[i], messages[i]) { return false, nil } if dbMsgs[i].ReasoningContent == messages[i].ReasoningContent { @@ -596,6 +615,57 @@ func (e *Engine) repairBootstrapReasoningContent(ctx context.Context, dbMsgs, me return true, nil } +func (e *Engine) repairBootstrapModelName(ctx context.Context, dbMsgs, messages []Message) (bool, error) { + if len(dbMsgs) == 0 || len(messages) == 0 { + return false, nil + } + + overlap := min(len(messages), len(dbMsgs)) + + var updates []struct { + index int + messageID int64 + modelName string + } + + for i := range overlap { + if !messageMatchesIgnoringReasoningAndModelName(dbMsgs[i], messages[i]) { + return false, nil + } + if dbMsgs[i].ModelName == messages[i].ModelName { + continue + } + if messages[i].ModelName == "" { + return false, nil + } + updates = append(updates, struct { + index int + messageID int64 + modelName string + }{ + index: i, + messageID: dbMsgs[i].ID, + modelName: messages[i].ModelName, + }) + } + + if len(updates) == 0 { + return false, nil + } + + for _, update := range updates { + if err := e.store.UpdateMessageModelName(ctx, update.messageID, update.modelName); err != nil { + return false, err + } + dbMsgs[update.index].ModelName = update.modelName + } + + logger.InfoCF("seahorse", "bootstrap: repaired missing model_name", map[string]any{ + "messages": len(updates), + }) + return true, nil +} + // truncate shortens a string for logging. func truncate(s string, maxLen int) string { if len(s) <= maxLen { @@ -610,13 +680,20 @@ func truncate(s string, maxLen int) string { // For messages with Parts (tool_use, tool_result), compare Parts instead of Content // because structured messages are matched by their parts payload. func messageMatches(a, b Message) bool { - if a.Role != b.Role || a.ReasoningContent != b.ReasoningContent { + if a.Role != b.Role || a.ReasoningContent != b.ReasoningContent || a.ModelName != b.ModelName { return false } return messageMatchesIgnoringReasoning(a, b) } func messageMatchesIgnoringReasoning(a, b Message) bool { + if a.ModelName != b.ModelName { + return false + } + return messageMatchesIgnoringReasoningAndModelName(a, b) +} + +func messageMatchesIgnoringReasoningAndModelName(a, b Message) bool { if a.Role != b.Role { return false } diff --git a/pkg/seahorse/short_engine_test.go b/pkg/seahorse/short_engine_test.go index 2a5c6c5d..337416f6 100644 --- a/pkg/seahorse/short_engine_test.go +++ b/pkg/seahorse/short_engine_test.go @@ -25,6 +25,43 @@ func newTestEngine(t *testing.T) *Engine { } } +func prepareBootstrapRepairConversation( + t *testing.T, + eng *Engine, + ctx context.Context, + sessionKey string, +) (*Conversation, []Message) { + t.Helper() + + conv, err := eng.store.GetOrCreateConversation(ctx, sessionKey) + if err != nil { + t.Fatalf("GetOrCreateConversation: %v", err) + } + + userMsg, err := eng.store.AddMessage(ctx, conv.ConversationID, "user", "hello", 3) + if err != nil { + t.Fatalf("AddMessage user: %v", err) + } + + assistantMsg, err := eng.store.AddMessage(ctx, conv.ConversationID, "assistant", "world", 3) + if err != nil { + t.Fatalf("AddMessage assistant: %v", err) + } + + if err := eng.store.AppendContextMessages( + ctx, + conv.ConversationID, + []int64{userMsg.ID, assistantMsg.ID}, + ); err != nil { + t.Fatalf("AppendContextMessages: %v", err) + } + + return conv, []Message{ + {Role: "user", Content: "hello", TokenCount: 3}, + {Role: "assistant", Content: "world", TokenCount: 3}, + } +} + // --- compileSessionPattern --- func TestCompileSessionPattern(t *testing.T) { @@ -328,6 +365,7 @@ func TestEngineIngestPreservesReasoningContent(t *testing.T) { { Role: "assistant", Content: "world", + ModelName: "gpt-5.4-mini", ReasoningContent: "let me think this through", TokenCount: 4, }, @@ -353,6 +391,9 @@ func TestEngineIngestPreservesReasoningContent(t *testing.T) { "let me think this through", ) } + if stored[0].ModelName != "gpt-5.4-mini" { + t.Errorf("stored[0].ModelName = %q, want %q", stored[0].ModelName, "gpt-5.4-mini") + } result, err := eng.Assemble(ctx, "agent:reasoning", AssembleInput{Budget: 1000}) if err != nil { @@ -368,6 +409,140 @@ func TestEngineIngestPreservesReasoningContent(t *testing.T) { "let me think this through", ) } + if result.Messages[0].ModelName != "gpt-5.4-mini" { + t.Errorf("assembled model_name = %q, want %q", result.Messages[0].ModelName, "gpt-5.4-mini") + } +} + +func TestBootstrapRepairsMissingModelName(t *testing.T) { + eng := newTestEngine(t) + ctx := context.Background() + sessionKey := "agent:repair-model-name" + conv, msgs := prepareBootstrapRepairConversation(t, eng, ctx, sessionKey) + msgs[1].ModelName = "gpt-5.4" + + err := eng.Bootstrap(ctx, sessionKey, msgs) + if err != nil { + t.Fatalf("Bootstrap: %v", err) + } + + stored, err := eng.store.GetMessages(ctx, conv.ConversationID, 10, 0) + if err != nil { + t.Fatalf("GetMessages: %v", err) + } + if len(stored) != 2 { + t.Fatalf("stored messages = %d, want 2", len(stored)) + } + if stored[1].ModelName != "gpt-5.4" { + t.Fatalf("stored[1].ModelName = %q, want %q", stored[1].ModelName, "gpt-5.4") + } +} + +func TestBootstrapRepairsReasoningContentAndModelNameTogether(t *testing.T) { + eng := newTestEngine(t) + ctx := context.Background() + sessionKey := "agent:repair-both-fields" + + conv, err := eng.store.GetOrCreateConversation(ctx, sessionKey) + if err != nil { + t.Fatalf("GetOrCreateConversation: %v", err) + } + + userMsg, err := eng.store.AddMessage(ctx, conv.ConversationID, "user", "hello", 3) + if err != nil { + t.Fatalf("AddMessage user: %v", err) + } + + assistantMsg, err := eng.store.AddMessage(ctx, conv.ConversationID, "assistant", "world", 3) + if err != nil { + t.Fatalf("AddMessage assistant: %v", err) + } + + err = eng.store.AppendContextMessages(ctx, conv.ConversationID, []int64{userMsg.ID, assistantMsg.ID}) + if err != nil { + t.Fatalf("AppendContextMessages: %v", err) + } + + err = eng.Bootstrap(ctx, sessionKey, []Message{ + {Role: "user", Content: "hello", TokenCount: 3}, + { + Role: "assistant", + Content: "world", + ModelName: "gpt-5.4", + ReasoningContent: "let me think this through", + TokenCount: 3, + }, + }) + if err != nil { + t.Fatalf("Bootstrap: %v", err) + } + + stored, err := eng.store.GetMessages(ctx, conv.ConversationID, 10, 0) + if err != nil { + t.Fatalf("GetMessages: %v", err) + } + if len(stored) != 2 { + t.Fatalf("stored messages = %d, want 2", len(stored)) + } + if stored[1].ReasoningContent != "let me think this through" { + t.Fatalf("stored[1].ReasoningContent = %q, want %q", stored[1].ReasoningContent, "let me think this through") + } + if stored[1].ModelName != "gpt-5.4" { + t.Fatalf("stored[1].ModelName = %q, want %q", stored[1].ModelName, "gpt-5.4") + } +} + +func TestBootstrapRepairsIncorrectNonEmptyModelName(t *testing.T) { + eng := newTestEngine(t) + ctx := context.Background() + sessionKey := "agent:repair-wrong-model-name" + + conv, err := eng.store.GetOrCreateConversation(ctx, sessionKey) + if err != nil { + t.Fatalf("GetOrCreateConversation: %v", err) + } + + userMsg, err := eng.store.AddMessage(ctx, conv.ConversationID, "user", "hello", 3) + if err != nil { + t.Fatalf("AddMessage user: %v", err) + } + + assistantMsg, err := eng.store.AddMessageWithReasoning( + ctx, + conv.ConversationID, + "assistant", + "world", + "wrong-model", + "", + 3, + ) + if err != nil { + t.Fatalf("AddMessageWithReasoning assistant: %v", err) + } + + err = eng.store.AppendContextMessages(ctx, conv.ConversationID, []int64{userMsg.ID, assistantMsg.ID}) + if err != nil { + t.Fatalf("AppendContextMessages: %v", err) + } + + err = eng.Bootstrap(ctx, sessionKey, []Message{ + {Role: "user", Content: "hello", TokenCount: 3}, + {Role: "assistant", Content: "world", ModelName: "gpt-5.4", TokenCount: 3}, + }) + if err != nil { + t.Fatalf("Bootstrap: %v", err) + } + + stored, err := eng.store.GetMessages(ctx, conv.ConversationID, 10, 0) + if err != nil { + t.Fatalf("GetMessages: %v", err) + } + if len(stored) != 2 { + t.Fatalf("stored messages = %d, want 2", len(stored)) + } + if stored[1].ModelName != "gpt-5.4" { + t.Fatalf("stored[1].ModelName = %q, want %q", stored[1].ModelName, "gpt-5.4") + } } func TestEngineIngestWithPartsPreservesReasoningContent(t *testing.T) { @@ -620,35 +795,10 @@ func TestBootstrapRepairsMissingReasoningContent(t *testing.T) { eng := newTestEngine(t) ctx := context.Background() sessionKey := "agent:repair-reasoning" + conv, msgs := prepareBootstrapRepairConversation(t, eng, ctx, sessionKey) + msgs[1].ReasoningContent = "let me think this through" - conv, err := eng.store.GetOrCreateConversation(ctx, sessionKey) - if err != nil { - t.Fatalf("GetOrCreateConversation: %v", err) - } - - userMsg, err := eng.store.AddMessage(ctx, conv.ConversationID, "user", "hello", 3) - if err != nil { - t.Fatalf("AddMessage user: %v", err) - } - - assistantMsg, err := eng.store.AddMessage(ctx, conv.ConversationID, "assistant", "world", 3) - if err != nil { - t.Fatalf("AddMessage assistant: %v", err) - } - - err = eng.store.AppendContextMessages( - ctx, - conv.ConversationID, - []int64{userMsg.ID, assistantMsg.ID}, - ) - if err != nil { - t.Fatalf("AppendContextMessages: %v", err) - } - - err = eng.Bootstrap(ctx, sessionKey, []Message{ - {Role: "user", Content: "hello", TokenCount: 3}, - {Role: "assistant", Content: "world", ReasoningContent: "let me think this through", TokenCount: 3}, - }) + err := eng.Bootstrap(ctx, sessionKey, msgs) if err != nil { t.Fatalf("Bootstrap: %v", err) } diff --git a/pkg/seahorse/store.go b/pkg/seahorse/store.go index 0edbbd12..b5e32e89 100644 --- a/pkg/seahorse/store.go +++ b/pkg/seahorse/store.go @@ -162,19 +162,25 @@ func (s *Store) getMessageTimeRange(ctx context.Context, convID int64) (time.Tim // AddMessage appends a message to a conversation. func (s *Store) AddMessage(ctx context.Context, convID int64, role, content string, tokenCount int) (*Message, error) { - return s.AddMessageWithReasoning(ctx, convID, role, content, "", tokenCount) + return s.AddMessageWithReasoning(ctx, convID, role, content, "", "", tokenCount) } // AddMessageWithReasoning appends a message with reasoning content to a conversation. func (s *Store) AddMessageWithReasoning( ctx context.Context, convID int64, - role, content, reasoningContent string, + role, content, modelName, reasoningContent string, tokenCount int, ) (*Message, error) { - result, err := s.db.ExecContext(ctx, - "INSERT INTO messages (conversation_id, role, content, reasoning_content, token_count) VALUES (?, ?, ?, ?, ?)", - convID, role, content, reasoningContent, tokenCount, + result, err := s.db.ExecContext( + ctx, + "INSERT INTO messages (conversation_id, role, content, model_name, reasoning_content, token_count) VALUES (?, ?, ?, ?, ?, ?)", + convID, + role, + content, + modelName, + reasoningContent, + tokenCount, ) if err != nil { return nil, fmt.Errorf("add message: %w", err) @@ -185,6 +191,7 @@ func (s *Store) AddMessageWithReasoning( ConversationID: convID, Role: role, Content: content, + ModelName: modelName, ReasoningContent: reasoningContent, TokenCount: tokenCount, }, nil @@ -224,7 +231,7 @@ func (s *Store) AddMessageWithParts( parts []MessagePart, tokenCount int, ) (*Message, error) { - return s.AddMessageWithPartsAndReasoning(ctx, convID, role, parts, "", tokenCount) + return s.AddMessageWithPartsAndReasoning(ctx, convID, role, parts, "", "", tokenCount) } // AddMessageWithPartsAndReasoning adds a message with structured parts and reasoning content. @@ -233,6 +240,7 @@ func (s *Store) AddMessageWithPartsAndReasoning( convID int64, role string, parts []MessagePart, + modelName string, reasoningContent string, tokenCount int, ) (*Message, error) { @@ -245,9 +253,15 @@ func (s *Store) AddMessageWithPartsAndReasoning( // Derive readable content from Parts for FTS5 indexing and summary formatting readableContent := partsToReadableContent(parts) - result, err := tx.ExecContext(ctx, - "INSERT INTO messages (conversation_id, role, content, reasoning_content, token_count) VALUES (?, ?, ?, ?, ?)", - convID, role, readableContent, reasoningContent, tokenCount, + result, err := tx.ExecContext( + ctx, + "INSERT INTO messages (conversation_id, role, content, model_name, reasoning_content, token_count) VALUES (?, ?, ?, ?, ?, ?)", + convID, + role, + readableContent, + modelName, + reasoningContent, + tokenCount, ) if err != nil { return nil, fmt.Errorf("add message: %w", err) @@ -282,6 +296,7 @@ func (s *Store) AddMessageWithPartsAndReasoning( ID: msgID, ConversationID: convID, Role: role, + ModelName: modelName, ReasoningContent: reasoningContent, TokenCount: tokenCount, Parts: make([]MessagePart, len(parts)), @@ -295,7 +310,7 @@ func (s *Store) AddMessageWithPartsAndReasoning( // GetMessages retrieves messages for a conversation. func (s *Store) GetMessages(ctx context.Context, convID int64, limit int, beforeID int64) ([]Message, error) { - query := "SELECT message_id, conversation_id, role, content, reasoning_content, token_count, created_at FROM messages WHERE conversation_id = ?" + query := "SELECT message_id, conversation_id, role, content, model_name, reasoning_content, token_count, created_at FROM messages WHERE conversation_id = ?" args := []any{convID} if beforeID > 0 { query += " AND message_id < ?" @@ -322,6 +337,7 @@ func (s *Store) GetMessages(ctx context.Context, convID int64, limit int, before &msg.ConversationID, &msg.Role, &msg.Content, + &msg.ModelName, &msg.ReasoningContent, &msg.TokenCount, &createdAt, @@ -362,9 +378,9 @@ func (s *Store) GetMessageByID(ctx context.Context, messageID int64) (*Message, var createdAt string err := s.db.QueryRowContext( ctx, - "SELECT message_id, conversation_id, role, content, reasoning_content, token_count, created_at FROM messages WHERE message_id = ?", + "SELECT message_id, conversation_id, role, content, model_name, reasoning_content, token_count, created_at FROM messages WHERE message_id = ?", messageID, - ).Scan(&msg.ID, &msg.ConversationID, &msg.Role, &msg.Content, &msg.ReasoningContent, &msg.TokenCount, &createdAt) + ).Scan(&msg.ID, &msg.ConversationID, &msg.Role, &msg.Content, &msg.ModelName, &msg.ReasoningContent, &msg.TokenCount, &createdAt) if err == sql.ErrNoRows { return nil, fmt.Errorf("message %d not found", messageID) } @@ -398,6 +414,27 @@ func (s *Store) UpdateMessageReasoningContent(ctx context.Context, messageID int return nil } +func (s *Store) UpdateMessageModelName(ctx context.Context, messageID int64, modelName string) error { + result, err := s.db.ExecContext( + ctx, + "UPDATE messages SET model_name = ? WHERE message_id = ?", + modelName, + messageID, + ) + if err != nil { + return fmt.Errorf("update message model_name: %w", err) + } + + rowsAffected, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("update message model_name rows affected: %w", err) + } + if rowsAffected == 0 { + return fmt.Errorf("message %d not found", messageID) + } + return nil +} + func (s *Store) loadMessageParts(ctx context.Context, msgID int64) ([]MessagePart, error) { rows, err := s.db.QueryContext(ctx, `SELECT part_id, message_id, type, text, name, arguments, tool_call_id, media_uri, mime_type @@ -581,8 +618,9 @@ func (s *Store) LinkSummaryToMessages(ctx context.Context, summaryID string, mes // GetSummarySourceMessages retrieves source messages for a summary. func (s *Store) GetSummarySourceMessages(ctx context.Context, summaryID string) ([]Message, error) { - rows, err := s.db.QueryContext(ctx, - `SELECT m.message_id, m.conversation_id, m.role, m.content, m.reasoning_content, m.token_count, m.created_at + rows, err := s.db.QueryContext( + ctx, + `SELECT m.message_id, m.conversation_id, m.role, m.content, m.model_name, m.reasoning_content, m.token_count, m.created_at FROM summary_messages sm JOIN messages m ON m.message_id = sm.message_id WHERE sm.summary_id = ? @@ -603,6 +641,7 @@ func (s *Store) GetSummarySourceMessages(ctx context.Context, summaryID string) &msg.ConversationID, &msg.Role, &msg.Content, + &msg.ModelName, &msg.ReasoningContent, &msg.TokenCount, &createdAt, diff --git a/pkg/seahorse/store_test.go b/pkg/seahorse/store_test.go index 67bed1c1..4ed2bb3b 100644 --- a/pkg/seahorse/store_test.go +++ b/pkg/seahorse/store_test.go @@ -210,6 +210,7 @@ func TestStoreAddAndGetMessagesWithReasoningContent(t *testing.T) { conv.ConversationID, "assistant", "hello world", + "gpt-5.4-mini", "let me think", 5, ) @@ -219,6 +220,9 @@ func TestStoreAddAndGetMessagesWithReasoningContent(t *testing.T) { if msg.ReasoningContent != "let me think" { t.Fatalf("ReasoningContent = %q, want %q", msg.ReasoningContent, "let me think") } + if msg.ModelName != "gpt-5.4-mini" { + t.Fatalf("ModelName = %q, want %q", msg.ModelName, "gpt-5.4-mini") + } msgs, err := s.GetMessages(ctx, conv.ConversationID, 10, 0) if err != nil { @@ -230,6 +234,9 @@ func TestStoreAddAndGetMessagesWithReasoningContent(t *testing.T) { if msgs[0].ReasoningContent != "let me think" { t.Errorf("ReasoningContent = %q, want %q", msgs[0].ReasoningContent, "let me think") } + if msgs[0].ModelName != "gpt-5.4-mini" { + t.Errorf("ModelName = %q, want %q", msgs[0].ModelName, "gpt-5.4-mini") + } found, err := s.GetMessageByID(ctx, msg.ID) if err != nil { @@ -238,6 +245,9 @@ func TestStoreAddAndGetMessagesWithReasoningContent(t *testing.T) { if found.ReasoningContent != "let me think" { t.Errorf("GetMessageByID ReasoningContent = %q, want %q", found.ReasoningContent, "let me think") } + if found.ModelName != "gpt-5.4-mini" { + t.Errorf("GetMessageByID ModelName = %q, want %q", found.ModelName, "gpt-5.4-mini") + } } func TestStoreAddMessageWithParts(t *testing.T) { @@ -288,6 +298,7 @@ func TestStoreAddMessageWithPartsAndReasoningContent(t *testing.T) { conv.ConversationID, "assistant", parts, + "gpt-5.4", "need to inspect the file first", 10, ) @@ -309,6 +320,9 @@ func TestStoreAddMessageWithPartsAndReasoningContent(t *testing.T) { "need to inspect the file first", ) } + if msgs[0].ModelName != "gpt-5.4" { + t.Errorf("ModelName = %q, want %q", msgs[0].ModelName, "gpt-5.4") + } } func TestStoreGetMessageCount(t *testing.T) { diff --git a/pkg/seahorse/types.go b/pkg/seahorse/types.go index 2bc7f931..af323d2b 100644 --- a/pkg/seahorse/types.go +++ b/pkg/seahorse/types.go @@ -22,6 +22,7 @@ type Message struct { ConversationID int64 `json:"conversationId"` Role string `json:"role"` Content string `json:"content"` + ModelName string `json:"modelName,omitempty"` ReasoningContent string `json:"reasoningContent,omitempty"` TokenCount int `json:"tokenCount"` CreatedAt time.Time `json:"createdAt"` @@ -135,6 +136,7 @@ func EstimateMessageTokens(msg Message) int { pm := providers.Message{ Role: msg.Role, Content: msg.Content, + ModelName: msg.ModelName, ReasoningContent: msg.ReasoningContent, } diff --git a/pkg/session/jsonl_backend_test.go b/pkg/session/jsonl_backend_test.go index 0b79ad84..6f67109d 100644 --- a/pkg/session/jsonl_backend_test.go +++ b/pkg/session/jsonl_backend_test.go @@ -66,6 +66,25 @@ func TestJSONLBackend_AddFullMessage(t *testing.T) { } } +func TestJSONLBackend_AddFullMessage_PreservesModelName(t *testing.T) { + b := newBackend(t) + + msg := providers.Message{ + Role: "assistant", + Content: "done", + ModelName: "gpt-5.4-mini", + } + b.AddFullMessage("s1", msg) + + history := b.GetHistory("s1") + if len(history) != 1 { + t.Fatalf("got %d, want 1", len(history)) + } + if history[0].ModelName != "gpt-5.4-mini" { + t.Fatalf("ModelName = %q, want %q", history[0].ModelName, "gpt-5.4-mini") + } +} + func TestJSONLBackend_Summary(t *testing.T) { b := newBackend(t) diff --git a/web/backend/api/session.go b/web/backend/api/session.go index cc18ee6e..e221386c 100644 --- a/web/backend/api/session.go +++ b/web/backend/api/session.go @@ -50,6 +50,7 @@ type sessionChatMessage struct { Role string `json:"role"` Content string `json:"content"` Kind string `json:"kind,omitempty"` + ModelName string `json:"model_name,omitempty"` Media []string `json:"media,omitempty"` Attachments []sessionChatAttachment `json:"attachments,omitempty"` ToolCalls []utils.VisibleToolCall `json:"tool_calls,omitempty"` @@ -510,6 +511,7 @@ func sessionTranscriptMessages( chatMsg := sessionChatMessage{ Role: "user", Content: msg.Content, + ModelName: msg.ModelName, Media: append([]string(nil), msg.Media...), Attachments: attachments, } @@ -529,9 +531,10 @@ func sessionTranscriptMessages( toolCallsMsg, hasToolCallsMsg := assistantToolCallsMessage( msg.ToolCalls, + msg.ModelName, toolFeedbackMaxArgsLength, ) - visibleToolMessages := visibleAssistantToolMessages(msg.ToolCalls) + visibleToolMessages := visibleAssistantToolMessages(msg.ToolCalls, msg.ModelName) // Pico web chat can persist both visible `message` tool output and a // later plain assistant reply in the same turn. Hide only the fixed @@ -556,6 +559,7 @@ func sessionTranscriptMessages( chatMsg := sessionChatMessage{ Role: "assistant", Content: content, + ModelName: msg.ModelName, Media: append([]string(nil), msg.Media...), Attachments: attachments, } @@ -682,14 +686,16 @@ func assistantThoughtMessage(msg providers.Message) (sessionChatMessage, bool) { return sessionChatMessage{}, false } return sessionChatMessage{ - Role: "assistant", - Content: reasoning, - Kind: "thought", + Role: "assistant", + Content: reasoning, + Kind: "thought", + ModelName: msg.ModelName, }, true } func assistantToolCallsMessage( toolCalls []providers.ToolCall, + modelName string, toolFeedbackMaxArgsLength int, ) (sessionChatMessage, bool) { if len(toolCalls) == 0 { @@ -707,6 +713,7 @@ func assistantToolCallsMessage( return sessionChatMessage{ Role: "assistant", Kind: "tool_calls", + ModelName: modelName, ToolCalls: visibleToolCalls, }, true } @@ -718,7 +725,7 @@ func visibleAssistantToolArgsPreview( return utils.VisibleToolCallArgumentsPreview(tc, toolFeedbackMaxArgsLength) } -func visibleAssistantToolMessages(toolCalls []providers.ToolCall) []sessionChatMessage { +func visibleAssistantToolMessages(toolCalls []providers.ToolCall, modelName string) []sessionChatMessage { if len(toolCalls) == 0 { return nil } @@ -734,8 +741,9 @@ func visibleAssistantToolMessages(toolCalls []providers.ToolCall) []sessionChatM continue } messages = append(messages, sessionChatMessage{ - Role: "assistant", - Content: content, + Role: "assistant", + Content: content, + ModelName: modelName, }) } diff --git a/web/backend/api/session_test.go b/web/backend/api/session_test.go index 760935db..8604f1df 100644 --- a/web/backend/api/session_test.go +++ b/web/backend/api/session_test.go @@ -564,7 +564,7 @@ func TestHandleGetSession_ReconstructsThoughtFromAssistantReasoningContent(t *te sessionKey := picoSessionPrefix + "detail-reasoning-content" for _, msg := range []providers.Message{ {Role: "user", Content: "hello"}, - {Role: "assistant", Content: "final visible answer", ReasoningContent: "internal chain of thought"}, + {Role: "assistant", Content: "final visible answer", ModelName: "gpt-5.4", ReasoningContent: "internal chain of thought"}, } { if err := store.AddFullMessage(nil, sessionKey, msg); err != nil { t.Fatalf("AddFullMessage() error = %v", err) @@ -597,9 +597,15 @@ func TestHandleGetSession_ReconstructsThoughtFromAssistantReasoningContent(t *te resp.Messages[1].Kind != "thought" { t.Fatalf("thought message = %#v, want assistant thought/internal chain of thought", resp.Messages[1]) } + if resp.Messages[1].ModelName != "gpt-5.4" { + t.Fatalf("thought model_name = %q, want %q", resp.Messages[1].ModelName, "gpt-5.4") + } if resp.Messages[2].Role != "assistant" || resp.Messages[2].Content != "final visible answer" { t.Fatalf("final message = %#v, want assistant/final visible answer", resp.Messages[2]) } + if resp.Messages[2].ModelName != "gpt-5.4" { + t.Fatalf("final model_name = %q, want %q", resp.Messages[2].ModelName, "gpt-5.4") + } } func TestHandleGetSession_ReconstructsRefreshMatrixForThoughtAndToolSummary(t *testing.T) { @@ -725,8 +731,9 @@ func TestHandleGetSession_ReconstructsVisibleMessageToolOutputWithoutDuplicateSu for _, msg := range []providers.Message{ {Role: "user", Content: "test"}, { - Role: "assistant", - Content: "", + Role: "assistant", + Content: "", + ModelName: "gpt-5.4-mini", ToolCalls: []providers.ToolCall{ { ID: "call_1", @@ -771,9 +778,15 @@ func TestHandleGetSession_ReconstructsVisibleMessageToolOutputWithoutDuplicateSu t.Fatalf("first message = %#v, want user/test", resp.Messages[0]) } assertVisibleToolCallMessage(t, resp.Messages[1], "message") + if resp.Messages[1].ModelName != "gpt-5.4-mini" { + t.Fatalf("tool_calls model_name = %q, want %q", resp.Messages[1].ModelName, "gpt-5.4-mini") + } if resp.Messages[2].Role != "assistant" || resp.Messages[2].Content != "visible tool output" { t.Fatalf("assistant message = %#v, want visible tool output", resp.Messages[2]) } + if resp.Messages[2].ModelName != "gpt-5.4-mini" { + t.Fatalf("visible tool output model_name = %q, want %q", resp.Messages[2].ModelName, "gpt-5.4-mini") + } } func TestHandleGetSession_PreservesFinalAssistantReplyAfterMessageToolOutput(t *testing.T) { diff --git a/web/frontend/src/api/sessions.ts b/web/frontend/src/api/sessions.ts index edd7d7c2..002a3c5d 100644 --- a/web/frontend/src/api/sessions.ts +++ b/web/frontend/src/api/sessions.ts @@ -15,6 +15,7 @@ export interface SessionDetail { role: "user" | "assistant" content: string kind?: "normal" | "thought" | "tool_calls" + model_name?: string media?: string[] attachments?: { type?: "image" | "audio" | "video" | "file" diff --git a/web/frontend/src/components/chat/assistant-message.tsx b/web/frontend/src/components/chat/assistant-message.tsx index 3857c674..c8d0480f 100644 --- a/web/frontend/src/components/chat/assistant-message.tsx +++ b/web/frontend/src/components/chat/assistant-message.tsx @@ -33,6 +33,7 @@ interface AssistantMessageProps { content: string attachments?: ChatAttachment[] kind?: AssistantMessageKind + modelName?: string toolCalls?: ChatToolCall[] timestamp?: string | number } @@ -41,6 +42,7 @@ export function AssistantMessage({ content, attachments = [], kind = "normal", + modelName, toolCalls = [], timestamp = "", }: AssistantMessageProps) { @@ -66,13 +68,20 @@ export function AssistantMessage({ const copyMessageLabel = isCopied ? t("chat.copiedLabel") : t("chat.copyMessage") + const trimmedModelName = modelName?.trim() ?? "" return (
{!isCollapsedBlock && ( -
+
PicoClaw + {trimmedModelName && ( + <> + • + {trimmedModelName} + + )} {formattedTimestamp && ( <> • @@ -104,6 +113,9 @@ export function AssistantMessage({ )} {collapsedLabel} + {trimmedModelName && ( + {trimmedModelName} + )}
diff --git a/web/frontend/src/features/chat/history.ts b/web/frontend/src/features/chat/history.ts index 9fc35bc1..72e9ff33 100644 --- a/web/frontend/src/features/chat/history.ts +++ b/web/frontend/src/features/chat/history.ts @@ -50,6 +50,7 @@ export async function loadSessionMessages( role: message.role, content: message.content, kind: message.role === "assistant" ? (message.kind ?? "normal") : undefined, + modelName: message.model_name, toolCalls: message.role === "assistant" ? parseToolCallsValue(message.tool_calls) @@ -86,7 +87,7 @@ function messageSignature(message: ChatMessage): string { return `${message.role}\u0000${message.content}\u0000${normalizeMessageTimestamp( message.timestamp, - )}\u0000${message.kind ?? ""}\u0000${attachmentSignature}\u0000${toolCallsSignature( + )}\u0000${message.kind ?? ""}\u0000${message.modelName ?? ""}\u0000${attachmentSignature}\u0000${toolCallsSignature( message.toolCalls, )}` } diff --git a/web/frontend/src/features/chat/protocol.ts b/web/frontend/src/features/chat/protocol.ts index 04cc924b..b372b235 100644 --- a/web/frontend/src/features/chat/protocol.ts +++ b/web/frontend/src/features/chat/protocol.ts @@ -83,6 +83,14 @@ function parseContextUsage( } } +function parseModelName(payload: Record): string | undefined { + if (typeof payload.model_name !== "string") { + return undefined + } + const modelName = payload.model_name.trim() + return modelName || undefined +} + export function handlePicoMessage( message: PicoMessage, expectedSessionId: string, @@ -102,6 +110,7 @@ export function handlePicoMessage( const attachments = parseAttachments(payload) const contextUsage = parseContextUsage(payload) const isPlaceholder = payload.placeholder === true + const modelName = parseModelName(payload) const timestamp = message.timestamp !== undefined && Number.isFinite(Number(message.timestamp)) @@ -116,6 +125,7 @@ export function handlePicoMessage( role: "assistant", content, kind, + ...(modelName ? { modelName } : {}), ...(toolCalls ? { toolCalls } : {}), attachments, timestamp, @@ -135,6 +145,7 @@ export function handlePicoMessage( const messageId = payload.message_id as string const attachments = parseAttachments(payload) const contextUsage = parseContextUsage(payload) + const modelName = parseModelName(payload) const timestamp = message.timestamp !== undefined && Number.isFinite(Number(message.timestamp)) @@ -160,6 +171,7 @@ export function handlePicoMessage( content, kind, toolCalls, + ...(modelName ? { modelName } : {}), ...(attachments ? { attachments } : {}), } }) @@ -178,6 +190,7 @@ export function handlePicoMessage( content, kind, toolCalls, + ...(modelName ? { modelName } : {}), ...(attachments ? { attachments } : {}), timestamp, }, diff --git a/web/frontend/src/store/chat.ts b/web/frontend/src/store/chat.ts index aa5f0849..056d5601 100644 --- a/web/frontend/src/store/chat.ts +++ b/web/frontend/src/store/chat.ts @@ -44,6 +44,7 @@ export interface ChatMessage { content: string timestamp: number | string kind?: AssistantMessageKind + modelName?: string attachments?: ChatAttachment[] toolCalls?: ChatToolCall[] } From e7e21df354cfd3137acb85b2e922b85bb8fe6980 Mon Sep 17 00:00:00 2001 From: lxowalle <83055338+lxowalle@users.noreply.github.com> Date: Thu, 21 May 2026 11:07:39 +0800 Subject: [PATCH 07/20] fix(agent): honor explicit thinking off (#2898) * fix(agent): honor explicit thinking off * fix(agent): address thinking off lint failures * Clarify unset thinking level display * fix ci --- pkg/agent/agent_command.go | 1 + pkg/agent/agent_test.go | 867 ++++++++++++++++++ pkg/agent/agent_utils.go | 1 + pkg/agent/instance.go | 3 + pkg/agent/pipeline_llm.go | 37 +- pkg/agent/pipeline_streaming.go | 2 +- pkg/agent/pipeline_streaming_test.go | 40 + pkg/agent/thinking.go | 92 +- pkg/agent/turn_coord.go | 72 +- pkg/agent/turn_state.go | 1 + pkg/providers/fallback.go | 18 +- pkg/providers/openai_compat/provider.go | 77 ++ pkg/providers/openai_compat/provider_test.go | 63 ++ .../src/components/models/add-model-sheet.tsx | 2 +- .../components/models/edit-model-sheet.tsx | 2 +- web/frontend/src/i18n/locales/en.json | 3 +- web/frontend/src/i18n/locales/pt-br.json | 3 +- web/frontend/src/i18n/locales/zh.json | 3 +- 18 files changed, 1243 insertions(+), 44 deletions(-) diff --git a/pkg/agent/agent_command.go b/pkg/agent/agent_command.go index ae0293d7..944c58f3 100644 --- a/pkg/agent/agent_command.go +++ b/pkg/agent/agent_command.go @@ -319,6 +319,7 @@ func (al *AgentLoop) buildCommandsRuntime( agent.Provider = nextProvider agent.Candidates = nextCandidates agent.ThinkingLevel = parseThinkingLevel(modelCfg.ThinkingLevel) + agent.ThinkingLevelConfigured = isConfiguredThinkingLevel(modelCfg.ThinkingLevel) if oldProvider != nil && oldProvider != nextProvider { if stateful, ok := oldProvider.(providers.StatefulProvider); ok { diff --git a/pkg/agent/agent_test.go b/pkg/agent/agent_test.go index aaf3d1a8..fe775656 100644 --- a/pkg/agent/agent_test.go +++ b/pkg/agent/agent_test.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "fmt" + "maps" "net/http" "net/http/httptest" "os" @@ -138,6 +139,121 @@ func (r *recordingProvider) GetDefaultModel() string { return "mock-model" } +type thinkingRecordingProvider struct { + lastOptions map[string]any +} + +func (r *thinkingRecordingProvider) Chat( + ctx context.Context, + messages []providers.Message, + tools []providers.ToolDefinition, + model string, + opts map[string]any, +) (*providers.LLMResponse, error) { + r.lastOptions = maps.Clone(opts) + return &providers.LLMResponse{ + Content: "Mock response", + ToolCalls: []providers.ToolCall{}, + }, nil +} + +func (r *thinkingRecordingProvider) GetDefaultModel() string { + return "mock-model" +} + +func (r *thinkingRecordingProvider) SupportsThinking() bool { + return true +} + +type thinkingOptionRecordingProvider struct { + lastOptions map[string]any +} + +func (r *thinkingOptionRecordingProvider) Chat( + ctx context.Context, + messages []providers.Message, + tools []providers.ToolDefinition, + model string, + opts map[string]any, +) (*providers.LLMResponse, error) { + r.lastOptions = maps.Clone(opts) + return &providers.LLMResponse{ + Content: "Mock response", + ToolCalls: []providers.ToolCall{}, + }, nil +} + +func (r *thinkingOptionRecordingProvider) GetDefaultModel() string { + return "mock-model" +} + +type reasoningOptionRecordingProvider struct { + lastOptions map[string]any +} + +func (r *reasoningOptionRecordingProvider) Chat( + ctx context.Context, + messages []providers.Message, + tools []providers.ToolDefinition, + model string, + opts map[string]any, +) (*providers.LLMResponse, error) { + r.lastOptions = maps.Clone(opts) + return &providers.LLMResponse{ + Content: "final answer", + ReasoningContent: "thinking trace", + ToolCalls: []providers.ToolCall{}, + }, nil +} + +func (r *reasoningOptionRecordingProvider) GetDefaultModel() string { + return "mock-model" +} + +type reasoningResponseProvider struct{} + +func (p *reasoningResponseProvider) Chat( + ctx context.Context, + messages []providers.Message, + tools []providers.ToolDefinition, + model string, + opts map[string]any, +) (*providers.LLMResponse, error) { + return &providers.LLMResponse{ + Content: "Mock response", + ReasoningContent: "thinking trace", + ToolCalls: []providers.ToolCall{}, + }, nil +} + +func (p *reasoningResponseProvider) GetDefaultModel() string { + return "mock-model" +} + +type sideQuestionFallbackTestProvider struct { + model string +} + +func (p *sideQuestionFallbackTestProvider) Chat( + ctx context.Context, + messages []providers.Message, + tools []providers.ToolDefinition, + model string, + opts map[string]any, +) (*providers.LLMResponse, error) { + if p.model == "test-model" { + return nil, context.DeadlineExceeded + } + return &providers.LLMResponse{ + ReasoningContent: "thinking trace", + ToolCalls: []providers.ToolCall{}, + }, nil +} + +func (p *sideQuestionFallbackTestProvider) GetDefaultModel() string { + return p.model +} + type modelRewriteHook struct { model string } @@ -386,6 +502,463 @@ func TestProcessMessage_IncludesCurrentSenderInDynamicContext(t *testing.T) { } } +func TestProcessMessage_DoesNotPassImplicitThinkingOffToCapableProvider(t *testing.T) { + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: t.TempDir(), + ModelName: "test-model", + MaxTokens: 4096, + MaxToolIterations: 10, + }, + }, + } + + provider := &thinkingRecordingProvider{} + al := NewAgentLoop(cfg, bus.NewMessageBus(), provider) + + response, err := al.processMessage(context.Background(), testInboundMessage(bus.InboundMessage{ + Channel: "pico", + ChatID: "chat-1", + Content: "hello", + })) + if err != nil { + t.Fatalf("processMessage() error = %v", err) + } + if response != "Mock response" { + t.Fatalf("processMessage() response = %q, want %q", response, "Mock response") + } + if _, ok := provider.lastOptions["thinking_level"]; ok { + t.Fatalf("thinking_level option should be omitted when unset, got %#v", provider.lastOptions["thinking_level"]) + } +} + +func TestProcessMessage_PassesExplicitThinkingOffToCapableProvider(t *testing.T) { + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: t.TempDir(), + ModelName: "test-model", + MaxTokens: 4096, + MaxToolIterations: 10, + }, + }, + ModelList: []*config.ModelConfig{{ + ModelName: "test-model", + Model: "test-model", + ThinkingLevel: "off", + }}, + } + + provider := &thinkingRecordingProvider{} + al := NewAgentLoop(cfg, bus.NewMessageBus(), provider) + + response, err := al.processMessage(context.Background(), testInboundMessage(bus.InboundMessage{ + Channel: "pico", + ChatID: "chat-1", + Content: "hello", + })) + if err != nil { + t.Fatalf("processMessage() error = %v", err) + } + if response != "Mock response" { + t.Fatalf("processMessage() response = %q, want %q", response, "Mock response") + } + if got := provider.lastOptions["thinking_level"]; got != "off" { + t.Fatalf("thinking_level option = %#v, want %q", got, "off") + } +} + +func TestProcessMessage_PassesExplicitThinkingOffToProviderWithoutThinkingCapability(t *testing.T) { + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: t.TempDir(), + ModelName: "test-model", + MaxTokens: 4096, + MaxToolIterations: 10, + }, + }, + ModelList: []*config.ModelConfig{{ + ModelName: "test-model", + Model: "test-model", + ThinkingLevel: "off", + }}, + } + + provider := &thinkingOptionRecordingProvider{} + al := NewAgentLoop(cfg, bus.NewMessageBus(), provider) + + response, err := al.processMessage(context.Background(), testInboundMessage(bus.InboundMessage{ + Channel: "pico", + ChatID: "chat-1", + Content: "hello", + })) + if err != nil { + t.Fatalf("processMessage() error = %v", err) + } + if response != "Mock response" { + t.Fatalf("processMessage() response = %q, want %q", response, "Mock response") + } + if got := provider.lastOptions["thinking_level"]; got != "off" { + t.Fatalf("thinking_level option = %#v, want %q", got, "off") + } +} + +func TestProcessMessage_SuppressesReasoningWhenThinkingOff(t *testing.T) { + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: t.TempDir(), + ModelName: "test-model", + MaxTokens: 4096, + MaxToolIterations: 10, + }, + }, + ModelList: []*config.ModelConfig{{ + ModelName: "test-model", + Model: "test-model", + ThinkingLevel: "off", + }}, + } + + msgBus := bus.NewMessageBus() + al := NewAgentLoop(cfg, msgBus, &reasoningResponseProvider{}) + + response, err := al.runAgentLoop( + context.Background(), + al.GetRegistry().GetDefaultAgent(), + processOptions{ + SessionKey: "agent:main:pico:chat-1", + Channel: "pico", + ChatID: "chat-1", + UserMessage: "hello", + SendResponse: false, + DefaultResponse: defaultResponse, + NoHistory: true, + }, + ) + if err != nil { + t.Fatalf("runAgentLoop() error = %v", err) + } + if response != "Mock response" { + t.Fatalf("response = %q, want %q", response, "Mock response") + } + select { + case outbound := <-msgBus.OutboundChan(): + t.Fatalf("expected no reasoning outbound when thinking is off, got %+v", outbound) + case <-time.After(50 * time.Millisecond): + } +} + +func TestProcessMessage_BeforeLLMModelRewriteReevaluatesThinkingOff(t *testing.T) { + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: t.TempDir(), + ModelName: "plain-model", + MaxTokens: 4096, + MaxToolIterations: 10, + }, + }, + ModelList: []*config.ModelConfig{ + { + ModelName: "plain-model", + Model: "openai/plain-model", + }, + { + ModelName: "off-model", + Model: "openai/off-model", + ThinkingLevel: "off", + }, + }, + } + + msgBus := bus.NewMessageBus() + provider := &reasoningOptionRecordingProvider{} + al := NewAgentLoop(cfg, msgBus, provider) + if err := al.MountHook(NamedHook("rewrite-model", modelRewriteHook{model: "off-model"})); err != nil { + t.Fatalf("MountHook failed: %v", err) + } + + response, err := al.processMessage(context.Background(), bus.InboundMessage{ + Channel: "pico", + SenderID: "user1", + ChatID: "pico:test-session", + Content: "hello", + }) + if err != nil { + t.Fatalf("processMessage() error = %v", err) + } + if response != "final answer" { + t.Fatalf("processMessage() response = %q, want final answer", response) + } + if got := provider.lastOptions["thinking_level"]; got != "off" { + t.Fatalf("thinking_level option = %#v, want off after hook model rewrite", got) + } + select { + case outbound := <-msgBus.OutboundChan(): + t.Fatalf("expected no reasoning outbound after hook rewrote to off model, got %+v", outbound) + case <-time.After(50 * time.Millisecond): + } +} + +func TestProcessMessage_BeforeLLMModelRewriteDoesNotLeakThinkingOff(t *testing.T) { + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: t.TempDir(), + ModelName: "off-model", + MaxTokens: 4096, + MaxToolIterations: 10, + }, + }, + ModelList: []*config.ModelConfig{ + { + ModelName: "off-model", + Model: "openai/off-model", + ThinkingLevel: "off", + }, + { + ModelName: "plain-model", + Model: "openai/plain-model", + }, + }, + } + + msgBus := bus.NewMessageBus() + provider := &reasoningOptionRecordingProvider{} + al := NewAgentLoop(cfg, msgBus, provider) + if err := al.MountHook(NamedHook("rewrite-model", modelRewriteHook{model: "plain-model"})); err != nil { + t.Fatalf("MountHook failed: %v", err) + } + + response, err := al.processMessage(context.Background(), bus.InboundMessage{ + Channel: "pico", + SenderID: "user1", + ChatID: "pico:test-session", + Content: "hello", + }) + if err != nil { + t.Fatalf("processMessage() error = %v", err) + } + if response != "final answer" { + t.Fatalf("processMessage() response = %q, want final answer", response) + } + if _, ok := provider.lastOptions["thinking_level"]; ok { + t.Fatalf( + "thinking_level option should be cleared after hook rewrote away from off model, got %#v", + provider.lastOptions["thinking_level"], + ) + } + select { + case outbound := <-msgBus.OutboundChan(): + if outbound.Content != "thinking trace" { + t.Fatalf("reasoning outbound content = %q, want thinking trace", outbound.Content) + } + case <-time.After(3 * time.Second): + t.Fatal("expected reasoning outbound after hook rewrote away from off model") + } +} + +func TestProcessMessage_BtwCommandSuppressesReasoningWhenThinkingOff(t *testing.T) { + tmpDir := t.TempDir() + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: tmpDir, + ModelName: "test-model", + MaxTokens: 4096, + MaxToolIterations: 10, + }, + }, + ModelList: []*config.ModelConfig{{ + ModelName: "test-model", + Model: "openai/test-model", + ThinkingLevel: "off", + }}, + } + + al := NewAgentLoop(cfg, bus.NewMessageBus(), &sideQuestionFallbackTestProvider{model: "test-model"}) + al.providerFactory = func(mc *config.ModelConfig) (providers.LLMProvider, string, error) { + model := "" + if mc != nil { + _, model = providers.ExtractProtocol(mc) + } + if model == "" { + model = "test-model" + } + return &sideQuestionFallbackTestProvider{model: model}, model, nil + } + + response, err := al.processMessage(context.Background(), bus.InboundMessage{ + Channel: "telegram", + SenderID: "telegram:123", + ChatID: "chat-1", + Content: "/btw explain privately", + }) + if err != nil { + t.Fatalf("processMessage() error = %v", err) + } + if strings.Contains(response, "thinking trace") { + t.Fatalf("processMessage() response = %q, should not expose reasoning with thinking off", response) + } +} + +func TestProcessMessage_BtwHookModelRewriteReevaluatesThinkingOff(t *testing.T) { + tmpDir := t.TempDir() + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: tmpDir, + ModelName: "plain-model", + MaxTokens: 4096, + MaxToolIterations: 10, + }, + }, + ModelList: []*config.ModelConfig{ + { + ModelName: "plain-model", + Model: "openai/plain-model", + }, + { + ModelName: "off-model", + Model: "openai/off-model", + ThinkingLevel: "off", + }, + }, + } + + al := NewAgentLoop(cfg, bus.NewMessageBus(), &sideQuestionFallbackTestProvider{model: "plain-model"}) + al.providerFactory = func(mc *config.ModelConfig) (providers.LLMProvider, string, error) { + model := "" + if mc != nil { + _, model = providers.ExtractProtocol(mc) + } + if model == "" { + model = "plain-model" + } + return &sideQuestionFallbackTestProvider{model: model}, model, nil + } + if err := al.MountHook(NamedHook("rewrite-model", modelRewriteHook{model: "off-model"})); err != nil { + t.Fatalf("MountHook failed: %v", err) + } + + response, err := al.processMessage(context.Background(), bus.InboundMessage{ + Channel: "telegram", + SenderID: "telegram:123", + ChatID: "chat-1", + Content: "/btw explain privately", + }) + if err != nil { + t.Fatalf("processMessage() error = %v", err) + } + if strings.Contains(response, "thinking trace") { + t.Fatalf( + "processMessage() response = %q, should not expose reasoning after hook rewrote to off model", + response, + ) + } +} + +func TestProcessMessage_BtwHookModelRewriteDoesNotLeakThinkingOff(t *testing.T) { + tmpDir := t.TempDir() + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: tmpDir, + ModelName: "off-model", + MaxTokens: 4096, + MaxToolIterations: 10, + }, + }, + ModelList: []*config.ModelConfig{ + { + ModelName: "off-model", + Model: "openai/off-model", + ThinkingLevel: "off", + }, + { + ModelName: "plain-model", + Model: "openai/plain-model", + }, + }, + } + + al := NewAgentLoop(cfg, bus.NewMessageBus(), &sideQuestionFallbackTestProvider{model: "off-model"}) + al.providerFactory = func(mc *config.ModelConfig) (providers.LLMProvider, string, error) { + model := "" + if mc != nil { + _, model = providers.ExtractProtocol(mc) + } + if model == "" { + model = "off-model" + } + return &sideQuestionFallbackTestProvider{model: model}, model, nil + } + if err := al.MountHook(NamedHook("rewrite-model", modelRewriteHook{model: "plain-model"})); err != nil { + t.Fatalf("MountHook failed: %v", err) + } + + response, err := al.processMessage(context.Background(), bus.InboundMessage{ + Channel: "telegram", + SenderID: "telegram:123", + ChatID: "chat-1", + Content: "/btw explain privately", + }) + if err != nil { + t.Fatalf("processMessage() error = %v", err) + } + if response != "thinking trace" { + t.Fatalf("processMessage() response = %q, want reasoning after hook rewrote away from off model", response) + } +} + +func TestProcessMessage_BtwFallbackDoesNotInheritPrimaryThinkingOff(t *testing.T) { + tmpDir := t.TempDir() + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: tmpDir, + ModelName: "test-model", + ModelFallbacks: []string{"openai/fallback-model"}, + MaxTokens: 4096, + MaxToolIterations: 10, + }, + }, + ModelList: []*config.ModelConfig{{ + ModelName: "test-model", + Model: "openai/test-model", + ThinkingLevel: "off", + }}, + } + + al := NewAgentLoop(cfg, bus.NewMessageBus(), &sideQuestionFallbackTestProvider{model: "test-model"}) + al.providerFactory = func(mc *config.ModelConfig) (providers.LLMProvider, string, error) { + model := "" + if mc != nil { + _, model = providers.ExtractProtocol(mc) + } + if model == "" { + model = "test-model" + } + return &sideQuestionFallbackTestProvider{model: model}, model, nil + } + + response, err := al.processMessage(context.Background(), bus.InboundMessage{ + Channel: "telegram", + SenderID: "telegram:123", + ChatID: "chat-1", + Content: "/btw explain fallback reasoning", + }) + if err != nil { + t.Fatalf("processMessage() error = %v", err) + } + if response != "thinking trace" { + t.Fatalf("processMessage() response = %q, want fallback reasoning when fallback has no off", response) + } +} + func TestProcessMessage_UseCommandLoadsRequestedSkill(t *testing.T) { tmpDir := t.TempDir() skillDir := filepath.Join(tmpDir, "skills", "shell") @@ -3178,6 +3751,300 @@ func TestProcessMessage_FallbackUsesPerCandidateProvider(t *testing.T) { } } +func TestProcessMessage_FallbackReceivesExplicitThinkingOff(t *testing.T) { + workspace := t.TempDir() + + primaryServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusTooManyRequests) + _ = json.NewEncoder(w).Encode(map[string]any{ + "error": map[string]any{ + "message": "rate limit exceeded", + "type": "rate_limit_error", + }, + }) + })) + defer primaryServer.Close() + + fallbackCalls := 0 + fallbackServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fallbackCalls++ + if r.URL.Path != "/chat/completions" { + t.Fatalf("fallback server path = %q, want /chat/completions", r.URL.Path) + } + defer r.Body.Close() + + var req map[string]any + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + t.Fatalf("decode fallback request: %v", err) + } + if got := req["model"]; got != "doubao-seed-1-6-flash-250828" { + t.Fatalf("fallback request model = %#v, want doubao-seed-1-6-flash-250828", got) + } + thinking, ok := req["thinking"].(map[string]any) + if !ok { + t.Fatalf("fallback request thinking = %#v, want map", req["thinking"]) + } + if got := thinking["type"]; got != "disabled" { + t.Fatalf("fallback request thinking.type = %#v, want disabled", got) + } + + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(map[string]any{ + "choices": []map[string]any{ + { + "message": map[string]any{"content": "fallback reply"}, + "finish_reason": "stop", + }, + }, + }); err != nil { + t.Fatalf("encode fallback response: %v", err) + } + })) + defer fallbackServer.Close() + + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: workspace, + ModelName: "primary-model", + ModelFallbacks: []string{"doubao-fallback"}, + MaxTokens: 4096, + MaxToolIterations: 3, + }, + }, + ModelList: []*config.ModelConfig{ + { + ModelName: "primary-model", + Model: "openrouter/primary-model", + APIBase: primaryServer.URL, + APIKeys: config.SimpleSecureStrings("primary-key"), + Workspace: workspace, + }, + { + ModelName: "doubao-fallback", + Model: "openai/doubao-seed-1-6-flash-250828", + APIBase: fallbackServer.URL, + APIKeys: config.SimpleSecureStrings("fallback-key"), + ThinkingLevel: "off", + Workspace: workspace, + }, + }, + } + + provider, _, err := providers.CreateProvider(cfg) + if err != nil { + t.Fatalf("CreateProvider() error = %v", err) + } + al := NewAgentLoop(cfg, bus.NewMessageBus(), provider) + helper := testHelper{al: al} + + resp := helper.executeAndGetResponse(t, context.Background(), bus.InboundMessage{ + Channel: "telegram", + SenderID: "user1", + ChatID: "chat1", + Content: "hi", + }) + + if resp != "fallback reply" { + t.Fatalf("response = %q, want fallback reply", resp) + } + if fallbackCalls != 1 { + t.Fatalf("fallback server calls = %d, want 1", fallbackCalls) + } +} + +func TestProcessMessage_PrimaryThinkingOffDoesNotLeakToFallback(t *testing.T) { + workspace := t.TempDir() + + primaryServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusTooManyRequests) + _ = json.NewEncoder(w).Encode(map[string]any{ + "error": map[string]any{ + "message": "rate limit exceeded", + "type": "rate_limit_error", + }, + }) + })) + defer primaryServer.Close() + + fallbackServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/chat/completions" { + t.Fatalf("fallback server path = %q, want /chat/completions", r.URL.Path) + } + defer r.Body.Close() + + var req map[string]any + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + t.Fatalf("decode fallback request: %v", err) + } + if _, ok := req["thinking"]; ok { + t.Fatalf("fallback request should not inherit primary thinking off, got thinking=%#v", req["thinking"]) + } + + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(map[string]any{ + "choices": []map[string]any{ + { + "message": map[string]any{"content": "fallback reply"}, + "finish_reason": "stop", + }, + }, + }); err != nil { + t.Fatalf("encode fallback response: %v", err) + } + })) + defer fallbackServer.Close() + + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: workspace, + ModelName: "primary-model", + ModelFallbacks: []string{"doubao-fallback"}, + MaxTokens: 4096, + MaxToolIterations: 3, + }, + }, + ModelList: []*config.ModelConfig{ + { + ModelName: "primary-model", + Model: "openrouter/primary-model", + APIBase: primaryServer.URL, + APIKeys: config.SimpleSecureStrings("primary-key"), + ThinkingLevel: "off", + Workspace: workspace, + }, + { + ModelName: "doubao-fallback", + Model: "openai/doubao-seed-1-6-flash-250828", + APIBase: fallbackServer.URL, + APIKeys: config.SimpleSecureStrings("fallback-key"), + Workspace: workspace, + }, + }, + } + + provider, _, err := providers.CreateProvider(cfg) + if err != nil { + t.Fatalf("CreateProvider() error = %v", err) + } + al := NewAgentLoop(cfg, bus.NewMessageBus(), provider) + helper := testHelper{al: al} + + resp := helper.executeAndGetResponse(t, context.Background(), bus.InboundMessage{ + Channel: "telegram", + SenderID: "user1", + ChatID: "chat1", + Content: "hi", + }) + if resp != "fallback reply" { + t.Fatalf("response = %q, want fallback reply", resp) + } +} + +func TestProcessMessage_FallbackThinkingOffUsesCandidateIdentity(t *testing.T) { + workspace := t.TempDir() + + primaryServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusTooManyRequests) + _ = json.NewEncoder(w).Encode(map[string]any{ + "error": map[string]any{ + "message": "rate limit exceeded", + "type": "rate_limit_error", + }, + }) + })) + defer primaryServer.Close() + + fallbackServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/chat/completions" { + t.Fatalf("fallback server path = %q, want /chat/completions", r.URL.Path) + } + defer r.Body.Close() + + var req map[string]any + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + t.Fatalf("decode fallback request: %v", err) + } + thinking, ok := req["thinking"].(map[string]any) + if !ok { + t.Fatalf("fallback request thinking = %#v, want map", req["thinking"]) + } + if got := thinking["type"]; got != "disabled" { + t.Fatalf("fallback request thinking.type = %#v, want disabled", got) + } + + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(map[string]any{ + "choices": []map[string]any{ + { + "message": map[string]any{"content": "fallback reply"}, + "finish_reason": "stop", + }, + }, + }); err != nil { + t.Fatalf("encode fallback response: %v", err) + } + })) + defer fallbackServer.Close() + + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: workspace, + ModelName: "primary-model", + ModelFallbacks: []string{"doubao-off"}, + MaxTokens: 4096, + MaxToolIterations: 3, + }, + }, + ModelList: []*config.ModelConfig{ + { + ModelName: "primary-model", + Model: "openrouter/primary-model", + APIBase: primaryServer.URL, + APIKeys: config.SimpleSecureStrings("primary-key"), + Workspace: workspace, + }, + { + ModelName: "doubao-default", + Model: "openai/doubao-seed-1-6-flash-250828", + APIBase: fallbackServer.URL, + APIKeys: config.SimpleSecureStrings("fallback-key"), + Workspace: workspace, + }, + { + ModelName: "doubao-off", + Model: "openai/doubao-seed-1-6-flash-250828", + APIBase: fallbackServer.URL, + APIKeys: config.SimpleSecureStrings("fallback-key"), + ThinkingLevel: "off", + Workspace: workspace, + }, + }, + } + + provider, _, err := providers.CreateProvider(cfg) + if err != nil { + t.Fatalf("CreateProvider() error = %v", err) + } + al := NewAgentLoop(cfg, bus.NewMessageBus(), provider) + helper := testHelper{al: al} + + resp := helper.executeAndGetResponse(t, context.Background(), bus.InboundMessage{ + Channel: "telegram", + SenderID: "user1", + ChatID: "chat1", + Content: "hi", + }) + if resp != "fallback reply" { + t.Fatalf("response = %q, want fallback reply", resp) + } +} + // TestProcessMessage_FallbackUsesActiveProviderWhenCandidateNotRegistered verifies // that when a candidate has no model_list entry it is absent from CandidateProviders // and the fallback closure falls back to activeProvider instead of panicking. diff --git a/pkg/agent/agent_utils.go b/pkg/agent/agent_utils.go index 432a9f24..8c638deb 100644 --- a/pkg/agent/agent_utils.go +++ b/pkg/agent/agent_utils.go @@ -560,6 +560,7 @@ func sideQuestionModelName(agent *AgentInstance, usedLight bool) string { } func modelNameFromIdentityKey(identityKey string) string { + identityKey = strings.TrimSpace(identityKey) if identityKey == "" { return "" } diff --git a/pkg/agent/instance.go b/pkg/agent/instance.go index 4ed71303..68150ce7 100644 --- a/pkg/agent/instance.go +++ b/pkg/agent/instance.go @@ -31,6 +31,7 @@ type AgentInstance struct { MaxTokens int Temperature float64 ThinkingLevel ThinkingLevel + ThinkingLevelConfigured bool ContextWindow int SummarizeMessageThreshold int SummarizeTokenPercent int @@ -184,6 +185,7 @@ func NewAgentInstance( thinkingLevelStr = mc.ThinkingLevel } thinkingLevel := parseThinkingLevel(thinkingLevelStr) + thinkingLevelConfigured := isConfiguredThinkingLevel(thinkingLevelStr) summarizeMessageThreshold := defaults.SummarizeMessageThreshold if summarizeMessageThreshold == 0 { @@ -251,6 +253,7 @@ func NewAgentInstance( MaxTokens: maxTokens, Temperature: temperature, ThinkingLevel: thinkingLevel, + ThinkingLevelConfigured: thinkingLevelConfigured, ContextWindow: contextWindow, SummarizeMessageThreshold: summarizeMessageThreshold, SummarizeTokenPercent: summarizeTokenPercent, diff --git a/pkg/agent/pipeline_llm.go b/pkg/agent/pipeline_llm.go index aaae765e..6df2e4ab 100644 --- a/pkg/agent/pipeline_llm.go +++ b/pkg/agent/pipeline_llm.go @@ -73,14 +73,7 @@ func (p *Pipeline) CallLLM( if exec.useNativeSearch { exec.llmOpts["native_search"] = true } - if ts.agent.ThinkingLevel != ThinkingOff { - if tc, ok := ts.agent.Provider.(providers.ThinkingCapable); ok && tc.SupportsThinking() { - exec.llmOpts["thinking_level"] = string(ts.agent.ThinkingLevel) - } else { - logger.WarnCF("agent", "thinking_level is set but current provider does not support it, ignoring", - map[string]any{"agent_id": ts.agent.ID, "thinking_level": string(ts.agent.ThinkingLevel)}) - } - } + applyTurnThinkingOptions(exec, ts.agent, exec.activeProvider, true) exec.llmModel = exec.activeModel @@ -105,6 +98,7 @@ func (p *Pipeline) CallLLM( exec.llmOpts = llmReq.Options if strings.TrimSpace(exec.llmModel) != "" && exec.llmModel != prevModel { p.applyBeforeLLMModelRewrite(ts, exec) + applyTurnThinkingOptions(exec, ts.agent, exec.activeProvider, true) } } case HookActionAbortTurn: @@ -172,21 +166,33 @@ func (p *Pipeline) CallLLM( } if len(exec.activeCandidates) > 1 && p.Fallback != nil { - fbResult, fbErr := p.Fallback.Execute( + fbResult, fbErr := p.Fallback.ExecuteCandidate( providerCtx, exec.activeCandidates, - func(ctx context.Context, provider, model string) (*providers.LLMResponse, error) { + func(ctx context.Context, candidate providers.FallbackCandidate) (*providers.LLMResponse, error) { candidateProvider, err := providerForFallbackCandidate( ts.agent, exec.activeProvider, exec.activeCandidates, - provider, - model, + candidate.Provider, + candidate.Model, ) if err != nil { return nil, err } - return candidateProvider.Chat(ctx, messagesForCall, toolDefsForCall, model, exec.llmOpts) + callOpts := shallowCloneLLMOptions(exec.llmOpts) + delete(callOpts, "thinking_level") + candidateCfg := resolveActiveModelConfig( + p.Cfg, + ts.agent.Workspace, + []providers.FallbackCandidate{candidate}, + candidate.Model, + p.Cfg.Agents.Defaults.Provider, + ) + candidateThinking := thinkingSettingsFromModelConfig(candidateCfg) + applyThinkingOption(callOpts, candidateProvider, candidateThinking, true, ts.agent.ID) + exec.suppressReasoning = shouldSuppressReasoningFor(candidateThinking) + return candidateProvider.Chat(ctx, messagesForCall, toolDefsForCall, candidate.Model, callOpts) }, ) if fbErr != nil { @@ -469,6 +475,11 @@ func (p *Pipeline) CallLLM( } } + if exec.suppressReasoning { + exec.response.Reasoning = "" + exec.response.ReasoningContent = "" + exec.response.ReasoningDetails = nil + } reasoningContent := responseReasoningContent(exec.response) shouldPublishPicoToolCallInterim := ts.channel == "pico" && len(exec.response.ToolCalls) > 0 if shouldPublishPicoToolCallInterim { diff --git a/pkg/agent/pipeline_streaming.go b/pkg/agent/pipeline_streaming.go index d0ef9052..115cbffc 100644 --- a/pkg/agent/pipeline_streaming.go +++ b/pkg/agent/pipeline_streaming.go @@ -85,7 +85,7 @@ func (p *Pipeline) tryConfiguredStreamingLLM( exec.llmOpts, func(chunk providers.StreamChunk) { recordChunk() - if strings.TrimSpace(chunk.ReasoningContent) != "" { + if !exec.suppressReasoning && strings.TrimSpace(chunk.ReasoningContent) != "" { publisher.UpdateReasoning(ctx, chunk.ReasoningContent) } if strings.TrimSpace(chunk.Content) != "" { diff --git a/pkg/agent/pipeline_streaming_test.go b/pkg/agent/pipeline_streaming_test.go index a18e4c6f..9bba1b90 100644 --- a/pkg/agent/pipeline_streaming_test.go +++ b/pkg/agent/pipeline_streaming_test.go @@ -517,6 +517,46 @@ func TestConfiguredStreamingStreamsPicoReasoningBeforeAnswerContent(t *testing.T } } +func TestConfiguredStreamingSuppressesPicoReasoningWhenThinkingOff(t *testing.T) { + cfg := newConfiguredStreamingTestConfig(t, true, true, nil) + cfg.ModelList[0].ThinkingLevel = "off" + streamer := &recordingStreamer{} + msgBus := bus.NewMessageBus() + msgBus.SetStreamDelegate(configuredStreamingDelegate{streamer: streamer}) + provider := &configuredStreamingProvider{ + eventPlan: []configuredStreamingEventCall{{ + chunks: []providers.StreamChunk{ + {ReasoningContent: "thinking"}, + {Content: "answer"}, + }, + response: &providers.LLMResponse{ + Content: "answer", + ReasoningContent: "thinking", + }, + }}, + } + al := NewAgentLoop(cfg, msgBus, provider) + + got := runConfiguredStreamingTurn(t, al, "pico") + if got != "answer" { + t.Fatalf("response = %q, want answer", got) + } + if len(streamer.reasoningUpdates) != 0 { + t.Fatalf("reasoning updates = %v, want none when thinking is off", streamer.reasoningUpdates) + } + if len(streamer.reasoningFinalized) != 0 { + t.Fatalf("reasoning finalized = %v, want none when thinking is off", streamer.reasoningFinalized) + } + if len(streamer.updates) != 1 || streamer.updates[0] != "answer" { + t.Fatalf("content updates = %v, want [answer]", streamer.updates) + } + select { + case outbound := <-msgBus.OutboundChan(): + t.Fatalf("expected no reasoning outbound when thinking is off, got %+v", outbound) + case <-time.After(50 * time.Millisecond): + } +} + func TestConfiguredStreamingFinalFlushFailureAfterVisibleOutputReturnsErrorWithoutFallbackOrCancel(t *testing.T) { cfg := newConfiguredStreamingTestConfig(t, true, true, nil) streamer := &failingFinalizeStreamer{err: errors.New("final failed")} diff --git a/pkg/agent/thinking.go b/pkg/agent/thinking.go index 015b6928..087f5136 100644 --- a/pkg/agent/thinking.go +++ b/pkg/agent/thinking.go @@ -1,6 +1,12 @@ package agent -import "strings" +import ( + "strings" + + "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/logger" + "github.com/sipeed/picoclaw/pkg/providers" +) // ThinkingLevel controls how the provider sends thinking parameters. // @@ -37,3 +43,87 @@ func parseThinkingLevel(level string) ThinkingLevel { return ThinkingOff } } + +func isConfiguredThinkingLevel(level string) bool { + switch strings.ToLower(strings.TrimSpace(level)) { + case "off", "low", "medium", "high", "xhigh", "adaptive": + return true + default: + return false + } +} + +type thinkingSettings struct { + level ThinkingLevel + configured bool +} + +func thinkingSettingsFromModelConfig(mc *config.ModelConfig) thinkingSettings { + if mc == nil || !isConfiguredThinkingLevel(mc.ThinkingLevel) { + return thinkingSettings{} + } + return thinkingSettings{ + level: parseThinkingLevel(mc.ThinkingLevel), + configured: true, + } +} + +func activeThinkingSettings(agent *AgentInstance, modelCfg *config.ModelConfig) thinkingSettings { + if settings := thinkingSettingsFromModelConfig(modelCfg); settings.configured { + return settings + } + if modelCfg == nil && agent != nil { + return thinkingSettings{ + level: agent.ThinkingLevel, + configured: agent.ThinkingLevelConfigured, + } + } + return thinkingSettings{} +} + +func applyThinkingOption( + opts map[string]any, + provider providers.LLMProvider, + settings thinkingSettings, + warnUnsupported bool, + agentID string, +) { + if opts == nil || !settings.configured { + return + } + if settings.level == ThinkingOff { + opts["thinking_level"] = string(settings.level) + return + } + if tc, ok := provider.(providers.ThinkingCapable); ok && tc.SupportsThinking() { + opts["thinking_level"] = string(settings.level) + return + } + if warnUnsupported { + logger.WarnCF("agent", "thinking_level is set but current provider does not support it, ignoring", + map[string]any{"agent_id": agentID, "thinking_level": string(settings.level)}) + } +} + +func applyTurnThinkingOptions( + exec *turnExecution, + agent *AgentInstance, + provider providers.LLMProvider, + warnUnsupported bool, +) { + if exec == nil || exec.llmOpts == nil { + return + } + delete(exec.llmOpts, "thinking_level") + settings := activeThinkingSettings(agent, exec.activeModelConfig) + agentID := "" + if agent != nil { + agentID = agent.ID + } + applyThinkingOption(exec.llmOpts, provider, settings, warnUnsupported, agentID) + exec.suppressReasoning = shouldSuppressReasoningFor(settings) +} + +func shouldSuppressReasoningFor(settings thinkingSettings) bool { + return settings.configured && settings.level == ThinkingOff +} diff --git a/pkg/agent/turn_coord.go b/pkg/agent/turn_coord.go index 06034633..a8a6ff89 100644 --- a/pkg/agent/turn_coord.go +++ b/pkg/agent/turn_coord.go @@ -423,6 +423,7 @@ func (al *AgentLoop) askSideQuestion( } hookModelChanged := false + sideSuppressReasoning := false callProvider := func( ctx context.Context, candidate providers.FallbackCandidate, @@ -430,7 +431,15 @@ func (al *AgentLoop) askSideQuestion( forceModel bool, callMessages []providers.Message, ) (*providers.LLMResponse, error) { - provider, providerModel, cleanup, err := al.isolatedSideQuestionProvider(agent, selectedModelName, candidate) + baseModelName := selectedModelName + if forceModel && strings.TrimSpace(model) != "" { + baseModelName = model + } + provider, providerModel, modelCfg, cleanup, err := al.isolatedSideQuestionProvider( + agent, + baseModelName, + candidate, + ) if err != nil { return nil, err } @@ -439,10 +448,12 @@ func (al *AgentLoop) askSideQuestion( model = providerModel } callOpts := llmOpts - if _, exists := callOpts["thinking_level"]; !exists && agent.ThinkingLevel != ThinkingOff { - if tc, ok := provider.(providers.ThinkingCapable); ok && tc.SupportsThinking() { + settings := thinkingSettingsFromModelConfig(modelCfg) + sideSuppressReasoning = shouldSuppressReasoningFor(settings) + if _, exists := callOpts["thinking_level"]; !exists { + if settings.configured { callOpts = shallowCloneLLMOptions(llmOpts) - callOpts["thinking_level"] = string(agent.ThinkingLevel) + applyThinkingOption(callOpts, provider, settings, false, agent.ID) } } return provider.Chat(ctx, callMessages, nil, model, callOpts) @@ -500,18 +511,11 @@ func (al *AgentLoop) askSideQuestion( callSideLLM := func(callMessages []providers.Message) (*providers.LLMResponse, error) { if len(activeCandidates) > 1 && al.fallback != nil { - fbResult, err := al.fallback.Execute( + fbResult, err := al.fallback.ExecuteCandidate( ctx, activeCandidates, - func(ctx context.Context, providerName, model string) (*providers.LLMResponse, error) { - candidate := providers.FallbackCandidate{Provider: providerName, Model: model} - for _, activeCandidate := range activeCandidates { - if activeCandidate.Provider == providerName && activeCandidate.Model == model { - candidate = activeCandidate - break - } - } - return callProvider(ctx, candidate, model, false, callMessages) + func(ctx context.Context, candidate providers.FallbackCandidate) (*providers.LLMResponse, error) { + return callProvider(ctx, candidate, candidate.Model, false, callMessages) }, ) if err != nil { @@ -584,6 +588,11 @@ func (al *AgentLoop) askSideQuestion( return "", fmt.Errorf("hook aborted turn during after_llm: %s", reason) } } + if sideSuppressReasoning { + resp.Reasoning = "" + resp.ReasoningContent = "" + resp.ReasoningDetails = nil + } return sideQuestionResponseContent(resp), nil } @@ -592,14 +601,14 @@ func (al *AgentLoop) isolatedSideQuestionProvider( agent *AgentInstance, baseModelName string, candidate providers.FallbackCandidate, -) (providers.LLMProvider, string, func(), error) { +) (providers.LLMProvider, string, *config.ModelConfig, func(), error) { if agent == nil { - return nil, "", func() {}, fmt.Errorf("isolatedSideQuestionProvider: no agent available for /btw") + return nil, "", nil, func() {}, fmt.Errorf("isolatedSideQuestionProvider: no agent available for /btw") } modelCfg, err := al.sideQuestionModelConfig(agent, baseModelName, candidate) if err != nil { - return nil, "", func() {}, fmt.Errorf("isolatedSideQuestionProvider: %w", err) + return nil, "", nil, func() {}, fmt.Errorf("isolatedSideQuestionProvider: %w", err) } factory := al.providerFactory @@ -608,13 +617,13 @@ func (al *AgentLoop) isolatedSideQuestionProvider( } provider, modelID, err := factory(modelCfg) if err != nil { - return nil, "", func() {}, fmt.Errorf("isolatedSideQuestionProvider: %w", err) + return nil, "", nil, func() {}, fmt.Errorf("isolatedSideQuestionProvider: %w", err) } cleanup := func() { closeProviderIfStateful(provider) } - return provider, modelID, cleanup, nil + return provider, modelID, modelCfg, cleanup, nil } func (al *AgentLoop) sideQuestionModelConfig( @@ -626,7 +635,15 @@ func (al *AgentLoop) sideQuestionModelConfig( return nil, fmt.Errorf("sideQuestionModelConfig: no agent available for /btw") } - // If candidate has an identity key, use that + if name := modelAliasFromCandidateIdentityKey(candidate.IdentityKey); name != "" { + modelCfg, err := resolvedModelConfig(al.GetConfig(), name, agent.Workspace) + if err == nil { + return modelCfg, nil + } + // Fallback: create a minimal config if lookup fails + } + + // Older identity keys used provider/model; keep resolving those by model. if name := modelNameFromIdentityKey(candidate.IdentityKey); name != "" { modelCfg, err := resolvedModelConfig(al.GetConfig(), name, agent.Workspace) if err == nil { @@ -635,6 +652,18 @@ func (al *AgentLoop) sideQuestionModelConfig( // Fallback: create a minimal config if lookup fails } + if candidate.Provider != "" && candidate.Model != "" { + candidateRef := providers.NormalizeProvider(candidate.Provider) + "/" + candidate.Model + if modelCfg, err := resolvedModelConfig(al.GetConfig(), candidateRef, agent.Workspace); err == nil { + return modelCfg, nil + } + return &config.ModelConfig{ + ModelName: candidateRef, + Model: candidateRef, + Workspace: agent.Workspace, + }, nil + } + // Otherwise, clean up the base model name and use it baseModelName = strings.TrimSpace(baseModelName) modelCfg, err := resolvedModelConfig(al.GetConfig(), baseModelName, agent.Workspace) @@ -658,8 +687,5 @@ func (al *AgentLoop) sideQuestionModelConfig( // If candidate specifies a different provider/model, override clone := *modelCfg - if candidate.Provider != "" && candidate.Model != "" { - clone.Model = providers.NormalizeProvider(candidate.Provider) + "/" + candidate.Model - } return &clone, nil } diff --git a/pkg/agent/turn_state.go b/pkg/agent/turn_state.go index ddd1eb89..2f40c2d3 100644 --- a/pkg/agent/turn_state.go +++ b/pkg/agent/turn_state.go @@ -138,6 +138,7 @@ type turnExecution struct { allResponsesHandled bool streamingPublisher *streamingChunkPublisher streamingFallback bool + suppressReasoning bool callMessages []providers.Message providerToolDefs []providers.ToolDefinition llmModel string diff --git a/pkg/providers/fallback.go b/pkg/providers/fallback.go index ca0e652d..6529e211 100644 --- a/pkg/providers/fallback.go +++ b/pkg/providers/fallback.go @@ -118,6 +118,22 @@ func (fc *FallbackChain) Execute( ctx context.Context, candidates []FallbackCandidate, run func(ctx context.Context, provider, model string) (*LLMResponse, error), +) (*FallbackResult, error) { + return fc.ExecuteCandidate( + ctx, + candidates, + func(ctx context.Context, candidate FallbackCandidate) (*LLMResponse, error) { + return run(ctx, candidate.Provider, candidate.Model) + }, + ) +} + +// ExecuteCandidate runs the fallback chain and passes the complete candidate +// to the caller so model-list identity metadata remains available. +func (fc *FallbackChain) ExecuteCandidate( + ctx context.Context, + candidates []FallbackCandidate, + run func(ctx context.Context, candidate FallbackCandidate) (*LLMResponse, error), ) (*FallbackResult, error) { if len(candidates) == 0 { return nil, fmt.Errorf("fallback: no candidates configured") @@ -181,7 +197,7 @@ func (fc *FallbackChain) Execute( // Execute the run function. start := time.Now() - resp, err := run(ctx, candidate.Provider, candidate.Model) + resp, err := run(ctx, candidate) elapsed := time.Since(start) if err == nil { diff --git a/pkg/providers/openai_compat/provider.go b/pkg/providers/openai_compat/provider.go index 2e85297e..5990f58e 100644 --- a/pkg/providers/openai_compat/provider.go +++ b/pkg/providers/openai_compat/provider.go @@ -193,6 +193,8 @@ func (p *Provider) buildRequestBody( } } + p.applyThinkingControl(requestBody, model, options) + // Merge extra body fields configured per-provider/model. // These are injected last so they take precedence over defaults. maps.Copy(requestBody, p.extraBody) @@ -200,6 +202,81 @@ func (p *Provider) buildRequestBody( return requestBody } +func (p *Provider) applyThinkingControl(requestBody map[string]any, model string, options map[string]any) { + level, ok := normalizedThinkingLevel(options) + if !ok || level != "off" { + return + } + + switch p.thinkingControlKind(model) { + case "thinking_type": + requestBody["thinking"] = map[string]any{"type": "disabled"} + case "enable_thinking": + requestBody["enable_thinking"] = false + } +} + +func normalizedThinkingLevel(options map[string]any) (string, bool) { + raw, ok := options["thinking_level"].(string) + if !ok { + return "", false + } + switch strings.ToLower(strings.TrimSpace(raw)) { + case "off", "low", "medium", "high", "xhigh", "adaptive": + return strings.ToLower(strings.TrimSpace(raw)), true + default: + return "", false + } +} + +func (p *Provider) thinkingControlKind(model string) string { + providerName := strings.ToLower(strings.TrimSpace(p.providerName)) + lowerModel := strings.ToLower(strings.TrimSpace(model)) + + switch providerName { + case "volcengine": + return "thinking_type" + case "zhipu", "zai": + return "thinking_type" + case "qwen", "qwen-portal", "qwen-intl", "qwen-international", "dashscope-intl", "qwen-us", "dashscope-us": + return "enable_thinking" + case "modelscope": + if strings.Contains(lowerModel, "qwen") { + return "enable_thinking" + } + } + + if providerName == "openai" || providerName == "" { + if isVolcengineHost(p.apiBase) || strings.Contains(lowerModel, "doubao") { + return "thinking_type" + } + if isDashScopeHost(p.apiBase) || strings.Contains(lowerModel, "qwen") { + return "enable_thinking" + } + } + + return "" +} + +func isVolcengineHost(apiBase string) bool { + host := normalizedHostname(apiBase) + return host == "volcengine.com" || strings.HasSuffix(host, ".volcengine.com") || + host == "volces.com" || strings.HasSuffix(host, ".volces.com") +} + +func isDashScopeHost(apiBase string) bool { + host := normalizedHostname(apiBase) + return host == "dashscope.aliyuncs.com" || strings.HasSuffix(host, ".dashscope.aliyuncs.com") +} + +func normalizedHostname(rawURL string) string { + parsed, err := url.Parse(strings.TrimSpace(rawURL)) + if err != nil { + return "" + } + return strings.ToLower(strings.TrimSpace(parsed.Hostname())) +} + func (p *Provider) applyCustomHeaders(req *http.Request) { for k, v := range p.customHeaders { if strings.TrimSpace(k) == "" { diff --git a/pkg/providers/openai_compat/provider_test.go b/pkg/providers/openai_compat/provider_test.go index 73615b6e..e8396171 100644 --- a/pkg/providers/openai_compat/provider_test.go +++ b/pkg/providers/openai_compat/provider_test.go @@ -62,6 +62,69 @@ func TestProviderChat_UsesMaxCompletionTokensForGLM(t *testing.T) { } } +func TestBuildRequestBody_DisablesDoubaoThinkingWhenThinkingLevelOff(t *testing.T) { + p := NewProvider("key", "https://ark.cn-beijing.volces.com/api/v3", "") + p.SetProviderName("openai") + + body := p.buildRequestBody( + []Message{{Role: "user", Content: "hi"}}, + nil, + "doubao-seed-1-6-flash-250828", + map[string]any{"thinking_level": "off"}, + ) + + thinking, ok := body["thinking"].(map[string]any) + if !ok { + t.Fatalf("thinking = %#v, want map", body["thinking"]) + } + if got := thinking["type"]; got != "disabled" { + t.Fatalf("thinking.type = %#v, want %q", got, "disabled") + } +} + +func TestBuildRequestBody_DisablesModelDependentQwenThinkingWhenThinkingLevelOff(t *testing.T) { + p := NewProvider("key", "https://api-inference.modelscope.cn/v1", "") + p.SetProviderName("modelscope") + + body := p.buildRequestBody( + []Message{{Role: "user", Content: "hi"}}, + nil, + "qwen3-coder-plus", + map[string]any{"thinking_level": "off"}, + ) + + if got := body["enable_thinking"]; got != false { + t.Fatalf("enable_thinking = %#v, want false", got) + } +} + +func TestBuildRequestBody_PreservesDoubaoRequestWhenThinkingLevelIsNotOff(t *testing.T) { + p := NewProvider("key", "https://ark.cn-beijing.volces.com/api/v3", "") + p.SetProviderName("openai") + + for _, level := range []string{"low", "adaptive", "unexpected"} { + t.Run(level, func(t *testing.T) { + body := p.buildRequestBody( + []Message{{Role: "user", Content: "hi"}}, + nil, + "doubao-seed-1-6-flash-250828", + map[string]any{"thinking_level": level}, + ) + + if _, ok := body["thinking"]; ok { + t.Fatalf( + "thinking should be omitted for %q to preserve existing behavior, got %#v", + level, + body["thinking"], + ) + } + if _, ok := body["enable_thinking"]; ok { + t.Fatalf("enable_thinking should be omitted for %q, got %#v", level, body["enable_thinking"]) + } + }) + } +} + func TestProviderChat_ParsesToolCalls(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { resp := map[string]any{ diff --git a/web/frontend/src/components/models/add-model-sheet.tsx b/web/frontend/src/components/models/add-model-sheet.tsx index 453bbe25..653380c8 100644 --- a/web/frontend/src/components/models/add-model-sheet.tsx +++ b/web/frontend/src/components/models/add-model-sheet.tsx @@ -720,7 +720,7 @@ export function AddModelSheet({ diff --git a/web/frontend/src/components/models/edit-model-sheet.tsx b/web/frontend/src/components/models/edit-model-sheet.tsx index c01732be..44cd6b66 100644 --- a/web/frontend/src/components/models/edit-model-sheet.tsx +++ b/web/frontend/src/components/models/edit-model-sheet.tsx @@ -690,7 +690,7 @@ export function EditModelSheet({ diff --git a/web/frontend/src/i18n/locales/en.json b/web/frontend/src/i18n/locales/en.json index 9b66f598..98799020 100644 --- a/web/frontend/src/i18n/locales/en.json +++ b/web/frontend/src/i18n/locales/en.json @@ -313,7 +313,8 @@ "rpm": "Rate Limit (RPM)", "rpmHint": "Maximum requests per minute. 0 = no limit.", "thinkingLevel": "Thinking Level", - "thinkingLevelHint": "Extended thinking budget: off, low, medium, high, xhigh, adaptive.", + "thinkingLevelHint": "Leave blank to omit thinking_level and use the provider default. Values: off, low, medium, high, xhigh, adaptive.", + "providerDefault": "provider default", "maxTokensField": "Max Tokens Field", "maxTokensFieldHint": "Override the request field name for max tokens, e.g. max_completion_tokens.", "toolSchemaTransform": "Tool Schema Transform", diff --git a/web/frontend/src/i18n/locales/pt-br.json b/web/frontend/src/i18n/locales/pt-br.json index 458fd572..bb17eea0 100644 --- a/web/frontend/src/i18n/locales/pt-br.json +++ b/web/frontend/src/i18n/locales/pt-br.json @@ -312,7 +312,8 @@ "rpm": "Limite de Taxa (RPM)", "rpmHint": "Máximo de requisições por minuto. 0 = sem limite.", "thinkingLevel": "Nível de Pensamento", - "thinkingLevelHint": "Orçamento de pensamento estendido: off, low, medium, high, xhigh, adaptive.", + "thinkingLevelHint": "Deixe em branco para omitir thinking_level e usar o padrão do provider. Valores: off, low, medium, high, xhigh, adaptive.", + "providerDefault": "padrão do provider", "maxTokensField": "Campo de Max Tokens", "maxTokensFieldHint": "Sobrescreve o nome do campo de max tokens na requisição, ex: max_completion_tokens.", "toolSchemaTransform": "Transformação de Schema de Ferramentas", diff --git a/web/frontend/src/i18n/locales/zh.json b/web/frontend/src/i18n/locales/zh.json index 21bb6879..5dde72e1 100644 --- a/web/frontend/src/i18n/locales/zh.json +++ b/web/frontend/src/i18n/locales/zh.json @@ -313,7 +313,8 @@ "rpm": "速率限制(RPM)", "rpmHint": "每分钟最大请求数,0 表示不限制。", "thinkingLevel": "思考级别", - "thinkingLevelHint": "扩展思考预算:off、low、medium、high、xhigh、adaptive。", + "thinkingLevelHint": "留空则不传 thinking_level,使用 Provider 默认值。可选值:off、low、medium、high、xhigh、adaptive。", + "providerDefault": "Provider 默认", "maxTokensField": "Max Tokens 字段名", "maxTokensFieldHint": "覆盖请求中 max_tokens 的字段名,例如 max_completion_tokens。", "toolSchemaTransform": "工具 Schema 转换", From 30938df40b89e147a697c941fad38d731557c306 Mon Sep 17 00:00:00 2001 From: Guoguo Date: Thu, 21 May 2026 15:51:45 +0800 Subject: [PATCH 08/20] fix(web): use stored API key when fetching models for saved providers (#2910) When editing an existing model, the edit form initializes apiKey as empty for security. This caused "Fetch Available Models" to reject with "please enter API Key first" even though the key is saved server-side. Add model_index support: the frontend passes the model's index to the backend, which looks up the stored key from config. The key never leaves the backend. Provider and API base are validated to prevent a stored key from being sent to an unrelated endpoint. Co-authored-by: Claude Opus 4.7 (1M context) --- web/backend/api/models.go | 43 ++++++- web/backend/api/models_test.go | 105 ++++++++++++++++++ web/frontend/src/api/models.ts | 1 + .../components/models/edit-model-sheet.tsx | 1 + .../components/models/fetch-models-dialog.tsx | 12 +- 5 files changed, 153 insertions(+), 9 deletions(-) diff --git a/web/backend/api/models.go b/web/backend/api/models.go index 5c1eb23a..18bd457b 100644 --- a/web/backend/api/models.go +++ b/web/backend/api/models.go @@ -640,9 +640,10 @@ func (h *Handler) handleFetchModels(w http.ResponseWriter, r *http.Request) { defer r.Body.Close() var req struct { - Provider string `json:"provider"` - APIKey string `json:"api_key"` - APIBase string `json:"api_base"` + Provider string `json:"provider"` + APIKey string `json:"api_key"` + APIBase string `json:"api_base"` + ModelIndex *int `json:"model_index,omitempty"` } if err = json.Unmarshal(body, &req); err != nil { http.Error(w, fmt.Sprintf("Invalid JSON: %v", err), http.StatusBadRequest) @@ -659,7 +660,15 @@ func (h *Handler) handleFetchModels(w http.ResponseWriter, r *http.Request) { return } + apiKey := strings.TrimSpace(req.APIKey) apiBase := strings.TrimSpace(req.APIBase) + + if apiKey == "" && req.ModelIndex != nil { + if stored := h.lookupStoredAPIKey(*req.ModelIndex, req.Provider, apiBase); stored != "" { + apiKey = stored + } + } + if apiBase == "" { apiBase = providers.DefaultAPIBaseForProtocol(req.Provider) } @@ -671,7 +680,7 @@ func (h *Handler) handleFetchModels(w http.ResponseWriter, r *http.Request) { ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) defer cancel() - models, err := fetchUpstreamModels(ctx, req.Provider, apiBase, req.APIKey) + models, err := fetchUpstreamModels(ctx, req.Provider, apiBase, apiKey) if err != nil { http.Error(w, fmt.Sprintf("Failed to fetch models: %v", err), http.StatusBadGateway) return @@ -682,7 +691,7 @@ func (h *Handler) handleFetchModels(w http.ResponseWriter, r *http.Request) { for i, m := range models { catalogModels[i] = CatalogModel{ID: m.ID, OwnedBy: m.OwnedBy} } - if saveErr := SaveCatalog(req.Provider, apiBase, req.APIKey, catalogModels); saveErr != nil { + if saveErr := SaveCatalog(req.Provider, apiBase, apiKey, catalogModels); saveErr != nil { // Log but don't fail the request — saving catalog is non-critical logger.Warnf("Failed to save model catalog: %v", saveErr) } @@ -694,6 +703,30 @@ func (h *Handler) handleFetchModels(w http.ResponseWriter, r *http.Request) { }) } +func (h *Handler) lookupStoredAPIKey(index int, reqProvider, reqAPIBase string) string { + cfg, err := config.LoadConfig(h.configPath) + if err != nil || index < 0 || index >= len(cfg.ModelList) { + return "" + } + stored := cfg.ModelList[index] + storedProvider, _ := providers.ExtractProtocol(stored) + if providers.NormalizeProvider(reqProvider) != providers.NormalizeProvider(storedProvider) { + return "" + } + effectiveReqBase := strings.TrimSpace(reqAPIBase) + if effectiveReqBase == "" { + effectiveReqBase = providers.DefaultAPIBaseForProtocol(reqProvider) + } + effectiveStoredBase := strings.TrimSpace(stored.APIBase) + if effectiveStoredBase == "" { + effectiveStoredBase = providers.DefaultAPIBaseForProtocol(storedProvider) + } + if normalizeAPIBaseForCompare(effectiveReqBase) != normalizeAPIBaseForCompare(effectiveStoredBase) { + return "" + } + return stored.APIKey() +} + type upstreamModel struct { ID string `json:"id"` OwnedBy string `json:"owned_by,omitempty"` diff --git a/web/backend/api/models_test.go b/web/backend/api/models_test.go index 94a3da3b..22b02c22 100644 --- a/web/backend/api/models_test.go +++ b/web/backend/api/models_test.go @@ -6,6 +6,8 @@ import ( "fmt" "net/http" "net/http/httptest" + "os" + "path/filepath" "strings" "sync" "testing" @@ -2576,3 +2578,106 @@ func TestHandleFetchModels_SiliconFlowUsesOpenAICompatibleEndpoint(t *testing.T) t.Fatalf("model id = %q, want %q", resp.Models[0].ID, "deepseek-ai/DeepSeek-V3") } } + +func TestHandleFetchModels_ModelIndexUsesStoredKey(t *testing.T) { + var gotAuth string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotAuth = r.Header.Get("Authorization") + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, `{"data":[{"id":"gpt-4o","owned_by":"openai"}]}`) + })) + defer srv.Close() + + tmp := t.TempDir() + oldHome := os.Getenv("PICOCLAW_HOME") + t.Setenv("PICOCLAW_HOME", filepath.Join(tmp, ".picoclaw")) + defer func() { + if oldHome != "" { + os.Setenv("PICOCLAW_HOME", oldHome) + } else { + os.Unsetenv("PICOCLAW_HOME") + } + }() + + cfg := config.DefaultConfig() + cfg.ModelList = []*config.ModelConfig{ + { + ModelName: "my-openai", + Provider: "openai", + Model: "gpt-4o", + APIKeys: config.SimpleSecureStrings("sk-stored-secret"), + APIBase: srv.URL, + }, + } + configPath := filepath.Join(tmp, "config.json") + if err := config.SaveConfig(configPath, cfg); err != nil { + t.Fatalf("SaveConfig error: %v", err) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + idx := 0 + body := fmt.Sprintf(`{"provider":"openai","api_base":"%s","model_index":%d}`, srv.URL, idx) + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/models/fetch", bytes.NewBufferString(body)) + req.Header.Set("Content-Type", "application/json") + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + if gotAuth != "Bearer sk-stored-secret" { + t.Fatalf("Authorization = %q, want stored key to be used", gotAuth) + } + + var resp struct { + Models []upstreamModel `json:"models"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("Unmarshal error: %v", err) + } + if len(resp.Models) != 1 || resp.Models[0].ID != "gpt-4o" { + t.Fatalf("unexpected response: %+v", resp) + } +} + +func TestHandleFetchModels_ModelIndexProviderMismatchRejectsKey(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") != "" { + t.Error("stored key should NOT be sent to mismatched provider") + } + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, `{"data":[]}`) + })) + defer srv.Close() + + tmp := t.TempDir() + t.Setenv("PICOCLAW_HOME", filepath.Join(tmp, ".picoclaw")) + + cfg := config.DefaultConfig() + cfg.ModelList = []*config.ModelConfig{ + { + ModelName: "my-openai", + Provider: "openai", + Model: "gpt-4o", + APIKeys: config.SimpleSecureStrings("sk-openai-secret"), + APIBase: "https://api.openai.com/v1", + }, + } + configPath := filepath.Join(tmp, "config.json") + if err := config.SaveConfig(configPath, cfg); err != nil { + t.Fatalf("SaveConfig error: %v", err) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + body := fmt.Sprintf(`{"provider":"siliconflow","api_base":"%s","model_index":0}`, srv.URL) + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/models/fetch", bytes.NewBufferString(body)) + req.Header.Set("Content-Type", "application/json") + mux.ServeHTTP(rec, req) +} diff --git a/web/frontend/src/api/models.ts b/web/frontend/src/api/models.ts index 5f89cbfc..a319eda5 100644 --- a/web/frontend/src/api/models.ts +++ b/web/frontend/src/api/models.ts @@ -167,6 +167,7 @@ export interface FetchModelsRequest { provider: string api_key?: string api_base?: string + model_index?: number } export interface FetchModelsResponse { diff --git a/web/frontend/src/components/models/edit-model-sheet.tsx b/web/frontend/src/components/models/edit-model-sheet.tsx index 44cd6b66..d5474748 100644 --- a/web/frontend/src/components/models/edit-model-sheet.tsx +++ b/web/frontend/src/components/models/edit-model-sheet.tsx @@ -804,6 +804,7 @@ export function EditModelSheet({ provider={canonicalProvider} apiKey={form.apiKey} apiBase={effectiveApiBase} + modelIndex={model?.index} backendOptions={providerOptions} /> diff --git a/web/frontend/src/components/models/fetch-models-dialog.tsx b/web/frontend/src/components/models/fetch-models-dialog.tsx index 8ea9e52a..164b2d30 100644 --- a/web/frontend/src/components/models/fetch-models-dialog.tsx +++ b/web/frontend/src/components/models/fetch-models-dialog.tsx @@ -30,6 +30,7 @@ interface FetchModelsDialogProps { provider: string apiKey: string apiBase: string + modelIndex?: number backendOptions?: ModelProviderOption[] } @@ -40,6 +41,7 @@ export function FetchModelsDialog({ provider, apiKey, apiBase, + modelIndex, backendOptions, }: FetchModelsDialogProps) { const { t } = useTranslation() @@ -52,6 +54,7 @@ export function FetchModelsDialog({ const canonicalProvider = getCanonicalProviderKey(provider, backendOptions) const providerDef = getProviderCatalogMap(backendOptions).get(canonicalProvider) const needsKey = providerDef?.requiresApiKey !== false + const hasKey = !!apiKey || modelIndex !== undefined const handleFetch = useCallback(async () => { setFetching(true) @@ -63,6 +66,7 @@ export function FetchModelsDialog({ provider: canonicalProvider, api_key: apiKey, api_base: apiBase, + model_index: modelIndex, }) setModels(res.models) // Auto-select all by default @@ -72,14 +76,14 @@ export function FetchModelsDialog({ } finally { setFetching(false) } - }, [canonicalProvider, apiKey, apiBase, t]) + }, [canonicalProvider, apiKey, apiBase, modelIndex, t]) // Auto-fetch when dialog opens (skip if provider requires API key but none is set) useEffect(() => { - if (open && provider && !(needsKey && !apiKey)) { + if (open && provider && !(needsKey && !hasKey)) { handleFetch() } - }, [open, provider, apiKey, needsKey, handleFetch]) + }, [open, provider, hasKey, needsKey, handleFetch]) const handleFill = () => { onFill(Array.from(selected)) @@ -140,7 +144,7 @@ export function FetchModelsDialog({
- {needsKey && !apiKey && ( + {needsKey && !hasKey && (
{t("models.fetch.needApiKey")}
From f55d7a0598142c09e8c3745af989292039c31a35 Mon Sep 17 00:00:00 2001 From: LC Date: Thu, 21 May 2026 15:55:37 +0800 Subject: [PATCH 09/20] fix(i18n): sync locale strings for model provider UI (#2911) --- web/frontend/src/i18n/locales/en.json | 2 +- web/frontend/src/i18n/locales/pt-br.json | 65 ++++++++++++++++++++++-- web/frontend/src/i18n/locales/zh.json | 12 ++--- 3 files changed, 68 insertions(+), 11 deletions(-) diff --git a/web/frontend/src/i18n/locales/en.json b/web/frontend/src/i18n/locales/en.json index 98799020..7415077c 100644 --- a/web/frontend/src/i18n/locales/en.json +++ b/web/frontend/src/i18n/locales/en.json @@ -274,7 +274,7 @@ "modelNameHint": "A short name used to identify this model in conversations.", "modelId": "Model Identifier", "modelIdPlaceholder": "e.g. gpt-4o or openai/gpt-4o", - "modelIdHint": "If Provider is not specified, values such as openai/gpt-4o are interpreted using the provider/model format. If Provider is specified, this field is treated as the canonical model ID and is not parsed for a provider prefix.", + "modelIdHint": "This field is treated as the canonical model ID for the selected provider. If the identifier itself contains a slash (for example openai/gpt-5.4), it is preserved as-is and not split again.", "errorRequired": "This field is required.", "errorDuplicateModelName": "Model alias already exists. Please use a different name.", "saveError": "Failed to add model", diff --git a/web/frontend/src/i18n/locales/pt-br.json b/web/frontend/src/i18n/locales/pt-br.json index bb17eea0..d89022ae 100644 --- a/web/frontend/src/i18n/locales/pt-br.json +++ b/web/frontend/src/i18n/locales/pt-br.json @@ -253,7 +253,8 @@ "setting": "Definindo como padrão...", "unavailable": "Não é possível definir um modelo indisponível como padrão", "isDefault": "Já é o modelo padrão", - "isVirtual": "Não é possível definir um modelo virtual como padrão" + "isVirtual": "Não é possível definir um modelo virtual como padrão", + "unsupportedProvider": "Este provedor não pode ser usado como modelo de chat padrão." }, "deleteDisabled": { "isDefault": "Não é possível excluir o modelo padrão" @@ -273,7 +274,7 @@ "modelNameHint": "Um nome curto usado para identificar este modelo nas conversas.", "modelId": "Identificador do Modelo", "modelIdPlaceholder": "ex: gpt-4o ou openai/gpt-4o", - "modelIdHint": "Se Provider não estiver especificado, valores como openai/gpt-4o são interpretados no formato provider/modelo. Se Provider estiver especificado, este campo é tratado como o ID canônico do modelo e não é parseado em busca de prefixo de provider.", + "modelIdHint": "Este campo é tratado como o ID canônico do modelo para o provedor selecionado. Se o próprio identificador contiver uma barra, como openai/gpt-5.4, ele será preservado como está e não será dividido novamente.", "errorRequired": "Este campo é obrigatório.", "errorDuplicateModelName": "Apelido de modelo já existe. Use um nome diferente.", "saveError": "Falha ao adicionar modelo", @@ -422,7 +423,8 @@ "maixcam": "MaixCam", "matrix": "Matrix", "irc": "IRC", - "weixin": "WeChat" + "weixin": "WeChat", + "mqtt": "MQTT" }, "weixin": { "bindTitle": "Vincular Conta do WeChat", @@ -553,9 +555,33 @@ "channels": "Canais IRC para entrar.", "requestCaps": "Lista de capabilities IRC requisitada na conexão.", "maxBase64FileSizeMiB": "Tamanho máximo em MiB para converter arquivos locais em base64 antes do upload. 0 significa ilimitado. Aplica-se apenas a arquivos locais, não a uploads via URL.", - "genericField": "Usado para configurar {{field}}." + "genericField": "Usado para configurar {{field}}.", + "broker": "Endereço do broker MQTT.", + "mqttAgentId": "Identificador único desta instância, usado para montar o caminho do topic.", + "topicPrefix": "Prefixo do topic. O padrão é /picoclaw.", + "mqttUsername": "Nome de usuário de autenticação do broker (opcional).", + "mqttPassword": "Senha de autenticação do broker (opcional).", + "mqttClientId": "ID do cliente MQTT. Deixe em branco para gerar automaticamente.", + "keepAlive": "Intervalo de keepalive em segundos. O padrão é 60.", + "qos": "Nível de qualidade de serviço da mensagem: 0 = no máximo uma vez, 1 = pelo menos uma vez, 2 = exatamente uma vez." } }, + "mqtt": { + "protocolTitle": "Referência do Protocolo", + "protocolDesc": "Os clientes enviam e recebem mensagens usando o seguinte formato de topic e payload.", + "uplink": "Uplink (Cliente → Agent)", + "downlink": "Downlink (Agent → Cliente)", + "topicParams": "Parâmetros do Topic", + "fieldText": "text", + "uplinkTextDesc": "Instrução em linguagem natural enviada pelo usuário (obrigatória).", + "downlinkTextDesc": "Texto de resposta do agente. No modo streaming, concatene várias mensagens na ordem para obter a resposta completa.", + "topicPrefixDesc": "Prefixo do topic, correspondente à configuração acima.", + "agentIdDesc": "Agent ID, correspondente à configuração acima.", + "clientIdDesc": "Identificador definido pelo cliente. Recomendação: gerar um UUID na primeira execução e persistir o valor para que o mesmo dispositivo sempre use o mesmo ID.", + "clientIdPlaceholder": "Gerado automaticamente se ficar em branco", + "secretSet": "Já configurado. Deixe em branco para manter sem alterações.", + "secretEmpty": "Não configurado" + }, "validation": { "requiredField": "Este campo é obrigatório." } @@ -823,10 +849,41 @@ "evolution_cold_path_manual": "Desligado", "evolution_cold_path_times": "Horários Agendados", "evolution_cold_path_times_hint": "Horários para o processamento cold-path agendado. Insira um valor HH:MM por linha.", + "mcp_section_hint": "Configure servidores MCP sem editar manualmente o config.json.", + "mcp_enabled": "Habilitar MCP", + "mcp_enabled_hint": "Ativa ou desativa a integração com servidores MCP.", + "mcp_discovery_enabled": "Habilitar Descoberta MCP", + "mcp_discovery_enabled_hint": "Permite que as ferramentas de descoberta MCP pesquisem os servidores MCP registrados.", + "mcp_discovery_ttl": "TTL de desbloqueio das ferramentas descobertas", + "mcp_discovery_ttl_hint": "Quantos ciclos de TTL de execução de ferramenta as ferramentas descobertas permanecem disponíveis após a busca.", + "mcp_discovery_max_results": "Máx. de Resultados da Descoberta", + "mcp_discovery_max_results_hint": "Número máximo de correspondências MCP retornadas por consulta.", + "mcp_discovery_use_bm25": "Usar Ordenação BM25", + "mcp_discovery_use_bm25_hint": "Usa pontuação lexical BM25 nos resultados da descoberta MCP.", + "mcp_discovery_use_regex": "Habilitar Busca por Regex", + "mcp_discovery_use_regex_hint": "Permite correspondência baseada em regex na descoberta MCP.", + "mcp_servers": "Servidores MCP", + "mcp_servers_hint": "Adicione, edite ou remova servidores MCP.", + "mcp_server_new": "Novo servidor MCP", + "mcp_server_add": "Adicionar servidor", + "mcp_server_remove": "Remover", + "mcp_server_enabled": "Habilitado", + "mcp_server_discovery_mode": "Modo de descoberta", + "mcp_server_discovery_mode_inherit": "Seguir modo global de descoberta", + "mcp_server_discovery_mode_deferred": "Descoberta adiada", + "mcp_server_discovery_mode_eager": "Registro imediato", + "mcp_server_name_placeholder": "Nome do servidor (ex.: github)", + "mcp_server_url_placeholder": "URL do servidor (ex.: https://example.com/mcp)", + "mcp_server_command_placeholder": "Comando (ex.: npx)", + "mcp_server_env_file_placeholder": "Caminho do arquivo de ambiente (opcional)", + "mcp_server_args_placeholder": "Args, um por linha", + "mcp_server_env_placeholder": "Objeto JSON de variáveis de ambiente", + "mcp_server_headers_placeholder": "Objeto JSON de headers", "sections": { "agent": "Agente", "runtime": "Runtime", "evolution": "Evolução", + "mcp": "MCP", "exec": "Execução de Comandos", "cron": "Tarefas Agendadas", "launcher": "Launcher", diff --git a/web/frontend/src/i18n/locales/zh.json b/web/frontend/src/i18n/locales/zh.json index 5dde72e1..4e81ddf8 100644 --- a/web/frontend/src/i18n/locales/zh.json +++ b/web/frontend/src/i18n/locales/zh.json @@ -254,7 +254,7 @@ "unavailable": "无法将不可用的模型设为默认", "isDefault": "该模型已是默认模型", "isVirtual": "无法将虚拟模型设为默认", - "unsupportedProvider": "该 Provider 仅用于 ASR,不能设为默认聊天模型" + "unsupportedProvider": "该服务商不能设为默认聊天模型。" }, "deleteDisabled": { "isDefault": "无法删除默认模型" @@ -263,8 +263,7 @@ "defaultOnSave": { "label": "默认模型", "description": "保存后自动将该模型设置为默认模型。", - "unsupportedProvider": "该 Provider 可以保存在 model_list 中,但不能作为默认聊天模型使用。", - "clearOnSave": "保存这个仅用于 ASR 的模型后,会清除当前的默认聊天模型设置。" + "unsupportedProvider": "该服务商可以保存在模型列表中,但不能作为默认聊天模型使用。" }, "add": { "button": "添加模型", @@ -344,10 +343,12 @@ "retry": "重试", "filterPlaceholder": "筛选模型...", "found": "已找到 {{count}} 个模型", + "found_plural": "已找到 {{count}} 个模型", "shown": "(显示 {{count}} 个)", "selectAll": "全选", "deselectAll": "取消全选", "fill": "填充 {{count}} 个选中的模型", + "fill_plural": "填充 {{count}} 个选中的模型", "failed": "获取模型失败" }, "catalog": { @@ -362,6 +363,7 @@ "delete": "删除目录", "refresh": "从上游刷新", "found": "共 {{count}} 个模型", + "found_plural": "共 {{count}} 个模型", "selectAll": "全选", "deselectAll": "取消全选", "addSelected": "添加 {{count}} 个选中模型", @@ -400,9 +402,7 @@ "searchProvider": "搜索服务商...", "noProvider": "未找到服务商。", "noCatalog": "Provider catalog 暂不可用。", - "local": "本地", - "custom": "自定义服务商...", - "customPlaceholder": "输入服务商名称..." + "local": "本地" } }, "channels": { From 5bbebb5fc817ba721226794a8b1583425621c6a0 Mon Sep 17 00:00:00 2001 From: LC Date: Thu, 21 May 2026 16:08:46 +0800 Subject: [PATCH 10/20] feat(provider): add gpt4free openai-compatible provider (#2909) --- pkg/providers/factory_provider.go | 2 +- pkg/providers/factory_provider_test.go | 30 ++++++++++++ pkg/providers/model_ref_test.go | 1 + pkg/providers/provider_metadata.go | 14 ++++++ web/backend/api/model_status.go | 21 ++++++-- web/backend/api/model_status_test.go | 66 ++++++++++++++++++++++++++ web/backend/api/models.go | 2 +- web/backend/api/models_test.go | 13 +++++ 8 files changed, 143 insertions(+), 6 deletions(-) diff --git a/pkg/providers/factory_provider.go b/pkg/providers/factory_provider.go index 667c6da6..7c03daab 100644 --- a/pkg/providers/factory_provider.go +++ b/pkg/providers/factory_provider.go @@ -191,7 +191,7 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err } return finalizeProviderFromConfig(provider, modelID, cfg) - case "litellm", "lmstudio", "openrouter", "groq", "zhipu", "nvidia", "venice", + case "litellm", "lmstudio", "gpt4free", "openrouter", "groq", "zhipu", "nvidia", "venice", "ollama", "moonshot", "shengsuanyun", "siliconflow", "deepseek", "cerebras", "vivgrid", "volcengine", "vllm", "qwen-portal", "qwen-intl", "qwen-us", "mistral", "avian", "longcat", "modelscope", "novita", "alibaba-coding", "zai", "mimo": diff --git a/pkg/providers/factory_provider_test.go b/pkg/providers/factory_provider_test.go index 09dbf8b4..2b734007 100644 --- a/pkg/providers/factory_provider_test.go +++ b/pkg/providers/factory_provider_test.go @@ -210,6 +210,7 @@ func TestCreateProviderFromConfig_DefaultAPIBase(t *testing.T) { {"deepseek", "deepseek"}, {"ollama", "ollama"}, {"lmstudio", "lmstudio"}, + {"gpt4free", "gpt4free"}, {"longcat", "longcat"}, {"modelscope", "modelscope"}, {"mimo", "mimo"}, @@ -248,6 +249,15 @@ func TestGetDefaultAPIBase_LMStudio(t *testing.T) { } } +func TestGetDefaultAPIBase_GPT4Free(t *testing.T) { + if got := getDefaultAPIBase("gpt4free"); got != "http://localhost:1337/v1" { + t.Fatalf("getDefaultAPIBase(%q) = %q, want %q", "gpt4free", got, "http://localhost:1337/v1") + } + if got := getDefaultAPIBase("g4f"); got != "http://localhost:1337/v1" { + t.Fatalf("getDefaultAPIBase(%q) = %q, want %q", "g4f", got, "http://localhost:1337/v1") + } +} + func TestGetDefaultAPIBase_Venice(t *testing.T) { if got := getDefaultAPIBase("venice"); got != "https://api.venice.ai/api/v1" { t.Fatalf("getDefaultAPIBase(%q) = %q, want %q", "venice", got, "https://api.venice.ai/api/v1") @@ -330,6 +340,13 @@ func TestCreateProviderFromConfig_LocalProviders(t *testing.T) { apiKey: "", wantModelID: "Qwen/Qwen3-8B", }, + { + name: "GPT4Free without API key", + modelName: "test-gpt4free", + model: "gpt4free/gpt-4o-mini", + apiKey: "", + wantModelID: "gpt-4o-mini", + }, } for _, tt := range tests { @@ -1010,6 +1027,19 @@ func TestModelProviderOptions(t *testing.T) { } else if !option.EmptyAPIKeyAllowed { t.Fatal("lmstudio should allow empty API keys") } + if option, ok := seen["gpt4free"]; !ok { + t.Fatal("gpt4free option missing") + } else { + if option.DefaultAPIBase != "http://localhost:1337/v1" { + t.Fatalf("gpt4free default_api_base = %q, want %q", option.DefaultAPIBase, "http://localhost:1337/v1") + } + if !option.EmptyAPIKeyAllowed { + t.Fatal("gpt4free should allow empty API keys") + } + if !option.SupportsFetch { + t.Fatal("gpt4free should support upstream model listing") + } + } if option, ok := seen["siliconflow"]; !ok { t.Fatal("siliconflow option missing") } else if option.DefaultAPIBase != "https://api.siliconflow.cn/v1" { diff --git a/pkg/providers/model_ref_test.go b/pkg/providers/model_ref_test.go index 80ea8bb6..57438a44 100644 --- a/pkg/providers/model_ref_test.go +++ b/pkg/providers/model_ref_test.go @@ -76,6 +76,7 @@ func TestNormalizeProvider(t *testing.T) { {"claudecli", "claude-cli"}, {"codexcli", "codex-cli"}, {"copilot", "github-copilot"}, + {"g4f", "gpt4free"}, // Alibaba Coding Plan aliases {"alibaba-coding", "alibaba-coding"}, {"coding-plan", "alibaba-coding"}, diff --git a/pkg/providers/provider_metadata.go b/pkg/providers/provider_metadata.go index 52d27dad..7b9c9e0e 100644 --- a/pkg/providers/provider_metadata.go +++ b/pkg/providers/provider_metadata.go @@ -361,6 +361,20 @@ var modelProviderOptionsByName = map[string]ModelProviderOption{ Priority: 48, httpAPI: true, }, + "gpt4free": { + ID: "gpt4free", + DisplayName: "GPT4Free", + Domain: "g4f.dev", + DefaultAPIBase: "http://localhost:1337/v1", + EmptyAPIKeyAllowed: true, + CreateAllowed: true, + DefaultModelAllowed: true, + SupportsFetch: true, + Local: true, + Priority: 47.5, + Aliases: []string{"g4f"}, + httpAPI: true, + }, "elevenlabs": { ID: "elevenlabs", DisplayName: "ElevenLabs ASR", diff --git a/web/backend/api/model_status.go b/web/backend/api/model_status.go index 435664d7..03666ae9 100644 --- a/web/backend/api/model_status.go +++ b/web/backend/api/model_status.go @@ -218,7 +218,7 @@ func runLocalModelProbe(m *config.ModelConfig) bool { switch protocol { case "ollama": return probeOllamaModelFunc(apiBase, modelID) - case "vllm", "lmstudio": + case "vllm", "lmstudio", "gpt4free": return probeOpenAICompatibleModelFunc(apiBase, modelID, m.APIKey()) case "github-copilot": return probeTCPServiceFunc(apiBase) @@ -563,16 +563,29 @@ func probeOpenAICompatibleModel(apiBase, modelID, apiKey string) bool { return false } - var resp struct { + var respEnvelope struct { Data []struct { ID string `json:"id"` } `json:"data"` } - if err := getJSON(strings.TrimRight(strings.TrimSpace(apiBase), "/")+"/models", &resp, apiKey); err != nil { + fetchURL := strings.TrimRight(strings.TrimSpace(apiBase), "/") + "/models" + if err := getJSON(fetchURL, &respEnvelope, apiKey); err == nil { + for _, model := range respEnvelope.Data { + if strings.EqualFold(strings.TrimSpace(model.ID), modelID) { + return true + } + } return false } - for _, model := range resp.Data { + var respArray []struct { + ID string `json:"id"` + } + if err := getJSON(fetchURL, &respArray, apiKey); err != nil { + return false + } + + for _, model := range respArray { if strings.EqualFold(strings.TrimSpace(model.ID), modelID) { return true } diff --git a/web/backend/api/model_status_test.go b/web/backend/api/model_status_test.go index d5463a85..7a8155e4 100644 --- a/web/backend/api/model_status_test.go +++ b/web/backend/api/model_status_test.go @@ -1,6 +1,7 @@ package api import ( + "fmt" "net/http" "net/http/httptest" "sync" @@ -39,6 +40,21 @@ func TestProbeLocalModelAvailability_OpenAICompatibleIncludesAPIKey(t *testing.T } } +func TestProbeOpenAICompatibleModel_SupportsBareArrayModels(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/models" { + t.Fatalf("path = %q, want %q", r.URL.Path, "/v1/models") + } + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, `[{"id":"gpt-4o-mini"},{"id":"deepseek-r1"}]`) + })) + defer srv.Close() + + if !probeOpenAICompatibleModel(srv.URL+"/v1", "gpt-4o-mini", "") { + t.Fatal("probeOpenAICompatibleModel() = false, want true for bare array /models response") + } +} + func TestRequiresRuntimeProbe_LMStudio(t *testing.T) { if !requiresRuntimeProbe(&config.ModelConfig{ Model: "lmstudio/openai/gpt-oss-20b", @@ -54,6 +70,21 @@ func TestRequiresRuntimeProbe_LMStudio(t *testing.T) { } } +func TestRequiresRuntimeProbe_GPT4Free(t *testing.T) { + if !requiresRuntimeProbe(&config.ModelConfig{ + Model: "gpt4free/gpt-4o-mini", + }) { + t.Fatal("requiresRuntimeProbe(gpt4free with default base) = false, want true") + } + + if requiresRuntimeProbe(&config.ModelConfig{ + Model: "gpt4free/gpt-4o-mini", + APIBase: "https://g4f.space/v1", + }) { + t.Fatal("requiresRuntimeProbe(gpt4free with remote base) = true, want false") + } +} + func TestModelProbeAPIBase_LMStudioDefault(t *testing.T) { got := modelProbeAPIBase(&config.ModelConfig{Model: "lmstudio/openai/gpt-oss-20b"}) if got != "http://localhost:1234/v1" { @@ -61,6 +92,13 @@ func TestModelProbeAPIBase_LMStudioDefault(t *testing.T) { } } +func TestModelProbeAPIBase_GPT4FreeDefault(t *testing.T) { + got := modelProbeAPIBase(&config.ModelConfig{Model: "gpt4free/gpt-4o-mini"}) + if got != "http://localhost:1337/v1" { + t.Fatalf("modelProbeAPIBase(gpt4free) = %q, want %q", got, "http://localhost:1337/v1") + } +} + func TestProbeLocalModelAvailability_LMStudioUsesOpenAICompatibleProbe(t *testing.T) { originalProbe := probeOpenAICompatibleModelFunc defer func() { probeOpenAICompatibleModelFunc = originalProbe }() @@ -89,6 +127,34 @@ func TestProbeLocalModelAvailability_LMStudioUsesOpenAICompatibleProbe(t *testin } } +func TestProbeLocalModelAvailability_GPT4FreeUsesOpenAICompatibleProbe(t *testing.T) { + originalProbe := probeOpenAICompatibleModelFunc + defer func() { probeOpenAICompatibleModelFunc = originalProbe }() + + called := false + probeOpenAICompatibleModelFunc = func(apiBase, modelID, apiKey string) bool { + called = true + if apiBase != "http://localhost:1337/v1" { + t.Fatalf("apiBase = %q, want %q", apiBase, "http://localhost:1337/v1") + } + if modelID != "gpt-4o-mini" { + t.Fatalf("modelID = %q, want %q", modelID, "gpt-4o-mini") + } + if apiKey != "" { + t.Fatalf("apiKey = %q, want empty", apiKey) + } + return true + } + + model := &config.ModelConfig{Model: "gpt4free/gpt-4o-mini"} + if !probeLocalModelAvailability(model) { + t.Fatal("probeLocalModelAvailability(gpt4free) = false, want true") + } + if !called { + t.Fatal("probeOpenAICompatibleModelFunc was not called for gpt4free") + } +} + func TestModelProbeCacheKey_DifferentAPIKeysProduceDifferentKeys(t *testing.T) { base := &config.ModelConfig{ ModelName: "local-vllm", diff --git a/web/backend/api/models.go b/web/backend/api/models.go index 18bd457b..be594e3e 100644 --- a/web/backend/api/models.go +++ b/web/backend/api/models.go @@ -1030,7 +1030,7 @@ func probeModelConnectivity(m *config.ModelConfig) bool { switch protocol { case "ollama": return probeOllamaModel(apiBase, modelID) - case "vllm", "lmstudio": + case "vllm", "lmstudio", "gpt4free": return probeOpenAICompatibleModel(apiBase, modelID, m.APIKey()) case "github-copilot": return probeTCPService(apiBase) diff --git a/web/backend/api/models_test.go b/web/backend/api/models_test.go index 22b02c22..f734d214 100644 --- a/web/backend/api/models_test.go +++ b/web/backend/api/models_test.go @@ -1939,6 +1939,19 @@ func TestHandleListModels_ReturnsProviderOptionsWithoutPersistingLegacyMigration } else if !option.EmptyAPIKeyAllowed { t.Fatal("lmstudio should allow empty api keys") } + if option, ok := optionsByID["gpt4free"]; !ok { + t.Fatal("gpt4free provider option missing") + } else { + if option.DefaultAPIBase != "http://localhost:1337/v1" { + t.Fatalf("gpt4free default_api_base = %q, want %q", option.DefaultAPIBase, "http://localhost:1337/v1") + } + if !option.EmptyAPIKeyAllowed { + t.Fatal("gpt4free should allow empty api keys") + } + if !option.SupportsFetch { + t.Fatal("gpt4free provider option should report supports_fetch") + } + } if option, ok := optionsByID["siliconflow"]; !ok { t.Fatal("siliconflow provider option missing") } else if option.DefaultAPIBase != "https://api.siliconflow.cn/v1" { From 0dfdb54198399872455f58d02685aaea12ff977e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 21 May 2026 18:28:28 +0000 Subject: [PATCH 11/20] build(deps): bump github.com/adhocore/gronx from 1.19.7 to 1.20.0 Bumps [github.com/adhocore/gronx](https://github.com/adhocore/gronx) from 1.19.7 to 1.20.0. - [Release notes](https://github.com/adhocore/gronx/releases) - [Changelog](https://github.com/adhocore/gronx/blob/main/CHANGELOG.md) - [Commits](https://github.com/adhocore/gronx/compare/v1.19.7...v1.20.0) --- updated-dependencies: - dependency-name: github.com/adhocore/gronx dependency-version: 1.20.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index f4e494d3..110e0d67 100644 --- a/go.mod +++ b/go.mod @@ -5,7 +5,7 @@ go 1.25.10 require ( fyne.io/systray v1.12.1 github.com/SevereCloud/vksdk/v3 v3.3.1 - github.com/adhocore/gronx v1.19.7 + github.com/adhocore/gronx v1.20.0 github.com/anthropics/anthropic-sdk-go v1.26.0 github.com/atc0005/go-teams-notify/v2 v2.14.0 github.com/aws/aws-sdk-go-v2 v1.41.7 diff --git a/go.sum b/go.sum index a4137887..feb904b0 100644 --- a/go.sum +++ b/go.sum @@ -9,8 +9,8 @@ github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7Oputl github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU= github.com/SevereCloud/vksdk/v3 v3.3.1 h1:O86zsp5LQnHE+O5acvuXM/s6S1LyxzVTkF6+Lup0Jyg= github.com/SevereCloud/vksdk/v3 v3.3.1/go.mod h1:c6WaA5aocUYsXfkcUbg2qy45V9M1VDcqHHmHIN14NAw= -github.com/adhocore/gronx v1.19.7 h1:7hhFwChgDw9eHC3+TQ+OKKBqJnP44oWkDCnnW9nrsuA= -github.com/adhocore/gronx v1.19.7/go.mod h1:7oUY1WAU8rEJWmAxXR2DN0JaO4gi9khSgKjiRypqteg= +github.com/adhocore/gronx v1.20.0 h1:PD13Mo0wekkZ7ZZR9yb1TqeqTfybs7/K3ez9DmjQwEs= +github.com/adhocore/gronx v1.20.0/go.mod h1:7oUY1WAU8rEJWmAxXR2DN0JaO4gi9khSgKjiRypqteg= github.com/agnivade/levenshtein v1.2.1 h1:EHBY3UOn1gwdy/VbFwgo4cxecRznFk7fKWN1KOX7eoM= github.com/agnivade/levenshtein v1.2.1/go.mod h1:QVVI16kDrtSuwcpd0p1+xMC6Z/VfhtCyDIjcwga4/DU= github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883 h1:bvNMNQO63//z+xNgfBlViaCIJKLlCJ6/fmUseuG0wVQ= From 76175b4bcf7f4f5f2cfe564f4752dadc158e67e5 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 21 May 2026 18:28:37 +0000 Subject: [PATCH 12/20] build(deps): bump github.com/line/line-bot-sdk-go/v8 Bumps [github.com/line/line-bot-sdk-go/v8](https://github.com/line/line-bot-sdk-go) from 8.19.0 to 8.20.0. - [Release notes](https://github.com/line/line-bot-sdk-go/releases) - [Commits](https://github.com/line/line-bot-sdk-go/compare/v8.19.0...v8.20.0) --- updated-dependencies: - dependency-name: github.com/line/line-bot-sdk-go/v8 dependency-version: 8.20.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index f4e494d3..16be2e84 100644 --- a/go.mod +++ b/go.mod @@ -23,7 +23,7 @@ require ( github.com/gorilla/websocket v1.5.3 github.com/h2non/filetype v1.1.3 github.com/larksuite/oapi-sdk-go/v3 v3.7.5 - github.com/line/line-bot-sdk-go/v8 v8.19.0 + github.com/line/line-bot-sdk-go/v8 v8.20.0 github.com/mdp/qrterminal/v3 v3.2.1 github.com/minio/selfupdate v0.6.0 github.com/modelcontextprotocol/go-sdk v1.5.0 diff --git a/go.sum b/go.sum index a4137887..ada7f944 100644 --- a/go.sum +++ b/go.sum @@ -181,8 +181,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/larksuite/oapi-sdk-go/v3 v3.7.5 h1:dimv+ZAGia01f4xCDGvCiBHKWMf4K1AB7fGsM+lv5Jw= github.com/larksuite/oapi-sdk-go/v3 v3.7.5/go.mod h1:ZEplY+kwuIrj/nqw5uSCINNATcH3KdxSN7y+UxYY5fI= -github.com/line/line-bot-sdk-go/v8 v8.19.0 h1:5FD/1SprRZ8Y0FiUI6syYiBewOs0ak2tuUBMYN0wzE4= -github.com/line/line-bot-sdk-go/v8 v8.19.0/go.mod h1:AeSRUuu7WGgveGDJb6DyKyFUOst2UB2aF6LO2cQeuXs= +github.com/line/line-bot-sdk-go/v8 v8.20.0 h1:Jv22DV3JuQ5qZvniqUbg504bJrVzffXs2CMpyoiuIZU= +github.com/line/line-bot-sdk-go/v8 v8.20.0/go.mod h1:QMXJwPka2ysSeVQKWXkBp8DzBFs+CFAXFNo75KJtWho= github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= From 2992eccbf03e54258a6889cf19a5a4fe6255895f Mon Sep 17 00:00:00 2001 From: lxowalle <83055338+lxowalle@users.noreply.github.com> Date: Fri, 22 May 2026 10:06:40 +0800 Subject: [PATCH 13/20] feat: add request-scoped context policies (#2914) * feat: add request-scoped context policies Add named turn profiles under agents.defaults so callers can opt into per-request context and tool policies without changing default chat behavior. Profiles can disable history, system context, skill prompts, or tools, and can limit skills/tools with allow lists. Wire profile selection through Pico message payloads, agent turn execution, Web chat selection, and Web visual config. Reject invalid turn profiles before saving config through Web APIs and document the new request context policy behavior. * fix: address turn profile review blockers * feat: simplify request context policy config * fix: suppress tool prompt when turn tools are disabled * fix: enforce turn profile tool restrictions --- docs/guides/configuration.fr.md | 44 + docs/guides/configuration.ja.md | 44 + docs/guides/configuration.md | 44 + docs/guides/configuration.pt-br.md | 44 + docs/guides/configuration.vi.md | 44 + docs/guides/configuration.zh.md | 44 + pkg/agent/agent.go | 32 +- pkg/agent/agent_message.go | 5 + pkg/agent/agent_utils.go | 6 + pkg/agent/context.go | 367 +++-- pkg/agent/pipeline_execute.go | 44 +- pkg/agent/pipeline_finalize.go | 2 +- pkg/agent/pipeline_llm.go | 13 +- pkg/agent/pipeline_setup.go | 6 +- pkg/agent/prompt.go | 7 + pkg/agent/prompt_contributors.go | 56 +- pkg/agent/prompt_test.go | 174 +++ pkg/agent/prompt_turn.go | 90 +- pkg/agent/subturn.go | 4 + pkg/agent/turn_coord.go | 41 +- pkg/agent/turn_coord_test.go | 4 + pkg/agent/turn_profile_policy.go | 131 ++ pkg/agent/turn_profile_test.go | 1175 +++++++++++++++++ pkg/agent/turn_state.go | 8 +- pkg/channels/pico/pico_test.go | 33 + pkg/config/config.go | 4 + pkg/config/config_test.go | 124 ++ pkg/config/turn_profile.go | 133 ++ web/backend/api/config.go | 4 + web/backend/api/config_test.go | 243 +++- .../src/components/chat/chat-composer.tsx | 23 +- .../src/components/chat/chat-page.tsx | 2 +- .../src/components/config/config-page.tsx | 45 +- .../src/components/config/config-sections.tsx | 148 +++ .../src/components/config/form-model.ts | 63 + web/frontend/src/features/chat/controller.ts | 10 +- web/frontend/src/i18n/locales/en.json | 17 + web/frontend/src/i18n/locales/pt-br.json | 17 + web/frontend/src/i18n/locales/zh.json | 17 + 39 files changed, 3150 insertions(+), 162 deletions(-) create mode 100644 pkg/agent/turn_profile_policy.go create mode 100644 pkg/agent/turn_profile_test.go create mode 100644 pkg/config/turn_profile.go diff --git a/docs/guides/configuration.fr.md b/docs/guides/configuration.fr.md index a3ce6cb0..dcacc187 100644 --- a/docs/guides/configuration.fr.md +++ b/docs/guides/configuration.fr.md @@ -66,6 +66,50 @@ PicoClaw stocke les données dans votre workspace configuré (par défaut : `~/. > **Remarque :** Les modifications apportées à `AGENT.md`, `SOUL.md`, `USER.md` et `memory/MEMORY.md` sont détectées automatiquement au moment de l'exécution via le suivi de la date de modification (mtime). Il n'est **pas nécessaire de redémarrer le gateway** après avoir modifié ces fichiers — l'agent charge le nouveau contenu à la prochaine requête. +### Politique de contexte de requête + +`turn_profile` est une politique facultative sous `agents.defaults.turn_profile` pour contrôler le contexte chargé par chaque nouveau tour : historique, prompt système, prompts de skills et outils autorisés. Sans cette configuration, ou avec `"enabled": false`, PicoClaw garde son comportement normal. Avec `"enabled": true`, la politique ci-dessous s'applique à chaque nouveau tour. + +Tous les blocs utilisent les mêmes valeurs de `mode` : + +| Mode | Signification | +| --- | --- | +| `default` | Garde le comportement normal de PicoClaw. Un bloc absent ou sans `mode` vaut `default`. | +| `off` | Désactive ce bloc pour le tour. | +| `custom` | Utilise une liste d'autorisation. Dans cette version, `custom` est pris en charge seulement pour `skills` et `tools`; l'utiliser pour `history` ou `system_prompt` produit une erreur de validation. | + +Blocs disponibles : + +| Bloc | Ce qu'il contrôle | +| --- | --- | +| `history` | Lecture de l'historique et du résumé, écriture des messages utilisateur/assistant/outil, ingestion de contexte, compression et résumé. | +| `system_prompt` | Injection de l'identité PicoClaw, des instructions de l'espace de travail, de la mémoire, du contexte d'exécution et du résumé. Les prompts système externes restent autorisés quand ce bloc est `off`. | +| `skills` | Chargement du catalogue de skills et du contenu des skills actifs. `custom.allow` ne garde que les noms listés. | +| `tools` | Outils exposés au modèle et autorisés à l'exécution. `custom.allow` ne garde que les outils enregistrés et listés. | + +Quand `system_prompt.mode` vaut `off`, que des outils restent visibles et qu'aucun prompt système externe n'est fourni, PicoClaw réutilise sa règle existante d'utilisation des outils comme prompt minimal de secours. Si `tools.mode` vaut `off`, ce prompt de secours n'est pas ajouté. + +Exemple de contexte propre avec outils web : + +```json +{ + "agents": { + "defaults": { + "turn_profile": { + "enabled": true, + "history": { "mode": "off" }, + "system_prompt": { "mode": "off" }, + "skills": { "mode": "off" }, + "tools": { + "mode": "custom", + "allow": ["web_search", "web_fetch"] + } + } + } + } +} +``` + ### Sources de Compétences Par défaut, les compétences sont chargées depuis : diff --git a/docs/guides/configuration.ja.md b/docs/guides/configuration.ja.md index 38d85bc6..7dd67644 100644 --- a/docs/guides/configuration.ja.md +++ b/docs/guides/configuration.ja.md @@ -67,6 +67,50 @@ PicoClaw は設定されたワークスペース(デフォルト: `~/.picoclaw > **注意:** `AGENT.md`、`SOUL.md`、`USER.md` および `memory/MEMORY.md` への変更は、ファイル更新時刻(mtime)の追跡により実行時に自動検出されます。これらのファイルを編集した後に **gateway を再起動する必要はありません** — Agent は次のリクエスト時に最新の内容を自動的に読み込みます。 +### リクエストコンテキストポリシー + +`turn_profile` は `agents.defaults.turn_profile` に置く任意のリクエストコンテキストポリシーです。各ターンに履歴、system prompt、skill prompt、許可ツールを含めるかどうかを制御します。未設定、または `"enabled": false` の場合、PicoClaw は通常動作のままです。`"enabled": true` にすると、このポリシーが各新規ターンに適用されます。 + +各ブロックは同じ `mode` を使います。 + +| Mode | 意味 | +| --- | --- | +| `default` | PicoClaw の通常動作を維持します。ブロックまたは `mode` が省略された場合も `default` です。 | +| `off` | そのブロックを無効にします。 | +| `custom` | 許可リストを使います。このバージョンでは `skills` と `tools` のみ対応し、`history` や `system_prompt` で使うと検証エラーになります。 | + +ブロックの意味: + +| ブロック | 制御する内容 | +| --- | --- | +| `history` | 履歴と要約の読み込み、ユーザー/アシスタント/ツールメッセージの保存、コンテキスト取り込み、圧縮と要約。 | +| `system_prompt` | PicoClaw の既定の identity、ワークスペース指示、メモリ、実行時コンテキスト、要約の注入。`off` でも外部 system prompt は利用できます。 | +| `skills` | Skill カタログと active skill のプロンプト内容。`custom.allow` は列挙した skill 名だけを残します。 | +| `tools` | モデルに見せ、実行を許可するツール。`custom.allow` は登録済みで列挙されたツール名だけを残します。 | + +`system_prompt.mode` が `off` で、ツールが表示され、外部 system prompt がない場合、PicoClaw は既存のツール使用ルールを最小のフォールバックプロンプトとして再利用します。`tools.mode` が `off` の場合、このフォールバックは追加されません。 + +Web ツールだけを残すクリーンなコンテキスト例: + +```json +{ + "agents": { + "defaults": { + "turn_profile": { + "enabled": true, + "history": { "mode": "off" }, + "system_prompt": { "mode": "off" }, + "skills": { "mode": "off" }, + "tools": { + "mode": "custom", + "allow": ["web_search", "web_fetch"] + } + } + } + } +} +``` + ### スキルソース デフォルトでは、スキルは以下の順序で読み込まれます: diff --git a/docs/guides/configuration.md b/docs/guides/configuration.md index 83a62fd0..ef4802a7 100644 --- a/docs/guides/configuration.md +++ b/docs/guides/configuration.md @@ -99,6 +99,50 @@ The `evolution` block controls PicoClaw's self-evolution runtime. When enabled, Use `observe` first if you want to inspect learning records without generating skill changes. Use `draft` when you want PicoClaw to prepare reviewable improvements. Use `apply` only when you are comfortable letting accepted drafts update workspace skills. +### Request Context Policy + +`turn_profile` is an optional request context policy under `agents.defaults.turn_profile`. Leave it unset or set `"enabled": false` to keep PicoClaw's normal behavior. When `"enabled": true`, the same policy applies to every new turn. + +Each block uses the same `mode` values: + +| Mode | Meaning | +| --- | --- | +| `default` | Keep PicoClaw's normal behavior for that block. Missing blocks and missing `mode` fields are treated as `default`. | +| `off` | Disable that block for the turn. | +| `custom` | Use an allow list. In this version, `custom` is supported only for `skills` and `tools`; using it for `history` or `system_prompt` is a validation error. | + +Profile blocks: + +| Block | What it controls | +| --- | --- | +| `history` | Whether the turn reads prior session history and summary, writes user/assistant/tool messages, ingests context, and runs compaction or summarization. | +| `system_prompt` | Whether PicoClaw injects its default identity, workspace instructions, memory, runtime context, and summary. External request system prompts are still allowed when this is `off`. | +| `skills` | Whether the skill catalog and active skill prompt content are loaded. `custom.allow` keeps only the listed skill names in prompt context. | +| `tools` | Which callable tools are exposed to the model and allowed at execution time. `custom.allow` keeps only listed registered tool names. | + +When `system_prompt.mode` is `off`, tools are still visible, and no external system prompt is supplied, PicoClaw uses its existing tool-use rule as the minimal fallback prompt. If `tools.mode` is `off`, no fallback prompt is added. + +Example clean web policy: + +```json +{ + "agents": { + "defaults": { + "turn_profile": { + "enabled": true, + "history": { "mode": "off" }, + "system_prompt": { "mode": "off" }, + "skills": { "mode": "off" }, + "tools": { + "mode": "custom", + "allow": ["web_search", "web_fetch"] + } + } + } + } +} +``` + ### Web launcher dashboard **picoclaw-launcher** serves a browser UI that requires password sign-in first. On first run, open `/launcher-setup` to create the dashboard password. Later manual sign-ins use `/launcher-login`. diff --git a/docs/guides/configuration.pt-br.md b/docs/guides/configuration.pt-br.md index b1c95f5b..bb5d1413 100644 --- a/docs/guides/configuration.pt-br.md +++ b/docs/guides/configuration.pt-br.md @@ -67,6 +67,50 @@ O PicoClaw armazena dados no seu workspace configurado (padrão: `~/.picoclaw/wo > **Nota:** Alterações em `AGENT.md`, `SOUL.md`, `USER.md` e `memory/MEMORY.md` são detectadas automaticamente em tempo de execução via rastreamento de data de modificação (mtime). **Não é necessário reiniciar o gateway** após editar esses arquivos — o agente carrega o novo conteúdo na próxima requisição. +### Política de contexto da requisição + +`turn_profile` é uma política opcional em `agents.defaults.turn_profile` para controlar qual contexto cada novo turno carrega: histórico, prompt de sistema, prompts de skills e ferramentas permitidas. Sem essa configuração, ou com `"enabled": false`, o PicoClaw mantém o comportamento normal. Com `"enabled": true`, a política abaixo se aplica a cada novo turno. + +Todos os blocos usam os mesmos valores de `mode`: + +| Mode | Significado | +| --- | --- | +| `default` | Mantém o comportamento normal do PicoClaw. Blocos ausentes ou sem `mode` são tratados como `default`. | +| `off` | Desativa esse bloco para o turno. | +| `custom` | Usa uma lista de permissão. Nesta versão, `custom` só é suportado para `skills` e `tools`; usá-lo em `history` ou `system_prompt` gera erro de validação. | + +Blocos disponíveis: + +| Bloco | O que controla | +| --- | --- | +| `history` | Leitura de histórico e resumo, gravação de mensagens de usuário/assistente/ferramenta, ingestão de contexto, compactação e resumo. | +| `system_prompt` | Injeção da identidade padrão do PicoClaw, instruções do workspace, memória, contexto de execução e resumo. Prompts de sistema externos ainda são permitidos quando este bloco está `off`. | +| `skills` | Catálogo de skills e conteúdo de skills ativas no prompt. `custom.allow` mantém apenas os nomes listados. | +| `tools` | Ferramentas visíveis ao modelo e permitidas na execução. `custom.allow` mantém apenas ferramentas registradas e listadas. | + +Quando `system_prompt.mode` é `off`, ferramentas continuam visíveis e nenhum prompt de sistema externo é fornecido, o PicoClaw reutiliza sua regra existente de uso de ferramentas como prompt mínimo de fallback. Se `tools.mode` é `off`, esse fallback não é adicionado. + +Exemplo de contexto limpo com ferramentas web: + +```json +{ + "agents": { + "defaults": { + "turn_profile": { + "enabled": true, + "history": { "mode": "off" }, + "system_prompt": { "mode": "off" }, + "skills": { "mode": "off" }, + "tools": { + "mode": "custom", + "allow": ["web_search", "web_fetch"] + } + } + } + } +} +``` + ### Fontes de Skills Por padrão, as skills são carregadas de: diff --git a/docs/guides/configuration.vi.md b/docs/guides/configuration.vi.md index 79ab4723..516ebe9f 100644 --- a/docs/guides/configuration.vi.md +++ b/docs/guides/configuration.vi.md @@ -67,6 +67,50 @@ PicoClaw lưu trữ dữ liệu trong workspace đã cấu hình (mặc định: > **Lưu ý:** Các thay đổi đối với `AGENT.md`, `SOUL.md`, `USER.md` và `memory/MEMORY.md` được tự động phát hiện trong thời gian chạy thông qua theo dõi thời gian sửa đổi file (mtime). **Không cần khởi động lại gateway** sau khi chỉnh sửa các file này — agent sẽ tải nội dung mới vào yêu cầu tiếp theo. +### Chính sách ngữ cảnh request + +`turn_profile` là chính sách tùy chọn trong `agents.defaults.turn_profile` để kiểm soát ngữ cảnh mỗi turn mới mang theo: lịch sử, system prompt, prompt skills và các tool được phép gọi. Nếu không cấu hình, hoặc đặt `"enabled": false`, PicoClaw giữ nguyên hành vi mặc định. Khi đặt `"enabled": true`, chính sách bên dưới áp dụng cho mỗi turn mới. + +Mỗi block dùng chung các giá trị `mode`: + +| Mode | Ý nghĩa | +| --- | --- | +| `default` | Giữ hành vi bình thường của PicoClaw. Block bị thiếu hoặc thiếu `mode` đều được xem là `default`. | +| `off` | Tắt block đó cho turn. | +| `custom` | Dùng danh sách cho phép. Phiên bản này chỉ hỗ trợ `custom` cho `skills` và `tools`; dùng cho `history` hoặc `system_prompt` sẽ lỗi validate. | + +Các block: + +| Block | Nội dung kiểm soát | +| --- | --- | +| `history` | Đọc lịch sử và tóm tắt, ghi tin nhắn user/assistant/tool, nạp context, compact và summarize. | +| `system_prompt` | Chèn identity mặc định của PicoClaw, chỉ dẫn workspace, memory, runtime context và summary. System prompt từ request bên ngoài vẫn được dùng khi block này `off`. | +| `skills` | Catalog skills và nội dung active skill trong prompt. `custom.allow` chỉ giữ các tên skill được liệt kê. | +| `tools` | Công cụ hiển thị cho model và được phép thực thi. `custom.allow` chỉ giữ các tool đã đăng ký và được liệt kê. | + +Khi `system_prompt.mode` là `off`, tools vẫn hiển thị và không có system prompt bên ngoài, PicoClaw dùng lại quy tắc dùng tool hiện có làm prompt fallback tối thiểu. Nếu `tools.mode` là `off`, fallback này không được thêm. + +Ví dụ ngữ cảnh sạch chỉ giữ tool web: + +```json +{ + "agents": { + "defaults": { + "turn_profile": { + "enabled": true, + "history": { "mode": "off" }, + "system_prompt": { "mode": "off" }, + "skills": { "mode": "off" }, + "tools": { + "mode": "custom", + "allow": ["web_search", "web_fetch"] + } + } + } + } +} +``` + ### Nguồn Skill Mặc định, skill được tải từ: diff --git a/docs/guides/configuration.zh.md b/docs/guides/configuration.zh.md index 790dd642..1d04a0d4 100644 --- a/docs/guides/configuration.zh.md +++ b/docs/guides/configuration.zh.md @@ -97,6 +97,50 @@ PicoClaw 将数据存储在您配置的工作区中(默认:`~/.picoclaw/work 如果你只想先检查学习记录,建议从 `observe` 开始。需要生成可审查改进时使用 `draft`。只有在你接受让已通过的草稿更新工作区技能时,才使用 `apply`。 +### 请求上下文策略 + +`turn_profile` 是 `agents.defaults.turn_profile` 下的可选请求上下文策略,用来控制每个新回合是否带入历史、系统提示、技能提示,以及允许调用哪些工具。不写该配置或设置 `"enabled": false` 时,PicoClaw 完全保持原逻辑;设置 `"enabled": true` 后,下面的策略会应用到每个新回合。 + +所有块都使用同一组 `mode`: + +| Mode | 含义 | +| --- | --- | +| `default` | 保持 PicoClaw 原逻辑。块缺失或 `mode` 缺失都按 `default` 处理。 | +| `off` | 关闭该块。 | +| `custom` | 使用允许列表。本版本仅支持 `skills` 和 `tools`,在 `history` 或 `system_prompt` 中使用会触发配置校验错误。 | + +各块含义: + +| 块 | 控制内容 | +| --- | --- | +| `history` | 是否读取历史和摘要、写入用户/助手/工具消息、写入 context manager,以及是否压缩或总结本轮会话。 | +| `system_prompt` | 是否注入 PicoClaw 默认身份、工作区指令、记忆、运行时上下文和摘要。关闭后仍可使用外部传入的 system prompt。 | +| `skills` | 是否加载技能目录和 active skill 提示。`custom.allow` 只保留列出的技能名。 | +| `tools` | 暴露给模型并允许执行的工具。`custom.allow` 只保留已注册且列出的工具名。 | + +当 `system_prompt.mode` 为 `off`、工具仍可见且没有外部 system prompt 时,PicoClaw 会复用现有的工具使用规则作为最小兜底提示。如果 `tools.mode` 为 `off`,则不会添加兜底提示。 + +只保留 Web 工具的干净上下文示例: + +```json +{ + "agents": { + "defaults": { + "turn_profile": { + "enabled": true, + "history": { "mode": "off" }, + "system_prompt": { "mode": "off" }, + "skills": { "mode": "off" }, + "tools": { + "mode": "custom", + "allow": ["web_search", "web_fetch"] + } + } + } + } +} +``` + ### Web 启动器控制台 用 **picoclaw-launcher** 打开浏览器控制台前需要先使用密码登录。首次启动时打开 `/launcher-setup` 创建 dashboard 登录密码;后续手动登录使用 `/launcher-login`。 diff --git a/pkg/agent/agent.go b/pkg/agent/agent.go index 0392829f..90745e5d 100644 --- a/pkg/agent/agent.go +++ b/pkg/agent/agent.go @@ -79,17 +79,18 @@ type AgentLoop struct { // processOptions configures how a message is processed type processOptions struct { - Dispatch DispatchRequest // Normalized routed request boundary for this turn - SessionKey string // Session identifier for history/context - SessionAliases []string // Compatibility aliases for the session key - Channel string // Target channel for tool execution - ChatID string // Target chat ID for tool execution - MessageID string // Current inbound platform message ID - ReplyToMessageID string // Current inbound reply target message ID - SenderID string // Current sender ID for dynamic context - SenderDisplayName string // Current sender display name for dynamic context - UserMessage string // User message content (may include prefix) - ForcedSkills []string // Skills explicitly requested for this message + Dispatch DispatchRequest // Normalized routed request boundary for this turn + SessionKey string // Session identifier for history/context + SessionAliases []string // Compatibility aliases for the session key + Channel string // Target channel for tool execution + ChatID string // Target chat ID for tool execution + MessageID string // Current inbound platform message ID + ReplyToMessageID string // Current inbound reply target message ID + SenderID string // Current sender ID for dynamic context + SenderDisplayName string // Current sender display name for dynamic context + UserMessage string // User message content (may include prefix) + ForcedSkills []string // Skills explicitly requested for this message + TurnProfile config.EffectiveTurnProfile SystemPromptOverride string // Override the default system prompt (Used by SubTurns) Media []string // media:// refs from inbound message InitialSteeringMessages []providers.Message // Steering messages from refactor/agent @@ -534,17 +535,22 @@ func (al *AgentLoop) runAgentLoop( opts processOptions, ) (string, error) { opts = normalizeProcessOptions(opts) + var err error + opts, err = resolveTurnProfileOptions(al.GetConfig(), opts) + if err != nil { + return "", err + } // Record last channel for heartbeat notifications (skip internal channels and cli) if opts.Dispatch.Channel() != "" && opts.Dispatch.ChatID() != "" && !constants.IsInternalChannel(opts.Dispatch.Channel()) { channelKey := fmt.Sprintf("%s:%s", opts.Dispatch.Channel(), opts.Dispatch.ChatID()) - if err := al.RecordLastChannel(channelKey); err != nil { + if recordErr := al.RecordLastChannel(channelKey); recordErr != nil { logger.WarnCF( "agent", "Failed to record last channel", - map[string]any{"error": err.Error()}, + map[string]any{"error": recordErr.Error()}, ) } } diff --git a/pkg/agent/agent_message.go b/pkg/agent/agent_message.go index 4d2886a8..419664ed 100644 --- a/pkg/agent/agent_message.go +++ b/pkg/agent/agent_message.go @@ -193,6 +193,11 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage) SendResponse: false, AllowInterimPicoPublish: true, } + var err error + opts, err = resolveTurnProfileOptions(al.GetConfig(), opts) + if err != nil { + return "", err + } // context-dependent commands check their own Runtime fields and report // "unavailable" when the required capability is nil. diff --git a/pkg/agent/agent_utils.go b/pkg/agent/agent_utils.go index 8c638deb..effa2b19 100644 --- a/pkg/agent/agent_utils.go +++ b/pkg/agent/agent_utils.go @@ -481,6 +481,9 @@ func activeSkillNames(agent *AgentInstance, opts processOptions) []string { if agent == nil { return nil } + if turnProfileSkillsOff(opts.TurnProfile) { + return nil + } combined := make([]string, 0, len(agent.SkillsFilter)+len(opts.ForcedSkills)) combined = append(combined, agent.SkillsFilter...) @@ -509,6 +512,9 @@ func activeSkillNames(agent *AgentInstance, opts processOptions) []string { resolved = append(resolved, name) } + if turnProfileCustomSkills(opts.TurnProfile) { + return filterNamesByTurnProfile(resolved, opts.TurnProfile.AllowedSkills) + } return resolved } diff --git a/pkg/agent/context.go b/pkg/agent/context.go index 87bdd6b4..01e9ec0b 100644 --- a/pkg/agent/context.go +++ b/pkg/agent/context.go @@ -54,9 +54,13 @@ func (cb *ContextBuilder) WithToolDiscovery(useBM25, useRegex bool) *ContextBuil useBM25: useBM25, useRegex: useRegex, }); err != nil { - logger.WarnCF("agent", "Failed to register tool discovery prompt contributor", map[string]any{ - "error": err.Error(), - }) + logger.WarnCF( + "agent", + "Failed to register tool discovery prompt contributor", + map[string]any{ + "error": err.Error(), + }, + ) } } return cb @@ -77,9 +81,13 @@ func (cb *ContextBuilder) WithAgentDiscovery( agentID: agentID, discover: discover, }); err != nil { - logger.WarnCF("agent", "Failed to register agent discovery prompt contributor", map[string]any{ - "error": err.Error(), - }) + logger.WarnCF( + "agent", + "Failed to register agent discovery prompt contributor", + map[string]any{ + "error": err.Error(), + }, + ) } } return cb @@ -130,9 +138,34 @@ func (cb *ContextBuilder) promptRegistryOrDefault() *PromptRegistry { return cb.promptRegistry } -func (cb *ContextBuilder) getIdentity() string { +func (cb *ContextBuilder) getIdentity(includeToolUseRule bool) string { workspacePath, _ := filepath.Abs(filepath.Join(cb.workspace)) version := config.FormatVersion() + rules := []string{} + if includeToolUseRule { + rules = append(rules, toolUseSystemPromptRule()) + } + accuracyRule := "**Be helpful and accurate** - Briefly explain what you're doing." + if includeToolUseRule { + accuracyRule = "**Be helpful and accurate** - When using tools, briefly explain what you're doing." + } + rules = append( + rules, + accuracyRule, + "**Context summaries** - Conversation summaries provided as context are approximate references only. They may be incomplete or outdated. Always defer to explicit user instructions over summary content.", + ) + if includeToolUseRule { + rules = append( + rules, + fmt.Sprintf( + "**Memory** - When interacting with me if something seems memorable, update %s/memory/MEMORY.md", + workspacePath, + ), + ) + } + for i, rule := range rules { + rules[i] = fmt.Sprintf("%d. %s", i+1, rule) + } return fmt.Sprintf( `# picoclaw 🦞 (%s) @@ -147,14 +180,15 @@ Your workspace is at: %s ## Important Rules -1. **ALWAYS use tools** - When you need to perform an action (schedule reminders, send messages, execute commands, etc.), you MUST call the appropriate tool. Do NOT just say you'll do it or pretend to do it. - -2. **Be helpful and accurate** - When using tools, briefly explain what you're doing. - -3. **Memory** - When interacting with me if something seems memorable, update %s/memory/MEMORY.md - -4. **Context summaries** - Conversation summaries provided as context are approximate references only. They may be incomplete or outdated. Always defer to explicit user instructions over summary content.`, - version, workspacePath, workspacePath, workspacePath, workspacePath, workspacePath) +%s +`, + version, + workspacePath, + workspacePath, + workspacePath, + workspacePath, + strings.Join(rules, "\n\n"), + ) } func formatToolDiscoveryRule(useBM25, useRegex bool) string { @@ -181,6 +215,20 @@ func (cb *ContextBuilder) BuildSystemPrompt() string { } func (cb *ContextBuilder) BuildSystemPromptParts() []PromptPart { + return cb.buildSystemPromptParts(systemPromptBuildOptions{ + IncludeSkillCatalog: true, + IncludeToolUseRule: true, + }) +} + +type systemPromptBuildOptions struct { + IncludeSkillCatalog bool + IncludeToolUseRule bool + AllowedSkills []string + AllowedTools []string +} + +func (cb *ContextBuilder) buildSystemPromptParts(opts systemPromptBuildOptions) []PromptPart { stack := NewPromptStack(cb.promptRegistryOrDefault()) add := func(part PromptPart) { if err := stack.Add(part); err != nil { @@ -201,7 +249,7 @@ func (cb *ContextBuilder) BuildSystemPromptParts() []PromptPart { Slot: PromptSlotIdentity, Source: PromptSource{ID: PromptSourceKernel, Name: "identity"}, Title: "picoclaw identity", - Content: cb.getIdentity(), + Content: cb.getIdentity(opts.IncludeToolUseRule), Stable: true, Cache: PromptCacheEphemeral, }) @@ -222,8 +270,19 @@ func (cb *ContextBuilder) BuildSystemPromptParts() []PromptPart { } // Skills - show summary, AI can read full content with read_file tool - skillsSummary := cb.skillsLoader.BuildSkillsSummary() + skillsSummary := "" + if opts.IncludeSkillCatalog { + skillsSummary = cb.buildSkillsSummary(opts.AllowedSkills) + } if skillsSummary != "" { + skillIntro := "The following skills extend your capabilities." + readFileAllowed := promptAllowsTool( + PromptBuildRequest{AllowedTools: opts.AllowedTools}, + "read_file", + ) + if opts.IncludeToolUseRule && readFileAllowed { + skillIntro += " To use a skill, read its SKILL.md file using the read_file tool." + } add(PromptPart{ ID: "capability.skill_catalog", Layer: PromptLayerCapability, @@ -232,9 +291,9 @@ func (cb *ContextBuilder) BuildSystemPromptParts() []PromptPart { Title: "skill catalog", Content: fmt.Sprintf(`# Skills -The following skills extend your capabilities. To use a skill, read its SKILL.md file using the read_file tool. +%s -%s`, skillsSummary), +%s`, skillIntro, skillsSummary), Stable: true, Cache: PromptCacheEphemeral, }) @@ -319,6 +378,96 @@ func (cb *ContextBuilder) BuildSystemPromptWithCache() string { return prompt } +func (cb *ContextBuilder) buildSystemPromptForRequest( + req PromptBuildRequest, +) (string, []providers.ContentBlock) { + if req.SuppressDefaultSystemPrompt { + return "", nil + } + + useDefaultCache := !req.SuppressSkillContext && + !req.SuppressToolUseRule && + len(req.AllowedSkills) == 0 && + len(req.AllowedTools) == 0 + if useDefaultCache { + staticPrompt := cb.BuildSystemPromptWithCache() + return staticPrompt, []providers.ContentBlock{ + promptContentBlock(PromptPart{ + ID: "kernel.static", + Layer: PromptLayerKernel, + Slot: PromptSlotIdentity, + Source: PromptSource{ID: PromptSourceKernel, Name: "static"}, + Content: staticPrompt, + }, &providers.CacheControl{Type: "ephemeral"}), + } + } + + parts := cb.buildSystemPromptParts(systemPromptBuildOptions{ + IncludeSkillCatalog: !req.SuppressSkillContext, + IncludeToolUseRule: !req.SuppressToolUseRule, + AllowedSkills: req.AllowedSkills, + AllowedTools: req.AllowedTools, + }) + staticPrompt := renderPromptPartsLegacy(parts) + blocks := make([]providers.ContentBlock, 0, len(parts)) + for _, part := range parts { + if strings.TrimSpace(part.Content) == "" { + continue + } + blocks = append(blocks, promptContentBlock(part, cacheControlForPromptPart(part))) + } + return staticPrompt, blocks +} + +func (cb *ContextBuilder) buildSkillsSummary(allowed []string) string { + if cb.skillsLoader == nil { + return "" + } + if len(allowed) == 0 { + return cb.skillsLoader.BuildSkillsSummary() + } + allowedSet := cleanAllowedSet(allowed) + if len(allowedSet) == 0 { + return "" + } + + var lines []string + lines = append(lines, "") + for _, s := range cb.skillsLoader.ListSkills() { + if _, ok := allowedSet[strings.ToLower(strings.TrimSpace(s.Name))]; !ok { + continue + } + lines = append(lines, " ") + lines = append(lines, fmt.Sprintf(" %s", xmlEscapeForPrompt(s.Name))) + lines = append( + lines, + fmt.Sprintf(" %s", xmlEscapeForPrompt(s.Description)), + ) + lines = append( + lines, + fmt.Sprintf(" %s", xmlEscapeForPrompt(s.Path)), + ) + lines = append(lines, fmt.Sprintf(" %s", xmlEscapeForPrompt(s.Source))) + lines = append(lines, " ") + } + if len(lines) == 1 { + return "" + } + lines = append(lines, "") + return strings.Join(lines, "\n") +} + +func xmlEscapeForPrompt(s string) string { + replacer := strings.NewReplacer( + "&", "&", + "<", "<", + ">", ">", + "\"", """, + "'", "'", + ) + return replacer.Replace(s) +} + // EstimateSystemTokens estimates the token count of the full system message // that would be sent to the LLM, mirroring the composition logic in BuildMessages. // It includes: static prompt, dynamic context, active skills, and summary with @@ -687,19 +836,18 @@ func (cb *ContextBuilder) BuildMessages( func (cb *ContextBuilder) BuildMessagesFromPrompt(req PromptBuildRequest) []providers.Message { messages := []providers.Message{} - // The static part (identity, bootstrap, skills, memory) is cached locally to - // avoid repeated file I/O and string building on every call (fixes issue #607). - // Dynamic parts (time, session, summary) are appended per request. + // The default static part (identity, bootstrap, skills, memory) is cached + // locally to avoid repeated file I/O and string building on every call + // (fixes issue #607). Profile-customized static prompts are built on demand. + // Dynamic parts (time, session, summary) are appended per request unless the + // profile suppresses PicoClaw system context. // Everything is sent as a single system message for provider compatibility: // - Anthropic adapter extracts messages[0] (Role=="system") and maps its content // to the top-level "system" parameter in the Messages API request. A single // contiguous system block makes this extraction straightforward. // - Codex maps only the first system message to its instructions field. // - OpenAI-compat passes messages through as-is. - staticPrompt := cb.BuildSystemPromptWithCache() - - // Build short dynamic context (time, runtime, session) — changes per request - dynamicCtx := cb.buildDynamicContext(req.Channel, req.ChatID, req.SenderID, req.SenderDisplayName) + staticPrompt, contentBlocks := cb.buildSystemPromptForRequest(req) // Compose a single system message: static (cached) + dynamic + optional summary. // Keeping all system content in one message ensures every provider adapter can @@ -710,26 +858,27 @@ func (cb *ContextBuilder) BuildMessagesFromPrompt(req PromptBuildRequest) []prov // cache-aware adapters (Anthropic) can set per-block cache_control. // The static block is marked "ephemeral" — its prefix hash is stable // across requests, enabling LLM-side KV cache reuse. - stringParts := []string{staticPrompt} - - contentBlocks := []providers.ContentBlock{ - promptContentBlock(PromptPart{ - ID: "kernel.static", - Layer: PromptLayerKernel, - Slot: PromptSlotIdentity, - Source: PromptSource{ID: PromptSourceKernel, Name: "static"}, - Content: staticPrompt, - }, &providers.CacheControl{Type: "ephemeral"}), + var stringParts []string + if strings.TrimSpace(staticPrompt) != "" { + stringParts = append(stringParts, staticPrompt) } promptParts := append([]PromptPart(nil), req.Overlays...) - promptParts = append(promptParts, cb.buildActiveSkillsPromptParts(req.ActiveSkills)...) - if contributedParts, err := cb.promptRegistryOrDefault().Collect(context.Background(), req); err != nil { - logger.WarnCF("agent", "Prompt contributor collection failed", map[string]any{ - "error": err.Error(), - }) - } else { - promptParts = append(promptParts, contributedParts...) + if !req.SuppressDefaultSystemPrompt && !req.SuppressSkillContext { + activeSkills := append([]string(nil), req.ActiveSkills...) + if len(req.AllowedSkills) > 0 { + activeSkills = filterNamesByTurnProfile(activeSkills, req.AllowedSkills) + } + promptParts = append(promptParts, cb.buildActiveSkillsPromptParts(activeSkills)...) + } + if !req.SuppressDefaultSystemPrompt { + if contributedParts, err := cb.promptRegistryOrDefault().Collect(context.Background(), req); err != nil { + logger.WarnCF("agent", "Prompt contributor collection failed", map[string]any{ + "error": err.Error(), + }) + } else { + promptParts = append(promptParts, contributedParts...) + } } if len(promptParts) > 0 { @@ -752,35 +901,62 @@ func (cb *ContextBuilder) BuildMessagesFromPrompt(req PromptBuildRequest) []prov } } - runtimePart := PromptPart{ - ID: "context.runtime", - Layer: PromptLayerContext, - Slot: PromptSlotRuntime, - Source: PromptSource{ID: PromptSourceRuntime, Name: "runtime"}, - Title: "runtime context", - Content: dynamicCtx, - Stable: false, - Cache: PromptCacheNone, - } - stringParts = append(stringParts, dynamicCtx) - contentBlocks = append(contentBlocks, promptContentBlock(runtimePart, nil)) - - if req.Summary != "" { - summaryPart := PromptPart{ - ID: "context.summary", - Layer: PromptLayerContext, - Slot: PromptSlotSummary, - Source: PromptSource{ID: PromptSourceSummary, Name: "context.summary"}, - Title: "context summary", - Content: fmt.Sprintf( - "CONTEXT_SUMMARY: The following is an approximate summary of prior conversation "+ - "for reference only. It may be incomplete or outdated — always defer to explicit instructions.\n\n%s", - req.Summary), - Stable: false, - Cache: PromptCacheNone, + dynamicChars := 0 + if !req.SuppressDefaultSystemPrompt { + // Build short dynamic context (time, runtime, session) — changes per request + dynamicCtx := cb.buildDynamicContext( + req.Channel, + req.ChatID, + req.SenderID, + req.SenderDisplayName, + ) + dynamicChars = len(dynamicCtx) + runtimePart := PromptPart{ + ID: "context.runtime", + Layer: PromptLayerContext, + Slot: PromptSlotRuntime, + Source: PromptSource{ID: PromptSourceRuntime, Name: "runtime"}, + Title: "runtime context", + Content: dynamicCtx, + Stable: false, + Cache: PromptCacheNone, } - stringParts = append(stringParts, summaryPart.Content) - contentBlocks = append(contentBlocks, promptContentBlock(summaryPart, nil)) + stringParts = append(stringParts, dynamicCtx) + contentBlocks = append(contentBlocks, promptContentBlock(runtimePart, nil)) + + if req.Summary != "" { + summaryPart := PromptPart{ + ID: "context.summary", + Layer: PromptLayerContext, + Slot: PromptSlotSummary, + Source: PromptSource{ID: PromptSourceSummary, Name: "context.summary"}, + Title: "context summary", + Content: fmt.Sprintf( + "CONTEXT_SUMMARY: The following is an approximate summary of prior conversation "+ + "for reference only. It may be incomplete or outdated — always defer to explicit instructions.\n\n%s", + req.Summary, + ), + Stable: false, + Cache: PromptCacheNone, + } + stringParts = append(stringParts, summaryPart.Content) + contentBlocks = append(contentBlocks, promptContentBlock(summaryPart, nil)) + } + } + + if len(stringParts) == 0 && req.ToolUseFallback { + fallbackPart := PromptPart{ + ID: "kernel.tool_use_fallback", + Layer: PromptLayerKernel, + Slot: PromptSlotIdentity, + Source: PromptSource{ID: PromptSourceKernel, Name: "tool_use_fallback"}, + Title: "tool use fallback", + Content: toolUseSystemPromptRule(), + Stable: true, + Cache: PromptCacheEphemeral, + } + stringParts = append(stringParts, fallbackPart.Content) + contentBlocks = append(contentBlocks, promptContentBlock(fallbackPart, nil)) } fullSystemPrompt := strings.Join(stringParts, "\n\n---\n\n") @@ -795,7 +971,7 @@ func (cb *ContextBuilder) BuildMessagesFromPrompt(req PromptBuildRequest) []prov logger.DebugCF("agent", "System prompt built", map[string]any{ "static_chars": len(staticPrompt), - "dynamic_chars": len(dynamicCtx), + "dynamic_chars": dynamicChars, "total_chars": len(fullSystemPrompt), "has_summary": req.Summary != "", "overlays": len(req.Overlays), @@ -814,11 +990,13 @@ func (cb *ContextBuilder) BuildMessagesFromPrompt(req PromptBuildRequest) []prov // Single system message containing all context — compatible with all providers. // SystemParts enables cache-aware adapters to set per-block cache_control; // Content is the concatenated fallback for adapters that don't read SystemParts. - messages = append(messages, providers.Message{ - Role: "system", - Content: fullSystemPrompt, - SystemParts: contentBlocks, - }) + if strings.TrimSpace(fullSystemPrompt) != "" { + messages = append(messages, providers.Message{ + Role: "system", + Content: fullSystemPrompt, + SystemParts: contentBlocks, + }) + } // Add conversation history messages = append(messages, history...) @@ -829,6 +1007,9 @@ func (cb *ContextBuilder) BuildMessagesFromPrompt(req PromptBuildRequest) []prov if strings.TrimSpace(req.CurrentMessage) != "" || len(req.Media) > 0 { messages = append(messages, userPromptMessage(req.CurrentMessage, req.Media)) } + if len(messages) == 0 { + messages = append(messages, userPromptMessage("", nil)) + } return messages } @@ -933,7 +1114,11 @@ func sanitizeHistoryForProvider(history []providers.Message) []providers.Message break } if next.ToolCallID == "" { - logger.DebugCF("agent", "Dropping tool result without tool_call_id", map[string]any{}) + logger.DebugCF( + "agent", + "Dropping tool result without tool_call_id", + map[string]any{}, + ) continue } if _, ok := expected[next.ToolCallID]; !ok { @@ -943,9 +1128,13 @@ func sanitizeHistoryForProvider(history []providers.Message) []providers.Message continue } if seenInBlock[next.ToolCallID] { - logger.DebugCF("agent", "Dropping duplicate tool result in tool block", map[string]any{ - "tool_call_id": next.ToolCallID, - }) + logger.DebugCF( + "agent", + "Dropping duplicate tool result in tool block", + map[string]any{ + "tool_call_id": next.ToolCallID, + }, + ) continue } seenInBlock[next.ToolCallID] = true @@ -955,7 +1144,11 @@ func sanitizeHistoryForProvider(history []providers.Message) []providers.Message allFound := !invalidToolCallID if invalidToolCallID { - logger.DebugCF("agent", "Dropping assistant message with empty tool_call_id", map[string]any{}) + logger.DebugCF( + "agent", + "Dropping assistant message with empty tool_call_id", + map[string]any{}, + ) } for toolCallID, found := range expected { if !found { @@ -985,9 +1178,13 @@ func sanitizeHistoryForProvider(history []providers.Message) []providers.Message } if msg.Role == "tool" { - logger.DebugCF("agent", "Dropping orphaned tool message after validation", map[string]any{ - "tool_call_id": msg.ToolCallID, - }) + logger.DebugCF( + "agent", + "Dropping orphaned tool message after validation", + map[string]any{ + "tool_call_id": msg.ToolCallID, + }, + ) continue } diff --git a/pkg/agent/pipeline_execute.go b/pkg/agent/pipeline_execute.go index 7281a0ea..81fc06ec 100644 --- a/pkg/agent/pipeline_execute.go +++ b/pkg/agent/pipeline_execute.go @@ -131,6 +131,36 @@ toolLoop: toolName := tc.Name toolArgs := cloneStringAnyMap(tc.Arguments) + denyByTurnProfile := func() bool { + if turnProfileToolAllowed(ts.profile, toolName) { + return false + } + exec.allResponsesHandled = false + denyContent := fmt.Sprintf("Tool %q is not allowed by the active turn profile.", toolName) + al.emitEvent( + runtimeevents.KindAgentToolExecSkipped, + ts.eventMeta("runTurn", "turn.tool.skipped"), + ToolExecSkippedPayload{ + Tool: toolName, + Reason: denyContent, + }, + ) + deniedMsg := providers.Message{ + Role: "tool", + Content: denyContent, + ToolCallID: tc.ID, + } + messages = append(messages, deniedMsg) + if !ts.opts.NoHistory { + ts.agent.Sessions.AddFullMessage(ts.sessionKey, deniedMsg) + ts.recordPersistedMessage(deniedMsg) + } + return true + } + + if denyByTurnProfile() { + continue + } if al.hooks != nil { toolReq, decision := al.hooks.BeforeTool(turnCtx, &ToolCallHookRequest{ @@ -363,7 +393,9 @@ toolLoop: content := al.cfg.FilterSensitiveData(result.ForLLM) msg := subTurnResultPromptMessage(content) messages = append(messages, msg) - ts.agent.Sessions.AddFullMessage(ts.sessionKey, msg) + if !ts.opts.NoHistory { + ts.agent.Sessions.AddFullMessage(ts.sessionKey, msg) + } } default: } @@ -441,6 +473,10 @@ toolLoop: } } + if denyByTurnProfile() { + continue + } + argsJSON, _ := json.Marshal(toolArgs) argsPreview := utils.Truncate(string(argsJSON), 200) logger.InfoCF("agent", fmt.Sprintf("Tool call: %s(%s)", toolName, argsPreview), @@ -748,7 +784,9 @@ toolLoop: content := al.cfg.FilterSensitiveData(result.ForLLM) msg := subTurnResultPromptMessage(content) messages = append(messages, msg) - ts.agent.Sessions.AddFullMessage(ts.sessionKey, msg) + if !ts.opts.NoHistory { + ts.agent.Sessions.AddFullMessage(ts.sessionKey, msg) + } } default: } @@ -802,7 +840,7 @@ toolLoop: }) } } - if ts.opts.EnableSummary { + if !ts.opts.NoHistory && ts.opts.EnableSummary { al.contextManager.Compact(turnCtx, &CompactRequest{ SessionKey: ts.sessionKey, Reason: ContextCompressReasonSummarize, diff --git a/pkg/agent/pipeline_finalize.go b/pkg/agent/pipeline_finalize.go index 1b674d0a..d42f1091 100644 --- a/pkg/agent/pipeline_finalize.go +++ b/pkg/agent/pipeline_finalize.go @@ -65,7 +65,7 @@ func (p *Pipeline) Finalize( } } - if ts.opts.EnableSummary { + if !ts.opts.NoHistory && ts.opts.EnableSummary { al.contextManager.Compact( turnCtx, &CompactRequest{ diff --git a/pkg/agent/pipeline_llm.go b/pkg/agent/pipeline_llm.go index 6df2e4ab..0a31048a 100644 --- a/pkg/agent/pipeline_llm.go +++ b/pkg/agent/pipeline_llm.go @@ -37,9 +37,10 @@ func (p *Pipeline) CallLLM( // PreLLM: graceful terminal handling exec.gracefulTerminal, _ = ts.gracefulInterruptRequested() exec.providerToolDefs = ts.agent.Tools.ToProviderDefs() + exec.providerToolDefs = filterToolsByTurnProfile(exec.providerToolDefs, ts.profile) // Native web search support - webSearchEnabled := al.cfg.Tools.IsToolEnabled("web") + webSearchEnabled := al.cfg.Tools.IsToolEnabled("web") && turnProfileToolAllowed(ts.profile, "web_search") exec.useNativeSearch = webSearchEnabled && al.cfg.Tools.Web.PreferNative && func() bool { if ns, ok := ts.agent.Provider.(providers.NativeSearchCapable); ok { @@ -47,7 +48,6 @@ func (p *Pipeline) CallLLM( } return false }() - if exec.useNativeSearch { filtered := make([]providers.ToolDefinition, 0, len(exec.providerToolDefs)) for _, td := range exec.providerToolDefs { @@ -94,8 +94,13 @@ func (p *Pipeline) CallLLM( prevModel := exec.llmModel exec.llmModel = llmReq.Model exec.callMessages = llmReq.Messages - exec.providerToolDefs = llmReq.Tools + exec.providerToolDefs = filterToolsByTurnProfile(llmReq.Tools, ts.profile) exec.llmOpts = llmReq.Options + nativeSearchAllowed := exec.useNativeSearch && + turnProfileToolAllowed(ts.profile, "web_search") + if !nativeSearchAllowed { + delete(exec.llmOpts, "native_search") + } if strings.TrimSpace(exec.llmModel) != "" && exec.llmModel != prevModel { p.applyBeforeLLMModelRewrite(ts, exec) applyTurnThinkingOptions(exec, ts.agent, exec.activeProvider, true) @@ -410,7 +415,7 @@ func (p *Pipeline) CallLLM( contextualSkills = ts.agent.ContextBuilder.ResolveActiveSkillsForContext(ts.activeSkills) } ts.recordSkillContextSnapshot(skillContextTriggerContextRetryRebuild, contextualSkills) - rebuildPromptReq := promptBuildRequestForTurn(ts, exec.history, exec.summary, "", nil) + rebuildPromptReq := promptBuildRequestForTurn(ts, exec.history, exec.summary, "", nil, p.Cfg) rebuildPromptReq.ActiveSkills = append([]string(nil), contextualSkills...) exec.messages = ts.agent.ContextBuilder.BuildMessagesFromPrompt(rebuildPromptReq) exec.callMessages = exec.messages diff --git a/pkg/agent/pipeline_setup.go b/pkg/agent/pipeline_setup.go index 1fdd8f6c..f764959e 100644 --- a/pkg/agent/pipeline_setup.go +++ b/pkg/agent/pipeline_setup.go @@ -36,14 +36,14 @@ func (p *Pipeline) SetupTurn(ctx context.Context, ts *turnState) (*turnExecution contextualSkills = ts.agent.ContextBuilder.ResolveActiveSkillsForContext(ts.activeSkills) } ts.recordSkillContextSnapshot(skillContextTriggerInitialBuild, contextualSkills) - initialPromptReq := promptBuildRequestForTurn(ts, history, summary, ts.userMessage, ts.media) + initialPromptReq := promptBuildRequestForTurn(ts, history, summary, ts.userMessage, ts.media, cfg) initialPromptReq.ActiveSkills = append([]string(nil), contextualSkills...) messages := ts.agent.ContextBuilder.BuildMessagesFromPrompt(initialPromptReq) messages = resolveMediaRefs(messages, p.MediaStore, maxMediaSize) if !ts.opts.NoHistory { - toolDefs := ts.agent.Tools.ToProviderDefs() + toolDefs := filterToolsByTurnProfile(ts.agent.Tools.ToProviderDefs(), ts.profile) if isOverContextBudget(ts.agent.ContextWindow, messages, toolDefs, ts.agent.MaxTokens) { logger.WarnCF("agent", "Proactive compression: context budget exceeded before LLM call", map[string]any{"session_key": ts.sessionKey}) @@ -66,7 +66,7 @@ func (p *Pipeline) SetupTurn(ctx context.Context, ts *turnState) (*turnExecution history = resp.History summary = resp.Summary } - rebuildPromptReq := promptBuildRequestForTurn(ts, history, summary, ts.userMessage, ts.media) + rebuildPromptReq := promptBuildRequestForTurn(ts, history, summary, ts.userMessage, ts.media, cfg) rebuildPromptReq.ActiveSkills = append([]string(nil), contextualSkills...) messages = ts.agent.ContextBuilder.BuildMessagesFromPrompt(rebuildPromptReq) messages = resolveMediaRefs(messages, p.MediaStore, maxMediaSize) diff --git a/pkg/agent/prompt.go b/pkg/agent/prompt.go index 02c85036..1c66fe69 100644 --- a/pkg/agent/prompt.go +++ b/pkg/agent/prompt.go @@ -115,6 +115,13 @@ type PromptBuildRequest struct { ActiveSkills []string Overlays []PromptPart + + SuppressDefaultSystemPrompt bool + SuppressSkillContext bool + SuppressToolUseRule bool + AllowedSkills []string + AllowedTools []string + ToolUseFallback bool } type PromptContributor interface { diff --git a/pkg/agent/prompt_contributors.go b/pkg/agent/prompt_contributors.go index d6a2c09e..3db5b851 100644 --- a/pkg/agent/prompt_contributors.go +++ b/pkg/agent/prompt_contributors.go @@ -4,6 +4,8 @@ import ( "context" "fmt" "strings" + + "github.com/sipeed/picoclaw/pkg/tools" ) type toolDiscoveryPromptContributor struct { @@ -23,9 +25,17 @@ func (c toolDiscoveryPromptContributor) PromptSource() PromptSourceDescriptor { func (c toolDiscoveryPromptContributor) ContributePrompt( _ context.Context, - _ PromptBuildRequest, + req PromptBuildRequest, ) ([]PromptPart, error) { - content := formatToolDiscoveryRule(c.useBM25, c.useRegex) + if req.SuppressToolUseRule { + return nil, nil + } + useBM25 := c.useBM25 && promptAllowsTool(req, tools.BM25SearchToolName) + useRegex := c.useRegex && promptAllowsTool(req, tools.RegexSearchToolName) + if !useBM25 && !useRegex { + return nil, nil + } + content := formatToolDiscoveryRule(useBM25, useRegex) if strings.TrimSpace(content) == "" { return nil, nil } @@ -62,12 +72,19 @@ func (c mcpServerPromptContributor) PromptSource() PromptSourceDescriptor { func (c mcpServerPromptContributor) ContributePrompt( _ context.Context, - _ PromptBuildRequest, + req PromptBuildRequest, ) ([]PromptPart, error) { + if req.SuppressToolUseRule { + return nil, nil + } serverName := strings.TrimSpace(c.serverName) if serverName == "" || c.toolCount <= 0 { return nil, nil } + if len(req.AllowedTools) > 0 && + !promptAllowsToolPrefix(req, "mcp_"+promptSourceComponent(serverName)+"_") { + return nil, nil + } availability := "available as native tools" if c.deferred { @@ -110,8 +127,14 @@ func (c agentDiscoveryPromptContributor) PromptSource() PromptSourceDescriptor { func (c agentDiscoveryPromptContributor) ContributePrompt( _ context.Context, - _ PromptBuildRequest, + req PromptBuildRequest, ) ([]PromptPart, error) { + if req.SuppressToolUseRule { + return nil, nil + } + if !promptAllowsTool(req, "spawn") { + return nil, nil + } if c.discover == nil { return nil, nil } @@ -178,3 +201,28 @@ func promptSourceComponent(value string) string { } return result } + +func promptAllowsTool(req PromptBuildRequest, name string) bool { + if len(req.AllowedTools) == 0 { + return true + } + allowed := cleanAllowedSet(req.AllowedTools) + _, ok := allowed[strings.ToLower(strings.TrimSpace(name))] + return ok +} + +func promptAllowsToolPrefix(req PromptBuildRequest, prefix string) bool { + if len(req.AllowedTools) == 0 { + return true + } + prefix = strings.ToLower(strings.TrimSpace(prefix)) + if prefix == "" { + return false + } + for _, name := range req.AllowedTools { + if strings.HasPrefix(strings.ToLower(strings.TrimSpace(name)), prefix) { + return true + } + } + return false +} diff --git a/pkg/agent/prompt_test.go b/pkg/agent/prompt_test.go index b76b0040..605e5f4a 100644 --- a/pkg/agent/prompt_test.go +++ b/pkg/agent/prompt_test.go @@ -197,6 +197,75 @@ func TestContextBuilder_CollectsToolDiscoveryContributor(t *testing.T) { } } +func TestContextBuilder_SuppressesToolDiscoveryContributorWhenToolsUnavailable(t *testing.T) { + t.Setenv("PICOCLAW_BUILTIN_SKILLS", t.TempDir()) + cb := NewContextBuilder(t.TempDir()).WithToolDiscovery(true, false) + + messages := cb.BuildMessagesFromPrompt(PromptBuildRequest{ + CurrentMessage: "hello", + SuppressToolUseRule: true, + }) + system := messages[0] + if strings.Contains(system.Content, "tool_search_tool_bm25") { + t.Fatalf("system prompt includes tool discovery despite tools being unavailable: %q", system.Content) + } + for _, part := range system.SystemParts { + if part.PromptSource == string(PromptSourceToolDiscovery) { + t.Fatalf("system parts include tool discovery despite tools being unavailable: %#v", part) + } + } +} + +func TestContextBuilder_SuppressesToolReferencesWhenToolsUnavailable(t *testing.T) { + workspace := t.TempDir() + t.Setenv("PICOCLAW_BUILTIN_SKILLS", t.TempDir()) + writeTurnProfileSkill( + t, + workspace, + "research", + "---\ndescription: research skill\n---\n# research\n\nResearch carefully.", + ) + cb := NewContextBuilder(workspace) + + messages := cb.BuildMessagesFromPrompt(PromptBuildRequest{ + CurrentMessage: "hello", + SuppressToolUseRule: true, + }) + system := messages[0] + if strings.Contains(system.Content, "When using tools") || + strings.Contains(system.Content, "read_file tool") || + strings.Contains(system.Content, "update "+workspace+"/memory/MEMORY.md") { + t.Fatalf("system prompt includes tool references despite tools being unavailable: %q", system.Content) + } + if !strings.Contains(system.Content, "research") { + t.Fatalf("system prompt should keep non-tool skill catalog context, got: %q", system.Content) + } +} + +func TestContextBuilder_CustomToolAllowListSuppressesReadFileSkillInstruction(t *testing.T) { + workspace := t.TempDir() + t.Setenv("PICOCLAW_BUILTIN_SKILLS", t.TempDir()) + writeTurnProfileSkill( + t, + workspace, + "research", + "---\ndescription: research skill\n---\n# research\n\nResearch carefully.", + ) + cb := NewContextBuilder(workspace) + + messages := cb.BuildMessagesFromPrompt(PromptBuildRequest{ + CurrentMessage: "hello", + AllowedTools: []string{"web_search"}, + }) + system := messages[0] + if strings.Contains(system.Content, "read_file tool") { + t.Fatalf("system prompt includes read_file skill instruction without read_file permission: %q", system.Content) + } + if !strings.Contains(system.Content, "research") { + t.Fatalf("system prompt should keep skill catalog context, got: %q", system.Content) + } +} + func TestContextBuilder_CollectsMCPServerContributor(t *testing.T) { t.Setenv("PICOCLAW_BUILTIN_SKILLS", t.TempDir()) cb := NewContextBuilder(t.TempDir()) @@ -232,6 +301,111 @@ func TestContextBuilder_CollectsMCPServerContributor(t *testing.T) { } } +func TestContextBuilder_SuppressesMCPServerContributorWhenToolsUnavailable(t *testing.T) { + t.Setenv("PICOCLAW_BUILTIN_SKILLS", t.TempDir()) + cb := NewContextBuilder(t.TempDir()) + err := cb.RegisterPromptContributor(mcpServerPromptContributor{ + serverName: "GitHub Server", + toolCount: 3, + deferred: false, + }) + if err != nil { + t.Fatalf("RegisterPromptContributor() error = %v", err) + } + + messages := cb.BuildMessagesFromPrompt(PromptBuildRequest{ + CurrentMessage: "hello", + SuppressToolUseRule: true, + }) + system := messages[0] + if strings.Contains(system.Content, "MCP server `GitHub Server` is connected") || + strings.Contains(system.Content, "available as native tools") { + t.Fatalf("system prompt includes MCP tooling despite tools being unavailable: %q", system.Content) + } + for _, part := range system.SystemParts { + if part.PromptSource == "mcp:github_server" { + t.Fatalf("system parts include MCP tooling despite tools being unavailable: %#v", part) + } + } +} + +func TestContextBuilder_SuppressesAgentDiscoveryContributorWhenToolsUnavailable(t *testing.T) { + t.Setenv("PICOCLAW_BUILTIN_SKILLS", t.TempDir()) + cb := NewContextBuilder(t.TempDir()).WithAgentDiscovery( + "main", + func(agentID string) []AgentDescriptor { + return []AgentDescriptor{{ + ID: "helper", + Name: "Helper", + Description: "Helps with tasks", + }} + }, + ) + + messages := cb.BuildMessagesFromPrompt(PromptBuildRequest{ + CurrentMessage: "hello", + SuppressToolUseRule: true, + }) + system := messages[0] + if strings.Contains(system.Content, "Agent Discovery") || + strings.Contains(system.Content, "calling spawn") { + t.Fatalf("system prompt includes agent discovery despite tools being unavailable: %q", system.Content) + } + for _, part := range system.SystemParts { + if part.PromptSource == string(PromptSourceAgentDiscovery) { + t.Fatalf("system parts include agent discovery despite tools being unavailable: %#v", part) + } + } +} + +func TestContextBuilder_CustomToolAllowListSuppressesUnallowedToolContributors(t *testing.T) { + t.Setenv("PICOCLAW_BUILTIN_SKILLS", t.TempDir()) + cb := NewContextBuilder(t.TempDir()). + WithToolDiscovery(true, true). + WithAgentDiscovery( + "main", + func(agentID string) []AgentDescriptor { + return []AgentDescriptor{{ + ID: "helper", + Name: "Helper", + Description: "Helps with tasks", + }} + }, + ) + err := cb.RegisterPromptContributor(mcpServerPromptContributor{ + serverName: "GitHub Server", + toolCount: 3, + deferred: false, + }) + if err != nil { + t.Fatalf("RegisterPromptContributor() error = %v", err) + } + + messages := cb.BuildMessagesFromPrompt(PromptBuildRequest{ + CurrentMessage: "hello", + AllowedTools: []string{"echo_text"}, + }) + system := messages[0] + blockedSnippets := []string{ + "tool_search_tool_bm25", + "tool_search_tool_regex", + "MCP server `GitHub Server` is connected", + "Agent Discovery", + "calling spawn", + } + for _, snippet := range blockedSnippets { + if strings.Contains(system.Content, snippet) { + t.Fatalf("system prompt includes unallowed tool contributor %q: %q", snippet, system.Content) + } + } + for _, part := range system.SystemParts { + switch part.PromptSource { + case string(PromptSourceToolDiscovery), string(PromptSourceAgentDiscovery), "mcp:github_server": + t.Fatalf("system parts include unallowed tool contributor: %#v", part) + } + } +} + type testPromptContributor struct { desc PromptSourceDescriptor part PromptPart diff --git a/pkg/agent/prompt_turn.go b/pkg/agent/prompt_turn.go index 588a8f00..053eb846 100644 --- a/pkg/agent/prompt_turn.go +++ b/pkg/agent/prompt_turn.go @@ -4,6 +4,7 @@ import ( "fmt" "strings" + "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/providers" ) @@ -13,8 +14,9 @@ func promptBuildRequestForTurn( summary string, currentMessage string, media []string, + cfg *config.Config, ) PromptBuildRequest { - return PromptBuildRequest{ + req := PromptBuildRequest{ History: history, Summary: summary, CurrentMessage: currentMessage, @@ -26,6 +28,92 @@ func promptBuildRequestForTurn( ActiveSkills: activeSkillNames(ts.agent, ts.opts), Overlays: promptOverlaysForOptions(ts.opts), } + hasCallableTools := true + if ts.profile.Enabled { + hasCallableTools = turnProfileHasCallableTools(ts.profile, ts.agent.Tools.ToProviderDefs()) || + turnProfileNativeSearchCallable(cfg, ts.profile, ts.agent) + } + if turnProfileSystemPromptOff(ts.profile) { + req.SuppressDefaultSystemPrompt = true + req.SuppressSkillContext = true + req.ToolUseFallback = hasCallableTools + } + if ts.profile.Enabled && !hasCallableTools { + req.SuppressToolUseRule = true + } + if turnProfileSkillsOff(ts.profile) { + req.SuppressSkillContext = true + } + if turnProfileCustomSkills(ts.profile) { + req.AllowedSkills = append([]string(nil), ts.profile.AllowedSkills...) + } + if ts.profile.Enabled && ts.profile.ToolsMode == config.TurnProfileModeCustom { + req.AllowedTools = append([]string(nil), ts.profile.AllowedTools...) + } + return req +} + +func turnProfileNativeSearchCallable( + cfg *config.Config, + profile config.EffectiveTurnProfile, + agent *AgentInstance, +) bool { + if cfg == nil || agent == nil { + return false + } + if !cfg.Tools.IsToolEnabled("web") || !cfg.Tools.Web.PreferNative { + return false + } + if !turnProfileToolAllowed(profile, "web_search") { + return false + } + nativeProvider, ok := agent.Provider.(providers.NativeSearchCapable) + return ok && nativeProvider.SupportsNativeSearch() +} + +func promptBuildRequestForProcessOptions( + agent *AgentInstance, + opts processOptions, + history []providers.Message, + summary string, + currentMessage string, + media []string, +) PromptBuildRequest { + req := PromptBuildRequest{ + History: history, + Summary: summary, + CurrentMessage: currentMessage, + Media: append([]string(nil), media...), + Channel: opts.Channel, + ChatID: opts.ChatID, + SenderID: opts.SenderID, + SenderDisplayName: opts.SenderDisplayName, + ActiveSkills: activeSkillNames(agent, opts), + Overlays: promptOverlaysForOptions(opts), + } + profile := opts.TurnProfile + hasCallableTools := true + if profile.Enabled && agent != nil { + hasCallableTools = turnProfileHasCallableTools(profile, agent.Tools.ToProviderDefs()) + } + if turnProfileSystemPromptOff(profile) { + req.SuppressDefaultSystemPrompt = true + req.SuppressSkillContext = true + req.ToolUseFallback = hasCallableTools + } + if profile.Enabled && !hasCallableTools { + req.SuppressToolUseRule = true + } + if turnProfileSkillsOff(profile) { + req.SuppressSkillContext = true + } + if turnProfileCustomSkills(profile) { + req.AllowedSkills = append([]string(nil), profile.AllowedSkills...) + } + if profile.Enabled && profile.ToolsMode == config.TurnProfileModeCustom { + req.AllowedTools = append([]string(nil), profile.AllowedTools...) + } + return req } func promptOverlaysForOptions(opts processOptions) []PromptPart { diff --git a/pkg/agent/subturn.go b/pkg/agent/subturn.go index 86617d02..fb9d9406 100644 --- a/pkg/agent/subturn.go +++ b/pkg/agent/subturn.go @@ -378,6 +378,7 @@ func spawnSubTurn( Dispatch: dispatch, SenderID: parentTS.opts.Dispatch.SenderID(), SenderDisplayName: parentTS.opts.SenderDisplayName, + TurnProfile: parentTS.profile, SystemPromptOverride: cfg.ActualSystemPrompt, InitialSteeringMessages: cfg.InitialMessages, DefaultResponse: "", @@ -386,6 +387,9 @@ func spawnSubTurn( NoHistory: true, // SubTurns don't use session history SkipInitialSteeringPoll: true, } + if !opts.TurnProfile.Enabled { + opts.TurnProfile = parentTS.opts.TurnProfile + } // Create event scope for the child turn scope := al.newTurnEventScope( diff --git a/pkg/agent/turn_coord.go b/pkg/agent/turn_coord.go index a8a6ff89..4e14c759 100644 --- a/pkg/agent/turn_coord.go +++ b/pkg/agent/turn_coord.go @@ -373,6 +373,11 @@ func (al *AgentLoop) askSideQuestion( if opts != nil { normalizeProcessOptionsInPlace(opts) + resolved, err := resolveTurnProfileOptions(al.GetConfig(), *opts) + if err != nil { + return "", err + } + *opts = resolved } var media []string @@ -399,16 +404,31 @@ func (al *AgentLoop) askSideQuestion( } } - messages := agent.ContextBuilder.BuildMessages( - history, - summary, - question, - media, - channel, - chatID, - senderID, - senderDisplayName, - ) + var promptReq PromptBuildRequest + if opts == nil { + promptReq = PromptBuildRequest{ + History: history, + Summary: summary, + CurrentMessage: question, + Media: append([]string(nil), media...), + Channel: channel, + ChatID: chatID, + SenderID: senderID, + SenderDisplayName: senderDisplayName, + } + } else { + promptReq = promptBuildRequestForProcessOptions( + agent, + *opts, + history, + summary, + question, + media, + ) + } + promptReq.SuppressToolUseRule = true + promptReq.ToolUseFallback = false + messages := agent.ContextBuilder.BuildMessagesFromPrompt(promptReq) maxMediaSize := al.GetConfig().Agents.Defaults.GetMaxMediaSize() messages = resolveMediaRefs(messages, al.mediaStore, maxMediaSize) @@ -487,6 +507,7 @@ func (al *AgentLoop) askSideQuestion( llmModel = llmReq.Model messages = llmReq.Messages llmOpts = llmReq.Options + delete(llmOpts, "native_search") } case HookActionAbortTurn: reason := decision.Reason diff --git a/pkg/agent/turn_coord_test.go b/pkg/agent/turn_coord_test.go index 4a63e639..dc2715af 100644 --- a/pkg/agent/turn_coord_test.go +++ b/pkg/agent/turn_coord_test.go @@ -73,6 +73,8 @@ func (p *sequenceProvider) GetDefaultModel() string { type nativeSearchCaptureProvider struct { lastOpts map[string]any + messages []providers.Message + tools []providers.ToolDefinition } func (p *nativeSearchCaptureProvider) Chat( @@ -82,6 +84,8 @@ func (p *nativeSearchCaptureProvider) Chat( model string, opts map[string]any, ) (*providers.LLMResponse, error) { + p.messages = append([]providers.Message(nil), messages...) + p.tools = append([]providers.ToolDefinition(nil), tools...) p.lastOpts = make(map[string]any, len(opts)) for k, v := range opts { p.lastOpts[k] = v diff --git a/pkg/agent/turn_profile_policy.go b/pkg/agent/turn_profile_policy.go new file mode 100644 index 00000000..140254e8 --- /dev/null +++ b/pkg/agent/turn_profile_policy.go @@ -0,0 +1,131 @@ +package agent + +import ( + "strings" + + "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/providers" +) + +func resolveTurnProfileOptions(cfg *config.Config, opts processOptions) (processOptions, error) { + if cfg == nil { + return opts, nil + } + profile, ok, err := cfg.Agents.Defaults.ResolveTurnProfile() + if err != nil { + return opts, err + } + if !ok { + return opts, nil + } + opts.TurnProfile = profile + if profile.HistoryMode == config.TurnProfileModeOff { + opts.NoHistory = true + opts.EnableSummary = false + } + return opts, nil +} + +func turnProfileSystemPromptOff(profile config.EffectiveTurnProfile) bool { + return profile.Enabled && profile.SystemPromptMode == config.TurnProfileModeOff +} + +func turnProfileSkillsOff(profile config.EffectiveTurnProfile) bool { + return profile.Enabled && profile.SkillsMode == config.TurnProfileModeOff +} + +func turnProfileCustomSkills(profile config.EffectiveTurnProfile) bool { + return profile.Enabled && profile.SkillsMode == config.TurnProfileModeCustom +} + +func turnProfileHasCallableTools( + profile config.EffectiveTurnProfile, + defs []providers.ToolDefinition, +) bool { + if !profile.Enabled { + return true + } + return len(filterToolsByTurnProfile(defs, profile)) > 0 +} + +func turnProfileToolAllowed(profile config.EffectiveTurnProfile, name string) bool { + if !profile.Enabled { + return true + } + switch profile.ToolsMode { + case config.TurnProfileModeOff: + return false + case config.TurnProfileModeCustom: + allowed := cleanAllowedSet(profile.AllowedTools) + if len(allowed) == 0 { + return false + } + _, ok := allowed[strings.ToLower(strings.TrimSpace(name))] + return ok + default: + return true + } +} + +func toolUseSystemPromptRule() string { + return "**ALWAYS use tools** - When you need to perform an action (schedule reminders, send messages, execute commands, etc.), you MUST call the appropriate tool. Do NOT just say you'll do it or pretend to do it." +} + +func filterNamesByTurnProfile(names []string, allowed []string) []string { + if len(names) == 0 { + return nil + } + allowedSet := cleanAllowedSet(allowed) + if len(allowedSet) == 0 { + return nil + } + out := make([]string, 0, len(names)) + for _, name := range names { + if _, ok := allowedSet[strings.ToLower(strings.TrimSpace(name))]; ok { + out = append(out, name) + } + } + return out +} + +func filterToolsByTurnProfile( + defs []providers.ToolDefinition, + profile config.EffectiveTurnProfile, +) []providers.ToolDefinition { + if !profile.Enabled { + return defs + } + switch profile.ToolsMode { + case config.TurnProfileModeOff: + return nil + case config.TurnProfileModeCustom: + allowed := cleanAllowedSet(profile.AllowedTools) + if len(allowed) == 0 { + return nil + } + filtered := make([]providers.ToolDefinition, 0, len(defs)) + for _, def := range defs { + if _, ok := allowed[strings.ToLower(strings.TrimSpace(def.Function.Name))]; ok { + filtered = append(filtered, def) + } + } + return filtered + default: + return defs + } +} + +func cleanAllowedSet(values []string) map[string]struct{} { + if len(values) == 0 { + return nil + } + out := make(map[string]struct{}, len(values)) + for _, value := range values { + value = strings.ToLower(strings.TrimSpace(value)) + if value == "" { + continue + } + out[value] = struct{}{} + } + return out +} diff --git a/pkg/agent/turn_profile_test.go b/pkg/agent/turn_profile_test.go new file mode 100644 index 00000000..4783552c --- /dev/null +++ b/pkg/agent/turn_profile_test.go @@ -0,0 +1,1175 @@ +package agent + +import ( + "context" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + "time" + + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/providers" + "github.com/sipeed/picoclaw/pkg/tools" +) + +type turnProfileCaptureProvider struct { + messages []providers.Message + tools []providers.ToolDefinition +} + +func (p *turnProfileCaptureProvider) Chat( + ctx context.Context, + messages []providers.Message, + tools []providers.ToolDefinition, + model string, + opts map[string]any, +) (*providers.LLMResponse, error) { + p.messages = append([]providers.Message(nil), messages...) + p.tools = append([]providers.ToolDefinition(nil), tools...) + return &providers.LLMResponse{Content: "profile response"}, nil +} + +func (p *turnProfileCaptureProvider) GetDefaultModel() string { + return "test-model" +} + +type turnProfileSideQuestionCaptureProvider struct { + messages []providers.Message +} + +func (p *turnProfileSideQuestionCaptureProvider) Chat( + ctx context.Context, + messages []providers.Message, + tools []providers.ToolDefinition, + model string, + opts map[string]any, +) (*providers.LLMResponse, error) { + p.messages = append([]providers.Message(nil), messages...) + return &providers.LLMResponse{Content: "side answer"}, nil +} + +func (p *turnProfileSideQuestionCaptureProvider) GetDefaultModel() string { + return "test-model" +} + +func newTurnProfileAgentLoop( + t *testing.T, + cfg *config.Config, + provider *turnProfileCaptureProvider, +) *AgentLoop { + t.Helper() + t.Setenv("PICOCLAW_BUILTIN_SKILLS", t.TempDir()) + if cfg.Agents.Defaults.Workspace == "" { + cfg.Agents.Defaults.Workspace = t.TempDir() + } + if cfg.Agents.Defaults.ModelName == "" { + cfg.Agents.Defaults.ModelName = "test-model" + } + if cfg.Agents.Defaults.MaxTokens == 0 { + cfg.Agents.Defaults.MaxTokens = 4096 + } + if cfg.Agents.Defaults.MaxToolIterations == 0 { + cfg.Agents.Defaults.MaxToolIterations = 10 + } + return NewAgentLoop(cfg, bus.NewMessageBus(), provider) +} + +func writeTurnProfileSkill(t *testing.T, workspace, name, body string) { + t.Helper() + dir := filepath.Join(workspace, "skills", name) + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatalf("mkdir skill: %v", err) + } + if err := os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte(body), 0o644); err != nil { + t.Fatalf("write skill: %v", err) + } +} + +func TestTurnProfile_DisabledPreservesDefaultHistoryAndPrompt(t *testing.T) { + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + TurnProfile: config.TurnProfileConfig{ + Enabled: false, + History: config.TurnProfileBlock{ + Mode: config.TurnProfileModeOff, + }, + }, + }, + }, + } + provider := &turnProfileCaptureProvider{} + al := newTurnProfileAgentLoop(t, cfg, provider) + agent := al.GetRegistry().GetDefaultAgent() + sessionKey := "agent:default:test-default" + initialHistory := []providers.Message{ + {Role: "user", Content: "old user"}, + {Role: "assistant", Content: "old assistant"}, + } + agent.Sessions.SetHistory(sessionKey, initialHistory) + agent.Sessions.SetSummary(sessionKey, "old summary") + + got, err := al.runAgentLoop(context.Background(), agent, processOptions{ + SessionKey: sessionKey, + UserMessage: "new user", + DefaultResponse: defaultResponse, + EnableSummary: false, + }) + if err != nil { + t.Fatalf("runAgentLoop() error = %v", err) + } + if got != "profile response" { + t.Fatalf("runAgentLoop() = %q, want profile response", got) + } + if len(provider.messages) != 4 { + t.Fatalf("provider messages len = %d, want system + history + user", len(provider.messages)) + } + if !reflect.DeepEqual(provider.messages[1:3], initialHistory) { + t.Fatalf("provider history = %#v, want %#v", provider.messages[1:3], initialHistory) + } + if !strings.Contains(provider.messages[0].Content, "CONTEXT_SUMMARY") { + t.Fatalf("system prompt missing summary in default mode:\n%s", provider.messages[0].Content) + } + history := agent.Sessions.GetHistory(sessionKey) + if len(history) != len(initialHistory)+2 { + t.Fatalf("history len = %d, want initial + user + assistant", len(history)) + } +} + +func TestTurnProfile_HistoryOffSuppressesHistoryAndPersistence(t *testing.T) { + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + TurnProfile: config.TurnProfileConfig{ + Enabled: true, + History: config.TurnProfileBlock{Mode: config.TurnProfileModeOff}, + }, + }, + }, + } + provider := &turnProfileCaptureProvider{} + al := newTurnProfileAgentLoop(t, cfg, provider) + agent := al.GetRegistry().GetDefaultAgent() + sessionKey := "agent:default:test-history-off" + initialHistory := []providers.Message{ + {Role: "user", Content: "old user"}, + {Role: "assistant", Content: "old assistant"}, + } + agent.Sessions.SetHistory(sessionKey, initialHistory) + agent.Sessions.SetSummary(sessionKey, "old summary") + + _, err := al.runAgentLoop(context.Background(), agent, processOptions{ + SessionKey: sessionKey, + UserMessage: "new user", + DefaultResponse: defaultResponse, + EnableSummary: true, + }) + if err != nil { + t.Fatalf("runAgentLoop() error = %v", err) + } + if len(provider.messages) != 2 { + t.Fatalf("provider messages len = %d, want system + current user", len(provider.messages)) + } + if provider.messages[1].Content != "new user" { + t.Fatalf("current message = %q, want new user", provider.messages[1].Content) + } + if strings.Contains(provider.messages[0].Content, "old summary") { + t.Fatalf("system prompt includes suppressed summary:\n%s", provider.messages[0].Content) + } + history := agent.Sessions.GetHistory(sessionKey) + if !reflect.DeepEqual(history, initialHistory) { + t.Fatalf("history = %#v, want unchanged %#v", history, initialHistory) + } +} + +func TestTurnProfile_ProcessMessageUsesEnabledTurnProfile(t *testing.T) { + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + TurnProfile: config.TurnProfileConfig{ + Enabled: true, + History: config.TurnProfileBlock{Mode: config.TurnProfileModeOff}, + }, + }, + }, + } + provider := &turnProfileCaptureProvider{} + al := newTurnProfileAgentLoop(t, cfg, provider) + + _, err := al.processMessage(context.Background(), bus.InboundMessage{ + Context: bus.InboundContext{ + Channel: "pico", + ChatID: "pico:sess-1", + ChatType: "direct", + SenderID: "pico-user", + }, + Content: "hello from pico", + }) + if err != nil { + t.Fatalf("processMessage() error = %v", err) + } + if len(provider.messages) != 2 { + t.Fatalf("provider messages len = %d, want system + current user", len(provider.messages)) + } + if provider.messages[1].Content != "hello from pico" { + t.Fatalf("current message = %q, want hello from pico", provider.messages[1].Content) + } +} + +func TestTurnProfile_BtwCommandUsesEnabledTurnProfile(t *testing.T) { + workspace := t.TempDir() + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: workspace, + ModelName: "test-model", + MaxTokens: 4096, + MaxToolIterations: 10, + TurnProfile: config.TurnProfileConfig{ + Enabled: true, + History: config.TurnProfileBlock{Mode: config.TurnProfileModeOff}, + Tools: config.TurnProfileBlock{Mode: config.TurnProfileModeOff}, + }, + }, + }, + ModelList: []*config.ModelConfig{{ + ModelName: "test-model", + Model: "openai/test-model", + }}, + } + t.Setenv("PICOCLAW_BUILTIN_SKILLS", t.TempDir()) + sideProvider := &turnProfileSideQuestionCaptureProvider{} + al := NewAgentLoop(cfg, bus.NewMessageBus(), &turnProfileCaptureProvider{}) + al.providerFactory = func(mc *config.ModelConfig) (providers.LLMProvider, string, error) { + return sideProvider, "test-model", nil + } + + _, err := al.processMessage(context.Background(), bus.InboundMessage{ + Context: bus.InboundContext{ + Channel: "pico", + ChatID: "pico:btw", + ChatType: "direct", + SenderID: "pico-user", + }, + Content: "/btw explain privately", + }) + if err != nil { + t.Fatalf("processMessage() error = %v", err) + } + if len(sideProvider.messages) < 2 { + t.Fatalf("side question messages len = %d, want system + user", len(sideProvider.messages)) + } + systemPrompt := sideProvider.messages[0].Content + blockedSnippets := []string{ + "ALWAYS use tools", + "When using tools", + "read_file tool", + "update " + workspace + "/memory/MEMORY.md", + } + for _, snippet := range blockedSnippets { + if strings.Contains(systemPrompt, snippet) { + t.Fatalf("side question system prompt includes %q despite tools.mode=off:\n%s", snippet, systemPrompt) + } + } +} + +func TestTurnProfile_BtwCommandDoesNotAddToolFallbackWhenSystemPromptOff(t *testing.T) { + workspace := t.TempDir() + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: workspace, + ModelName: "test-model", + MaxTokens: 4096, + MaxToolIterations: 10, + TurnProfile: config.TurnProfileConfig{ + Enabled: true, + History: config.TurnProfileBlock{Mode: config.TurnProfileModeOff}, + SystemPrompt: config.TurnProfileBlock{Mode: config.TurnProfileModeOff}, + Tools: config.TurnProfileBlock{ + Mode: config.TurnProfileModeCustom, + Allow: []string{"echo_text"}, + }, + }, + }, + }, + ModelList: []*config.ModelConfig{{ + ModelName: "test-model", + Model: "openai/test-model", + }}, + } + t.Setenv("PICOCLAW_BUILTIN_SKILLS", t.TempDir()) + sideProvider := &turnProfileSideQuestionCaptureProvider{} + al := NewAgentLoop(cfg, bus.NewMessageBus(), &turnProfileCaptureProvider{}) + al.RegisterTool(&echoTextTool{}) + al.providerFactory = func(mc *config.ModelConfig) (providers.LLMProvider, string, error) { + return sideProvider, "test-model", nil + } + + _, err := al.processMessage(context.Background(), bus.InboundMessage{ + Context: bus.InboundContext{ + Channel: "pico", + ChatID: "pico:btw-system-off", + ChatType: "direct", + SenderID: "pico-user", + }, + Content: "/btw explain privately", + }) + if err != nil { + t.Fatalf("processMessage() error = %v", err) + } + for _, msg := range sideProvider.messages { + if msg.Role == "system" && strings.Contains(msg.Content, toolUseSystemPromptRule()) { + t.Fatalf("side question system prompt includes tool fallback despite no tools:\n%s", msg.Content) + } + } +} + +func TestTurnProfile_BtwHookCannotReenableNativeSearchWhenToolsOff(t *testing.T) { + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: t.TempDir(), + ModelName: "test-model", + MaxTokens: 4096, + MaxToolIterations: 10, + TurnProfile: config.TurnProfileConfig{ + Enabled: true, + History: config.TurnProfileBlock{Mode: config.TurnProfileModeOff}, + Tools: config.TurnProfileBlock{Mode: config.TurnProfileModeOff}, + }, + }, + }, + ModelList: []*config.ModelConfig{{ + ModelName: "test-model", + Model: "openai/test-model", + }}, + } + provider := &nativeSearchCaptureProvider{} + al := NewAgentLoop(cfg, bus.NewMessageBus(), provider) + al.providerFactory = func(mc *config.ModelConfig) (providers.LLMProvider, string, error) { + return provider, "test-model", nil + } + if err := al.MountHook(NamedHook("enable-native-search", turnProfileEnableNativeSearchHook{})); err != nil { + t.Fatalf("MountHook() error = %v", err) + } + + _, err := al.processMessage(context.Background(), bus.InboundMessage{ + Context: bus.InboundContext{ + Channel: "pico", + ChatID: "pico:btw-native-search", + ChatType: "direct", + SenderID: "pico-user", + }, + Content: "/btw search privately", + }) + if err != nil { + t.Fatalf("processMessage() error = %v", err) + } + if provider.lastOpts["turn_profile_test_hook"] != true { + t.Fatalf("BeforeLLM hook did not run for /btw: %#v", provider.lastOpts) + } + if provider.lastOpts["native_search"] == true { + t.Fatalf("native_search option enabled by /btw hook despite tools.mode=off: %#v", provider.lastOpts) + } +} + +func TestTurnProfile_SubTurnInheritsParentToolProfile(t *testing.T) { + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: t.TempDir(), + ModelName: "test-model", + MaxTokens: 4096, + MaxToolIterations: 10, + TurnProfile: config.TurnProfileConfig{ + Enabled: true, + History: config.TurnProfileBlock{Mode: config.TurnProfileModeOff}, + Tools: config.TurnProfileBlock{ + Mode: config.TurnProfileModeCustom, + Allow: []string{"echo_text"}, + }, + }, + }, + }, + } + provider := &turnProfileCaptureProvider{} + al := newTurnProfileAgentLoop(t, cfg, provider) + al.RegisterTool(&echoTextTool{}) + al.RegisterTool(&echoTextRewrittenTool{}) + agent := al.GetRegistry().GetDefaultAgent() + profile, ok, err := cfg.Agents.Defaults.ResolveTurnProfile() + if err != nil { + t.Fatalf("ResolveTurnProfile() error = %v", err) + } + if !ok { + t.Fatal("ResolveTurnProfile() did not return enabled profile") + } + parentOpts := processOptions{ + Dispatch: DispatchRequest{ + SessionKey: "agent:default:test-parent", + UserMessage: "parent", + }, + TurnProfile: profile, + } + parentTS := newTurnState(agent, parentOpts, turnEventScope{ + turnID: "parent-turn-profile", + }) + + _, err = spawnSubTurn(context.Background(), al, parentTS, SubTurnConfig{ + Model: "test-model", + SystemPrompt: "child task", + Timeout: time.Second, + }) + if err != nil { + t.Fatalf("spawnSubTurn() error = %v", err) + } + if len(provider.tools) != 1 { + t.Fatalf("child provider tools len = %d, want 1: %#v", len(provider.tools), provider.tools) + } + if provider.tools[0].Function.Name != "echo_text" { + t.Fatalf("child provider tool = %q, want echo_text", provider.tools[0].Function.Name) + } +} + +func TestTurnProfile_SystemPromptOffUsesExternalPromptOnly(t *testing.T) { + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + TurnProfile: config.TurnProfileConfig{ + Enabled: true, + History: config.TurnProfileBlock{Mode: config.TurnProfileModeOff}, + SystemPrompt: config.TurnProfileBlock{Mode: config.TurnProfileModeOff}, + Skills: config.TurnProfileBlock{Mode: config.TurnProfileModeOff}, + Tools: config.TurnProfileBlock{Mode: config.TurnProfileModeOff}, + }, + }, + }, + } + provider := &turnProfileCaptureProvider{} + al := newTurnProfileAgentLoop(t, cfg, provider) + agent := al.GetRegistry().GetDefaultAgent() + + _, err := al.runAgentLoop(context.Background(), agent, processOptions{ + SessionKey: "agent:default:test-external-prompt", + UserMessage: "hello", + DefaultResponse: defaultResponse, + SystemPromptOverride: "External prompt only.", + }) + if err != nil { + t.Fatalf("runAgentLoop() error = %v", err) + } + if len(provider.messages) != 2 { + t.Fatalf("messages len = %d, want system + user", len(provider.messages)) + } + if strings.TrimSpace(provider.messages[0].Content) != "External prompt only." { + t.Fatalf("system prompt = %q, want external only", provider.messages[0].Content) + } +} + +func TestTurnProfile_SystemPromptOffBlankTurnStillSendsMessage(t *testing.T) { + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + TurnProfile: config.TurnProfileConfig{ + Enabled: true, + History: config.TurnProfileBlock{Mode: config.TurnProfileModeOff}, + SystemPrompt: config.TurnProfileBlock{Mode: config.TurnProfileModeOff}, + Skills: config.TurnProfileBlock{Mode: config.TurnProfileModeOff}, + Tools: config.TurnProfileBlock{Mode: config.TurnProfileModeOff}, + }, + }, + }, + } + provider := &turnProfileCaptureProvider{} + al := newTurnProfileAgentLoop(t, cfg, provider) + + _, err := al.runAgentLoop(context.Background(), al.GetRegistry().GetDefaultAgent(), processOptions{ + SessionKey: "agent:default:test-blank-system-off", + UserMessage: "", + DefaultResponse: defaultResponse, + }) + if err != nil { + t.Fatalf("runAgentLoop() error = %v", err) + } + if len(provider.messages) != 1 { + t.Fatalf( + "provider messages len = %d, want one blank user message: %#v", + len(provider.messages), + provider.messages, + ) + } + if provider.messages[0].Role != "user" || provider.messages[0].Content != "" { + t.Fatalf("provider message = %#v, want blank user message", provider.messages[0]) + } +} + +func TestTurnProfile_SystemPromptOffAddsToolFallbackWhenToolsVisible(t *testing.T) { + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + TurnProfile: config.TurnProfileConfig{ + Enabled: true, + History: config.TurnProfileBlock{Mode: config.TurnProfileModeOff}, + SystemPrompt: config.TurnProfileBlock{Mode: config.TurnProfileModeOff}, + Skills: config.TurnProfileBlock{Mode: config.TurnProfileModeOff}, + Tools: config.TurnProfileBlock{ + Mode: config.TurnProfileModeCustom, + Allow: []string{"echo_text"}, + }, + }, + }, + }, + } + provider := &turnProfileCaptureProvider{} + al := newTurnProfileAgentLoop(t, cfg, provider) + al.RegisterTool(&echoTextTool{}) + agent := al.GetRegistry().GetDefaultAgent() + + _, err := al.runAgentLoop(context.Background(), agent, processOptions{ + SessionKey: "agent:default:test-tool-fallback", + UserMessage: "hello", + DefaultResponse: defaultResponse, + }) + if err != nil { + t.Fatalf("runAgentLoop() error = %v", err) + } + if got := strings.TrimSpace(provider.messages[0].Content); got != toolUseSystemPromptRule() { + t.Fatalf("fallback prompt = %q, want existing tool rule %q", got, toolUseSystemPromptRule()) + } +} + +func TestTurnProfile_SkillsOffAndCustomControlCatalogAndActiveSkills(t *testing.T) { + workspace := t.TempDir() + writeTurnProfileSkill( + t, + workspace, + "shell", + "---\ndescription: shell skill\n---\n# shell\n\nUse shell carefully.", + ) + writeTurnProfileSkill( + t, + workspace, + "paint", + "---\ndescription: paint skill\n---\n# paint\n\nUse paint vividly.", + ) + + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: workspace, + TurnProfile: config.TurnProfileConfig{ + Enabled: true, + History: config.TurnProfileBlock{ + Mode: config.TurnProfileModeOff, + }, + Skills: config.TurnProfileBlock{ + Mode: config.TurnProfileModeOff, + }, + }, + }, + }, + } + provider := &turnProfileCaptureProvider{} + al := newTurnProfileAgentLoop(t, cfg, provider) + agent := al.GetRegistry().GetDefaultAgent() + + _, err := al.runAgentLoop(context.Background(), agent, processOptions{ + SessionKey: "agent:default:test-skills-off", + UserMessage: "hello", + DefaultResponse: defaultResponse, + ForcedSkills: []string{"shell"}, + }) + if err != nil { + t.Fatalf("runAgentLoop(no-skills) error = %v", err) + } + noSkillsPrompt := provider.messages[0].Content + if strings.Contains(noSkillsPrompt, "# Skills") || + strings.Contains(noSkillsPrompt, "# Active Skills") { + t.Fatalf("skills-off prompt includes skill context:\n%s", noSkillsPrompt) + } + + cfg.Agents.Defaults.TurnProfile.Skills = config.TurnProfileBlock{ + Mode: config.TurnProfileModeCustom, + Allow: []string{"shell"}, + } + provider = &turnProfileCaptureProvider{} + al = newTurnProfileAgentLoop(t, cfg, provider) + agent = al.GetRegistry().GetDefaultAgent() + + _, err = al.runAgentLoop(context.Background(), agent, processOptions{ + SessionKey: "agent:default:test-skills-custom", + UserMessage: "hello", + DefaultResponse: defaultResponse, + ForcedSkills: []string{"shell", "paint"}, + }) + if err != nil { + t.Fatalf("runAgentLoop(shell-only) error = %v", err) + } + customPrompt := provider.messages[0].Content + if !strings.Contains(customPrompt, "shell") || + !strings.Contains(customPrompt, "### Skill: shell") { + t.Fatalf("custom skills prompt missing allowed shell context:\n%s", customPrompt) + } + if strings.Contains(customPrompt, "paint") || + strings.Contains(customPrompt, "### Skill: paint") { + t.Fatalf("custom skills prompt includes disallowed paint context:\n%s", customPrompt) + } +} + +type turnProfileAddToolHook struct{} + +func (h turnProfileAddToolHook) BeforeLLM( + ctx context.Context, + req *LLMHookRequest, +) (*LLMHookRequest, HookDecision, error) { + next := req.Clone() + next.Tools = append(next.Tools, providers.ToolDefinition{ + Type: "function", + Function: providers.ToolFunctionDefinition{ + Name: "echo_text_rewritten", + Description: "hook-added tool", + Parameters: map[string]any{"type": "object"}, + }, + }) + return next, HookDecision{Action: HookActionModify}, nil +} + +type turnProfileEnableNativeSearchHook struct{} + +func (h turnProfileEnableNativeSearchHook) BeforeLLM( + ctx context.Context, + req *LLMHookRequest, +) (*LLMHookRequest, HookDecision, error) { + next := req.Clone() + if next.Options == nil { + next.Options = map[string]any{} + } + next.Options["turn_profile_test_hook"] = true + next.Options["native_search"] = true + return next, HookDecision{Action: HookActionModify}, nil +} + +func (h turnProfileEnableNativeSearchHook) AfterLLM( + ctx context.Context, + resp *LLMHookResponse, +) (*LLMHookResponse, HookDecision, error) { + return resp, HookDecision{Action: HookActionContinue}, nil +} + +type turnProfileRespondToolHook struct{} + +func (h turnProfileRespondToolHook) BeforeTool( + ctx context.Context, + req *ToolCallHookRequest, +) (*ToolCallHookRequest, HookDecision, error) { + next := req.Clone() + next.HookResult = &tools.ToolResult{ForLLM: "hook bypassed profile"} + return next, HookDecision{Action: HookActionRespond}, nil +} + +func (h turnProfileRespondToolHook) AfterTool( + ctx context.Context, + result *ToolResultHookResponse, +) (*ToolResultHookResponse, HookDecision, error) { + return result, HookDecision{Action: HookActionContinue}, nil +} + +type turnProfileToolCallProvider struct { + calls int + messages []providers.Message +} + +func (p *turnProfileToolCallProvider) Chat( + ctx context.Context, + messages []providers.Message, + tools []providers.ToolDefinition, + model string, + opts map[string]any, +) (*providers.LLMResponse, error) { + p.calls++ + p.messages = append([]providers.Message(nil), messages...) + if p.calls == 1 { + return &providers.LLMResponse{ + Content: "calling disallowed", + ToolCalls: []providers.ToolCall{{ + ID: "call_1", + Name: "echo_text_rewritten", + Arguments: map[string]any{"text": "blocked"}, + }}, + FinishReason: "tool_calls", + }, nil + } + return &providers.LLMResponse{Content: "done"}, nil +} + +func (p *turnProfileToolCallProvider) GetDefaultModel() string { + return "test-model" +} + +func TestTurnProfile_ToolsCustomFiltersProviderToolsAndHookAdditions(t *testing.T) { + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + TurnProfile: config.TurnProfileConfig{ + Enabled: true, + History: config.TurnProfileBlock{Mode: config.TurnProfileModeOff}, + Tools: config.TurnProfileBlock{ + Mode: config.TurnProfileModeCustom, + Allow: []string{"echo_text"}, + }, + }, + }, + }, + } + provider := &turnProfileCaptureProvider{} + al := newTurnProfileAgentLoop(t, cfg, provider) + al.RegisterTool(&echoTextTool{}) + al.RegisterTool(&echoTextRewrittenTool{}) + if err := al.MountHook(NamedHook("add-disallowed-tool", turnProfileAddToolHook{})); err != nil { + t.Fatalf("MountHook() error = %v", err) + } + + _, err := al.runAgentLoop( + context.Background(), + al.GetRegistry().GetDefaultAgent(), + processOptions{ + SessionKey: "agent:default:test-tools-filter", + UserMessage: "hello", + DefaultResponse: defaultResponse, + }, + ) + if err != nil { + t.Fatalf("runAgentLoop() error = %v", err) + } + if len(provider.tools) != 1 { + t.Fatalf("provider tools len = %d, want 1: %#v", len(provider.tools), provider.tools) + } + if provider.tools[0].Function.Name != "echo_text" { + t.Fatalf("provider tool = %q, want echo_text", provider.tools[0].Function.Name) + } +} + +func TestTurnProfile_ToolsOffDisablesProviderAndNativeSearchTools(t *testing.T) { + cfg := &config.Config{ + Tools: config.ToolsConfig{ + Web: config.WebToolsConfig{ + ToolConfig: config.ToolConfig{Enabled: true}, + PreferNative: true, + }, + }, + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + TurnProfile: config.TurnProfileConfig{ + Enabled: true, + History: config.TurnProfileBlock{Mode: config.TurnProfileModeOff}, + Tools: config.TurnProfileBlock{Mode: config.TurnProfileModeOff}, + }, + }, + }, + } + cfg.Agents.Defaults.Workspace = t.TempDir() + cfg.Agents.Defaults.ModelName = "test-model" + cfg.Agents.Defaults.MaxTokens = 4096 + cfg.Agents.Defaults.MaxToolIterations = 10 + provider := &nativeSearchCaptureProvider{} + al := NewAgentLoop(cfg, bus.NewMessageBus(), provider) + + _, err := al.runAgentLoop( + context.Background(), + al.GetRegistry().GetDefaultAgent(), + processOptions{ + SessionKey: "agent:default:test-tools-off", + UserMessage: "hello", + DefaultResponse: defaultResponse, + }, + ) + if err != nil { + t.Fatalf("runAgentLoop() error = %v", err) + } + if provider.lastOpts["native_search"] == true { + t.Fatalf("native_search option enabled despite tools.mode=off: %#v", provider.lastOpts) + } +} + +func TestTurnProfile_ToolsOffSuppressesToolUsePromptRule(t *testing.T) { + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + TurnProfile: config.TurnProfileConfig{ + Enabled: true, + History: config.TurnProfileBlock{Mode: config.TurnProfileModeOff}, + Tools: config.TurnProfileBlock{Mode: config.TurnProfileModeOff}, + }, + }, + }, + } + provider := &turnProfileCaptureProvider{} + al := newTurnProfileAgentLoop(t, cfg, provider) + al.RegisterTool(&echoTextTool{}) + + _, err := al.runAgentLoop( + context.Background(), + al.GetRegistry().GetDefaultAgent(), + processOptions{ + SessionKey: "agent:default:test-tools-off-prompt", + UserMessage: "hello", + DefaultResponse: defaultResponse, + }, + ) + if err != nil { + t.Fatalf("runAgentLoop() error = %v", err) + } + if len(provider.tools) != 0 { + t.Fatalf("provider tools len = %d, want 0", len(provider.tools)) + } + if len(provider.messages) == 0 || provider.messages[0].Role != "system" { + t.Fatalf("first provider message = %#v, want system prompt", provider.messages) + } + if strings.Contains(provider.messages[0].Content, toolUseSystemPromptRule()) || + strings.Contains(provider.messages[0].Content, "**ALWAYS use tools**") { + t.Fatalf("tools-off system prompt still asks the model to use tools:\n%s", provider.messages[0].Content) + } +} + +func TestTurnProfile_ToolsCustomMissingToolSuppressesToolUsePromptRule(t *testing.T) { + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + TurnProfile: config.TurnProfileConfig{ + Enabled: true, + History: config.TurnProfileBlock{Mode: config.TurnProfileModeOff}, + Tools: config.TurnProfileBlock{ + Mode: config.TurnProfileModeCustom, + Allow: []string{"web_search"}, + }, + }, + }, + }, + } + provider := &turnProfileCaptureProvider{} + al := newTurnProfileAgentLoop(t, cfg, provider) + al.RegisterTool(&echoTextTool{}) + + _, err := al.runAgentLoop( + context.Background(), + al.GetRegistry().GetDefaultAgent(), + processOptions{ + SessionKey: "agent:default:test-tools-custom-missing-prompt", + UserMessage: "hello", + DefaultResponse: defaultResponse, + }, + ) + if err != nil { + t.Fatalf("runAgentLoop() error = %v", err) + } + if len(provider.tools) != 0 { + t.Fatalf("provider tools len = %d, want 0", len(provider.tools)) + } + if strings.Contains(provider.messages[0].Content, toolUseSystemPromptRule()) || + strings.Contains(provider.messages[0].Content, "**ALWAYS use tools**") { + t.Fatalf( + "custom profile with no resolved tools still asks the model to use tools:\n%s", + provider.messages[0].Content, + ) + } +} + +func TestTurnProfile_ToolsCustomAllowsNativeWebSearch(t *testing.T) { + cfg := &config.Config{ + Tools: config.ToolsConfig{ + Web: config.WebToolsConfig{ + ToolConfig: config.ToolConfig{Enabled: true}, + PreferNative: true, + }, + }, + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: t.TempDir(), + ModelName: "test-model", + MaxTokens: 4096, + MaxToolIterations: 10, + TurnProfile: config.TurnProfileConfig{ + Enabled: true, + History: config.TurnProfileBlock{Mode: config.TurnProfileModeOff}, + Tools: config.TurnProfileBlock{ + Mode: config.TurnProfileModeCustom, + Allow: []string{"web_search"}, + }, + }, + }, + }, + } + provider := &nativeSearchCaptureProvider{} + al := NewAgentLoop(cfg, bus.NewMessageBus(), provider) + + _, err := al.runAgentLoop( + context.Background(), + al.GetRegistry().GetDefaultAgent(), + processOptions{ + SessionKey: "agent:default:test-native-web-allowed", + UserMessage: "search", + DefaultResponse: defaultResponse, + }, + ) + if err != nil { + t.Fatalf("runAgentLoop() error = %v", err) + } + if got, _ := provider.lastOpts["native_search"].(bool); !got { + t.Fatalf("native_search = %#v, want true", provider.lastOpts["native_search"]) + } +} + +func TestTurnProfile_SystemPromptOffAddsToolFallbackForNativeWebSearch(t *testing.T) { + cfg := &config.Config{ + Tools: config.ToolsConfig{ + Web: config.WebToolsConfig{ + ToolConfig: config.ToolConfig{Enabled: true}, + PreferNative: true, + }, + }, + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: t.TempDir(), + ModelName: "test-model", + MaxTokens: 4096, + MaxToolIterations: 10, + TurnProfile: config.TurnProfileConfig{ + Enabled: true, + History: config.TurnProfileBlock{Mode: config.TurnProfileModeOff}, + SystemPrompt: config.TurnProfileBlock{Mode: config.TurnProfileModeOff}, + Skills: config.TurnProfileBlock{Mode: config.TurnProfileModeOff}, + Tools: config.TurnProfileBlock{ + Mode: config.TurnProfileModeCustom, + Allow: []string{"web_search"}, + }, + }, + }, + }, + } + provider := &nativeSearchCaptureProvider{} + al := NewAgentLoop(cfg, bus.NewMessageBus(), provider) + + _, err := al.runAgentLoop( + context.Background(), + al.GetRegistry().GetDefaultAgent(), + processOptions{ + SessionKey: "agent:default:test-native-web-fallback", + UserMessage: "search", + DefaultResponse: defaultResponse, + }, + ) + if err != nil { + t.Fatalf("runAgentLoop() error = %v", err) + } + if got, _ := provider.lastOpts["native_search"].(bool); !got { + t.Fatalf("native_search = %#v, want true", provider.lastOpts["native_search"]) + } + if len(provider.messages) == 0 || provider.messages[0].Content != toolUseSystemPromptRule() { + t.Fatalf("native-search-only prompt = %#v, want tool fallback", provider.messages) + } +} + +func TestTurnProfile_BeforeLLMHookCannotReenableNativeSearchWhenToolsOff(t *testing.T) { + cfg := &config.Config{ + Tools: config.ToolsConfig{ + Web: config.WebToolsConfig{ + ToolConfig: config.ToolConfig{Enabled: true}, + PreferNative: true, + }, + }, + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: t.TempDir(), + ModelName: "test-model", + MaxTokens: 4096, + MaxToolIterations: 10, + TurnProfile: config.TurnProfileConfig{ + Enabled: true, + History: config.TurnProfileBlock{Mode: config.TurnProfileModeOff}, + Tools: config.TurnProfileBlock{Mode: config.TurnProfileModeOff}, + }, + }, + }, + } + provider := &nativeSearchCaptureProvider{} + al := NewAgentLoop(cfg, bus.NewMessageBus(), provider) + if err := al.MountHook(NamedHook("enable-native-search", turnProfileEnableNativeSearchHook{})); err != nil { + t.Fatalf("MountHook() error = %v", err) + } + + _, err := al.runAgentLoop( + context.Background(), + al.GetRegistry().GetDefaultAgent(), + processOptions{ + SessionKey: "agent:default:test-native-web-hook-denied", + UserMessage: "search", + DefaultResponse: defaultResponse, + }, + ) + if err != nil { + t.Fatalf("runAgentLoop() error = %v", err) + } + if provider.lastOpts["native_search"] == true { + t.Fatalf("native_search option enabled by hook despite tools.mode=off: %#v", provider.lastOpts) + } +} + +func TestTurnProfile_BeforeLLMHookCannotReenableNativeSearchWhenCustomToolsResolveEmpty( + t *testing.T, +) { + cfg := &config.Config{ + Tools: config.ToolsConfig{ + Web: config.WebToolsConfig{ + ToolConfig: config.ToolConfig{Enabled: true}, + PreferNative: true, + }, + }, + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: t.TempDir(), + ModelName: "test-model", + MaxTokens: 4096, + MaxToolIterations: 10, + TurnProfile: config.TurnProfileConfig{ + Enabled: true, + History: config.TurnProfileBlock{Mode: config.TurnProfileModeOff}, + Tools: config.TurnProfileBlock{ + Mode: config.TurnProfileModeCustom, + Allow: []string{"missing_tool"}, + }, + }, + }, + }, + } + provider := &nativeSearchCaptureProvider{} + al := NewAgentLoop(cfg, bus.NewMessageBus(), provider) + if err := al.MountHook(NamedHook("enable-native-search", turnProfileEnableNativeSearchHook{})); err != nil { + t.Fatalf("MountHook() error = %v", err) + } + + _, err := al.runAgentLoop( + context.Background(), + al.GetRegistry().GetDefaultAgent(), + processOptions{ + SessionKey: "agent:default:test-native-web-hook-custom-empty", + UserMessage: "search", + DefaultResponse: defaultResponse, + }, + ) + if err != nil { + t.Fatalf("runAgentLoop() error = %v", err) + } + if provider.lastOpts["native_search"] == true { + t.Fatalf( + "native_search option enabled by hook despite no resolved tools: %#v", + provider.lastOpts, + ) + } +} + +func TestTurnProfile_ToolExecutionRejectsDisallowedToolCalls(t *testing.T) { + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + TurnProfile: config.TurnProfileConfig{ + Enabled: true, + History: config.TurnProfileBlock{Mode: config.TurnProfileModeOff}, + Tools: config.TurnProfileBlock{ + Mode: config.TurnProfileModeCustom, + Allow: []string{"echo_text"}, + }, + }, + }, + }, + } + provider := &turnProfileToolCallProvider{} + al := NewAgentLoop(cfg, bus.NewMessageBus(), provider) + al.RegisterTool(&echoTextTool{}) + al.RegisterTool(&echoTextRewrittenTool{}) + + response, err := al.runAgentLoop( + context.Background(), + al.GetRegistry().GetDefaultAgent(), + processOptions{ + SessionKey: "agent:default:test-tool-exec-deny", + UserMessage: "run tool", + DefaultResponse: defaultResponse, + }, + ) + if err != nil { + t.Fatalf("runAgentLoop() error = %v", err) + } + if response != "done" { + t.Fatalf("response = %q, want done", response) + } + if provider.calls != 2 { + t.Fatalf("provider calls = %d, want 2", provider.calls) + } + var foundDeniedResult bool + for _, msg := range provider.messages { + if msg.Role == "tool" && + strings.Contains(msg.Content, "not allowed by the active turn profile") { + foundDeniedResult = true + break + } + } + if !foundDeniedResult { + t.Fatalf("second provider call did not include denied tool result: %#v", provider.messages) + } +} + +func TestTurnProfile_BeforeToolRespondCannotBypassDisallowedTool(t *testing.T) { + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + TurnProfile: config.TurnProfileConfig{ + Enabled: true, + History: config.TurnProfileBlock{Mode: config.TurnProfileModeOff}, + Tools: config.TurnProfileBlock{ + Mode: config.TurnProfileModeCustom, + Allow: []string{"echo_text"}, + }, + }, + }, + }, + } + provider := &turnProfileToolCallProvider{} + al := NewAgentLoop(cfg, bus.NewMessageBus(), provider) + al.RegisterTool(&echoTextTool{}) + al.RegisterTool(&echoTextRewrittenTool{}) + if err := al.MountHook(NamedHook("respond-tool", turnProfileRespondToolHook{})); err != nil { + t.Fatalf("MountHook() error = %v", err) + } + + _, err := al.runAgentLoop( + context.Background(), + al.GetRegistry().GetDefaultAgent(), + processOptions{ + SessionKey: "agent:default:test-tool-hook-respond-denied", + UserMessage: "run tool", + DefaultResponse: defaultResponse, + }, + ) + if err != nil { + t.Fatalf("runAgentLoop() error = %v", err) + } + var foundDeniedResult bool + for _, msg := range provider.messages { + if msg.Role != "tool" { + continue + } + if strings.Contains(msg.Content, "hook bypassed profile") { + t.Fatalf("hook respond result bypassed turn profile: %#v", provider.messages) + } + if strings.Contains(msg.Content, "not allowed by the active turn profile") { + foundDeniedResult = true + } + } + if !foundDeniedResult { + t.Fatalf("second provider call did not include denied tool result: %#v", provider.messages) + } +} diff --git a/pkg/agent/turn_state.go b/pkg/agent/turn_state.go index 2f40c2d3..4318dd28 100644 --- a/pkg/agent/turn_state.go +++ b/pkg/agent/turn_state.go @@ -180,9 +180,10 @@ func newTurnExecution( type turnState struct { mu sync.RWMutex - agent *AgentInstance - opts processOptions - scope turnEventScope + agent *AgentInstance + opts processOptions + profile config.EffectiveTurnProfile + scope turnEventScope turnID string agentID string @@ -254,6 +255,7 @@ func newTurnState(agent *AgentInstance, opts processOptions, scope turnEventScop ts := &turnState{ agent: agent, opts: opts, + profile: opts.TurnProfile, scope: scope, turnID: scope.turnID, agentID: agent.ID, diff --git a/pkg/channels/pico/pico_test.go b/pkg/channels/pico/pico_test.go index 9e90fc53..a793d7ad 100644 --- a/pkg/channels/pico/pico_test.go +++ b/pkg/channels/pico/pico_test.go @@ -36,6 +36,39 @@ func newTestPicoChannel(t *testing.T) *PicoChannel { return ch } +func TestHandleMessageSend_ForwardsMessageMetadata(t *testing.T) { + msgBus := bus.NewMessageBus() + bc := &config.Channel{Type: config.ChannelPico, Enabled: true} + cfg := &config.PicoSettings{} + cfg.SetToken("test-token") + ch, err := NewPicoChannel(bc, cfg, msgBus) + if err != nil { + t.Fatalf("NewPicoChannel: %v", err) + } + ch.ctx = context.Background() + + ch.handleMessageSend(&picoConn{id: "conn-1", sessionID: "sess-1"}, PicoMessage{ + Type: TypeMessageSend, + ID: "msg-1", + SessionID: "sess-1", + Payload: map[string]any{ + PayloadKeyContent: "hello", + }, + }) + + select { + case inbound := <-msgBus.InboundChan(): + if inbound.Content != "hello" { + t.Fatalf("content = %q, want hello", inbound.Content) + } + if got := inbound.Context.Raw["session_id"]; got != "sess-1" { + t.Fatalf("session_id raw = %q, want sess-1", got) + } + case <-time.After(time.Second): + t.Fatal("expected inbound pico message") + } +} + func TestFinalizeTrackedToolFeedbackMessage_StopsTrackingBeforeEdit(t *testing.T) { ch := &PicoChannel{ progress: channels.NewToolFeedbackAnimator(nil), diff --git a/pkg/config/config.go b/pkg/config/config.go index 1fea4736..d9608d11 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -399,6 +399,7 @@ type AgentDefaults struct { SplitOnMarker bool `json:"split_on_marker" env:"PICOCLAW_AGENTS_DEFAULTS_SPLIT_ON_MARKER"` // split messages on <|[SPLIT]|> marker ContextManager string `json:"context_manager,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_CONTEXT_MANAGER"` ContextManagerConfig json.RawMessage `json:"context_manager_config,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_CONTEXT_MANAGER_CONFIG"` + TurnProfile TurnProfileConfig `json:"turn_profile,omitempty"` MaxLLMRetries int `json:"max_llm_retries,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_LLM_RETRIES"` LLMRetryBackoffSecs int `json:"llm_retry_backoff_secs,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_LLM_RETRY_BACKOFF_SECS"` } @@ -1418,6 +1419,9 @@ func LoadConfig(path string) (*Config, error) { if err = InitChannelList(cfg.Channels); err != nil { return nil, err } + if err = cfg.ValidateTurnProfile(); err != nil { + return nil, err + } cfg.Gateway.Host, err = resolveGatewayHostFromEnv(gatewayHostBeforeEnv) if err != nil { return nil, fmt.Errorf("invalid gateway host: %w", err) diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index 968fa975..e34f2389 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -160,6 +160,130 @@ func TestAgentConfig_FullParse(t *testing.T) { } } +func TestTurnProfileConfig_ParseAndResolve(t *testing.T) { + jsonData := `{ + "agents": { + "defaults": { + "turn_profile": { + "enabled": true, + "history": {"mode": "off"}, + "system_prompt": {"mode": "off"}, + "skills": {"mode": "off"}, + "tools": { + "mode": "custom", + "allow": ["web_search", "web_fetch"] + } + } + } + } + }` + + cfg := DefaultConfig() + if err := json.Unmarshal([]byte(jsonData), cfg); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if err := cfg.ValidateTurnProfile(); err != nil { + t.Fatalf("ValidateTurnProfile() error = %v", err) + } + + profile, ok, err := cfg.Agents.Defaults.ResolveTurnProfile() + if err != nil { + t.Fatalf("ResolveTurnProfile() error = %v", err) + } + if !ok { + t.Fatal("ResolveTurnProfile() ok = false, want true") + } + if profile.HistoryMode != TurnProfileModeOff || + profile.SystemPromptMode != TurnProfileModeOff || + profile.SkillsMode != TurnProfileModeOff || + profile.ToolsMode != TurnProfileModeCustom { + t.Fatalf("resolved clean_web modes = %+v", profile) + } + assert.Equal(t, []string{"web_search", "web_fetch"}, profile.AllowedTools) +} + +func TestTurnProfileConfig_DisabledOrMissingIsNoop(t *testing.T) { + cfg := DefaultConfig() + + profile, ok, err := cfg.Agents.Defaults.ResolveTurnProfile() + if err != nil { + t.Fatalf("ResolveTurnProfile(missing) error = %v", err) + } + if ok { + t.Fatal("ResolveTurnProfile(missing) ok = true, want false") + } + if profile.Enabled { + t.Fatalf("ResolveTurnProfile(missing) profile.Enabled = true, want false") + } + + cfg.Agents.Defaults.TurnProfile = TurnProfileConfig{ + Enabled: false, + History: TurnProfileBlock{ + Mode: TurnProfileModeOff, + }, + } + profile, ok, err = cfg.Agents.Defaults.ResolveTurnProfile() + if err != nil { + t.Fatalf("ResolveTurnProfile(disabled) error = %v", err) + } + if ok || profile.Enabled { + t.Fatalf("disabled profile = (%+v, %v), want no-op", profile, ok) + } + + cfg.Agents.Defaults.TurnProfile = TurnProfileConfig{ + Enabled: false, + History: TurnProfileBlock{ + Mode: TurnProfileModeCustom, + }, + Tools: TurnProfileBlock{ + Mode: TurnProfileMode("sometimes"), + }, + } + if err := cfg.ValidateTurnProfile(); err != nil { + t.Fatalf("ValidateTurnProfile(disabled unsupported modes) error = %v, want nil", err) + } +} + +func TestTurnProfileConfig_ValidationRejectsUnsupportedModes(t *testing.T) { + tests := []struct { + name string + raw string + want string + }{ + { + name: "history custom unsupported", + raw: `{"agents":{"defaults":{"turn_profile":{"enabled":true,"history":{"mode":"custom"}}}}}`, + want: "history.mode", + }, + { + name: "system prompt custom unsupported", + raw: `{"agents":{"defaults":{"turn_profile":{"enabled":true,"system_prompt":{"mode":"custom"}}}}}`, + want: "system_prompt.mode", + }, + { + name: "unknown mode", + raw: `{"agents":{"defaults":{"turn_profile":{"enabled":true,"tools":{"mode":"sometimes"}}}}}`, + want: "unsupported mode", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := DefaultConfig() + if err := json.Unmarshal([]byte(tt.raw), cfg); err != nil { + t.Fatalf("unmarshal: %v", err) + } + err := cfg.ValidateTurnProfile() + if err == nil { + t.Fatal("ValidateTurnProfile() error = nil, want error") + } + if !strings.Contains(err.Error(), tt.want) { + t.Fatalf("ValidateTurnProfile() error = %v, want containing %q", err, tt.want) + } + }) + } +} + func TestDefaultConfig_MCPMaxInlineTextChars(t *testing.T) { cfg := DefaultConfig() if cfg.Tools.MCP.GetMaxInlineTextChars() != DefaultMCPMaxInlineTextChars { diff --git a/pkg/config/turn_profile.go b/pkg/config/turn_profile.go new file mode 100644 index 00000000..967a697e --- /dev/null +++ b/pkg/config/turn_profile.go @@ -0,0 +1,133 @@ +package config + +import ( + "fmt" + "strings" +) + +type TurnProfileMode string + +const ( + TurnProfileModeDefault TurnProfileMode = "default" + TurnProfileModeOff TurnProfileMode = "off" + TurnProfileModeCustom TurnProfileMode = "custom" +) + +type TurnProfileConfig struct { + Enabled bool `json:"enabled"` + History TurnProfileBlock `json:"history,omitempty"` + SystemPrompt TurnProfileBlock `json:"system_prompt,omitempty"` + Skills TurnProfileBlock `json:"skills,omitempty"` + Tools TurnProfileBlock `json:"tools,omitempty"` +} + +type TurnProfileBlock struct { + Mode TurnProfileMode `json:"mode,omitempty"` + Allow []string `json:"allow,omitempty"` +} + +type EffectiveTurnProfile struct { + Enabled bool + HistoryMode TurnProfileMode + SystemPromptMode TurnProfileMode + SkillsMode TurnProfileMode + ToolsMode TurnProfileMode + AllowedSkills []string + AllowedTools []string +} + +func (m TurnProfileMode) Effective() TurnProfileMode { + switch TurnProfileMode(strings.ToLower(strings.TrimSpace(string(m)))) { + case "", TurnProfileModeDefault: + return TurnProfileModeDefault + case TurnProfileModeOff: + return TurnProfileModeOff + case TurnProfileModeCustom: + return TurnProfileModeCustom + default: + return TurnProfileMode(strings.ToLower(strings.TrimSpace(string(m)))) + } +} + +func (d *AgentDefaults) ResolveTurnProfile() (EffectiveTurnProfile, bool, error) { + if d == nil { + return EffectiveTurnProfile{}, false, nil + } + profile := d.TurnProfile + if !profile.Enabled { + return EffectiveTurnProfile{}, false, nil + } + if err := validateTurnProfile(profile); err != nil { + return EffectiveTurnProfile{}, false, err + } + return EffectiveTurnProfile{ + Enabled: true, + HistoryMode: profile.History.Mode.Effective(), + SystemPromptMode: profile.SystemPrompt.Mode.Effective(), + SkillsMode: profile.Skills.Mode.Effective(), + ToolsMode: profile.Tools.Mode.Effective(), + AllowedSkills: cleanStringList(profile.Skills.Allow), + AllowedTools: cleanStringList(profile.Tools.Allow), + }, true, nil +} + +func (c *Config) ValidateTurnProfile() error { + if c == nil { + return nil + } + return validateTurnProfile(c.Agents.Defaults.TurnProfile) +} + +func validateTurnProfile(profile TurnProfileConfig) error { + if !profile.Enabled { + return nil + } + if err := validateTurnProfileBlock("history", profile.History, false); err != nil { + return err + } + if err := validateTurnProfileBlock("system_prompt", profile.SystemPrompt, false); err != nil { + return err + } + if err := validateTurnProfileBlock("skills", profile.Skills, true); err != nil { + return err + } + if err := validateTurnProfileBlock("tools", profile.Tools, true); err != nil { + return err + } + return nil +} + +func validateTurnProfileBlock(field string, block TurnProfileBlock, allowCustom bool) error { + mode := block.Mode.Effective() + switch mode { + case TurnProfileModeDefault, TurnProfileModeOff: + return nil + case TurnProfileModeCustom: + if allowCustom { + return nil + } + return fmt.Errorf("turn_profile.%s.mode custom is not supported in this version", field) + default: + return fmt.Errorf("turn_profile.%s.mode has unsupported mode %q", field, block.Mode) + } +} + +func cleanStringList(values []string) []string { + if len(values) == 0 { + return nil + } + out := make([]string, 0, len(values)) + seen := make(map[string]struct{}, len(values)) + for _, value := range values { + value = strings.TrimSpace(value) + if value == "" { + continue + } + if _, ok := seen[value]; ok { + continue + } + seen[value] = struct{}{} + out = append(out, value) + } + return out +} diff --git a/web/backend/api/config.go b/web/backend/api/config.go index 6bea6733..b3795804 100644 --- a/web/backend/api/config.go +++ b/web/backend/api/config.go @@ -312,6 +312,10 @@ func validateConfig(cfg *config.Config) []string { errs = append(errs, err.Error()) } + if err := cfg.ValidateTurnProfile(); err != nil { + errs = append(errs, err.Error()) + } + // Gateway port range if cfg.Gateway.Port != 0 && (cfg.Gateway.Port < 1 || cfg.Gateway.Port > 65535) { errs = append(errs, fmt.Sprintf("gateway.port %d is out of valid range (1-65535)", cfg.Gateway.Port)) diff --git a/web/backend/api/config_test.go b/web/backend/api/config_test.go index 1531e715..8a23b7a3 100644 --- a/web/backend/api/config_test.go +++ b/web/backend/api/config_test.go @@ -13,6 +13,95 @@ import ( "github.com/sipeed/picoclaw/pkg/logger" ) +func TestHandlePatchConfig_PreservesTurnProfile(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + cfg.Agents.Defaults.TurnProfile = config.TurnProfileConfig{ + Enabled: true, + History: config.TurnProfileBlock{Mode: config.TurnProfileModeOff}, + SystemPrompt: config.TurnProfileBlock{Mode: config.TurnProfileModeOff}, + Skills: config.TurnProfileBlock{Mode: config.TurnProfileModeOff}, + Tools: config.TurnProfileBlock{ + Mode: config.TurnProfileModeCustom, + Allow: []string{"web_search", "web_fetch"}, + }, + } + if saveErr := config.SaveConfig(configPath, cfg); saveErr != nil { + t.Fatalf("SaveConfig() error = %v", saveErr) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + req := httptest.NewRequest(http.MethodPatch, "/api/config", bytes.NewBufferString(`{ + "agents": { + "defaults": { + "max_tokens": 1234 + } + } + }`)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + updated, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig(updated) error = %v", err) + } + profile := updated.Agents.Defaults.TurnProfile + if profile.Tools.Mode != config.TurnProfileModeCustom || + strings.Join(profile.Tools.Allow, ",") != "web_search,web_fetch" { + t.Fatalf("profile tools = %#v, want custom web_search/web_fetch", profile.Tools) + } +} + +func TestHandlePatchConfig_RejectsInvalidTurnProfile(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + req := httptest.NewRequest(http.MethodPatch, "/api/config", bytes.NewBufferString(`{ + "agents": { + "defaults": { + "turn_profile": { + "enabled": true, + "history": { "mode": "custom" } + } + } + } + }`)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + if rec.Code != http.StatusBadRequest { + t.Fatalf( + "status = %d, want %d, body=%s", + rec.Code, + http.StatusBadRequest, + rec.Body.String(), + ) + } + if !strings.Contains(rec.Body.String(), "history.mode custom is not supported") { + t.Fatalf("body=%s, want turn profile validation error", rec.Body.String()) + } + + if _, err := config.LoadConfig(configPath); err != nil { + t.Fatalf("LoadConfig() after rejected patch error = %v", err) + } +} + func assertGatewayLogLevelApplied(t *testing.T, method, body string, want logger.LogLevel) { t.Helper() @@ -35,7 +124,13 @@ func assertGatewayLogLevelApplied(t *testing.T, method, body string, want logger rec := httptest.NewRecorder() mux.ServeHTTP(rec, req) if rec.Code != http.StatusOK { - t.Fatalf("%s /api/config status = %d, want %d, body=%s", method, rec.Code, http.StatusOK, rec.Body.String()) + t.Fatalf( + "%s /api/config status = %d, want %d, body=%s", + method, + rec.Code, + http.StatusOK, + rec.Body.String(), + ) } if got := logger.GetLevel(); got != want { t.Fatalf("logger.GetLevel() = %v, want %v", got, want) @@ -141,10 +236,18 @@ func TestHandlePatchConfig_RejectsInvalidExecRegexPatterns(t *testing.T) { rec := httptest.NewRecorder() mux.ServeHTTP(rec, req) if rec.Code != http.StatusBadRequest { - t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusBadRequest, rec.Body.String()) + t.Fatalf( + "status = %d, want %d, body=%s", + rec.Code, + http.StatusBadRequest, + rec.Body.String(), + ) } if !bytes.Contains(rec.Body.Bytes(), []byte("custom_deny_patterns")) { - t.Fatalf("expected validation error mentioning custom_deny_patterns, body=%s", rec.Body.String()) + t.Fatalf( + "expected validation error mentioning custom_deny_patterns, body=%s", + rec.Body.String(), + ) } } @@ -200,7 +303,12 @@ func TestHandlePatchConfig_SavesChannelListSettingsPatch(t *testing.T) { rec := httptest.NewRecorder() mux.ServeHTTP(rec, req) if rec.Code != http.StatusOK { - t.Fatalf("PATCH /api/config status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + t.Fatalf( + "PATCH /api/config status = %d, want %d, body=%s", + rec.Code, + http.StatusOK, + rec.Body.String(), + ) } cfg, err := config.LoadConfig(configPath) @@ -264,7 +372,12 @@ func TestHandlePatchConfig_NormalizesStringChannelArrayFields(t *testing.T) { rec := httptest.NewRecorder() mux.ServeHTTP(rec, req) if rec.Code != http.StatusOK { - t.Fatalf("PATCH /api/config status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + t.Fatalf( + "PATCH /api/config status = %d, want %d, body=%s", + rec.Code, + http.StatusOK, + rec.Body.String(), + ) } cfg, err := config.LoadConfig(configPath) @@ -277,7 +390,10 @@ func TestHandlePatchConfig_NormalizesStringChannelArrayFields(t *testing.T) { picoChannel.AllowFrom[0] != "ou_a" || picoChannel.AllowFrom[1] != "ou_b" || picoChannel.AllowFrom[2] != "ou_c" { - t.Fatalf("pico allow_from = %#v, want [\"ou_a\", \"ou_b\", \"ou_c\"]", picoChannel.AllowFrom) + t.Fatalf( + "pico allow_from = %#v, want [\"ou_a\", \"ou_b\", \"ou_c\"]", + picoChannel.AllowFrom, + ) } if len(picoChannel.GroupTrigger.Prefixes) != 3 || picoChannel.GroupTrigger.Prefixes[0] != "/" || @@ -346,7 +462,12 @@ func TestHandlePatchConfig_NormalizesSingleNumericAllowFrom(t *testing.T) { rec := httptest.NewRecorder() mux.ServeHTTP(rec, req) if rec.Code != http.StatusOK { - t.Fatalf("PATCH /api/config status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + t.Fatalf( + "PATCH /api/config status = %d, want %d, body=%s", + rec.Code, + http.StatusOK, + rec.Body.String(), + ) } cfg, err := config.LoadConfig(configPath) @@ -420,7 +541,11 @@ func TestHandlePatchConfig_RejectsInvalidChannelArrayFields(t *testing.T) { mux := http.NewServeMux() h.RegisterRoutes(mux) - req := httptest.NewRequest(http.MethodPatch, "/api/config", bytes.NewBufferString(tt.body)) + req := httptest.NewRequest( + http.MethodPatch, + "/api/config", + bytes.NewBufferString(tt.body), + ) req.Header.Set("Content-Type", "application/json") rec := httptest.NewRecorder() @@ -439,8 +564,12 @@ func TestHandlePatchConfig_RejectsInvalidChannelArrayFields(t *testing.T) { t.Fatalf("LoadConfig() error = %v", err) } telegramChannel := cfg.Channels[config.ChannelTelegram] - if len(telegramChannel.AllowFrom) != 1 || telegramChannel.AllowFrom[0] != "existing-user" { - t.Fatalf("telegram allow_from = %#v, want unchanged [\"existing-user\"]", telegramChannel.AllowFrom) + if len(telegramChannel.AllowFrom) != 1 || + telegramChannel.AllowFrom[0] != "existing-user" { + t.Fatalf( + "telegram allow_from = %#v, want unchanged [\"existing-user\"]", + telegramChannel.AllowFrom, + ) } }) } @@ -471,10 +600,18 @@ func TestHandlePatchConfig_RejectsNegativeStreamingDeliveryValues(t *testing.T) rec := httptest.NewRecorder() mux.ServeHTTP(rec, req) if rec.Code != http.StatusBadRequest { - t.Fatalf("PATCH /api/config status = %d, want %d, body=%s", rec.Code, http.StatusBadRequest, rec.Body.String()) + t.Fatalf( + "PATCH /api/config status = %d, want %d, body=%s", + rec.Code, + http.StatusBadRequest, + rec.Body.String(), + ) } if !strings.Contains(rec.Body.String(), "streaming.throttle_seconds") { - t.Fatalf("response body = %q, want streaming.throttle_seconds validation error", rec.Body.String()) + t.Fatalf( + "response body = %q, want streaming.throttle_seconds validation error", + rec.Body.String(), + ) } } @@ -520,7 +657,12 @@ func TestHandlePatchConfig_ClearingAllowFromDoesNotLeaveEmptyStringItem(t *testi rec := httptest.NewRecorder() mux.ServeHTTP(rec, req) if rec.Code != http.StatusOK { - t.Fatalf("PATCH /api/config status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + t.Fatalf( + "PATCH /api/config status = %d, want %d, body=%s", + rec.Code, + http.StatusOK, + rec.Body.String(), + ) } cfg, err = config.LoadConfig(configPath) @@ -537,7 +679,10 @@ func TestHandlePatchConfig_ClearingAllowFromDoesNotLeaveEmptyStringItem(t *testi t.Fatalf("ReadFile(configPath) error = %v", err) } if strings.Contains(string(configData), `"allow_from": [""]`) { - t.Fatalf("config file should not contain empty-string allow_from item: %s", string(configData)) + t.Fatalf( + "config file should not contain empty-string allow_from item: %s", + string(configData), + ) } } @@ -575,7 +720,12 @@ func TestHandlePatchConfig_CreatesMissingChannelWithTypeAndSecret(t *testing.T) rec := httptest.NewRecorder() mux.ServeHTTP(rec, req) if rec.Code != http.StatusOK { - t.Fatalf("PATCH /api/config status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + t.Fatalf( + "PATCH /api/config status = %d, want %d, body=%s", + rec.Code, + http.StatusOK, + rec.Body.String(), + ) } cfg, err = config.LoadConfig(configPath) @@ -696,7 +846,12 @@ func TestHandleUpdateConfig_SucceedsWhenPicoTokenInSecurityOnly(t *testing.T) { rec := httptest.NewRecorder() mux.ServeHTTP(rec, req) if rec.Code != http.StatusOK { - t.Fatalf("PUT /api/config status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + t.Fatalf( + "PUT /api/config status = %d, want %d, body=%s", + rec.Code, + http.StatusOK, + rec.Body.String(), + ) } } @@ -719,7 +874,12 @@ func TestHandlePatchConfig_SucceedsWhenPicoTokenInSecurityOnly(t *testing.T) { rec := httptest.NewRecorder() mux.ServeHTTP(rec, req) if rec.Code != http.StatusOK { - t.Fatalf("PATCH /api/config status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + t.Fatalf( + "PATCH /api/config status = %d, want %d, body=%s", + rec.Code, + http.StatusOK, + rec.Body.String(), + ) } } @@ -778,7 +938,12 @@ func TestHandlePatchConfig_PreservesDebugFlagOverride(t *testing.T) { rec := httptest.NewRecorder() mux.ServeHTTP(rec, req) if rec.Code != http.StatusOK { - t.Fatalf("PATCH /api/config status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + t.Fatalf( + "PATCH /api/config status = %d, want %d, body=%s", + rec.Code, + http.StatusOK, + rec.Body.String(), + ) } if got := logger.GetLevel(); got != logger.DEBUG { t.Fatalf("logger.GetLevel() = %v, want %v", got, logger.DEBUG) @@ -808,7 +973,12 @@ func TestHandlePatchConfig_SavesDiscordTokenFromPayload(t *testing.T) { rec := httptest.NewRecorder() mux.ServeHTTP(rec, req) if rec.Code != http.StatusOK { - t.Fatalf("PATCH /api/config status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + t.Fatalf( + "PATCH /api/config status = %d, want %d, body=%s", + rec.Code, + http.StatusOK, + rec.Body.String(), + ) } cfg, err := config.LoadConfig(configPath) @@ -852,7 +1022,12 @@ func TestHandlePatchConfig_DoesNotPersistShadowRegistryAuthTokenField(t *testing rec := httptest.NewRecorder() mux.ServeHTTP(rec, req) if rec.Code != http.StatusOK { - t.Fatalf("PATCH /api/config status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + t.Fatalf( + "PATCH /api/config status = %d, want %d, body=%s", + rec.Code, + http.StatusOK, + rec.Body.String(), + ) } cfg, err := config.LoadConfig(configPath) @@ -875,7 +1050,10 @@ func TestHandlePatchConfig_DoesNotPersistShadowRegistryAuthTokenField(t *testing t.Fatalf("ReadFile(configPath) error = %v", err) } if strings.Contains(string(rawConfig), "_auth_token") { - t.Fatalf("config.json should not persist _auth_token shadow field, got:\n%s", string(rawConfig)) + t.Fatalf( + "config.json should not persist _auth_token shadow field, got:\n%s", + string(rawConfig), + ) } } @@ -911,7 +1089,11 @@ func testCommandPatterns(t *testing.T, configPath string, body string) *httptest h := NewHandler(configPath) mux := http.NewServeMux() h.RegisterRoutes(mux) - req := httptest.NewRequest(http.MethodPost, "/api/config/test-command-patterns", bytes.NewBufferString(body)) + req := httptest.NewRequest( + http.MethodPost, + "/api/config/test-command-patterns", + bytes.NewBufferString(body), + ) req.Header.Set("Content-Type", "application/json") rec := httptest.NewRecorder() mux.ServeHTTP(rec, req) @@ -954,7 +1136,10 @@ func TestHandleTestCommandPatterns_MatchesBlacklistNotWhitelist(t *testing.T) { t.Fatalf("expected blocked=true, body=%s", rec.Body.String()) } if bytes.Contains(rec.Body.Bytes(), []byte(`"allowed":true`)) { - t.Fatalf("expected allowed=false when blacklist matches but not whitelist, body=%s", rec.Body.String()) + t.Fatalf( + "expected allowed=false when blacklist matches but not whitelist, body=%s", + rec.Body.String(), + ) } } @@ -1028,7 +1213,10 @@ func TestHandleTestCommandPatterns_InvalidRegexSkipped(t *testing.T) { t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) } if !bytes.Contains(rec.Body.Bytes(), []byte(`"allowed":true`)) { - t.Fatalf("expected allowed=true, invalid pattern skipped and valid one matched, body=%s", rec.Body.String()) + t.Fatalf( + "expected allowed=true, invalid pattern skipped and valid one matched, body=%s", + rec.Body.String(), + ) } } @@ -1068,7 +1256,12 @@ func TestHandleTestCommandPatterns_InvalidJSON(t *testing.T) { rec := httptest.NewRecorder() mux.ServeHTTP(rec, req) if rec.Code != http.StatusBadRequest { - t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusBadRequest, rec.Body.String()) + t.Fatalf( + "status = %d, want %d, body=%s", + rec.Code, + http.StatusBadRequest, + rec.Body.String(), + ) } } diff --git a/web/frontend/src/components/chat/chat-composer.tsx b/web/frontend/src/components/chat/chat-composer.tsx index 569ed21e..43bc8a46 100644 --- a/web/frontend/src/components/chat/chat-composer.tsx +++ b/web/frontend/src/components/chat/chat-composer.tsx @@ -1,5 +1,5 @@ import { IconArrowUp, IconPhotoPlus, IconX } from "@tabler/icons-react" -import type { KeyboardEvent } from "react" +import { useRef, type KeyboardEvent as ReactKeyboardEvent } from "react" import { useTranslation } from "react-i18next" import TextareaAutosize from "react-textarea-autosize" @@ -52,14 +52,25 @@ export function ChatComposer({ }: ChatComposerProps) { const { t } = useTranslation() const canInput = inputDisabledReason === null + const composingRef = useRef(false) const disabledMessage = inputDisabledReason === null ? null : t(`chat.disabledPlaceholder.${inputDisabledReason}`) const placeholder = disabledMessage ?? t("chat.placeholder") - const handleKeyDown = (e: KeyboardEvent) => { - if (e.nativeEvent.isComposing) return + const handleKeyDown = (e: ReactKeyboardEvent) => { + const nativeEvent = e.nativeEvent as Event & { + isComposing?: boolean + keyCode?: number + } + if ( + composingRef.current || + nativeEvent.isComposing || + nativeEvent.keyCode === 229 + ) { + return + } if (e.key === "Enter" && !e.shiftKey) { e.preventDefault() onSend() @@ -98,6 +109,12 @@ export function ChatComposer({ onInputChange(e.target.value)} + onCompositionStart={() => { + composingRef.current = true + }} + onCompositionEnd={() => { + composingRef.current = false + }} onKeyDown={handleKeyDown} placeholder={placeholder} disabled={!canInput} diff --git a/web/frontend/src/components/chat/chat-page.tsx b/web/frontend/src/components/chat/chat-page.tsx index 11ad1dd3..3b4cac73 100644 --- a/web/frontend/src/components/chat/chat-page.tsx +++ b/web/frontend/src/components/chat/chat-page.tsx @@ -351,7 +351,7 @@ export function ChatPage() {
{messages.length === 0 && !isTyping && ( diff --git a/web/frontend/src/components/config/config-page.tsx b/web/frontend/src/components/config/config-page.tsx index 4db059cd..b3787726 100644 --- a/web/frontend/src/components/config/config-page.tsx +++ b/web/frontend/src/components/config/config-page.tsx @@ -32,6 +32,7 @@ import { EMPTY_LAUNCHER_FORM, type LauncherForm, type MCPServerForm, + type TurnProfileForm, buildFormFromConfig, parseCIDRText, parseFloatField, @@ -40,7 +41,6 @@ import { parseMultilineList, } from "@/components/config/form-model" import { PageHeader } from "@/components/page-header" -import { Badge } from "@/components/ui/badge" import { AlertDialog, AlertDialogAction, @@ -52,6 +52,7 @@ import { AlertDialogTitle, AlertDialogTrigger, } from "@/components/ui/alert-dialog" +import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" import { showSaveSuccessOrRestartToast } from "@/lib/restart-required" import { refreshGatewayState } from "@/store/gateway" @@ -71,6 +72,33 @@ function buildStringMapMergePatch( return patch } +function buildTurnProfilePatch( + profile: TurnProfileForm, +): Record { + const result: Record = { + enabled: profile.enabled, + history: { mode: profile.historyMode }, + system_prompt: { mode: profile.systemPromptMode }, + skills: { mode: profile.skillsMode }, + tools: { mode: profile.toolsMode }, + } + + if (profile.skillsMode === "custom") { + result.skills = { + mode: "custom", + allow: parseMultilineList(profile.skillsAllowText), + } + } + if (profile.toolsMode === "custom") { + result.tools = { + mode: "custom", + allow: parseMultilineList(profile.toolsAllowText), + } + } + + return result +} + export function ConfigPage() { const { t } = useTranslation() const queryClient = useQueryClient() @@ -221,6 +249,13 @@ export function ConfigPage() { ) } + const handleTurnProfileFieldChange = ( + key: K, + value: TurnProfileForm[K], + ) => { + updateField("turnProfile", { ...form.turnProfile, [key]: value }) + } + const handleReset = () => { setForm(baseline) setLauncherForm(launcherBaseline) @@ -314,6 +349,7 @@ export function ConfigPage() { "Summarize token percent", { min: 1, max: 100 }, ) + const turnProfile = buildTurnProfilePatch(form.turnProfile) const heartbeatInterval = parseIntField( form.heartbeatInterval, "Heartbeat interval", @@ -548,6 +584,7 @@ export function ConfigPage() { max_tool_iterations: maxToolIterations, summarize_message_threshold: summarizeMessageThreshold, summarize_token_percent: summarizeTokenPercent, + turn_profile: turnProfile, }, }, session: { @@ -757,7 +794,11 @@ export function ConfigPage() { disabled={saving || isLauncherLoading} /> - + diff --git a/web/frontend/src/components/config/config-sections.tsx b/web/frontend/src/components/config/config-sections.tsx index cd6a6869..75baa397 100644 --- a/web/frontend/src/components/config/config-sections.tsx +++ b/web/frontend/src/components/config/config-sections.tsx @@ -9,6 +9,8 @@ import { type LauncherForm, type MCPServerForm, type MCPServerType, + type TurnProfileForm, + type TurnProfileMode, } from "@/components/config/form-model" import { Field, SwitchCardField } from "@/components/shared-form" import { Button } from "@/components/ui/button" @@ -66,13 +68,49 @@ function ConfigSectionCard({ interface AgentDefaultsSectionProps { form: CoreConfigForm onFieldChange: UpdateCoreField + onTurnProfileFieldChange: ( + key: K, + value: TurnProfileForm[K], + ) => void } export function AgentDefaultsSection({ form, onFieldChange, + onTurnProfileFieldChange, }: AgentDefaultsSectionProps) { const { t } = useTranslation() + const renderModeSelect = ({ + value, + onValueChange, + allowCustom, + }: { + value: TurnProfileMode + onValueChange: (mode: TurnProfileMode) => void + allowCustom: boolean + }) => ( + + ) return ( @@ -215,6 +253,116 @@ export function AgentDefaultsSection({ } /> + + +
+ + onTurnProfileFieldChange("enabled", checked) + } + /> + +
+
+ + {renderModeSelect({ + value: form.turnProfile.historyMode, + onValueChange: (mode) => + onTurnProfileFieldChange( + "historyMode", + mode === "off" ? "off" : "default", + ), + allowCustom: false, + })} +

+ {t("pages.config.turn_profile_history_hint")} +

+
+ +
+ + {renderModeSelect({ + value: form.turnProfile.systemPromptMode, + onValueChange: (mode) => + onTurnProfileFieldChange( + "systemPromptMode", + mode === "off" ? "off" : "default", + ), + allowCustom: false, + })} +

+ {t("pages.config.turn_profile_system_prompt_hint")} +

+
+ +
+ + {renderModeSelect({ + value: form.turnProfile.skillsMode, + onValueChange: (mode) => + onTurnProfileFieldChange("skillsMode", mode), + allowCustom: true, + })} +

+ {t("pages.config.turn_profile_skills_hint")} +

+ {form.turnProfile.skillsMode === "custom" && ( +