diff --git a/docs/guides/providers.md b/docs/guides/providers.md index d99d8c016..0bc2a79ce 100644 --- a/docs/guides/providers.md +++ b/docs/guides/providers.md @@ -293,6 +293,31 @@ For direct Anthropic API access or custom endpoints that only support Anthropic' `api_base` defaults to `http://localhost:1234/v1`. API key is optional unless your LM Studio server enables authentication.
With explicit `provider`, PicoClaw sends `openai/gpt-oss-20b` unchanged to the LM Studio server. The legacy compatibility form `"model": "lmstudio/openai/gpt-oss-20b"` still resolves to the same upstream model ID when `provider` is omitted. +**OpenRouter reasoning models (suppressing thinking output)** + +Some OpenRouter reasoning models (for example `nvidia/nemotron-3-super-120b-a12b:free`) emit their chain-of-thought into `message.content` rather than a separate reasoning channel. With a strict prompt like `Reply with exactly: PONG`, the visible reply may then start with reasoning preamble such as `We need to ...` before the requested text. + +Pass `extra_body.reasoning.exclude = true` so OpenRouter strips the reasoning server-side: + +```json +{ + "model_name": "openrouter-nemotron-free", + "provider": "openrouter", + "model": "nvidia/nemotron-3-super-120b-a12b:free", + "api_base": "https://openrouter.ai/api/v1", + "api_keys": ["sk-or-v1-..."], + "extra_body": { + "reasoning": { + "exclude": true + } + } +} +``` + +This is shipped as a default `model_list` entry (`openrouter-nemotron-free`); add your `api_keys` to enable it. + +Tradeoff: only set this on entries that should hide reasoning. If you rely on OpenRouter to surface reasoning tokens (for example to render thinking separately), leave `extra_body` unset on those models. PicoClaw does not auto-inject `reasoning.exclude` for OpenRouter, since reasoning visibility is a per-model preference. + **Custom Proxy/API** ```json diff --git a/docs/operations/troubleshooting.md b/docs/operations/troubleshooting.md index 16229f369..98ae9a4a7 100644 --- a/docs/operations/troubleshooting.md +++ b/docs/operations/troubleshooting.md @@ -48,3 +48,30 @@ Example snippet: ``` Get your key at [OpenRouter Keys](https://openrouter.ai/keys). + +## OpenRouter reasoning model leaks thinking into reply + +**Symptom:** With an OpenRouter reasoning model (for example `nvidia/nemotron-3-super-120b-a12b:free`), even a strict prompt like `Reply with exactly: PONG` produces a reply whose visible content begins with reasoning preamble such as `We need to follow the instruction ...` before the requested text. + +**Cause:** Some OpenRouter reasoning models put their chain-of-thought into `message.content` rather than a separate reasoning channel. Without server-side suppression, that reasoning surfaces as the assistant's visible reply. + +**Fix:** Set `extra_body.reasoning.exclude = true` on the model entry so OpenRouter strips reasoning before returning content: + +```json +{ + "model_name": "openrouter-nemotron-free", + "provider": "openrouter", + "model": "nvidia/nemotron-3-super-120b-a12b:free", + "api_base": "https://openrouter.ai/api/v1", + "api_keys": ["sk-or-v1-YOUR_OPENROUTER_KEY"], + "extra_body": { + "reasoning": { + "exclude": true + } + } +} +``` + +PicoClaw ships this as a default `model_list` entry (`openrouter-nemotron-free`); set `api_keys` to use it. + +**Tradeoff:** apply this per-model, not globally. Some users want OpenRouter to return reasoning tokens (for example to render thinking separately), so PicoClaw does not auto-inject `reasoning.exclude` for all OpenRouter models. diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index d455572eb..06c3d1c65 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -2019,6 +2019,53 @@ func TestDefaultConfig_MinimaxExtraBody(t *testing.T) { } } +// TestDefaultConfig_OpenRouterNemotronReasoningExcluded verifies the default +// OpenRouter Nemotron entry ships with reasoning suppression so reasoning +// preamble does not leak into visible assistant content. +func TestDefaultConfig_OpenRouterNemotronReasoningExcluded(t *testing.T) { + cfg := DefaultConfig() + + var nemo *ModelConfig + for i := range cfg.ModelList { + if cfg.ModelList[i].Provider == "openrouter" && + cfg.ModelList[i].Model == "nvidia/nemotron-3-super-120b-a12b:free" { + nemo = cfg.ModelList[i] + break + } + } + if nemo == nil { + t.Fatal("OpenRouter Nemotron model not found in ModelList") + } + if nemo.ExtraBody == nil { + t.Fatal("OpenRouter Nemotron ExtraBody should not be nil") + } + reasoning, ok := nemo.ExtraBody["reasoning"].(map[string]any) + if !ok { + t.Fatalf("ExtraBody[reasoning] = %T, want map[string]any", nemo.ExtraBody["reasoning"]) + } + if got, ok := reasoning["exclude"]; !ok || got != true { + t.Fatalf("ExtraBody[reasoning][exclude] = %v, want true", got) + } + + // Round-trip the model entry through JSON to confirm the nested + // reasoning map survives marshal/unmarshal the same way config files do. + data, err := json.Marshal(nemo) + if err != nil { + t.Fatalf("json.Marshal: %v", err) + } + var rt ModelConfig + if err := json.Unmarshal(data, &rt); err != nil { + t.Fatalf("json.Unmarshal: %v", err) + } + rtReasoning, ok := rt.ExtraBody["reasoning"].(map[string]any) + if !ok { + t.Fatalf("round-tripped ExtraBody[reasoning] = %T, want map[string]any", rt.ExtraBody["reasoning"]) + } + if got, ok := rtReasoning["exclude"]; !ok || got != true { + t.Fatalf("round-tripped ExtraBody[reasoning][exclude] = %v, want true", got) + } +} + func TestFilterSensitiveData(t *testing.T) { // Test with nil security config cfg := &Config{} diff --git a/pkg/config/defaults.go b/pkg/config/defaults.go index be8c32495..3be234d14 100644 --- a/pkg/config/defaults.go +++ b/pkg/config/defaults.go @@ -144,6 +144,20 @@ func DefaultConfig() *Config { Model: "openai/gpt-5.4", APIBase: "https://openrouter.ai/api/v1", }, + // OpenRouter reasoning models that emit thinking into message.content + // require server-side reasoning suppression to avoid leaking the + // chain-of-thought into the visible assistant reply. See + // docs/operations/troubleshooting.md ("OpenRouter reasoning model + // leaks thinking into reply"). + { + ModelName: "openrouter-nemotron-free", + Provider: "openrouter", + Model: "nvidia/nemotron-3-super-120b-a12b:free", + APIBase: "https://openrouter.ai/api/v1", + ExtraBody: map[string]any{ + "reasoning": map[string]any{"exclude": true}, + }, + }, // NVIDIA - https://build.nvidia.com/ {