Unified Agent + reusable Go agent core.
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: improve console llm settings workflow

Lyric 79f30acc d3461285

+2224 -260
+560 -76
cmd/mistermorph/consolecmd/agent_settings.go
··· 10 10 "net/url" 11 11 "os" 12 12 "path/filepath" 13 + "regexp" 13 14 "sort" 14 15 "strings" 15 16 "time" ··· 32 33 33 34 var supportedMultimodalSources = []string{"telegram", "slack", "line", "remote_download"} 34 35 36 + var benchmarkErrorStatusPattern = regexp.MustCompile(`(?is)\bstatus\s+\d{3}\s*:\s*(.+)$`) 37 + 35 38 type llmSettingsPayload struct { 36 39 Provider string `json:"provider"` 37 40 Endpoint string `json:"endpoint"` 38 41 Model string `json:"model"` 39 42 APIKey string `json:"api_key"` 43 + CloudflareAPIToken string `json:"cloudflare_api_token"` 40 44 CloudflareAccountID string `json:"cloudflare_account_id"` 41 45 ReasoningEffort string `json:"reasoning_effort"` 42 46 ToolsEmulationMode string `json:"tools_emulation_mode"` 43 47 } 44 48 49 + type llmSettingsUpdatePayload struct { 50 + Provider *string `json:"provider,omitempty"` 51 + Endpoint *string `json:"endpoint,omitempty"` 52 + Model *string `json:"model,omitempty"` 53 + APIKey *string `json:"api_key,omitempty"` 54 + CloudflareAPIToken *string `json:"cloudflare_api_token,omitempty"` 55 + CloudflareAccountID *string `json:"cloudflare_account_id,omitempty"` 56 + ReasoningEffort *string `json:"reasoning_effort,omitempty"` 57 + ToolsEmulationMode *string `json:"tools_emulation_mode,omitempty"` 58 + } 59 + 45 60 type multimodalSettingsPayload struct { 46 61 ImageSources []string `json:"image_sources"` 47 62 } ··· 62 77 Tools toolsSettingsPayload `json:"tools"` 63 78 } 64 79 80 + type agentSettingsUpdatePayload struct { 81 + LLM llmSettingsUpdatePayload `json:"llm"` 82 + Multimodal multimodalSettingsPayload `json:"multimodal"` 83 + Tools toolsSettingsPayload `json:"tools"` 84 + } 85 + 86 + type agentSettingsEnvManagedField struct { 87 + EnvName string `json:"env_name"` 88 + Value string `json:"value,omitempty"` 89 + } 90 + 91 + type agentSettingsEnvManagedPayload struct { 92 + LLM map[string]agentSettingsEnvManagedField `json:"llm,omitempty"` 93 + } 94 + 65 95 type agentSettingsModelsRequest struct { 66 96 Endpoint string `json:"endpoint"` 67 97 APIKey string `json:"api_key"` ··· 72 102 } 73 103 74 104 type agentSettingsBenchmarkResult struct { 75 - ID string `json:"id"` 76 - OK bool `json:"ok"` 77 - DurationMS int64 `json:"duration_ms"` 78 - Detail string `json:"detail,omitempty"` 79 - Error string `json:"error,omitempty"` 105 + ID string `json:"id"` 106 + OK bool `json:"ok"` 107 + DurationMS int64 `json:"duration_ms"` 108 + Detail string `json:"detail,omitempty"` 109 + Error string `json:"error,omitempty"` 110 + RawResponse string `json:"raw_response,omitempty"` 80 111 } 81 112 82 113 type agentSettingsTestResult struct { 83 114 Provider string 84 115 Model string 85 116 Benchmarks []agentSettingsBenchmarkResult 117 + } 118 + 119 + type agentSettingsConnectionTestOptions struct { 120 + InspectPrompt bool 121 + InspectRequest bool 86 122 } 87 123 88 124 var runAgentSettingsConnectionTest = defaultAgentSettingsConnectionTest ··· 116 152 writeError(w, http.StatusInternalServerError, err.Error()) 117 153 return 118 154 } 119 - settings = readAgentSettingsFromReader(viper.GetViper()) 155 + settings = defaultAgentSettingsPayload() 120 156 configSource = "defaults" 121 157 configValid = false 122 158 } 159 + effectiveLLM := settingsFromCurrentRuntime() 123 160 writeJSON(w, http.StatusOK, map[string]any{ 124 161 "llm": settings.LLM, 162 + "env_managed": currentAgentSettingsEnvManaged(effectiveLLM.Provider), 125 163 "multimodal": settings.Multimodal, 126 164 "tools": settings.Tools, 127 165 "config_path": configPath, ··· 132 170 } 133 171 134 172 func (s *server) handleAgentSettingsPut(w http.ResponseWriter, r *http.Request) { 135 - var req agentSettingsPayload 173 + var req agentSettingsUpdatePayload 136 174 if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&req); err != nil { 137 175 writeError(w, http.StatusBadRequest, "invalid json") 138 176 return ··· 143 181 return 144 182 } 145 183 146 - serialized, err := writeAgentSettings(configPath, req) 184 + serialized, err := writeAgentSettingsUpdate(configPath, req) 147 185 if err != nil { 148 186 writeError(w, http.StatusBadRequest, err.Error()) 149 187 return 150 188 } 151 - tmp, err := validateAgentConfigDocument(serialized) 189 + effectiveLLM := resolveAgentSettingsLLM(req.LLM) 190 + tmp, err := validateAgentConfigDocument(serialized, effectiveLLM) 152 191 if err != nil { 153 192 writeError(w, http.StatusBadRequest, err.Error()) 154 193 return ··· 162 201 return 163 202 } 164 203 165 - viper.Set(llmSettingsKey, tmp.Get(llmSettingsKey)) 204 + viper.Set(llmSettingsKey, llmSettingsPayloadToMap(effectiveLLM)) 166 205 viper.Set(multimodalSettingsKey, tmp.Get(multimodalSettingsKey)) 167 206 viper.Set(toolsSettingsKey, tmp.Get(toolsSettingsKey)) 168 207 if s != nil && s.localRuntime != nil { ··· 179 218 } 180 219 181 220 next := readAgentSettingsFromReader(tmp) 221 + if doc, docErr := loadYAMLDocumentBytes(serialized); docErr == nil { 222 + next = normalizeAgentSettingsConfigView(next, doc) 223 + } 182 224 writeJSON(w, http.StatusOK, map[string]any{ 183 225 "ok": true, 184 226 "llm": next.LLM, 227 + "env_managed": currentAgentSettingsEnvManaged(effectiveLLM.Provider), 185 228 "multimodal": next.Multimodal, 186 229 "tools": next.Tools, 187 230 "config_path": configPath, ··· 202 245 writeError(w, http.StatusBadRequest, "invalid json") 203 246 return 204 247 } 205 - if strings.TrimSpace(req.APIKey) == "" { 248 + current := settingsFromCurrentRuntime() 249 + endpoint := strings.TrimSpace(req.Endpoint) 250 + if endpoint == "" { 251 + endpoint = strings.TrimSpace(current.Endpoint) 252 + } 253 + apiKey := strings.TrimSpace(req.APIKey) 254 + if apiKey == "" { 255 + apiKey = strings.TrimSpace(current.APIKey) 256 + } 257 + if apiKey == "" { 206 258 writeError(w, http.StatusBadRequest, "api key is required") 207 259 return 208 260 } 209 - models, err := fetchOpenAICompatibleModels(r.Context(), req.Endpoint, req.APIKey) 261 + models, err := fetchOpenAICompatibleModels(r.Context(), endpoint, apiKey) 210 262 if err != nil { 211 263 writeError(w, http.StatusBadGateway, err.Error()) 212 264 return ··· 228 280 return 229 281 } 230 282 231 - result, err := runAgentSettingsConnectionTest(r.Context(), req.LLM) 283 + result, err := runAgentSettingsConnectionTest( 284 + r.Context(), 285 + resolveAgentSettingsLLM(llmSettingsPayloadAsNonEmptyUpdate(req.LLM)), 286 + agentSettingsConnectionTestOptions{ 287 + InspectPrompt: s != nil && s.cfg.inspectPrompt, 288 + InspectRequest: s != nil && s.cfg.inspectRequest, 289 + }, 290 + ) 232 291 if err != nil { 233 292 writeError(w, http.StatusBadGateway, err.Error()) 234 293 return ··· 260 319 data, err := os.ReadFile(configPath) 261 320 if err != nil { 262 321 if os.IsNotExist(err) { 263 - return readAgentSettingsFromReader(viper.GetViper()), nil 322 + return defaultAgentSettingsPayload(), nil 264 323 } 265 324 return agentSettingsPayload{}, err 266 325 } 267 326 if len(bytes.TrimSpace(data)) == 0 { 268 - return readAgentSettingsFromReader(viper.GetViper()), nil 327 + return defaultAgentSettingsPayload(), nil 269 328 } 270 329 tmp := viper.New() 271 330 integration.ApplyViperDefaults(tmp) 272 331 if err := readExpandedConsoleConfig(tmp, configPath); err != nil { 273 332 return agentSettingsPayload{}, fmt.Errorf("invalid config yaml: %w", err) 274 333 } 275 - return readAgentSettingsFromReader(tmp), nil 334 + settings := readAgentSettingsFromReader(tmp) 335 + doc, err := loadYAMLDocument(configPath) 336 + if err != nil { 337 + return agentSettingsPayload{}, err 338 + } 339 + return normalizeAgentSettingsConfigView(settings, doc), nil 340 + } 341 + 342 + func defaultAgentSettingsPayload() agentSettingsPayload { 343 + tmp := viper.New() 344 + integration.ApplyViperDefaults(tmp) 345 + settings := readAgentSettingsFromReader(tmp) 346 + settings.LLM.Endpoint = "" 347 + settings.LLM.Model = "" 348 + return settings 276 349 } 277 350 278 351 func inspectAgentSettingsConfigSource(configPath string) (bool, string, error) { ··· 290 363 } 291 364 292 365 func writeAgentSettings(configPath string, values agentSettingsPayload) ([]byte, error) { 366 + return writeAgentSettingsUpdate(configPath, agentSettingsUpdatePayload{ 367 + LLM: llmSettingsPayloadAsUpdate(values.LLM), 368 + Multimodal: values.Multimodal, 369 + Tools: values.Tools, 370 + }) 371 + } 372 + 373 + func writeAgentSettingsUpdate(configPath string, values agentSettingsUpdatePayload) ([]byte, error) { 293 374 doc, err := loadYAMLDocument(configPath) 294 375 if err != nil { 295 376 if !isInvalidConfigYAMLError(err) { ··· 297 378 } 298 379 doc = newEmptyYAMLDocument() 299 380 } 381 + current := defaultAgentSettingsPayload() 382 + if existing, readErr := readAgentSettings(configPath); readErr == nil { 383 + current = existing 384 + } else if !isInvalidConfigYAMLError(readErr) && !os.IsNotExist(readErr) { 385 + return nil, readErr 386 + } 387 + nextLLM := applyLLMSettingsUpdate(current.LLM, values.LLM) 300 388 root, err := documentMapping(doc) 301 389 if err != nil { 302 390 return nil, err 303 391 } 304 392 305 393 llmNode := ensureMappingValue(root, llmSettingsKey) 306 - setOrDeleteMappingScalar(llmNode, "provider", values.LLM.Provider) 307 - setOrDeleteMappingScalar(llmNode, "endpoint", values.LLM.Endpoint) 308 - setOrDeleteMappingScalar(llmNode, "model", values.LLM.Model) 309 - setOrDeleteMappingScalar(llmNode, "api_key", values.LLM.APIKey) 310 - if strings.EqualFold(strings.TrimSpace(values.LLM.Provider), "cloudflare") { 394 + if values.LLM.Provider != nil { 395 + setOrDeleteMappingScalar(llmNode, "provider", *values.LLM.Provider) 396 + } 397 + if values.LLM.Endpoint != nil { 398 + setOrDeleteMappingScalar(llmNode, "endpoint", *values.LLM.Endpoint) 399 + } 400 + if values.LLM.Model != nil { 401 + setOrDeleteMappingScalar(llmNode, "model", *values.LLM.Model) 402 + } 403 + if strings.EqualFold(strings.TrimSpace(nextLLM.Provider), "cloudflare") { 404 + setOrDeleteMappingScalar(llmNode, "api_key", "") 311 405 cloudflareNode := ensureMappingValue(llmNode, "cloudflare") 312 - setOrDeleteMappingScalar(cloudflareNode, "account_id", values.LLM.CloudflareAccountID) 406 + if values.LLM.CloudflareAccountID != nil { 407 + setOrDeleteMappingScalar(cloudflareNode, "account_id", *values.LLM.CloudflareAccountID) 408 + } 409 + if values.LLM.CloudflareAPIToken != nil { 410 + setOrDeleteMappingScalar(cloudflareNode, "api_token", *values.LLM.CloudflareAPIToken) 411 + } 313 412 } else { 413 + if values.LLM.APIKey != nil { 414 + setOrDeleteMappingScalar(llmNode, "api_key", *values.LLM.APIKey) 415 + } 314 416 deleteMappingKey(llmNode, "cloudflare") 315 417 } 316 - setOrDeleteMappingScalar(llmNode, "reasoning_effort", values.LLM.ReasoningEffort) 317 - setOrDeleteMappingScalar(llmNode, "tools_emulation_mode", values.LLM.ToolsEmulationMode) 418 + if values.LLM.ReasoningEffort != nil { 419 + setOrDeleteMappingScalar(llmNode, "reasoning_effort", *values.LLM.ReasoningEffort) 420 + } 421 + if values.LLM.ToolsEmulationMode != nil { 422 + setOrDeleteMappingScalar(llmNode, "tools_emulation_mode", *values.LLM.ToolsEmulationMode) 423 + } 318 424 319 425 multimodalNode := ensureMappingValue(root, multimodalSettingsKey) 320 426 imageNode := ensureMappingValue(multimodalNode, "image") ··· 332 438 return marshalYAMLDocument(doc) 333 439 } 334 440 335 - func validateAgentConfigDocument(data []byte) (*viper.Viper, error) { 441 + func validateAgentConfigDocument(data []byte, effectiveLLM llmSettingsPayload) (*viper.Viper, error) { 336 442 tmp := viper.New() 337 443 integration.ApplyViperDefaults(tmp) 338 444 tmp.SetConfigType("yaml") ··· 340 446 return nil, fmt.Errorf("invalid config yaml: %w", err) 341 447 } 342 448 values := llmutil.RuntimeValuesFromReader(tmp) 449 + values.Provider = firstNonEmpty(strings.TrimSpace(effectiveLLM.Provider), values.Provider) 450 + values.Endpoint = firstNonEmpty(strings.TrimSpace(effectiveLLM.Endpoint), values.Endpoint) 451 + values.APIKey = firstNonEmpty(strings.TrimSpace(effectiveLLM.APIKey), values.APIKey) 452 + values.Model = firstNonEmpty(strings.TrimSpace(effectiveLLM.Model), values.Model) 453 + values.CloudflareAPIToken = firstNonEmpty(strings.TrimSpace(effectiveLLM.CloudflareAPIToken), values.CloudflareAPIToken) 454 + values.CloudflareAccountID = firstNonEmpty(strings.TrimSpace(effectiveLLM.CloudflareAccountID), values.CloudflareAccountID) 455 + values.ReasoningEffortRaw = firstNonEmpty(strings.TrimSpace(effectiveLLM.ReasoningEffort), values.ReasoningEffortRaw) 456 + values.ToolsEmulationMode = firstNonEmpty(strings.TrimSpace(effectiveLLM.ToolsEmulationMode), values.ToolsEmulationMode) 343 457 route, err := llmutil.ResolveRoute(values, llmutil.RoutePurposeMainLoop) 344 458 if err != nil { 345 459 return nil, err ··· 350 464 return tmp, nil 351 465 } 352 466 353 - func defaultAgentSettingsConnectionTest(ctx context.Context, settings llmSettingsPayload) (agentSettingsTestResult, error) { 467 + func settingsFromCurrentRuntime() llmSettingsPayload { 468 + values := currentConsoleLLMRuntimeValues() 469 + provider := strings.TrimSpace(values.Provider) 470 + cloudflareAPIToken := strings.TrimSpace(values.CloudflareAPIToken) 471 + if cloudflareAPIToken == "" && strings.EqualFold(provider, "cloudflare") { 472 + cloudflareAPIToken = llmutil.APIKeyForProviderWithValues(provider, values) 473 + } 474 + return llmSettingsPayload{ 475 + Provider: provider, 476 + Endpoint: llmutil.EndpointForProviderWithValues(provider, values), 477 + Model: llmutil.ModelForProviderWithValues(provider, values), 478 + APIKey: strings.TrimSpace(values.APIKey), 479 + CloudflareAPIToken: cloudflareAPIToken, 480 + CloudflareAccountID: strings.TrimSpace(values.CloudflareAccountID), 481 + ReasoningEffort: strings.TrimSpace(values.ReasoningEffortRaw), 482 + ToolsEmulationMode: strings.TrimSpace(values.ToolsEmulationMode), 483 + } 484 + } 485 + 486 + func resolveAgentSettingsLLM(overrides llmSettingsUpdatePayload) llmSettingsPayload { 487 + return applyLLMSettingsUpdate(settingsFromCurrentRuntime(), overrides) 488 + } 489 + 490 + func currentConsoleLLMRuntimeValues() llmutil.RuntimeValues { 491 + values := llmutil.RuntimeValuesFromViper() 492 + 493 + if _, value, ok := firstManagedEnv("MISTER_MORPH_LLM_PROVIDER"); ok { 494 + values.Provider = strings.TrimSpace(value) 495 + } 496 + if _, value, ok := firstManagedEnv("MISTER_MORPH_LLM_ENDPOINT"); ok { 497 + values.Endpoint = strings.TrimSpace(value) 498 + } 499 + if _, value, ok := firstManagedEnv("MISTER_MORPH_LLM_API_KEY"); ok { 500 + values.APIKey = strings.TrimSpace(value) 501 + } 502 + if _, value, ok := firstManagedEnv("MISTER_MORPH_LLM_MODEL"); ok { 503 + values.Model = strings.TrimSpace(value) 504 + } 505 + if _, value, ok := firstManagedEnv("MISTER_MORPH_LLM_AZURE_DEPLOYMENT"); ok { 506 + values.AzureDeployment = strings.TrimSpace(value) 507 + } 508 + if _, value, ok := firstManagedEnv("MISTER_MORPH_LLM_REASONING_EFFORT"); ok { 509 + values.ReasoningEffortRaw = strings.TrimSpace(value) 510 + } 511 + if _, value, ok := firstManagedEnv("MISTER_MORPH_LLM_TOOLS_EMULATION_MODE"); ok { 512 + values.ToolsEmulationMode = strings.TrimSpace(value) 513 + } 514 + if _, value, ok := firstManagedEnv("MISTER_MORPH_LLM_CLOUDFLARE_ACCOUNT_ID"); ok { 515 + values.CloudflareAccountID = strings.TrimSpace(value) 516 + } 517 + if _, value, ok := firstManagedEnv("MISTER_MORPH_LLM_CLOUDFLARE_API_TOKEN"); ok { 518 + values.CloudflareAPIToken = strings.TrimSpace(value) 519 + } 520 + 521 + return values 522 + } 523 + 524 + func applyLLMSettingsUpdate(current llmSettingsPayload, incoming llmSettingsUpdatePayload) llmSettingsPayload { 525 + merged := current 526 + if incoming.Provider != nil { 527 + merged.Provider = strings.TrimSpace(*incoming.Provider) 528 + } 529 + if incoming.Endpoint != nil { 530 + merged.Endpoint = strings.TrimSpace(*incoming.Endpoint) 531 + } 532 + if incoming.Model != nil { 533 + merged.Model = strings.TrimSpace(*incoming.Model) 534 + } 535 + if incoming.APIKey != nil { 536 + merged.APIKey = strings.TrimSpace(*incoming.APIKey) 537 + } 538 + if incoming.CloudflareAPIToken != nil { 539 + merged.CloudflareAPIToken = strings.TrimSpace(*incoming.CloudflareAPIToken) 540 + } 541 + if incoming.CloudflareAccountID != nil { 542 + merged.CloudflareAccountID = strings.TrimSpace(*incoming.CloudflareAccountID) 543 + } 544 + if incoming.ReasoningEffort != nil { 545 + merged.ReasoningEffort = strings.TrimSpace(*incoming.ReasoningEffort) 546 + } 547 + if incoming.ToolsEmulationMode != nil { 548 + merged.ToolsEmulationMode = strings.TrimSpace(*incoming.ToolsEmulationMode) 549 + } 550 + if strings.EqualFold(strings.TrimSpace(merged.Provider), "cloudflare") { 551 + merged.APIKey = "" 552 + } else { 553 + merged.CloudflareAPIToken = "" 554 + merged.CloudflareAccountID = "" 555 + } 556 + return merged 557 + } 558 + 559 + func llmSettingsPayloadAsUpdate(values llmSettingsPayload) llmSettingsUpdatePayload { 560 + return llmSettingsUpdatePayload{ 561 + Provider: stringPointer(values.Provider), 562 + Endpoint: stringPointer(values.Endpoint), 563 + Model: stringPointer(values.Model), 564 + APIKey: stringPointer(values.APIKey), 565 + CloudflareAPIToken: stringPointer(values.CloudflareAPIToken), 566 + CloudflareAccountID: stringPointer(values.CloudflareAccountID), 567 + ReasoningEffort: stringPointer(values.ReasoningEffort), 568 + ToolsEmulationMode: stringPointer(values.ToolsEmulationMode), 569 + } 570 + } 571 + 572 + func llmSettingsPayloadAsNonEmptyUpdate(values llmSettingsPayload) llmSettingsUpdatePayload { 573 + update := llmSettingsUpdatePayload{} 574 + if value := strings.TrimSpace(values.Provider); value != "" { 575 + update.Provider = stringPointer(value) 576 + } 577 + if value := strings.TrimSpace(values.Endpoint); value != "" { 578 + update.Endpoint = stringPointer(value) 579 + } 580 + if value := strings.TrimSpace(values.Model); value != "" { 581 + update.Model = stringPointer(value) 582 + } 583 + if value := strings.TrimSpace(values.APIKey); value != "" { 584 + update.APIKey = stringPointer(value) 585 + } 586 + if value := strings.TrimSpace(values.CloudflareAPIToken); value != "" { 587 + update.CloudflareAPIToken = stringPointer(value) 588 + } 589 + if value := strings.TrimSpace(values.CloudflareAccountID); value != "" { 590 + update.CloudflareAccountID = stringPointer(value) 591 + } 592 + if value := strings.TrimSpace(values.ReasoningEffort); value != "" { 593 + update.ReasoningEffort = stringPointer(value) 594 + } 595 + if value := strings.TrimSpace(values.ToolsEmulationMode); value != "" { 596 + update.ToolsEmulationMode = stringPointer(value) 597 + } 598 + return update 599 + } 600 + 601 + func stringPointer(value string) *string { 602 + next := value 603 + return &next 604 + } 605 + 606 + func llmSettingsPayloadToMap(values llmSettingsPayload) map[string]any { 607 + out := map[string]any{ 608 + "provider": strings.TrimSpace(values.Provider), 609 + "endpoint": strings.TrimSpace(values.Endpoint), 610 + "model": strings.TrimSpace(values.Model), 611 + "reasoning_effort": strings.TrimSpace(values.ReasoningEffort), 612 + "tools_emulation_mode": strings.TrimSpace(values.ToolsEmulationMode), 613 + } 614 + if !strings.EqualFold(strings.TrimSpace(values.Provider), "cloudflare") { 615 + out["api_key"] = strings.TrimSpace(values.APIKey) 616 + } 617 + if strings.EqualFold(strings.TrimSpace(values.Provider), "cloudflare") && 618 + (strings.TrimSpace(values.CloudflareAccountID) != "" || strings.TrimSpace(values.CloudflareAPIToken) != "") { 619 + out["cloudflare"] = map[string]any{} 620 + if accountID := strings.TrimSpace(values.CloudflareAccountID); accountID != "" { 621 + out["cloudflare"].(map[string]any)["account_id"] = accountID 622 + } 623 + if apiToken := strings.TrimSpace(values.CloudflareAPIToken); apiToken != "" { 624 + out["cloudflare"].(map[string]any)["api_token"] = apiToken 625 + } 626 + } 627 + return out 628 + } 629 + 630 + func defaultAgentSettingsConnectionTest(ctx context.Context, settings llmSettingsPayload, opts agentSettingsConnectionTestOptions) (agentSettingsTestResult, error) { 354 631 values := llmutil.RuntimeValues{ 355 632 Provider: normalizeAgentSettingsProvider(settings.Provider), 356 633 Endpoint: strings.TrimSpace(settings.Endpoint), ··· 359 636 RequestTimeoutRaw: "20s", 360 637 ReasoningEffortRaw: strings.TrimSpace(settings.ReasoningEffort), 361 638 ToolsEmulationMode: strings.TrimSpace(settings.ToolsEmulationMode), 639 + CloudflareAPIToken: strings.TrimSpace(settings.CloudflareAPIToken), 362 640 CloudflareAccountID: strings.TrimSpace(settings.CloudflareAccountID), 363 641 } 364 642 ··· 370 648 if err != nil { 371 649 return agentSettingsTestResult{}, err 372 650 } 651 + inspectors, err := newConsoleInspectors(opts.InspectPrompt, opts.InspectRequest, "console_settings_test", "settings_test", "20060102_150405.000000000") 652 + if err != nil { 653 + return agentSettingsTestResult{}, err 654 + } 655 + defer func() { 656 + if inspectors != nil { 657 + _ = inspectors.Close() 658 + } 659 + }() 660 + client = inspectors.Wrap(client, route) 373 661 374 662 return agentSettingsTestResult{ 375 663 Provider: route.ClientConfig.Provider, ··· 386 674 start := time.Now() 387 675 result, err := client.Chat(ctx, llm.Request{ 388 676 Model: model, 677 + Scene: "console.settings_test.text_reply", 389 678 Messages: []llm.Message{ 390 - {Role: "system", Content: "You are a connection test. Reply briefly."}, 391 - {Role: "user", Content: "Reply with exactly: OK"}, 679 + {Role: "system", Content: "You're acting the linux cmd `echo`, will echo back the text."}, 680 + {Role: "user", Content: "OK"}, 392 681 }, 393 682 Parameters: map[string]any{ 394 - "max_tokens": 24, 683 + "max_tokens": 1024, 395 684 }, 396 685 }) 397 686 durationMS := time.Since(start).Milliseconds() 398 687 if err != nil { 399 688 return agentSettingsBenchmarkResult{ 400 - ID: "text_reply", 401 - OK: false, 402 - DurationMS: durationMS, 403 - Error: strings.TrimSpace(err.Error()), 689 + ID: "text_reply", 690 + OK: false, 691 + DurationMS: durationMS, 692 + Error: strings.TrimSpace(err.Error()), 693 + RawResponse: benchmarkRawResponseFromError(err), 404 694 } 405 695 } 406 696 407 697 text := strings.TrimSpace(result.Text) 408 698 if text == "" { 409 699 return agentSettingsBenchmarkResult{ 410 - ID: "text_reply", 411 - OK: false, 412 - DurationMS: durationMS, 413 - Error: "received an empty text reply", 700 + ID: "text_reply", 701 + OK: false, 702 + DurationMS: durationMS, 703 + Error: "received an empty text reply", 704 + RawResponse: benchmarkRawResponse(result), 414 705 } 415 706 } 416 707 417 708 return agentSettingsBenchmarkResult{ 418 - ID: "text_reply", 419 - OK: true, 420 - DurationMS: durationMS, 421 - Detail: summarizeBenchmarkDetail(text), 709 + ID: "text_reply", 710 + OK: true, 711 + DurationMS: durationMS, 712 + Detail: summarizeBenchmarkDetail(text), 713 + RawResponse: benchmarkRawResponse(result), 422 714 } 423 715 } 424 716 ··· 426 718 start := time.Now() 427 719 result, err := client.Chat(ctx, llm.Request{ 428 720 Model: model, 721 + Scene: "console.settings_test.json_response", 429 722 ForceJSON: true, 430 723 Messages: []llm.Message{ 431 - {Role: "system", Content: "You are a JSON response test. Reply with a JSON object only."}, 432 - {Role: "user", Content: `Return exactly this JSON object: {"status":"ok","message":"json ok"}`}, 724 + {Role: "system", Content: "You wrap the input by a JSON object and echo back the JSON object only. for example, IF input is `Hello` THEN return {\"message\": \"Hello\"}."}, 725 + {Role: "user", Content: `Hello`}, 433 726 }, 434 727 Parameters: map[string]any{ 435 - "max_tokens": 48, 728 + "max_tokens": 1024, 436 729 }, 437 730 }) 438 731 durationMS := time.Since(start).Milliseconds() 439 732 if err != nil { 440 733 return agentSettingsBenchmarkResult{ 441 - ID: "json_response", 442 - OK: false, 443 - DurationMS: durationMS, 444 - Error: strings.TrimSpace(err.Error()), 734 + ID: "json_response", 735 + OK: false, 736 + DurationMS: durationMS, 737 + Error: strings.TrimSpace(err.Error()), 738 + RawResponse: benchmarkRawResponseFromError(err), 445 739 } 446 740 } 447 741 448 742 var payload struct { 449 - Status string `json:"status"` 450 743 Message string `json:"message"` 451 744 } 452 745 if err := jsonutil.DecodeWithFallback(result.Text, &payload); err != nil { 453 746 return agentSettingsBenchmarkResult{ 454 - ID: "json_response", 455 - OK: false, 456 - DurationMS: durationMS, 457 - Error: "response was not valid json", 747 + ID: "json_response", 748 + OK: false, 749 + DurationMS: durationMS, 750 + Error: "response was not valid json", 751 + RawResponse: benchmarkRawResponse(result), 458 752 } 459 753 } 460 - if strings.TrimSpace(payload.Status) != "ok" { 754 + if strings.TrimSpace(payload.Message) != "Hello" { 461 755 return agentSettingsBenchmarkResult{ 462 - ID: "json_response", 463 - OK: false, 464 - DurationMS: durationMS, 465 - Error: "json response missing status=ok", 756 + ID: "json_response", 757 + OK: false, 758 + DurationMS: durationMS, 759 + Error: "json response is not so correct", 760 + RawResponse: benchmarkRawResponse(result), 466 761 } 467 762 } 468 763 ··· 471 766 detail = "status=ok" 472 767 } 473 768 return agentSettingsBenchmarkResult{ 474 - ID: "json_response", 475 - OK: true, 476 - DurationMS: durationMS, 477 - Detail: detail, 769 + ID: "json_response", 770 + OK: true, 771 + DurationMS: durationMS, 772 + Detail: detail, 773 + RawResponse: benchmarkRawResponse(result), 478 774 } 479 775 } 480 776 ··· 482 778 start := time.Now() 483 779 result, err := client.Chat(ctx, llm.Request{ 484 780 Model: model, 781 + Scene: "console.settings_test.tool_calling", 485 782 Messages: []llm.Message{ 486 783 {Role: "system", Content: "You are a tool calling test. Always call the ping tool exactly once."}, 487 784 {Role: "user", Content: "Call the ping tool now."}, ··· 494 791 }, 495 792 }, 496 793 Parameters: map[string]any{ 497 - "max_tokens": 96, 794 + "max_tokens": 1024, 498 795 }, 499 796 }) 500 797 durationMS := time.Since(start).Milliseconds() 501 798 if err != nil { 502 799 return agentSettingsBenchmarkResult{ 503 - ID: "tool_calling", 504 - OK: false, 505 - DurationMS: durationMS, 506 - Error: strings.TrimSpace(err.Error()), 800 + ID: "tool_calling", 801 + OK: false, 802 + DurationMS: durationMS, 803 + Error: strings.TrimSpace(err.Error()), 804 + RawResponse: benchmarkRawResponseFromError(err), 507 805 } 508 806 } 509 807 510 808 for _, call := range result.ToolCalls { 511 809 if strings.EqualFold(strings.TrimSpace(call.Name), "ping") { 512 810 return agentSettingsBenchmarkResult{ 513 - ID: "tool_calling", 514 - OK: true, 515 - DurationMS: durationMS, 516 - Detail: "called ping", 811 + ID: "tool_calling", 812 + OK: true, 813 + DurationMS: durationMS, 814 + Detail: "called ping", 815 + RawResponse: benchmarkRawResponse(result), 517 816 } 518 817 } 519 818 } ··· 525 824 detail = "model replied without calling the tool: " + detail 526 825 } 527 826 return agentSettingsBenchmarkResult{ 528 - ID: "tool_calling", 529 - OK: false, 530 - DurationMS: durationMS, 531 - Error: detail, 827 + ID: "tool_calling", 828 + OK: false, 829 + DurationMS: durationMS, 830 + Error: detail, 831 + RawResponse: benchmarkRawResponse(result), 832 + } 833 + } 834 + 835 + func benchmarkRawResponse(result llm.Result) string { 836 + text := strings.TrimSpace(result.Text) 837 + if len(result.ToolCalls) == 0 && result.JSON == nil { 838 + return text 839 + } 840 + 841 + payload := map[string]any{} 842 + if text != "" { 843 + payload["text"] = text 844 + } 845 + if result.JSON != nil { 846 + payload["json"] = result.JSON 847 + } 848 + if len(result.ToolCalls) > 0 { 849 + payload["tool_calls"] = result.ToolCalls 850 + } 851 + if len(payload) == 0 { 852 + return "" 853 + } 854 + 855 + serialized, err := json.MarshalIndent(payload, "", " ") 856 + if err != nil { 857 + return text 532 858 } 859 + return string(serialized) 860 + } 861 + 862 + func benchmarkRawResponseFromError(err error) string { 863 + if err == nil { 864 + return "" 865 + } 866 + 867 + text := strings.TrimSpace(err.Error()) 868 + if text == "" { 869 + return "" 870 + } 871 + 872 + matches := benchmarkErrorStatusPattern.FindStringSubmatch(text) 873 + if len(matches) == 2 { 874 + if raw := strings.TrimSpace(matches[1]); raw != "" { 875 + return raw 876 + } 877 + } 878 + 879 + return text 533 880 } 534 881 535 882 func summarizeBenchmarkDetail(value string) string { ··· 648 995 } 649 996 return nil, err 650 997 } 998 + if len(bytes.TrimSpace(data)) == 0 { 999 + return newEmptyYAMLDocument(), nil 1000 + } 1001 + return loadYAMLDocumentBytes(data) 1002 + } 1003 + 1004 + func loadYAMLDocumentBytes(data []byte) (*yaml.Node, error) { 651 1005 if len(bytes.TrimSpace(data)) == 0 { 652 1006 return newEmptyYAMLDocument(), nil 653 1007 } ··· 839 1193 return "false" 840 1194 } 841 1195 1196 + func firstNonEmpty(values ...string) string { 1197 + for _, value := range values { 1198 + trimmed := strings.TrimSpace(value) 1199 + if trimmed != "" { 1200 + return trimmed 1201 + } 1202 + } 1203 + return "" 1204 + } 1205 + 1206 + func normalizeAgentSettingsConfigView(settings agentSettingsPayload, doc *yaml.Node) agentSettingsPayload { 1207 + if !agentSettingsYAMLHasLLMKey(doc, "endpoint") { 1208 + settings.LLM.Endpoint = "" 1209 + } 1210 + if !agentSettingsYAMLHasLLMKey(doc, "model") { 1211 + settings.LLM.Model = "" 1212 + } 1213 + return settings 1214 + } 1215 + 1216 + func agentSettingsYAMLHasLLMKey(doc *yaml.Node, key string) bool { 1217 + root, err := documentMapping(doc) 1218 + if err != nil { 1219 + return false 1220 + } 1221 + llmNode := findMappingValue(root, llmSettingsKey) 1222 + if llmNode == nil || llmNode.Kind != yaml.MappingNode { 1223 + return false 1224 + } 1225 + return findMappingValue(llmNode, key) != nil 1226 + } 1227 + 842 1228 func readAgentSettingsFromReader(r interface { 843 1229 GetString(string) string 844 1230 GetStringSlice(string) []string ··· 847 1233 if r == nil { 848 1234 return agentSettingsPayload{} 849 1235 } 1236 + provider := strings.TrimSpace(r.GetString("llm.provider")) 1237 + cloudflareAPIToken := strings.TrimSpace(r.GetString("llm.cloudflare.api_token")) 1238 + if strings.EqualFold(provider, "cloudflare") { 1239 + cloudflareAPIToken = firstNonEmpty(cloudflareAPIToken, strings.TrimSpace(r.GetString("llm.api_key"))) 1240 + } 850 1241 return agentSettingsPayload{ 851 1242 LLM: llmSettingsPayload{ 852 - Provider: strings.TrimSpace(r.GetString("llm.provider")), 1243 + Provider: provider, 853 1244 Endpoint: strings.TrimSpace(r.GetString("llm.endpoint")), 854 1245 Model: strings.TrimSpace(r.GetString("llm.model")), 855 1246 APIKey: strings.TrimSpace(r.GetString("llm.api_key")), 1247 + CloudflareAPIToken: cloudflareAPIToken, 856 1248 CloudflareAccountID: strings.TrimSpace(r.GetString("llm.cloudflare.account_id")), 857 1249 ReasoningEffort: strings.TrimSpace(r.GetString("llm.reasoning_effort")), 858 1250 ToolsEmulationMode: strings.TrimSpace(r.GetString("llm.tools_emulation_mode")), ··· 870 1262 BashEnabled: r.GetBool("tools.bash.enabled"), 871 1263 }, 872 1264 } 1265 + } 1266 + 1267 + func currentAgentSettingsEnvManaged(provider string) agentSettingsEnvManagedPayload { 1268 + return agentSettingsEnvManagedPayload{ 1269 + LLM: currentAgentSettingsLLMEnvManaged(provider), 1270 + } 1271 + } 1272 + 1273 + func currentAgentSettingsLLMEnvManaged(provider string) map[string]agentSettingsEnvManagedField { 1274 + fields := map[string]agentSettingsEnvManagedField{} 1275 + normalizedProvider := strings.TrimSpace(strings.ToLower(provider)) 1276 + 1277 + if field, ok := currentAgentSettingsManagedEnvField(false, "MISTER_MORPH_LLM_PROVIDER"); ok { 1278 + fields["provider"] = field 1279 + } 1280 + if field, ok := currentAgentSettingsManagedEnvField(false, "MISTER_MORPH_LLM_ENDPOINT"); ok { 1281 + fields["endpoint"] = field 1282 + } 1283 + if field, ok := currentAgentSettingsModelEnvField(provider); ok { 1284 + fields["model"] = field 1285 + } 1286 + if normalizedProvider == "cloudflare" { 1287 + if field, ok := currentAgentSettingsManagedEnvField( 1288 + true, 1289 + "MISTER_MORPH_LLM_CLOUDFLARE_API_TOKEN", 1290 + "MISTER_MORPH_LLM_API_KEY", 1291 + ); ok { 1292 + fields["cloudflare_api_token"] = field 1293 + } 1294 + } else { 1295 + if field, ok := currentAgentSettingsManagedEnvField(true, "MISTER_MORPH_LLM_API_KEY"); ok { 1296 + fields["api_key"] = field 1297 + } 1298 + if field, ok := currentAgentSettingsManagedEnvField(true, "MISTER_MORPH_LLM_CLOUDFLARE_API_TOKEN"); ok { 1299 + fields["cloudflare_api_token"] = field 1300 + } 1301 + } 1302 + if field, ok := currentAgentSettingsManagedEnvField(false, "MISTER_MORPH_LLM_CLOUDFLARE_ACCOUNT_ID"); ok { 1303 + fields["cloudflare_account_id"] = field 1304 + } 1305 + if field, ok := currentAgentSettingsManagedEnvField(false, "MISTER_MORPH_LLM_REASONING_EFFORT"); ok { 1306 + fields["reasoning_effort"] = field 1307 + } 1308 + if field, ok := currentAgentSettingsManagedEnvField(false, "MISTER_MORPH_LLM_TOOLS_EMULATION_MODE"); ok { 1309 + fields["tools_emulation_mode"] = field 1310 + } 1311 + 1312 + if len(fields) == 0 { 1313 + return nil 1314 + } 1315 + return fields 1316 + } 1317 + 1318 + func currentAgentSettingsModelEnvField(provider string) (agentSettingsEnvManagedField, bool) { 1319 + if strings.EqualFold(strings.TrimSpace(provider), "azure") { 1320 + return currentAgentSettingsManagedEnvField( 1321 + false, 1322 + "MISTER_MORPH_LLM_AZURE_DEPLOYMENT", 1323 + "MISTER_MORPH_LLM_MODEL", 1324 + ) 1325 + } 1326 + return currentAgentSettingsManagedEnvField(false, "MISTER_MORPH_LLM_MODEL") 1327 + } 1328 + 1329 + func currentAgentSettingsManagedEnvField(sensitive bool, names ...string) (agentSettingsEnvManagedField, bool) { 1330 + name, value, ok := firstManagedEnv(names...) 1331 + if !ok { 1332 + return agentSettingsEnvManagedField{}, false 1333 + } 1334 + field := agentSettingsEnvManagedField{EnvName: name} 1335 + if !sensitive { 1336 + field.Value = strings.TrimSpace(value) 1337 + } 1338 + return field, true 1339 + } 1340 + 1341 + func firstManagedEnvName(names ...string) (string, bool) { 1342 + name, _, ok := firstManagedEnv(names...) 1343 + return name, ok 1344 + } 1345 + 1346 + func firstManagedEnv(names ...string) (string, string, bool) { 1347 + for _, name := range names { 1348 + name = strings.TrimSpace(name) 1349 + if name == "" { 1350 + continue 1351 + } 1352 + if value, ok := os.LookupEnv(name); ok { 1353 + return name, value, true 1354 + } 1355 + } 1356 + return "", "", false 873 1357 } 874 1358 875 1359 func sanitizeMultimodalSources(values []string) []string {
+677 -7
cmd/mistermorph/consolecmd/agent_settings_test.go
··· 4 4 "bytes" 5 5 "context" 6 6 "encoding/json" 7 + "errors" 7 8 "net/http" 8 9 "net/http/httptest" 9 10 "os" ··· 11 12 "strings" 12 13 "testing" 13 14 15 + "github.com/quailyquaily/mistermorph/llm" 14 16 "github.com/spf13/viper" 15 17 ) 16 18 17 19 func TestReadAgentSettings(t *testing.T) { 18 20 configPath := filepath.Join(t.TempDir(), "config.yaml") 19 21 if err := os.WriteFile(configPath, []byte( 20 - "llm:\n provider: openai\n model: gpt-5.2\n reasoning_effort: high\n cloudflare:\n account_id: acc-123\n"+ 22 + "llm:\n provider: cloudflare\n model: gpt-5.2\n reasoning_effort: high\n api_key: legacy-cf-token\n cloudflare:\n account_id: acc-123\n"+ 21 23 "multimodal:\n image:\n sources: [telegram, line]\n"+ 22 24 "tools:\n bash:\n enabled: false\n", 23 25 ), 0o600); err != nil { ··· 28 30 if err != nil { 29 31 t.Fatalf("readAgentSettings() error = %v", err) 30 32 } 31 - if got.LLM.Provider != "openai" || got.LLM.Model != "gpt-5.2" || got.LLM.ReasoningEffort != "high" { 33 + if got.LLM.Provider != "cloudflare" || got.LLM.Model != "gpt-5.2" || got.LLM.ReasoningEffort != "high" { 32 34 t.Fatalf("got.LLM = %+v", got.LLM) 33 35 } 34 36 if got.LLM.CloudflareAccountID != "acc-123" { 35 37 t.Fatalf("got.LLM.CloudflareAccountID = %q, want acc-123", got.LLM.CloudflareAccountID) 38 + } 39 + if got.LLM.CloudflareAPIToken != "legacy-cf-token" { 40 + t.Fatalf("got.LLM.CloudflareAPIToken = %q, want legacy-cf-token", got.LLM.CloudflareAPIToken) 36 41 } 37 42 if len(got.Multimodal.ImageSources) != 2 || got.Multimodal.ImageSources[0] != "telegram" || got.Multimodal.ImageSources[1] != "line" { 38 43 t.Fatalf("got.Multimodal = %+v", got.Multimodal) ··· 194 199 } 195 200 } 196 201 202 + func TestHandleAgentSettingsPutPreservesOmittedLLMFields(t *testing.T) { 203 + configPath := filepath.Join(t.TempDir(), "config.yaml") 204 + if err := os.WriteFile(configPath, []byte( 205 + "llm:\n provider: openai\n endpoint: https://api.openai.com\n model: gpt-5.2\n api_key: sk-file\n reasoning_effort: low\n"+ 206 + "multimodal:\n image:\n sources: [telegram]\n"+ 207 + "tools:\n bash:\n enabled: true\n", 208 + ), 0o600); err != nil { 209 + t.Fatalf("WriteFile() error = %v", err) 210 + } 211 + 212 + prevConfig, hadConfig := viper.Get("config"), viper.IsSet("config") 213 + prevLLM, hadLLM := viper.Get("llm"), viper.IsSet("llm") 214 + prevMM, hadMM := viper.Get("multimodal"), viper.IsSet("multimodal") 215 + prevTools, hadTools := viper.Get("tools"), viper.IsSet("tools") 216 + viper.Set("config", configPath) 217 + viper.Set("llm", map[string]any{ 218 + "provider": "openai", 219 + "endpoint": "https://api.openai.com", 220 + "model": "gpt-5.2", 221 + "api_key": "sk-file", 222 + "reasoning_effort": "low", 223 + }) 224 + viper.Set("multimodal", map[string]any{ 225 + "image": map[string]any{ 226 + "sources": []string{"telegram"}, 227 + }, 228 + }) 229 + viper.Set("tools", map[string]any{ 230 + "bash": map[string]any{"enabled": true}, 231 + }) 232 + t.Cleanup(func() { 233 + if hadConfig { 234 + viper.Set("config", prevConfig) 235 + } else { 236 + viper.Set("config", nil) 237 + } 238 + if hadLLM { 239 + viper.Set("llm", prevLLM) 240 + } else { 241 + viper.Set("llm", nil) 242 + } 243 + if hadMM { 244 + viper.Set("multimodal", prevMM) 245 + } else { 246 + viper.Set("multimodal", nil) 247 + } 248 + if hadTools { 249 + viper.Set("tools", prevTools) 250 + } else { 251 + viper.Set("tools", nil) 252 + } 253 + }) 254 + 255 + req := httptest.NewRequest(http.MethodPut, "/api/settings/agent", bytes.NewBufferString(`{ 256 + "llm":{"model":"gpt-5.1"}, 257 + "multimodal":{"image_sources":["telegram"]}, 258 + "tools":{"write_file_enabled":true,"contacts_send_enabled":true,"todo_update_enabled":true,"plan_create_enabled":true,"url_fetch_enabled":true,"web_search_enabled":true,"bash_enabled":true} 259 + }`)) 260 + rec := httptest.NewRecorder() 261 + 262 + (&server{}).handleAgentSettings(rec, req) 263 + 264 + if rec.Code != http.StatusOK { 265 + t.Fatalf("status = %d, want %d (%s)", rec.Code, http.StatusOK, rec.Body.String()) 266 + } 267 + raw, err := os.ReadFile(configPath) 268 + if err != nil { 269 + t.Fatalf("ReadFile() error = %v", err) 270 + } 271 + out := string(raw) 272 + if !strings.Contains(out, "provider: openai") || !strings.Contains(out, "endpoint: https://api.openai.com") { 273 + t.Fatalf("config should preserve omitted provider/endpoint: %s", out) 274 + } 275 + if !strings.Contains(out, "api_key: sk-file") || !strings.Contains(out, "reasoning_effort: low") { 276 + t.Fatalf("config should preserve omitted llm secrets/settings: %s", out) 277 + } 278 + if !strings.Contains(out, "model: gpt-5.1") { 279 + t.Fatalf("config should update provided llm field: %s", out) 280 + } 281 + } 282 + 283 + func TestHandleAgentSettingsPutClearsExplicitEmptyLLMField(t *testing.T) { 284 + configPath := filepath.Join(t.TempDir(), "config.yaml") 285 + if err := os.WriteFile(configPath, []byte( 286 + "llm:\n provider: openai\n endpoint: https://api.openai.com\n model: gpt-5.2\n api_key: sk-file\n"+ 287 + "multimodal:\n image:\n sources: [telegram]\n"+ 288 + "tools:\n bash:\n enabled: true\n", 289 + ), 0o600); err != nil { 290 + t.Fatalf("WriteFile() error = %v", err) 291 + } 292 + 293 + prevConfig, hadConfig := viper.Get("config"), viper.IsSet("config") 294 + prevLLM, hadLLM := viper.Get("llm"), viper.IsSet("llm") 295 + prevMM, hadMM := viper.Get("multimodal"), viper.IsSet("multimodal") 296 + prevTools, hadTools := viper.Get("tools"), viper.IsSet("tools") 297 + viper.Set("config", configPath) 298 + viper.Set("llm", map[string]any{ 299 + "provider": "openai", 300 + "endpoint": "https://api.openai.com", 301 + "model": "gpt-5.2", 302 + "api_key": "sk-file", 303 + }) 304 + viper.Set("multimodal", map[string]any{ 305 + "image": map[string]any{ 306 + "sources": []string{"telegram"}, 307 + }, 308 + }) 309 + viper.Set("tools", map[string]any{ 310 + "bash": map[string]any{"enabled": true}, 311 + }) 312 + t.Cleanup(func() { 313 + if hadConfig { 314 + viper.Set("config", prevConfig) 315 + } else { 316 + viper.Set("config", nil) 317 + } 318 + if hadLLM { 319 + viper.Set("llm", prevLLM) 320 + } else { 321 + viper.Set("llm", nil) 322 + } 323 + if hadMM { 324 + viper.Set("multimodal", prevMM) 325 + } else { 326 + viper.Set("multimodal", nil) 327 + } 328 + if hadTools { 329 + viper.Set("tools", prevTools) 330 + } else { 331 + viper.Set("tools", nil) 332 + } 333 + }) 334 + 335 + req := httptest.NewRequest(http.MethodPut, "/api/settings/agent", bytes.NewBufferString(`{ 336 + "llm":{"endpoint":""}, 337 + "multimodal":{"image_sources":["telegram"]}, 338 + "tools":{"write_file_enabled":true,"contacts_send_enabled":true,"todo_update_enabled":true,"plan_create_enabled":true,"url_fetch_enabled":true,"web_search_enabled":true,"bash_enabled":true} 339 + }`)) 340 + rec := httptest.NewRecorder() 341 + 342 + (&server{}).handleAgentSettings(rec, req) 343 + 344 + if rec.Code != http.StatusOK { 345 + t.Fatalf("status = %d, want %d (%s)", rec.Code, http.StatusOK, rec.Body.String()) 346 + } 347 + raw, err := os.ReadFile(configPath) 348 + if err != nil { 349 + t.Fatalf("ReadFile() error = %v", err) 350 + } 351 + out := string(raw) 352 + if strings.Contains(out, "endpoint: https://api.openai.com") { 353 + t.Fatalf("config should delete explicitly cleared llm field: %s", out) 354 + } 355 + if !strings.Contains(out, "provider: openai") || !strings.Contains(out, "model: gpt-5.2") { 356 + t.Fatalf("config should preserve other llm fields: %s", out) 357 + } 358 + } 359 + 360 + func TestHandleAgentSettingsPutFallsBackToRuntimeCloudflareCredentials(t *testing.T) { 361 + configPath := filepath.Join(t.TempDir(), "config.yaml") 362 + prevConfig, hadConfig := viper.Get("config"), viper.IsSet("config") 363 + prevLLM, hadLLM := viper.Get("llm"), viper.IsSet("llm") 364 + prevMM, hadMM := viper.Get("multimodal"), viper.IsSet("multimodal") 365 + prevTools, hadTools := viper.Get("tools"), viper.IsSet("tools") 366 + viper.Set("config", configPath) 367 + viper.Set("llm", map[string]any{ 368 + "provider": "openai", 369 + "endpoint": "https://api.openai.com", 370 + "model": "gpt-5.2", 371 + }) 372 + viper.Set("multimodal", map[string]any{ 373 + "image": map[string]any{ 374 + "sources": []string{"telegram"}, 375 + }, 376 + }) 377 + viper.Set("tools", map[string]any{ 378 + "write_file": map[string]any{"enabled": true}, 379 + "contacts_send": map[string]any{"enabled": true}, 380 + "todo_update": map[string]any{"enabled": true}, 381 + "plan_create": map[string]any{"enabled": true}, 382 + "url_fetch": map[string]any{"enabled": true}, 383 + "web_search": map[string]any{"enabled": true}, 384 + "bash": map[string]any{"enabled": true}, 385 + }) 386 + t.Setenv("MISTER_MORPH_LLM_CLOUDFLARE_ACCOUNT_ID", "acc-env") 387 + t.Setenv("MISTER_MORPH_LLM_CLOUDFLARE_API_TOKEN", "cf-env-token") 388 + t.Cleanup(func() { 389 + if hadConfig { 390 + viper.Set("config", prevConfig) 391 + } else { 392 + viper.Set("config", nil) 393 + } 394 + if hadLLM { 395 + viper.Set("llm", prevLLM) 396 + } else { 397 + viper.Set("llm", nil) 398 + } 399 + if hadMM { 400 + viper.Set("multimodal", prevMM) 401 + } else { 402 + viper.Set("multimodal", nil) 403 + } 404 + if hadTools { 405 + viper.Set("tools", prevTools) 406 + } else { 407 + viper.Set("tools", nil) 408 + } 409 + }) 410 + 411 + req := httptest.NewRequest(http.MethodPut, "/api/settings/agent", bytes.NewBufferString(`{ 412 + "llm":{"provider":"cloudflare","endpoint":"https://api.openai.com"}, 413 + "multimodal":{"image_sources":["telegram"]}, 414 + "tools":{"write_file_enabled":true,"contacts_send_enabled":true,"todo_update_enabled":true,"plan_create_enabled":true,"url_fetch_enabled":true,"web_search_enabled":true,"bash_enabled":true} 415 + }`)) 416 + rec := httptest.NewRecorder() 417 + 418 + (&server{}).handleAgentSettings(rec, req) 419 + 420 + if rec.Code != http.StatusOK { 421 + t.Fatalf("status = %d, want %d (%s)", rec.Code, http.StatusOK, rec.Body.String()) 422 + } 423 + body := rec.Body.String() 424 + if !strings.Contains(body, `"env_name":"MISTER_MORPH_LLM_CLOUDFLARE_API_TOKEN"`) { 425 + t.Fatalf("response should expose runtime cloudflare api token env-managed metadata: %s", body) 426 + } 427 + } 428 + 197 429 func TestWriteAgentSettingsKeepsCloudflareBlockForCloudflareProvider(t *testing.T) { 198 430 configPath := filepath.Join(t.TempDir(), "config.yaml") 199 431 if err := os.WriteFile(configPath, []byte( 200 - "llm:\n provider: openai\n model: gpt-5.2\n cloudflare:\n api_token: ${CLOUDFLARE_API_TOKEN}\n", 432 + "llm:\n provider: openai\n model: gpt-5.2\n api_key: sk-old\n cloudflare:\n api_token: ${CLOUDFLARE_API_TOKEN}\n", 201 433 ), 0o600); err != nil { 202 434 t.Fatalf("WriteFile() error = %v", err) 203 435 } ··· 206 438 LLM: llmSettingsPayload{ 207 439 Provider: "cloudflare", 208 440 Model: "@cf/meta/llama-3.1-8b-instruct", 441 + CloudflareAPIToken: "cf-new-token", 209 442 CloudflareAccountID: "acc-live", 210 443 }, 211 444 }) ··· 216 449 if !strings.Contains(out, "provider: cloudflare") || !strings.Contains(out, "account_id: acc-live") { 217 450 t.Fatalf("serialized config missing cloudflare settings: %s", out) 218 451 } 219 - if !strings.Contains(out, "api_token: ${CLOUDFLARE_API_TOKEN}") { 220 - t.Fatalf("serialized config should preserve cloudflare api_token: %s", out) 452 + if !strings.Contains(out, "api_token: cf-new-token") { 453 + t.Fatalf("serialized config should write cloudflare api_token: %s", out) 454 + } 455 + if strings.Contains(out, "api_key: sk-old") { 456 + t.Fatalf("serialized config should remove generic api_key for cloudflare provider: %s", out) 221 457 } 222 458 } 223 459 ··· 286 522 } 287 523 } 288 524 525 + func TestHandleAgentSettingsGetIncludesEnvManagedMetadata(t *testing.T) { 526 + configPath := filepath.Join(t.TempDir(), "config.yaml") 527 + if err := os.WriteFile(configPath, []byte( 528 + "llm:\n provider: openai\n endpoint: https://api.openai.com\n model: gpt-5.2\n", 529 + ), 0o600); err != nil { 530 + t.Fatalf("WriteFile() error = %v", err) 531 + } 532 + 533 + t.Setenv("MISTER_MORPH_LLM_PROVIDER", "openai") 534 + t.Setenv("MISTER_MORPH_LLM_API_KEY", "sk-test") 535 + t.Setenv("MISTER_MORPH_LLM_REASONING_EFFORT", "high") 536 + 537 + prevConfig, hadConfig := viper.Get("config"), viper.IsSet("config") 538 + prevLLM, hadLLM := viper.Get("llm"), viper.IsSet("llm") 539 + viper.Set("config", configPath) 540 + viper.Set("llm", map[string]any{ 541 + "provider": "openai", 542 + "endpoint": "https://api.openai.com", 543 + "model": "gpt-5.2", 544 + "api_key": "sk-test", 545 + "reasoning_effort": "high", 546 + }) 547 + t.Cleanup(func() { 548 + if hadConfig { 549 + viper.Set("config", prevConfig) 550 + } else { 551 + viper.Set("config", nil) 552 + } 553 + if hadLLM { 554 + viper.Set("llm", prevLLM) 555 + } else { 556 + viper.Set("llm", nil) 557 + } 558 + }) 559 + 560 + req := httptest.NewRequest(http.MethodGet, "/api/settings/agent", nil) 561 + rec := httptest.NewRecorder() 562 + 563 + (&server{}).handleAgentSettings(rec, req) 564 + 565 + if rec.Code != http.StatusOK { 566 + t.Fatalf("status = %d, want %d (%s)", rec.Code, http.StatusOK, rec.Body.String()) 567 + } 568 + if got := rec.Body.String(); !strings.Contains(got, `"env_name":"MISTER_MORPH_LLM_API_KEY"`) { 569 + t.Fatalf("response missing api key env-managed metadata: %s", got) 570 + } 571 + if strings.Contains(rec.Body.String(), `"api_key":"sk-test"`) { 572 + t.Fatalf("response should not leak env-managed api key into llm payload: %s", rec.Body.String()) 573 + } 574 + if !strings.Contains(rec.Body.String(), `"env_managed":{"llm":`) { 575 + t.Fatalf("response missing env_managed wrapper: %s", rec.Body.String()) 576 + } 577 + if !strings.Contains(rec.Body.String(), `"env_name":"MISTER_MORPH_LLM_PROVIDER","value":"openai"`) { 578 + t.Fatalf("response missing provider env-managed metadata: %s", rec.Body.String()) 579 + } 580 + if !strings.Contains(rec.Body.String(), `"env_name":"MISTER_MORPH_LLM_REASONING_EFFORT","value":"high"`) { 581 + t.Fatalf("response missing reasoning env-managed metadata: %s", rec.Body.String()) 582 + } 583 + } 584 + 585 + func TestHandleAgentSettingsGetUsesDefaultsForLLMWhenConfigMissing(t *testing.T) { 586 + t.Setenv("MISTER_MORPH_LLM_PROVIDER", "cloudflare") 587 + t.Setenv("MISTER_MORPH_LLM_MODEL", "@cf/meta/llama-3.3-70b-instruct-fp8-fast") 588 + t.Setenv("MISTER_MORPH_LLM_API_KEY", "sk-env") 589 + 590 + configPath := filepath.Join(t.TempDir(), "missing-config.yaml") 591 + prevConfig, hadConfig := viper.Get("config"), viper.IsSet("config") 592 + viper.Set("config", configPath) 593 + t.Cleanup(func() { 594 + if hadConfig { 595 + viper.Set("config", prevConfig) 596 + } else { 597 + viper.Set("config", nil) 598 + } 599 + }) 600 + 601 + req := httptest.NewRequest(http.MethodGet, "/api/settings/agent", nil) 602 + rec := httptest.NewRecorder() 603 + 604 + (&server{}).handleAgentSettings(rec, req) 605 + 606 + if rec.Code != http.StatusOK { 607 + t.Fatalf("status = %d, want %d (%s)", rec.Code, http.StatusOK, rec.Body.String()) 608 + } 609 + body := rec.Body.String() 610 + if !strings.Contains(body, `"llm":{"provider":"openai","endpoint":"","model":"","api_key":"","cloudflare_api_token":"","cloudflare_account_id":"","reasoning_effort":"","tools_emulation_mode":"off"}`) { 611 + t.Fatalf("llm payload should expose defaults only: %s", body) 612 + } 613 + if !strings.Contains(body, `"env_managed":{"llm":`) || !strings.Contains(body, `"env_name":"MISTER_MORPH_LLM_PROVIDER","value":"cloudflare"`) { 614 + t.Fatalf("env_managed payload should expose env overrides separately: %s", body) 615 + } 616 + } 617 + 618 + func TestHandleAgentSettingsGetIncludesCloudflareTokenEnvManagedWithoutCloudflareProvider(t *testing.T) { 619 + configPath := filepath.Join(t.TempDir(), "missing-config.yaml") 620 + t.Setenv("MISTER_MORPH_LLM_CLOUDFLARE_ACCOUNT_ID", "acc-env") 621 + t.Setenv("MISTER_MORPH_LLM_CLOUDFLARE_API_TOKEN", "cf-env-token") 622 + 623 + prevConfig, hadConfig := viper.Get("config"), viper.IsSet("config") 624 + viper.Set("config", configPath) 625 + t.Cleanup(func() { 626 + if hadConfig { 627 + viper.Set("config", prevConfig) 628 + } else { 629 + viper.Set("config", nil) 630 + } 631 + }) 632 + 633 + req := httptest.NewRequest(http.MethodGet, "/api/settings/agent", nil) 634 + rec := httptest.NewRecorder() 635 + 636 + (&server{}).handleAgentSettings(rec, req) 637 + 638 + if rec.Code != http.StatusOK { 639 + t.Fatalf("status = %d, want %d (%s)", rec.Code, http.StatusOK, rec.Body.String()) 640 + } 641 + body := rec.Body.String() 642 + if !strings.Contains(body, `"env_name":"MISTER_MORPH_LLM_CLOUDFLARE_API_TOKEN"`) { 643 + t.Fatalf("response missing cloudflare api token env-managed metadata: %s", body) 644 + } 645 + if !strings.Contains(body, `"env_name":"MISTER_MORPH_LLM_CLOUDFLARE_ACCOUNT_ID","value":"acc-env"`) { 646 + t.Fatalf("response missing cloudflare account id env-managed metadata: %s", body) 647 + } 648 + } 649 + 289 650 func TestWriteAgentSettingsRepairsMalformedConfig(t *testing.T) { 290 651 configPath := filepath.Join(t.TempDir(), "config.yaml") 291 652 if err := os.WriteFile(configPath, []byte("llm: [\n"), 0o600); err != nil { ··· 339 700 } 340 701 } 341 702 703 + func TestHandleAgentSettingsModelsFallsBackToRuntimeAPIKey(t *testing.T) { 704 + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 705 + if got := r.Header.Get("Authorization"); got != "Bearer sk-runtime" { 706 + t.Fatalf("authorization = %q, want Bearer sk-runtime", got) 707 + } 708 + w.Header().Set("Content-Type", "application/json") 709 + _, _ = w.Write([]byte(`{"data":[{"id":"gpt-5"}]}`)) 710 + })) 711 + defer upstream.Close() 712 + 713 + prevLLM, hadLLM := viper.Get("llm"), viper.IsSet("llm") 714 + viper.Set("llm", map[string]any{ 715 + "provider": "openai", 716 + "endpoint": upstream.URL, 717 + "api_key": "sk-runtime", 718 + }) 719 + t.Cleanup(func() { 720 + if hadLLM { 721 + viper.Set("llm", prevLLM) 722 + } else { 723 + viper.Set("llm", nil) 724 + } 725 + }) 726 + 727 + req := httptest.NewRequest(http.MethodPost, "/api/settings/agent/models", bytes.NewBufferString(`{"endpoint":"","api_key":""}`)) 728 + rec := httptest.NewRecorder() 729 + 730 + (&server{}).handleAgentSettingsModels(rec, req) 731 + 732 + if rec.Code != http.StatusOK { 733 + t.Fatalf("status = %d, want %d (%s)", rec.Code, http.StatusOK, rec.Body.String()) 734 + } 735 + } 736 + 342 737 func TestHandleAgentSettingsTest(t *testing.T) { 343 738 prev := runAgentSettingsConnectionTest 344 - runAgentSettingsConnectionTest = func(_ context.Context, settings llmSettingsPayload) (agentSettingsTestResult, error) { 739 + runAgentSettingsConnectionTest = func(_ context.Context, settings llmSettingsPayload, opts agentSettingsConnectionTestOptions) (agentSettingsTestResult, error) { 740 + if opts.InspectPrompt || opts.InspectRequest { 741 + t.Fatalf("unexpected inspect opts: %+v", opts) 742 + } 345 743 if settings.Provider != "openai" { 346 744 t.Fatalf("provider = %q, want openai", settings.Provider) 347 745 } ··· 352 750 Provider: "openai", 353 751 Model: "gpt-5", 354 752 Benchmarks: []agentSettingsBenchmarkResult{ 355 - {ID: "text_reply", OK: true, DurationMS: 912, Detail: "OK"}, 753 + {ID: "text_reply", OK: true, DurationMS: 912, Detail: "OK", RawResponse: "OK"}, 356 754 {ID: "json_response", OK: true, DurationMS: 1044, Detail: "json ok"}, 357 755 {ID: "tool_calling", OK: false, DurationMS: 1350, Error: "model replied without calling the tool"}, 358 756 }, ··· 375 773 if got := rec.Body.String(); !strings.Contains(got, `"benchmarks":[`) || !strings.Contains(got, `"id":"text_reply"`) || !strings.Contains(got, `"id":"json_response"`) || !strings.Contains(got, `"id":"tool_calling"`) { 376 774 t.Fatalf("response missing test result fields: %s", got) 377 775 } 776 + if got := rec.Body.String(); !strings.Contains(got, `"raw_response":"OK"`) { 777 + t.Fatalf("response missing raw benchmark response: %s", got) 778 + } 779 + } 780 + 781 + func TestHandleAgentSettingsTestFallsBackToRuntimeConfig(t *testing.T) { 782 + prev := runAgentSettingsConnectionTest 783 + prevLLM, hadLLM := viper.Get("llm"), viper.IsSet("llm") 784 + viper.Set("llm", map[string]any{ 785 + "provider": "openai", 786 + "endpoint": "https://api.openai.com", 787 + "model": "gpt-5-mini", 788 + "api_key": "sk-runtime", 789 + }) 790 + runAgentSettingsConnectionTest = func(_ context.Context, settings llmSettingsPayload, opts agentSettingsConnectionTestOptions) (agentSettingsTestResult, error) { 791 + if opts.InspectPrompt || opts.InspectRequest { 792 + t.Fatalf("unexpected inspect opts: %+v", opts) 793 + } 794 + if settings.Provider != "openai" { 795 + t.Fatalf("provider = %q, want openai", settings.Provider) 796 + } 797 + if settings.Endpoint != "https://api.openai.com" { 798 + t.Fatalf("endpoint = %q, want https://api.openai.com", settings.Endpoint) 799 + } 800 + if settings.APIKey != "sk-runtime" { 801 + t.Fatalf("api_key = %q, want sk-runtime", settings.APIKey) 802 + } 803 + if settings.Model != "gpt-5" { 804 + t.Fatalf("model = %q, want gpt-5", settings.Model) 805 + } 806 + return agentSettingsTestResult{ 807 + Provider: "openai", 808 + Model: "gpt-5", 809 + Benchmarks: []agentSettingsBenchmarkResult{ 810 + {ID: "text_reply", OK: true, DurationMS: 1, Detail: "OK"}, 811 + }, 812 + }, nil 813 + } 814 + t.Cleanup(func() { 815 + runAgentSettingsConnectionTest = prev 816 + if hadLLM { 817 + viper.Set("llm", prevLLM) 818 + } else { 819 + viper.Set("llm", nil) 820 + } 821 + }) 822 + 823 + req := httptest.NewRequest(http.MethodPost, "/api/settings/agent/test", bytes.NewBufferString( 824 + `{"llm":{"model":"gpt-5"}}`, 825 + )) 826 + rec := httptest.NewRecorder() 827 + 828 + (&server{}).handleAgentSettingsTest(rec, req) 829 + 830 + if rec.Code != http.StatusOK { 831 + t.Fatalf("status = %d, want %d (%s)", rec.Code, http.StatusOK, rec.Body.String()) 832 + } 833 + } 834 + 835 + func TestHandleAgentSettingsTestFallsBackToRuntimeCloudflareToken(t *testing.T) { 836 + prev := runAgentSettingsConnectionTest 837 + prevLLM, hadLLM := viper.Get("llm"), viper.IsSet("llm") 838 + viper.Set("llm", map[string]any{ 839 + "provider": "cloudflare", 840 + "model": "@cf/meta/llama-3.3-70b-instruct-fp8-fast", 841 + "cloudflare": map[string]any{ 842 + "account_id": "acc-runtime", 843 + "api_token": "cf-runtime-token", 844 + }, 845 + }) 846 + runAgentSettingsConnectionTest = func(_ context.Context, settings llmSettingsPayload, opts agentSettingsConnectionTestOptions) (agentSettingsTestResult, error) { 847 + if opts.InspectPrompt || opts.InspectRequest { 848 + t.Fatalf("unexpected inspect opts: %+v", opts) 849 + } 850 + if settings.Provider != "cloudflare" { 851 + t.Fatalf("provider = %q, want cloudflare", settings.Provider) 852 + } 853 + if settings.CloudflareAccountID != "acc-runtime" { 854 + t.Fatalf("cloudflare_account_id = %q, want acc-runtime", settings.CloudflareAccountID) 855 + } 856 + if settings.CloudflareAPIToken != "cf-runtime-token" { 857 + t.Fatalf("cloudflare_api_token = %q, want cf-runtime-token", settings.CloudflareAPIToken) 858 + } 859 + if settings.Model != "@cf/meta/llama-3.3-70b-instruct-fp8-fast" { 860 + t.Fatalf("model = %q, want cloudflare runtime model", settings.Model) 861 + } 862 + return agentSettingsTestResult{ 863 + Provider: "cloudflare", 864 + Model: settings.Model, 865 + Benchmarks: []agentSettingsBenchmarkResult{ 866 + {ID: "text_reply", OK: true, DurationMS: 1, Detail: "OK"}, 867 + }, 868 + }, nil 869 + } 870 + t.Cleanup(func() { 871 + runAgentSettingsConnectionTest = prev 872 + if hadLLM { 873 + viper.Set("llm", prevLLM) 874 + } else { 875 + viper.Set("llm", nil) 876 + } 877 + }) 878 + 879 + req := httptest.NewRequest(http.MethodPost, "/api/settings/agent/test", bytes.NewBufferString( 880 + `{"llm":{}}`, 881 + )) 882 + rec := httptest.NewRecorder() 883 + 884 + (&server{}).handleAgentSettingsTest(rec, req) 885 + 886 + if rec.Code != http.StatusOK { 887 + t.Fatalf("status = %d, want %d (%s)", rec.Code, http.StatusOK, rec.Body.String()) 888 + } 889 + } 890 + 891 + func TestHandleAgentSettingsTestPrefersEnvManagedRuntimeConfig(t *testing.T) { 892 + prev := runAgentSettingsConnectionTest 893 + prevLLM, hadLLM := viper.Get("llm"), viper.IsSet("llm") 894 + viper.Set("llm", map[string]any{ 895 + "provider": "openai", 896 + "endpoint": "https://api.openai.com", 897 + "model": "gpt-5.2", 898 + "api_key": "sk-viper", 899 + }) 900 + t.Setenv("MISTER_MORPH_LLM_PROVIDER", "cloudflare") 901 + t.Setenv("MISTER_MORPH_LLM_MODEL", "@cf/meta/llama-3.3-70b-instruct-fp8-fast") 902 + t.Setenv("MISTER_MORPH_LLM_CLOUDFLARE_ACCOUNT_ID", "acc-env") 903 + t.Setenv("MISTER_MORPH_LLM_CLOUDFLARE_API_TOKEN", "cf-env-token") 904 + runAgentSettingsConnectionTest = func(_ context.Context, settings llmSettingsPayload, opts agentSettingsConnectionTestOptions) (agentSettingsTestResult, error) { 905 + if opts.InspectPrompt || opts.InspectRequest { 906 + t.Fatalf("unexpected inspect opts: %+v", opts) 907 + } 908 + if settings.Provider != "cloudflare" { 909 + t.Fatalf("provider = %q, want cloudflare", settings.Provider) 910 + } 911 + if settings.Model != "@cf/meta/llama-3.3-70b-instruct-fp8-fast" { 912 + t.Fatalf("model = %q, want cloudflare env model", settings.Model) 913 + } 914 + if settings.CloudflareAccountID != "acc-env" { 915 + t.Fatalf("cloudflare_account_id = %q, want acc-env", settings.CloudflareAccountID) 916 + } 917 + if settings.CloudflareAPIToken != "cf-env-token" { 918 + t.Fatalf("cloudflare_api_token = %q, want cf-env-token", settings.CloudflareAPIToken) 919 + } 920 + return agentSettingsTestResult{ 921 + Provider: "cloudflare", 922 + Model: settings.Model, 923 + Benchmarks: []agentSettingsBenchmarkResult{ 924 + {ID: "text_reply", OK: true, DurationMS: 1, Detail: "OK"}, 925 + }, 926 + }, nil 927 + } 928 + t.Cleanup(func() { 929 + runAgentSettingsConnectionTest = prev 930 + if hadLLM { 931 + viper.Set("llm", prevLLM) 932 + } else { 933 + viper.Set("llm", nil) 934 + } 935 + }) 936 + 937 + req := httptest.NewRequest(http.MethodPost, "/api/settings/agent/test", bytes.NewBufferString( 938 + `{"llm":{}}`, 939 + )) 940 + rec := httptest.NewRecorder() 941 + 942 + (&server{}).handleAgentSettingsTest(rec, req) 943 + 944 + if rec.Code != http.StatusOK { 945 + t.Fatalf("status = %d, want %d (%s)", rec.Code, http.StatusOK, rec.Body.String()) 946 + } 947 + } 948 + 949 + func TestAgentSettingsBenchmarksIncludeRawResponseOnChatError(t *testing.T) { 950 + tests := []struct { 951 + name string 952 + run func(context.Context, llm.Client, string) agentSettingsBenchmarkResult 953 + }{ 954 + {name: "text", run: runAgentSettingsTextBenchmark}, 955 + {name: "json", run: runAgentSettingsJSONBenchmark}, 956 + {name: "tool", run: runAgentSettingsToolCallingBenchmark}, 957 + } 958 + 959 + for _, tc := range tests { 960 + t.Run(tc.name, func(t *testing.T) { 961 + got := tc.run(context.Background(), benchmarkClientStub{ 962 + err: errors.New(`openai API request failed with status 400: {"error":{"message":"bad model"}}`), 963 + }, "gpt-5") 964 + if got.OK { 965 + t.Fatalf("got.OK = true, want false") 966 + } 967 + if got.RawResponse != `{"error":{"message":"bad model"}}` { 968 + t.Fatalf("got.RawResponse = %q, want upstream response body", got.RawResponse) 969 + } 970 + }) 971 + } 972 + } 973 + 974 + func TestBenchmarkRawResponseFromErrorFallsBackToErrorText(t *testing.T) { 975 + err := errors.New("dial tcp 127.0.0.1:443: connect: connection refused") 976 + if got := benchmarkRawResponseFromError(err); got != err.Error() { 977 + t.Fatalf("benchmarkRawResponseFromError() = %q, want %q", got, err.Error()) 978 + } 979 + } 980 + 981 + func TestHandleAgentSettingsTestPassesInspectFlags(t *testing.T) { 982 + prev := runAgentSettingsConnectionTest 983 + runAgentSettingsConnectionTest = func(_ context.Context, _ llmSettingsPayload, opts agentSettingsConnectionTestOptions) (agentSettingsTestResult, error) { 984 + if !opts.InspectPrompt || !opts.InspectRequest { 985 + t.Fatalf("inspect opts = %+v, want both enabled", opts) 986 + } 987 + return agentSettingsTestResult{ 988 + Provider: "openai", 989 + Model: "gpt-5", 990 + Benchmarks: []agentSettingsBenchmarkResult{ 991 + {ID: "text_reply", OK: true, DurationMS: 1, Detail: "OK"}, 992 + }, 993 + }, nil 994 + } 995 + t.Cleanup(func() { 996 + runAgentSettingsConnectionTest = prev 997 + }) 998 + 999 + req := httptest.NewRequest(http.MethodPost, "/api/settings/agent/test", bytes.NewBufferString(`{"llm":{"provider":"openai","model":"gpt-5"}}`)) 1000 + rec := httptest.NewRecorder() 1001 + 1002 + (&server{cfg: serveConfig{inspectPrompt: true, inspectRequest: true}}).handleAgentSettingsTest(rec, req) 1003 + 1004 + if rec.Code != http.StatusOK { 1005 + t.Fatalf("status = %d, want %d (%s)", rec.Code, http.StatusOK, rec.Body.String()) 1006 + } 378 1007 } 379 1008 380 1009 func TestNormalizeAgentSettingsProvider(t *testing.T) { ··· 396 1025 }) 397 1026 } 398 1027 } 1028 + 1029 + func TestCurrentAgentSettingsLLMEnvManagedPrefersProviderSpecificEnv(t *testing.T) { 1030 + t.Setenv("MISTER_MORPH_LLM_API_KEY", "sk-generic") 1031 + t.Setenv("MISTER_MORPH_LLM_CLOUDFLARE_API_TOKEN", "cf-token") 1032 + t.Setenv("MISTER_MORPH_LLM_PROVIDER", "cloudflare") 1033 + t.Setenv("MISTER_MORPH_LLM_MODEL", "gpt-5.2") 1034 + t.Setenv("MISTER_MORPH_LLM_AZURE_DEPLOYMENT", "azure-deploy") 1035 + 1036 + cloudflareFields := currentAgentSettingsLLMEnvManaged("cloudflare") 1037 + if got := cloudflareFields["cloudflare_api_token"].EnvName; got != "MISTER_MORPH_LLM_CLOUDFLARE_API_TOKEN" { 1038 + t.Fatalf("cloudflare api token env = %q, want MISTER_MORPH_LLM_CLOUDFLARE_API_TOKEN", got) 1039 + } 1040 + 1041 + azureFields := currentAgentSettingsLLMEnvManaged("azure") 1042 + if got := azureFields["model"].EnvName; got != "MISTER_MORPH_LLM_AZURE_DEPLOYMENT" { 1043 + t.Fatalf("azure model env = %q, want MISTER_MORPH_LLM_AZURE_DEPLOYMENT", got) 1044 + } 1045 + if got := cloudflareFields["provider"].Value; got != "cloudflare" { 1046 + t.Fatalf("provider value = %q, want cloudflare", got) 1047 + } 1048 + if got := cloudflareFields["cloudflare_api_token"].Value; got != "" { 1049 + t.Fatalf("api token value = %q, want empty for sensitive env-managed field", got) 1050 + } 1051 + 1052 + emptyProviderFields := currentAgentSettingsLLMEnvManaged("") 1053 + if got := emptyProviderFields["cloudflare_api_token"].EnvName; got != "MISTER_MORPH_LLM_CLOUDFLARE_API_TOKEN" { 1054 + t.Fatalf("empty provider cloudflare api token env = %q, want MISTER_MORPH_LLM_CLOUDFLARE_API_TOKEN", got) 1055 + } 1056 + if got := emptyProviderFields["api_key"].EnvName; got != "MISTER_MORPH_LLM_API_KEY" { 1057 + t.Fatalf("empty provider api key env = %q, want MISTER_MORPH_LLM_API_KEY", got) 1058 + } 1059 + } 1060 + 1061 + type benchmarkClientStub struct { 1062 + result llm.Result 1063 + err error 1064 + } 1065 + 1066 + func (c benchmarkClientStub) Chat(context.Context, llm.Request) (llm.Result, error) { 1067 + return c.result, c.err 1068 + }
+1 -1
cmd/mistermorph/consolecmd/console_settings_test.go
··· 82 82 req := httptest.NewRequest(http.MethodPut, "/api/settings/console", body) 83 83 rec := httptest.NewRecorder() 84 84 85 - (&server{managed: newManagedRuntimeSupervisor(nil, nil)}).handleConsoleSettings(rec, req) 85 + (&server{managed: newManagedRuntimeSupervisor(nil, serveConfig{})}).handleConsoleSettings(rec, req) 86 86 87 87 if rec.Code != http.StatusOK { 88 88 t.Fatalf("status = %d, want %d (%s)", rec.Code, http.StatusOK, rec.Body.String())
+76
cmd/mistermorph/consolecmd/inspect.go
··· 1 + package consolecmd 2 + 3 + import ( 4 + "errors" 5 + "strings" 6 + 7 + "github.com/quailyquaily/mistermorph/internal/llminspect" 8 + "github.com/quailyquaily/mistermorph/internal/llmutil" 9 + "github.com/quailyquaily/mistermorph/llm" 10 + ) 11 + 12 + type consoleInspectors struct { 13 + prompt *llminspect.PromptInspector 14 + request *llminspect.RequestInspector 15 + } 16 + 17 + func newConsoleInspectors(inspectPrompt bool, inspectRequest bool, mode string, task string, timestampFormat string) (*consoleInspectors, error) { 18 + out := &consoleInspectors{} 19 + if inspectRequest { 20 + requestInspector, err := llminspect.NewRequestInspector(llminspect.Options{ 21 + Mode: strings.TrimSpace(mode), 22 + Task: strings.TrimSpace(task), 23 + TimestampFormat: strings.TrimSpace(timestampFormat), 24 + }) 25 + if err != nil { 26 + return nil, err 27 + } 28 + out.request = requestInspector 29 + } 30 + if inspectPrompt { 31 + promptInspector, err := llminspect.NewPromptInspector(llminspect.Options{ 32 + Mode: strings.TrimSpace(mode), 33 + Task: strings.TrimSpace(task), 34 + TimestampFormat: strings.TrimSpace(timestampFormat), 35 + }) 36 + if err != nil { 37 + _ = out.Close() 38 + return nil, err 39 + } 40 + out.prompt = promptInspector 41 + } 42 + return out, nil 43 + } 44 + 45 + func (i *consoleInspectors) Wrap(client llm.Client, route llmutil.ResolvedRoute) llm.Client { 46 + if i == nil { 47 + return client 48 + } 49 + return llminspect.WrapClient(client, llminspect.ClientOptions{ 50 + PromptInspector: i.prompt, 51 + RequestInspector: i.request, 52 + APIBase: route.ClientConfig.Endpoint, 53 + Model: strings.TrimSpace(route.ClientConfig.Model), 54 + }) 55 + } 56 + 57 + func (i *consoleInspectors) Close() error { 58 + if i == nil { 59 + return nil 60 + } 61 + return errors.Join(closeConsolePromptInspector(i.prompt), closeConsoleRequestInspector(i.request)) 62 + } 63 + 64 + func closeConsolePromptInspector(inspector *llminspect.PromptInspector) error { 65 + if inspector == nil { 66 + return nil 67 + } 68 + return inspector.Close() 69 + } 70 + 71 + func closeConsoleRequestInspector(inspector *llminspect.RequestInspector) error { 72 + if inspector == nil { 73 + return nil 74 + } 75 + return inspector.Close() 76 + }
+19 -3
cmd/mistermorph/consolecmd/local_runtime.go
··· 76 76 77 77 type consoleLocalRuntime struct { 78 78 logger *slog.Logger 79 + inspectors *consoleInspectors 79 80 store *daemonruntime.ConsoleFileStore 80 81 bus *busruntime.Inproc 81 82 runner *runtimecore.ConversationRunner[string, consoleLocalTaskJob] ··· 99 100 seq atomic.Uint64 100 101 } 101 102 102 - func newConsoleLocalRuntime() (*consoleLocalRuntime, error) { 103 + func newConsoleLocalRuntime(cfg serveConfig) (*consoleLocalRuntime, error) { 103 104 logger, err := logutil.LoggerFromViper() 104 105 if err != nil { 105 106 return nil, err 106 107 } 107 108 slog.SetDefault(logger) 108 109 logOpts := logutil.LogOptionsFromViper() 110 + inspectors, err := newConsoleInspectors(cfg.inspectPrompt, cfg.inspectRequest, "console", "console", "20060102_150405") 111 + if err != nil { 112 + return nil, err 113 + } 109 114 out := &consoleLocalRuntime{ 110 - logger: logger, 115 + logger: logger, 116 + inspectors: inspectors, 111 117 } 112 118 var baseRegistry *tools.Registry 113 119 var sharedGuard *guard.Guard ··· 128 134 if err != nil { 129 135 return nil, err 130 136 } 131 - return llmstats.WrapRuntimeClient(base, route.ClientConfig.Provider, route.ClientConfig.Endpoint, route.ClientConfig.Model, logger), nil 137 + client := llmstats.WrapRuntimeClient(base, route.ClientConfig.Provider, route.ClientConfig.Endpoint, route.ClientConfig.Model, logger) 138 + return inspectors.Wrap(client, route), nil 132 139 }, 133 140 RuntimeToolsConfig: toolsutil.LoadRuntimeToolsRegisterConfigFromViper(), 134 141 Registry: func() *tools.Registry { ··· 154 161 } 155 162 execRuntime, err := taskruntime.Bootstrap(commonDeps, taskRuntimeOpts) 156 163 if err != nil { 164 + _ = inspectors.Close() 157 165 return nil, err 158 166 } 159 167 if warning := consoleLLMCredentialsWarning(execRuntime.MainRoute); warning != "" { ··· 176 184 Logger: logger, 177 185 }) 178 186 if err != nil { 187 + _ = inspectors.Close() 179 188 cancelWorkers() 180 189 return nil, err 181 190 } ··· 185 194 186 195 authToken, err := consoleLocalRuntimeAuthToken() 187 196 if err != nil { 197 + _ = inspectors.Close() 188 198 cancelWorkers() 189 199 memRuntime.Cleanup() 190 200 return nil, err ··· 195 205 Persist: consoleTaskPersistenceEnabled(), 196 206 }) 197 207 if err != nil { 208 + _ = inspectors.Close() 198 209 cancelWorkers() 199 210 memRuntime.Cleanup() 200 211 return nil, err ··· 209 220 Component: "console", 210 221 }) 211 222 if err != nil { 223 + _ = inspectors.Close() 212 224 cancelWorkers() 213 225 memRuntime.Cleanup() 214 226 return nil, err ··· 241 253 }, 242 254 ) 243 255 if err := inprocBus.Subscribe(busruntime.TopicChatMessage, out.handleConsoleBusMessage); err != nil { 256 + _ = inspectors.Close() 244 257 inprocBus.Close() 245 258 cancelWorkers() 246 259 memRuntime.Cleanup() ··· 333 346 } 334 347 if r.memRuntime.Cleanup != nil { 335 348 r.memRuntime.Cleanup() 349 + } 350 + if r.inspectors != nil { 351 + _ = r.inspectors.Close() 336 352 } 337 353 bundle := r.currentBundle() 338 354 if bundle != nil && bundle.mcpHost != nil {
+21 -13
cmd/mistermorph/consolecmd/managed_runtime.go
··· 31 31 ) 32 32 33 33 type managedRuntimeSupervisor struct { 34 - mu sync.Mutex 35 - kinds []string 36 - localRuntime *consoleLocalRuntime 37 - parentCtx context.Context 38 - cancel context.CancelFunc 39 - onFatal func(error) 40 - generation uint64 34 + mu sync.Mutex 35 + kinds []string 36 + inspectPrompt bool 37 + inspectRequest bool 38 + localRuntime *consoleLocalRuntime 39 + parentCtx context.Context 40 + cancel context.CancelFunc 41 + onFatal func(error) 42 + generation uint64 41 43 } 42 44 43 45 type managedRuntimeConfigError struct { ··· 80 82 return out, nil 81 83 } 82 84 83 - func newManagedRuntimeSupervisor(localRuntime *consoleLocalRuntime, kinds []string) *managedRuntimeSupervisor { 85 + func newManagedRuntimeSupervisor(localRuntime *consoleLocalRuntime, cfg serveConfig) *managedRuntimeSupervisor { 84 86 return &managedRuntimeSupervisor{ 85 - kinds: append([]string(nil), kinds...), 86 - localRuntime: localRuntime, 87 + kinds: append([]string(nil), cfg.managedKinds...), 88 + inspectPrompt: cfg.inspectPrompt, 89 + inspectRequest: cfg.inspectRequest, 90 + localRuntime: localRuntime, 87 91 } 88 92 } 89 93 ··· 202 206 deps, cleanup := buildManagedRuntimeDeps(s.logger()) 203 207 cfg := channelopts.TelegramConfigFromViper() 204 208 runOpts, err := channelopts.BuildTelegramRunOptions(cfg, channelopts.TelegramInput{ 205 - BotToken: botToken, 209 + BotToken: botToken, 210 + InspectPrompt: s.inspectPrompt, 211 + InspectRequest: s.inspectRequest, 206 212 }) 207 213 if err != nil { 208 214 cleanup() ··· 231 237 deps, cleanup := buildManagedRuntimeDeps(s.logger()) 232 238 cfg := channelopts.SlackConfigFromViper() 233 239 runOpts := channelopts.BuildSlackRunOptions(cfg, channelopts.SlackInput{ 234 - BotToken: botToken, 235 - AppToken: appToken, 240 + BotToken: botToken, 241 + AppToken: appToken, 242 + InspectPrompt: s.inspectPrompt, 243 + InspectRequest: s.inspectRequest, 236 244 }) 237 245 runOpts.Server.Listen = "" 238 246 runOpts.Server.AuthToken = ""
+1 -1
cmd/mistermorph/consolecmd/managed_runtime_test.go
··· 11 11 viper.Reset() 12 12 t.Cleanup(viper.Reset) 13 13 14 - supervisor := newManagedRuntimeSupervisor(nil, []string{"telegram"}) 14 + supervisor := newManagedRuntimeSupervisor(nil, serveConfig{managedKinds: []string{"telegram"}}) 15 15 if err := supervisor.Start(context.Background(), nil); err != nil { 16 16 t.Fatalf("Start() error = %v, want nil", err) 17 17 }
+16 -2
cmd/mistermorph/consolecmd/serve.go
··· 34 34 basePath string 35 35 staticDir string 36 36 staticFS fs.FS 37 + inspectPrompt bool 38 + inspectRequest bool 37 39 sessionTTL time.Duration 38 40 passwordOptional bool 39 41 password string ··· 123 125 cmd.Flags().String("console-static-dir", "", "Mistermorph Console SPA static directory.") 124 126 cmd.Flags().Duration("console-session-ttl", 12*time.Hour, "Session TTL for console bearer token.") 125 127 cmd.Flags().Bool("allow-empty-password", false, "Allow console to run without console.password/console.password_hash. If a password is configured, login is still required.") 128 + cmd.Flags().Bool("inspect-prompt", false, "Dump prompts (messages) to ./dump/prompt_console_YYYYMMDD_HHmmss.md.") 129 + cmd.Flags().Bool("inspect-request", false, "Dump LLM request/response payloads to ./dump/request_console_YYYYMMDD_HHmmss.md.") 126 130 127 131 return cmd 128 132 } ··· 151 155 if err != nil { 152 156 return serveConfig{}, err 153 157 } 158 + inspectPrompt, err := cmd.Flags().GetBool("inspect-prompt") 159 + if err != nil { 160 + return serveConfig{}, err 161 + } 162 + inspectRequest, err := cmd.Flags().GetBool("inspect-request") 163 + if err != nil { 164 + return serveConfig{}, err 165 + } 154 166 155 167 stateDir := pathutil.ResolveStateDir(viper.GetString("file_state_dir")) 156 168 var rawEndpoints []runtimeEndpointConfigRaw ··· 167 179 basePath: basePath, 168 180 staticDir: staticDir, 169 181 staticFS: consoleStaticFS, 182 + inspectPrompt: inspectPrompt, 183 + inspectRequest: inspectRequest, 170 184 sessionTTL: sessionTTL, 171 185 passwordOptional: passwordOptional, 172 186 password: viper.GetString("console.password"), ··· 303 317 sessionStorePath = filepath.Join(cfg.stateDir, "console", "sessions.json") 304 318 } 305 319 306 - localRuntime, err := newConsoleLocalRuntime() 320 + localRuntime, err := newConsoleLocalRuntime(cfg) 307 321 if err != nil { 308 322 return nil, err 309 323 } 310 - managed := newManagedRuntimeSupervisor(localRuntime, cfg.managedKinds) 324 + managed := newManagedRuntimeSupervisor(localRuntime, cfg) 311 325 312 326 endpoints := make([]runtimeEndpoint, 0, len(cfg.endpoints)+1) 313 327 endpointByRef := make(map[string]runtimeEndpoint, len(cfg.endpoints)+1)
+21
cmd/mistermorph/consolecmd/serve_test.go
··· 239 239 } 240 240 } 241 241 242 + func TestLoadServeConfigCarriesInspectFlags(t *testing.T) { 243 + viper.Reset() 244 + t.Cleanup(viper.Reset) 245 + 246 + cmd := newServeCmd() 247 + if err := cmd.Flags().Set("inspect-prompt", "true"); err != nil { 248 + t.Fatalf("set inspect-prompt: %v", err) 249 + } 250 + if err := cmd.Flags().Set("inspect-request", "true"); err != nil { 251 + t.Fatalf("set inspect-request: %v", err) 252 + } 253 + 254 + cfg, err := loadServeConfig(cmd) 255 + if err != nil { 256 + t.Fatalf("loadServeConfig() error = %v", err) 257 + } 258 + if !cfg.inspectPrompt || !cfg.inspectRequest { 259 + t.Fatalf("inspect flags not loaded: %+v", cfg) 260 + } 261 + } 262 + 242 263 func TestLoadServeConfigSkipsIncompleteEndpoints(t *testing.T) { 243 264 viper.Reset() 244 265 t.Cleanup(viper.Reset)
+2 -2
integration/defaults.go
··· 12 12 } 13 13 // Shared agent defaults. 14 14 v.SetDefault("llm.provider", "openai") 15 - v.SetDefault("llm.endpoint", "https://api.openai.com") 16 - v.SetDefault("llm.model", "gpt-5.2") 15 + v.SetDefault("llm.endpoint", "") 16 + v.SetDefault("llm.model", "") 17 17 v.SetDefault("llm.api_key", "") 18 18 v.SetDefault("llm.request_timeout", 90*time.Second) 19 19 v.SetDefault("llm.tools_emulation_mode", "off")
+2 -2
internal/configdefaults/defaults.go
··· 13 13 } 14 14 15 15 v.SetDefault("llm.provider", "openai") 16 - v.SetDefault("llm.endpoint", "https://api.openai.com") 17 - v.SetDefault("llm.model", "gpt-5.2") 16 + v.SetDefault("llm.endpoint", "") 17 + v.SetDefault("llm.model", "") 18 18 v.SetDefault("llm.api_key", "") 19 19 v.SetDefault("llm.request_timeout", 90*time.Second) 20 20 v.SetDefault("llm.tools_emulation_mode", "off")
+5 -1
web/console/src/components/RawJsonDialog.js
··· 8 8 type: Boolean, 9 9 default: false, 10 10 }, 11 + title: { 12 + type: String, 13 + default: "", 14 + }, 11 15 json: { 12 16 type: String, 13 17 default: "", ··· 61 65 > 62 66 <template #header> 63 67 <header class="raw-json-head"> 64 - <h3 class="raw-json-title">RAW JSON</h3> 68 + <h3 class="raw-json-title">{{ title || 'RAW JSON' }}</h3> 65 69 </header> 66 70 </template> 67 71
+96 -24
web/console/src/components/SetupConnectionTestDialog.css
··· 1 1 .connection-test-dialog { 2 - --connection-test-accent: var(--setup-accent, var(--q-c-blue)); 2 + --connection-test-accent: var(--setup-accent, var(--accent-1)); 3 3 width: min(560px, 100%); 4 4 display: grid; 5 5 gap: 12px; ··· 51 51 52 52 .connection-test-benchmark { 53 53 box-sizing: border-box; 54 - display: flex; 55 - height: 68px; 56 - justify-content: space-between; 54 + display: grid; 55 + min-height: 68px; 57 56 gap: 12px; 58 - padding: 12px; 59 57 border: 1px solid color-mix(in srgb, var(--line-soft) 86%, transparent); 60 58 background: color-mix(in srgb, var(--surface-elevated, #fff) 92%, transparent); 61 59 } ··· 64 62 opacity: 0.8; 65 63 } 66 64 65 + .connection-test-benchmark-summary { 66 + display: flex; 67 + justify-content: space-between; 68 + gap: 12px; 69 + padding: 12px 12px 0; 70 + } 71 + 67 72 .connection-test-benchmark-main { 68 - align-content: space-between; 69 - height: 100%; 73 + align-content: start; 74 + height: auto; 70 75 min-width: 0; 71 76 display: grid; 72 - grid-template-rows: auto auto; 73 - gap: 0; 77 + grid-template-rows: auto minmax(18px, auto); 78 + gap: 4px; 74 79 } 75 80 76 81 .connection-test-benchmark-title { ··· 80 85 } 81 86 82 87 .connection-test-benchmark-detail, 83 - .connection-test-benchmark-error { 88 + .connection-test-benchmark-error, 89 + .connection-test-benchmark-time { 84 90 margin: 0; 91 + font-family: var(--font-mono); 85 92 font-size: 12px; 86 93 line-height: 1.5; 87 94 word-break: break-word; ··· 89 96 90 97 .connection-test-benchmark-detail { 91 98 color: var(--text-2); 92 - font-family: var(--font-mono); 93 99 } 94 100 95 101 .connection-test-benchmark-error { 96 - color: var(--q-c-danger); 102 + color: var(--danger); 103 + } 104 + 105 + .connection-test-benchmark-toggle { 106 + width: 100%; 107 + min-height: 18px; 108 + display: inline-flex; 109 + align-items: center; 110 + justify-content: flex-start; 111 + gap: 6px; 112 + text-align: left; 113 + color: inherit; 114 + cursor: pointer; 115 + } 116 + 117 + .connection-test-benchmark-toggle.connection-test-benchmark-error { 118 + color: var(--danger); 119 + } 120 + 121 + .connection-test-benchmark-toggle.connection-test-benchmark-detail { 122 + color: var(--text-2); 123 + } 124 + 125 + .connection-test-benchmark-toggle:focus-visible { 126 + outline: 1px solid color-mix(in srgb, var(--accent-1) 50%, transparent); 127 + outline-offset: 2px; 128 + } 129 + 130 + .connection-test-benchmark-toggle-text { 131 + min-width: 0; 132 + flex: 1 1 auto; 133 + min-height: 18px; 134 + display: inline-flex; 135 + align-items: center; 136 + } 137 + 138 + .connection-test-benchmark-toggle-icon { 139 + width: 10px; 140 + height: 10px; 141 + flex: 0 0 auto; 142 + margin-top: 0; 143 + color: currentColor; 144 + transition: transform 120ms ease; 145 + } 146 + 147 + .connection-test-benchmark-toggle-icon.is-open { 148 + transform: rotate(90deg); 97 149 } 98 150 99 151 .connection-test-benchmark-side { 100 152 flex: 0 0 auto; 101 153 min-width: 72px; 102 154 display: grid; 103 - grid-template-rows: auto auto; 155 + grid-template-rows: auto 18px; 104 156 justify-items: end; 105 - align-content: space-between; 106 - height: 100%; 107 - gap: 0; 157 + align-content: start; 158 + height: auto; 159 + gap: 4px; 108 160 } 109 161 110 162 .connection-test-benchmark-status-row { ··· 126 178 127 179 .connection-test-benchmark-status { 128 180 font-size: 12px; 181 + color: var(--text-0); 129 182 } 130 183 131 - .connection-test-benchmark.is-ok .connection-test-benchmark-status { 132 - color: var(--q-c-success); 184 + .connection-test-benchmark-status.is-ok { 185 + color: var(--ok); 133 186 } 134 187 135 - .connection-test-benchmark.is-failed .connection-test-benchmark-status { 136 - color: var(--q-c-danger); 188 + .connection-test-benchmark-status.is-failed { 189 + color: var(--danger); 137 190 } 138 191 139 - .connection-test-benchmark.is-loading .connection-test-benchmark-status { 192 + .connection-test-benchmark-status.is-loading { 140 193 color: var(--connection-test-accent); 141 194 } 142 195 143 196 .connection-test-benchmark-time { 144 - align-self: end; 145 - font-family: var(--font-mono); 146 - font-size: 11px; 197 + display: inline-flex; 198 + min-height: 18px; 199 + align-items: center; 147 200 color: var(--text-2); 148 201 } 149 202 203 + .connection-test-benchmark-raw-output { 204 + margin: 0; 205 + padding: 10px 12px; 206 + border-top: 1px solid color-mix(in srgb, var(--line-soft) 86%, transparent); 207 + background: color-mix(in srgb, var(--bg-2, #fff) 96%, var(--accent-1) 4%); 208 + color: var(--text-1); 209 + font-family: var(--font-mono); 210 + font-size: 12px; 211 + line-height: 1.55; 212 + max-height: 120px; 213 + white-space: pre-wrap; 214 + word-break: break-word; 215 + overflow-x: auto; 216 + overflow-y: auto; 217 + } 218 + 150 219 .connection-test-actions { 151 220 display: flex; 152 221 justify-content: space-between; ··· 175 244 .connection-test-benchmark { 176 245 height: auto; 177 246 min-height: 68px; 247 + } 248 + 249 + .connection-test-benchmark-summary { 178 250 flex-direction: column; 179 251 } 180 252
+108 -18
web/console/src/components/SetupConnectionTestDialog.js
··· 1 - import { computed } from "vue"; 1 + import { computed, ref, watch } from "vue"; 2 2 import { translate } from "../core/context"; 3 3 import "./SetupConnectionTestDialog.css"; 4 4 ··· 32 32 emits: ["update:modelValue", "retry"], 33 33 setup(props, { emit }) { 34 34 const t = translate; 35 + const expandedRawBenchmarkId = ref(""); 35 36 36 37 const hasBenchmarks = computed(() => Array.isArray(props.benchmarks) && props.benchmarks.length > 0); 37 38 const visibleBenchmarks = computed(() => { ··· 43 44 duration_ms: 0, 44 45 detail: "", 45 46 error: "", 47 + raw_response: "", 46 48 })); 47 49 } 48 50 return (Array.isArray(props.benchmarks) ? props.benchmarks : []).map((item) => ({ ··· 53 55 const showBenchmarks = computed(() => props.loading || hasBenchmarks.value); 54 56 55 57 function close() { 58 + expandedRawBenchmarkId.value = ""; 56 59 emit("update:modelValue", false); 57 60 } 58 61 59 62 function retry() { 63 + expandedRawBenchmarkId.value = ""; 60 64 emit("retry"); 61 65 } 62 66 67 + function isRawExpanded(item) { 68 + return String(item?.id || "").trim() !== "" && expandedRawBenchmarkId.value === String(item.id).trim(); 69 + } 70 + 71 + function toggleRawResponse(item) { 72 + const id = String(item?.id || "").trim(); 73 + if (!id) { 74 + return; 75 + } 76 + expandedRawBenchmarkId.value = expandedRawBenchmarkId.value === id ? "" : id; 77 + } 78 + 79 + function rawResponseText(item) { 80 + const raw = String(item?.raw_response || "").trim(); 81 + return raw === "" ? t("setup_llm_test_raw_empty") : raw; 82 + } 83 + 84 + watch( 85 + () => props.modelValue, 86 + (open) => { 87 + if (!open) { 88 + expandedRawBenchmarkId.value = ""; 89 + } 90 + }, 91 + ); 92 + 93 + watch( 94 + () => props.loading, 95 + (loading) => { 96 + if (loading) { 97 + expandedRawBenchmarkId.value = ""; 98 + } 99 + }, 100 + ); 101 + 102 + watch( 103 + () => props.benchmarks, 104 + (benchmarks) => { 105 + if (!Array.isArray(benchmarks) || benchmarks.every((item) => String(item?.id || "").trim() !== expandedRawBenchmarkId.value)) { 106 + expandedRawBenchmarkId.value = ""; 107 + } 108 + }, 109 + { deep: true }, 110 + ); 111 + 112 + function benchmarkStatusClass(item) { 113 + if (item?.running) { 114 + return "is-loading"; 115 + } 116 + return item?.ok ? "is-ok" : "is-failed"; 117 + } 118 + 119 + function benchmarkDetailToggleClass(item) { 120 + return item?.ok ? "connection-test-benchmark-detail" : "connection-test-benchmark-error"; 121 + } 122 + 123 + function benchmarkSummaryText(item) { 124 + const id = String(item?.id || "").trim(); 125 + if (!id) { 126 + return item?.ok ? String(item?.detail || "").trim() : String(item?.error || "").trim(); 127 + } 128 + const key = `setup_llm_test_benchmark_${id}_${item?.ok ? "ok" : "failed"}`; 129 + const translated = t(key); 130 + if (translated && translated !== key) { 131 + return translated; 132 + } 133 + return item?.ok ? String(item?.detail || "").trim() : String(item?.error || "").trim(); 134 + } 135 + 63 136 function formatBenchmarkSeconds(durationMS) { 64 137 const ms = Number(durationMS || 0); 65 138 if (!Number.isFinite(ms) || ms <= 0) { ··· 79 152 showBenchmarks, 80 153 close, 81 154 retry, 155 + isRawExpanded, 156 + toggleRawResponse, 157 + rawResponseText, 158 + benchmarkStatusClass, 159 + benchmarkDetailToggleClass, 160 + benchmarkSummaryText, 82 161 formatBenchmarkSeconds, 83 162 }; 84 163 }, ··· 105 184 /> 106 185 107 186 <div v-if="showBenchmarks" class="connection-test-result"> 108 - <p class="connection-test-result-label"> 109 - {{ t("setup_llm_test_success", { provider: provider || t('ttl_unknown'), model: model || t('ttl_unknown') }) }} 110 - </p> 111 187 <div class="connection-test-benchmark-list"> 112 188 <article 113 189 v-for="item in visibleBenchmarks" 114 190 :key="item.id" 115 191 :class="['connection-test-benchmark', { 'is-ok': item.ok, 'is-failed': !item.ok, 'is-loading': item.running }]" 116 192 > 117 - <div class="connection-test-benchmark-main"> 118 - <p class="connection-test-benchmark-title">{{ t('setup_llm_test_benchmark_' + item.id) }}</p> 119 - <p v-if="item.running" class="connection-test-benchmark-detail">{{ t("setup_llm_test_running") }}</p> 120 - <p v-else-if="item.ok && item.detail" class="connection-test-benchmark-detail">{{ item.detail }}</p> 121 - <p v-else-if="item.error" class="connection-test-benchmark-error">{{ item.error }}</p> 122 - </div> 123 - <div class="connection-test-benchmark-side"> 124 - <div class="connection-test-benchmark-status-row"> 125 - <span v-if="item.running" class="connection-test-benchmark-spinner" aria-hidden="true"></span> 126 - <strong v-else class="connection-test-benchmark-status"> 127 - {{ item.ok ? t("setup_llm_test_status_ok") : t("setup_llm_test_status_failed") }} 128 - </strong> 193 + <div class="connection-test-benchmark-summary"> 194 + <div class="connection-test-benchmark-main"> 195 + <p class="connection-test-benchmark-title">{{ t('setup_llm_test_benchmark_' + item.id) }}</p> 196 + <p v-if="item.running" class="connection-test-benchmark-detail">{{ t("setup_llm_test_running") }}</p> 197 + <div 198 + v-else-if="(item.ok && item.detail) || item.error" 199 + role="button" 200 + tabindex="0" 201 + :aria-expanded="isRawExpanded(item) ? 'true' : 'false'" 202 + :class="['connection-test-benchmark-toggle', benchmarkDetailToggleClass(item)]" 203 + @click="toggleRawResponse(item)" 204 + @keydown.enter.prevent="toggleRawResponse(item)" 205 + @keydown.space.prevent="toggleRawResponse(item)" 206 + > 207 + <span class="connection-test-benchmark-toggle-text">{{ benchmarkSummaryText(item) }}</span> 208 + <QIconArrowRight :class="['connection-test-benchmark-toggle-icon', { 'is-open': isRawExpanded(item) }]" /> 209 + </div> 210 + </div> 211 + <div class="connection-test-benchmark-side"> 212 + <div class="connection-test-benchmark-status-row"> 213 + <span v-if="item.running" class="connection-test-benchmark-spinner" aria-hidden="true"></span> 214 + <strong v-else :class="['connection-test-benchmark-status', benchmarkStatusClass(item)]"> 215 + {{ item.ok ? t("setup_llm_test_status_ok") : t("setup_llm_test_status_failed") }} 216 + </strong> 217 + </div> 218 + <span v-if="!item.running" class="connection-test-benchmark-time">{{ formatBenchmarkSeconds(item.duration_ms) }}</span> 129 219 </div> 130 - <span v-if="!item.running" class="connection-test-benchmark-time">{{ formatBenchmarkSeconds(item.duration_ms) }}</span> 131 220 </div> 221 + <pre v-if="!item.running && isRawExpanded(item)" class="connection-test-benchmark-raw-output">{{ rawResponseText(item) }}</pre> 132 222 </article> 133 223 </div> 134 224 </div> 135 225 136 226 <div class="connection-test-actions"> 137 - <QButton class="outlined" @click="close">{{ t("action_close") }}</QButton> 138 227 <QButton class="primary" :loading="loading" @click="retry"> 139 228 {{ loading ? t("setup_llm_test_running") : t("setup_llm_test_retry") }} 140 229 </QButton> 230 + <QButton class="outlined" @click="close">{{ t("action_close") }}</QButton> 141 231 </div> 142 232 </section> 143 233 </QDialog>
+5
web/console/src/core/setup-contract.js
··· 164 164 return normalizeSetupProviderChoice(choice, { allowEmpty: true }) === SETUP_PROVIDER_OPENAI_COMPATIBLE; 165 165 } 166 166 167 + function setupProviderRequiresAPIKey(choice) { 168 + return normalizeSetupProviderChoice(choice, { allowEmpty: true }) !== SETUP_PROVIDER_CLOUDFLARE; 169 + } 170 + 167 171 function findOpenAICompatibleAPIBaseOption(endpoint) { 168 172 const normalized = normalizeAPIBase(endpoint); 169 173 if (!normalized) { ··· 221 225 normalizeSetupProviderChoice, 222 226 normalizeSetupProviderForSave, 223 227 resolveSetupAPIKeyHelp, 228 + setupProviderRequiresAPIKey, 224 229 setupProviderSupportsModelLookup, 225 230 };
+47 -5
web/console/src/i18n/index.js
··· 107 107 setup_llm_api_base_picker_empty: "No API base matched.", 108 108 setup_llm_api_key_hint: "Click here to open {provider} and get an API key.", 109 109 setup_llm_api_key_hint_plain: "Get an API key from {provider}.", 110 + setup_llm_api_token_hint: "Click here to open {provider} and get an API token.", 111 + setup_llm_api_token_hint_plain: "Get an API token from {provider}.", 110 112 setup_llm_test_button: "Test Connection", 111 113 setup_llm_test_title: "Test Connection", 112 - setup_llm_test_intro: "Run two benchmarks against the current LLM settings: text reply and tool calling.", 114 + setup_llm_test_intro: "Run three benchmarks with the current LLM settings.", 113 115 setup_llm_test_success: "Benchmark target: {provider} · {model}", 114 116 setup_llm_test_result_label: "Benchmarks", 115 117 setup_llm_test_retry: "Run Again", ··· 119 121 setup_llm_test_benchmark_text_reply: "Text Response", 120 122 setup_llm_test_benchmark_json_response: "JSON Response", 121 123 setup_llm_test_benchmark_tool_calling: "Tool Calling", 124 + setup_llm_test_benchmark_text_reply_ok: "LLM returned text correctly", 125 + setup_llm_test_benchmark_text_reply_failed: "LLM did not return text correctly", 126 + setup_llm_test_benchmark_json_response_ok: "LLM handled JSON correctly", 127 + setup_llm_test_benchmark_json_response_failed: "LLM did not handle JSON correctly", 128 + setup_llm_test_benchmark_tool_calling_ok: "LLM can call tools", 129 + setup_llm_test_benchmark_tool_calling_failed: "LLM cannot call tools", 130 + setup_llm_test_view_raw: "RAW", 131 + setup_llm_test_raw_title: "{benchmark} · RAW Response", 132 + setup_llm_test_raw_empty: "No raw response was captured for this benchmark.", 122 133 repair_title: "Repair Files", 123 134 repair_intro: "These files exist, but their current contents cannot be trusted. Fix the source directly or rewrite them through setup.", 124 135 repair_action_edit_source: "Edit Source", ··· 375 386 settings_agent_api_key_placeholder: "Your API Key", 376 387 settings_agent_cloudflare_account_label: "Cloudflare Account ID", 377 388 settings_agent_cloudflare_account_placeholder: "your-account-id", 389 + settings_agent_cloudflare_api_token_label: "Cloudflare API Token", 390 + settings_agent_cloudflare_api_token_placeholder: "Your Cloudflare API Token", 391 + settings_env_managed_body: "This setting is managed by the environment variable above. Change that environment variable.", 378 392 settings_llm_reasoning_label: "Reasoning Effort", 379 393 settings_llm_reasoning_placeholder: "Select reasoning effort", 380 394 settings_llm_reasoning_none: "Default", ··· 590 604 setup_llm_api_base_picker_empty: "没有匹配的 API Base。", 591 605 setup_llm_api_key_hint: "点击这里前往 {provider} 获取 API key。", 592 606 setup_llm_api_key_hint_plain: "从 {provider} 获取 API key。", 607 + setup_llm_api_token_hint: "点击这里前往 {provider} 获取 API token。", 608 + setup_llm_api_token_hint_plain: "从 {provider} 获取 API token。", 593 609 setup_llm_test_button: "测试连接", 594 610 setup_llm_test_title: "测试连接", 595 - setup_llm_test_intro: "用当前 LLM 设置运行三项基准测试:文本响应、JSON 响应和 tool calling。", 611 + setup_llm_test_intro: "用当前 LLM 设置运行三项基准测试。", 596 612 setup_llm_test_success: "测试目标:{provider} · {model}", 597 613 setup_llm_test_result_label: "基准测试", 598 614 setup_llm_test_retry: "重新运行", ··· 601 617 setup_llm_test_status_failed: "失败", 602 618 setup_llm_test_benchmark_text_reply: "文本响应", 603 619 setup_llm_test_benchmark_json_response: "JSON 响应", 604 - setup_llm_test_benchmark_tool_calling: "Tool Calling", 620 + setup_llm_test_benchmark_tool_calling: "工具调用", 621 + setup_llm_test_benchmark_text_reply_ok: "LLM 正确返回了文本", 622 + setup_llm_test_benchmark_text_reply_failed: "LLM 未正确返回文本", 623 + setup_llm_test_benchmark_json_response_ok: "LLM 正确处理了 JSON", 624 + setup_llm_test_benchmark_json_response_failed: "LLM 未正确处理 JSON", 625 + setup_llm_test_benchmark_tool_calling_ok: "LLM 能进行工具调用", 626 + setup_llm_test_benchmark_tool_calling_failed: "LLM 不能进行工具调用", 627 + setup_llm_test_view_raw: "RAW", 628 + setup_llm_test_raw_title: "{benchmark} · RAW Response", 629 + setup_llm_test_raw_empty: "这个 benchmark 没有捕获到 raw response。", 605 630 repair_title: "修复文件", 606 631 repair_intro: "这些文件已存在,但当前内容已经不可信。你可以直接修源文件,或通过 setup 重新写回。", 607 632 repair_action_edit_source: "编辑源文件", ··· 858 883 settings_agent_api_key_placeholder: "Your API Key", 859 884 settings_agent_cloudflare_account_label: "Cloudflare Account ID", 860 885 settings_agent_cloudflare_account_placeholder: "你的 account_id", 886 + settings_agent_cloudflare_api_token_label: "Cloudflare API Token", 887 + settings_agent_cloudflare_api_token_placeholder: "你的 Cloudflare API Token", 888 + settings_env_managed_body: "环境变量托管了这条设置,请修改该环境变量。", 861 889 settings_llm_reasoning_label: "Reasoning Effort", 862 890 settings_llm_reasoning_placeholder: "选择 reasoning 强度", 863 891 settings_llm_reasoning_none: "默认", ··· 1077 1105 setup_llm_api_base_picker_empty: "一致する API Base がありません。", 1078 1106 setup_llm_api_key_hint: "{provider} を開いて API key を取得する", 1079 1107 setup_llm_api_key_hint_plain: "{provider} から API key を取得する", 1108 + setup_llm_api_token_hint: "{provider} を開いて API token を取得する", 1109 + setup_llm_api_token_hint_plain: "{provider} から API token を取得する", 1080 1110 setup_llm_test_button: "接続テスト", 1081 1111 setup_llm_test_title: "接続テスト", 1082 - setup_llm_test_intro: "現在の LLM 設定に対して、テキスト応答、JSON 応答、tool calling の 3 項目を検証します。", 1112 + setup_llm_test_intro: "現在の LLM 設定で 3 項目のベンチマークを実行します。", 1083 1113 setup_llm_test_success: "テスト対象:{provider} · {model}", 1084 1114 setup_llm_test_result_label: "ベンチマーク", 1085 1115 setup_llm_test_retry: "再実行", ··· 1088 1118 setup_llm_test_status_failed: "失敗", 1089 1119 setup_llm_test_benchmark_text_reply: "テキスト応答", 1090 1120 setup_llm_test_benchmark_json_response: "JSON 応答", 1091 - setup_llm_test_benchmark_tool_calling: "Tool Calling", 1121 + setup_llm_test_benchmark_tool_calling: "ツール呼び出し", 1122 + setup_llm_test_benchmark_text_reply_ok: "LLM はテキストを正しく返しました", 1123 + setup_llm_test_benchmark_text_reply_failed: "LLM はテキストを正しく返せませんでした", 1124 + setup_llm_test_benchmark_json_response_ok: "LLM は JSON を正しく処理しました", 1125 + setup_llm_test_benchmark_json_response_failed: "LLM は JSON を正しく処理できませんでした", 1126 + setup_llm_test_benchmark_tool_calling_ok: "LLM はツール呼び出しができます", 1127 + setup_llm_test_benchmark_tool_calling_failed: "LLM はツール呼び出しができません", 1128 + setup_llm_test_view_raw: "RAW", 1129 + setup_llm_test_raw_title: "{benchmark} · RAW Response", 1130 + setup_llm_test_raw_empty: "この benchmark では raw response を取得できませんでした。", 1092 1131 repair_title: "ファイル修復", 1093 1132 repair_intro: "これらのファイルは存在しますが、現在の内容は信頼できません。ソースを直接直すか、setup から書き直してください。", 1094 1133 repair_action_edit_source: "ソースを編集", ··· 1335 1374 settings_agent_api_key_placeholder: "Your API Key", 1336 1375 settings_agent_cloudflare_account_label: "Cloudflare Account ID", 1337 1376 settings_agent_cloudflare_account_placeholder: "account_id を入力", 1377 + settings_agent_cloudflare_api_token_label: "Cloudflare API Token", 1378 + settings_agent_cloudflare_api_token_placeholder: "Cloudflare API Token を入力", 1379 + settings_env_managed_body: "この設定は上の環境変数で管理されています。該当の環境変数を編集してください。", 1338 1380 settings_llm_reasoning_label: "Reasoning Effort", 1339 1381 settings_llm_reasoning_placeholder: "reasoning 強度を選択", 1340 1382 settings_llm_reasoning_none: "デフォルト",
+33
web/console/src/views/SettingsView.css
··· 142 142 text-transform: uppercase; 143 143 } 144 144 145 + .settings-env-managed { 146 + display: grid; 147 + gap: 6px; 148 + padding: 10px 12px; 149 + border: 1px solid color-mix(in srgb, var(--line) 72%, transparent); 150 + border-radius: 2px; 151 + background: color-mix(in srgb, var(--q-bg-paper) 95%, transparent); 152 + box-shadow: inset 0 1px 0 color-mix(in srgb, var(--q-c-white) 75%, transparent); 153 + } 154 + 155 + .settings-env-managed-env { 156 + display: block; 157 + max-width: 100%; 158 + padding: 0; 159 + border: 0; 160 + background: none; 161 + color: var(--text-1); 162 + font-family: var(--font-mono); 163 + font-size: 11px; 164 + font-weight: 500; 165 + letter-spacing: 0.01em; 166 + line-height: 1.3; 167 + overflow-wrap: anywhere; 168 + } 169 + 170 + .settings-env-managed-body { 171 + margin: 0; 172 + font-size: 12px; 173 + line-height: 1.5; 174 + color: var(--text-2); 175 + max-width: 56ch; 176 + } 177 + 145 178 .settings-field-hint { 146 179 margin: 0; 147 180 display: flex;
+259 -54
web/console/src/views/SettingsView.js
··· 23 23 resolveSetupAPIKeyHelp, 24 24 SETUP_PROVIDER_CLOUDFLARE, 25 25 SETUP_PROVIDER_OPTIONS, 26 + setupProviderRequiresAPIKey, 26 27 setupProviderSupportsModelLookup, 27 28 } from "../core/setup-contract"; 28 29 ··· 69 70 endpoint: String(state.llm.endpoint || "").trim(), 70 71 model: String(state.llm.model || "").trim(), 71 72 api_key: String(state.llm.api_key || "").trim(), 73 + cloudflare_api_token: String(state.llm.cloudflare_api_token || "").trim(), 72 74 cloudflare_account_id: String(state.llm.cloudflare_account_id || "").trim(), 73 75 reasoning_effort: String(state.llm.reasoning_effort || "").trim(), 74 76 tools_emulation_mode: String(state.llm.tools_emulation_mode || "").trim(), ··· 117 119 const agentOk = ref(""); 118 120 const llmConfigPath = ref(""); 119 121 const loadedSnapshot = ref(""); 122 + const llmEnvManaged = ref({}); 120 123 const consoleLoading = ref(false); 121 124 const consoleSaving = ref(false); 122 125 const consoleErr = ref(""); ··· 146 149 endpoint: "", 147 150 model: "", 148 151 api_key: "", 152 + cloudflare_api_token: "", 149 153 cloudflare_account_id: "", 150 154 reasoning_effort: "", 151 155 tools_emulation_mode: "", ··· 176 180 () => providerItems.value.find((item) => item.value === state.llm.provider) || null 177 181 ); 178 182 const showCloudflareAccountField = computed( 179 - () => String(state.llm.provider || "").trim() === SETUP_PROVIDER_CLOUDFLARE 183 + () => normalizeSetupProviderChoice(llmFieldValue("provider")) === SETUP_PROVIDER_CLOUDFLARE 180 184 ); 181 - const showOpenAICompatibleHelpers = computed(() => setupProviderSupportsModelLookup(state.llm.provider)); 185 + const credentialFieldName = computed(() => (showCloudflareAccountField.value ? "cloudflare_api_token" : "api_key")); 186 + const credentialLabelKey = computed(() => 187 + showCloudflareAccountField.value ? "settings_agent_cloudflare_api_token_label" : "settings_agent_api_key_label" 188 + ); 189 + const credentialPlaceholderKey = computed(() => 190 + showCloudflareAccountField.value ? "settings_agent_cloudflare_api_token_placeholder" : "settings_agent_api_key_placeholder" 191 + ); 192 + const credentialHintKey = computed(() => 193 + showCloudflareAccountField.value ? "setup_llm_api_token_hint" : "setup_llm_api_key_hint" 194 + ); 195 + const credentialHintPlainKey = computed(() => 196 + showCloudflareAccountField.value ? "setup_llm_api_token_hint_plain" : "setup_llm_api_key_hint_plain" 197 + ); 198 + const showOpenAICompatibleHelpers = computed(() => setupProviderSupportsModelLookup(llmFieldValue("provider"))); 182 199 const modelLookupDisabled = computed( 183 200 () => 184 201 agentLoading.value || 185 202 agentSaving.value || 186 203 !showOpenAICompatibleHelpers.value || 187 - String(state.llm.api_key || "").trim() === "" 204 + !hasLLMFieldValue("api_key") 188 205 ); 189 206 const apiBasePickerItems = computed(() => 190 207 OPENAI_COMPATIBLE_API_BASE_OPTIONS.map((item) => ({ ··· 194 211 note: "", 195 212 })) 196 213 ); 197 - const apiKeyHelp = computed(() => { 198 - if (String(state.llm.provider || "").trim() === "") { 214 + const credentialHelp = computed(() => { 215 + const provider = llmFieldValue("provider"); 216 + if (provider === "" || isLLMFieldEnvManaged(credentialFieldName.value)) { 199 217 return null; 200 218 } 201 - return resolveSetupAPIKeyHelp(state.llm.provider, state.llm.endpoint); 219 + return resolveSetupAPIKeyHelp(provider, llmFieldValue("endpoint")); 202 220 }); 203 - const apiKeyHelpParts = computed(() => { 204 - if (!apiKeyHelp.value) { 221 + const credentialHelpParts = computed(() => { 222 + if (!credentialHelp.value) { 205 223 return null; 206 224 } 207 225 const marker = "__PROVIDER__"; 208 - const template = String(t("setup_llm_api_key_hint", { provider: marker }) || ""); 226 + const template = String(t(credentialHintKey.value, { provider: marker }) || ""); 209 227 const index = template.indexOf(marker); 210 228 if (index === -1) { 211 229 return { ··· 329 347 testConnectionLoading.value || 330 348 agentLoading.value || 331 349 agentSaving.value || 332 - String(state.llm.provider || "").trim() === "" || 333 - String(state.llm.model || "").trim() === "" || 334 - String(state.llm.api_key || "").trim() === "" || 335 - (showCloudflareAccountField.value && String(state.llm.cloudflare_account_id || "").trim() === "") 350 + !hasLLMFieldValue("provider") || 351 + !hasLLMFieldValue("model") || 352 + (setupProviderRequiresAPIKey(llmFieldValue("provider")) && !hasLLMFieldValue(credentialFieldName.value)) || 353 + (showCloudflareAccountField.value && !hasLLMFieldValue("cloudflare_api_token")) || 354 + (showCloudflareAccountField.value && !hasLLMFieldValue("cloudflare_account_id")) 336 355 ); 337 356 const agentDirty = computed(() => buildAgentSnapshot(state) !== loadedSnapshot.value); 338 357 const agentSaveDisabled = computed( 339 358 () => 340 359 agentLoading.value || 341 360 agentSaving.value || 342 - !String(state.llm.provider || "").trim() || 361 + !hasLLMFieldValue("provider") || 343 362 !agentDirty.value || 344 - (showCloudflareAccountField.value && String(state.llm.cloudflare_account_id || "").trim() === "") 363 + (showCloudflareAccountField.value && !hasLLMFieldValue("cloudflare_api_token")) || 364 + (showCloudflareAccountField.value && !hasLLMFieldValue("cloudflare_account_id")) 345 365 ); 346 366 const consoleDirty = computed(() => buildConsoleSnapshot(state) !== loadedConsoleSnapshot.value); 347 367 const consoleSaveDisabled = computed(() => consoleLoading.value || consoleSaving.value || !consoleDirty.value); 348 368 349 369 function applyPayload(data) { 350 370 const llm = data?.llm && typeof data.llm === "object" ? data.llm : {}; 371 + const envManagedPayload = data?.env_managed && typeof data.env_managed === "object" ? data.env_managed : {}; 372 + const llmEnvManagedPayload = 373 + envManagedPayload?.llm && typeof envManagedPayload.llm === "object" ? envManagedPayload.llm : {}; 351 374 const multimodal = data?.multimodal && typeof data.multimodal === "object" ? data.multimodal : {}; 352 375 const tools = data?.tools && typeof data.tools === "object" ? data.tools : {}; 353 376 const imageSources = Array.isArray(multimodal.image_sources) ? multimodal.image_sources : []; 354 377 355 378 state.llm.provider = normalizeSetupProviderChoice(llm.provider); 356 - state.llm.endpoint = 357 - typeof llm.endpoint === "string" && llm.endpoint.trim() !== "" 358 - ? llm.endpoint 359 - : defaultEndpointForSetupProvider(state.llm.provider); 379 + state.llm.endpoint = typeof llm.endpoint === "string" ? llm.endpoint : ""; 360 380 state.llm.model = typeof llm.model === "string" ? llm.model : ""; 361 381 state.llm.api_key = typeof llm.api_key === "string" ? llm.api_key : ""; 382 + state.llm.cloudflare_api_token = 383 + typeof llm.cloudflare_api_token === "string" ? llm.cloudflare_api_token : ""; 362 384 state.llm.cloudflare_account_id = typeof llm.cloudflare_account_id === "string" ? llm.cloudflare_account_id : ""; 363 385 state.llm.reasoning_effort = typeof llm.reasoning_effort === "string" ? llm.reasoning_effort : ""; 364 386 state.llm.tools_emulation_mode = 365 387 typeof llm.tools_emulation_mode === "string" ? llm.tools_emulation_mode : "off"; 366 - 367 388 for (const item of MULTIMODAL_SOURCES) { 368 389 state.multimodal[item.id] = imageSources.includes(item.id); 369 390 } ··· 374 395 state.tools.url_fetch = !!tools.url_fetch_enabled; 375 396 state.tools.web_search = !!tools.web_search_enabled; 376 397 state.tools.bash = !!tools.bash_enabled; 398 + llmEnvManaged.value = llmEnvManagedPayload; 377 399 378 400 loadedSnapshot.value = buildAgentSnapshot(state); 379 401 } 380 402 403 + function llmFieldEnvName(field) { 404 + const entry = llmEnvManaged.value && typeof llmEnvManaged.value === "object" ? llmEnvManaged.value[field] : null; 405 + if (!entry || typeof entry !== "object") { 406 + return ""; 407 + } 408 + return typeof entry.env_name === "string" ? entry.env_name : ""; 409 + } 410 + 411 + function llmFieldEnvValue(field) { 412 + const entry = llmEnvManaged.value && typeof llmEnvManaged.value === "object" ? llmEnvManaged.value[field] : null; 413 + if (!entry || typeof entry !== "object") { 414 + return ""; 415 + } 416 + return typeof entry.value === "string" ? entry.value.trim() : ""; 417 + } 418 + 419 + function isLLMFieldEnvManaged(field) { 420 + return llmFieldEnvName(field) !== ""; 421 + } 422 + 423 + function llmFieldValue(field) { 424 + const key = String(field || "").trim(); 425 + if (!key) { 426 + return ""; 427 + } 428 + if (isLLMFieldEnvManaged(key)) { 429 + return llmFieldEnvValue(key); 430 + } 431 + const value = state.llm && typeof state.llm === "object" ? state.llm[key] : ""; 432 + return typeof value === "string" ? value.trim() : ""; 433 + } 434 + 435 + function hasLLMFieldValue(field) { 436 + return llmFieldValue(field) !== "" || isLLMFieldEnvManaged(field); 437 + } 438 + 439 + function llmFieldManagedDisplayValue(field) { 440 + const envValue = llmFieldEnvValue(field); 441 + if (envValue !== "") { 442 + return envValue; 443 + } 444 + if (["api_key", "cloudflare_api_token"].includes(String(field || "").trim())) { 445 + return ""; 446 + } 447 + return isLLMFieldEnvManaged(field) ? llmFieldValue(field) : ""; 448 + } 449 + 450 + function llmFieldManagedHeadline(field) { 451 + const envName = llmFieldEnvName(field); 452 + if (envName === "") { 453 + return ""; 454 + } 455 + const value = llmFieldManagedDisplayValue(field); 456 + return value === "" ? envName : `${envName}=${value}`; 457 + } 458 + 381 459 async function loadAgentSettings() { 382 460 agentLoading.value = true; 383 461 agentErr.value = ""; ··· 438 516 } 439 517 440 518 function buildLLMSettingsPayload() { 441 - return { 442 - provider: normalizeSetupProviderForSave(state.llm.provider, state.llm.endpoint), 443 - endpoint: String(state.llm.endpoint || "").trim(), 444 - model: String(state.llm.model || "").trim(), 445 - api_key: String(state.llm.api_key || "").trim(), 446 - cloudflare_account_id: String(state.llm.cloudflare_account_id || "").trim(), 447 - reasoning_effort: String(state.llm.reasoning_effort || "").trim(), 448 - tools_emulation_mode: String(state.llm.tools_emulation_mode || "").trim(), 449 - }; 519 + const payload = {}; 520 + const provider = normalizeSetupProviderChoice(llmFieldValue("provider"), { allowEmpty: true }); 521 + const useCloudflareCredentials = normalizeSetupProviderChoice(state.llm.provider) === SETUP_PROVIDER_CLOUDFLARE; 522 + if (!isLLMFieldEnvManaged("provider")) { 523 + payload.provider = normalizeSetupProviderForSave(state.llm.provider, state.llm.endpoint); 524 + } 525 + if (!isLLMFieldEnvManaged("endpoint")) { 526 + payload.endpoint = String(state.llm.endpoint || "").trim(); 527 + } 528 + if (!isLLMFieldEnvManaged("model")) { 529 + payload.model = String(state.llm.model || "").trim(); 530 + } 531 + if (provider === SETUP_PROVIDER_CLOUDFLARE) { 532 + if (!isLLMFieldEnvManaged("cloudflare_api_token")) { 533 + payload.cloudflare_api_token = useCloudflareCredentials ? String(state.llm.cloudflare_api_token || "").trim() : ""; 534 + } 535 + if (!isLLMFieldEnvManaged("cloudflare_account_id")) { 536 + payload.cloudflare_account_id = String(state.llm.cloudflare_account_id || "").trim(); 537 + } 538 + } else if (!isLLMFieldEnvManaged("api_key")) { 539 + payload.api_key = String(state.llm.api_key || "").trim(); 540 + } 541 + if (!isLLMFieldEnvManaged("reasoning_effort")) { 542 + payload.reasoning_effort = String(state.llm.reasoning_effort || "").trim(); 543 + } 544 + if (!isLLMFieldEnvManaged("tools_emulation_mode")) { 545 + payload.tools_emulation_mode = String(state.llm.tools_emulation_mode || "").trim(); 546 + } 547 + return payload; 548 + } 549 + 550 + function buildLLMTestPayload() { 551 + const payload = {}; 552 + const provider = normalizeSetupProviderChoice(llmFieldValue("provider"), { allowEmpty: true }); 553 + if (!isLLMFieldEnvManaged("provider") && provider !== "") { 554 + payload.provider = normalizeSetupProviderForSave(state.llm.provider, state.llm.endpoint); 555 + } 556 + if (!isLLMFieldEnvManaged("endpoint")) { 557 + const endpoint = String(state.llm.endpoint || "").trim(); 558 + if (endpoint !== "") { 559 + payload.endpoint = endpoint; 560 + } 561 + } 562 + if (!isLLMFieldEnvManaged("model")) { 563 + const model = String(state.llm.model || "").trim(); 564 + if (model !== "") { 565 + payload.model = model; 566 + } 567 + } 568 + if (provider === SETUP_PROVIDER_CLOUDFLARE) { 569 + if (!isLLMFieldEnvManaged("cloudflare_api_token")) { 570 + const token = String(state.llm.cloudflare_api_token || "").trim(); 571 + if (token !== "") { 572 + payload.cloudflare_api_token = token; 573 + } 574 + } 575 + if (!isLLMFieldEnvManaged("cloudflare_account_id")) { 576 + const accountID = String(state.llm.cloudflare_account_id || "").trim(); 577 + if (accountID !== "") { 578 + payload.cloudflare_account_id = accountID; 579 + } 580 + } 581 + } else if (!isLLMFieldEnvManaged("api_key")) { 582 + const apiKey = String(state.llm.api_key || "").trim(); 583 + if (apiKey !== "") { 584 + payload.api_key = apiKey; 585 + } 586 + } 587 + if (!isLLMFieldEnvManaged("reasoning_effort")) { 588 + const reasoningEffort = String(state.llm.reasoning_effort || "").trim(); 589 + if (reasoningEffort !== "") { 590 + payload.reasoning_effort = reasoningEffort; 591 + } 592 + } 593 + if (!isLLMFieldEnvManaged("tools_emulation_mode")) { 594 + const toolsEmulationMode = String(state.llm.tools_emulation_mode || "").trim(); 595 + if (toolsEmulationMode !== "") { 596 + payload.tools_emulation_mode = toolsEmulationMode; 597 + } 598 + } 599 + return payload; 450 600 } 451 601 452 602 function buildConsoleSavePayload() { ··· 557 707 const payload = await apiFetch("/settings/agent/models", { 558 708 method: "POST", 559 709 body: { 560 - endpoint: String(state.llm.endpoint || "").trim(), 561 - api_key: String(state.llm.api_key || "").trim(), 710 + endpoint: llmFieldValue("endpoint"), 711 + api_key: llmFieldValue("api_key"), 562 712 }, 563 713 }); 564 714 const items = Array.isArray(payload?.items) ? payload.items : []; ··· 591 741 if (testConnectionLoading.value) { 592 742 return; 593 743 } 594 - const nextPayload = buildLLMSettingsPayload(); 744 + const nextPayload = buildLLMTestPayload(); 595 745 testConnectionLoading.value = true; 596 746 testConnectionError.value = ""; 597 747 testConnectionBenchmarks.value = []; 598 - testConnectionMeta.provider = normalizeSetupProviderForSave(state.llm.provider, state.llm.endpoint); 748 + testConnectionMeta.provider = normalizeSetupProviderForSave(llmFieldValue("provider"), llmFieldValue("endpoint")); 599 749 testConnectionMeta.model = String(nextPayload.model || "").trim(); 600 750 try { 601 751 const payload = await apiFetch("/settings/agent/test", { ··· 613 763 duration_ms: Number(item?.duration_ms || 0), 614 764 detail: String(item?.detail || "").trim(), 615 765 error: String(item?.error || "").trim(), 766 + raw_response: String(item?.raw_response || ""), 616 767 })); 617 768 } catch (e) { 618 769 testConnectionError.value = e.message || t("msg_load_failed"); ··· 732 883 consoleErr, 733 884 consoleOk, 734 885 state, 886 + llmEnvManaged, 735 887 providerItems, 736 888 providerItem, 737 889 showCloudflareAccountField, ··· 742 894 showOpenAICompatibleHelpers, 743 895 modelLookupDisabled, 744 896 apiBasePickerItems, 745 - apiKeyHelp, 746 - apiKeyHelpParts, 897 + credentialLabelKey, 898 + credentialPlaceholderKey, 899 + credentialHelp, 900 + credentialHelpParts, 901 + credentialHintPlainKey, 747 902 multimodalItems, 748 903 toolItems, 749 904 managedRuntimeItems, ··· 766 921 onProviderChange, 767 922 onReasoningEffortChange, 768 923 onToolsEmulationChange, 924 + llmFieldEnvName, 925 + llmFieldEnvValue, 926 + isLLMFieldEnvManaged, 927 + llmFieldValue, 928 + hasLLMFieldValue, 929 + llmFieldManagedDisplayValue, 930 + llmFieldManagedHeadline, 769 931 openExternal, 770 932 openAPIBasePicker, 771 933 applyAPIBaseOption, ··· 883 1045 <div v-if="selectedSection.id === 'agent'" class="settings-form-grid"> 884 1046 <label class="settings-field is-wide"> 885 1047 <span class="settings-field-label">{{ t("settings_agent_provider_label") }}</span> 1048 + <div v-if="isLLMFieldEnvManaged('provider')" class="settings-env-managed"> 1049 + <code class="settings-env-managed-env">{{ llmFieldManagedHeadline("provider") }}</code> 1050 + <p class="settings-env-managed-body">{{ t("settings_env_managed_body") }}</p> 1051 + </div> 886 1052 <QDropdownMenu 1053 + v-else 887 1054 :key="state.llm.provider || 'provider'" 888 1055 :items="providerItems" 889 1056 :initialItem="providerItem" ··· 891 1058 @change="onProviderChange" 892 1059 /> 893 1060 </label> 894 - <label class="settings-field is-wide"> 1061 + <label v-if="!showCloudflareAccountField" class="settings-field is-wide"> 895 1062 <span class="settings-field-label">{{ t("settings_agent_endpoint_label") }}</span> 896 - <div class="settings-field-control"> 1063 + <div v-if="isLLMFieldEnvManaged('endpoint')" class="settings-env-managed"> 1064 + <code class="settings-env-managed-env">{{ llmFieldManagedHeadline("endpoint") }}</code> 1065 + <p class="settings-env-managed-body">{{ t("settings_env_managed_body") }}</p> 1066 + </div> 1067 + <div v-else class="settings-field-control"> 897 1068 <QInput 898 1069 v-model="state.llm.endpoint" 899 1070 :placeholder="t('settings_agent_endpoint_placeholder')" ··· 911 1082 </QButton> 912 1083 </div> 913 1084 </label> 1085 + <label v-if="showCloudflareAccountField" class="settings-field is-wide"> 1086 + <span class="settings-field-label">{{ t("settings_agent_cloudflare_account_label") }}</span> 1087 + <div v-if="isLLMFieldEnvManaged('cloudflare_account_id')" class="settings-env-managed"> 1088 + <code class="settings-env-managed-env">{{ llmFieldManagedHeadline("cloudflare_account_id") }}</code> 1089 + <p class="settings-env-managed-body">{{ t("settings_env_managed_body") }}</p> 1090 + </div> 1091 + <QInput 1092 + v-else 1093 + v-model="state.llm.cloudflare_account_id" 1094 + :placeholder="t('settings_agent_cloudflare_account_placeholder')" 1095 + :disabled="agentLoading || agentSaving" 1096 + /> 1097 + </label> 914 1098 <label class="settings-field is-wide"> 915 - <span class="settings-field-label">{{ t("settings_agent_api_key_label") }}</span> 1099 + <span class="settings-field-label">{{ t(credentialLabelKey) }}</span> 1100 + <div 1101 + v-if="showCloudflareAccountField ? isLLMFieldEnvManaged('cloudflare_api_token') : isLLMFieldEnvManaged('api_key')" 1102 + class="settings-env-managed" 1103 + > 1104 + <code class="settings-env-managed-env">{{ llmFieldManagedHeadline(showCloudflareAccountField ? "cloudflare_api_token" : "api_key") }}</code> 1105 + <p class="settings-env-managed-body">{{ t("settings_env_managed_body") }}</p> 1106 + </div> 1107 + <QInput 1108 + v-else-if="showCloudflareAccountField" 1109 + v-model="state.llm.cloudflare_api_token" 1110 + inputType="password" 1111 + :placeholder="t(credentialPlaceholderKey)" 1112 + :disabled="agentLoading || agentSaving" 1113 + /> 916 1114 <QInput 1115 + v-else 917 1116 v-model="state.llm.api_key" 918 1117 inputType="password" 919 - :placeholder="t('settings_agent_api_key_placeholder')" 1118 + :placeholder="t(credentialPlaceholderKey)" 920 1119 :disabled="agentLoading || agentSaving" 921 1120 /> 922 - <p v-if="apiKeyHelp" class="settings-field-hint"> 923 - <button v-if="apiKeyHelp.url" type="button" class="settings-field-link" @click="openExternal(apiKeyHelp.url)"> 924 - <span>{{ apiKeyHelpParts?.before }}</span> 925 - <span class="settings-field-link-provider">{{ apiKeyHelp.title }}</span> 926 - <span>{{ apiKeyHelpParts?.after }}</span> 1121 + <p v-if="credentialHelp" class="settings-field-hint"> 1122 + <button v-if="credentialHelp.url" type="button" class="settings-field-link" @click="openExternal(credentialHelp.url)"> 1123 + <span>{{ credentialHelpParts?.before }}</span> 1124 + <span class="settings-field-link-provider">{{ credentialHelp.title }}</span> 1125 + <span>{{ credentialHelpParts?.after }}</span> 927 1126 <QIconArrowUpRight class="icon settings-field-link-icon" /> 928 1127 </button> 929 1128 <span v-else class="settings-field-link is-static"> 930 - {{ t("setup_llm_api_key_hint_plain", { provider: apiKeyHelp.title }) }} 1129 + {{ t(credentialHintPlainKey, { provider: credentialHelp.title }) }} 931 1130 </span> 932 1131 </p> 933 1132 </label> 934 1133 <label class="settings-field is-wide"> 935 1134 <span class="settings-field-label">{{ t("settings_agent_model_label") }}</span> 936 - <div class="settings-field-control"> 1135 + <div v-if="isLLMFieldEnvManaged('model')" class="settings-env-managed"> 1136 + <code class="settings-env-managed-env">{{ llmFieldManagedHeadline("model") }}</code> 1137 + <p class="settings-env-managed-body">{{ t("settings_env_managed_body") }}</p> 1138 + </div> 1139 + <div v-else class="settings-field-control"> 937 1140 <QInput 938 1141 v-model="state.llm.model" 939 1142 :placeholder="t('settings_agent_model_placeholder')" ··· 951 1154 </QButton> 952 1155 </div> 953 1156 </label> 954 - <label v-if="showCloudflareAccountField" class="settings-field"> 955 - <span class="settings-field-label">{{ t("settings_agent_cloudflare_account_label") }}</span> 956 - <QInput 957 - v-model="state.llm.cloudflare_account_id" 958 - :placeholder="t('settings_agent_cloudflare_account_placeholder')" 959 - :disabled="agentLoading || agentSaving" 960 - /> 961 - </label> 962 1157 <label class="settings-field"> 963 1158 <span class="settings-field-label">{{ t("settings_llm_reasoning_label") }}</span> 1159 + <div v-if="isLLMFieldEnvManaged('reasoning_effort')" class="settings-env-managed"> 1160 + <code class="settings-env-managed-env">{{ llmFieldManagedHeadline("reasoning_effort") }}</code> 1161 + <p class="settings-env-managed-body">{{ t("settings_env_managed_body") }}</p> 1162 + </div> 964 1163 <QDropdownMenu 1164 + v-else 965 1165 :key="state.llm.reasoning_effort || 'reasoning'" 966 1166 :items="reasoningEffortItems" 967 1167 :initialItem="reasoningEffortItem" ··· 971 1171 </label> 972 1172 <label class="settings-field"> 973 1173 <span class="settings-field-label">{{ t("settings_llm_tools_emulation_label") }}</span> 1174 + <div v-if="isLLMFieldEnvManaged('tools_emulation_mode')" class="settings-env-managed"> 1175 + <code class="settings-env-managed-env">{{ llmFieldManagedHeadline("tools_emulation_mode") }}</code> 1176 + <p class="settings-env-managed-body">{{ t("settings_env_managed_body") }}</p> 1177 + </div> 974 1178 <QDropdownMenu 1179 + v-else 975 1180 :key="state.llm.tools_emulation_mode || 'tools-emulation'" 976 1181 :items="toolsEmulationItems" 977 1182 :initialItem="toolsEmulationItem"
+32
web/console/src/views/SetupView.css
··· 123 123 color: var(--text-2); 124 124 } 125 125 126 + .setup-env-managed { 127 + display: grid; 128 + gap: 6px; 129 + padding: 10px 12px; 130 + border: 1px solid color-mix(in srgb, var(--line) 72%, transparent); 131 + border-radius: 2px; 132 + background: color-mix(in srgb, var(--q-bg-paper) 95%, transparent); 133 + box-shadow: inset 0 1px 0 color-mix(in srgb, var(--q-c-white) 75%, transparent); 134 + } 135 + 136 + .setup-env-managed-env { 137 + display: block; 138 + max-width: 100%; 139 + padding: 0; 140 + border: 0; 141 + background: none; 142 + color: var(--text-1); 143 + font-family: var(--font-mono); 144 + font-size: 11px; 145 + font-weight: 500; 146 + letter-spacing: 0.01em; 147 + line-height: 1.3; 148 + overflow-wrap: anywhere; 149 + } 150 + 151 + .setup-env-managed-body { 152 + margin: 0; 153 + font-size: 12px; 154 + line-height: 1.5; 155 + color: var(--text-2); 156 + } 157 + 126 158 .setup-form .q-input, 127 159 .setup-form .q-textarea, 128 160 .setup-form .q-dropdown-menu {
+243 -51
web/console/src/views/SetupView.js
··· 26 26 SETUP_PROVIDER_CLOUDFLARE, 27 27 SETUP_PROVIDER_OPENAI_COMPATIBLE, 28 28 SETUP_PROVIDER_OPTIONS, 29 + setupProviderRequiresAPIKey, 29 30 setupProviderSupportsModelLookup, 30 31 } from "../core/setup-contract"; 31 32 import { pickRandomPersonaSeed } from "../core/persona-seeds"; ··· 117 118 endpoint: "", 118 119 model: "", 119 120 api_key: "", 121 + cloudflare_api_token: "", 120 122 cloudflare_account_id: "", 121 123 reasoning_effort: "", 122 124 tools_emulation_mode: "", ··· 305 307 306 308 const loadedPayload = ref(buildDefaultPayload()); 307 309 const loadedConfigSource = ref("defaults"); 310 + const llmEnvManaged = ref({}); 308 311 const loadedIdentityRaw = ref(""); 309 312 const loadedSoulRaw = ref(""); 310 313 const llmForm = reactive({ ··· 312 315 endpoint: "", 313 316 model: "", 314 317 api_key: "", 318 + cloudflare_api_token: "", 315 319 cloudflare_account_id: "", 316 320 }); 317 321 const personaForm = reactive(buildEmptyIdentityProfile()); ··· 358 362 () => providerItems.value.find((item) => item.value === llmForm.provider) || null 359 363 ); 360 364 const showCloudflareAccountField = computed( 361 - () => String(llmForm.provider || "").trim() === SETUP_PROVIDER_CLOUDFLARE 365 + () => normalizeSetupProviderChoice(llmFieldValue("provider")) === SETUP_PROVIDER_CLOUDFLARE 366 + ); 367 + const credentialFieldName = computed(() => (showCloudflareAccountField.value ? "cloudflare_api_token" : "api_key")); 368 + const credentialLabelKey = computed(() => 369 + showCloudflareAccountField.value ? "settings_agent_cloudflare_api_token_label" : "settings_agent_api_key_label" 370 + ); 371 + const credentialPlaceholderKey = computed(() => 372 + showCloudflareAccountField.value ? "settings_agent_cloudflare_api_token_placeholder" : "settings_agent_api_key_placeholder" 373 + ); 374 + const credentialHintKey = computed(() => 375 + showCloudflareAccountField.value ? "setup_llm_api_token_hint" : "setup_llm_api_key_hint" 376 + ); 377 + const credentialHintPlainKey = computed(() => 378 + showCloudflareAccountField.value ? "setup_llm_api_token_hint_plain" : "setup_llm_api_key_hint_plain" 362 379 ); 363 - const showOpenAICompatibleHelpers = computed(() => setupProviderSupportsModelLookup(llmForm.provider)); 380 + const showOpenAICompatibleHelpers = computed(() => setupProviderSupportsModelLookup(llmFieldValue("provider"))); 364 381 const modelLookupDisabled = computed( 365 382 () => 366 383 loading.value || 367 384 saving.value || 368 385 !showOpenAICompatibleHelpers.value || 369 - String(llmForm.api_key || "").trim() === "" 386 + !hasLLMFieldValue("api_key") 370 387 ); 371 388 const apiBasePickerItems = computed(() => 372 389 OPENAI_COMPATIBLE_API_BASE_OPTIONS.map((item) => ({ ··· 376 393 note: "", 377 394 })) 378 395 ); 379 - const apiKeyHelp = computed(() => { 380 - if (String(llmForm.provider || "").trim() === "") { 396 + const credentialHelp = computed(() => { 397 + const provider = llmFieldValue("provider"); 398 + if (provider === "" || isLLMFieldEnvManaged(credentialFieldName.value)) { 381 399 return null; 382 400 } 383 - return resolveSetupAPIKeyHelp(llmForm.provider, llmForm.endpoint); 401 + return resolveSetupAPIKeyHelp(provider, llmFieldValue("endpoint")); 384 402 }); 385 - const apiKeyHelpParts = computed(() => { 386 - if (!apiKeyHelp.value) { 403 + const credentialHelpParts = computed(() => { 404 + if (!credentialHelp.value) { 387 405 return null; 388 406 } 389 407 const marker = "__PROVIDER__"; 390 - const template = String(t("setup_llm_api_key_hint", { provider: marker }) || ""); 408 + const template = String(t(credentialHintKey.value, { provider: marker }) || ""); 391 409 const index = template.indexOf(marker); 392 410 if (index === -1) { 393 411 return { ··· 415 433 () => 416 434 loading.value || 417 435 saving.value || 418 - String(llmForm.provider || "").trim() === "" || 419 - String(llmForm.model || "").trim() === "" || 420 - String(llmForm.api_key || "").trim() === "" || 421 - (showCloudflareAccountField.value && String(llmForm.cloudflare_account_id || "").trim() === "") 436 + !hasLLMFieldValue("provider") || 437 + !hasLLMFieldValue("model") || 438 + !hasLLMFieldValue(credentialFieldName.value) || 439 + (showCloudflareAccountField.value && !hasLLMFieldValue("cloudflare_account_id")) 440 + ); 441 + const testConnectionDisabled = computed( 442 + () => 443 + loading.value || 444 + saving.value || 445 + testConnectionLoading.value || 446 + !hasLLMFieldValue("provider") || 447 + !hasLLMFieldValue("model") || 448 + (setupProviderRequiresAPIKey(llmFieldValue("provider")) && !hasLLMFieldValue(credentialFieldName.value)) || 449 + (showCloudflareAccountField.value && !hasLLMFieldValue("cloudflare_api_token")) || 450 + (showCloudflareAccountField.value && !hasLLMFieldValue("cloudflare_account_id")) 422 451 ); 423 - const testConnectionDisabled = computed(() => llmSaveDisabled.value || testConnectionLoading.value); 424 452 const personaSaveDisabled = computed( 425 453 () => 426 454 loading.value || ··· 553 581 554 582 function applyLLMPayload(data) { 555 583 const normalized = normalizePayload(data); 584 + const envManagedPayload = data?.env_managed && typeof data.env_managed === "object" ? data.env_managed : {}; 585 + const llmEnvManagedPayload = 586 + envManagedPayload?.llm && typeof envManagedPayload.llm === "object" ? envManagedPayload.llm : {}; 556 587 loadedPayload.value = normalized; 557 588 loadedConfigSource.value = String(data?.config_source || "defaults").trim() || "defaults"; 589 + llmEnvManaged.value = llmEnvManagedPayload; 558 590 if (loadedConfigSource.value !== "config") { 559 591 llmForm.provider = SETUP_PROVIDER_OPENAI_COMPATIBLE; 560 592 llmForm.endpoint = ""; 561 593 llmForm.model = ""; 562 594 llmForm.api_key = ""; 595 + llmForm.cloudflare_api_token = ""; 563 596 llmForm.cloudflare_account_id = ""; 564 597 return; 565 598 } ··· 567 600 llmForm.endpoint = String(normalized.llm.endpoint || "").trim(); 568 601 llmForm.model = String(normalized.llm.model || "").trim(); 569 602 llmForm.api_key = String(normalized.llm.api_key || "").trim(); 603 + llmForm.cloudflare_api_token = String(normalized.llm.cloudflare_api_token || "").trim(); 570 604 llmForm.cloudflare_account_id = String(normalized.llm.cloudflare_account_id || "").trim(); 571 605 } 572 606 607 + function llmFieldEnvName(field) { 608 + const entry = llmEnvManaged.value && typeof llmEnvManaged.value === "object" ? llmEnvManaged.value[field] : null; 609 + if (!entry || typeof entry !== "object") { 610 + return ""; 611 + } 612 + return typeof entry.env_name === "string" ? entry.env_name : ""; 613 + } 614 + 615 + function llmFieldEnvValue(field) { 616 + const entry = llmEnvManaged.value && typeof llmEnvManaged.value === "object" ? llmEnvManaged.value[field] : null; 617 + if (!entry || typeof entry !== "object") { 618 + return ""; 619 + } 620 + return typeof entry.value === "string" ? entry.value.trim() : ""; 621 + } 622 + 623 + function isLLMFieldEnvManaged(field) { 624 + return llmFieldEnvName(field) !== ""; 625 + } 626 + 627 + function llmFieldValue(field) { 628 + const key = String(field || "").trim(); 629 + if (!key) { 630 + return ""; 631 + } 632 + if (isLLMFieldEnvManaged(key)) { 633 + return llmFieldEnvValue(key); 634 + } 635 + const value = llmForm && typeof llmForm === "object" ? llmForm[key] : ""; 636 + return typeof value === "string" ? value.trim() : ""; 637 + } 638 + 639 + function hasLLMFieldValue(field) { 640 + return llmFieldValue(field) !== "" || isLLMFieldEnvManaged(field); 641 + } 642 + 643 + function llmFieldManagedDisplayValue(field) { 644 + const envValue = llmFieldEnvValue(field); 645 + if (envValue !== "") { 646 + return envValue; 647 + } 648 + if (["api_key", "cloudflare_api_token"].includes(String(field || "").trim())) { 649 + return ""; 650 + } 651 + return isLLMFieldEnvManaged(field) ? llmFieldValue(field) : ""; 652 + } 653 + 654 + function llmFieldManagedHeadline(field) { 655 + const envName = llmFieldEnvName(field); 656 + if (envName === "") { 657 + return ""; 658 + } 659 + const value = llmFieldManagedDisplayValue(field); 660 + return value === "" ? envName : `${envName}=${value}`; 661 + } 662 + 573 663 async function loadLLMForm() { 574 664 loading.value = true; 575 665 err.value = ""; ··· 701 791 saving.value = true; 702 792 err.value = ""; 703 793 try { 794 + const llm = buildLLMSettingsPayload(); 795 + if (Object.keys(llm).length === 0) { 796 + await finishStep(); 797 + return; 798 + } 704 799 const payload = await apiFetch("/settings/agent", { 705 800 method: "PUT", 706 801 body: { 707 - llm: buildLLMSettingsPayload(), 802 + llm, 708 803 multimodal: loadedPayload.value.multimodal, 709 804 tools: loadedPayload.value.tools, 710 805 }, ··· 719 814 } 720 815 721 816 function buildLLMSettingsPayload() { 722 - return { 723 - ...loadedPayload.value.llm, 724 - provider: normalizeSetupProviderForSave(llmForm.provider, llmForm.endpoint), 725 - endpoint: String(llmForm.endpoint || "").trim(), 726 - model: String(llmForm.model || "").trim(), 727 - api_key: String(llmForm.api_key || "").trim(), 728 - cloudflare_account_id: String(llmForm.cloudflare_account_id || "").trim(), 729 - }; 817 + const payload = {}; 818 + const provider = normalizeSetupProviderChoice(llmFieldValue("provider"), { allowEmpty: true }); 819 + const useCloudflareCredentials = normalizeSetupProviderChoice(llmForm.provider) === SETUP_PROVIDER_CLOUDFLARE; 820 + if (!isLLMFieldEnvManaged("provider")) { 821 + payload.provider = normalizeSetupProviderForSave(llmForm.provider, llmForm.endpoint); 822 + } 823 + if (!isLLMFieldEnvManaged("endpoint")) { 824 + payload.endpoint = String(llmForm.endpoint || "").trim(); 825 + } 826 + if (!isLLMFieldEnvManaged("model")) { 827 + payload.model = String(llmForm.model || "").trim(); 828 + } 829 + if (provider === SETUP_PROVIDER_CLOUDFLARE) { 830 + if (!isLLMFieldEnvManaged("cloudflare_api_token")) { 831 + payload.cloudflare_api_token = useCloudflareCredentials ? String(llmForm.cloudflare_api_token || "").trim() : ""; 832 + } 833 + if (!isLLMFieldEnvManaged("cloudflare_account_id")) { 834 + payload.cloudflare_account_id = String(llmForm.cloudflare_account_id || "").trim(); 835 + } 836 + } else if (!isLLMFieldEnvManaged("api_key")) { 837 + payload.api_key = String(llmForm.api_key || "").trim(); 838 + } 839 + return payload; 840 + } 841 + 842 + function buildLLMTestPayload() { 843 + const payload = {}; 844 + const provider = normalizeSetupProviderChoice(llmFieldValue("provider"), { allowEmpty: true }); 845 + if (!isLLMFieldEnvManaged("provider") && provider !== "") { 846 + payload.provider = normalizeSetupProviderForSave(llmForm.provider, llmForm.endpoint); 847 + } 848 + if (!isLLMFieldEnvManaged("endpoint")) { 849 + const endpoint = String(llmForm.endpoint || "").trim(); 850 + if (endpoint !== "") { 851 + payload.endpoint = endpoint; 852 + } 853 + } 854 + if (!isLLMFieldEnvManaged("model")) { 855 + const model = String(llmForm.model || "").trim(); 856 + if (model !== "") { 857 + payload.model = model; 858 + } 859 + } 860 + if (provider === SETUP_PROVIDER_CLOUDFLARE) { 861 + if (!isLLMFieldEnvManaged("cloudflare_api_token")) { 862 + const token = String(llmForm.cloudflare_api_token || "").trim(); 863 + if (token !== "") { 864 + payload.cloudflare_api_token = token; 865 + } 866 + } 867 + if (!isLLMFieldEnvManaged("cloudflare_account_id")) { 868 + const accountID = String(llmForm.cloudflare_account_id || "").trim(); 869 + if (accountID !== "") { 870 + payload.cloudflare_account_id = accountID; 871 + } 872 + } 873 + } else if (!isLLMFieldEnvManaged("api_key")) { 874 + const apiKey = String(llmForm.api_key || "").trim(); 875 + if (apiKey !== "") { 876 + payload.api_key = apiKey; 877 + } 878 + } 879 + return payload; 730 880 } 731 881 732 882 async function savePersona() { ··· 829 979 const payload = await apiFetch("/settings/agent/models", { 830 980 method: "POST", 831 981 body: { 832 - endpoint: String(llmForm.endpoint || "").trim(), 833 - api_key: String(llmForm.api_key || "").trim(), 982 + endpoint: llmFieldValue("endpoint"), 983 + api_key: llmFieldValue("api_key"), 834 984 }, 835 985 }); 836 986 const items = Array.isArray(payload?.items) ? payload.items : []; ··· 863 1013 if (testConnectionLoading.value) { 864 1014 return; 865 1015 } 866 - const nextPayload = buildLLMSettingsPayload(); 1016 + const nextPayload = buildLLMTestPayload(); 867 1017 testConnectionLoading.value = true; 868 1018 testConnectionError.value = ""; 869 1019 testConnectionBenchmarks.value = []; 870 - testConnectionMeta.provider = normalizeSetupProviderForSave(llmForm.provider, llmForm.endpoint); 1020 + testConnectionMeta.provider = normalizeSetupProviderForSave(llmFieldValue("provider"), llmFieldValue("endpoint")); 871 1021 testConnectionMeta.model = String(nextPayload.model || "").trim(); 872 1022 try { 873 1023 const payload = await apiFetch("/settings/agent/test", { ··· 885 1035 duration_ms: Number(item?.duration_ms || 0), 886 1036 detail: String(item?.detail || "").trim(), 887 1037 error: String(item?.error || "").trim(), 1038 + raw_response: String(item?.raw_response || ""), 888 1039 })); 889 1040 } catch (e) { 890 1041 testConnectionError.value = e.message || t("msg_load_failed"); ··· 1018 1169 doneStatusItems, 1019 1170 providerItems, 1020 1171 providerItem, 1172 + llmEnvManaged, 1021 1173 showCloudflareAccountField, 1022 1174 showOpenAICompatibleHelpers, 1023 1175 modelLookupDisabled, 1024 1176 apiBasePickerItems, 1025 - apiKeyHelp, 1026 - apiKeyHelpParts, 1177 + credentialLabelKey, 1178 + credentialPlaceholderKey, 1179 + credentialHelp, 1180 + credentialHelpParts, 1181 + credentialHintPlainKey, 1182 + llmFieldEnvName, 1183 + llmFieldEnvValue, 1184 + isLLMFieldEnvManaged, 1185 + llmFieldValue, 1186 + hasLLMFieldValue, 1187 + llmFieldManagedDisplayValue, 1188 + llmFieldManagedHeadline, 1027 1189 previousStage, 1028 1190 showPrevious, 1029 1191 llmSaveDisabled, ··· 1064 1226 }, 1065 1227 template: ` 1066 1228 <section :class="screenClass"> 1067 - <section class="setup-shell stat-item"> 1229 + <QCard class="setup-shell stat-item" variant="default"> 1068 1230 <header class="setup-head"> 1069 1231 <p class="ui-kicker setup-step">{{ stageKicker }}</p> 1070 1232 <div class="setup-progress" aria-hidden="true"> ··· 1085 1247 > 1086 1248 <label class="setup-field is-wide"> 1087 1249 <span class="setup-field-label">{{ t("settings_agent_provider_label") }}</span> 1250 + <div v-if="isLLMFieldEnvManaged('provider')" class="setup-env-managed"> 1251 + <code class="setup-env-managed-env">{{ llmFieldManagedHeadline("provider") }}</code> 1252 + <p class="setup-env-managed-body">{{ t("settings_env_managed_body") }}</p> 1253 + </div> 1088 1254 <QDropdownMenu 1255 + v-else 1089 1256 :key="llmForm.provider || 'provider'" 1090 1257 :items="providerItems" 1091 1258 :initialItem="providerItem" ··· 1095 1262 /> 1096 1263 </label> 1097 1264 1098 - <label class="setup-field is-wide"> 1265 + <label v-if="!showCloudflareAccountField" class="setup-field is-wide"> 1099 1266 <span class="setup-field-label">{{ t("settings_agent_endpoint_label") }}</span> 1100 - <div class="setup-field-control"> 1267 + <div v-if="isLLMFieldEnvManaged('endpoint')" class="setup-env-managed"> 1268 + <code class="setup-env-managed-env">{{ llmFieldManagedHeadline("endpoint") }}</code> 1269 + <p class="setup-env-managed-body">{{ t("settings_env_managed_body") }}</p> 1270 + </div> 1271 + <div v-else class="setup-field-control"> 1101 1272 <QInput 1102 1273 v-model="llmForm.endpoint" 1103 1274 :placeholder="t('settings_agent_endpoint_placeholder')" ··· 1116 1287 </div> 1117 1288 </label> 1118 1289 1290 + <label v-if="showCloudflareAccountField" class="setup-field is-wide"> 1291 + <span class="setup-field-label">{{ t("settings_agent_cloudflare_account_label") }}</span> 1292 + <div v-if="isLLMFieldEnvManaged('cloudflare_account_id')" class="setup-env-managed"> 1293 + <code class="setup-env-managed-env">{{ llmFieldManagedHeadline("cloudflare_account_id") }}</code> 1294 + <p class="setup-env-managed-body">{{ t("settings_env_managed_body") }}</p> 1295 + </div> 1296 + <QInput 1297 + v-else 1298 + v-model="llmForm.cloudflare_account_id" 1299 + :placeholder="t('settings_agent_cloudflare_account_placeholder')" 1300 + :disabled="loading || saving" 1301 + /> 1302 + </label> 1303 + 1119 1304 <label class="setup-field is-wide"> 1120 - <span class="setup-field-label">{{ t("settings_agent_api_key_label") }}</span> 1305 + <span class="setup-field-label">{{ t(credentialLabelKey) }}</span> 1306 + <div v-if="showCloudflareAccountField ? isLLMFieldEnvManaged('cloudflare_api_token') : isLLMFieldEnvManaged('api_key')" class="setup-env-managed"> 1307 + <code class="setup-env-managed-env">{{ llmFieldManagedHeadline(showCloudflareAccountField ? "cloudflare_api_token" : "api_key") }}</code> 1308 + <p class="setup-env-managed-body">{{ t("settings_env_managed_body") }}</p> 1309 + </div> 1310 + <QInput 1311 + v-else-if="showCloudflareAccountField" 1312 + v-model="llmForm.cloudflare_api_token" 1313 + inputType="password" 1314 + :placeholder="t(credentialPlaceholderKey)" 1315 + :disabled="loading || saving" 1316 + /> 1121 1317 <QInput 1318 + v-else 1122 1319 v-model="llmForm.api_key" 1123 1320 inputType="password" 1124 - :placeholder="t('settings_agent_api_key_placeholder')" 1321 + :placeholder="t(credentialPlaceholderKey)" 1125 1322 :disabled="loading || saving" 1126 1323 /> 1127 - <p v-if="apiKeyHelp" class="setup-field-hint"> 1128 - <button v-if="apiKeyHelp.url" type="button" class="setup-field-link" @click="openExternal(apiKeyHelp.url)"> 1129 - <span>{{ apiKeyHelpParts?.before }}</span> 1130 - <span class="setup-field-link-provider">{{ apiKeyHelp.title }}</span> 1131 - <span>{{ apiKeyHelpParts?.after }}</span> 1324 + <p v-if="credentialHelp" class="setup-field-hint"> 1325 + <button v-if="credentialHelp.url" type="button" class="setup-field-link" @click="openExternal(credentialHelp.url)"> 1326 + <span>{{ credentialHelpParts?.before }}</span> 1327 + <span class="setup-field-link-provider">{{ credentialHelp.title }}</span> 1328 + <span>{{ credentialHelpParts?.after }}</span> 1132 1329 <QIconArrowUpRight class="icon setup-field-link-icon" /> 1133 1330 </button> 1134 1331 <span v-else class="setup-field-link is-static"> 1135 - {{ t("setup_llm_api_key_hint_plain", { provider: apiKeyHelp.title }) }} 1332 + {{ t(credentialHintPlainKey, { provider: credentialHelp.title }) }} 1136 1333 </span> 1137 1334 </p> 1138 1335 </label> 1139 1336 1140 1337 <label class="setup-field is-wide"> 1141 1338 <span class="setup-field-label">{{ t("settings_agent_model_label") }}</span> 1142 - <div class="setup-field-control"> 1339 + <div v-if="isLLMFieldEnvManaged('model')" class="setup-env-managed"> 1340 + <code class="setup-env-managed-env">{{ llmFieldManagedHeadline("model") }}</code> 1341 + <p class="setup-env-managed-body">{{ t("settings_env_managed_body") }}</p> 1342 + </div> 1343 + <div v-else class="setup-field-control"> 1143 1344 <QInput 1144 1345 v-model="llmForm.model" 1145 1346 :placeholder="t('settings_agent_model_placeholder')" ··· 1156 1357 <QIconSearch class="icon" /> 1157 1358 </QButton> 1158 1359 </div> 1159 - </label> 1160 - 1161 - <label v-if="showCloudflareAccountField" class="setup-field is-wide"> 1162 - <span class="setup-field-label">{{ t("settings_agent_cloudflare_account_label") }}</span> 1163 - <QInput 1164 - v-model="llmForm.cloudflare_account_id" 1165 - :placeholder="t('settings_agent_cloudflare_account_placeholder')" 1166 - :disabled="loading || saving" 1167 - /> 1168 1360 </label> 1169 1361 1170 1362 <QFence v-if="err" class="setup-error is-wide" type="danger" icon="QIconCloseCircle" :text="err" /> ··· 1391 1583 :showIntro="false" 1392 1584 @retry="runConnectionTest" 1393 1585 /> 1394 - </section> 1586 + </QCard> 1395 1587 </section> 1396 1588 `, 1397 1589 };